From 864c171a6647d391f67911dd83f36b61e200c415 Mon Sep 17 00:00:00 2001 From: Alex Andres Date: Thu, 22 Oct 2026 00:18:12 +0200 Subject: [PATCH] Add a JavaFX camera example on the client API --- design/client-api.md | 2 +- docs/guide/client-api.md | 2 +- docs/guide/examples.md | 16 ++ webrtc-examples/pom.xml | 12 + .../webrtc/examples/JavaFxCameraExample.java | 242 ++++++++++++++++++ .../src/main/java/module-info.java | 2 + 6 files changed, 274 insertions(+), 2 deletions(-) create mode 100644 webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/JavaFxCameraExample.java diff --git a/design/client-api.md b/design/client-api.md index 1665aa103..49c20e196 100644 --- a/design/client-api.md +++ b/design/client-api.md @@ -810,7 +810,7 @@ Next: executor, where key frame requests run too, and do nothing once the sender or receiver is gone. - `VideoView` for JavaFX lives in the `webrtc-java-javafx` module, package `dev.onvoid.webrtc.client.javafx`, compiled for Java 11 since OpenJFX is; JavaFX is an optional - dependency the application brings. + dependency the application brings. `JavaFxCameraExample` in `webrtc-examples` uses it. - phase 2 is done: `Whip.to(...).publish(rtc)` and `Whep.from(...).play(rtc)` return sessions over `HttpURLConnection` (Java 8), and `VideoView` is in `dev.onvoid.webrtc.client.swing`; diff --git a/docs/guide/client-api.md b/docs/guide/client-api.md index 1f52220cc..269957571 100644 --- a/docs/guide/client-api.md +++ b/docs/guide/client-api.md @@ -265,7 +265,7 @@ peer.onTrack(track -> { stage.setScene(new Scene(new StackPane(remoteView), 960, 540)); ``` -The view converts frames on their own thread and paints at most once per JavaFX pulse. `setMirrored(true)`, or its `mirroredProperty()`, flips a camera preview, and `snapshot()`, on the JavaFX Application Thread, returns a copy of the picture. +The view converts frames on their own thread and paints at most once per JavaFX pulse. `setMirrored(true)`, or its `mirroredProperty()`, flips a camera preview, and `snapshot()`, on the JavaFX Application Thread, returns a copy of the picture. The `JavaFxCameraExample` in the [examples](/guide/examples#javafx-camera) shows a preview and a received track side by side. ### Don't Wait in a Handler diff --git a/docs/guide/examples.md b/docs/guide/examples.md index 377778764..53a904bd6 100644 --- a/docs/guide/examples.md +++ b/docs/guide/examples.md @@ -122,6 +122,22 @@ mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFilePlayerExampl ``` ::: +## JavaFX Camera + +The [`JavaFxCameraExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/JavaFxCameraExample.java) is a JavaFX application built on the [Client API](/guide/client-api). It sends the camera from one peer to another in the same process, and shows the camera's preview beside the video that arrives. + +**Key features demonstrated:** +- Running the peers' handlers on the JavaFX Application Thread, with `Platform::runLater` as the event executor +- Showing a local preview and a remote track in the JavaFX `VideoView` of the `webrtc-java-javafx` module +- Mirroring the preview through the view's `mirroredProperty()` +- Switching the camera while it is being sent, without renegotiating +- Showing the connection state, and the encoded resolution and frame rate read from `onStats` +- Closing the runtime off the JavaFX thread before JavaFX exits + +```bash +mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.JavaFxCameraExample" +``` + ## Encrypted Recording The [`EncryptedRecordingExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/EncryptedRecordingExample.java) encrypts a call end to end with encoded frame transforms, and records what the receiving side decrypted into a media file. See the [Encoded Transforms](/guide/advanced/encoded-transforms) and [Media Recording](/guide/media/media-recording) guides for the APIs it uses. diff --git a/webrtc-examples/pom.xml b/webrtc-examples/pom.xml index 8e2ef039b..c8a773a89 100644 --- a/webrtc-examples/pom.xml +++ b/webrtc-examples/pom.xml @@ -17,6 +17,8 @@ true UTF-8 12.1.14 + + 17.0.20 @@ -91,6 +93,16 @@ webrtc-java-media ${project.version} + + ${project.groupId} + webrtc-java-javafx + ${project.version} + + + org.openjfx + javafx-controls + ${javafx.version} + org.eclipse.jetty diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/JavaFxCameraExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/JavaFxCameraExample.java new file mode 100644 index 000000000..8a8a2f25c --- /dev/null +++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/JavaFxCameraExample.java @@ -0,0 +1,242 @@ +/* + * Copyright 2026 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; + +import java.time.Duration; +import java.util.List; + +import javafx.application.Platform; +import javafx.geometry.Insets; +import javafx.geometry.Pos; +import javafx.scene.Scene; +import javafx.scene.control.CheckBox; +import javafx.scene.control.ComboBox; +import javafx.scene.control.Label; +import javafx.scene.layout.BorderPane; +import javafx.scene.layout.HBox; +import javafx.scene.layout.Priority; +import javafx.scene.layout.VBox; +import javafx.stage.Stage; +import javafx.util.StringConverter; + +import dev.onvoid.webrtc.client.AudioMode; +import dev.onvoid.webrtc.client.LocalCamera; +import dev.onvoid.webrtc.client.MediaException; +import dev.onvoid.webrtc.client.Peer; +import dev.onvoid.webrtc.client.PeerOptions; +import dev.onvoid.webrtc.client.Signaling; +import dev.onvoid.webrtc.client.SignalingPair; +import dev.onvoid.webrtc.client.VideoStats; +import dev.onvoid.webrtc.client.WebRtc; +import dev.onvoid.webrtc.client.javafx.VideoView; +import dev.onvoid.webrtc.media.video.VideoDevice; + +/** + * Example of the client API in a JavaFX application: a camera is sent from one peer to another + * in the same process, and both its preview and the received video are shown. + *

+ * This example shows how to: + *

    + *
  • Run every handler of a {@link WebRtc} runtime on the JavaFX Application Thread
  • + *
  • Show a local camera preview and a remote track in the JavaFX {@link VideoView}
  • + *
  • Mirror the preview through the view's {@code mirrored} property
  • + *
  • Switch the camera while it is being sent, without renegotiating
  • + *
  • Show the connection state and the encoded resolution and frame rate
  • + *
+ *

+ * Both peers run in this process, connected through a loopback signaling. In a real application + * each peer is in another process, and its {@link Signaling} carries the messages over a + * WebSocket or the like. + *

+ * Run it with a camera connected: + *

+ * mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.JavaFxCameraExample"
+ * 
+ * + * @author Alex Andres + */ +public class JavaFxCameraExample { + + private final VideoView preview = new VideoView(); + private final VideoView remoteView = new VideoView(); + private final ComboBox cameraBox = new ComboBox<>(); + private final CheckBox mirrorBox = new CheckBox("Mirror preview"); + private final Label stateLabel = new Label("Starting"); + private final Label statsLabel = new Label(); + + private WebRtc rtc; + + + public static void main(String[] args) { + // Started without an Application subclass, so that it also runs with JavaFX on the + // class path, as exec:java puts it. + Platform.startup(() -> new JavaFxCameraExample().show(new Stage())); + } + + private void show(Stage stage) { + // Only video is sent, so the runtime needs no audio devices. Every handler and every + // future of a peer runs on the JavaFX Application Thread, so they may change the scene. + rtc = WebRtc.builder() + .audio(AudioMode.HEADLESS) + .eventExecutor(Platform::runLater) + .build(); + + stage.setTitle("JavaFX Camera"); + stage.setScene(new Scene(layout(), 1000, 480)); + stage.setOnCloseRequest(event -> exit()); + stage.show(); + + // The runtime is closed before JavaFX exits. + Platform.setImplicitExit(false); + + List cameras = rtc.media().devices().cameras(); + + if (cameras.isEmpty()) { + stateLabel.setText("No camera found"); + return; + } + + LocalCamera camera; + + try { + camera = rtc.media().camera(); + } + catch (MediaException e) { + stateLabel.setText("The camera could not be opened: " + e.getMessage()); + return; + } + + // The preview gets the camera's frames before they are encoded. + camera.onFrame(preview); + + call(camera); + + cameraBox.getItems().setAll(cameras); + cameraBox.getSelectionModel().select(camera.device()); + cameraBox.setDisable(cameras.size() < 2); + cameraBox.setOnAction(event -> switchTo(camera, cameraBox.getValue())); + } + + /** + * Sends the camera from one peer to the other, and shows what arrives. + */ + private void call(LocalCamera camera) { + SignalingPair signaling = Signaling.loopback(); + + Peer caller = rtc.createPeer(PeerOptions.builder() + .name("caller") + .signaling(signaling.first()) + .build()); + Peer callee = rtc.createPeer(PeerOptions.builder() + .name("callee") + .polite(true) + .signaling(signaling.second()) + .build()); + + // Both handlers run on the JavaFX Application Thread. + caller.onStateChange(state -> stateLabel.setText("Caller is " + state)); + caller.onStats(Duration.ofSeconds(1), stats -> { + if (!stats.outboundVideo().isEmpty()) { + VideoStats video = stats.outboundVideo().get(0); + + statsLabel.setText(String.format("Sending %s %dx%d at %.0f fps, %d kbit/s", video.codec(), + video.width(), video.height(), video.framesPerSecond(), + Math.max(0, stats.outgoingBitrate()) / 1000)); + } + }); + + callee.onTrack(track -> { + if (track.isVideo()) { + // Frames are converted on the track's frame thread; the view only paints on the + // JavaFX Application Thread. + track.asVideo().onFrame(remoteView); + track.onEnded(remoteView::clear); + } + }); + + caller.addTrack(camera); + } + + private void switchTo(LocalCamera camera, VideoDevice device) { + if (device == null || device.equals(camera.device())) { + return; + } + + cameraBox.setDisable(true); + stateLabel.setText("Switching to " + device.getName()); + + // The peers keep sending, and the preview keeps showing, the new camera. The future + // completes on a background thread. + camera.switchCamera(device).whenComplete((done, error) -> Platform.runLater(() -> { + cameraBox.setDisable(false); + + if (error != null) { + stateLabel.setText("Switching failed: " + error.getMessage()); + cameraBox.getSelectionModel().select(camera.device()); + } + else { + stateLabel.setText("Sending " + camera.device().getName()); + } + })); + } + + private BorderPane layout() { + preview.setMirrored(true); + mirrorBox.selectedProperty().bindBidirectional(preview.mirroredProperty()); + + cameraBox.setConverter(new StringConverter() { + + @Override + public String toString(VideoDevice device) { + // Two sensors of one camera can share a name. + int index = cameraBox.getItems().indexOf(device); + + return device == null ? "" : (index + 1) + ": " + device.getName(); + } + + @Override + public VideoDevice fromString(String text) { + return null; + } + }); + + HBox views = new HBox(8, titled("Camera", preview), titled("Received", remoteView)); + views.setPadding(new Insets(8)); + + HBox controls = new HBox(12, new Label("Camera:"), cameraBox, mirrorBox, stateLabel, statsLabel); + controls.setAlignment(Pos.CENTER_LEFT); + controls.setPadding(new Insets(0, 8, 8, 8)); + + BorderPane root = new BorderPane(views); + root.setBottom(controls); + + return root; + } + + private static VBox titled(String title, VideoView view) { + VBox box = new VBox(4, new Label(title), view); + VBox.setVgrow(view, Priority.ALWAYS); + HBox.setHgrow(box, Priority.ALWAYS); + + return box; + } + + private void exit() { + // Closing waits for the peers and the camera, so it happens off the JavaFX thread. + rtc.closeAsync().whenComplete((done, error) -> Platform.exit()); + } +} diff --git a/webrtc-examples/src/main/java/module-info.java b/webrtc-examples/src/main/java/module-info.java index 8f970f32d..d01dc012b 100644 --- a/webrtc-examples/src/main/java/module-info.java +++ b/webrtc-examples/src/main/java/module-info.java @@ -4,9 +4,11 @@ requires java.desktop; requires java.logging; requires java.net.http; + requires javafx.controls; requires org.eclipse.jetty.server; requires org.eclipse.jetty.websocket.server; requires webrtc.java; + requires webrtc.java.javafx; requires webrtc.java.media; exports dev.onvoid.webrtc.examples.web.client;