diff --git a/webrtc-examples/pom.xml b/webrtc-examples/pom.xml
index 4f459d1a..37329111 100644
--- a/webrtc-examples/pom.xml
+++ b/webrtc-examples/pom.xml
@@ -16,6 +16,7 @@
truetrueUTF-8
+ 12.0.23
@@ -48,6 +49,13 @@
+
+
+
+ src/main/resources
+ resources
+
+
@@ -56,5 +64,34 @@
webrtc-java${project.version}
+
+
+ org.eclipse.jetty
+ jetty-server
+ ${jetty.version}
+
+
+ org.eclipse.jetty.websocket
+ jetty-websocket-jetty-server
+ ${jetty.version}
+
+
+ org.eclipse.jetty
+ jetty-util
+ ${jetty.version}
+
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.16.1
+
+
+
+ org.slf4j
+ slf4j-jdk14
+ 2.0.17
+
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java
index bba5bcb8..87225511 100644
--- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/CodecListExample.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 WebRTC Java Contributors
+ * Copyright 2025 Alex Andres
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java
index 88fddc6d..ee95938e 100644
--- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/DesktopVideoExample.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 WebRTC Java Contributors
+ * Copyright 2025 Alex Andres
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
index 27cc7eb2..2c670cdf 100644
--- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/PeerConnectionExample.java
@@ -1,5 +1,5 @@
/*
- * Copyright 2025 WebRTC Java Contributors
+ * Copyright 2025 Alex Andres
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java
index 3280d194..cde86f14 100644
--- a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/WhepExample.java
@@ -1,6 +1,17 @@
/*
- * Example application that demonstrates how to set up a WebRTC peer connection,
- * create an offer, and accept a remote answer.
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
*/
package dev.onvoid.webrtc.examples;
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java
new file mode 100644
index 00000000..ae1f748a
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/util/MediaFrameLogger.java
@@ -0,0 +1,113 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.util;
+
+import dev.onvoid.webrtc.media.audio.AudioTrackSink;
+import dev.onvoid.webrtc.media.video.VideoFrame;
+import dev.onvoid.webrtc.media.video.VideoTrackSink;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Utility class for logging audio and video frame information.
+ *
+ * @author Alex Andres
+ */
+public class MediaFrameLogger {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MediaFrameLogger.class);
+
+
+ /**
+ * Creates a new audio track sink that logs information about received audio data.
+ *
+ * @return An AudioTrackSink that logs audio frame information.
+ */
+ public static AudioTrackSink createAudioLogger() {
+ return new AudioFrameLogger();
+ }
+
+ /**
+ * Creates a new video track sink that logs information about received video frames.
+ *
+ * @return A VideoTrackSink that logs video frame information.
+ */
+ public static VideoTrackSink createVideoLogger() {
+ return new VideoFrameLogger();
+ }
+
+
+
+ /**
+ * A simple implementation of AudioTrackSink that logs information about received audio data.
+ */
+ private static class AudioFrameLogger implements AudioTrackSink {
+
+ private static final long LOG_INTERVAL_MS = 1000; // Log every second
+ private int frameCount = 0;
+ private long lastLogTime = System.currentTimeMillis();
+
+ @Override
+ public void onData(byte[] data, int bitsPerSample, int sampleRate, int channels, int frames) {
+ frameCount++;
+
+ long now = System.currentTimeMillis();
+ if (now - lastLogTime >= LOG_INTERVAL_MS) {
+ LOG.info(String.format("Received %d audio frames in the last %.1f seconds",
+ frameCount, (now - lastLogTime) / 1000.0));
+ LOG.info(String.format("Last audio data: %d bytes, %d bits/sample, %d Hz, %d channels, %d frames",
+ data.length, bitsPerSample, sampleRate, channels, frames));
+
+ frameCount = 0;
+ lastLogTime = now;
+ }
+ }
+ }
+
+
+
+ /**
+ * A simple implementation of VideoTrackSink that logs information about received frames.
+ */
+ private static class VideoFrameLogger implements VideoTrackSink {
+
+ private static final long LOG_INTERVAL_MS = 1000; // Log every second
+ private int frameCount = 0;
+ private long lastLogTime = System.currentTimeMillis();
+
+ @Override
+ public void onVideoFrame(VideoFrame frame) {
+ frameCount++;
+
+ long now = System.currentTimeMillis();
+ if (now - lastLogTime >= LOG_INTERVAL_MS) {
+ LOG.info(String.format("Received %d video frames in the last %.1f seconds",
+ frameCount, (now - lastLogTime) / 1000.0));
+ LOG.info(String.format("Last frame: %dx%d, rotation: %d, timestamp: %dms",
+ frame.buffer.getWidth(), frame.buffer.getHeight(), frame.rotation,
+ frame.timestampNs / 1000000));
+
+ frameCount = 0;
+ lastLogTime = now;
+ }
+
+ // Release the native resources associated with this frame to prevent memory leaks.
+ frame.release();
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java
new file mode 100644
index 00000000..41b9b30e
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/WebClientExample.java
@@ -0,0 +1,246 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.util.logging.LogManager;
+
+import dev.onvoid.webrtc.examples.web.model.LeaveMessage;
+import dev.onvoid.webrtc.media.MediaStreamTrack;
+
+import dev.onvoid.webrtc.examples.web.client.SignalingManager;
+import dev.onvoid.webrtc.examples.web.connection.PeerConnectionManager;
+import dev.onvoid.webrtc.examples.web.media.MediaManager;
+import dev.onvoid.webrtc.examples.web.model.JoinMessage;
+import dev.onvoid.webrtc.examples.util.MediaFrameLogger;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Example demonstrating how to combine WebSocket signaling with WebRTC peer connections.
+ *
+ * This example shows how to:
+ *
+ *
Connect to a signaling server using WebSockets
+ *
Set up media tracks
+ *
Establish a WebRTC peer connection with audio and video
+ *
+ *
+ * @author Alex Andres
+ */
+public class WebClientExample {
+
+ static {
+ setupLogging();
+ }
+
+ private static final Logger LOG = LoggerFactory.getLogger(WebClientExample.class);
+
+ /** Manages communication with the signaling server. */
+ private final SignalingManager signaling;
+
+ /** Handles WebRTC peer connection setup and management. */
+ private final PeerConnectionManager peerConnectionManager;
+
+ /** Manages audio and video tracks and devices. */
+ private final MediaManager mediaManager;
+
+
+ /**
+ * Creates a new WebClientExample that combines WebSocket signaling with WebRTC.
+ *
+ * @param signalingUri The URI of the signaling server.
+ * @param subprotocol The WebSocket subprotocol to use.
+ */
+ public WebClientExample(URI signalingUri, String subprotocol) {
+ signaling = new SignalingManager(signalingUri, subprotocol);
+ signaling.setUserId("java-client");
+
+ peerConnectionManager = new PeerConnectionManager();
+
+ mediaManager = new MediaManager();
+ mediaManager.createTracks(peerConnectionManager, true);
+
+ setupCallbacks();
+
+ LOG.info("WebClientExample created with audio and video tracks");
+ }
+
+ /**
+ * Sets up callbacks between components.
+ */
+ private void setupCallbacks() {
+ // Set up signaling callbacks.
+ signaling.setOnOfferReceived(peerConnectionManager::handleOffer);
+ signaling.setOnAnswerReceived(peerConnectionManager::handleAnswer);
+ signaling.setOnCandidateReceived(peerConnectionManager::handleCandidate);
+ signaling.setOnJoinReceived(this::handleJoin);
+ signaling.setOnLeaveReceived(this::handleLeave);
+
+ // Set up peer connection callbacks.
+ peerConnectionManager.setOnLocalDescriptionCreated(signaling::sendSessionDescription);
+ peerConnectionManager.setOnIceCandidateGenerated(signaling::sendIceCandidate);
+ peerConnectionManager.setOnTrackReceived(this::handleTrackReceived);
+ }
+
+ /**
+ * Handles a received media track.
+ *
+ * @param track The received media track.
+ */
+ private void handleTrackReceived(MediaStreamTrack track) {
+ String kind = track.getKind();
+ LOG.info("Received track: {}", kind);
+
+ if (kind.equals(MediaStreamTrack.AUDIO_TRACK_KIND)) {
+ mediaManager.addAudioSink(MediaFrameLogger.createAudioLogger());
+ }
+ else if (kind.equals(MediaStreamTrack.VIDEO_TRACK_KIND)) {
+ mediaManager.addVideoSink(MediaFrameLogger.createVideoLogger());
+ }
+ }
+
+ /**
+ * Connects to the signaling server.
+ *
+ * @return true if the connection was successful, false otherwise.
+ */
+ public boolean connect() {
+ return signaling.connect();
+ }
+
+ /**
+ * Disconnects from the signaling server and closes the peer connection.
+ */
+ public void disconnect() {
+ signaling.disconnect();
+
+ if (mediaManager != null) {
+ mediaManager.dispose();
+ }
+ if (peerConnectionManager != null) {
+ peerConnectionManager.close();
+ }
+
+ LOG.info("Disconnected from signaling server and closed peer connection");
+ }
+
+ /**
+ * Joins a room on the signaling server.
+ *
+ * This method sends a join request to the signaling server for the specified room.
+ *
+ * @param roomName the name of the room to join on the signaling server
+ */
+ public void joinRoom(String roomName) {
+ if (!signaling.isConnected()) {
+ LOG.warn("Cannot join room: not connected to signaling server");
+ return;
+ }
+
+ signaling.sendJoin(roomName);
+ }
+
+ /**
+ * Initiates a call by creating and sending an offer.
+ */
+ public void call() {
+ if (!signaling.isConnected()) {
+ LOG.warn("Cannot initiate call: not connected to signaling server");
+ return;
+ }
+
+ peerConnectionManager.setInitiator(true);
+ peerConnectionManager.createOffer();
+ }
+
+ /**
+ * Handles an incoming join message from the remote peer.
+ *
+ * @param message The join message.
+ */
+ private void handleJoin(JoinMessage message) {
+ LOG.info("Received join message from peer: {}", message.getFrom());
+
+ // Remote peer wants to join a room, we need to create an offer if we're not the initiator.
+ if (!peerConnectionManager.isInitiator()) {
+ peerConnectionManager.createOffer();
+ }
+ }
+
+ /**
+ * Handles an incoming leave message from the remote peer.
+ *
+ * @param message The leave message.
+ */
+ private void handleLeave(LeaveMessage message) {
+ LOG.info("Peer {} has left the room", message.getFrom());
+
+ // Handle peer leaving logic, such as closing tracks or updating UI.
+ }
+
+ /**
+ * Entry point to demonstrate the WebClientExample.
+ */
+ public static void main(String[] args) {
+ try {
+ WebClientExample client = new WebClientExample(
+ URI.create("wss://localhost:8443/ws"),
+ "ws-signaling");
+
+ if (client.connect()) {
+ LOG.info("Connected to signaling server");
+
+ // Join a room.
+ client.joinRoom("default-room");
+
+ // Initiate the call.
+ client.call();
+
+ // Keep the application running to observe state changes.
+ System.out.println("Press Enter to exit...");
+ System.in.read();
+ }
+ else {
+ LOG.error("Failed to connect to signaling server");
+ }
+
+ // Clean up.
+ client.disconnect();
+ }
+ catch (Exception e) {
+ LOG.error("Error in WebClientExample", e);
+ }
+ }
+
+ /**
+ * Sets up logging for the application.
+ */
+ public static void setupLogging() {
+ try {
+ InputStream configFile = WebClientExample.class.getResourceAsStream("/resources/logging.properties");
+ LogManager.getLogManager().readConfiguration(configFile);
+ }
+ catch (IOException e) {
+ System.err.println("Could not load configuration file:");
+ System.err.println(e.getMessage());
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java
new file mode 100644
index 00000000..2d6fd4ea
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/SignalingManager.java
@@ -0,0 +1,415 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.client;
+
+import java.net.URI;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import dev.onvoid.webrtc.RTCIceCandidate;
+import dev.onvoid.webrtc.RTCSessionDescription;
+
+import dev.onvoid.webrtc.examples.web.model.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages WebSocket signaling for WebRTC connections.
+ *
+ * This class handles the communication between peers during WebRTC connection establishment.
+ * It manages sending and receiving:
+ *
+ *
Session descriptions (offers and answers)
+ *
ICE candidates
+ *
Join messages for room-based signaling
+ *
+ *
+ * The manager connects to a signaling server via WebSocket and translates WebRTC-related
+ * objects to JSON messages that can be transmitted over the signaling channel.
+ *
+ * @author Alex Andres
+ */
+public class SignalingManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(SignalingManager.class);
+
+ /** The WebSocket client used to communicate with the signaling server. */
+ private final WebSocketClient signaling;
+
+ /** JSON mapper for serializing and deserializing signaling messages. */
+ private final ObjectMapper jsonMapper;
+
+ /** Callback that is triggered when an offer message is received from a remote peer. */
+ private Consumer onOfferReceived;
+
+ /** Callback that is triggered when an answer message is received from a remote peer. */
+ private Consumer onAnswerReceived;
+
+ /** Callback that is triggered when an ICE candidate is received from a remote peer. */
+ private Consumer onCandidateReceived;
+
+ /** Callback that is triggered when a join message is received. */
+ private Consumer onJoinReceived;
+
+ /** Callback that is triggered when a leave message is received. */
+ private Consumer onLeaveReceived;
+
+ private String userId = "java-client";
+ private String remotePeerId = "web-client";
+
+
+ /**
+ * Creates a new SignalingManager with the specified URI and subprotocol.
+ *
+ * @param signalingUri The URI of the signaling server.
+ * @param subprotocol The WebSocket subprotocol to use.
+ */
+ public SignalingManager(URI signalingUri, String subprotocol) {
+ this.signaling = new WebSocketClient(signalingUri, subprotocol);
+ this.jsonMapper = new ObjectMapper();
+
+ setupSignaling();
+
+ LOG.info("SignalingManager created");
+ }
+
+ /**
+ * Connects to the signaling server.
+ *
+ * @return true if the connection was successful, false otherwise.
+ */
+ public boolean connect() {
+ try {
+ CountDownLatch connectLatch = new CountDownLatch(1);
+
+ signaling.connect()
+ .thenAccept(ws -> {
+ LOG.info("Connected to signaling server");
+ connectLatch.countDown();
+ })
+ .exceptionally(e -> {
+ LOG.error("Failed to connect to signaling server", e);
+ connectLatch.countDown();
+ return null;
+ });
+
+ // Wait for connection.
+ connectLatch.await(5, TimeUnit.SECONDS);
+
+ return signaling.isConnected();
+ }
+ catch (Exception e) {
+ LOG.error("Error connecting to signaling server", e);
+ return false;
+ }
+ }
+
+ /**
+ * Disconnects from the signaling server.
+ */
+ public void disconnect() {
+ if (isConnected()) {
+ signaling.disconnect(1000, "Client disconnecting");
+
+ LOG.info("Disconnected from signaling server");
+ }
+ }
+
+ /**
+ * Sends a join message to the signaling server.
+ *
+ * @param roomId The ID of the room to join.
+ */
+ public void sendJoin(String roomId) {
+ try {
+ JoinMessage joinMessage = new JoinMessage(userId, roomId, "Java Client");
+
+ // Convert to JSON and send.
+ String joinMessageJson = jsonMapper.writeValueAsString(joinMessage);
+ signaling.sendMessage(joinMessageJson);
+
+ LOG.info("Sent join message for room: {}", roomId);
+ }
+ catch (Exception e) {
+ LOG.error("Error creating JSON join message", e);
+ }
+ }
+
+ /**
+ * Sends a session description (offer or answer) to the remote peer.
+ *
+ * @param sdp The session description to send.
+ */
+ public void sendSessionDescription(RTCSessionDescription sdp) {
+ String type = sdp.sdpType.toString().toLowerCase();
+
+ try {
+ SessionDescriptionMessage message = new SessionDescriptionMessage(
+ type,
+ userId,
+ remotePeerId,
+ sdp.sdp
+ );
+
+ String messageJson = jsonMapper.writeValueAsString(message);
+ signaling.sendMessage(messageJson);
+
+ LOG.info("Sent {} to remote peer", type);
+ }
+ catch (Exception e) {
+ LOG.error("Error creating JSON message", e);
+ }
+ }
+
+ /**
+ * Sends an ICE candidate to the remote peer.
+ *
+ * @param candidate The ICE candidate to send.
+ */
+ public void sendIceCandidate(RTCIceCandidate candidate) {
+ try {
+ IceCandidateMessage message = new IceCandidateMessage(
+ userId,
+ remotePeerId,
+ candidate.sdp,
+ candidate.sdpMid,
+ candidate.sdpMLineIndex
+ );
+
+ String candidateJson = jsonMapper.writeValueAsString(message);
+ signaling.sendMessage(candidateJson);
+
+ LOG.info("Sent ICE candidate to remote peer");
+ }
+ catch (Exception e) {
+ LOG.error("Error creating JSON message", e);
+ }
+ }
+
+ /**
+ * Sets up the WebSocket signaling to handle incoming messages.
+ */
+ private void setupSignaling() {
+ signaling.addMessageListener(message -> {
+ try {
+ // Parse the message to extract the type.
+ String type = jsonMapper.readTree(message).path("type").asText();
+ MessageType messageType = MessageType.fromString(type);
+
+ switch (messageType) {
+ case OFFER -> handleOfferMessage(message);
+ case ANSWER -> handleAnswerMessage(message);
+ case ICE_CANDIDATE -> handleIceCandidateMessage(message);
+ case JOIN -> handleJoinMessage(message);
+ case LEAVE -> handleLeaveMessage(message);
+ case HEARTBEAT_ACK -> LOG.info("Received: {}", type);
+ default -> LOG.info("Received message with unknown type: {}", type);
+ }
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing message type", e);
+ }
+ });
+
+ signaling.addCloseListener(() -> {
+ LOG.info("Signaling connection closed");
+ });
+
+ signaling.addErrorListener(error -> {
+ LOG.error("Signaling error", error);
+ });
+ }
+
+ /**
+ * Sets a callback to be invoked when an offer is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnOfferReceived(Consumer callback) {
+ this.onOfferReceived = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when an answer is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnAnswerReceived(Consumer callback) {
+ this.onAnswerReceived = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when an ICE candidate is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnCandidateReceived(Consumer callback) {
+ this.onCandidateReceived = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when a join message is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnJoinReceived(Consumer callback) {
+ this.onJoinReceived = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when a leave message is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnLeaveReceived(Consumer callback) {
+ this.onLeaveReceived = callback;
+ }
+
+ /**
+ * Sets the user ID for this client.
+ *
+ * @param userId The user ID to set.
+ */
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+
+ /**
+ * Sets the remote peer ID.
+ *
+ * @param remotePeerId The remote peer ID to set.
+ */
+ public void setRemotePeerId(String remotePeerId) {
+ this.remotePeerId = remotePeerId;
+ }
+
+ /**
+ * Gets the user ID for this client.
+ *
+ * @return The user ID.
+ */
+ public String getUserId() {
+ return userId;
+ }
+
+ /**
+ * Gets the remote peer ID.
+ *
+ * @return The remote peer ID.
+ */
+ public String getRemotePeerId() {
+ return remotePeerId;
+ }
+
+ /**
+ * Checks if the signaling connection is established.
+ *
+ * @return true if connected, false otherwise.
+ */
+ public boolean isConnected() {
+ return signaling != null && signaling.isConnected();
+ }
+
+ /**
+ * Handles an incoming offer message.
+ *
+ * @param message The JSON message containing an offer.
+ */
+ private void handleOfferMessage(String message) {
+ if (onOfferReceived != null) {
+ try {
+ SessionDescriptionMessage sdpMessage = jsonMapper.readValue(message, SessionDescriptionMessage.class);
+ onOfferReceived.accept(sdpMessage);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing offer message", e);
+ }
+ }
+ }
+
+ /**
+ * Handles an incoming answer message.
+ *
+ * @param message The JSON message containing an answer.
+ */
+ private void handleAnswerMessage(String message) {
+ if (onAnswerReceived != null) {
+ try {
+ SessionDescriptionMessage sdpMessage = jsonMapper.readValue(message, SessionDescriptionMessage.class);
+ onAnswerReceived.accept(sdpMessage);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing answer message", e);
+ }
+ }
+ }
+
+ /**
+ * Handles an incoming ICE candidate message.
+ *
+ * @param message The JSON message containing an ICE candidate.
+ */
+ private void handleIceCandidateMessage(String message) {
+ if (onCandidateReceived != null) {
+ try {
+ IceCandidateMessage iceMessage = jsonMapper.readValue(message, IceCandidateMessage.class);
+ onCandidateReceived.accept(iceMessage);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing ICE candidate message", e);
+ }
+ }
+ }
+
+ /**
+ * Handles an incoming join message.
+ *
+ * @param message The JSON message containing join information.
+ */
+ private void handleJoinMessage(String message) {
+ if (onJoinReceived != null) {
+ try {
+ JoinMessage joinMessage = jsonMapper.readValue(message, JoinMessage.class);
+ onJoinReceived.accept(joinMessage);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing join message", e);
+ }
+ }
+ }
+
+ /**
+ * Handles an incoming leave message.
+ *
+ * @param message The JSON message indicating a peer has left.
+ */
+ private void handleLeaveMessage(String message) {
+ if (onLeaveReceived != null) {
+ try {
+ LeaveMessage leaveMessage = jsonMapper.readValue(message, LeaveMessage.class);
+ onLeaveReceived.accept(leaveMessage);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing join message", e);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java
new file mode 100644
index 00000000..e2a87628
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/client/WebSocketClient.java
@@ -0,0 +1,453 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.client;
+
+import java.net.URI;
+import java.net.http.HttpClient;
+import java.net.http.WebSocket;
+import java.nio.ByteBuffer;
+import java.time.Duration;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionStage;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import dev.onvoid.webrtc.examples.web.model.SignalingMessage;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A WebSocket client implementation using JDK internal WebSockets (java.net.http.WebSocket).
+ * This client supports secure WebSocket connections (WSS), message sending/receiving,
+ * and automatic heartbeat to keep the connection alive.
+ *
+ * @author Alex Andres
+ */
+public class WebSocketClient {
+
+ private static final Logger LOG = LoggerFactory.getLogger(WebSocketClient.class);
+
+ /** The interval between heartbeat messages in seconds to keep the WebSocket connection alive. */
+ private static final long HEARTBEAT_INTERVAL_SECONDS = 15;
+
+ /** JSON serializer/deserializer for processing WebSocket messages. */
+ private final ObjectMapper jsonMapper = new ObjectMapper();
+
+ private final URI serverUri;
+ private final List subprotocols;
+ private final List> messageListeners;
+ private final List closeListeners;
+ private final List> errorListeners;
+
+ private WebSocket webSocket;
+ private ScheduledExecutorService heartbeatExecutor;
+ private boolean connected = false;
+
+
+ /**
+ * Creates a new WebSocket client.
+ *
+ * @param serverUri The URI of the WebSocket server (e.g., "wss://localhost:8443/ws").
+ * @param subprotocols The WebSocket subprotocols to use (e.g., "ws-signaling").
+ */
+ public WebSocketClient(URI serverUri, List subprotocols) {
+ this.serverUri = serverUri;
+ this.subprotocols = subprotocols;
+ this.messageListeners = new CopyOnWriteArrayList<>();
+ this.closeListeners = new CopyOnWriteArrayList<>();
+ this.errorListeners = new CopyOnWriteArrayList<>();
+ }
+
+ /**
+ * Creates a new WebSocket client with a single subprotocol.
+ *
+ * @param serverUri The URI of the WebSocket server.
+ * @param subprotocol The WebSocket subprotocol to use.
+ */
+ public WebSocketClient(URI serverUri, String subprotocol) {
+ this(serverUri, List.of(subprotocol));
+ }
+
+ /**
+ * Connects to the WebSocket server.
+ *
+ * @return A CompletableFuture that completes when the connection is established.
+ */
+ public CompletableFuture connect() {
+ if (connected) {
+ return CompletableFuture.completedFuture(webSocket);
+ }
+
+ WebSocket.Listener listener = new WebSocketListener();
+
+ // Create an HTTP client that accepts all SSL certificates (useful for self-signed certs in development).
+ HttpClient client = HttpClient.newBuilder()
+ .sslContext(TrustAllCertificates.createSslContext())
+ .connectTimeout(Duration.ofSeconds(10))
+ .build();
+
+ // Build the WebSocket with our listener and subprotocols.
+ WebSocket.Builder wsBuilder = client.newWebSocketBuilder()
+ .connectTimeout(Duration.ofSeconds(10));
+
+ // Add subprotocols if provided.
+ if (subprotocols != null && !subprotocols.isEmpty()) {
+ for (String protocol : subprotocols) {
+ wsBuilder.subprotocols(protocol);
+ }
+ }
+
+ LOG.info("Connecting to WebSocket server: {}", serverUri);
+
+ return wsBuilder.buildAsync(serverUri, listener)
+ .thenApply(ws -> {
+ this.webSocket = ws;
+ this.connected = true;
+
+ // Start heartbeat to keep connection alive.
+ startHeartbeat();
+
+ LOG.info("Connected to WebSocket server: {}", serverUri);
+ return ws;
+ })
+ .exceptionally(ex -> {
+ LOG.error("Failed to connect to WebSocket server: {}", ex.getMessage());
+ notifyErrorListeners(ex);
+ return null;
+ });
+ }
+
+ /**
+ * Sends a text message to the WebSocket server.
+ *
+ * @param message The message to send.
+ *
+ * @return A CompletableFuture that completes when the message is sent.
+ */
+ public CompletableFuture sendMessage(String message) {
+ if (!connected || webSocket == null) {
+ return CompletableFuture.failedFuture(
+ new IllegalStateException("Not connected to WebSocket server"));
+ }
+
+ LOG.debug("Sending message: {}", message);
+
+ return webSocket.sendText(message, true);
+ }
+
+ /**
+ * Closes the WebSocket connection.
+ *
+ * @param statusCode The WebSocket close status code.
+ * @param reason The reason for closing.
+ *
+ * @return A CompletableFuture that completes when the connection is closed.
+ */
+ public CompletableFuture disconnect(int statusCode, String reason) {
+ if (!connected || webSocket == null) {
+ return CompletableFuture.completedFuture(null);
+ }
+
+ stopHeartbeat();
+
+ LOG.info("Disconnecting from WebSocket server: {}", serverUri);
+
+ return webSocket.sendClose(statusCode, reason)
+ .thenApply(ws -> {
+ this.connected = false;
+ this.webSocket = null;
+ return ws;
+ });
+ }
+
+ /**
+ * Adds a listener for incoming text messages.
+ *
+ * @param listener The message listener.
+ */
+ public void addMessageListener(Consumer listener) {
+ messageListeners.add(listener);
+ }
+
+ /**
+ * Removes a message listener.
+ *
+ * @param listener The message listener to remove.
+ */
+ public void removeMessageListener(Consumer listener) {
+ messageListeners.remove(listener);
+ }
+
+ /**
+ * Adds a listener for connection close events.
+ *
+ * @param listener The close listener.
+ */
+ public void addCloseListener(Runnable listener) {
+ closeListeners.add(listener);
+ }
+
+ /**
+ * Removes a close listener.
+ *
+ * @param listener The close listener to remove.
+ */
+ public void removeCloseListener(Runnable listener) {
+ closeListeners.remove(listener);
+ }
+
+ /**
+ * Adds a listener for connection errors.
+ *
+ * @param listener The error listener.
+ */
+ public void addErrorListener(Consumer listener) {
+ errorListeners.add(listener);
+ }
+
+ /**
+ * Removes an error listener.
+ *
+ * @param listener The error listener to remove.
+ */
+ public void removeErrorListener(Consumer listener) {
+ errorListeners.remove(listener);
+ }
+
+ /**
+ * Checks if the client is connected to the WebSocket server.
+ *
+ * @return true if connected, false otherwise.
+ */
+ public boolean isConnected() {
+ return connected;
+ }
+
+ /**
+ * Starts the heartbeat to keep the WebSocket connection alive.
+ */
+ private void startHeartbeat() {
+ // Ensure any existing heartbeat is stopped.
+ stopHeartbeat();
+
+ heartbeatExecutor = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "websocket-heartbeat");
+ t.setDaemon(true);
+ return t;
+ });
+
+ heartbeatExecutor.scheduleAtFixedRate(() -> {
+ if (connected && webSocket != null) {
+ LOG.debug("Sending heartbeat");
+
+ sendHeartbeat();
+ }
+ }, HEARTBEAT_INTERVAL_SECONDS, HEARTBEAT_INTERVAL_SECONDS, TimeUnit.SECONDS);
+ }
+
+ /**
+ * Sends a heartbeat message to the WebSocket server to keep the connection alive.
+ */
+ private void sendHeartbeat() {
+ SignalingMessage heartbeat = new SignalingMessage("heartbeat");
+ String heartbeatJson;
+
+ try {
+ heartbeatJson = jsonMapper.writeValueAsString(heartbeat);
+ }
+ catch (JsonProcessingException e) {
+ LOG.error("Failed to send heartbeat", e);
+ return;
+ }
+
+ sendMessage(heartbeatJson)
+ .exceptionally(ex -> {
+ LOG.error("Failed to send heartbeat: {}", ex.getMessage());
+ return null;
+ });
+ }
+
+ /**
+ * Stops the heartbeat.
+ */
+ private void stopHeartbeat() {
+ if (heartbeatExecutor != null && !heartbeatExecutor.isShutdown()) {
+ heartbeatExecutor.shutdownNow();
+ heartbeatExecutor = null;
+ }
+ }
+
+ /**
+ * Notifies all registered message listeners with the received message.
+ * Any exceptions thrown by listeners are caught and logged to prevent
+ * them from affecting other listeners or the WebSocket connection.
+ *
+ * @param message The message received from the WebSocket server.
+ */
+ private void notifyMessageListeners(String message) {
+ for (Consumer listener : messageListeners) {
+ try {
+ listener.accept(message);
+ }
+ catch (Exception e) {
+ LOG.error("Error in message listener", e);
+ }
+ }
+ }
+
+ /**
+ * Notifies all registered close listeners that the WebSocket connection has closed.
+ * Any exceptions thrown by listeners are caught and logged to prevent
+ * them from affecting other listeners.
+ */
+ private void notifyCloseListeners() {
+ for (Runnable listener : closeListeners) {
+ try {
+ listener.run();
+ }
+ catch (Exception e) {
+ LOG.error("Error in close listener", e);
+ }
+ }
+ }
+
+ /**
+ * Notifies all registered error listeners about a WebSocket error.
+ * Any exceptions thrown by listeners are caught and logged to prevent
+ * them from affecting other listeners.
+ *
+ * @param error The error that occurred during WebSocket communication.
+ */
+ private void notifyErrorListeners(Throwable error) {
+ for (Consumer listener : errorListeners) {
+ try {
+ listener.accept(error);
+ }
+ catch (Exception e) {
+ LOG.error("Error in error listener", e);
+ }
+ }
+ }
+
+
+
+ /**
+ * WebSocket listener implementation.
+ */
+ private class WebSocketListener implements WebSocket.Listener {
+
+ private final StringBuilder messageBuilder = new StringBuilder();
+
+
+ @Override
+ public void onOpen(WebSocket webSocket) {
+ LOG.debug("WebSocket connection opened");
+ WebSocket.Listener.super.onOpen(webSocket);
+ }
+
+ @Override
+ public CompletionStage> onText(WebSocket webSocket, CharSequence data, boolean last) {
+ messageBuilder.append(data);
+
+ if (last) {
+ String message = messageBuilder.toString();
+ messageBuilder.setLength(0);
+
+ LOG.debug("Received message: {}", message);
+ notifyMessageListeners(message);
+ }
+
+ return WebSocket.Listener.super.onText(webSocket, data, last);
+ }
+
+ @Override
+ public CompletionStage> onBinary(WebSocket webSocket, ByteBuffer data, boolean last) {
+ LOG.debug("Received binary data, size: {}", data.remaining());
+ return WebSocket.Listener.super.onBinary(webSocket, data, last);
+ }
+
+ @Override
+ public CompletionStage> onPing(WebSocket webSocket, ByteBuffer message) {
+ LOG.debug("Received ping");
+ return WebSocket.Listener.super.onPing(webSocket, message);
+ }
+
+ @Override
+ public CompletionStage> onPong(WebSocket webSocket, ByteBuffer message) {
+ LOG.debug("Received pong");
+ return WebSocket.Listener.super.onPong(webSocket, message);
+ }
+
+ @Override
+ public CompletionStage> onClose(WebSocket webSocket, int statusCode, String reason) {
+ LOG.info("WebSocket connection closed: {} - {}", statusCode, reason);
+ connected = false;
+ WebSocketClient.this.webSocket = null;
+ stopHeartbeat();
+ notifyCloseListeners();
+ return WebSocket.Listener.super.onClose(webSocket, statusCode, reason);
+ }
+
+ @Override
+ public void onError(WebSocket webSocket, Throwable error) {
+ LOG.error("WebSocket error", error);
+ notifyErrorListeners(error);
+ WebSocket.Listener.super.onError(webSocket, error);
+ }
+ }
+
+
+
+ /**
+ * Utility class to create an SSL context that trusts all certificates.
+ * This is useful for development with self-signed certificates.
+ */
+ private static class TrustAllCertificates {
+
+ public static javax.net.ssl.SSLContext createSslContext() {
+ try {
+ javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLS");
+ sslContext.init(null, new javax.net.ssl.TrustManager[] {
+ new javax.net.ssl.X509TrustManager() {
+ public java.security.cert.X509Certificate[] getAcceptedIssuers() {
+ return new java.security.cert.X509Certificate[0];
+ }
+
+ public void checkClientTrusted(java.security.cert.X509Certificate[] certs, String authType) {
+ }
+
+ public void checkServerTrusted(java.security.cert.X509Certificate[] certs, String authType) {
+ }
+ }
+ }, new java.security.SecureRandom());
+
+ return sslContext;
+ }
+ catch (Exception e) {
+ throw new RuntimeException("Failed to create SSL context", e);
+ }
+ }
+ }
+}
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java
new file mode 100644
index 00000000..fbece89c
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionManager.java
@@ -0,0 +1,429 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.connection;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import dev.onvoid.webrtc.CreateSessionDescriptionObserver;
+import dev.onvoid.webrtc.PeerConnectionFactory;
+import dev.onvoid.webrtc.PeerConnectionObserver;
+import dev.onvoid.webrtc.RTCAnswerOptions;
+import dev.onvoid.webrtc.RTCConfiguration;
+import dev.onvoid.webrtc.RTCDataChannel;
+import dev.onvoid.webrtc.RTCIceCandidate;
+import dev.onvoid.webrtc.RTCIceConnectionState;
+import dev.onvoid.webrtc.RTCIceGatheringState;
+import dev.onvoid.webrtc.RTCIceServer;
+import dev.onvoid.webrtc.RTCOfferOptions;
+import dev.onvoid.webrtc.RTCPeerConnection;
+import dev.onvoid.webrtc.RTCPeerConnectionState;
+import dev.onvoid.webrtc.RTCRtpReceiver;
+import dev.onvoid.webrtc.RTCRtpTransceiver;
+import dev.onvoid.webrtc.RTCSdpType;
+import dev.onvoid.webrtc.RTCSessionDescription;
+import dev.onvoid.webrtc.RTCSignalingState;
+import dev.onvoid.webrtc.SetSessionDescriptionObserver;
+import dev.onvoid.webrtc.examples.web.model.IceCandidateMessage;
+import dev.onvoid.webrtc.examples.web.model.SessionDescriptionMessage;
+import dev.onvoid.webrtc.media.MediaStream;
+import dev.onvoid.webrtc.media.MediaStreamTrack;
+
+import dev.onvoid.webrtc.media.audio.AudioOptions;
+import dev.onvoid.webrtc.media.audio.AudioTrack;
+import dev.onvoid.webrtc.media.audio.AudioTrackSource;
+import dev.onvoid.webrtc.media.audio.CustomAudioSource;
+import dev.onvoid.webrtc.media.video.VideoTrack;
+import dev.onvoid.webrtc.media.video.VideoTrackSource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages WebRTC peer connections, including creation, configuration,
+ * and session description handling. Also handles signaling messages related to
+ * peer connections such as offers, answers, and ICE candidates.
+ *
+ * @author Alex Andres
+ */
+public class PeerConnectionManager implements PeerConnectionSignalingHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(PeerConnectionManager.class);
+
+ private final PeerConnectionFactory factory;
+ private final RTCPeerConnection peerConnection;
+
+ private Consumer onLocalDescriptionCreated;
+ private Consumer onIceCandidateGenerated;
+ private Consumer onTrackReceived;
+
+ private boolean isInitiator = false;
+
+
+ /**
+ * Creates a new PeerConnectionManager.
+ */
+ public PeerConnectionManager() {
+ factory = new PeerConnectionFactory();
+
+ // Create peer connection with default configuration.
+ RTCIceServer iceServer = new RTCIceServer();
+ iceServer.urls.add("stun:stun.l.google.com:19302");
+
+ RTCConfiguration config = new RTCConfiguration();
+ config.iceServers.add(iceServer);
+
+ peerConnection = factory.createPeerConnection(config, new PeerConnectionObserverImpl());
+
+ LOG.info("PeerConnectionManager created");
+ }
+
+ /**
+ * Adds a media track to the peer connection.
+ *
+ * @param track The media track to add.
+ * @param streamIds The stream IDs to associate with the track.
+ */
+ public void addTrack(MediaStreamTrack track, List streamIds) {
+ peerConnection.addTrack(track, streamIds);
+
+ LOG.info("Added track: {}", track.getKind());
+ }
+
+ /**
+ * Creates an audio track with the specified options and label.
+ *
+ * @param options Configuration options for the audio track source.
+ * @param label A unique identifier for the audio track.
+ *
+ * @return A new AudioTrack instance configured with the provided options.
+ */
+ public AudioTrack createAudioTrack(AudioOptions options, String label) {
+ AudioTrackSource audioSource = factory.createAudioSource(options);
+
+ return factory.createAudioTrack(label, audioSource);
+ }
+
+ /**
+ * Creates an audio track with a custom audio source that can push audio frames.
+ *
+ * @param source The custom audio source providing the audio frames.
+ * @param label A unique identifier for the audio track.
+ *
+ * @return A new AudioTrack instance connected to the provided custom source.
+ */
+ public AudioTrack createAudioTrack(CustomAudioSource source, String label) {
+ return factory.createAudioTrack(label, source);
+ }
+
+ /**
+ * Creates a video track with the specified source and label.
+ *
+ * @param source The video track source providing the video frames.
+ * @param label A unique identifier for the video track.
+ *
+ * @return A new VideoTrack instance connected to the provided source.
+ */
+ public VideoTrack createVideoTrack(VideoTrackSource source, String label) {
+ return factory.createVideoTrack(label, source);
+ }
+
+ /**
+ * Closes the peer connection.
+ */
+ public void close() {
+ if (peerConnection != null) {
+ peerConnection.close();
+ }
+
+ if (factory != null) {
+ factory.dispose();
+ }
+
+ LOG.info("Peer connection closed");
+ }
+
+ /**
+ * Creates an offer to initiate a connection.
+ */
+ public void createOffer() {
+ RTCOfferOptions options = new RTCOfferOptions();
+
+ peerConnection.createOffer(options, new CreateSessionDescriptionObserver() {
+ @Override
+ public void onSuccess(RTCSessionDescription sdp) {
+ setLocalDescription(sdp);
+ }
+
+ @Override
+ public void onFailure(String error) {
+ LOG.error("Failed to create offer: {}", error);
+ }
+ });
+ }
+
+ /**
+ * Creates an answer in response to an offer.
+ */
+ public void createAnswer() {
+ RTCAnswerOptions options = new RTCAnswerOptions();
+ peerConnection.createAnswer(options, new CreateSessionDescriptionObserver() {
+ @Override
+ public void onSuccess(RTCSessionDescription answer) {
+ setLocalDescription(answer);
+ }
+
+ @Override
+ public void onFailure(String error) {
+ LOG.error("Failed to create answer: {}", error);
+ }
+ });
+ }
+
+ /**
+ * Sets the local session description.
+ *
+ * @param sdp The session description to set.
+ */
+ private void setLocalDescription(RTCSessionDescription sdp) {
+ peerConnection.setLocalDescription(sdp, new SetSessionDescriptionObserver() {
+ @Override
+ public void onSuccess() {
+ LOG.info("Local description set successfully");
+
+ if (onLocalDescriptionCreated != null) {
+ onLocalDescriptionCreated.accept(sdp);
+ }
+ }
+
+ @Override
+ public void onFailure(String error) {
+ LOG.error("Failed to set local session description: {}", error);
+ }
+ });
+ }
+
+ /**
+ * Sets the remote session description.
+ *
+ * @param sdp The session description to set.
+ * @param isOffer True if the description is an offer, false otherwise.
+ */
+ public void setRemoteDescription(RTCSessionDescription sdp, boolean isOffer) {
+ peerConnection.setRemoteDescription(sdp, new SetSessionDescriptionObserver() {
+ @Override
+ public void onSuccess() {
+ LOG.info("Remote description set successfully");
+
+ if (isOffer) {
+ createAnswer();
+ }
+ }
+
+ @Override
+ public void onFailure(String error) {
+ LOG.error("Failed to set remote description: {}", error);
+ }
+ });
+ }
+
+ /**
+ * Adds an ICE candidate to the peer connection.
+ *
+ * @param candidate The ICE candidate to add.
+ */
+ public void addIceCandidate(RTCIceCandidate candidate) {
+ peerConnection.addIceCandidate(candidate);
+ LOG.info("Added ICE candidate: {}", candidate.sdp);
+ }
+
+ /**
+ * Sets a callback to be invoked when a local session description is created.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnLocalDescriptionCreated(Consumer callback) {
+ this.onLocalDescriptionCreated = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when an ICE candidate is generated.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnIceCandidateGenerated(Consumer callback) {
+ this.onIceCandidateGenerated = callback;
+ }
+
+ /**
+ * Sets a callback to be invoked when a media track is received.
+ *
+ * @param callback The callback to invoke.
+ */
+ public void setOnTrackReceived(Consumer callback) {
+ this.onTrackReceived = callback;
+ }
+
+ /**
+ * Sets whether this peer is the initiator of the connection.
+ *
+ * @param isInitiator true if this peer is the initiator, false otherwise.
+ */
+ public void setInitiator(boolean isInitiator) {
+ this.isInitiator = isInitiator;
+ }
+
+ /**
+ * Gets whether this peer is the initiator of the connection.
+ *
+ * @return true if this peer is the initiator, false otherwise.
+ */
+ public boolean isInitiator() {
+ return isInitiator;
+ }
+
+ @Override
+ public void handleOffer(SessionDescriptionMessage message) {
+ LOG.info("Received offer");
+
+ try {
+ String sdpString = message.getSdp();
+ String fromPeer = message.getFrom();
+
+ LOG.info("Parsed offer from: {}", fromPeer);
+
+ // Create remote session description.
+ RTCSessionDescription sdp = new RTCSessionDescription(RTCSdpType.OFFER, sdpString);
+
+ // Set remote description and create answer
+ setRemoteDescription(sdp, true);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing offer JSON", e);
+ }
+ }
+
+ @Override
+ public void handleAnswer(SessionDescriptionMessage message) {
+ LOG.info("Received answer");
+
+ try {
+ String sdpString = message.getSdp();
+
+ LOG.info("Successfully parsed answer JSON");
+
+ // Create remote session description.
+ RTCSessionDescription sdp = new RTCSessionDescription(RTCSdpType.ANSWER, sdpString);
+
+ // Set remote description
+ setRemoteDescription(sdp, false);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing answer JSON", e);
+ }
+ }
+
+ @Override
+ public void handleCandidate(IceCandidateMessage message) {
+ LOG.info("Received ICE candidate");
+
+ try {
+ IceCandidateMessage.IceCandidateData data = message.getData();
+ String sdpMid = data.getSdpMid();
+ int sdpMLineIndex = data.getSdpMLineIndex();
+ String candidate = data.getCandidate();
+
+ LOG.info("Successfully parsed ICE candidate JSON");
+
+ RTCIceCandidate iceCandidate = new RTCIceCandidate(sdpMid, sdpMLineIndex, candidate);
+ addIceCandidate(iceCandidate);
+ }
+ catch (Exception e) {
+ LOG.error("Error parsing ICE candidate JSON", e);
+ }
+ }
+
+
+
+ /**
+ * Implementation of PeerConnectionObserver to handle WebRTC events.
+ */
+ private class PeerConnectionObserverImpl implements PeerConnectionObserver {
+
+ @Override
+ public void onIceCandidate(RTCIceCandidate candidate) {
+ LOG.info("New ICE candidate: {}", candidate.sdp);
+
+ if (onIceCandidateGenerated != null) {
+ onIceCandidateGenerated.accept(candidate);
+ }
+ }
+
+ @Override
+ public void onConnectionChange(RTCPeerConnectionState state) {
+ LOG.info("Connection state changed to: {}", state);
+ }
+
+ @Override
+ public void onIceConnectionChange(RTCIceConnectionState state) {
+ LOG.info("ICE connection state changed to: {}", state);
+ }
+
+ @Override
+ public void onIceGatheringChange(RTCIceGatheringState state) {
+ LOG.info("ICE gathering state changed to: {}", state);
+ }
+
+ @Override
+ public void onSignalingChange(RTCSignalingState state) {
+ LOG.info("Signaling state changed to: {}", state);
+ }
+
+ @Override
+ public void onDataChannel(RTCDataChannel dataChannel) {
+ LOG.info("Data channel created: {}", dataChannel.getLabel());
+ }
+
+ @Override
+ public void onRenegotiationNeeded() {
+ LOG.info("Renegotiation needed");
+ createOffer();
+ }
+
+ @Override
+ public void onAddTrack(RTCRtpReceiver receiver, MediaStream[] mediaStreams) {
+ LOG.info("Track added: {}", receiver.getTrack().getKind());
+ }
+
+ @Override
+ public void onRemoveTrack(RTCRtpReceiver receiver) {
+ LOG.info("Track removed: {}", receiver.getTrack().getKind());
+ }
+
+ @Override
+ public void onTrack(RTCRtpTransceiver transceiver) {
+ MediaStreamTrack track = transceiver.getReceiver().getTrack();
+ String kind = track.getKind();
+
+ LOG.info("{} track added to transceiver", kind);
+
+ if (onTrackReceived != null) {
+ onTrackReceived.accept(track);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java
new file mode 100644
index 00000000..fda95cb3
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/connection/PeerConnectionSignalingHandler.java
@@ -0,0 +1,51 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.connection;
+
+import dev.onvoid.webrtc.examples.web.model.IceCandidateMessage;
+import dev.onvoid.webrtc.examples.web.model.SessionDescriptionMessage;
+
+/**
+ * This interface defines methods for processing signaling messages
+ * directly related to WebRTC peer connections.
+ *
+ * @author Alex Andres
+ */
+public interface PeerConnectionSignalingHandler {
+
+ /**
+ * Handles an incoming offer from a remote peer.
+ *
+ * @param message The session description message containing the offer.
+ */
+ void handleOffer(SessionDescriptionMessage message);
+
+ /**
+ * Handles an incoming answer from a remote peer.
+ *
+ * @param message The session description message containing the answer.
+ */
+ void handleAnswer(SessionDescriptionMessage message);
+
+ /**
+ * Handles an incoming ICE candidate from a remote peer.
+ *
+ * @param message The message containing the ICE candidate.
+ */
+ void handleCandidate(IceCandidateMessage message);
+
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java
new file mode 100644
index 00000000..7e2ba87f
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/AudioGenerator.java
@@ -0,0 +1,174 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.media;
+
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import dev.onvoid.webrtc.media.audio.CustomAudioSource;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A generator that produces synthetic audio samples as a continuous sine wave.
+ *
+ * This class creates a 440 Hz (A4 note) sine wave and delivers audio frames
+ * at regular 10 ms intervals. The generated audio is stereo with 16-bit samples
+ * at a 48 kHz sample rate. The audio generator runs on a dedicated scheduled
+ * thread that can be started and stopped on demand.
+ *
+ *
+ * The sine wave phase is maintained between audio frames to ensure a continuous
+ * waveform without clicks or pops that would occur from phase discontinuities.
+ *
+ *
+ * @author Alex Andres
+ */
+public class AudioGenerator {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AudioGenerator.class);
+
+ /** The custom audio source that receives generated audio frames. */
+ private final CustomAudioSource customAudioSource;
+
+ /** Flag indicating whether the audio generator is running. */
+ private final AtomicBoolean generatorRunning = new AtomicBoolean(false);
+
+ /** Executor service for scheduling audio sample generation. */
+ private ScheduledExecutorService executorService;
+
+ /** Future for the scheduled audio sample generation task. */
+ private ScheduledFuture> generatorFuture;
+
+ /** Default audio parameters */
+ private static final int DEFAULT_BITS_PER_SAMPLE = 16;
+ private static final int DEFAULT_SAMPLE_RATE = 48000;
+ private static final int DEFAULT_CHANNELS = 2;
+ private static final int DEFAULT_FRAME_COUNT = 480; // 10ms at 48kHz
+
+ /**
+ * Sine wave phase tracking for audio generation.
+ * Maintained between frames to ensure a continuous sine wave without clicks or pops.
+ */
+ private double sinePhase = 0.0;
+
+
+ public AudioGenerator(CustomAudioSource audioSource) {
+ customAudioSource = audioSource;
+ }
+
+ /**
+ * Starts the audio sample generator thread that pushes audio frames
+ * to the custom audio source every 10 ms.
+ */
+ public void start() {
+ if (generatorRunning.get()) {
+ LOG.info("Audio generator is already running");
+ return;
+ }
+
+ executorService = Executors.newSingleThreadScheduledExecutor();
+
+ generatorRunning.set(true);
+
+ generatorFuture = executorService.scheduleAtFixedRate(() -> {
+ if (!generatorRunning.get()) {
+ return;
+ }
+
+ try {
+ // Create a buffer with audio data (silence in this case).
+ int bytesPerSample = DEFAULT_BITS_PER_SAMPLE / 8;
+ byte[] audioData = new byte[DEFAULT_FRAME_COUNT * DEFAULT_CHANNELS * bytesPerSample];
+
+ // Generate a pleasant sine wave at 440 Hz (A4 note).
+ double amplitude = 0.2; // 30% of maximum to avoid being too loud
+ double frequency = 440.0; // A4 note (440 Hz)
+ double radiansPerSample = 2.0 * Math.PI * frequency / DEFAULT_SAMPLE_RATE;
+
+ for (int i = 0; i < DEFAULT_FRAME_COUNT; i++) {
+ short sample = (short) (amplitude * Short.MAX_VALUE * Math.sin(sinePhase));
+ sinePhase += radiansPerSample;
+
+ // Keep the phase between 0 and 2Ï€.
+ if (sinePhase > 2.0 * Math.PI) {
+ sinePhase -= 2.0 * Math.PI;
+ }
+
+ // Write the sample to both channels (stereo).
+ for (int channel = 0; channel < DEFAULT_CHANNELS; channel++) {
+ int index = (i * DEFAULT_CHANNELS + channel) * bytesPerSample;
+ // Write as little-endian (LSB first).
+ audioData[index] = (byte) (sample & 0xFF);
+ audioData[index + 1] = (byte) ((sample >> 8) & 0xFF);
+ }
+ }
+
+ // Push the audio data to the custom audio source.
+ customAudioSource.pushAudio(
+ audioData,
+ DEFAULT_BITS_PER_SAMPLE,
+ DEFAULT_SAMPLE_RATE,
+ DEFAULT_CHANNELS,
+ DEFAULT_FRAME_COUNT
+ );
+ }
+ catch (Exception e) {
+ LOG.error("Error in audio generator thread", e);
+ }
+ }, 0, 10, TimeUnit.MILLISECONDS);
+
+ LOG.info("Audio generator started");
+ }
+
+ /**
+ * Stops the audio sample generator thread.
+ */
+ public void stop() {
+ if (!generatorRunning.get()) {
+ return;
+ }
+
+ generatorRunning.set(false);
+
+ if (generatorFuture != null) {
+ generatorFuture.cancel(false);
+ generatorFuture = null;
+ }
+
+ if (executorService != null) {
+ executorService.shutdown();
+ try {
+ if (!executorService.awaitTermination(100, TimeUnit.MILLISECONDS)) {
+ executorService.shutdownNow();
+ }
+ }
+ catch (InterruptedException e) {
+ executorService.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ executorService = null;
+ }
+
+ LOG.info("Audio generator stopped");
+ }
+
+}
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java
new file mode 100644
index 00000000..b448feda
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/media/MediaManager.java
@@ -0,0 +1,214 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.media;
+
+import java.util.List;
+
+import dev.onvoid.webrtc.media.audio.AudioOptions;
+import dev.onvoid.webrtc.media.audio.AudioTrack;
+import dev.onvoid.webrtc.media.audio.AudioTrackSink;
+import dev.onvoid.webrtc.media.audio.CustomAudioSource;
+import dev.onvoid.webrtc.media.video.VideoDeviceSource;
+import dev.onvoid.webrtc.media.video.VideoTrack;
+import dev.onvoid.webrtc.media.video.VideoTrackSink;
+
+import dev.onvoid.webrtc.examples.web.connection.PeerConnectionManager;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Manages media tracks for WebRTC connections, including audio and video.
+ *
+ * @author Alex Andres
+ */
+public class MediaManager {
+
+ private static final Logger LOG = LoggerFactory.getLogger(MediaManager.class);
+
+ /** The audio track managed by this class. */
+ private AudioTrack audioTrack;
+
+ /** The video track managed by this class. */
+ private VideoTrack videoTrack;
+
+ /** The custom audio source for generating audio frames. */
+ private CustomAudioSource customAudioSource;
+
+ /** The audio generator responsible for creating and pushing audio frames to the custom audio source. */
+ private AudioGenerator audioGenerator;
+
+
+ /**
+ * Creates a new MediaManager.
+ */
+ public MediaManager() {
+ LOG.info("MediaManager created");
+ }
+
+ /**
+ * Creates and initializes audio and video tracks.
+ *
+ * @param peerConnectionManager The peer connection manager to use for creating tracks.
+ */
+ public void createTracks(PeerConnectionManager peerConnectionManager) {
+ createAudioTrack(peerConnectionManager);
+ createVideoTrack(peerConnectionManager);
+
+ // Add tracks to the peer connection.
+ List streamIds = List.of("stream0");
+
+ peerConnectionManager.addTrack(getAudioTrack(), streamIds);
+ peerConnectionManager.addTrack(getVideoTrack(), streamIds);
+ }
+
+ /**
+ * Creates and initializes tracks with a custom audio source and standard video track.
+ *
+ * @param peerConnectionManager The peer connection manager to use for creating tracks.
+ * @param useCustomAudio Whether to use a custom audio source that can push frames.
+ */
+ public void createTracks(PeerConnectionManager peerConnectionManager, boolean useCustomAudio) {
+ if (useCustomAudio) {
+ createCustomAudioTrack(peerConnectionManager);
+ }
+ else {
+ createAudioTrack(peerConnectionManager);
+ }
+
+ createVideoTrack(peerConnectionManager);
+
+ // Add tracks to the peer connection.
+ List streamIds = List.of("stream0");
+
+ peerConnectionManager.addTrack(getAudioTrack(), streamIds);
+ peerConnectionManager.addTrack(getVideoTrack(), streamIds);
+ }
+
+ /**
+ * Creates an audio track with default options.
+ */
+ private void createAudioTrack(PeerConnectionManager peerConnectionManager) {
+ AudioOptions audioOptions = new AudioOptions();
+ audioOptions.echoCancellation = true;
+ audioOptions.autoGainControl = true;
+ audioOptions.noiseSuppression = true;
+
+ audioTrack = peerConnectionManager.createAudioTrack(audioOptions, "audio0");
+
+ LOG.info("Audio track created");
+ }
+
+ /**
+ * Creates an audio track with a custom audio source that can push audio frames.
+ * Also starts the audio generator thread that pushes audio frames every 10ms.
+ *
+ * @param peerConnectionManager The peer connection manager to use for creating the track.
+ */
+ public void createCustomAudioTrack(PeerConnectionManager peerConnectionManager) {
+ customAudioSource = new CustomAudioSource();
+
+ audioTrack = peerConnectionManager.createAudioTrack(customAudioSource, "audio0");
+
+ // Start the audio generator.
+ audioGenerator = new AudioGenerator(customAudioSource);
+ audioGenerator.start();
+
+ LOG.info("Custom audio track created with audio generator");
+ }
+
+ /**
+ * Creates a video track using the default video device.
+ */
+ private void createVideoTrack(PeerConnectionManager peerConnectionManager) {
+ VideoDeviceSource videoSource = new VideoDeviceSource();
+ videoSource.start();
+
+ videoTrack = peerConnectionManager.createVideoTrack(videoSource, "video0");
+
+ LOG.info("Video track created");
+ }
+
+ /**
+ * Gets the audio track.
+ *
+ * @return The audio track.
+ */
+ public AudioTrack getAudioTrack() {
+ return audioTrack;
+ }
+
+ /**
+ * Gets the video track.
+ *
+ * @return The video track.
+ */
+ public VideoTrack getVideoTrack() {
+ return videoTrack;
+ }
+
+ /**
+ * Adds a sink to the audio track.
+ *
+ * @param sink The sink to add.
+ */
+ public void addAudioSink(AudioTrackSink sink) {
+ if (audioTrack != null) {
+ audioTrack.addSink(sink);
+
+ LOG.info("Added sink to audio track");
+ }
+ }
+
+ /**
+ * Adds a sink to the video track.
+ *
+ * @param sink The sink to add.
+ */
+ public void addVideoSink(VideoTrackSink sink) {
+ if (videoTrack != null) {
+ videoTrack.addSink(sink);
+
+ LOG.info("Added sink to video track");
+ }
+ }
+
+ /**
+ * Disposes of all media resources.
+ */
+ public void dispose() {
+ // Stop the audio generator if it's running.
+ if (audioGenerator != null) {
+ audioGenerator.stop();
+ audioGenerator = null;
+ }
+
+ if (audioTrack != null) {
+ audioTrack.dispose();
+ audioTrack = null;
+ }
+
+ if (videoTrack != null) {
+ videoTrack.dispose();
+ videoTrack = null;
+ }
+
+ customAudioSource = null;
+
+ LOG.info("Media resources disposed");
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java
new file mode 100644
index 00000000..d53f366a
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/IceCandidateMessage.java
@@ -0,0 +1,170 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Represents an ICE candidate message used in WebRTC signaling.
+ *
+ * This class is used to exchange ICE candidates between peers during
+ * the connection establishment process.
+ *
+ *
+ * @author Alex Andres
+ */
+public class IceCandidateMessage extends SignalingMessage {
+
+ private IceCandidateData data;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public IceCandidateMessage() {
+ super(MessageType.ICE_CANDIDATE.getValue());
+ }
+
+ /**
+ * Creates a new IceCandidateMessage with the specified sender and recipient.
+ *
+ * @param from the sender ID.
+ * @param to the recipient ID.
+ */
+ public IceCandidateMessage(String from, String to) {
+ super(MessageType.ICE_CANDIDATE.getValue(), from, to);
+
+ data = new IceCandidateData();
+ }
+
+ /**
+ * Creates a new IceCandidateMessage with the specified sender, recipient, and candidate information.
+ *
+ * @param from the sender ID.
+ * @param to the recipient ID.
+ * @param candidate the candidate string.
+ * @param sdpMid the SDP mid.
+ * @param sdpMLineIndex the SDP mline index.
+ */
+ public IceCandidateMessage(String from, String to, String candidate, String sdpMid, int sdpMLineIndex) {
+ super(MessageType.ICE_CANDIDATE.getValue(), from, to);
+
+ data = new IceCandidateData(candidate, sdpMid, sdpMLineIndex);
+ }
+
+ /**
+ * Gets the ICE candidate data.
+ *
+ * @return the ICE candidate data.
+ */
+ public IceCandidateData getData() {
+ return data;
+ }
+
+ /**
+ * Sets the ICE candidate data.
+ *
+ * @param data the ICE candidate data.
+ */
+ public void setData(IceCandidateData data) {
+ this.data = data;
+ }
+
+
+
+ /**
+ * Inner class representing the data payload of an ICE candidate message.
+ */
+ public static class IceCandidateData {
+
+ private String candidate;
+ private String sdpMid;
+ private int sdpMLineIndex;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public IceCandidateData() {
+ }
+
+ /**
+ * Creates a new IceCandidateData with the specified candidate information.
+ *
+ * @param candidate the candidate string.
+ * @param sdpMid the SDP mid.
+ * @param sdpMLineIndex the SDP mline index.
+ */
+ public IceCandidateData(String candidate, String sdpMid, int sdpMLineIndex) {
+ this.candidate = candidate;
+ this.sdpMid = sdpMid;
+ this.sdpMLineIndex = sdpMLineIndex;
+ }
+
+ /**
+ * Gets the candidate string.
+ *
+ * @return the candidate string.
+ */
+ public String getCandidate() {
+ return candidate;
+ }
+
+ /**
+ * Sets the candidate string.
+ *
+ * @param candidate the candidate string.
+ */
+ public void setCandidate(String candidate) {
+ this.candidate = candidate;
+ }
+
+ /**
+ * Gets the SDP mid.
+ *
+ * @return the SDP mid.
+ */
+ public String getSdpMid() {
+ return sdpMid;
+ }
+
+ /**
+ * Sets the SDP mid.
+ *
+ * @param sdpMid the SDP mid.
+ */
+ public void setSdpMid(String sdpMid) {
+ this.sdpMid = sdpMid;
+ }
+
+ /**
+ * Gets the SDP mline index.
+ *
+ * @return the SDP mline index.
+ */
+ public int getSdpMLineIndex() {
+ return sdpMLineIndex;
+ }
+
+ /**
+ * Sets the SDP mline index.
+ *
+ * @param sdpMLineIndex the SDP mline index.
+ */
+ public void setSdpMLineIndex(int sdpMLineIndex) {
+ this.sdpMLineIndex = sdpMLineIndex;
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java
new file mode 100644
index 00000000..53b22077
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/JoinMessage.java
@@ -0,0 +1,207 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Represents a join message used in WebRTC signaling.
+ * This class is used when a peer wants to join a room or session.
+ *
+ * @author Alex Andres
+ */
+public class JoinMessage extends SignalingMessage {
+
+ private JoinData data;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public JoinMessage() {
+ super(MessageType.JOIN.getValue());
+ }
+
+ /**
+ * Creates a new JoinMessage with the specified sender, room, and user information.
+ *
+ * @param from the sender ID.
+ * @param room the room to join.
+ * @param userName the user's name.
+ */
+ public JoinMessage(String from, String room, String userName) {
+ super(MessageType.JOIN.getValue(), from, null);
+
+ data = new JoinData(room, new UserInfo(userName, from));
+ }
+
+ /**
+ * Gets the join data.
+ *
+ * @return the join data.
+ */
+ public JoinData getData() {
+ return data;
+ }
+
+ /**
+ * Sets the join data.
+ *
+ * @param data the join data.
+ */
+ public void setData(JoinData data) {
+ this.data = data;
+ }
+
+
+
+ /**
+ * Inner class representing the data payload of a join message.
+ */
+ public static class JoinData {
+
+ private String room;
+ private UserInfo userInfo;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public JoinData() {
+ }
+
+ /**
+ * Creates a new JoinData with the specified room.
+ *
+ * @param room the room to join
+ */
+ public JoinData(String room) {
+ this.room = room;
+ }
+
+ /**
+ * Creates a new JoinData with the specified room and user information.
+ *
+ * @param room the room to join
+ * @param userInfo the user information
+ */
+ public JoinData(String room, UserInfo userInfo) {
+ this.room = room;
+ this.userInfo = userInfo;
+ }
+
+ /**
+ * Gets the room.
+ *
+ * @return the room
+ */
+ public String getRoom() {
+ return room;
+ }
+
+ /**
+ * Sets the room.
+ *
+ * @param room the room
+ */
+ public void setRoom(String room) {
+ this.room = room;
+ }
+
+ /**
+ * Gets the user information.
+ *
+ * @return the user information
+ */
+ public UserInfo getUserInfo() {
+ return userInfo;
+ }
+
+ /**
+ * Sets the user information.
+ *
+ * @param userInfo the user information
+ */
+ public void setUserInfo(UserInfo userInfo) {
+ this.userInfo = userInfo;
+ }
+ }
+
+
+
+ /**
+ * Inner class representing user information.
+ */
+ public static class UserInfo {
+
+ private String userId;
+
+ private String name;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public UserInfo() {
+ }
+
+ /**
+ * Creates a new UserInfo with the specified name and avatar.
+ *
+ * @param name the user's name.
+ * @param userId the user's ID.
+ */
+ public UserInfo(String name, String userId) {
+ this.name = name;
+ this.userId = userId;
+ }
+
+ /**
+ * Gets the user's name.
+ *
+ * @return the user's name.
+ */
+ public String getName() {
+ return name;
+ }
+
+ /**
+ * Sets the user's name.
+ *
+ * @param name the user's name.
+ */
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * Gets the user's ID.
+ *
+ * @return the user's ID.
+ */
+ public String getUserId() {
+ return userId;
+ }
+
+ /**
+ * Sets the user's ID.
+ *
+ * @param userId the user's ID.
+ */
+ public void setUserId(String userId) {
+ this.userId = userId;
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java
new file mode 100644
index 00000000..6b5cf1c6
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/LeaveMessage.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Represents a message indicating that a user is leaving a room.
+ * This class is used when a peer leaves a room or session.
+ *
+ * @author Alex Andres
+ */
+public class LeaveMessage extends SignalingMessage {
+
+ /**
+ * Constructs a new leave message with default values.
+ */
+ public LeaveMessage() {
+ super(MessageType.LEAVE.getValue());
+ }
+
+}
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java
new file mode 100644
index 00000000..b4b8f2c7
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/MessageType.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Represents the types of messages exchanged during WebRTC signaling.
+ * Each message type corresponds to a specific action or state in the WebRTC connection process.
+ *
+ * @author Alex Andres
+ */
+public enum MessageType {
+
+ /** WebRTC offer message containing session description. */
+ OFFER("offer"),
+
+ /** WebRTC answer message containing session description. */
+ ANSWER("answer"),
+
+ /** ICE candidate information for establishing peer connections. */
+ ICE_CANDIDATE("ice-candidate"),
+
+ /** Message indicating a peer joining the signaling session. */
+ JOIN("join"),
+
+ /** Message indicating a peer leaving the signaling session. */
+ LEAVE("leave"),
+
+ /** Heartbeat message to maintain connection. */
+ HEARTBEAT("heartbeat"),
+
+ /** Acknowledgment response to a heartbeat message. */
+ HEARTBEAT_ACK("heartbeat-ack"),
+
+ /** Fallback type for unrecognized messages. */
+ UNKNOWN("");
+
+ /** The string representation of the message type. */
+ private final String value;
+
+
+ /**
+ * Constructs a message type with the specified string value.
+ *
+ * @param value The string representation of this message type
+ */
+ MessageType(String value) {
+ this.value = value;
+ }
+
+ /**
+ * Returns the string representation of this message type.
+ *
+ * @return The string value of this message type
+ */
+ public String getValue() {
+ return value;
+ }
+
+ /**
+ * Finds and returns the message type enum constant that matches the given string.
+ *
+ * @param text The string to convert to a message type
+ * @return The matching message type, or UNKNOWN if no match is found
+ */
+ public static MessageType fromString(String text) {
+ for (MessageType type : MessageType.values()) {
+ if (type.value.equals(text)) {
+ return type;
+ }
+ }
+ return UNKNOWN;
+ }
+}
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java
new file mode 100644
index 00000000..08e529af
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SessionDescriptionMessage.java
@@ -0,0 +1,136 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Represents a session description message used in WebRTC signaling.
+ *
+ * This class is used for both offer and answer messages, which contain
+ * Session Description Protocol (SDP) information.
+ *
+ *
+ * @author Alex Andres
+ */
+public class SessionDescriptionMessage extends SignalingMessage {
+
+ private SessionDescriptionData data;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public SessionDescriptionMessage() {
+ super();
+ }
+
+ /**
+ * Creates a new SessionDescriptionMessage with the specified type, sender, recipient, and SDP.
+ *
+ * @param type the message type (either "offer" or "answer").
+ * @param from the sender ID.
+ * @param to the recipient ID.
+ * @param sdp the SDP string.
+ */
+ public SessionDescriptionMessage(String type, String from, String to, String sdp) {
+ super(type, from, to);
+
+ data = new SessionDescriptionData(sdp);
+ }
+
+ /**
+ * Gets the session description data.
+ *
+ * @return the session description data.
+ */
+ public SessionDescriptionData getData() {
+ return data;
+ }
+
+ /**
+ * Sets the session description data.
+ *
+ * @param data the session description data.
+ */
+ public void setData(SessionDescriptionData data) {
+ this.data = data;
+ }
+
+ /**
+ * Sets the SDP string.
+ *
+ * @param sdp the SDP string.
+ */
+ public void setSdp(String sdp) {
+ if (data == null) {
+ data = new SessionDescriptionData();
+ }
+ data.setSdp(sdp);
+ }
+
+ /**
+ * Gets the SDP string.
+ *
+ * @return the SDP string.
+ */
+ public String getSdp() {
+ return data != null ? data.getSdp() : null;
+ }
+
+
+
+ /**
+ * Inner class representing the data payload of a session description message.
+ */
+ public static class SessionDescriptionData {
+
+ private String sdp;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public SessionDescriptionData() {
+ }
+
+ /**
+ * Creates a new SessionDescriptionData with the specified SDP.
+ *
+ * @param sdp the SDP string.
+ */
+ public SessionDescriptionData(String sdp) {
+ this.sdp = sdp;
+ }
+
+ /**
+ * Gets the SDP string.
+ *
+ * @return the SDP string.
+ */
+ public String getSdp() {
+ return sdp;
+ }
+
+ /**
+ * Sets the SDP string.
+ *
+ * @param sdp the SDP string.
+ */
+ public void setSdp(String sdp) {
+ this.sdp = sdp;
+ }
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java
new file mode 100644
index 00000000..f09d7fcb
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/model/SignalingMessage.java
@@ -0,0 +1,117 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.model;
+
+/**
+ * Base class for all signaling messages.
+ *
+ * This class represents the common structure of all messages exchanged
+ * during WebRTC signaling. Specific message types extend this class
+ * and add their own data payload.
+ *
+ *
+ * @author Alex Andres
+ */
+public class SignalingMessage {
+
+ private String type;
+ private String from;
+ private String to;
+
+
+ /**
+ * Default constructor required for JSON deserialization.
+ */
+ public SignalingMessage() {
+ }
+
+ /**
+ * Creates a new SignalingMessage with the specified type.
+ *
+ * @param type the message type.
+ */
+ public SignalingMessage(String type) {
+ this.type = type;
+ }
+
+ /**
+ * Creates a new SignalingMessage with the specified type, sender, and recipient.
+ *
+ * @param type the message type.
+ * @param from the sender ID.
+ * @param to the recipient ID.
+ */
+ public SignalingMessage(String type, String from, String to) {
+ this.type = type;
+ this.from = from;
+ this.to = to;
+ }
+
+ /**
+ * Gets the message type.
+ *
+ * @return the message type.
+ */
+ public String getType() {
+ return type;
+ }
+
+ /**
+ * Sets the message type.
+ *
+ * @param type the message type.
+ */
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ /**
+ * Gets the sender ID.
+ *
+ * @return the sender ID.
+ */
+ public String getFrom() {
+ return from;
+ }
+
+ /**
+ * Sets the sender ID.
+ *
+ * @param from the sender ID.
+ */
+ public void setFrom(String from) {
+ this.from = from;
+ }
+
+ /**
+ * Gets the recipient ID.
+ *
+ * @return the recipient ID.
+ */
+ public String getTo() {
+ return to;
+ }
+
+ /**
+ * Sets the recipient ID.
+ *
+ * @param to the recipient ID.
+ */
+ public void setTo(String to) {
+ this.to = to;
+ }
+}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java
new file mode 100644
index 00000000..98eb42a5
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebServer.java
@@ -0,0 +1,134 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.server;
+
+import java.io.InputStream;
+import java.security.KeyStore;
+
+import dev.onvoid.webrtc.examples.web.WebClientExample;
+
+import org.eclipse.jetty.http.pathmap.PathSpec;
+import org.eclipse.jetty.server.*;
+import org.eclipse.jetty.server.handler.PathMappingsHandler;
+import org.eclipse.jetty.server.handler.ResourceHandler;
+import org.eclipse.jetty.util.resource.ResourceFactory;
+import org.eclipse.jetty.util.ssl.SslContextFactory;
+import org.eclipse.jetty.websocket.server.WebSocketUpgradeHandler;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * A web server implementation using Jetty that provides both HTTPS and WebSocket functionality.
+ *
+ * This server:
+ *
+ *
Runs on port 8443 with HTTPS only (TLS 1.2/1.3)
+ *
Provides WebSocket endpoints with the "ws-signaling" protocol
+ *
Serves static resources from the classpath at "/resources/web/"
+ *
Uses a self-signed certificate from a bundled keystore
+ *
+ *
+ *
+ * @author Alex Andres
+ */
+public class WebServer {
+
+ static {
+ WebClientExample.setupLogging();
+ }
+
+ public static final Logger LOG = LoggerFactory.getLogger(WebServer.class);
+
+ private static final int PORT = 8443;
+
+
+ public static void main(String[] args) throws Exception {
+ Server server = new Server();
+
+ // Configure HTTPS only.
+ HttpConfiguration httpsConfig = new HttpConfiguration();
+ httpsConfig.addCustomizer(new SecureRequestCustomizer());
+
+ // Create the HTTP/1.1 ConnectionFactory.
+ HttpConnectionFactory http = new HttpConnectionFactory(httpsConfig);
+
+ // Create the TLS ConnectionFactory, setting HTTP/1.1 as the wrapped protocol.
+ SslContextFactory.Server sslContextFactory = createSslContextFactory();
+ SslConnectionFactory tls = new SslConnectionFactory(sslContextFactory, http.getProtocol());
+
+ ServerConnector httpsConnector = new ServerConnector(server, tls, http);
+ httpsConnector.setPort(PORT);
+
+ // Add only HTTPS connector.
+ server.addConnector(httpsConnector);
+
+ // Create WebSocket upgrade handler.
+ WebSocketUpgradeHandler wsHandler = WebSocketUpgradeHandler.from(server, container -> {
+ container.addMapping("/ws", (upgradeRequest, upgradeResponse, callback) -> {
+ if (upgradeRequest.getSubProtocols().contains("ws-signaling")) {
+ upgradeResponse.setAcceptedSubProtocol("ws-signaling");
+ return new WebSocketHandler();
+ }
+ return null;
+ });
+ });
+
+ // Create a static resource handler.
+ ResourceHandler resourceHandler = new ResourceHandler();
+ ResourceFactory resourceFactory = ResourceFactory.of(resourceHandler);
+ resourceHandler.setBaseResource(resourceFactory.newClassLoaderResource("/resources/web/"));
+
+ // Create a path mappings handler to combine WebSocket and static content.
+ PathMappingsHandler pathHandler = new PathMappingsHandler();
+ pathHandler.addMapping(PathSpec.from("/ws/*"), wsHandler);
+ pathHandler.addMapping(PathSpec.from("/"), resourceHandler);
+
+ server.setHandler(pathHandler);
+ server.start();
+
+ LOG.info("HTTPS: https://localhost:{}", PORT);
+ LOG.info("WebSocket: wss://localhost:{}/ws", PORT);
+ LOG.info("Note: Using self-signed certificate - browsers will show security warnings");
+
+ server.join();
+ }
+
+ private static SslContextFactory.Server createSslContextFactory() {
+ SslContextFactory.Server sslContextFactory = new SslContextFactory.Server();
+
+ try (InputStream keystoreStream = WebServer.class.getResourceAsStream("/resources/keystore.p12")) {
+ if (keystoreStream == null) {
+ throw new IllegalArgumentException("Keystore not found in resources.");
+ }
+
+ KeyStore keyStore = KeyStore.getInstance("PKCS12");
+ keyStore.load(keystoreStream, "l0c4lh0s7".toCharArray());
+
+ sslContextFactory.setKeyStore(keyStore);
+ sslContextFactory.setKeyStorePassword("l0c4lh0s7");
+ }
+ catch (Exception e) {
+ LOG.error("Load keystore failed", e);
+ }
+
+ // Security settings.
+ sslContextFactory.setIncludeProtocols("TLSv1.3", "TLSv1.2");
+
+ return sslContextFactory;
+ }
+}
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java
new file mode 100644
index 00000000..d4ece58a
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/web/server/WebSocketHandler.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.examples.web.server;
+
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArraySet;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import dev.onvoid.webrtc.examples.web.model.LeaveMessage;
+import dev.onvoid.webrtc.examples.web.model.MessageType;
+import dev.onvoid.webrtc.examples.web.model.SignalingMessage;
+
+import org.eclipse.jetty.websocket.api.Callback;
+import org.eclipse.jetty.websocket.api.Session;
+import org.eclipse.jetty.websocket.api.annotations.*;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * Handles WebSocket connections and messages for the WebRTC signaling server.
+ *
+ * This class manages all active WebSocket sessions, processes incoming messages,
+ * and provides methods for broadcasting messages to connected clients.
+ *
+ *
+ * @author Alex Andres
+ */
+@WebSocket
+public class WebSocketHandler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(WebSocketHandler.class);
+
+ /** Thread-safe set to store all active sessions. */
+ private static final Set sessions = new CopyOnWriteArraySet<>();
+
+ /** Thread-safe map to store session to user-id mappings. */
+ private static final Map sessionToUserId = new ConcurrentHashMap<>();
+
+ /** JSON serializer/deserializer for processing WebSocket messages. */
+ private final ObjectMapper jsonMapper = new ObjectMapper();
+
+
+ @OnWebSocketOpen
+ public void onOpen(Session session) {
+ sessions.add(session);
+
+ LOG.info("New connection: {} (Total connections: {})", session.getRemoteSocketAddress(), sessions.size());
+ }
+
+ @OnWebSocketMessage
+ public void onMessage(Session session, String message) {
+ LOG.info("Message from {}: {}", session.getRemoteSocketAddress(), message);
+
+ try {
+ JsonNode messageNode = jsonMapper.readTree(message);
+ String type = messageNode.path("type").asText();
+
+ // Check if this is a heartbeat message.
+ if (MessageType.HEARTBEAT.getValue().equals(type)) {
+ // Send heartbeat acknowledgment.
+ SignalingMessage heartbeatAck = new SignalingMessage("heartbeat-ack");
+ String heartbeatAckJson = jsonMapper.writeValueAsString(heartbeatAck);
+
+ sendMessage(session, heartbeatAckJson);
+ return;
+ }
+
+ // Check if this is a join message and extract the user-id.
+ if (MessageType.JOIN.getValue().equals(type)) {
+ String userId = messageNode.path("from").asText();
+
+ if (userId != null && !userId.isEmpty()) {
+ // Store the user-id for this session.
+ sessionToUserId.put(session, userId);
+
+ LOG.info("Registered user-id '{}' for session {}", userId, session.getRemoteSocketAddress());
+ }
+ }
+ }
+ catch (JsonProcessingException e) {
+ LOG.error("Error parsing message type", e);
+ }
+
+ // Broadcast the message to all other connected peers (excluding sender).
+ broadcastToOthers(session, message);
+ }
+
+ @OnWebSocketClose
+ public void onClose(Session session, int statusCode, String reason) {
+ sessions.remove(session);
+
+ LOG.info("Connection closed: {} ({}) - Status: {}", session.getRemoteSocketAddress(), reason, statusCode);
+ LOG.info("Remaining connections: {}", sessions.size());
+
+ // Notify other clients about disconnection.
+ try {
+ // Get the user-id for this session or use the socket address as a fallback.
+ String userId = sessionToUserId.getOrDefault(session, session.getRemoteSocketAddress().toString());
+
+ // Create a leave message.
+ LeaveMessage leaveMessage = new LeaveMessage();
+ leaveMessage.setFrom(userId);
+
+ String leaveMessageJson = jsonMapper.writeValueAsString(leaveMessage);
+ broadcastToAll(leaveMessageJson);
+
+ LOG.info("Sent participant left message for user-id: {}", userId);
+
+ // Clean up the session-to-userId mapping.
+ sessionToUserId.remove(session);
+ }
+ catch (JsonProcessingException e) {
+ LOG.error("Error creating participant left message", e);
+ }
+ }
+
+ @OnWebSocketError
+ public void onError(Session session, Throwable error) {
+ LOG.error("WebSocket error for {}", session.getRemoteSocketAddress(), error);
+ }
+
+ /**
+ * Broadcasts a message to all connected peers except the sender.
+ *
+ * @param sender the session of the sender.
+ * @param message the message to broadcast.
+ */
+ private void broadcastToOthers(Session sender, String message) {
+ sessions.stream()
+ .filter(Session::isOpen)
+ .filter(session -> !session.equals(sender))
+ .forEach(session -> sendMessage(session, message));
+ }
+
+ /**
+ * Broadcasts a message to all connected peers.
+ *
+ * @param message the message to broadcast.
+ */
+ private void broadcastToAll(String message) {
+ sessions.stream()
+ .filter(Session::isOpen)
+ .forEach(session -> sendMessage(session, message));
+ }
+
+ private void sendMessage(Session session, String message) {
+ session.sendText(message, Callback.from(() -> {}, throwable -> {
+ LOG.error("Error sending message to session: {}", throwable.getMessage());
+ // Remove broken sessions.
+ sessions.remove(session);
+ }));
+ }
+}
diff --git a/webrtc-examples/src/main/java/module-info.java b/webrtc-examples/src/main/java/module-info.java
index e051b4cd..b330984a 100644
--- a/webrtc-examples/src/main/java/module-info.java
+++ b/webrtc-examples/src/main/java/module-info.java
@@ -1,7 +1,14 @@
module webrtc.java.examples {
+ requires com.fasterxml.jackson.databind;
requires java.logging;
requires java.net.http;
+ requires org.eclipse.jetty.server;
+ requires org.eclipse.jetty.websocket.server;
requires webrtc.java;
+ exports dev.onvoid.webrtc.examples.web.client;
+ exports dev.onvoid.webrtc.examples.web.server;
+ exports dev.onvoid.webrtc.examples.web.model;
+
}
\ No newline at end of file
diff --git a/webrtc-examples/src/main/resources/keystore.p12 b/webrtc-examples/src/main/resources/keystore.p12
new file mode 100644
index 00000000..b655bbac
Binary files /dev/null and b/webrtc-examples/src/main/resources/keystore.p12 differ
diff --git a/webrtc-examples/src/main/resources/logging.properties b/webrtc-examples/src/main/resources/logging.properties
new file mode 100644
index 00000000..47bfe9da
--- /dev/null
+++ b/webrtc-examples/src/main/resources/logging.properties
@@ -0,0 +1,11 @@
+.level = INFO
+
+# Configure console handler.
+handlers = java.util.logging.ConsoleHandler
+
+# Set the console handler level and formatter.
+java.util.logging.ConsoleHandler.level = ALL
+java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
+
+# Custom format pattern.
+java.util.logging.SimpleFormatter.format = [%4$s] %5$s%n
diff --git a/webrtc-examples/src/main/resources/web/PeerConnectionManager.js b/webrtc-examples/src/main/resources/web/PeerConnectionManager.js
new file mode 100644
index 00000000..716013b7
--- /dev/null
+++ b/webrtc-examples/src/main/resources/web/PeerConnectionManager.js
@@ -0,0 +1,212 @@
+/**
+ * Manages WebRTC peer connections for real-time communication.
+ *
+ * This class encapsulates the WebRTC API functionality including
+ * - Connection initialization
+ * - Local media stream management
+ * - SDP offer/answer creation and handling
+ * - ICE candidate processing
+ * - Connection state monitoring
+ *
+ * @class
+ * @property {Object} configuration - The RTCPeerConnection configuration
+ * @property {function} onTrackCallback - Callback for track events
+ * @property {function} onIceCandidateCallback - Callback for ICE candidate events
+ * @property {function} onIceConnectionStateChangeCallback - Callback for ICE connection state changes
+ * @property {function} onConnectionStateChangeCallback - Callback for connection state changes
+ * @property {RTCPeerConnection|null} peerConnection - The WebRTC peer connection object
+ * @property {MediaStream|null} localStream - The local media stream
+ */
+class PeerConnectionManager {
+
+ /**
+ * Creates a new PeerConnectionManager instance.
+ *
+ * @param {Array|Object} iceServers - ICE server configuration for the RTCPeerConnection
+ * @param {function} onTrack - Callback function that handles track events
+ * @param {function} onIceCandidate - Callback function for ICE candidate events
+ * @param {function} onIceConnectionStateChange - Callback function for ICE connection state changes
+ * @param {function} onConnectionStateChange - Callback function for connection state changes
+ */
+ constructor(iceServers, onTrack, onIceCandidate, onIceConnectionStateChange, onConnectionStateChange) {
+ this.configuration = { iceServers };
+ this.onTrackCallback = onTrack;
+ this.onIceCandidateCallback = onIceCandidate;
+ this.onIceConnectionStateChangeCallback = onIceConnectionStateChange;
+ this.onConnectionStateChangeCallback = onConnectionStateChange;
+ this.peerConnection = null;
+ this.localStream = null;
+ }
+
+ // Helper method to get the current user ID
+ getUserId() {
+ // In a real application, this would be a unique identifier for the user
+ return "web-client";
+ }
+
+ // Helper method to get the remote peer ID
+ getRemotePeerId() {
+ // In a real application, this would be the ID of the peer we're communicating with
+ // For simplicity, we're using a fixed value here
+ return "java-client";
+ }
+
+ initialize() {
+ this.peerConnection = new RTCPeerConnection(this.configuration);
+
+ this.peerConnection.ontrack = (event) => {
+ if (this.onTrackCallback) {
+ this.onTrackCallback(event);
+ }
+ };
+
+ this.peerConnection.onicecandidate = (event) => {
+ if (event.candidate && this.onIceCandidateCallback) {
+ const candidate = {
+ type: "ice-candidate",
+ from: this.getUserId(),
+ to: this.getRemotePeerId(),
+ data: {
+ candidate: event.candidate.candidate,
+ sdpMid: event.candidate.sdpMid,
+ sdpMLineIndex: event.candidate.sdpMLineIndex
+ }
+ };
+ this.onIceCandidateCallback(candidate);
+ }
+ };
+
+ this.peerConnection.oniceconnectionstatechange = () => {
+ if (this.onIceConnectionStateChangeCallback) {
+ this.onIceConnectionStateChangeCallback(this.peerConnection.iceConnectionState);
+ }
+ };
+
+ this.peerConnection.onconnectionstatechange = () => {
+ if (this.onConnectionStateChangeCallback) {
+ this.onConnectionStateChangeCallback(this.peerConnection.connectionState);
+ }
+ };
+ }
+
+ async addLocalMedia() {
+ try {
+ this.localStream = await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
+ this.localStream.getTracks().forEach(track => {
+ this.peerConnection.addTrack(track, this.localStream);
+ });
+ return true;
+ }
+ catch (error) {
+ console.error("Error accessing media devices:", error);
+ return false;
+ }
+ }
+
+ async createOffer() {
+ try {
+ const offer = await this.peerConnection.createOffer();
+ await this.peerConnection.setLocalDescription(offer);
+ return {
+ type: "offer",
+ from: this.getUserId(),
+ to: this.getRemotePeerId(),
+ data: {
+ sdp: this.peerConnection.localDescription.sdp
+ }
+ };
+ }
+ catch (error) {
+ console.error("Error creating offer:", error);
+ throw error;
+ }
+ }
+
+ async handleOffer(message) {
+ try {
+ // Extract SDP from the data object in the message format
+ const sdp = message.data.sdp.replace(/\\n/g, '\n');
+ const sessionDescription = new RTCSessionDescription({
+ type: "offer",
+ sdp: sdp
+ });
+ await this.peerConnection.setRemoteDescription(sessionDescription);
+ return true;
+ }
+ catch (error) {
+ console.error("Error setting remote description for offer:", error);
+ throw error;
+ }
+ }
+
+ async createAnswer() {
+ try {
+ const answer = await this.peerConnection.createAnswer();
+ await this.peerConnection.setLocalDescription(answer);
+
+ console.log("Local answer:", answer);
+ console.log("Local description:", this.peerConnection.localDescription);
+
+ return {
+ type: "answer",
+ from: this.getUserId(),
+ to: this.getRemotePeerId(),
+ data: {
+ sdp: this.peerConnection.localDescription.sdp
+ }
+ };
+ }
+ catch (error) {
+ console.error("Error creating answer:", error);
+ throw error;
+ }
+ }
+
+ async handleAnswer(message) {
+ try {
+ // Extract SDP from the data object in the new message format
+ const sdp = message.data.sdp.replace(/\\n/g, '\n');
+ const sessionDescription = new RTCSessionDescription({
+ type: "answer",
+ sdp: sdp
+ });
+ await this.peerConnection.setRemoteDescription(sessionDescription);
+ }
+ catch (error) {
+ console.error("Error setting remote description:", error);
+ throw error;
+ }
+ }
+
+ async addIceCandidate(message) {
+ try {
+ // Extract candidate information from the data object in the new message format
+ const candidate = new RTCIceCandidate({
+ sdpMid: message.data.sdpMid,
+ sdpMLineIndex: message.data.sdpMLineIndex,
+ candidate: message.data.candidate
+ });
+ await this.peerConnection.addIceCandidate(candidate);
+
+ console.log('ICE candidate added successfully');
+ }
+ catch (error) {
+ console.error('Error adding ICE candidate:', error);
+ throw error;
+ }
+ }
+
+ close() {
+ if (this.localStream) {
+ this.localStream.getTracks().forEach(track => track.stop());
+ this.localStream = null;
+ }
+
+ if (this.peerConnection) {
+ this.peerConnection.close();
+ this.peerConnection = null;
+ }
+ }
+}
+
+export { PeerConnectionManager };
\ No newline at end of file
diff --git a/webrtc-examples/src/main/resources/web/SignalingChannel.js b/webrtc-examples/src/main/resources/web/SignalingChannel.js
new file mode 100644
index 00000000..cdcb128a
--- /dev/null
+++ b/webrtc-examples/src/main/resources/web/SignalingChannel.js
@@ -0,0 +1,142 @@
+/**
+ * SignalingChannel handles WebSocket communication for WebRTC signaling.
+ * It includes a heartbeat mechanism to keep the connection alive and detect disconnections early.
+ */
+class SignalingChannel {
+
+ /**
+ * Creates a new SignalingChannel instance.
+ *
+ * @param {string} serverUrl - The WebSocket server URL.
+ * @param {function} onMessage - Callback for received messages.
+ * @param {function} onOpen - Callback for connection open.
+ * @param {function} onClose - Callback for connection close.
+ * @param {function} onError - Callback for connection errors.
+ */
+ constructor(serverUrl, onOpen, onClose, onMessage, onError) {
+ this.serverUrl = serverUrl;
+ this.onMessageCallback = onMessage;
+ this.onOpenCallback = onOpen;
+ this.onCloseCallback = onClose;
+ this.onErrorCallback = onError;
+ this.socket = null;
+ this.heartbeatInterval = 10000; // 10 seconds
+ this.heartbeatTimer = null;
+ }
+
+ /**
+ * Establishes a WebSocket connection to the signaling server.
+ * Automatically starts the heartbeat mechanism when connected.
+ */
+ connect() {
+ this.socket = new WebSocket(this.serverUrl, "ws-signaling");
+
+ this.socket.onopen = () => {
+ this.startHeartbeat();
+
+ if (this.onOpenCallback) {
+ this.onOpenCallback();
+ }
+ };
+
+ this.socket.onmessage = (event) => {
+ console.log("Received message:", event.data);
+ const message = JSON.parse(event.data);
+
+ // Handle heartbeat response.
+ if (message.type === "heartbeat-ack") {
+ // Heartbeat acknowledged.
+ return;
+ }
+
+ if (this.onMessageCallback) {
+ this.onMessageCallback(message);
+ }
+ };
+
+ this.socket.onclose = () => {
+ this.stopHeartbeat();
+
+ if (this.onCloseCallback) {
+ this.onCloseCallback();
+ }
+ };
+
+ this.socket.onerror = (error) => {
+ this.stopHeartbeat();
+
+ if (this.onErrorCallback) {
+ this.onErrorCallback(error);
+ }
+ };
+ }
+
+ /**
+ * Starts the heartbeat mechanism to keep the WebSocket connection alive.
+ * Sends a heartbeat message every this.heartbeatInterval millisecond.
+ */
+ startHeartbeat() {
+ // Clear any existing timer.
+ this.stopHeartbeat();
+
+ this.heartbeatTimer = setInterval(() => {
+ if (this.isConnected()) {
+ this.send({
+ type: "heartbeat",
+ from: this.getUserId()
+ });
+ }
+ else {
+ this.stopHeartbeat();
+ }
+ }, this.heartbeatInterval);
+ }
+
+ // Helper method to get the current user ID
+ getUserId() {
+ // In a real application, this would be a unique identifier for the user
+ return "web-client";
+ }
+
+ /**
+ * Stops the heartbeat mechanism.
+ * Called automatically when the connection is closed or on error.
+ */
+ stopHeartbeat() {
+ if (this.heartbeatTimer) {
+ clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ }
+
+ send(message) {
+ if (this.isConnected()) {
+ this.socket.send(JSON.stringify(message));
+ }
+ else {
+ console.error("WebSocket is not open. Cannot send message:", message);
+ }
+ }
+
+ /**
+ * Closes the WebSocket connection and stops the heartbeat mechanism.
+ */
+ close() {
+ this.stopHeartbeat();
+
+ if (this.isConnected()) {
+ this.socket.close();
+ }
+ }
+
+ /**
+ * Checks if the WebSocket connection is currently open.
+ *
+ * @returns {boolean} True if the connection is open.
+ */
+ isConnected() {
+ return this.socket && this.socket.readyState === WebSocket.OPEN;
+ }
+}
+
+export { SignalingChannel };
\ No newline at end of file
diff --git a/webrtc-examples/src/main/resources/web/WebRTCClient.js b/webrtc-examples/src/main/resources/web/WebRTCClient.js
new file mode 100644
index 00000000..4bc5a683
--- /dev/null
+++ b/webrtc-examples/src/main/resources/web/WebRTCClient.js
@@ -0,0 +1,332 @@
+import { SignalingChannel } from './SignalingChannel.js';
+import { PeerConnectionManager } from './PeerConnectionManager.js';
+
+class WebRTCClient {
+
+ constructor() {
+ // Configuration.
+ this.serverUrl = "wss://localhost:8443/ws";
+ this.iceServers = [
+ { urls: "stun:stun.l.google.com:19302" }
+ ];
+
+ // DOM elements.
+ this.remoteVideo = document.getElementById("remoteVideo");
+ this.videoContainer = document.getElementById("videoContainer");
+ this.stopButton = document.getElementById("stopButton");
+ this.statusElement = document.getElementById("status");
+ this.participantsList = document.getElementById("participantsList");
+
+ // Initialize components.
+ this.signalingChannel = null;
+ this.peerConnectionManager = null;
+ this.participants = new Map();
+
+ // Set up event listeners.
+ this.stopButton.addEventListener("click", () => this.disconnect());
+
+ // Set the initial status.
+ this.setStatus("Click \"Start Connection\" to begin");
+
+ this.connect();
+ }
+
+ setStatus(status) {
+ this.statusElement.textContent = status;
+
+ console.log(status);
+ }
+
+ connect() {
+ this.setStatus("Connecting to the signaling server...");
+
+ // Initialize signaling channel.
+ this.signalingChannel = new SignalingChannel(
+ this.serverUrl,
+ () => this.handleSignalingOpen(),
+ () => this.handleSignalingClose(),
+ (message) => this.handleSignalingMessage(message),
+ (error) => this.handleSignalingError(error)
+ );
+
+ // Connect to the signaling server.
+ this.signalingChannel.connect();
+ }
+
+ disconnect() {
+ this.setStatus("Closing connection");
+
+ // Close peer connection
+ if (this.peerConnectionManager) {
+ this.peerConnectionManager.close();
+ this.peerConnectionManager = null;
+ }
+
+ // Close signaling channel
+ if (this.signalingChannel) {
+ this.signalingChannel.close();
+ this.signalingChannel = null;
+ }
+
+ // Clear remote video
+ if (this.remoteVideo.srcObject) {
+ this.remoteVideo.srcObject.getTracks().forEach(track => track.stop());
+ this.remoteVideo.srcObject = null;
+ }
+
+ // Hide video container
+ this.videoContainer.style.display = 'none';
+
+ // Clear participants list
+ this.participants.clear();
+ this.updateParticipantsList();
+
+ // Update UI
+ this.stopButton.disabled = true;
+ this.setStatus("Connection closed");
+ }
+
+ joinRoom(roomName) {
+ // First, send a join message to the room.
+ const joinMessage = {
+ type: "join",
+ from: this.peerConnectionManager.getUserId(),
+ data: {
+ room: roomName,
+ userInfo: {
+ name: "Web Client"
+ }
+ }
+ };
+ this.signalingChannel.send(joinMessage);
+ this.setStatus('Joining room: default-room');
+ }
+
+ handleSignalingOpen() {
+ this.setStatus("Connected to the signaling server");
+
+ // Initialize peer connection manager.
+ this.peerConnectionManager = new PeerConnectionManager(
+ this.iceServers,
+ (event) => this.handleTrack(event),
+ (candidate) => this.handleLocalCandidate(candidate),
+ (state) => this.handleIceConnectionStateChange(state),
+ (state) => this.handleConnectionStateChange(state)
+ );
+
+ // Add ourselves to the participant list.
+ const myUserId = this.peerConnectionManager.getUserId();
+ this.participants.set(myUserId, { name: "Me (Local)" });
+ this.updateParticipantsList();
+
+ this.joinRoom("default-room");
+
+ // Update UI.
+ this.stopButton.disabled = false;
+ }
+
+ handleSignalingMessage(message) {
+ switch(message.type) {
+ case 'answer':
+ this.handleAnswer(message);
+ break;
+ case 'ice-candidate':
+ this.handleRemoteCandidate(message);
+ break;
+ case 'offer':
+ this.handleOffer(message);
+ break;
+ case 'join':
+ this.handleUserJoined(message);
+ break;
+ case 'leave':
+ this.handleUserLeft(message);
+ break;
+ default:
+ console.log('Unknown message type:', message.type);
+ }
+ }
+
+ handleUserJoined(message) {
+ const userId = message.data.userInfo.userId;
+ this.setStatus('User joined: ' + userId);
+
+ // Add user to participants map if not already present.
+ if (!this.participants.has(userId)) {
+ const userInfo = message.data.userInfo || { userId: userId };
+ this.participants.set(userId, userInfo);
+
+ // Update the participant list in the UI.
+ this.updateParticipantsList();
+ }
+ }
+
+ handleUserLeft(message) {
+ const userId = message.from;
+ this.setStatus('User left: ' + userId);
+
+ // Remove user from participants map if present
+ if (this.participants.has(userId)) {
+ this.participants.delete(userId);
+
+ // Update the participant list in the UI
+ this.updateParticipantsList();
+ }
+ }
+
+ async handleOffer(message) {
+ this.setStatus('Received offer from ' + message.from);
+ console.log('Received offer:', message);
+
+ // Initialize WebRTC.
+ this.startWebRTC();
+
+ try {
+ // Set the offer to the peer connection.
+ await this.peerConnectionManager.handleOffer(message);
+ this.setStatus('Remote offer set successfully, creating answer');
+
+ // Create and send answer
+ const answer = await this.peerConnectionManager.createAnswer();
+ this.setStatus('Sending answer to ' + message.from);
+ this.signalingChannel.send(answer);
+ }
+ catch (error) {
+ this.setStatus('Failed to process offer: ' + error);
+ console.error('Error handling offer:', error);
+ }
+ }
+
+ updateParticipantsList() {
+ // Clear the current list
+ this.participantsList.innerHTML = '';
+
+ // Add each participant to the list
+ this.participants.forEach((userInfo, userId) => {
+ const listItem = document.createElement('li');
+ listItem.textContent = userInfo.name || userId;
+ listItem.setAttribute('data-user-id', userId);
+ this.participantsList.appendChild(listItem);
+ });
+ }
+
+ handleSignalingClose() {
+ this.setStatus('Disconnected from signaling server');
+ this.disconnect();
+ }
+
+ handleSignalingError(error) {
+ this.setStatus('Error connecting to signaling server');
+ console.error('WebSocket error:', error);
+ }
+
+ async startWebRTC() {
+ this.peerConnectionManager.initialize();
+
+ // Add local media if available
+ // const mediaAdded = await this.peerConnectionManager.addLocalMedia();
+ // if (!mediaAdded) {
+ // this.setStatus('Failed to access camera/microphone. Creating offer without local media.');
+ // }
+
+ // Create and send offer
+ // try {
+ // const offer = await this.peerConnectionManager.createOffer();
+ // this.setStatus('Sending offer to Java server');
+ // this.signalingChannel.send(offer);
+ // }
+ // catch (error) {
+ // this.setStatus('Failed to create offer: ' + error);
+ // }
+ }
+
+ handleTrack(event) {
+ this.setStatus('Received remote track');
+ console.log('Track event:', event);
+
+ if (event.streams && event.streams[0]) {
+ // If remoteVideo already has a srcObject, we need to add the new track to it
+ // instead of replacing the entire stream.
+ if (this.remoteVideo.srcObject) {
+ // Check if this track already exists in the current stream.
+ const existingTrack = this.remoteVideo.srcObject.getTracks().find(
+ track => track.id === event.track.id
+ );
+
+ // Only add the track if it doesn't already exist.
+ if (!existingTrack) {
+ // Add the new track to the existing stream.
+ this.remoteVideo.srcObject.addTrack(event.track);
+ this.setStatus(`Added ${event.track.kind} track to existing stream`);
+ }
+ }
+ else {
+ // First track, set the stream directly.
+ this.remoteVideo.srcObject = event.streams[0];
+ this.setStatus(`Set initial ${event.track.kind} stream`);
+ }
+
+ // Show the video container when we receive a track.
+ this.videoContainer.style.display = 'block';
+
+ // Ensure the video plays.
+ this.remoteVideo.play().catch(error => {
+ console.error('Error playing video:', error);
+ this.setStatus('Error playing video: ' + error.message);
+ });
+ }
+ else {
+ console.warn('Received track event without streams');
+ this.setStatus('Received track without stream data');
+ }
+ }
+
+ handleLocalCandidate(candidate) {
+ this.signalingChannel.send(candidate);
+ }
+
+ handleIceConnectionStateChange(state) {
+ this.setStatus('ICE connection state: ' + state);
+
+ // Hide video if connection is disconnected, failed, or closed
+ if (['disconnected', 'failed', 'closed'].includes(state)) {
+ this.videoContainer.style.display = 'none';
+ }
+ }
+
+ handleConnectionStateChange(state) {
+ this.setStatus('Connection state: ' + state);
+
+ // Show video only when connected
+ if (state === 'connected') {
+ this.videoContainer.style.display = 'block';
+ } else if (['disconnected', 'failed', 'closed'].includes(state)) {
+ this.videoContainer.style.display = 'none';
+ }
+ }
+
+ async handleAnswer(message) {
+ this.setStatus('Received answer from ' + message.from + ', setting remote description');
+
+ try {
+ await this.peerConnectionManager.handleAnswer(message);
+ this.setStatus('Remote description set successfully');
+ }
+ catch (error) {
+ this.setStatus('Failed to set remote description: ' + error);
+ }
+ }
+
+ async handleRemoteCandidate(message) {
+ try {
+ this.setStatus('Received ICE candidate from ' + message.from);
+
+ await this.peerConnectionManager.addIceCandidate(message);
+ }
+ catch (error) {
+ console.error("Error handling remote candidate:", error);
+ }
+ }
+}
+
+export { WebRTCClient };
\ No newline at end of file
diff --git a/webrtc-examples/src/main/resources/web/index.html b/webrtc-examples/src/main/resources/web/index.html
new file mode 100644
index 00000000..7ba5e44f
--- /dev/null
+++ b/webrtc-examples/src/main/resources/web/index.html
@@ -0,0 +1,158 @@
+
+
+
+
+
+ WebRTC Java - Browser Demo
+
+
+
+
+
+
WebRTC Java - Browser Demo
+
Not connected
+
+
+
+
+
+
+
Participants
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webrtc-examples/src/main/resources/web/main.js b/webrtc-examples/src/main/resources/web/main.js
new file mode 100644
index 00000000..3a456f7c
--- /dev/null
+++ b/webrtc-examples/src/main/resources/web/main.js
@@ -0,0 +1,6 @@
+import { WebRTCClient } from './WebRTCClient.js';
+
+// Initialize the WebRTC client when the page loads
+document.addEventListener("DOMContentLoaded", () => {
+ new WebRTCClient();
+});
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h
new file mode 100644
index 00000000..c9a2fd5b
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/include/JNI_CustomAudioSource.h
@@ -0,0 +1,29 @@
+/* DO NOT EDIT THIS FILE - it is machine generated */
+#include
+/* Header for class dev_onvoid_webrtc_media_audio_CustomAudioSource */
+
+#ifndef _Included_dev_onvoid_webrtc_media_audio_CustomAudioSource
+#define _Included_dev_onvoid_webrtc_media_audio_CustomAudioSource
+#ifdef __cplusplus
+extern "C" {
+#endif
+ /*
+ * Class: dev_onvoid_webrtc_media_audio_CustomAudioSource
+ * Method: initialize
+ * Signature: ()V
+ */
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_initialize
+ (JNIEnv *, jobject);
+
+ /*
+ * Class: dev_onvoid_webrtc_media_audio_CustomAudioSource
+ * Method: pushAudio
+ * Signature: ([BIIII)V
+ */
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudio
+ (JNIEnv *, jobject, jbyteArray, jint, jint, jint, jint);
+
+#ifdef __cplusplus
+}
+#endif
+#endif
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/include/media/SyncClock.h b/webrtc-jni/src/main/cpp/include/media/SyncClock.h
new file mode 100644
index 00000000..283a9338
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/include/media/SyncClock.h
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef JNI_WEBRTC_MEDIA_SYNC_CLOCK_H_
+#define JNI_WEBRTC_MEDIA_SYNC_CLOCK_H_
+
+#include "system_wrappers/include/clock.h"
+#include "system_wrappers/include/ntp_time.h"
+
+#include
+
+namespace jni
+{
+ // Synchronized Clock for A/V timing
+ class SyncClock
+ {
+ public:
+ SyncClock();
+
+ // Get current timestamp in microseconds
+ int64_t GetTimestampUs() const;
+
+ // Get current timestamp in milliseconds
+ int64_t GetTimestampMs() const;
+
+ // Get NTP timestamp for RTP synchronization
+ webrtc::NtpTime GetNtpTime() const;
+
+ private:
+ std::chrono::steady_clock::time_point start_time_;
+ };
+}
+#endif
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h
new file mode 100644
index 00000000..56679726
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/include/media/audio/CustomAudioSource.h
@@ -0,0 +1,61 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef JNI_WEBRTC_API_AUDIO_CUSTOM_AUDIO_SOURCE_H_
+#define JNI_WEBRTC_API_AUDIO_CUSTOM_AUDIO_SOURCE_H_
+
+#include "api/media_stream_interface.h"
+#include "rtc_base/ref_counted_object.h"
+
+#include "media/SyncClock.h"
+
+#include
+#include
+#include
+
+namespace jni
+{
+ class CustomAudioSource : public webrtc::AudioSourceInterface
+ {
+ public:
+ explicit CustomAudioSource(std::shared_ptr clock);
+
+ // AudioSourceInterface implementation
+ void RegisterObserver(webrtc::ObserverInterface * observer) override;
+ void UnregisterObserver(webrtc::ObserverInterface * observer) override;
+ void AddSink(webrtc::AudioTrackSinkInterface * sink) override;
+ void RemoveSink(webrtc::AudioTrackSinkInterface * sink) override;
+ SourceState state() const override;
+ bool remote() const override;
+
+ // Push audio data with synchronization
+ void PushAudioData(const void * audio_data, int bits_per_sample,
+ int sample_rate, size_t number_of_channels,
+ size_t number_of_frames);
+
+ // Set audio capture delay for synchronization adjustment
+ void SetAudioCaptureDelay(int64_t delay_us);
+
+ private:
+ std::vector sinks_;
+ std::shared_ptr clock_;
+ //webrtc::CriticalSection crit_;
+ std::atomic total_samples_captured_;
+ int64_t audio_capture_delay_us_;
+ };
+}
+
+#endif
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h
new file mode 100644
index 00000000..e6d923e7
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/include/media/video/CustomVideoSource.h
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef JNI_WEBRTC_MEDIA_VIDEO_CUSTOM_VIDEO_SOURCE_H_
+#define JNI_WEBRTC_MEDIA_VIDEO_CUSTOM_VIDEO_SOURCE_H_
+
+#include "api/video/video_frame.h"
+#include "api/video/video_source_interface.h"
+#include "media/base/adapted_video_track_source.h"
+#include "rtc_base/ref_counted_object.h"
+
+#include "media/SyncClock.h"
+
+#include
+
+namespace jni
+{
+ class CustomVideoSource : public webrtc::AdaptedVideoTrackSource
+ {
+ public:
+ explicit CustomVideoSource(std::shared_ptr clock);
+
+ // AdaptedVideoTrackSource implementation.
+ virtual bool is_screencast() const override;
+ virtual std::optional needs_denoising() const override;
+ SourceState state() const override;
+ bool remote() const override;
+
+ void PushFrame(const webrtc::VideoFrame & frame);
+
+ private:
+ std::shared_ptr clock_;
+ uint16_t frame_id_;
+ };
+}
+#endif
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp
new file mode 100644
index 00000000..a0dbca45
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/src/JNI_CustomAudioSource.cpp
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "JNI_CustomAudioSource.h"
+#include "JavaUtils.h"
+
+#include "media/audio/CustomAudioSource.h"
+
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_initialize
+(JNIEnv * env, jobject caller)
+{
+ std::shared_ptr sync_clock = std::make_shared();
+ webrtc::scoped_refptr source = webrtc::make_ref_counted(sync_clock);
+
+ SetHandle(env, caller, source.release());
+}
+
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_audio_CustomAudioSource_pushAudio
+(JNIEnv * env, jobject caller, jbyteArray audioData, jint bits_per_sample, jint sampleRate, jint channels, jint frameCount)
+{
+ jni::CustomAudioSource * source = GetHandle(env, caller);
+ CHECK_HANDLE(source);
+
+ jbyte * data = env->GetByteArrayElements(audioData, nullptr);
+ jsize length = env->GetArrayLength(audioData);
+
+ if (data != nullptr) {
+ source->PushAudioData(data, bits_per_sample, sampleRate, channels, frameCount);
+
+ env->ReleaseByteArrayElements(audioData, data, JNI_ABORT);
+ }
+}
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp b/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp
new file mode 100644
index 00000000..ecc1a915
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/src/media/SyncClock.cpp
@@ -0,0 +1,43 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "media/SyncClock.h"
+
+namespace jni
+{
+ SyncClock::SyncClock() :
+ start_time_(std::chrono::steady_clock::now())
+ {
+ }
+
+ int64_t SyncClock::GetTimestampUs() const
+ {
+ auto now = std::chrono::steady_clock::now();
+ auto duration = now - start_time_;
+ return std::chrono::duration_cast(duration).count();
+ }
+
+ int64_t SyncClock::GetTimestampMs() const
+ {
+ return GetTimestampUs() / 1000;
+ }
+
+ webrtc::NtpTime SyncClock::GetNtpTime() const
+ {
+ int64_t ntp_time_ms = webrtc::Clock::GetRealTimeClock()->CurrentNtpInMilliseconds();
+ return webrtc::NtpTime(ntp_time_ms);
+ }
+}
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp
new file mode 100644
index 00000000..0b90cbd6
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/src/media/audio/CustomAudioSource.cpp
@@ -0,0 +1,82 @@
+#include "media/audio/CustomAudioSource.h"
+
+#include
+#include
+
+namespace jni
+{
+ CustomAudioSource::CustomAudioSource(std::shared_ptr clock) :
+ clock_(clock),
+ total_samples_captured_(0),
+ audio_capture_delay_us_(0)
+ {
+ }
+
+ void CustomAudioSource::RegisterObserver(webrtc::ObserverInterface * observer)
+ {
+ // Not implemented - not needed for custom sources
+ }
+
+ void CustomAudioSource::UnregisterObserver(webrtc::ObserverInterface * observer)
+ {
+ // Not implemented - not needed for custom sources
+ }
+
+ void CustomAudioSource::AddSink(webrtc::AudioTrackSinkInterface * sink)
+ {
+ //webrtc::CritScope lock(&crit_);
+
+ sinks_.push_back(sink);
+ }
+
+ void CustomAudioSource::RemoveSink(webrtc::AudioTrackSinkInterface * sink)
+ {
+ //webrtc::CritScope lock(&crit_);
+
+ sinks_.erase(std::remove(sinks_.begin(), sinks_.end(), sink), sinks_.end());
+ }
+
+ webrtc::AudioSourceInterface::SourceState CustomAudioSource::state() const
+ {
+ return kLive;
+ }
+
+ bool CustomAudioSource::remote() const
+ {
+ return false;
+ }
+
+ void CustomAudioSource::PushAudioData(const void * audio_data, int bits_per_sample,
+ int sample_rate, size_t number_of_channels,
+ size_t number_of_frames)
+ {
+ //webrtc::CritScope lock(&crit_);
+
+ // Calculate absolute capture time
+ int64_t timestamp_us = clock_->GetTimestampUs();
+
+ // Apply delay if audio capture has inherent latency
+ timestamp_us -= audio_capture_delay_us_;
+
+ // Calculate NTP time for this audio frame
+ int64_t ntp_time_ms = clock_->GetNtpTime().ToMs();
+
+ // Create absolute capture time
+ absl::optional absolute_capture_time_ms = timestamp_us / 1000;
+
+ // Send to all sinks with timing information
+ for (auto * sink : sinks_) {
+ sink->OnData(audio_data, bits_per_sample, sample_rate,
+ number_of_channels, number_of_frames,
+ absolute_capture_time_ms);
+ }
+
+ // Update total samples for tracking
+ total_samples_captured_ += number_of_frames;
+ }
+
+ void CustomAudioSource::SetAudioCaptureDelay(int64_t delay_us)
+ {
+ audio_capture_delay_us_ = delay_us;
+ }
+}
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp
new file mode 100644
index 00000000..db1f979a
--- /dev/null
+++ b/webrtc-jni/src/main/cpp/src/media/video/CustomVideoSource.cpp
@@ -0,0 +1,68 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#include "media/video/CustomVideoSource.h"
+
+namespace jni
+{
+ CustomVideoSource::CustomVideoSource(std::shared_ptr clock) :
+ clock_(clock),
+ frame_id_(0)
+ {
+ }
+
+ bool CustomVideoSource::is_screencast() const
+ {
+ return false;
+ }
+
+ std::optional CustomVideoSource::needs_denoising() const
+ {
+ return false;
+ }
+
+ webrtc::MediaSourceInterface::SourceState CustomVideoSource::state() const
+ {
+ return kLive;
+ }
+
+ bool CustomVideoSource::remote() const
+ {
+ return false;
+ }
+
+ void CustomVideoSource::PushFrame(const webrtc::VideoFrame& frame)
+ {
+ // Create frame with proper timestamp
+ webrtc::VideoFrame timestamped_frame = frame;
+
+ // Use synchronized clock for timestamp
+ int64_t timestamp_us = clock_->GetTimestampUs();
+ timestamped_frame.set_timestamp_us(timestamp_us);
+
+ // Set RTP timestamp (90kHz clock)
+ uint32_t rtp_timestamp = static_cast((timestamp_us * 90) / 1000);
+ timestamped_frame.set_rtp_timestamp(rtp_timestamp);
+
+ // Set NTP time for synchronization
+ timestamped_frame.set_ntp_time_ms(clock_->GetNtpTime().ToMs());
+
+ // Increment frame ID
+ timestamped_frame.set_id(frame_id_++);
+
+ OnFrame(timestamped_frame);
+ }
+}
\ No newline at end of file
diff --git a/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp b/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp
index 6127c846..07c850de 100644
--- a/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp
+++ b/webrtc-jni/src/main/cpp/src/media/video/VideoTrackDesktopSource.cpp
@@ -98,19 +98,23 @@ namespace jni
FireOnChanged();
}
- bool VideoTrackDesktopSource::is_screencast() const {
+ bool VideoTrackDesktopSource::is_screencast() const
+ {
return true;
}
- std::optional VideoTrackDesktopSource::needs_denoising() const {
+ std::optional VideoTrackDesktopSource::needs_denoising() const
+ {
return false;
}
- webrtc::MediaSourceInterface::SourceState VideoTrackDesktopSource::state() const {
+ webrtc::MediaSourceInterface::SourceState VideoTrackDesktopSource::state() const
+ {
return sourceState;
}
- bool VideoTrackDesktopSource::remote() const {
+ bool VideoTrackDesktopSource::remote() const
+ {
return false;
}
diff --git a/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java
new file mode 100644
index 00000000..0461aa3d
--- /dev/null
+++ b/webrtc/src/main/java/dev/onvoid/webrtc/media/audio/CustomAudioSource.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2019 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.media.audio;
+
+/**
+ * Custom implementation of an audio source for WebRTC that allows pushing audio data
+ * from external sources directly to the WebRTC audio pipeline.
+ *
+ * @author Alex Andres
+ */
+public class CustomAudioSource extends AudioTrackSource {
+
+ /**
+ * Constructs a new CustomAudioSource instance.
+ */
+ public CustomAudioSource() {
+ super();
+
+ initialize();
+ }
+
+ /**
+ * Pushes audio data to be processed by this audio source.
+ *
+ * @param audioData The raw audio data bytes to process.
+ * @param bits_per_sample The number of bits per sample (e.g., 8, 16, 32).
+ * @param sampleRate The sample rate of the audio in Hz (e.g., 44100, 48000).
+ * @param channels The number of audio channels (1 for mono, 2 for stereo).
+ * @param frameCount The number of frames in the provided audio data.
+ */
+ public native void pushAudio(byte[] audioData, int bits_per_sample,
+ int sampleRate, int channels, int frameCount);
+
+ /**
+ * Initializes the native resources required by this audio source.
+ */
+ private native void initialize();
+
+}
diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java
new file mode 100644
index 00000000..a9e5db5e
--- /dev/null
+++ b/webrtc/src/test/java/dev/onvoid/webrtc/media/audio/CustomAudioSourceTest.java
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2025 Alex Andres
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package dev.onvoid.webrtc.media.audio;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import dev.onvoid.webrtc.TestBase;
+import dev.onvoid.webrtc.media.MediaSource;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+class CustomAudioSourceTest extends TestBase {
+
+ private CustomAudioSource customAudioSource;
+
+
+ @BeforeEach
+ void init() {
+ customAudioSource = new CustomAudioSource();
+ }
+
+ @AfterEach
+ void dispose() {
+
+ }
+
+ @Test
+ void stateAfterCreation() {
+ assertEquals(MediaSource.State.LIVE, customAudioSource.getState());
+ }
+
+ @Test
+ void addNullSink() {
+ assertThrows(NullPointerException.class, () -> {
+ AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource);
+ audioTrack.addSink(null);
+ audioTrack.dispose();
+ });
+ }
+
+ @Test
+ void removeNullSink() {
+ assertThrows(NullPointerException.class, () -> {
+ AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource);
+ audioTrack.removeSink(null);
+ audioTrack.dispose();
+ });
+ }
+
+ @Test
+ void addRemoveSink() {
+ AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource);
+ AudioTrackSink sink = (data, bitsPerSample, sampleRate, channels, frames) -> { };
+
+ audioTrack.addSink(sink);
+ audioTrack.removeSink(sink);
+ audioTrack.dispose();
+ }
+
+ @Test
+ void pushAudioData() {
+ // 16-bit, 48kHz, stereo, 10ms
+ testAudioFormat(16, 48000, 2, 480);
+ }
+
+ @Test
+ void pushAudioWithDifferentFormats() {
+ testAudioFormat(8, 8000, 1, 80); // 8-bit, 8kHz, mono, 10ms
+ testAudioFormat(16, 16000, 1, 160); // 16-bit, 16kHz, mono, 10ms
+ testAudioFormat(16, 44100, 2, 441); // 16-bit, 44.1kHz, stereo, 10ms
+ testAudioFormat(16, 48000, 2, 480); // 16-bit, 48kHz, stereo, 10ms
+ }
+
+ private void testAudioFormat(int bitsPerSample, int sampleRate, int channels, int frameCount) {
+ AudioTrack audioTrack = factory.createAudioTrack("audioTrack", customAudioSource);
+
+ final AtomicBoolean dataReceived = new AtomicBoolean(false);
+ final AtomicInteger receivedBitsPerSample = new AtomicInteger(0);
+ final AtomicInteger receivedSampleRate = new AtomicInteger(0);
+ final AtomicInteger receivedChannels = new AtomicInteger(0);
+ final AtomicInteger receivedFrames = new AtomicInteger(0);
+
+ AudioTrackSink testSink = (data, bits, rate, chans, frames) -> {
+ dataReceived.set(true);
+ receivedBitsPerSample.set(bits);
+ receivedSampleRate.set(rate);
+ receivedChannels.set(chans);
+ receivedFrames.set(frames);
+ };
+
+ audioTrack.addSink(testSink);
+
+ // Create a buffer with test audio data (silence in this case).
+ int bytesPerSample = bitsPerSample / 8;
+ byte[] audioData = new byte[frameCount * channels * bytesPerSample];
+
+ customAudioSource.pushAudio(audioData, bitsPerSample, sampleRate, channels, frameCount);
+
+ // Verify that our sink received the data with correct parameters.
+ assertTrue(dataReceived.get(), "Audio data was not received by the sink");
+ assertEquals(bitsPerSample, receivedBitsPerSample.get(), "Bits per sample doesn't match");
+ assertEquals(sampleRate, receivedSampleRate.get(), "Sample rate doesn't match");
+ assertEquals(channels, receivedChannels.get(), "Channel count doesn't match");
+ assertEquals(frameCount, receivedFrames.get(), "Frame count doesn't match");
+
+ // Clean up.
+ audioTrack.removeSink(testSink);
+ audioTrack.dispose();
+ }
+}
\ No newline at end of file