Skip to content

feat: add high-level client API - #397

Open
devopvoid wants to merge 25 commits into
mainfrom
feat/client-api
Open

devopvoid wants to merge 25 commits into
mainfrom
feat/client-api

Conversation

@devopvoid

@devopvoid devopvoid commented Oct 18, 2026 •

Copy link
Copy Markdown
Owner

Adds dev.onvoid.webrtc.client, a pure Java layer over the existing API:

  • A WebRtc runtime that owns the factory and everything created through it; closing it closes all in order. No dispose() in the API.
  • Peers negotiate by themselves with perfect negotiation, through a pluggable Signaling and browser-compatible JSON messages. Manual negotiation for SFUs, with ICE gathering bounded by a timeout.
  • One event thread per peer for handlers and future callbacks, never a WebRTC thread. Waiting on a future from an event thread throws BlockingCallException instead of deadlocking.
  • Local media (camera, microphone, screen, pushed audio and video, tracks over existing sources such as a MediaFileSource, switching cameras), remote tracks with frame drop policies, data channels with backpressure, DTMF, stats.
  • Whip and Whep sessions over HTTP, VideoView (Swing) in dev.onvoid.webrtc.client.swing, and recording client API senders and remote tracks with the media module's MediaRecorder.

No native changes. Also in this PR:

  • Design doc (design/client-api.md), a guide page, the get-started guide opening with the client API, and a README entry.
  • ClientApiExample; the web and WHEP examples ported to the client API.
  • fix: the web example server failed every request since the Jetty 12.1 bump (build(deps): bump the maven-dependencies group with 3 updates #370); its handlers now sit in a root context.

Adds dev.onvoid.webrtc.client: a WebRtc runtime that owns the factory and
everything created through it, peers with built-in perfect negotiation,
futures instead of nested observers, and one event thread per peer that
never runs on a WebRTC thread. Pure Java, no native changes.
The guide covers the runtime, signaling and perfect negotiation, threads,
media, data channels, manual negotiation, stats and closing. The example
connects two peers in one process without hardware or a server.
A video sink owns the reference to each frame it gets. The dispatcher
retained it once more, so every delivered frame leaked, and frames that
arrived after a subscription closed were never released.
Jetty 12.1 needs a context around the handlers; without one every page and
WebSocket request failed with a NullPointerException. The WebSocket upgrade
handler now wraps the static content inside a root context.
The Java client is a peer of the client API, with SignalingManager as its
Signaling over the example's own message format. A new peer calls the
browser whenever it joins. WebSocket sends are queued, since a WebSocket
takes one at a time, and the browser reuses its connection when the Java
side renegotiates.
The test asserted more than 10 fps, which the H.264 encoder on macOS arm64
had not ramped up to yet. It now checks that the decoder gets well ahead of
the slow handler within the same time.
An unreachable STUN or TURN server held up the end of gathering past the
operation timeout, so Gathering.COMPLETE failed on networks that block UDP.
It now waits at most PeerOptions.gatheringTimeout, 3 seconds by default,
and goes with the candidates gathered by then.
It negotiates by hand with complete gathering, receives video and audio,
and ends the session with a DELETE of the resource the server created.
A first call with the client API comes first; the core API walkthrough
follows unchanged. The introduction names both APIs.
Sender.sendTones completes once the tones have been sent, and onTone reports
each tone on the peer's event thread. Tones still queued are replaced, and
the earlier future is cancelled.
Messages that arrive while a channel has no message handler are kept up to
16 MiB; past that the oldest are dropped, and the peer's error handlers
hear of it once.
Both peers created a channel at once, but one offer could arrive before the
other was made, so nothing collided and the impolite test timed out. A test
signaling now holds the messages until both peers have sent an offer.
It converts frames on their own thread, paints on the EDT, keeps the aspect
ratio and rotation, and can mirror a local preview.
Whip.to(endpoint).publish(rtc) and Whep.from(endpoint).play(rtc) negotiate a
peer over HTTP and complete once it is connected. Closing a session deletes
it on the server; a failed setup leaves nothing behind. The WHEP example
uses Whep now.
A native crash ends the test JVM with an hs_err report beside the module,
which the upload left out. The client tests also note each test in the
surefire reports before it starts, so the last line names the one that ran.
A peer whose Signaling.attach failed was closed in the background and
dropped from the runtime, so closing the runtime could dispose the factory,
and WebRTC's threads with it, while that peer's close() still ran on them.
The macOS runners crashed in RTCPeerConnection.close() that way.

createPeer now closes a failed peer before it throws. Closing the runtime
waits until no peer or track is left, and leaves the factory undisposed
if something did not close in time.
LocalMedia.videoTrack and audioTrack wrap an existing source, such as those
a MediaFileSource of the media module feeds. The source stays the caller's
and is not disposed with the track.
WebRtc.close() returns once every peer is closed; the whenClosed futures
complete afterwards on the peers' event threads, which a slow macOS runner
had not reached yet. The test now waits for them, and the javadoc and the
guide say what close() guarantees.
The tone went to the event executor straight from WebRTC's thread, and the
future through the control executor, so on a fast control thread the future
could complete first. Both now go through the control executor, in order.
The guide's media file snippet now runs as a test of the media module. It
also showed that the runtime has to exist before the MediaFileSource, since
the runtime loads the native library the file's sources need; the guide
says so now.
camera() returns a LocalCamera, whose switchCamera puts a new native track
over the other camera in place of the old one, on every sender, without
renegotiating. Cameras that cannot capture at once, like the two sensors of
a laptop camera, are handed over by closing the old one first, and a failed
switch keeps the camera the track had. Tested with a real camera; the test
skips without one.
MediaRecorder.addTrack takes a Sender or a RemoteTrack. The client API hands
the media module their encoded frames through a package exported to it
alone, taken on the peer's control executor, where key frames are asked for
too; once the sender or receiver is gone those requests do nothing, so
closing the peer mid-recording just ends the track in the file.
Without PulseAudio, reading the audio devices and watching for changes threw,
so devices() failed, and with it camera(). A list the system cannot read is
empty now, and without change reports the lists are read on each call.
macOS negotiates H.264 first, which the test did not expect, and which WebM
cannot hold. The test records into Matroska and checks for video of the
sent size, whatever its codec.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant