diff --git a/.github/actions/build/action.yml b/.github/actions/build/action.yml
index a9f167c5..99ca8e90 100644
--- a/.github/actions/build/action.yml
+++ b/.github/actions/build/action.yml
@@ -34,6 +34,17 @@ runs:
key: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}-${{ hashFiles('webrtc-jni/pom.xml') }}
restore-keys: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}-
+ - name: Derive the FFmpeg version from the pom
+ run: echo "FFMPEG_VERSION=$(sed -n 's|.*\([^<]*\).*|\1|p' webrtc-java-media/pom.xml)" >> "$GITHUB_ENV"
+ shell: bash
+
+ - name: Set up FFmpeg cache
+ uses: actions/cache@v4
+ with:
+ path: ~/ffmpeg
+ key: ffmpeg-${{ env.FFMPEG_VERSION }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt') }}-${{ hashFiles('webrtc-java-media/pom.xml') }}
+ restore-keys: ffmpeg-${{ env.FFMPEG_VERSION }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt') }}-
+
- name: Set up Maven cache
uses: actions/cache@v4
with:
diff --git a/.github/actions/prepare-linux/action.yml b/.github/actions/prepare-linux/action.yml
index 6fbf9f30..23f07c40 100644
--- a/.github/actions/prepare-linux/action.yml
+++ b/.github/actions/prepare-linux/action.yml
@@ -31,7 +31,8 @@ runs:
- name: Install required packages
run: |
sudo apt update
- sudo apt install -y binutils cmake git locales lsb-release ninja-build pipewire pipewire-pulse pkg-config python3 python3-setuptools rsync unzip wget xz-utils
+ # nasm is what FFmpeg assembles its x86 code with.
+ sudo apt install -y binutils cmake git locales lsb-release nasm ninja-build pipewire pipewire-pulse pkg-config python3 python3-setuptools rsync unzip wget xz-utils
# Chromium Clang to be used with the clang toolchain file
#curl -s https://raw.githubusercontent.com/chromium/chromium/main/tools/clang/scripts/update.py | python3 - --output-dir=/opt/clang
diff --git a/.github/actions/prepare-macos/action.yml b/.github/actions/prepare-macos/action.yml
index 818e04ea..5ef69300 100644
--- a/.github/actions/prepare-macos/action.yml
+++ b/.github/actions/prepare-macos/action.yml
@@ -9,6 +9,9 @@ runs:
run: |
# Required on macos-14
brew install python-setuptools
+ # nasm is what FFmpeg assembles its x86 code with, which the Intel
+ # build needs even though the runner itself is Apple Silicon.
+ brew install nasm
shell: bash
- name: Select Xcode version
diff --git a/.github/actions/prepare-windows/action.yml b/.github/actions/prepare-windows/action.yml
index ff9b023a..dd0e96a5 100644
--- a/.github/actions/prepare-windows/action.yml
+++ b/.github/actions/prepare-windows/action.yml
@@ -38,3 +38,16 @@ runs:
run: |
choco install ninja
shell: powershell
+
+ - name: Install MSYS2 and the FFmpeg build tools
+ run: |
+ # FFmpeg's configure and makefiles are shell scripts, so building it
+ # needs a POSIX shell even with MSVC, and that shell has to carry make
+ # and nasm. Git for Windows' bash carries neither.
+ if (-not (Test-Path "C:\msys64\usr\bin\bash.exe")) {
+ choco install msys2 --no-progress -y
+ }
+ C:\msys64\usr\bin\bash.exe -lc "pacman -Sy --noconfirm && pacman -S --needed --noconfirm make nasm diffutils pkgconf"
+ # Where the media module's CMake looks for it.
+ "MSYS2_ROOT=C:\msys64" | Out-File -FilePath $env:GITHUB_ENV -Append -Encoding utf8
+ shell: powershell
diff --git a/.github/actions/release/action.yml b/.github/actions/release/action.yml
index fa20ed67..0c6fe4af 100644
--- a/.github/actions/release/action.yml
+++ b/.github/actions/release/action.yml
@@ -25,6 +25,17 @@ runs:
key: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}-${{ hashFiles('webrtc-jni/pom.xml') }}
restore-keys: webrtc-${{ env.WEBRTC_CACHE_BRANCH }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-jni/src/main/cpp/dependencies/webrtc/CMakeLists.txt') }}-
+ - name: Derive the FFmpeg version from the pom
+ run: echo "FFMPEG_VERSION=$(sed -n 's|.*\([^<]*\).*|\1|p' webrtc-java-media/pom.xml)" >> "$GITHUB_ENV"
+ shell: bash
+
+ - name: Set up FFmpeg cache
+ uses: actions/cache@v4
+ with:
+ path: ~/ffmpeg
+ key: ffmpeg-${{ env.FFMPEG_VERSION }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt') }}-${{ hashFiles('webrtc-java-media/pom.xml') }}
+ restore-keys: ffmpeg-${{ env.FFMPEG_VERSION }}-${{ inputs.platform-name }}-${{ hashFiles('webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt') }}-
+
- name: Set up Maven cache
uses: actions/cache@v4
with:
diff --git a/.github/actions/test-natives/action.yml b/.github/actions/test-natives/action.yml
index 3866de00..02459615 100644
--- a/.github/actions/test-natives/action.yml
+++ b/.github/actions/test-natives/action.yml
@@ -76,13 +76,50 @@ runs:
- name: Install the natives into the local repository
run: |
VERSION=$(sed -n '0,//s|.*\([^<]*\).*|\1|p' pom.xml)
- JAR=$(ls natives/webrtc-java-*.jar | head -n 1)
+ JAR=$(find natives -path '*webrtc-jni*' -name 'webrtc-java-*.jar' | head -n 1)
echo "Installing $JAR as webrtc-java:$VERSION:${{ inputs.classifier }}"
mvn -B -q install:install-file -Dfile="$JAR" -DgroupId=dev.onvoid.webrtc \
-DartifactId=webrtc-java -Dversion="$VERSION" -Dpackaging=jar \
-Dclassifier=${{ inputs.classifier }}
shell: bash
+ # Both native libraries go into the directory the media module's tests
+ # already read from, which is where its own build collects them. That is
+ # what puts them on the class path, and it is also what makes the reactor
+ # below work: webrtc-java's natives are a classifier artifact of a module
+ # that is in that reactor, and Maven answers a dependency on such a thing
+ # with the module's output directory, which holds no native library at
+ # all. Coming from a directory instead, they are simply found.
+ - name: Unpack the natives where the tests look for them
+ run: |
+ MEDIA_JAR=$(find natives -name 'webrtc-java-media-*-${{ inputs.classifier }}.jar' | head -n 1)
+ WEBRTC_JAR=$(find natives -path '*webrtc-jni*' -name 'webrtc-java-*.jar' | head -n 1)
+
+ if [ -z "$MEDIA_JAR" ]; then
+ echo "No webrtc-java-media natives for ${{ inputs.classifier }} in the artifact" >&2
+ exit 1
+ fi
+
+ MEDIA_JAR="$PWD/$MEDIA_JAR"
+ WEBRTC_JAR="$PWD/$WEBRTC_JAR"
+
+ mkdir -p webrtc-java-media/target/natives
+ cd webrtc-java-media/target/natives
+ # jar rather than unzip: the JDK is set up above, and unzip is not on
+ # every runner image.
+ "$JAVA_HOME/bin/jar" xf "$MEDIA_JAR"
+ "$JAVA_HOME/bin/jar" xf "$WEBRTC_JAR"
+ rm -rf META-INF
+ ls -l
+ shell: bash
+
+ # One reactor for both, so that webrtc-java-media resolves webrtc-java
+ # from it. Testing rather than installing: webrtc attaches the host
+ # natives from webrtc-jni/target during its package phase, and on a runner
+ # that only downloaded them there is no such directory.
+ #
+ # The native build is off: these natives were cross compiled by another
+ # job, and this runner is here to run them, not to build them again.
- name: Test
- run: mvn -B -pl webrtc test -P${{ inputs.profile }}
+ run: mvn -B -pl webrtc,webrtc-java-media test -DskipNativeBuild -P${{ inputs.profile }}
shell: bash
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 5c63b2fe..4de02cd7 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -34,6 +34,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- id: prepare
name: Prepare build
@@ -53,7 +56,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: natives-${{ matrix.platform.name }}
- path: webrtc-jni/target/webrtc-java-*.jar
+ path: |
+ webrtc-jni/target/webrtc-java-*.jar
+ webrtc-java-media/target/webrtc-java-media-*.jar
if-no-files-found: error
build-linux:
@@ -72,6 +77,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- id: prepare
name: Prepare build
@@ -91,7 +99,9 @@ jobs:
uses: actions/upload-artifact@v4
with:
name: natives-${{ matrix.platform.name }}
- path: webrtc-jni/target/webrtc-java-*.jar
+ path: |
+ webrtc-jni/target/webrtc-java-*.jar
+ webrtc-java-media/target/webrtc-java-media-*.jar
if-no-files-found: error
build-macos:
@@ -108,6 +118,9 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v6
+ with:
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- id: prepare-build
name: Prepare build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 0684e52f..33085969 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -148,6 +148,8 @@ jobs:
uses: actions/checkout@v6
with:
fetch-tags: true
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- name: Get tag from current branch
id: tag-selector
@@ -189,6 +191,8 @@ jobs:
uses: actions/checkout@v6
with:
fetch-tags: true
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- name: Get tag from current branch
id: tag-selector
@@ -227,6 +231,8 @@ jobs:
uses: actions/checkout@v6
with:
fetch-tags: true
+ # The media module builds FFmpeg from its third-party submodule.
+ submodules: true
- name: Get tag from current branch
id: tag-selector
diff --git a/NOTICE b/NOTICE
index d8f064b2..b726bb05 100644
--- a/NOTICE
+++ b/NOTICE
@@ -6,6 +6,19 @@ EXTERNAL COMPONENTS
This product bundles WebRTC, which is available under a "3-clause BSD" license.
For details, see https://webrtc.github.io/webrtc-org/license.
+WebRTC is linked statically into the webrtc-java platform jars, together with
+the third-party code it builds in, among them libvpx, libyuv, OpenH264, libaom,
+dav1d, Opus, BoringSSL, Abseil and, for H.264 decoding, Chromium's FFmpeg under
+the GNU Lesser General Public License version 2.1 or later. Each platform jar
+carries their licenses and WebRTC's patent grant under META-INF/licenses/webrtc.
+
+The webrtc-java-media platform jars bundle FFmpeg (https://ffmpeg.org), which is
+available under the GNU Lesser General Public License version 2.1 or later. It is
+built from an unmodified FFmpeg release, without --enable-gpl and without
+--enable-nonfree, and linked dynamically, so it can be replaced. Each platform
+jar carries the license text and a notice naming the FFmpeg version and where
+its source is, under META-INF/licenses/ffmpeg.
+
DOCUMENTATION
WebRTC 1.0: Real-time Communication Between Browsers
diff --git a/README.md b/README.md
index 6a93e40e..8a3a0e1e 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@ The library provides a comprehensive set of Java classes that map to the WebRTC
- **Cross-platform support** - Works on Windows, macOS, and Linux (x64, ARM, ARM64)
- **Media capabilities** - Audio and video capture from cameras and microphones
- **Desktop capture** - Screen and application window sharing
+- **Media file playback** - Send video and audio files over a peer connection in place of a camera and microphone, with the optional FFmpeg-based `webrtc-java-media` module
- **Data channels** - Bidirectional peer-to-peer data exchange
- **Statistics API** - Detailed metrics for monitoring connection quality
- **Simple integration** - Available as a Maven dependency
@@ -28,6 +29,7 @@ For more detailed information, check out the documentation:
- [Quickstart](https://jrtc.dev/guide/get-started) - Get up and running quickly with webrtc-java
- [Guides](https://jrtc.dev/guide/) - Comprehensive documentation on using the library
- [Examples](https://jrtc.dev/guide/examples) - Sample code demonstrating various features
+- [Media Files](https://jrtc.dev/guide/media/media-files) - Sending video and audio files with the media module
- [Build Notes](https://jrtc.dev/guide/build) - Instructions for building the library from source
## License
diff --git a/docs/guide/examples.md b/docs/guide/examples.md
index ae9191cb..a6636a84 100644
--- a/docs/guide/examples.md
+++ b/docs/guide/examples.md
@@ -54,22 +54,44 @@ This example is particularly useful for applications that need to implement scre
## Media File
-The [`MediaFileExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java) demonstrates how to send a media file over a peer connection, in place of a camera and a microphone. See the [Media Files](/guide/media/media-files) guide for the API it uses.
+The [`MediaFileExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java) demonstrates how to send a media file over a peer connection, in place of a camera and a microphone. See the [Media Files](/guide/media/media-files) guide for the API it uses.
**Key features demonstrated:**
-- Opening a media file with a `MediaFileSource`
+- Opening a media file, or a live RTSP stream, with a `MediaFileSource`
- Reading what the source contains from its `MediaInfo`
- Creating audio and video tracks from the media sources it feeds
- Adding those tracks to a peer connection
- Following playback through a `MediaPlayerListener`
-This example is useful for applications that stream pre-recorded media, or that need a dependable stand-in for a camera in testing.
+This example is useful for applications that stream pre-recorded media, relay an IP camera into WebRTC, or need a dependable stand-in for a camera in testing.
-::: warning
-This example needs the `webrtc-java-media` module, which is opt-in while it is being brought up on every platform, so it is built and run with the `with-media-extension` profile:
+::: info
+This example needs the `webrtc-java-media` module, which builds FFmpeg from the `third-party/ffmpeg` submodule. Make sure the submodule is checked out before building, as the [Media Files](/guide/media/media-files) guide describes.
+
+```bash
+mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFileExample" -D"exec.args=movie.mp4"
+mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFileExample" -D"exec.args=rtsp://camera.local/stream1"
+```
+:::
+
+## Media File Player
+
+The [`MediaFilePlayerExample`](https://github.com/devopvoid/webrtc-java/blob/master/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java) is a Swing application that sends a media file, or a live RTSP stream, from one peer connection to another in the same process, then shows the received video and plays the received audio in sync. Start and Stop buttons set up and tear down the whole session.
+
+**Key features demonstrated:**
+- Connecting two peer connections in one application, without a signaling server
+- Using one factory with a dummy audio layer to send the file, and another with the platform audio layer to play what is received
+- Keeping audio and video in sync by sending both tracks in the same media stream
+- Raising the video sender's `maxBitrate` above WebRTC's default, so a high-resolution file is not held to a fraction of its size
+- Rendering received video frames in a Swing component with `VideoBufferConverter`
+- Showing the source's format, the playback position, and live receive metrics (codec, resolution, frame rate, bitrate, packet loss, jitter, audio level) read from `getStats()`
+- Releasing senders, peer connections, tracks and the source in order on Stop
+
+::: info
+Like the [Media File](#media-file) example, this one needs the `webrtc-java-media` module. The source argument is optional; a file can also be chosen from the window, or a stream URL typed into it. A stream URL's credentials are never shown, and the Loop box is disabled while a live stream plays, since it has no length to start over from.
```bash
-mvn -Pwith-media-extension exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFileExample" -D"exec.args=movie.mp4"
+mvn exec:java -D"exec.mainClass=dev.onvoid.webrtc.examples.MediaFilePlayerExample" -D"exec.args=movie.mp4"
```
:::
diff --git a/docs/guide/index.md b/docs/guide/index.md
index 6e1b913e..b851497e 100644
--- a/docs/guide/index.md
+++ b/docs/guide/index.md
@@ -7,6 +7,7 @@ This section provides detailed guides for various features of the webrtc-java li
- [Media Devices](/guide/media/media-devices) - Working with audio and video devices
- [Bitrate and Framerate Constraints](/guide/media/constraints) - Controlling media quality
- [Send-only and Receive-only](/guide/media/directionality) - Configure transceiver directions (send-only, receive-only or inactive)
+- [Media Files](/guide/media/media-files) - Sending video and audio files instead of a camera and microphone
## Audio
diff --git a/docs/guide/media/media-files.md b/docs/guide/media/media-files.md
index 56874a1e..c0fc63b8 100644
--- a/docs/guide/media/media-files.md
+++ b/docs/guide/media/media-files.md
@@ -7,22 +7,19 @@ This guide explains how to send a media file over a peer connection instead of a
- Reading what a source contains with `MediaReader` and `MediaInfo`
- Controlling playback and following it with a listener
- Feeding your own media sources with `MediaPlayer`
+- Playing a live stream from an IP camera or media server over RTSP
Sending a file is a common need: a test pattern instead of a webcam, a pre-recorded briefing, a video that has to reach several participants. Without help, an application has to bring its own decoder and push I420 frames into a `CustomVideoSource` itself. The media module removes that work by decoding with [FFmpeg](https://ffmpeg.org) inside the library.
Decoding happens entirely in native code. Frames never travel through Java: the module hands decoded pictures straight to the native side of a `CustomVideoSource`, and 10 ms chunks to a `CustomAudioSource`. They are paced in real time and carry the presentation times of the file, so what a receiver gets keeps the timing of the media rather than the timing of a Java thread.
-::: warning Opt-in while it is being brought up
-The media module is not part of the default build yet, and its native library has so far been built for `windows-x86_64` only. Build it with the `with-media-extension` profile, as described below.
-:::
-
## Adding the Module
-The module builds FFmpeg from a submodule pinned to a release tag, so the submodule has to be present:
+The module is part of the normal build, and it builds FFmpeg from a submodule pinned to a release tag, so the submodule has to be present:
```shell
git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg
-mvn install -Pwith-media-extension
+mvn install
```
Building FFmpeg needs `make` and `nasm`. On Windows they come from MSYS2:
@@ -34,7 +31,7 @@ C:\msys64\usr\bin\bash -lc "pacman -S --needed make nasm diffutils pkgconf"
Maven still runs from an ordinary shell; the build enters MSYS2 and the Visual Studio environment on its own. The first build compiles FFmpeg, which takes a while; later builds reuse the install directory.
-Once installed, depend on it alongside `webrtc-java`:
+Once installed, depend on it alongside `webrtc-java`. It takes two entries: one for the Java API, and one for the natives of the platform you are running on.
```xml
@@ -42,9 +39,19 @@ Once installed, depend on it alongside `webrtc-java`:
webrtc-java-media0.19.0-SNAPSHOT
+
+ dev.onvoid.webrtc
+ webrtc-java-media
+ 0.19.0-SNAPSHOT
+ windows-x86_64
+
```
-The classifier jar carries the module's native library together with the FFmpeg shared libraries it uses. Applications that do not use this module never download FFmpeg.
+The classifier jar carries the module's native library together with the FFmpeg shared libraries it uses, so applications that do not use this module never download FFmpeg. Replace the classifier with the platform you are building for: `windows-x86_64`, `windows-aarch64`, `linux-x86_64`, `linux-aarch64`, `linux-aarch32`, `macos-x86_64` or `macos-aarch64`.
+
+::: info
+Unlike `webrtc-java`, which brings its natives along by itself, this module cannot: the natives are built by the module rather than by a separate one, so a dependency on them would have nothing to resolve against on a first build. Asking for them explicitly is the price of that.
+:::
## Sending a File
@@ -52,7 +59,7 @@ The classifier jar carries the module's native library together with the FFmpeg
```java
// Import required classes
-import dev.onvoid.webrtc.media.ffmpeg.MediaFileSource;
+import dev.onvoid.webrtc.media.player.MediaFileSource;
import dev.onvoid.webrtc.media.audio.AudioTrack;
import dev.onvoid.webrtc.media.video.VideoTrack;
import java.nio.file.Path;
@@ -95,8 +102,8 @@ PeerConnectionFactory factory = new PeerConnectionFactory(audioModule);
```java
// Import required classes
-import dev.onvoid.webrtc.media.ffmpeg.MediaInfo;
-import dev.onvoid.webrtc.media.ffmpeg.MediaReader;
+import dev.onvoid.webrtc.media.player.MediaInfo;
+import dev.onvoid.webrtc.media.player.MediaReader;
try (MediaReader reader = new MediaReader(Path.of("movie.mp4"))) {
MediaInfo info = reader.getInfo();
@@ -143,8 +150,8 @@ A seek lands on the keyframe at or before the position asked for, which is how f
```java
// Import required classes
-import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerListener;
-import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerState;
+import dev.onvoid.webrtc.media.player.MediaPlayerListener;
+import dev.onvoid.webrtc.media.player.MediaPlayerState;
source.setListener(new MediaPlayerListener() {
@@ -167,6 +174,8 @@ source.setListener(new MediaPlayerListener() {
::: warning
Every call arrives on the player's own thread, and that thread is the one decoding the media. A listener must return promptly, and must not wait on the player.
+
+Closing the player, or the `MediaFileSource`, from a listener is fine: it takes effect at once, and the native player is released on another thread once the listener has returned.
:::
## Feeding Your Own Media Sources
@@ -175,8 +184,8 @@ Every call arrives on the player's own thread, and that thread is the one decodi
```java
// Import required classes
-import dev.onvoid.webrtc.media.ffmpeg.MediaPlayer;
-import dev.onvoid.webrtc.media.ffmpeg.MediaReader;
+import dev.onvoid.webrtc.media.player.MediaPlayer;
+import dev.onvoid.webrtc.media.player.MediaReader;
import dev.onvoid.webrtc.media.video.CustomVideoSource;
CustomVideoSource videoSource = new CustomVideoSource();
@@ -191,8 +200,33 @@ player.play();
::: info
The player takes over the reader it is given. That reader must not be used or closed afterwards; closing the player releases it.
+
+It also keeps the native side of the media sources it feeds alive until it is closed, so disposing of a source or its track while the player runs is safe.
:::
+## Playing a Live Stream
+
+A source can also be a live stream behind an `rtsp://` URL, which is what IP cameras, video recorders and most media servers offer. Everything above works the same way; only the URL differs:
+
+```java
+MediaFileSource source = new MediaFileSource("rtsp://camera.local:554/stream1");
+```
+
+Credentials go into the URL, as `rtsp://user:password@camera.local/stream1`. FFmpeg first asks the server for RTP over UDP, and falls back to RTP interleaved on the RTSP connection over TCP if the server refuses UDP or no UDP packets arrive, which is what gets a stream through most firewalls.
+
+A network source can stall or disappear, so every operation that waits on a source has a time limit: opening it, and then each read during playback. It is 10 seconds unless you pass another:
+
+```java
+// Import required classes
+import java.time.Duration;
+
+MediaFileSource source = new MediaFileSource("rtsp://camera.local/stream1", Duration.ofSeconds(5));
+```
+
+An unreachable server makes the constructor throw an `IOException` once that time is up. A stream that stops sending during playback is reported to the listener's `onError`, and the player is left `PAUSED`. Closing a player never waits for the time limit: it breaks off whatever the player is waiting for at once.
+
+A live stream has no end and no length: its `MediaInfo` reports a duration of 0, looping does not apply, and seeking is reported as an error. Pausing stops reading from the server, but the server keeps sending; resuming carries on from where playback paused, which leaves it behind real time by as long as it was paused. To get back to live, close the source and open it again.
+
## Closing
Closing a `MediaFileSource` stops playback and releases the player along with both media sources. Release the senders a peer connection handed out, and the tracks, before that:
@@ -212,21 +246,25 @@ The FFmpeg build is deliberately small, and carries only what this module plays:
| | |
| --- | --- |
| **Containers** | MP4 and MOV, Matroska and WebM, AVI, MPEG-TS, FLV, WAV, MP3, Ogg, FLAC, AAC |
-| **Video** | H.264, H.265/HEVC, VP8, VP9, MPEG-4, MJPEG |
-| **Audio** | AAC, MP3, Opus, Vorbis, FLAC, PCM |
+| **Video** | H.264, H.265/HEVC, VP8, VP9, MPEG-4 (including Xvid and DivX), Microsoft MPEG-4 v1 to v3, MJPEG |
+| **Audio** | AAC, MP3, MP2, AC-3, Opus, Vorbis, FLAC, PCM, MS and IMA ADPCM |
Audio of any rate or layout is resampled to what WebRTC takes, which is 48 kHz 16-bit PCM in mono or stereo. Video that decodes to I420 — almost all 8-bit H.264, VP8, VP9 and MPEG-4 — reaches the encoder without being copied; anything else is converted first.
-Only local files play today. FFmpeg demuxes network sources just as well, so the same code will cover http, rtsp and rtmp once those protocols are turned on in the build.
+Sources can be local files, or live streams over RTSP (`rtsp://`), with RTP over UDP or TCP. Protocols that need TLS, such as `rtsps://` and `https://`, are not part of the build, and neither are HLS, DASH and RTMP. A source cannot reach any other protocol either, even one FFmpeg uses internally, so a URL passed on from a user cannot make the module fetch something else.
## Licensing
The module uses FFmpeg under the LGPL version 2.1 or later. It is configured without `--enable-gpl` and without `--enable-nonfree`, and FFmpeg is linked dynamically and shipped as separate files inside the platform jar, so its libraries may be replaced with your own build, as the LGPL requires. The wrapper code is licensed under the Apache License 2.0 like the rest of webrtc-java.
+Each platform jar carries the LGPL text and a notice under `META-INF/licenses/ffmpeg`, naming the FFmpeg release the libraries are built from, unmodified, and where its source is. If you redistribute your application with these jars, keep those files with them.
+
+Some of the formats FFmpeg decodes, such as H.264, H.265/HEVC and AAC, may be covered by patents in some countries. Whether your use of them needs a patent license is for you to determine.
+
## Complete Example
See `MediaFileExample` in the `webrtc-examples` module, which opens a file, reports what it contains, creates tracks, adds them to a peer connection and follows playback to the end.
```shell
-mvn -Pwith-media-extension -pl webrtc-examples compile
+mvn -pl webrtc-examples compile
```
diff --git a/docs/guide/video/custom-video-source.md b/docs/guide/video/custom-video-source.md
index 9753ac13..0b3da97e 100644
--- a/docs/guide/video/custom-video-source.md
+++ b/docs/guide/video/custom-video-source.md
@@ -78,6 +78,7 @@ When pushing video frames, you need to consider the following:
### Resolution
- Common resolutions: 320x240, 640x480, 1280x720, 1920x1080
- Higher resolutions require more bandwidth and processing power
+- Push frames at their full size: when the bitrate cannot carry them, the source scales them down (and, if asked, drops some) before they reach the encoder, the way a camera source does, and scales back up once the bandwidth allows. Local sinks on the track see the adapted frames too.
### Frame Rate
- Common frame rates: 15, 24, 30, 60 fps
diff --git a/docs/index.md b/docs/index.md
index 443f50ed..3e3d6dc6 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -26,6 +26,8 @@ features:
details: Thin JNI layer with minimal overhead, delivering near-native performance with minimal context switching between Java and native code.
- title: Audio and video streaming
details: Audio and video capture from cameras and microphones devices, with support for custom media sources for flexible streaming solutions.
+ - title: Media File Playback
+ details: Send video and audio files over a peer connection. The optional media module decodes with FFmpeg in native code and paces playback in real time, keeping audio and video in sync.
- title: Screen Sharing
details: Share application windows or the full desktop with minimal setup; integrate screen capture streams like any other media stream.
- title: Data Channels
diff --git a/pom.xml b/pom.xml
index b8e6770a..90d3a69d 100644
--- a/pom.xml
+++ b/pom.xml
@@ -52,6 +52,7 @@
webrtc-jniwebrtc
+ webrtc-java-mediawebrtc-examples
@@ -354,19 +355,6 @@
webrtc.macos.aarch64
-
-
- with-media-extension
-
- webrtc-java-media
-
-
diff --git a/webrtc-examples/pom.xml b/webrtc-examples/pom.xml
index cf9437d3..5f0118f9 100644
--- a/webrtc-examples/pom.xml
+++ b/webrtc-examples/pom.xml
@@ -21,6 +21,27 @@
+
+
+ org.codehaus.mojo
+ exec-maven-plugin
+ 3.5.0
+
+
+ ${project.basedir}/../webrtc-java-media/target/natives
+
+
+
+
org.apache.maven.pluginsmaven-compiler-plugin
@@ -65,6 +86,12 @@
${project.version}
+
+ ${project.groupId}
+ webrtc-java-media
+ ${project.version}
+
+
org.eclipse.jettyjetty-server
@@ -95,74 +122,4 @@
-
-
-
- with-media-extension
-
-
-
-
- org.apache.maven.plugins
- maven-compiler-plugin
-
-
- compile-media-example
- compile
-
- compile
-
-
- 17
-
- ${project.basedir}/src/media/java
-
- ${project.build.directory}/media-classes
-
-
-
-
-
-
-
- org.codehaus.mojo
- exec-maven-plugin
- 3.5.0
-
-
- ${project.build.directory}/media-classes
-
-
-
-
-
-
-
-
- ${project.groupId}
- webrtc-java-media
- ${project.version}
-
-
-
-
\ No newline at end of file
diff --git a/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java
similarity index 89%
rename from webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java
rename to webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java
index cdbc3b05..ccc6a71d 100644
--- a/webrtc-examples/src/media/java/dev/onvoid/webrtc/examples/MediaFileExample.java
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFileExample.java
@@ -32,10 +32,10 @@
import dev.onvoid.webrtc.media.audio.AudioDeviceModule;
import dev.onvoid.webrtc.media.audio.AudioLayer;
import dev.onvoid.webrtc.media.audio.AudioTrack;
-import dev.onvoid.webrtc.media.ffmpeg.MediaFileSource;
-import dev.onvoid.webrtc.media.ffmpeg.MediaInfo;
-import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerListener;
-import dev.onvoid.webrtc.media.ffmpeg.MediaPlayerState;
+import dev.onvoid.webrtc.media.player.MediaFileSource;
+import dev.onvoid.webrtc.media.player.MediaInfo;
+import dev.onvoid.webrtc.media.player.MediaPlayerListener;
+import dev.onvoid.webrtc.media.player.MediaPlayerState;
import dev.onvoid.webrtc.media.video.VideoTrack;
import java.util.List;
@@ -46,7 +46,7 @@
*
* This example shows how to:
*
- *
Open a media file or network stream with a MediaFileSource
+ *
Open a media file, or a live RTSP stream, with a MediaFileSource
*
Read what the source contains from its MediaInfo
*
Create audio and video tracks from the sources it feeds
*
Add those tracks to a peer connection
@@ -61,14 +61,13 @@
* reach a remote peer through a signaling channel, as
* {@link PeerConnectionExample} shows.
*
- * Run it with the media file to send:
+ * Run it with the media file or stream URL to send:
*
- * Only local files play today. FFmpeg demuxes network sources just as well,
- * so the same code covers http, rtsp and rtmp once those protocols are turned
- * on in the build.
+ * A stream that cannot be reached, or stops sending, is given up on after the
+ * reader's timeout, 10 seconds unless another is passed to the source.
*
* @author Alex Andres
*/
@@ -76,8 +75,9 @@ public class MediaFileExample {
public static void main(String[] args) {
if (args.length < 1) {
- System.out.println("Usage: MediaFileExample ");
+ System.out.println("Usage: MediaFileExample ");
System.out.println(" for example: MediaFileExample movie.mp4");
+ System.out.println(" or: MediaFileExample rtsp://camera.local/stream1");
return;
}
@@ -144,7 +144,13 @@ public void onError(String message) {
}
private static void printInfo(MediaInfo info) {
- System.out.printf("Source runs %.3f s%n", info.getDurationUs() / 1_000_000.0);
+ if (info.getDurationUs() > 0) {
+ System.out.printf("Source runs %.3f s%n", info.getDurationUs() / 1_000_000.0);
+ }
+ else {
+ // A live stream, which has no length.
+ System.out.println("Source is live");
+ }
if (info.hasVideo()) {
System.out.printf(" video: %dx%d at %.2f fps, %s%n", info.getVideoWidth(),
diff --git a/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java
new file mode 100644
index 00000000..5bd61875
--- /dev/null
+++ b/webrtc-examples/src/main/java/dev/onvoid/webrtc/examples/MediaFilePlayerExample.java
@@ -0,0 +1,1138 @@
+/*
+ * 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.awt.BorderLayout;
+import java.awt.Color;
+import java.awt.Dimension;
+import java.awt.FlowLayout;
+import java.awt.Font;
+import java.awt.Graphics;
+import java.awt.Graphics2D;
+import java.awt.GridBagConstraints;
+import java.awt.GridBagLayout;
+import java.awt.Insets;
+import java.awt.RenderingHints;
+import java.awt.event.WindowAdapter;
+import java.awt.event.WindowEvent;
+import java.awt.image.BufferedImage;
+import java.awt.image.DataBufferInt;
+import java.io.File;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+import javax.swing.BorderFactory;
+import javax.swing.JButton;
+import javax.swing.JCheckBox;
+import javax.swing.JFileChooser;
+import javax.swing.JFrame;
+import javax.swing.JLabel;
+import javax.swing.JPanel;
+import javax.swing.JProgressBar;
+import javax.swing.JTextField;
+import javax.swing.SwingUtilities;
+import javax.swing.Timer;
+import javax.swing.UIManager;
+
+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.RTCIceCandidate;
+import dev.onvoid.webrtc.RTCOfferOptions;
+import dev.onvoid.webrtc.RTCPeerConnection;
+import dev.onvoid.webrtc.RTCPeerConnectionState;
+import dev.onvoid.webrtc.RTCRtpReceiver;
+import dev.onvoid.webrtc.RTCRtpEncodingParameters;
+import dev.onvoid.webrtc.RTCRtpSendParameters;
+import dev.onvoid.webrtc.RTCRtpSender;
+import dev.onvoid.webrtc.RTCRtpTransceiver;
+import dev.onvoid.webrtc.RTCSessionDescription;
+import dev.onvoid.webrtc.RTCStats;
+import dev.onvoid.webrtc.RTCStatsReport;
+import dev.onvoid.webrtc.RTCStatsType;
+import dev.onvoid.webrtc.SetSessionDescriptionObserver;
+import dev.onvoid.webrtc.media.FourCC;
+import dev.onvoid.webrtc.media.MediaStreamTrack;
+import dev.onvoid.webrtc.media.audio.AudioDeviceModule;
+import dev.onvoid.webrtc.media.audio.AudioLayer;
+import dev.onvoid.webrtc.media.audio.AudioTrack;
+import dev.onvoid.webrtc.media.player.MediaFileSource;
+import dev.onvoid.webrtc.media.player.MediaInfo;
+import dev.onvoid.webrtc.media.player.MediaPlayerListener;
+import dev.onvoid.webrtc.media.player.MediaPlayerState;
+import dev.onvoid.webrtc.media.video.VideoBufferConverter;
+import dev.onvoid.webrtc.media.video.VideoFrame;
+import dev.onvoid.webrtc.media.video.VideoFrameBuffer;
+import dev.onvoid.webrtc.media.video.VideoTrack;
+import dev.onvoid.webrtc.media.video.VideoTrackSink;
+
+/**
+ * Desktop UI counterpart to {@link MediaFileExample}: plays a media file, or
+ * a live stream from an {@code rtsp://} URL such as an IP camera's, through a
+ * pair of peer connections that live in the same application, and shows the
+ * received video while the received audio plays on the speakers.
+ *
+ * This example shows how to:
+ *
+ *
Send a MediaFileSource over one peer connection
+ *
Connect it to a second, receiving peer connection without a
+ * signaling server, by handing descriptions and candidates across
+ * directly
+ *
Render received video frames in a Swing component
+ *
Play received audio through the default playout device
+ *
Show what the file contains, where playback is, and what arrives
+ * at the receiver, read from its statistics
+ *
Start and stop a session repeatedly, releasing everything in
+ * order
+ *
+ *
+ * The two sides use two factories. The sending factory is fed pushed audio,
+ * so it gets a dummy audio layer. The receiving factory gets the platform's
+ * audio layer, whose playout is what makes the received audio audible.
+ *
+ * Audio and video stay in sync because both tracks are sent in the same
+ * media stream: the receiver lines them up with each other before handing
+ * them on, so the video sink only has to draw each frame as it arrives.
+ *
+ * Run it with an optional media file or stream URL to preselect:
+ *
+ * A live stream has no length, so the Loop box is disabled while one plays.
+ *
+ * @author Alex Andres
+ */
+public class MediaFilePlayerExample {
+
+ private static final Logger LOG = Logger.getLogger(MediaFilePlayerExample.class.getName());
+
+ /** Both tracks go in this stream, which is what ties them together for lip sync. */
+ private static final List STREAM_IDS = List.of("media-file");
+
+ /**
+ * The most the video may be sent with. Without a limit of its own, WebRTC
+ * holds video to about 2 Mbit/s, which fits a call but leaves a 1080p file
+ * at a fraction of its resolution. The two peers here share a machine, so
+ * there is room for more; the bandwidth estimate still decides how much of
+ * it is actually used.
+ */
+ private static final int MAX_VIDEO_BITRATE = 10_000_000;
+
+ private final AudioDeviceModule senderAudioModule;
+ private final PeerConnectionFactory senderFactory;
+
+ private final AudioDeviceModule receiverAudioModule;
+ private final PeerConnectionFactory receiverFactory;
+
+ /** Setting up and tearing down a session blocks, so it stays off the EDT. */
+ private final ExecutorService worker = Executors.newSingleThreadExecutor(r -> {
+ Thread thread = new Thread(r, "media-session");
+ thread.setDaemon(true);
+ return thread;
+ });
+
+ private final JFrame frame = new JFrame("Media Player");
+ private final JTextField fileField = new JTextField(32);
+ private final JButton browseButton = new JButton("Browse...");
+ private final JButton startButton = new JButton("Start");
+ private final JButton stopButton = new JButton("Stop");
+ private final JCheckBox loopBox = new JCheckBox("Loop");
+ private final JLabel statusLabel = new JLabel("Idle");
+ private final JProgressBar timeBar = new JProgressBar(0, 1000);
+ private final VideoPanel videoPanel = new VideoPanel();
+ private final MetricsPanel metricsPanel = new MetricsPanel();
+ private final Timer statusTimer = new Timer(250, e -> updateStatus());
+ private final Timer statsTimer = new Timer(1000, e -> requestStats());
+
+ /** Only touched from the worker thread and read on the EDT for status. */
+ private volatile Session session;
+
+
+ public static void main(String[] args) {
+ String file = args.length > 0 ? args[0] : "";
+
+ SwingUtilities.invokeLater(() -> {
+ usePlatformLookAndFeel();
+
+ new MediaFilePlayerExample().show(file);
+ });
+ }
+
+ /**
+ * A short name for a source: a file's name, or a stream URL without the
+ * credentials it may carry.
+ */
+ private static String sourceName(String source) {
+ return source.contains("://") ? withoutCredentials(source) : new File(source).getName();
+ }
+
+ /**
+ * The given source without the user name and password a URL can carry in
+ * front of its host, so that it can be shown. Anything else, a file path
+ * included, is returned as it is.
+ */
+ private static String withoutCredentials(String source) {
+ // Everything between the scheme and the last @ before the path. Not
+ // parsed as a URI: a password is free to hold characters a URI
+ // parser rejects, and then it would be shown after all.
+ return source.replaceFirst("://[^/?#]*@", "://");
+ }
+
+ /**
+ * Makes the window look like the platform's own applications rather than
+ * Swing's cross-platform default. It has to happen before the first
+ * component is created, which only picks up the look it was made with.
+ */
+ private static void usePlatformLookAndFeel() {
+ try {
+ UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
+ }
+ catch (Exception e) {
+ // The default look works everywhere, so this is only cosmetic.
+ LOG.log(Level.WARNING, "Could not use the platform look and feel", e);
+ }
+ }
+
+ private MediaFilePlayerExample() {
+ senderAudioModule = new AudioDeviceModule(AudioLayer.kDummyAudio);
+ senderFactory = new PeerConnectionFactory(senderAudioModule);
+
+ receiverAudioModule = new AudioDeviceModule();
+ receiverFactory = new PeerConnectionFactory(receiverAudioModule);
+ }
+
+ private void show(String file) {
+ fileField.setText(file);
+ fileField.setToolTipText("A media file, or an rtsp:// stream URL");
+
+ browseButton.addActionListener(e -> chooseFile());
+ startButton.addActionListener(e -> start());
+ stopButton.addActionListener(e -> stop());
+ loopBox.addActionListener(e -> {
+ Session current = session;
+ if (current != null) {
+ current.source.setLooping(loopBox.isSelected());
+ }
+ });
+
+ JPanel controls = new JPanel(new FlowLayout(FlowLayout.LEFT));
+ controls.add(fileField);
+ controls.add(browseButton);
+ controls.add(startButton);
+ controls.add(stopButton);
+ controls.add(loopBox);
+
+ timeBar.setStringPainted(true);
+ timeBar.setString("");
+
+ JPanel statusBar = new JPanel(new BorderLayout(12, 0));
+ statusBar.setBorder(BorderFactory.createEmptyBorder(4, 8, 4, 8));
+ statusBar.add(statusLabel, BorderLayout.WEST);
+ statusBar.add(timeBar, BorderLayout.CENTER);
+
+ frame.setLayout(new BorderLayout());
+ frame.add(controls, BorderLayout.NORTH);
+ frame.add(videoPanel, BorderLayout.CENTER);
+ frame.add(metricsPanel, BorderLayout.EAST);
+ frame.add(statusBar, BorderLayout.SOUTH);
+ frame.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
+ frame.addWindowListener(new WindowAdapter() {
+
+ @Override
+ public void windowClosing(WindowEvent e) {
+ exit();
+ }
+ });
+
+ setRunning(false);
+
+ frame.pack();
+ frame.setLocationRelativeTo(null);
+ frame.setVisible(true);
+ }
+
+ private void chooseFile() {
+ JFileChooser chooser = new JFileChooser();
+
+ String current = fileField.getText().trim();
+ if (!current.isEmpty()) {
+ chooser.setSelectedFile(new File(current));
+ }
+
+ if (chooser.showOpenDialog(frame) == JFileChooser.APPROVE_OPTION) {
+ fileField.setText(chooser.getSelectedFile().getAbsolutePath());
+ }
+ }
+
+ private void start() {
+ String file = fileField.getText().trim();
+ if (file.isEmpty()) {
+ statusLabel.setText("Choose a media file or enter an rtsp:// URL first.");
+ return;
+ }
+
+ boolean looping = loopBox.isSelected();
+
+ setRunning(true);
+ statusLabel.setText("Connecting...");
+ metricsPanel.clear("");
+
+ worker.execute(() -> {
+ try {
+ Session started = new Session(file, looping);
+ session = started;
+
+ SwingUtilities.invokeLater(() -> {
+ metricsPanel.showMedia(file, started.info);
+
+ // Only a source with a length can start over.
+ loopBox.setEnabled(started.info.getDurationUs() > 0);
+ });
+ }
+ catch (Exception e) {
+ LOG.log(Level.SEVERE, "Could not start playback", e);
+
+ SwingUtilities.invokeLater(() -> {
+ setRunning(false);
+ statusLabel.setText("Failed: " + e.getMessage());
+ });
+ }
+ });
+ }
+
+ private void stop() {
+ startButton.setEnabled(false);
+ stopButton.setEnabled(false);
+
+ worker.execute(() -> {
+ closeSession();
+
+ SwingUtilities.invokeLater(() -> {
+ setRunning(false);
+ videoPanel.clear();
+ statusLabel.setText("Stopped");
+ timeBar.setValue(0);
+ timeBar.setString("");
+
+ // What the file contains stays up, what was playing does not.
+ metricsPanel.clear("play.");
+ metricsPanel.clear("video.");
+ metricsPanel.clear("audio.");
+ });
+ });
+ }
+
+ private void exit() {
+ statusTimer.stop();
+ statsTimer.stop();
+ frame.dispose();
+
+ // Queued behind anything still running, so a session being set up
+ // is closed rather than left behind.
+ worker.execute(() -> {
+ closeSession();
+
+ receiverFactory.dispose();
+ receiverAudioModule.dispose();
+ senderFactory.dispose();
+ senderAudioModule.dispose();
+
+ System.exit(0);
+ });
+ }
+
+ private void closeSession() {
+ Session current = session;
+ session = null;
+
+ if (current != null) {
+ current.close();
+ }
+ }
+
+ private void setRunning(boolean running) {
+ startButton.setEnabled(!running);
+ stopButton.setEnabled(running);
+ fileField.setEnabled(!running);
+ browseButton.setEnabled(!running);
+
+ if (!running) {
+ loopBox.setEnabled(true);
+ }
+
+ if (running) {
+ statusTimer.start();
+ statsTimer.start();
+ }
+ else {
+ statusTimer.stop();
+ statsTimer.stop();
+ }
+ }
+
+ private void updateStatus() {
+ Session current = session;
+ if (current == null) {
+ return;
+ }
+
+ MediaPlayerState state = current.source.getState();
+ long positionUs = current.source.getPositionUs();
+ long durationUs = current.info.getDurationUs();
+
+ // Live streams and some containers have no duration to measure against.
+ String time = durationUs > 0
+ ? formatTime(positionUs) + " / " + formatTime(durationUs)
+ : formatTime(positionUs);
+
+ statusLabel.setText(state.toString());
+ timeBar.setValue(durationUs > 0 ? (int) Math.min(1000, positionUs * 1000 / durationUs) : 0);
+ timeBar.setString(time);
+
+ metricsPanel.set("play.state", state.toString());
+ metricsPanel.set("play.position", time);
+ metricsPanel.set("play.connection", current.connectionState.toString());
+ }
+
+ /**
+ * Asks the current session for its statistics. This goes through the
+ * worker, which is where sessions are closed, so a request can never
+ * reach a peer connection that is being closed at the same time.
+ */
+ private void requestStats() {
+ worker.execute(() -> {
+ Session current = session;
+ if (current != null) {
+ current.requestStats();
+ }
+ });
+ }
+
+ private static String formatTime(long us) {
+ long tenths = us / 100_000;
+ long s = tenths / 10;
+
+ if (s >= 3600) {
+ return String.format("%d:%02d:%02d.%d", s / 3600, s / 60 % 60, s % 60, tenths % 10);
+ }
+ return String.format("%d:%02d.%d", s / 60, s % 60, tenths % 10);
+ }
+
+ private static String formatBitrate(Double bitsPerSecond) {
+ if (bitsPerSecond == null) {
+ return null;
+ }
+ if (bitsPerSecond >= 1_000_000) {
+ return String.format("%.2f Mbit/s", bitsPerSecond / 1_000_000);
+ }
+ return String.format("%.0f kbit/s", bitsPerSecond / 1_000);
+ }
+
+ /**
+ * Formats a codec entry of a stats report, such as {@code VP8} or
+ * {@code opus, 48000 Hz, 2 ch}.
+ */
+ private static String formatCodec(RTCStats codec) {
+ if (codec == null) {
+ return null;
+ }
+
+ Map attributes = codec.getAttributes();
+ String mimeType = String.valueOf(attributes.get("mimeType"));
+ String name = mimeType.substring(mimeType.indexOf('/') + 1);
+
+ if (!mimeType.startsWith("audio/")) {
+ return name;
+ }
+
+ long channels = count(attributes, "channels");
+
+ return String.format("%s, %d Hz, %d ch", name, count(attributes, "clockRate"),
+ channels > 0 ? channels : 1);
+ }
+
+ private static String formatLoss(Map attributes) {
+ long lost = count(attributes, "packetsLost");
+ long total = lost + count(attributes, "packetsReceived");
+
+ if (total <= 0) {
+ return null;
+ }
+ return String.format("%d of %d (%.1f %%)", lost, total, lost * 100.0 / total);
+ }
+
+ private static String format(String pattern, Double value) {
+ return value != null ? String.format(pattern, value) : null;
+ }
+
+ /**
+ * Reads a counter from stats attributes. Counters come as Integer, Long or
+ * BigInteger depending on their native type, and are absent until there is
+ * something to count.
+ */
+ private static long count(Map attributes, String name) {
+ Object value = attributes.get(name);
+ return value instanceof Number ? ((Number) value).longValue() : 0;
+ }
+
+ private static Double decimal(Map attributes, String name) {
+ Object value = attributes.get(name);
+ return value instanceof Number ? ((Number) value).doubleValue() : null;
+ }
+
+ /**
+ * One run of the file, from Start to Stop: the source, the sending and
+ * receiving peer connections, and what connects them.
+ */
+ private class Session {
+
+ final MediaFileSource source;
+ final MediaInfo info;
+
+ final Peer sender;
+ final Peer receiver;
+
+ private final VideoTrack videoTrack;
+ private final AudioTrack audioTrack;
+ private RTCRtpSender videoSender;
+ private final List rtpSenders = new ArrayList<>();
+
+ volatile RTCPeerConnectionState connectionState = RTCPeerConnectionState.NEW;
+
+ /** The received video track, kept to detach the sink from on close. */
+ private volatile VideoTrack remoteVideoTrack;
+
+ /** Turn the byte counters of successive stats reports into bitrates. */
+ private final RateMeter videoRate = new RateMeter();
+ private final RateMeter audioRate = new RateMeter();
+
+
+ Session(String file, boolean looping) throws Exception {
+ source = new MediaFileSource(file);
+ info = source.getInfo();
+
+ sender = new Peer(senderFactory, "sender");
+ receiver = new Peer(receiverFactory, "receiver");
+
+ sender.remote = receiver;
+ receiver.remote = sender;
+
+ // A source with no video has no video source, and likewise for
+ // audio, so each track is only made if there is something to feed
+ // it.
+ if (source.getVideoSource() != null) {
+ videoTrack = senderFactory.createVideoTrack("video", source.getVideoSource());
+ videoSender = sender.peerConnection.addTrack(videoTrack, STREAM_IDS);
+ rtpSenders.add(videoSender);
+ }
+ else {
+ videoTrack = null;
+ }
+
+ if (source.getAudioSource() != null) {
+ audioTrack = senderFactory.createAudioTrack("audio", source.getAudioSource());
+ rtpSenders.add(sender.peerConnection.addTrack(audioTrack, STREAM_IDS));
+ }
+ else {
+ audioTrack = null;
+ }
+
+ source.setLooping(looping);
+ source.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onStateChanged(MediaPlayerState state) {
+ LOG.info("Player state: " + state);
+ }
+
+ @Override
+ public void onEndOfStream() {
+ stopIfCurrent();
+ }
+
+ @Override
+ public void onError(String message) {
+ LOG.warning("Playback failed: " + message);
+ stopIfCurrent();
+ }
+ });
+
+ try {
+ // The offer and answer go straight across, where an application
+ // talking to a real remote peer would send them over signaling.
+ RTCSessionDescription offer = sender.createOffer();
+ receiver.setRemoteDescription(offer);
+
+ RTCSessionDescription answer = receiver.createAnswer();
+ sender.setRemoteDescription(answer);
+
+ if (videoSender != null) {
+ raiseMaxBitrate(videoSender, MAX_VIDEO_BITRATE);
+ }
+ }
+ catch (Exception e) {
+ close();
+ throw e;
+ }
+
+ // Frames are sent in real time from the moment playback starts,
+ // and whatever goes out before the connection is up is dropped.
+ // Starting right away keeps the example short; an application
+ // that must not lose the first second would wait for CONNECTED.
+ source.play();
+ }
+
+ /**
+ * Lets a sender use up to the given bitrate. Parameters are read,
+ * changed and written back as a whole, and only have encodings to
+ * change once negotiation has set the sender up.
+ */
+ private void raiseMaxBitrate(RTCRtpSender rtpSender, int bitrate) {
+ RTCRtpSendParameters parameters = rtpSender.getParameters();
+
+ for (RTCRtpEncodingParameters encoding : parameters.encodings) {
+ encoding.maxBitrate = bitrate;
+ }
+
+ rtpSender.setParameters(parameters);
+ }
+
+ /**
+ * Stops from a player callback. Closing here would wait on the
+ * player's own thread, so it goes through the UI like the Stop button,
+ * and only if the user has not already moved on to another session.
+ */
+ private void stopIfCurrent() {
+ SwingUtilities.invokeLater(() -> {
+ if (session == this) {
+ stop();
+ }
+ });
+ }
+
+ /**
+ * Collects the receiving side's statistics and shows them once they
+ * arrive, unless the session has ended in the meantime.
+ */
+ void requestStats() {
+ receiver.peerConnection.getStats(report -> {
+ Map values = readStats(report);
+
+ SwingUtilities.invokeLater(() -> {
+ if (session == this) {
+ metricsPanel.setAll(values);
+ }
+ });
+ });
+ }
+
+ /**
+ * Picks the inbound RTP entries out of a stats report, one per
+ * received track, and turns them into the rows of the metrics panel.
+ */
+ private Map readStats(RTCStatsReport report) {
+ Map all = report.getStats();
+ Map values = new HashMap<>();
+
+ for (RTCStats stats : all.values()) {
+ if (stats.getType() != RTCStatsType.INBOUND_RTP) {
+ continue;
+ }
+
+ Map attributes = stats.getAttributes();
+ Object codecId = attributes.get("codecId");
+ RTCStats codec = codecId != null ? all.get(codecId) : null;
+ Double jitter = decimal(attributes, "jitter");
+ Double jitterMs = jitter != null ? jitter * 1000 : null;
+
+ if ("video".equals(attributes.get("kind"))) {
+ long width = count(attributes, "frameWidth");
+ long height = count(attributes, "frameHeight");
+
+ values.put("video.codec", formatCodec(codec));
+ values.put("video.resolution", width > 0 ? width + "x" + height : null);
+ values.put("video.fps", format("%.1f fps", decimal(attributes, "framesPerSecond")));
+ values.put("video.bitrate", formatBitrate(videoRate.update(stats)));
+ values.put("video.frames", String.format("%d decoded, %d dropped",
+ count(attributes, "framesDecoded"), count(attributes, "framesDropped")));
+ values.put("video.lost", formatLoss(attributes));
+ values.put("video.jitter", format("%.1f ms", jitterMs));
+ }
+ else if ("audio".equals(attributes.get("kind"))) {
+ Double level = decimal(attributes, "audioLevel");
+ long samples = count(attributes, "totalSamplesReceived");
+ long concealed = count(attributes, "concealedSamples");
+
+ values.put("audio.codec", formatCodec(codec));
+ values.put("audio.bitrate", formatBitrate(audioRate.update(stats)));
+ values.put("audio.level", level == null ? null
+ : level > 0 ? String.format("%.1f dBFS", 20 * Math.log10(level))
+ : "silent");
+ values.put("audio.concealed", samples > 0
+ ? String.format("%.1f %%", concealed * 100.0 / samples) : null);
+ values.put("audio.lost", formatLoss(attributes));
+ values.put("audio.jitter", format("%.1f ms", jitterMs));
+ }
+ }
+
+ return values;
+ }
+
+ void onRemoteTrack(MediaStreamTrack track) {
+ if (track instanceof VideoTrack) {
+ remoteVideoTrack = (VideoTrack) track;
+ remoteVideoTrack.addSink(videoPanel);
+ }
+ // A received audio track needs nothing: the receiving factory's
+ // audio device module mixes it into playout on its own.
+ }
+
+ void close() {
+ // Stop drawing before anything goes away underneath the sink.
+ VideoTrack remoteVideo = remoteVideoTrack;
+ if (remoteVideo != null) {
+ remoteVideo.removeSink(videoPanel);
+ }
+
+ // Senders are not owned by the peer connection. Releasing them
+ // before it closes lets go of the tracks they carry.
+ for (RTCRtpSender rtpSender : rtpSenders) {
+ rtpSender.dispose();
+ }
+
+ sender.close();
+ receiver.close();
+
+ // The tracks go before the source, which disposes of the media
+ // sources they were made from.
+ if (videoTrack != null) {
+ videoTrack.dispose();
+ }
+ if (audioTrack != null) {
+ audioTrack.dispose();
+ }
+
+ source.close();
+ }
+
+
+ /**
+ * One end of the in-process connection. Candidates found here are
+ * handed to the other end, held back until it has a remote
+ * description to match them against.
+ */
+ private class Peer implements PeerConnectionObserver {
+
+ final RTCPeerConnection peerConnection;
+
+ private final String name;
+
+ private final List pendingCandidates = new ArrayList<>();
+
+ private boolean hasRemoteDescription;
+
+ Peer remote;
+
+
+ Peer(PeerConnectionFactory factory, String name) {
+ this.name = name;
+ this.peerConnection = factory.createPeerConnection(new RTCConfiguration(), this);
+ }
+
+ RTCSessionDescription createOffer() throws Exception {
+ CompletableFuture offer = new CompletableFuture<>();
+ peerConnection.createOffer(new RTCOfferOptions(), createObserver(offer));
+
+ return setLocalDescription(offer.get());
+ }
+
+ RTCSessionDescription createAnswer() throws Exception {
+ CompletableFuture answer = new CompletableFuture<>();
+ peerConnection.createAnswer(new RTCAnswerOptions(), createObserver(answer));
+
+ return setLocalDescription(answer.get());
+ }
+
+ RTCSessionDescription setLocalDescription(RTCSessionDescription description)
+ throws Exception {
+ CompletableFuture done = new CompletableFuture<>();
+ peerConnection.setLocalDescription(description, setObserver(done));
+ done.get();
+
+ return description;
+ }
+
+ void setRemoteDescription(RTCSessionDescription description) throws Exception {
+ CompletableFuture done = new CompletableFuture<>();
+ peerConnection.setRemoteDescription(description, setObserver(done));
+ done.get();
+
+ List candidates;
+ synchronized (this) {
+ hasRemoteDescription = true;
+ candidates = new ArrayList<>(pendingCandidates);
+ pendingCandidates.clear();
+ }
+
+ candidates.forEach(peerConnection::addIceCandidate);
+ }
+
+ void addRemoteCandidate(RTCIceCandidate candidate) {
+ synchronized (this) {
+ if (!hasRemoteDescription) {
+ pendingCandidates.add(candidate);
+ return;
+ }
+ }
+
+ peerConnection.addIceCandidate(candidate);
+ }
+
+ void close() {
+ peerConnection.close();
+ }
+
+ @Override
+ public void onIceCandidate(RTCIceCandidate candidate) {
+ remote.addRemoteCandidate(candidate);
+ }
+
+ @Override
+ public void onConnectionChange(RTCPeerConnectionState state) {
+ LOG.info(name + " connection state: " + state);
+
+ if (this == receiver) {
+ connectionState = state;
+ }
+ }
+
+ @Override
+ public void onTrack(RTCRtpTransceiver transceiver) {
+ RTCRtpReceiver rtpReceiver = transceiver.getReceiver();
+
+ onRemoteTrack(rtpReceiver.getTrack());
+
+ // The receiver and transceiver are query results the
+ // application owns. The track itself is unaffected and keeps
+ // delivering frames to its sink.
+ rtpReceiver.dispose();
+ transceiver.dispose();
+ }
+
+ private CreateSessionDescriptionObserver createObserver(
+ CompletableFuture future) {
+ return new CreateSessionDescriptionObserver() {
+
+ @Override
+ public void onSuccess(RTCSessionDescription description) {
+ future.complete(description);
+ }
+
+ @Override
+ public void onFailure(String error) {
+ future.completeExceptionally(new Exception(name + ": " + error));
+ }
+ };
+ }
+
+ private SetSessionDescriptionObserver setObserver(CompletableFuture future) {
+ return new SetSessionDescriptionObserver() {
+
+ @Override
+ public void onSuccess() {
+ future.complete(null);
+ }
+
+ @Override
+ public void onFailure(String error) {
+ future.completeExceptionally(new Exception(name + ": " + error));
+ }
+ };
+ }
+ }
+ }
+
+
+
+ /**
+ * Turns a byte counter that only grows into a bitrate, measured over the
+ * time between two successive readings of it.
+ */
+ private static class RateMeter {
+
+ private long lastBytes;
+
+ private long lastTimestampUs;
+
+
+ /**
+ * Takes the next reading from inbound RTP stats.
+ *
+ * @return The bitrate in bits per second since the last reading, or
+ * {@code null} if there is nothing to compare against yet.
+ */
+ Double update(RTCStats stats) {
+ long bytes = count(stats.getAttributes(), "bytesReceived");
+ long timestampUs = stats.getTimestamp();
+ Double rate = null;
+
+ if (lastTimestampUs > 0 && timestampUs > lastTimestampUs && bytes >= lastBytes) {
+ rate = (bytes - lastBytes) * 8 * 1_000_000.0 / (timestampUs - lastTimestampUs);
+ }
+
+ lastBytes = bytes;
+ lastTimestampUs = timestampUs;
+
+ return rate;
+ }
+ }
+
+
+
+ /**
+ * A column of named values, grouped into sections: what the file
+ * contains, where playback is, and what the receiver gets for each track.
+ * Each value is addressed by a key such as {@code video.fps}, and shows a
+ * dash while there is nothing to show.
+ */
+ private static class MetricsPanel extends JPanel {
+
+ private static final String NONE = "-";
+
+ private final Map values = new LinkedHashMap<>();
+
+ private int row;
+
+
+ MetricsPanel() {
+ super(new GridBagLayout());
+
+ setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12));
+ // Fixed, so the video does not shift as the values change width.
+ setPreferredSize(new Dimension(320, 0));
+
+ addSection("Media");
+ addRow("media.file", "Source");
+ addRow("media.duration", "Duration");
+ addRow("media.video", "Video");
+ addRow("media.audio", "Audio");
+
+ addSection("Playback");
+ addRow("play.state", "State");
+ addRow("play.position", "Position");
+ addRow("play.connection", "Connection");
+
+ addSection("Received video");
+ addRow("video.codec", "Codec");
+ addRow("video.resolution", "Resolution");
+ addRow("video.fps", "Frame rate");
+ addRow("video.bitrate", "Bitrate");
+ addRow("video.frames", "Frames");
+ addRow("video.lost", "Packets lost");
+ addRow("video.jitter", "Jitter");
+
+ addSection("Received audio");
+ addRow("audio.codec", "Codec");
+ addRow("audio.bitrate", "Bitrate");
+ addRow("audio.level", "Level");
+ addRow("audio.concealed", "Concealed");
+ addRow("audio.lost", "Packets lost");
+ addRow("audio.jitter", "Jitter");
+
+ // Takes up the remaining height, keeping the rows at the top.
+ GridBagConstraints filler = new GridBagConstraints();
+ filler.gridy = row;
+ filler.weighty = 1;
+ add(new JPanel(), filler);
+ }
+
+ void showMedia(String file, MediaInfo info) {
+ set("media.file", sourceName(file));
+ // Never the source as typed: a stream URL may carry a password.
+ values.get("media.file").setToolTipText(withoutCredentials(file));
+ set("media.duration", info.getDurationUs() > 0
+ ? formatTime(info.getDurationUs()) : "unknown");
+ set("media.video", info.hasVideo()
+ ? String.format("%s, %dx%d, %.2f fps", info.getVideoCodec(),
+ info.getVideoWidth(), info.getVideoHeight(), info.getFrameRate())
+ : "none");
+ set("media.audio", info.hasAudio()
+ ? String.format("%s, %d Hz, %d ch", info.getAudioCodec(),
+ info.getSampleRate(), info.getChannels())
+ : "none");
+ }
+
+ void set(String key, String text) {
+ values.get(key).setText(text != null ? text : NONE);
+ }
+
+ void setAll(Map newValues) {
+ newValues.forEach(this::set);
+ }
+
+ /**
+ * Resets every value whose key starts with the prefix, all of them
+ * for an empty one.
+ */
+ void clear(String prefix) {
+ values.forEach((key, label) -> {
+ if (key.startsWith(prefix)) {
+ label.setText(NONE);
+ label.setToolTipText(null);
+ }
+ });
+ }
+
+ private void addSection(String title) {
+ JLabel label = new JLabel(title);
+ label.setFont(label.getFont().deriveFont(Font.BOLD));
+
+ GridBagConstraints c = new GridBagConstraints();
+ c.gridx = 0;
+ c.gridy = row;
+ c.gridwidth = 2;
+ c.anchor = GridBagConstraints.WEST;
+ c.insets = new Insets(row == 0 ? 0 : 12, 0, 4, 0);
+
+ add(label, c);
+ row++;
+ }
+
+ private void addRow(String key, String name) {
+ GridBagConstraints c = new GridBagConstraints();
+ c.gridx = 0;
+ c.gridy = row;
+ c.anchor = GridBagConstraints.WEST;
+ c.insets = new Insets(1, 0, 1, 12);
+
+ add(new JLabel(name), c);
+
+ JLabel value = new JLabel(NONE);
+
+ c = new GridBagConstraints();
+ c.gridx = 1;
+ c.gridy = row;
+ c.weightx = 1;
+ c.fill = GridBagConstraints.HORIZONTAL;
+ c.insets = new Insets(1, 0, 1, 0);
+
+ add(value, c);
+ values.put(key, value);
+ row++;
+ }
+ }
+
+
+
+ /**
+ * Draws the most recent received video frame, scaled to fit while keeping
+ * its aspect ratio.
+ *
+ * Frames arrive on a WebRTC thread already timed to match the audio, so
+ * they are converted there and only the drawing is left to the EDT.
+ */
+ private static class VideoPanel extends JPanel implements VideoTrackSink {
+
+ private BufferedImage image;
+
+ private ByteBuffer pixelBuffer;
+
+
+ VideoPanel() {
+ setBackground(Color.BLACK);
+ setPreferredSize(new Dimension(960, 540));
+ }
+
+ @Override
+ public void onVideoFrame(VideoFrame frame) {
+ VideoFrameBuffer buffer = frame.buffer;
+ int width = buffer.getWidth();
+ int height = buffer.getHeight();
+
+ try {
+ synchronized (this) {
+ if (image == null || image.getWidth() != width || image.getHeight() != height) {
+ image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
+ pixelBuffer = ByteBuffer.allocateDirect(width * height * 4)
+ .order(ByteOrder.LITTLE_ENDIAN);
+ }
+
+ // libyuv's ARGB is B, G, R, A in memory, which read as
+ // little-endian ints is exactly what TYPE_INT_RGB holds.
+ pixelBuffer.clear();
+ VideoBufferConverter.convertFromI420(buffer, pixelBuffer, FourCC.ARGB);
+ pixelBuffer.rewind();
+
+ int[] pixels = ((DataBufferInt) image.getRaster().getDataBuffer()).getData();
+ pixelBuffer.asIntBuffer().get(pixels);
+ }
+ }
+ catch (Exception e) {
+ LOG.log(Level.WARNING, "Could not convert a video frame", e);
+ }
+ finally {
+ frame.release();
+ }
+
+ repaint();
+ }
+
+ void clear() {
+ synchronized (this) {
+ image = null;
+ pixelBuffer = null;
+ }
+ repaint();
+ }
+
+ @Override
+ protected void paintComponent(Graphics g) {
+ super.paintComponent(g);
+
+ synchronized (this) {
+ if (image == null) {
+ return;
+ }
+
+ double scale = Math.min((double) getWidth() / image.getWidth(),
+ (double) getHeight() / image.getHeight());
+ int w = (int) (image.getWidth() * scale);
+ int h = (int) (image.getHeight() * scale);
+ int x = (getWidth() - w) / 2;
+ int y = (getHeight() - h) / 2;
+
+ Graphics2D g2 = (Graphics2D) g;
+ g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
+ RenderingHints.VALUE_INTERPOLATION_BILINEAR);
+ g2.drawImage(image, x, y, w, h, null);
+ }
+ }
+ }
+}
diff --git a/webrtc-examples/src/main/java/module-info.java b/webrtc-examples/src/main/java/module-info.java
index b330984a..8f970f32 100644
--- a/webrtc-examples/src/main/java/module-info.java
+++ b/webrtc-examples/src/main/java/module-info.java
@@ -1,11 +1,13 @@
module webrtc.java.examples {
requires com.fasterxml.jackson.databind;
+ requires java.desktop;
requires java.logging;
requires java.net.http;
requires org.eclipse.jetty.server;
requires org.eclipse.jetty.websocket.server;
requires webrtc.java;
+ requires webrtc.java.media;
exports dev.onvoid.webrtc.examples.web.client;
exports dev.onvoid.webrtc.examples.web.server;
diff --git a/webrtc-java-media/README.md b/webrtc-java-media/README.md
index ef048c1c..c496a8f8 100644
--- a/webrtc-java-media/README.md
+++ b/webrtc-java-media/README.md
@@ -37,13 +37,20 @@ FFmpeg is linked dynamically and its libraries ship as separate files inside the
you may replace them with your own build, as the LGPL requires. The wrapper code in this module is
licensed under the Apache License 2.0 like the rest of webrtc-java.
+Each platform jar carries the LGPL text and a notice under `META-INF/licenses/ffmpeg`. The notice
+names the FFmpeg release the libraries are built from, unmodified, and where its source is; how it
+is configured is in `src/main/cpp/dependencies/ffmpeg/CMakeLists.txt`. Some of the formats FFmpeg
+decodes, such as H.264, H.265/HEVC and AAC, may be covered by patents in some countries, and whether
+your use of them needs a patent license is for you to determine.
+
## Building
-The submodule has to be present:
+This module is part of the normal build, and it builds FFmpeg from a submodule, so the submodule
+has to be present:
```shell
git submodule update --init --depth 1 webrtc-java-media/third-party/ffmpeg
-mvn install -Pwith-media-extension
+mvn install
```
The first build compiles FFmpeg, which takes a while; later builds reuse the install directory
diff --git a/webrtc-java-media/pom.xml b/webrtc-java-media/pom.xml
index 45c08edf..d2ba8c2c 100644
--- a/webrtc-java-media/pom.xml
+++ b/webrtc-java-media/pom.xml
@@ -18,17 +18,37 @@
- 7.1.1
+ 8.1${user.home}/ffmpeg/${platform.classifier}Release
+
+ process-sources
+ compile
+
+
+ ${project.basedir}/../webrtc-jni/src/main/cpp/toolchain
+
+ ${user.home}/webrtc/build
@@ -52,6 +72,14 @@
${project.build.directory}/natives
+
+
+ ${ffmpeg.version}
+
@@ -63,6 +91,7 @@
cmake-generate
+ ${cmake.generate.phase}generate
@@ -73,6 +102,12 @@
+
+
@@ -90,6 +125,7 @@
cmake-compile
+ ${cmake.compile.phase}compile
@@ -130,25 +166,98 @@
+
dev.onvoid.webrtcwebrtc-java${project.version}
-
-
-
- ${project.groupId}
- ${project.artifactId}
- ${project.version}
- ${platform.classifier}
-
+
+
+ skip-native-build
+
+
+ skipNativeBuild
+
+
+
+ none
+ none
+
+
+
+
+
+ linux-x86_64
+
+
+ linux
+ amd64
+
+
+
+ ${jni.toolchain.dir}/x86_64-linux-clang.cmake
+
+
+
+ linux-aarch32
+
+
+ linux
+ aarch32
+
+
+
+ ${jni.toolchain.dir}/aarch32-linux-clang.cmake
+
+
+
+ linux-aarch64
+
+
+ linux
+ aarch64
+
+
+
+ ${jni.toolchain.dir}/aarch64-linux-clang.cmake
+
+
+
+ macos-aarch64
+
+
+ mac
+ aarch64
+
+
+
+
+
+ macos-cross-x86_64
+
+
@@ -190,6 +299,7 @@
-AARM64${cmake.build.type}
+ ${jni.toolchain.dir}/aarch64-windows-clang.cmake
diff --git a/webrtc-java-media/src/main/cpp/CMakeLists.txt b/webrtc-java-media/src/main/cpp/CMakeLists.txt
index c9a8c294..85718327 100644
--- a/webrtc-java-media/src/main/cpp/CMakeLists.txt
+++ b/webrtc-java-media/src/main/cpp/CMakeLists.txt
@@ -1,6 +1,27 @@
cmake_minimum_required(VERSION 3.13)
+
+#
+# A cross build is driven by a toolchain file, the same ones webrtc-jni uses:
+# they are where this project says which compiler and which sysroot a target is
+# built with, and having two answers to that would only let them drift apart.
+# It has to be set before project(), which is why it arrives under a name of
+# its own rather than as CMAKE_TOOLCHAIN_FILE.
+#
+if(DEFINED MEDIA_TOOLCHAIN_FILE AND NOT MEDIA_TOOLCHAIN_FILE STREQUAL "")
+ message(STATUS "Toolchain file: ${MEDIA_TOOLCHAIN_FILE}")
+ set(CMAKE_TOOLCHAIN_FILE "${MEDIA_TOOLCHAIN_FILE}")
+endif()
+
project(webrtc-java-media CXX)
+# The toolchain files find a sysroot but leave it to be applied here, after
+# project(), because setting it earlier breaks CMake's own compiler checks.
+if(DEFINED DEFERRED_SYSROOT AND DEFERRED_SYSROOT)
+ message(STATUS "Sysroot: ${DEFERRED_SYSROOT}")
+ set(CMAKE_SYSROOT ${DEFERRED_SYSROOT})
+ set(CMAKE_FIND_ROOT_PATH ${DEFERRED_SYSROOT})
+endif()
+
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -38,6 +59,56 @@ target_include_directories(${PROJECT_NAME}
target_link_libraries(${PROJECT_NAME} PRIVATE ffmpeg)
if(LINUX)
+ #
+ # The toolchain file builds against the Chromium sysroot, so that this
+ # library needs no newer glibc than webrtc-java's does: a machine that can
+ # run one can run the other. It also compiles with -nostdinc++, because
+ # everything Linux in this project uses the libc++ that WebRTC bundles
+ # rather than whatever the build machine happens to have.
+ #
+ # That libc++ is installed beside WebRTC itself, so webrtc-jni has to have
+ # been built before this module on Linux. It is the one thing this module
+ # takes from that build, and it is a C++ standard library, not any part of
+ # WebRTC: no WebRTC symbol is linked here, and the two native libraries
+ # still meet only through the C function table.
+ #
+ set(LIBCPP_CONFIG_DIR "${WEBRTC_INSTALL_DIR}/include/third_party/libc++")
+ set(LIBCPP_INCLUDE_DIR "${LIBCPP_CONFIG_DIR}/include")
+ set(LIBCPP_ABI_INCLUDE_DIR "${WEBRTC_INSTALL_DIR}/include/third_party/libc++abi/include")
+
+ if(NOT EXISTS "${LIBCPP_INCLUDE_DIR}")
+ message(FATAL_ERROR
+ "No libc++ in ${LIBCPP_INCLUDE_DIR}. This module builds against the one WebRTC "
+ "bundles, so webrtc-jni has to be built first, or WEBRTC_INSTALL_DIR has to point "
+ "at where it installed WebRTC.")
+ endif()
+
+ # The first carries __config_site, which the headers in the second need.
+ target_include_directories(${PROJECT_NAME} PRIVATE "${LIBCPP_CONFIG_DIR}")
+ target_include_directories(${PROJECT_NAME} SYSTEM PRIVATE
+ "${LIBCPP_INCLUDE_DIR}" "${LIBCPP_ABI_INCLUDE_DIR}")
+
+ # These two belong with those include directories and have to be set here
+ # rather than left to the toolchain file, which only the x86_64 one does.
+ # webrtc-jni does not notice, because it links the webrtc target and picks
+ # them up from its PUBLIC flags; nothing carries them to this module.
+ #
+ # The bundled libc++ has wanted the hardening mode chosen at configuration
+ # time since branch-heads/7977, and without it every translation unit
+ # fails in __config before it reaches a line of ours.
+ target_compile_definitions(${PROJECT_NAME}
+ PRIVATE _LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE)
+
+ # Clang finds the GCC installation inside the cross sysroots and puts its
+ # libstdc++ headers on the implicit search path, where they collide with
+ # the bundled libc++. Excluding the implicit C++ directories leaves the
+ # bundled headers serving C++ on their own.
+ target_compile_options(${PROJECT_NAME} PRIVATE -nostdinc++)
+
+ target_link_directories(${PROJECT_NAME} PRIVATE "${WEBRTC_INSTALL_DIR}/lib")
+ target_link_libraries(${PROJECT_NAME} PRIVATE
+ -static-libgcc -stdlib=libc++ -lc++ -lc++abi)
+
target_link_libraries(${PROJECT_NAME} PRIVATE dl pthread)
endif()
@@ -55,7 +126,12 @@ endif()
#
set(NATIVES_DIR "${CMAKE_CURRENT_BINARY_DIR}/../natives")
+# The directory is filled afresh on every build, rather than added to: a
+# library that is no longer part of the build, such as one of an earlier
+# FFmpeg release under its older major version, must not stay in it and end
+# up in the jar. The commands below run in the order they are declared.
add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E remove_directory "${NATIVES_DIR}"
COMMAND ${CMAKE_COMMAND} -E make_directory "${NATIVES_DIR}"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$" "${NATIVES_DIR}"
@@ -67,3 +143,30 @@ foreach(RUNTIME_LIB ${FFMPEG_RUNTIME_LIBS})
"${RUNTIME_LIB}" "${NATIVES_DIR}"
COMMENT "Collecting ${RUNTIME_LIB} for the platform jar")
endforeach()
+
+#
+# The LGPL asks for its text to travel with the FFmpeg libraries, along with
+# a notice saying what they are and where their source is. Both go under
+# META-INF, where they cannot collide with anything on a classpath.
+#
+set(FFMPEG_LICENSE_FILE "${CMAKE_CURRENT_SOURCE_DIR}/../../../third-party/ffmpeg/COPYING.LGPLv2.1")
+
+if(NOT EXISTS "${FFMPEG_LICENSE_FILE}")
+ message(FATAL_ERROR
+ "${FFMPEG_LICENSE_FILE} is missing, and the platform jar must not ship FFmpeg "
+ "without it. Check out the FFmpeg submodule.")
+endif()
+
+set(FFMPEG_NOTICE_FILE "${CMAKE_CURRENT_BINARY_DIR}/FFmpeg-NOTICE.txt")
+configure_file("${CMAKE_CURRENT_SOURCE_DIR}/dependencies/ffmpeg/FFmpeg-NOTICE.txt.in"
+ "${FFMPEG_NOTICE_FILE}" @ONLY)
+
+set(FFMPEG_LICENSES_DIR "${NATIVES_DIR}/META-INF/licenses/ffmpeg")
+
+add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD
+ COMMAND ${CMAKE_COMMAND} -E make_directory "${FFMPEG_LICENSES_DIR}"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${FFMPEG_LICENSE_FILE}" "${FFMPEG_LICENSES_DIR}/COPYING.LGPLv2.1"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different
+ "${FFMPEG_NOTICE_FILE}" "${FFMPEG_LICENSES_DIR}/NOTICE.txt"
+ COMMENT "Collecting the FFmpeg license and notice for the platform jar")
diff --git a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt
index 30fe26a8..39f90a5d 100644
--- a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt
+++ b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt
@@ -8,8 +8,8 @@ project(ffmpeg)
#
# The build runs at CMake configure time and installs into FFMPEG_INSTALL_DIR,
# the way the WebRTC dependency of webrtc-jni does. A configure that finds the
-# libraries already installed there skips the whole thing, so only the first
-# build of a platform pays for it.
+# libraries already installed there, built with the same components, skips the
+# whole thing, so only the first build of a platform pays for it.
#
# The configuration is deliberately LGPL only: no --enable-gpl and no
# --enable-nonfree, and the libraries are shared so that they can be replaced,
@@ -25,15 +25,99 @@ if(NOT DEFINED FFMPEG_INSTALL_DIR OR FFMPEG_INSTALL_DIR STREQUAL "")
message(FATAL_ERROR "FFMPEG_INSTALL_DIR is not set")
endif()
+# It arrives from Maven, where the user's home on Windows is a backslash
+# path. CMake reads a backslash as the start of an escape, so C:\Users\...
+# is not only the wrong separator, it is a parse error waiting to happen.
+file(TO_CMAKE_PATH "${FFMPEG_INSTALL_DIR}" FFMPEG_INSTALL_DIR)
+
file(MAKE_DIRECTORY "${FFMPEG_INSTALL_DIR}")
# The libraries this module needs, in dependency order. The order matters
# twice: for the link line, and for the order the Java side loads them in.
set(FFMPEG_LIBS avutil swresample swscale avcodec avformat)
+# The parts of FFmpeg this module plays with. Everything else is left out.
+set(FFMPEG_COMPONENTS
+ # Containers this module reads.
+ --enable-demuxer=mov
+ --enable-demuxer=matroska
+ --enable-demuxer=avi
+ --enable-demuxer=mpegts
+ --enable-demuxer=flv
+ --enable-demuxer=wav
+ --enable-demuxer=mp3
+ --enable-demuxer=ogg
+ --enable-demuxer=flac
+ # Live streams from IP cameras and media servers. The RTSP demuxer brings
+ # the RTP depacketizers along, and with them the demuxers of the formats
+ # RTP can carry (ASF, MOV, MPEG-TS, RealMedia), as well as the HTTP
+ # protocol for RTSP tunnelled over HTTP.
+ --enable-demuxer=rtsp
+ --enable-demuxer=aac
+ # Video this module decodes.
+ --enable-decoder=h264
+ --enable-decoder=hevc
+ --enable-decoder=vp8
+ --enable-decoder=vp9
+ --enable-decoder=mpeg4
+ --enable-decoder=mjpeg
+ # What AVI files carry besides MPEG-4: Microsoft's MPEG-4 variants, which
+ # is what an AVI tagged MPG4, MP42 or DIV3 holds.
+ --enable-decoder=msmpeg4v1
+ --enable-decoder=msmpeg4v2
+ --enable-decoder=msmpeg4v3
+ # Audio this module decodes.
+ --enable-decoder=aac
+ --enable-decoder=mp3
+ --enable-decoder=opus
+ --enable-decoder=vorbis
+ --enable-decoder=flac
+ --enable-decoder=pcm_s16le
+ --enable-decoder=pcm_s16be
+ --enable-decoder=pcm_u8
+ --enable-decoder=pcm_f32le
+ # Audio common in AVI files and in MPEG transport streams.
+ --enable-decoder=mp2
+ --enable-decoder=ac3
+ # Not only for E-AC-3 itself: the AC-3 decoder calls into its tables, and
+ # MSVC, unlike GCC and clang, does not drop those calls when it is left
+ # out, so AC-3 alone fails to link on Windows.
+ --enable-decoder=eac3
+ --enable-decoder=adpcm_ms
+ --enable-decoder=adpcm_ima_wav
+ # Parsers for the decoders above.
+ --enable-parser=h264
+ --enable-parser=hevc
+ --enable-parser=vp8
+ --enable-parser=vp9
+ --enable-parser=mpeg4video
+ --enable-parser=mjpeg
+ --enable-parser=aac
+ --enable-parser=mpegaudio
+ --enable-parser=ac3
+ --enable-parser=opus
+ --enable-parser=vorbis
+ --enable-parser=flac
+ # Local files, and what RTSP runs over. Protocols that need TLS are left
+ # out until a TLS backend is chosen per platform.
+ --enable-protocol=file
+ --enable-protocol=pipe
+ --enable-protocol=rtp
+ --enable-protocol=udp
+ --enable-protocol=tcp
+)
+
+# Written next to an install once it is built, naming the components it was
+# built with. Without it, an install from before a component was added would
+# be taken as good, and a file needing that component would fail to play with
+# nothing to say why.
+set(FFMPEG_COMPONENTS_FILE "${FFMPEG_INSTALL_DIR}/components.txt")
+string(REPLACE ";" "\n" FFMPEG_COMPONENTS_TEXT "${FFMPEG_VERSION};${FFMPEG_COMPONENTS}")
+
#
# Decides whether FFmpeg has to be built, by looking for one of its headers
-# and one of its libraries in the install directory.
+# and one of its libraries in the install directory, and for a record of it
+# having been built with the components listed above.
#
function(ffmpeg_is_installed RESULT)
if(NOT EXISTS "${FFMPEG_INSTALL_DIR}/include/libavformat/avformat.h")
@@ -41,6 +125,19 @@ function(ffmpeg_is_installed RESULT)
return()
endif()
+ if(NOT EXISTS "${FFMPEG_COMPONENTS_FILE}")
+ set(${RESULT} FALSE PARENT_SCOPE)
+ return()
+ endif()
+
+ file(READ "${FFMPEG_COMPONENTS_FILE}" INSTALLED_COMPONENTS)
+
+ if(NOT INSTALLED_COMPONENTS STREQUAL FFMPEG_COMPONENTS_TEXT)
+ message(STATUS "FFmpeg in ${FFMPEG_INSTALL_DIR} was built with other components, rebuilding")
+ set(${RESULT} FALSE PARENT_SCOPE)
+ return()
+ endif()
+
file(GLOB FOUND_LIBS
"${FFMPEG_INSTALL_DIR}/lib/avformat*"
"${FFMPEG_INSTALL_DIR}/lib/libavformat*"
@@ -158,13 +255,18 @@ else()
# configure and make are run by a POSIX shell, which on Windows means MSYS2
# and its /c/... form of a path rather than C:/...
- macro(to_shell_path PATH_IN RESULT)
- set(${RESULT} "${PATH_IN}")
+ # A function rather than a macro on purpose: a macro substitutes its
+ # arguments into the body as text, so a path still holding a backslash
+ # would be parsed as an escape sequence instead of passed as a value.
+ function(to_shell_path PATH_IN RESULT)
+ set(CONVERTED "${PATH_IN}")
if(WIN32)
- string(REGEX REPLACE "^([A-Za-z]):/" "/\\1/" ${RESULT} "${${RESULT}}")
+ string(REGEX REPLACE "^([A-Za-z]):/" "/\\1/" CONVERTED "${CONVERTED}")
endif()
- endmacro()
+
+ set(${RESULT} "${CONVERTED}" PARENT_SCOPE)
+ endfunction()
to_shell_path("${FFMPEG_INSTALL_DIR}" FFMPEG_INSTALL_DIR_SH)
to_shell_path("${FFMPEG_SOURCE_DIR}" FFMPEG_SOURCE_DIR_SH)
@@ -179,78 +281,158 @@ else()
--disable-programs
--disable-avdevice
--disable-avfilter
- --disable-postproc
- --disable-network
--disable-everything
--disable-autodetect
- # Containers this module reads.
- --enable-demuxer=mov
- --enable-demuxer=matroska
- --enable-demuxer=avi
- --enable-demuxer=mpegts
- --enable-demuxer=flv
- --enable-demuxer=wav
- --enable-demuxer=mp3
- --enable-demuxer=ogg
- --enable-demuxer=flac
- --enable-demuxer=aac
- # Video this module decodes.
- --enable-decoder=h264
- --enable-decoder=hevc
- --enable-decoder=vp8
- --enable-decoder=vp9
- --enable-decoder=mpeg4
- --enable-decoder=mjpeg
- # Audio this module decodes.
- --enable-decoder=aac
- --enable-decoder=mp3
- --enable-decoder=opus
- --enable-decoder=vorbis
- --enable-decoder=flac
- --enable-decoder=pcm_s16le
- --enable-decoder=pcm_s16be
- --enable-decoder=pcm_u8
- --enable-decoder=pcm_f32le
- # Parsers for the decoders above.
- --enable-parser=h264
- --enable-parser=hevc
- --enable-parser=vp8
- --enable-parser=vp9
- --enable-parser=mpeg4video
- --enable-parser=mjpeg
- --enable-parser=aac
- --enable-parser=mpegaudio
- --enable-parser=opus
- --enable-parser=vorbis
- --enable-parser=flac
- # Local files only for now; network protocols follow once the TLS
- # backends are wired up per platform.
- --enable-protocol=file
- --enable-protocol=pipe
+ ${FFMPEG_COMPONENTS}
)
+ #
+ # What configure has to be told about the target. It is all worked out from
+ # what the toolchain file already set, so a target CMake can build for
+ # needs no new case here.
+ #
+
+ # The architecture under the name FFmpeg knows it by.
+ function(ffmpeg_arch_name PROCESSOR RESULT)
+ set(NAME "")
+
+ if("${PROCESSOR}" MATCHES "^(x86_64|AMD64|amd64)$")
+ set(NAME x86_64)
+ elseif("${PROCESSOR}" MATCHES "^(aarch64|arm64|ARM64)$")
+ if(APPLE)
+ # Apple's clang knows this architecture as arm64 and rejects
+ # -arch aarch64 outright, which configure reports only as
+ # being unable to create an executable.
+ set(NAME arm64)
+ else()
+ set(NAME aarch64)
+ endif()
+ elseif("${PROCESSOR}" MATCHES "^(arm|armv7.*|armhf)$")
+ set(NAME arm)
+ endif()
+
+ set(${RESULT} "${NAME}" PARENT_SCOPE)
+ endfunction()
+
+ ffmpeg_arch_name("${CMAKE_SYSTEM_PROCESSOR}" FFMPEG_ARCH)
+ ffmpeg_arch_name("${CMAKE_HOST_SYSTEM_PROCESSOR}" FFMPEG_HOST_ARCH)
+
if(WIN32)
- # FFmpeg builds against the MSVC runtime through its own msvc
- # toolchain support, which keeps the DLLs free of an MSYS2 runtime
- # dependency. configure needs Windows-style output, so cl and link
- # must be on the PATH of the shell that runs it.
+ set(FFMPEG_TARGET_OS win64)
+
+ # FFmpeg builds against the MSVC runtime through its own msvc toolchain
+ # support, which keeps the DLLs free of an MSYS2 runtime dependency.
+ # configure needs Windows-style output, so cl and link must be on the
+ # PATH of the shell that runs it.
list(APPEND FFMPEG_CONFIGURE_ARGS --toolchain=msvc)
+ elseif(APPLE)
+ set(FFMPEG_TARGET_OS darwin)
+ else()
+ set(FFMPEG_TARGET_OS linux)
endif()
- if(CMAKE_SYSTEM_PROCESSOR MATCHES "^(aarch64|arm64|ARM64)$" AND WIN32)
- # No assembler for this target yet; correctness first, speed later.
- list(APPEND FFMPEG_CONFIGURE_ARGS --disable-asm --arch=arm64)
+ if(FFMPEG_ARCH)
+ list(APPEND FFMPEG_CONFIGURE_ARGS --arch=${FFMPEG_ARCH})
endif()
+ set(FFMPEG_EXTRA_CFLAGS "")
+ set(FFMPEG_EXTRA_LDFLAGS "")
+
if(APPLE AND CMAKE_OSX_ARCHITECTURES)
+ # On Apple the architecture is chosen by a compiler flag rather than by
+ # a different compiler, and the deployment target has to match what the
+ # rest of the build asks for or the linker complains about every object.
+ set(FFMPEG_EXTRA_CFLAGS "-arch ${CMAKE_OSX_ARCHITECTURES}")
+ set(FFMPEG_EXTRA_LDFLAGS "-arch ${CMAKE_OSX_ARCHITECTURES}")
+
+ if(CMAKE_OSX_DEPLOYMENT_TARGET)
+ set(FFMPEG_EXTRA_CFLAGS
+ "${FFMPEG_EXTRA_CFLAGS} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
+ set(FFMPEG_EXTRA_LDFLAGS
+ "${FFMPEG_EXTRA_LDFLAGS} -mmacosx-version-min=${CMAKE_OSX_DEPLOYMENT_TARGET}")
+ endif()
+ elseif(NOT WIN32)
+ # Elsewhere the target is a compiler flag the toolchain file put into
+ # CMAKE_C_FLAGS, which is exactly what FFmpeg needs to be handed too.
+ set(FFMPEG_EXTRA_CFLAGS "${CMAKE_C_FLAGS}")
+ set(FFMPEG_EXTRA_LDFLAGS "${CMAKE_C_FLAGS}")
+ endif()
+
+ if(CMAKE_SYSROOT)
+ list(APPEND FFMPEG_CONFIGURE_ARGS --sysroot=${CMAKE_SYSROOT})
+ endif()
+
+ # CMake reports a cross build whenever a toolchain file names a system,
+ # even one that matches the host, and every toolchain file here does. What
+ # decides this for FFmpeg is narrower: whether it can run what it builds,
+ # which is a question about the architecture and the operating system.
+ set(FFMPEG_CROSS_COMPILING FALSE)
+
+ if(CMAKE_CROSSCOMPILING)
+ if(NOT CMAKE_SYSTEM_NAME STREQUAL CMAKE_HOST_SYSTEM_NAME
+ OR NOT FFMPEG_ARCH STREQUAL FFMPEG_HOST_ARCH)
+ set(FFMPEG_CROSS_COMPILING TRUE)
+ endif()
+ endif()
+
+ if(FFMPEG_CROSS_COMPILING)
list(APPEND FFMPEG_CONFIGURE_ARGS
- --extra-cflags=-arch\ ${CMAKE_OSX_ARCHITECTURES}
- --extra-ldflags=-arch\ ${CMAKE_OSX_ARCHITECTURES})
+ --enable-cross-compile
+ --target-os=${FFMPEG_TARGET_OS})
+
+ # make install strips what it installs, with whichever strip is on
+ # the PATH, and the host's cannot read a binary built for another
+ # architecture: "unable to recognise the format of the input file".
+ # Shipping unstripped costs a little size and nothing else.
+ list(APPEND FFMPEG_CONFIGURE_ARGS --disable-stripping)
+
+ # An assembler for a foreign architecture is a build prerequisite of
+ # its own, and this module is not fast enough to need one yet.
+ # Correctness on every platform first, speed where it is measured.
+ if(FFMPEG_ARCH STREQUAL "aarch64" OR FFMPEG_ARCH STREQUAL "arm")
+ list(APPEND FFMPEG_CONFIGURE_ARGS --disable-asm)
+ endif()
+ endif()
+
+ # The compiler CMake settled on, so that FFmpeg is built by the same one as
+ # the rest of this module rather than by whatever configure finds first.
+ #
+ # Not on Apple. There CMake names the compiler inside the toolchain, and
+ # calling that directly skips the /usr/bin/cc shim that runs xcrun to
+ # point SDKROOT at an SDK. Without one there is no libSystem to link
+ # against, which configure reports only as being unable to create an
+ # executable. Its own default, plain "cc", goes through the shim.
+ if(NOT WIN32 AND NOT APPLE AND CMAKE_C_COMPILER)
+ list(APPEND FFMPEG_CONFIGURE_ARGS --cc=${CMAKE_C_COMPILER})
+ endif()
+
+ # These carry spaces, so they are quoted here rather than in the script.
+ if(NOT FFMPEG_EXTRA_CFLAGS STREQUAL "")
+ string(STRIP "${FFMPEG_EXTRA_CFLAGS}" FFMPEG_EXTRA_CFLAGS)
+ list(APPEND FFMPEG_CONFIGURE_ARGS "--extra-cflags=\"${FFMPEG_EXTRA_CFLAGS}\"")
+ endif()
+ if(NOT FFMPEG_EXTRA_LDFLAGS STREQUAL "")
+ string(STRIP "${FFMPEG_EXTRA_LDFLAGS}" FFMPEG_EXTRA_LDFLAGS)
+ list(APPEND FFMPEG_CONFIGURE_ARGS "--extra-ldflags=\"${FFMPEG_EXTRA_LDFLAGS}\"")
endif()
# Build out of tree so the submodule working tree stays clean.
set(FFMPEG_BUILD_DIR "${CMAKE_CURRENT_BINARY_DIR}/ffmpeg-build")
- file(MAKE_DIRECTORY "${FFMPEG_BUILD_DIR}")
+
+ # A rebuild starts from nothing. make install only adds to the install
+ # directory, so the libraries of an earlier FFmpeg would stay beside the
+ # new ones, under the older major version in their names, and be packaged
+ # into the jar with them. The install directory is only cleared when the
+ # record of an earlier build of ours shows it is ours to clear, since it
+ # can be pointed anywhere. The build directory always belongs to this
+ # build, and objects compiled from another release are no use to it.
+ if(EXISTS "${FFMPEG_COMPONENTS_FILE}")
+ message(STATUS "Clearing the earlier FFmpeg build in ${FFMPEG_INSTALL_DIR}")
+ file(REMOVE_RECURSE "${FFMPEG_INSTALL_DIR}")
+ endif()
+
+ file(REMOVE_RECURSE "${FFMPEG_BUILD_DIR}")
+ file(MAKE_DIRECTORY "${FFMPEG_INSTALL_DIR}" "${FFMPEG_BUILD_DIR}")
to_shell_path("${FFMPEG_BUILD_DIR}" FFMPEG_BUILD_DIR_SH)
string(REPLACE ";" " " FFMPEG_CONFIGURE_ARGS_STR "${FFMPEG_CONFIGURE_ARGS}")
@@ -293,9 +475,15 @@ fi
"# Generated by CMake. Builds FFmpeg ${FFMPEG_VERSION} for webrtc-java-media.
set -e
${FFMPEG_SHELL_PREAMBLE}cd \"${FFMPEG_BUILD_DIR_SH}\"
+# FFmpeg names its version after the git tag it finds, and a shallow clone of
+# the submodule has none, so it would name itself after a commit hash. Handing
+# make the release the source says it is names it the same way however the
+# source was checked out. It has to be REVISION on the make command line: the
+# makefile sets the revision version.sh reads from it, over the environment.
+FFMPEG_REVISION=\"n$(cat \"${FFMPEG_SOURCE_DIR_SH}/RELEASE\")\"
\"${FFMPEG_SOURCE_DIR_SH}/configure\" ${FFMPEG_CONFIGURE_ARGS_STR}
-\"${FFMPEG_MAKE}\" -j${BUILD_JOBS}
-\"${FFMPEG_MAKE}\" install
+\"${FFMPEG_MAKE}\" -j${BUILD_JOBS} REVISION=\"$FFMPEG_REVISION\"
+\"${FFMPEG_MAKE}\" install REVISION=\"$FFMPEG_REVISION\"
")
if(WIN32)
@@ -329,12 +517,33 @@ set MSYS2_PATH_TYPE=inherit
)
if(NOT BUILD_RESULT EQUAL 0)
+ # configure says almost nothing on the console and writes the actual
+ # reason into config.log, which on a build machine nobody can reach is
+ # the same as writing it nowhere. The tail of it goes to the console.
+ set(FFMPEG_CONFIG_LOG "${FFMPEG_BUILD_DIR}/ffbuild/config.log")
+
+ if(EXISTS "${FFMPEG_CONFIG_LOG}")
+ file(READ "${FFMPEG_CONFIG_LOG}" FFMPEG_CONFIG_LOG_TEXT)
+ string(LENGTH "${FFMPEG_CONFIG_LOG_TEXT}" FFMPEG_CONFIG_LOG_LENGTH)
+
+ if(FFMPEG_CONFIG_LOG_LENGTH GREATER 6000)
+ math(EXPR FFMPEG_CONFIG_LOG_OFFSET "${FFMPEG_CONFIG_LOG_LENGTH} - 6000")
+ string(SUBSTRING "${FFMPEG_CONFIG_LOG_TEXT}"
+ ${FFMPEG_CONFIG_LOG_OFFSET} -1 FFMPEG_CONFIG_LOG_TEXT)
+ endif()
+
+ message("---- tail of ${FFMPEG_CONFIG_LOG} ----")
+ message("${FFMPEG_CONFIG_LOG_TEXT}")
+ message("---- end of config.log ----")
+ endif()
+
message(FATAL_ERROR
"Building FFmpeg failed (${BUILD_RESULT}). The script that ran is "
- "${FFMPEG_BUILD_SCRIPT}; see ${FFMPEG_BUILD_DIR}/ffbuild/config.log for what "
- "configure could not find.")
+ "${FFMPEG_BUILD_SCRIPT}.")
endif()
+ file(WRITE "${FFMPEG_COMPONENTS_FILE}" "${FFMPEG_COMPONENTS_TEXT}")
+
ffmpeg_is_installed(FFMPEG_INSTALLED)
if(NOT FFMPEG_INSTALLED)
diff --git a/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/FFmpeg-NOTICE.txt.in b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/FFmpeg-NOTICE.txt.in
new file mode 100644
index 00000000..b675cd17
--- /dev/null
+++ b/webrtc-java-media/src/main/cpp/dependencies/ffmpeg/FFmpeg-NOTICE.txt.in
@@ -0,0 +1,39 @@
+FFmpeg
+======
+
+The libraries avcodec, avformat, avutil, swresample and swscale in this jar
+are FFmpeg @FFMPEG_VERSION@ (https://ffmpeg.org), licensed under the GNU Lesser
+General Public License version 2.1 or later. The full text of that license is in
+COPYING.LGPLv2.1, next to this file.
+
+They are built without --enable-gpl and without --enable-nonfree, so no part
+of them is under the GPL or a non-free license.
+
+They are linked dynamically: the webrtc-java-media library loads them as
+separate shared libraries, which you may replace with your own build of a
+compatible FFmpeg version.
+
+
+Source code
+-----------
+
+The libraries are built from the FFmpeg release n@FFMPEG_VERSION@, without
+modifications:
+
+ https://github.com/FFmpeg/FFmpeg/tree/n@FFMPEG_VERSION@
+ https://ffmpeg.org/releases/ffmpeg-@FFMPEG_VERSION@.tar.xz
+
+How they are configured and built, including which demuxers, decoders,
+parsers and protocols are enabled, is set out in
+webrtc-java-media/src/main/cpp/dependencies/ffmpeg/CMakeLists.txt of
+webrtc-java, at the release this jar belongs to:
+
+ https://github.com/devopvoid/webrtc-java
+
+
+Patents
+-------
+
+Some of the formats FFmpeg decodes, such as H.264, H.265/HEVC and AAC, may be
+covered by patents in some countries. Whether your use of them needs a patent
+license is for you to determine.
diff --git a/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h b/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h
index 84234f0a..0ceb0092 100644
--- a/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h
+++ b/webrtc-java-media/src/main/cpp/include/JNI_FFmpeg.h
@@ -15,27 +15,27 @@
*/
#include
-/* Header for class dev_onvoid_webrtc_media_ffmpeg_FFmpeg */
+/* Header for class dev_onvoid_webrtc_media_player_FFmpeg */
-#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_FFmpeg
-#define _Included_dev_onvoid_webrtc_media_ffmpeg_FFmpeg
+#ifndef _Included_dev_onvoid_webrtc_media_player_FFmpeg
+#define _Included_dev_onvoid_webrtc_media_player_FFmpeg
#ifdef __cplusplus
extern "C" {
#endif
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_FFmpeg
+ * Class: dev_onvoid_webrtc_media_player_FFmpeg
* Method: version
* Signature: ()Ljava/lang/String;
*/
- JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_version
+ JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_player_FFmpeg_version
(JNIEnv *, jclass);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_FFmpeg
+ * Class: dev_onvoid_webrtc_media_player_FFmpeg
* Method: license
* Signature: ()Ljava/lang/String;
*/
- JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_license
+ JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_player_FFmpeg_license
(JNIEnv *, jclass);
#ifdef __cplusplus
diff --git a/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h b/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h
index fd080bd9..f1222c88 100644
--- a/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h
+++ b/webrtc-java-media/src/main/cpp/include/JNI_MediaPlayer.h
@@ -15,75 +15,75 @@
*/
#include
-/* Header for class dev_onvoid_webrtc_media_ffmpeg_MediaPlayer */
+/* Header for class dev_onvoid_webrtc_media_player_MediaPlayer */
-#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
-#define _Included_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+#ifndef _Included_dev_onvoid_webrtc_media_player_MediaPlayer
+#define _Included_dev_onvoid_webrtc_media_player_MediaPlayer
#ifdef __cplusplus
extern "C" {
#endif
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: create
* Signature: (JJJJ)J
*/
- JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create
+ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_create
(JNIEnv *, jobject, jlong, jlong, jlong, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: start
* Signature: (J)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_start
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_start
(JNIEnv *, jclass, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: suspend
* Signature: (J)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_suspend
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_suspend
(JNIEnv *, jclass, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: seek
* Signature: (JJ)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_seek
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_seek
(JNIEnv *, jclass, jlong, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: setLooping
* Signature: (JZ)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_setLooping
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_setLooping
(JNIEnv *, jclass, jlong, jboolean);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: position
* Signature: (J)J
*/
- JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_position
+ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_position
(JNIEnv *, jclass, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: state
* Signature: (J)I
*/
- JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_state
+ JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_state
(JNIEnv *, jclass, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaPlayer
+ * Class: dev_onvoid_webrtc_media_player_MediaPlayer
* Method: dispose
* Signature: (J)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_dispose
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_dispose
(JNIEnv *, jclass, jlong);
#ifdef __cplusplus
diff --git a/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h b/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h
index 77d8badc..f26d0b8a 100644
--- a/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h
+++ b/webrtc-java-media/src/main/cpp/include/JNI_MediaReader.h
@@ -15,35 +15,35 @@
*/
#include
-/* Header for class dev_onvoid_webrtc_media_ffmpeg_MediaReader */
+/* Header for class dev_onvoid_webrtc_media_player_MediaReader */
-#ifndef _Included_dev_onvoid_webrtc_media_ffmpeg_MediaReader
-#define _Included_dev_onvoid_webrtc_media_ffmpeg_MediaReader
+#ifndef _Included_dev_onvoid_webrtc_media_player_MediaReader
+#define _Included_dev_onvoid_webrtc_media_player_MediaReader
#ifdef __cplusplus
extern "C" {
#endif
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader
+ * Class: dev_onvoid_webrtc_media_player_MediaReader
* Method: open
- * Signature: (Ljava/lang/String;)J
+ * Signature: (Ljava/lang/String;J)J
*/
- JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open
- (JNIEnv *, jclass, jstring);
+ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_open
+ (JNIEnv *, jclass, jstring, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader
+ * Class: dev_onvoid_webrtc_media_player_MediaReader
* Method: info
- * Signature: (J)Ldev/onvoid/webrtc/media/ffmpeg/MediaInfo;
+ * Signature: (J)Ldev/onvoid/webrtc/media/player/MediaInfo;
*/
- JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info
+ JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_info
(JNIEnv *, jclass, jlong);
/*
- * Class: dev_onvoid_webrtc_media_ffmpeg_MediaReader
+ * Class: dev_onvoid_webrtc_media_player_MediaReader
* Method: dispose
* Signature: (J)V
*/
- JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_dispose
+ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_dispose
(JNIEnv *, jclass, jlong);
#ifdef __cplusplus
diff --git a/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h b/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h
index 14a80c91..65a280fa 100644
--- a/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h
+++ b/webrtc-java-media/src/main/cpp/include/media/AudioDecoder.h
@@ -82,12 +82,28 @@ namespace ffmpeg
// Resamples one decoded frame into the pending buffer.
int Resample(const AVFrame * frame);
+ // Moves whatever swresample still holds into the pending buffer,
+ // which it only hands out when told that no more input follows.
+ int DrainResampler();
+
+ // Sets swresample up to convert from the given input to the
+ // output this decoder produces, replacing any earlier setup.
+ int ConfigureResampler(const AVChannelLayout * layout, int format,
+ int sample_rate);
+
// Moves one chunk out of the pending buffer.
void TakeChunk(std::vector & chunk, int64_t * timestamp_us);
AVCodecContext * codec_context_ = nullptr;
SwrContext * swr_context_ = nullptr;
AVFrame * decoded_ = nullptr;
+
+ // The input swresample is set up for. A stream may change any of
+ // these from one frame to the next, and a frame that no longer
+ // matches would be read with the wrong layout.
+ AVChannelLayout in_layout_ = {};
+ int in_format_ = -1;
+ int in_rate_ = 0;
AVRational time_base_ = { 0, 1 };
int channels_ = 0;
@@ -100,6 +116,10 @@ namespace ffmpeg
// packet timing looks like.
int64_t next_timestamp_us_ = 0;
bool have_timestamp_ = false;
+
+ // Set once the resampler has been drained at the end of the
+ // stream, so that it is drained once and not on every call.
+ bool resampler_drained_ = false;
};
}
diff --git a/webrtc-java-media/src/main/cpp/include/media/ErrorText.h b/webrtc-java-media/src/main/cpp/include/media/ErrorText.h
new file mode 100644
index 00000000..64af6d51
--- /dev/null
+++ b/webrtc-java-media/src/main/cpp/include/media/ErrorText.h
@@ -0,0 +1,31 @@
+/*
+ * 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.
+ */
+
+#ifndef WEBRTC_JAVA_MEDIA_ERROR_TEXT_H_
+#define WEBRTC_JAVA_MEDIA_ERROR_TEXT_H_
+
+#include
+
+namespace ffmpeg
+{
+ // What a negative AVERROR code means, in words, for messages that reach
+ // Java. FFmpeg describes most codes itself, but leaves plain errno codes
+ // to the C runtime, and on Windows that has no words for some of the
+ // ones networking produces, such as a timeout.
+ std::string ErrorText(int error);
+}
+
+#endif
diff --git a/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h b/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h
index 187337b8..ef924f6d 100644
--- a/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h
+++ b/webrtc-java-media/src/main/cpp/include/media/JavaPlayerObserver.h
@@ -54,6 +54,8 @@ namespace ffmpeg
// A listener that throws must not be left to surface somewhere
// unrelated later, so anything pending is reported and cleared.
+ bool CanCallJava(JNIEnv * env);
+
void ClearPendingException(JNIEnv * env);
JavaVM * vm_ = nullptr;
diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h b/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h
index 37773ee2..546bd1ec 100644
--- a/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h
+++ b/webrtc-java-media/src/main/cpp/include/media/MediaPacer.h
@@ -58,6 +58,20 @@ namespace ffmpeg
static constexpr size_t kVideoCapacity = 60;
static constexpr size_t kAudioCapacity = 100;
+ // How far audio may run past its capacity while video is short.
+ //
+ // One thread decodes both, in the order the container stores
+ // them, so a full audio queue holds up video as well. Containers
+ // interleave coarsely, and a frame-threaded video decoder hands
+ // out each frame several packets after it was fed, so video can
+ // lag audio in the file by a second or more. Blocking on audio
+ // then leaves the video queue running dry, and its frames arrive
+ // late and all at once, which WebRTC's encoder answers by dropping
+ // all but the last of each burst. Letting audio grow while video
+ // is below half its capacity is what keeps video fed; this bounds
+ // how much, at ten seconds, for a source that has no video at all.
+ static constexpr size_t kAudioOverflowCapacity = 1000;
+
// The sources may be 0, in which case media of that kind is
// dropped rather than delivered.
MediaPacer(const webrtc_java_api * api, void * video_source,
diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h b/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h
index 43c8a742..c27eab85 100644
--- a/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h
+++ b/webrtc-java-media/src/main/cpp/include/media/MediaPlayer.h
@@ -114,6 +114,15 @@ namespace ffmpeg
void PerformSeek(int64_t position_us);
void SetState(int state);
+
+ // Records the state, with the lock held. Returns whether it
+ // changed, in which case the caller reports it with NotifyState
+ // once the lock is released.
+ bool UpdateStateLocked(int state);
+ void NotifyState(int state);
+
+ // Stops playback where it is, as a pause does, and reports the
+ // error. The thread stays, so that playback can be resumed.
void ReportError(const std::string & message, int error);
std::unique_ptr reader_;
diff --git a/webrtc-java-media/src/main/cpp/include/media/MediaReader.h b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h
index bf38a4c7..f51f2c49 100644
--- a/webrtc-java-media/src/main/cpp/include/media/MediaReader.h
+++ b/webrtc-java-media/src/main/cpp/include/media/MediaReader.h
@@ -17,6 +17,8 @@
#ifndef WEBRTC_JAVA_MEDIA_MEDIA_READER_H_
#define WEBRTC_JAVA_MEDIA_MEDIA_READER_H_
+#include
+#include
#include
extern "C" {
@@ -27,14 +29,22 @@ namespace ffmpeg
{
// Opens a media source with libavformat and reports what it contains.
//
- // A source is anything libavformat accepts: a file path today, and an
- // http, rtsp or rtmp URL once those protocols are enabled in the build.
- // Opening reads the container and picks the streams that will be played.
- // Decoding is built on top of this and does not belong here.
+ // A source is a file path or an rtsp:// URL. Opening reads the container
+ // and picks the streams that will be played. Decoding is built on top of
+ // this and does not belong here.
+ //
+ // Every call that can block on the source, which for a network source is
+ // every call that touches it, gives up once the timeout has passed, and
+ // Interrupt() makes one give up at once from another thread.
class MediaReader
{
public:
- MediaReader() = default;
+ // How long a single blocking call may take when no timeout is
+ // given: opening, reading a packet, seeking or closing.
+ static constexpr int64_t kDefaultTimeoutUs = 10 * 1000 * 1000;
+
+ // A timeout of 0 or less means no timeout.
+ explicit MediaReader(int64_t timeout_us = kDefaultTimeoutUs);
~MediaReader();
MediaReader(const MediaReader &) = delete;
@@ -43,8 +53,14 @@ namespace ffmpeg
// Opens the given source and selects the video and audio stream
// that are meant to be played. Returns 0, or the negative AVERROR
// libavformat reported, in which case the reader stays closed.
+ // Running out of time reads as AVERROR(ETIMEDOUT).
int Open(const std::string & url);
+ // Makes the call blocking on the source return at once, and
+ // every later one too. May be called from any thread; the reader
+ // can only be closed afterwards.
+ void Interrupt();
+
// Releases the container. Does nothing on a closed reader, and
// runs from the destructor.
void Close();
@@ -83,7 +99,8 @@ namespace ffmpeg
// Reads the next packet of any stream into the given packet, which
// the caller unrefs. Returns 0, AVERROR_EOF once the source is
- // exhausted, or another negative AVERROR.
+ // exhausted, AVERROR(ETIMEDOUT) if nothing arrived in time,
+ // AVERROR_EXIT once interrupted, or another negative AVERROR.
int ReadPacket(AVPacket * packet);
// Moves to the keyframe at or before the given position, in
@@ -92,9 +109,34 @@ namespace ffmpeg
int Seek(int64_t position_us);
private:
+ // Polled by libavformat while it blocks. Returning non-zero
+ // makes the blocking call fail with AVERROR_EXIT.
+ static int OnInterrupt(void * opaque);
+
+ // Starts the time the next blocking call has, and ends it.
+ void BeginBlocking();
+ void EndBlocking();
+
+ // A blocking call that failed because it ran out of time rather
+ // than because it was interrupted is reported as a timeout.
+ int TranslateError(int error) const;
+
AVFormatContext * format_context_ = nullptr;
int video_stream_index_ = -1;
int audio_stream_index_ = -1;
+
+ const int64_t timeout_us_;
+
+ // When the current blocking call runs out of time, on the clock
+ // of av_gettime_relative(), or 0 while none is running.
+ std::atomic deadline_us_{ 0 };
+ std::atomic interrupted_{ false };
+
+ // Set when the callback ended the current blocking call because
+ // its time was up. libavformat does not always hand back the
+ // AVERROR_EXIT the callback causes; an aborted RTSP exchange, for
+ // one, can come back as an I/O error.
+ std::atomic timed_out_{ false };
};
}
diff --git a/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp b/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp
index dd8c7285..e66a1d47 100644
--- a/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp
+++ b/webrtc-java-media/src/main/cpp/src/JNI_FFmpeg.cpp
@@ -21,7 +21,7 @@ extern "C" {
#include
}
-JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_version
+JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_player_FFmpeg_version
(JNIEnv * env, jclass caller)
{
// The version of the libraries that were actually loaded, which is not
@@ -30,7 +30,7 @@ JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_version
return env->NewStringUTF(av_version_info());
}
-JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_FFmpeg_license
+JNIEXPORT jstring JNICALL Java_dev_onvoid_webrtc_media_player_FFmpeg_license
(JNIEnv * env, jclass caller)
{
return env->NewStringUTF(avformat_license());
diff --git a/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp b/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp
index 13a6e2a6..3120128b 100644
--- a/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp
+++ b/webrtc-java-media/src/main/cpp/src/JNI_MediaPlayer.cpp
@@ -15,6 +15,7 @@
*/
#include "JNI_MediaPlayer.h"
+#include "media/ErrorText.h"
#include "media/JavaPlayerObserver.h"
#include "media/MediaPlayer.h"
#include "media/MediaReader.h"
@@ -29,17 +30,6 @@ extern "C" {
namespace
{
- std::string ErrorMessage(int error)
- {
- char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 };
-
- if (av_strerror(error, buffer, sizeof(buffer)) < 0) {
- return "Unknown FFmpeg error " + std::to_string(error);
- }
-
- return buffer;
- }
-
void ThrowIOException(JNIEnv * env, const std::string & message)
{
jclass cls = env->FindClass("java/io/IOException");
@@ -56,7 +46,7 @@ namespace
}
}
-JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create
+JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_create
(JNIEnv * env, jobject caller, jlong readerHandle, jlong tableAddress,
jlong videoSourceHandle, jlong audioSourceHandle)
{
@@ -86,6 +76,15 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create
return 0;
}
+ if (api->size < sizeof(webrtc_java_api)) {
+ // The same version, but from before the members this module relies
+ // on were appended.
+ ThrowIOException(env, "The loaded webrtc-java library provides "
+ + std::to_string(api->size) + " bytes of its interface, but this module needs "
+ + std::to_string(sizeof(webrtc_java_api)));
+
+ return 0;
+ }
auto player = std::make_unique(std::move(reader), api,
reinterpret_cast(videoSourceHandle),
@@ -96,7 +95,14 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create
int result = player->Initialize();
if (result < 0) {
- ThrowIOException(env, "Opening the decoders failed: " + ErrorMessage(result));
+ // Released before throwing: closing reports the closed state to the
+ // observer, which calls into Java, and a call into Java with an
+ // exception pending is not allowed, and would also clear it. Nothing
+ // is listening yet anyway, so the observer goes first.
+ player->SetObserver(nullptr);
+ player.reset();
+
+ ThrowIOException(env, "Opening the decoders failed: " + ffmpeg::ErrorText(result));
return 0;
}
@@ -104,7 +110,7 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_create
return reinterpret_cast(player.release());
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_start
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_start
(JNIEnv * env, jclass caller, jlong handle)
{
if (handle != 0) {
@@ -112,7 +118,7 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_start
}
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_suspend
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_suspend
(JNIEnv * env, jclass caller, jlong handle)
{
if (handle != 0) {
@@ -120,7 +126,7 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_suspend
}
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_seek
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_seek
(JNIEnv * env, jclass caller, jlong handle, jlong positionUs)
{
if (handle != 0) {
@@ -128,7 +134,7 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_seek
}
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_setLooping
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_setLooping
(JNIEnv * env, jclass caller, jlong handle, jboolean looping)
{
if (handle != 0) {
@@ -136,13 +142,13 @@ JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_setLoopin
}
}
-JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_position
+JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_position
(JNIEnv * env, jclass caller, jlong handle)
{
return handle != 0 ? PlayerOf(handle)->GetPositionUs() : 0;
}
-JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_state
+JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_state
(JNIEnv * env, jclass caller, jlong handle)
{
// A player that is gone is closed, which is what the Java side reports
@@ -150,7 +156,7 @@ JNIEXPORT jint JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_state
return handle != 0 ? PlayerOf(handle)->GetState() : ffmpeg::kClosed;
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaPlayer_dispose
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaPlayer_dispose
(JNIEnv * env, jclass caller, jlong handle)
{
// Closing twice is allowed, so a handle that is already zero is simply
diff --git a/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp b/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp
index 3e952f7a..07781a6f 100644
--- a/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp
+++ b/webrtc-java-media/src/main/cpp/src/JNI_MediaReader.cpp
@@ -15,6 +15,7 @@
*/
#include "JNI_MediaReader.h"
+#include "media/ErrorText.h"
#include "media/MediaReader.h"
#include
@@ -25,18 +26,27 @@ extern "C" {
namespace
{
- // Turns an AVERROR code into the message FFmpeg has for it, so that a
- // failure to open reaches Java saying what libavformat actually objected
- // to instead of a bare number.
- std::string ErrorMessage(int error)
+ // The source as it may be shown: a URL loses the user name and password
+ // it carries in front of its host, since error messages end up in logs.
+ // Anything that is not a URL with credentials is returned as it is.
+ std::string WithoutCredentials(const std::string & source)
{
- char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 };
+ const size_t scheme_end = source.find("://");
- if (av_strerror(error, buffer, sizeof(buffer)) < 0) {
- return "Unknown FFmpeg error " + std::to_string(error);
+ if (scheme_end == std::string::npos) {
+ return source;
}
- return buffer;
+ const size_t authority_start = scheme_end + 3;
+ const size_t authority_end = source.find_first_of("/?#", authority_start);
+ const size_t at = source.rfind('@', authority_end == std::string::npos
+ ? std::string::npos : authority_end - 1);
+
+ if (at == std::string::npos || at < authority_start) {
+ return source;
+ }
+
+ return source.substr(0, authority_start) + source.substr(at + 1);
}
void ThrowIOException(JNIEnv * env, const std::string & message)
@@ -66,8 +76,8 @@ namespace
}
}
-JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open
-(JNIEnv * env, jclass caller, jstring source)
+JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_open
+(JNIEnv * env, jclass caller, jstring source, jlong timeoutUs)
{
if (source == nullptr) {
ThrowIOException(env, "Source must not be null");
@@ -86,13 +96,13 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open
env->ReleaseStringUTFChars(source, chars);
- auto reader = new ffmpeg::MediaReader();
+ auto reader = new ffmpeg::MediaReader(static_cast(timeoutUs));
int result = reader->Open(url);
if (result < 0) {
delete reader;
- ThrowIOException(env, "Opening '" + url + "' failed: " + ErrorMessage(result));
+ ThrowIOException(env, "Opening '" + WithoutCredentials(url) + "' failed: " + ffmpeg::ErrorText(result));
return 0;
}
@@ -100,7 +110,7 @@ JNIEXPORT jlong JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_open
return reinterpret_cast(reader);
}
-JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info
+JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_info
(JNIEnv * env, jclass caller, jlong handle)
{
ffmpeg::MediaReader * reader = ReaderOf(env, handle);
@@ -109,7 +119,7 @@ JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info
return nullptr;
}
- jclass cls = env->FindClass("dev/onvoid/webrtc/media/ffmpeg/MediaInfo");
+ jclass cls = env->FindClass("dev/onvoid/webrtc/media/player/MediaInfo");
if (cls == nullptr) {
return nullptr;
@@ -153,7 +163,7 @@ JNIEXPORT jobject JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_info
return info;
}
-JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_ffmpeg_MediaReader_dispose
+JNIEXPORT void JNICALL Java_dev_onvoid_webrtc_media_player_MediaReader_dispose
(JNIEnv * env, jclass caller, jlong handle)
{
// Closing twice is allowed, so a handle that is already zero is simply
diff --git a/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp b/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp
index c1594cac..ca1a9594 100644
--- a/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp
+++ b/webrtc-java-media/src/main/cpp/src/media/AudioDecoder.cpp
@@ -68,26 +68,13 @@ namespace ffmpeg
// WebRTC takes mono or stereo, so anything wider is downmixed. The
// downmix itself is swresample's job.
+ // The output keeps this channel count for the whole stream, even if
+ // the stream's own count changes later: the pending buffer and the
+ // source it feeds both assume one.
channels_ = codec_context_->ch_layout.nb_channels >= 2 ? 2 : 1;
- AVChannelLayout out_layout;
-
- av_channel_layout_default(&out_layout, channels_);
-
- result = swr_alloc_set_opts2(&swr_context_,
- &out_layout, AV_SAMPLE_FMT_S16, kSampleRate,
- &codec_context_->ch_layout, codec_context_->sample_fmt,
- codec_context_->sample_rate, 0, nullptr);
-
- av_channel_layout_uninit(&out_layout);
-
- if (result < 0) {
- Close();
-
- return result;
- }
-
- result = swr_init(swr_context_);
+ result = ConfigureResampler(&codec_context_->ch_layout,
+ codec_context_->sample_fmt, codec_context_->sample_rate);
if (result < 0) {
Close();
@@ -120,12 +107,17 @@ namespace ffmpeg
avcodec_free_context(&codec_context_);
}
+ av_channel_layout_uninit(&in_layout_);
+ in_format_ = -1;
+ in_rate_ = 0;
+
pending_.clear();
time_base_ = { 0, 1 };
channels_ = 0;
next_timestamp_us_ = 0;
have_timestamp_ = false;
+ resampler_drained_ = false;
}
void AudioDecoder::Flush()
@@ -133,8 +125,15 @@ namespace ffmpeg
if (codec_context_ != nullptr) {
avcodec_flush_buffers(codec_context_);
}
+ if (swr_context_ != nullptr) {
+ // What swresample holds back belongs to the old position as much
+ // as what is pending does. Initializing it again drops that and
+ // keeps its setup.
+ swr_init(swr_context_);
+ }
pending_.clear();
+ resampler_drained_ = false;
// The next frame decoded tells where the audio now starts.
have_timestamp_ = false;
@@ -161,6 +160,20 @@ namespace ffmpeg
int result = avcodec_receive_frame(codec_context_, decoded_);
if (result == AVERROR_EOF) {
+ if (!resampler_drained_) {
+ // swresample holds the last few samples back, waiting for
+ // more input to filter them with. There is none, so they
+ // have to be asked for, or the stream ends short.
+ resampler_drained_ = true;
+
+ result = DrainResampler();
+
+ if (result < 0) {
+ return result;
+ }
+
+ continue;
+ }
if (pending_.empty()) {
return AVERROR_EOF;
}
@@ -201,11 +214,29 @@ namespace ffmpeg
have_timestamp_ = true;
}
+ if (frame->format != in_format_ || frame->sample_rate != in_rate_
+ || av_channel_layout_compare(&frame->ch_layout, &in_layout_) != 0) {
+ // The stream changed its format. What swresample still holds of
+ // the old one comes out first, since it plays before this frame.
+ int result = DrainResampler();
+
+ if (result < 0) {
+ return result;
+ }
+
+ result = ConfigureResampler(&frame->ch_layout, frame->format,
+ frame->sample_rate);
+
+ if (result < 0) {
+ return result;
+ }
+ }
+
// swresample holds samples back when rates differ, and those come out
// of a later call, so the room needed is the delay plus this frame.
- int64_t delay = swr_get_delay(swr_context_, codec_context_->sample_rate);
+ int64_t delay = swr_get_delay(swr_context_, in_rate_);
int64_t capacity = av_rescale_rnd(delay + frame->nb_samples, kSampleRate,
- codec_context_->sample_rate, AV_ROUND_UP);
+ in_rate_, AV_ROUND_UP);
if (capacity <= 0) {
return 0;
@@ -232,6 +263,113 @@ namespace ffmpeg
return 0;
}
+ int AudioDecoder::DrainResampler()
+ {
+ if (swr_context_ == nullptr) {
+ return 0;
+ }
+
+ for (;;) {
+ int capacity = swr_get_out_samples(swr_context_, 0);
+
+ if (capacity <= 0) {
+ return 0;
+ }
+
+ const size_t offset = pending_.size();
+
+ pending_.resize(offset + static_cast(capacity) * channels_);
+
+ uint8_t * output = reinterpret_cast(pending_.data() + offset);
+
+ // No input is what tells swresample to hand out what it holds.
+ int converted = swr_convert(swr_context_, &output, capacity, nullptr, 0);
+
+ if (converted < 0) {
+ pending_.resize(offset);
+
+ return converted;
+ }
+
+ pending_.resize(offset + static_cast(converted) * channels_);
+
+ if (converted == 0) {
+ return 0;
+ }
+ }
+ }
+
+ int AudioDecoder::ConfigureResampler(const AVChannelLayout * layout, int format,
+ int sample_rate)
+ {
+ if (layout == nullptr || layout->nb_channels <= 0 || sample_rate <= 0) {
+ return AVERROR(EINVAL);
+ }
+
+ if (swr_context_ != nullptr) {
+ swr_free(&swr_context_);
+ }
+
+ av_channel_layout_uninit(&in_layout_);
+ in_format_ = -1;
+ in_rate_ = 0;
+
+ // A layout that only gives a channel count leaves swresample nothing
+ // to build a downmix from, so it is read as the usual layout for that
+ // many channels. What is remembered is the frame's own layout, so the
+ // next frame compares equal to it.
+ AVChannelLayout mix_layout = {};
+ int result = 0;
+
+ if (layout->order == AV_CHANNEL_ORDER_UNSPEC) {
+ av_channel_layout_default(&mix_layout, layout->nb_channels);
+ }
+ else {
+ result = av_channel_layout_copy(&mix_layout, layout);
+ }
+
+ if (result < 0) {
+ return result;
+ }
+
+ AVChannelLayout out_layout = {};
+
+ av_channel_layout_default(&out_layout, channels_);
+
+ result = swr_alloc_set_opts2(&swr_context_,
+ &out_layout, AV_SAMPLE_FMT_S16, kSampleRate,
+ &mix_layout, static_cast(format), sample_rate,
+ 0, nullptr);
+
+ av_channel_layout_uninit(&out_layout);
+ av_channel_layout_uninit(&mix_layout);
+
+ if (result < 0) {
+ return result;
+ }
+
+ result = swr_init(swr_context_);
+
+ if (result < 0) {
+ swr_free(&swr_context_);
+
+ return result;
+ }
+
+ result = av_channel_layout_copy(&in_layout_, layout);
+
+ if (result < 0) {
+ swr_free(&swr_context_);
+
+ return result;
+ }
+
+ in_format_ = format;
+ in_rate_ = sample_rate;
+
+ return 0;
+ }
+
void AudioDecoder::TakeChunk(std::vector & chunk, int64_t * timestamp_us)
{
const size_t wanted = static_cast(kFramesPerChunk) * channels_;
diff --git a/webrtc-java-media/src/main/cpp/src/media/ErrorText.cpp b/webrtc-java-media/src/main/cpp/src/media/ErrorText.cpp
new file mode 100644
index 00000000..31ab51d1
--- /dev/null
+++ b/webrtc-java-media/src/main/cpp/src/media/ErrorText.cpp
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+#include "media/ErrorText.h"
+
+#include
+
+extern "C" {
+#include
+}
+
+namespace
+{
+ struct NamedError
+ {
+ int error;
+ const char * text;
+ };
+
+ // Spelled out rather than left to av_strerror, which hands plain errno
+ // codes to the C runtime: its words for these differ between platforms,
+ // and MSVC's has none at all for the networking ones.
+ const NamedError kNamedErrors[] = {
+ { AVERROR(ETIMEDOUT), "Timed out" },
+ { AVERROR(ECONNREFUSED), "Connection refused" },
+ { AVERROR(ECONNRESET), "Connection reset" },
+ { AVERROR(ECONNABORTED), "Connection aborted" },
+ { AVERROR(EHOSTUNREACH), "Host unreachable" },
+ { AVERROR(ENETUNREACH), "Network unreachable" },
+#ifdef _WIN32
+ // FFmpeg maps only a few Winsock errors to errno codes, and hands
+ // the others on as the negated Winsock code. These are the ones a
+ // dropped or unreachable stream produces.
+ { -10050, "Network is down" }, // WSAENETDOWN
+ { -10051, "Network unreachable" }, // WSAENETUNREACH
+ { -10053, "Connection aborted" }, // WSAECONNABORTED
+ { -10054, "Connection reset" }, // WSAECONNRESET
+ { -10065, "Host unreachable" }, // WSAEHOSTUNREACH
+#endif
+ };
+}
+
+namespace ffmpeg
+{
+ std::string ErrorText(int error)
+ {
+ for (const NamedError & named : kNamedErrors) {
+ if (named.error == error) {
+ return named.text;
+ }
+ }
+
+ char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 };
+
+ if (av_strerror(error, buffer, sizeof(buffer)) < 0) {
+ return "FFmpeg error " + std::to_string(error);
+ }
+
+ return buffer;
+ }
+}
diff --git a/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp b/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp
index 02d8c160..57fbcd5c 100644
--- a/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp
+++ b/webrtc-java-media/src/main/cpp/src/media/JavaPlayerObserver.cpp
@@ -72,7 +72,7 @@ namespace ffmpeg
bool attached = false;
JNIEnv * env = Attach(&attached);
- if (env != nullptr) {
+ if (CanCallJava(env)) {
env->CallVoidMethod(player_, on_state_changed_, static_cast(state));
ClearPendingException(env);
@@ -90,7 +90,7 @@ namespace ffmpeg
bool attached = false;
JNIEnv * env = Attach(&attached);
- if (env != nullptr) {
+ if (CanCallJava(env)) {
env->CallVoidMethod(player_, on_end_of_stream_);
ClearPendingException(env);
@@ -108,7 +108,7 @@ namespace ffmpeg
bool attached = false;
JNIEnv * env = Attach(&attached);
- if (env != nullptr) {
+ if (CanCallJava(env)) {
jstring text = env->NewStringUTF(message.c_str());
if (text != nullptr) {
@@ -163,6 +163,15 @@ namespace ffmpeg
}
}
+ bool JavaPlayerObserver::CanCallJava(JNIEnv * env)
+ {
+ // A thread that is already carrying an exception is one returning
+ // from native code into Java, which is about to see it thrown. A call
+ // into Java now is not allowed, and clearing afterwards would swallow
+ // an exception that was never ours, so the event is dropped instead.
+ return env != nullptr && env->ExceptionCheck() == JNI_FALSE;
+ }
+
void JavaPlayerObserver::ClearPendingException(JNIEnv * env)
{
if (env->ExceptionCheck() == JNI_TRUE) {
diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp
index 9d7fdba7..fdb560a5 100644
--- a/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp
+++ b/webrtc-java-media/src/main/cpp/src/media/MediaPacer.cpp
@@ -69,11 +69,29 @@ namespace ffmpeg
video_source_(video_source),
audio_source_(audio_source)
{
+ // The pacing thread pushes into the sources long after the call that
+ // handed them over, so they must not go away when the application
+ // disposes of them first.
+ if (video_source_ != nullptr) {
+ api_->video_source_retain(video_source_);
+ }
+ if (audio_source_ != nullptr) {
+ api_->audio_source_retain(audio_source_);
+ }
}
MediaPacer::~MediaPacer()
{
Stop();
+
+ // Nothing pushes any more. A frame WebRTC still holds does not need
+ // the source, only its release callback, which frees the frame alone.
+ if (video_source_ != nullptr) {
+ api_->video_source_release(video_source_);
+ }
+ if (audio_source_ != nullptr) {
+ api_->audio_source_release(audio_source_);
+ }
}
void MediaPacer::Start()
@@ -144,7 +162,14 @@ namespace ffmpeg
std::unique_lock lock(mutex_);
audio_space_.wait(lock, [this] {
- return !running_ || audio_queue_.size() < kAudioCapacity;
+ if (!running_ || audio_queue_.size() < kAudioCapacity) {
+ return true;
+ }
+
+ // Full, but blocking now would keep video from being decoded
+ // while it is running short.
+ return audio_queue_.size() < kAudioOverflowCapacity
+ && video_queue_.size() < kVideoCapacity / 2;
});
if (!running_) {
@@ -263,6 +288,9 @@ namespace ffmpeg
position_us_ = item.timestamp_us;
video_space_.notify_one();
+ // A shorter video queue may be what lets audio past its
+ // capacity.
+ audio_space_.notify_one();
lock.unlock();
DeliverVideo(std::move(item), due);
diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp
index 72695e87..3e007847 100644
--- a/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp
+++ b/webrtc-java-media/src/main/cpp/src/media/MediaPlayer.cpp
@@ -15,6 +15,7 @@
*/
#include "media/MediaPlayer.h"
+#include "media/ErrorText.h"
#include
#include
@@ -23,20 +24,6 @@ extern "C" {
#include
}
-namespace
-{
- std::string ErrorText(int error)
- {
- char buffer[AV_ERROR_MAX_STRING_SIZE] = { 0 };
-
- if (av_strerror(error, buffer, sizeof(buffer)) < 0) {
- return "error " + std::to_string(error);
- }
-
- return buffer;
- }
-}
-
namespace ffmpeg
{
MediaPlayer::MediaPlayer(std::unique_ptr reader,
@@ -103,6 +90,8 @@ namespace ffmpeg
void MediaPlayer::Play()
{
+ bool changed = false;
+
{
std::lock_guard lock(mutex_);
@@ -120,16 +109,23 @@ namespace ffmpeg
seek_position_us_ = 0;
}
+ // Under the lock, together with the flag: the decode thread stops
+ // playback on an error, and the two must not interleave.
+ pacer_->Resume();
+ changed = UpdateStateLocked(kPlaying);
+
command_.notify_all();
}
- pacer_->Resume();
-
- SetState(kPlaying);
+ if (changed) {
+ NotifyState(kPlaying);
+ }
}
void MediaPlayer::Pause()
{
+ bool changed = false;
+
{
std::lock_guard lock(mutex_);
@@ -138,11 +134,14 @@ namespace ffmpeg
}
playing_ = false;
- }
- pacer_->Pause();
+ pacer_->Pause();
+ changed = UpdateStateLocked(kPaused);
+ }
- SetState(kPaused);
+ if (changed) {
+ NotifyState(kPaused);
+ }
}
void MediaPlayer::Seek(int64_t position_us)
@@ -193,6 +192,13 @@ namespace ffmpeg
command_.notify_all();
}
+ // A decode thread blocked reading a source that has gone quiet, such
+ // as a stalled network stream, would otherwise hold up the join below
+ // until the read timed out.
+ if (reader_ != nullptr) {
+ reader_->Interrupt();
+ }
+
// Stopping the pacer is what releases a decode thread waiting for room
// in a queue that nothing is draining any more.
pacer_->Stop();
@@ -266,7 +272,10 @@ namespace ffmpeg
bool end_of_stream = false;
if (!PumpOnce(packet, &end_of_stream)) {
- break;
+ // Playback has stopped and been reported. The thread stays,
+ // so that playing again carries on past what failed, and a
+ // seek can move away from it.
+ continue;
}
if (!end_of_stream) {
@@ -277,7 +286,9 @@ namespace ffmpeg
// goes out before anything is rewound.
DrainDecoders();
- if (looping_.load()) {
+ // A source that cannot be rewound, such as a live stream that has
+ // ended, cannot start over either, and ends as if not looping.
+ if (looping_.load() && reader_->Seek(0) >= 0) {
int64_t advance = reader_->GetDurationUs();
if (advance <= 0) {
@@ -288,7 +299,6 @@ namespace ffmpeg
loop_offset_us_.fetch_add(advance);
- reader_->Seek(0);
video_decoder_.Flush();
audio_decoder_.Flush();
@@ -477,39 +487,70 @@ namespace ffmpeg
void MediaPlayer::SetState(int state)
{
- MediaPlayerObserver * observer = nullptr;
+ bool changed = false;
{
std::lock_guard lock(mutex_);
- if (state_ == state) {
- return;
- }
+ changed = UpdateStateLocked(state);
+ }
+
+ if (changed) {
+ NotifyState(state);
+ }
+ }
- state_ = state;
- observer = observer_.get();
+ bool MediaPlayer::UpdateStateLocked(int state)
+ {
+ if (state_ == state) {
+ return false;
}
+ state_ = state;
+
+ return true;
+ }
+
+ void MediaPlayer::NotifyState(int state)
+ {
// Called with the lock released: an observer runs Java code, which
- // must never happen underneath a lock of ours.
- if (observer != nullptr) {
- observer->OnStateChanged(state);
+ // must never happen underneath a lock of ours. The observer does not
+ // change once the thread runs, so it is read without the lock.
+ if (observer_ != nullptr) {
+ observer_->OnStateChanged(state);
}
}
void MediaPlayer::ReportError(const std::string & message, int error)
{
- MediaPlayerObserver * observer = nullptr;
+ bool changed = false;
{
std::lock_guard lock(mutex_);
- playing_ = false;
- observer = observer_.get();
+ // A player being closed fails whatever it was waiting on on
+ // purpose, since closing interrupts the reader. That is not an
+ // error of the source, and nobody should hear of it.
+ if (closing_) {
+ return;
+ }
+
+ // Playback stops where it failed, as if paused: what is queued
+ // stays queued, and playing again carries on from here.
+ if (playing_) {
+ playing_ = false;
+
+ pacer_->Pause();
+ changed = UpdateStateLocked(kPaused);
+ }
+ }
+
+ if (changed) {
+ NotifyState(kPaused);
}
- if (observer != nullptr) {
- observer->OnError(message + ": " + ErrorText(error));
+ if (observer_ != nullptr) {
+ observer_->OnError(message + ": " + ErrorText(error));
}
}
}
diff --git a/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp
index 32493604..1c0eb001 100644
--- a/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp
+++ b/webrtc-java-media/src/main/cpp/src/media/MediaReader.cpp
@@ -16,13 +16,32 @@
#include "media/MediaReader.h"
+#include
+
extern "C" {
#include
+#include
#include
}
+namespace
+{
+ // The protocols a source may use, including the ones a source opens on
+ // its own behalf, such as the TCP connection behind an rtsp:// URL. The
+ // build also carries HTTP, for RTSP tunnelled over it, which is left out
+ // here: nothing this module plays is meant to fetch an arbitrary URL.
+ constexpr const char * kProtocolWhitelist = "file,pipe,rtsp,rtp,udp,tcp";
+
+ std::once_flag network_initialized;
+}
+
namespace ffmpeg
{
+ MediaReader::MediaReader(int64_t timeout_us)
+ : timeout_us_(timeout_us)
+ {
+ }
+
MediaReader::~MediaReader()
{
Close();
@@ -32,19 +51,46 @@ namespace ffmpeg
{
Close();
- int result = avformat_open_input(&format_context_, url.c_str(), nullptr, nullptr);
+ std::call_once(network_initialized, [] {
+ avformat_network_init();
+ });
+
+ // Allocated here rather than by avformat_open_input, since the
+ // interrupt callback has to be in place before opening starts.
+ format_context_ = avformat_alloc_context();
+
+ if (format_context_ == nullptr) {
+ return AVERROR(ENOMEM);
+ }
+
+ format_context_->interrupt_callback.callback = &MediaReader::OnInterrupt;
+ format_context_->interrupt_callback.opaque = this;
+
+ AVDictionary * options = nullptr;
+
+ av_dict_set(&options, "protocol_whitelist", kProtocolWhitelist, 0);
+
+ BeginBlocking();
+ int result = avformat_open_input(&format_context_, url.c_str(), nullptr, &options);
+ EndBlocking();
+
+ av_dict_free(&options);
if (result < 0) {
// avformat_open_input frees the context and nulls the pointer
// itself when it fails, so there is nothing left to release.
format_context_ = nullptr;
- return result;
+ return TranslateError(result);
}
+ BeginBlocking();
result = avformat_find_stream_info(format_context_, nullptr);
+ EndBlocking();
if (result < 0) {
+ result = TranslateError(result);
+
Close();
return result;
@@ -80,7 +126,12 @@ namespace ffmpeg
void MediaReader::Close()
{
if (format_context_ != nullptr) {
+ // Closing an RTSP source tells the server, which can block as
+ // well, unless the reader was interrupted, in which case it
+ // gives up at once.
+ BeginBlocking();
avformat_close_input(&format_context_);
+ EndBlocking();
}
format_context_ = nullptr;
@@ -185,7 +236,11 @@ namespace ffmpeg
return AVERROR(EINVAL);
}
- return av_read_frame(format_context_, packet);
+ BeginBlocking();
+ int result = av_read_frame(format_context_, packet);
+ EndBlocking();
+
+ return result == AVERROR_EOF ? result : TranslateError(result);
}
int MediaReader::Seek(int64_t position_us)
@@ -201,7 +256,55 @@ namespace ffmpeg
// AVSEEK_FLAG_BACKWARD lands on the keyframe at or before the target,
// so that what follows can actually be decoded. Frames between that
// keyframe and the target are decoded and dropped by the caller.
- return av_seek_frame(format_context_, -1, position_us, AVSEEK_FLAG_BACKWARD);
+ BeginBlocking();
+ int result = av_seek_frame(format_context_, -1, position_us, AVSEEK_FLAG_BACKWARD);
+ EndBlocking();
+
+ return TranslateError(result);
+ }
+
+ void MediaReader::Interrupt()
+ {
+ interrupted_.store(true);
+ }
+
+ int MediaReader::OnInterrupt(void * opaque)
+ {
+ MediaReader * reader = static_cast(opaque);
+
+ if (reader->interrupted_.load()) {
+ return 1;
+ }
+
+ const int64_t deadline = reader->deadline_us_.load();
+
+ if (deadline != 0 && av_gettime_relative() > deadline) {
+ reader->timed_out_.store(true);
+
+ return 1;
+ }
+
+ return 0;
+ }
+
+ void MediaReader::BeginBlocking()
+ {
+ timed_out_.store(false);
+ deadline_us_.store(timeout_us_ > 0 ? av_gettime_relative() + timeout_us_ : 0);
+ }
+
+ void MediaReader::EndBlocking()
+ {
+ deadline_us_.store(0);
+ }
+
+ int MediaReader::TranslateError(int error) const
+ {
+ if (error < 0 && !interrupted_.load() && timed_out_.load()) {
+ return AVERROR(ETIMEDOUT);
+ }
+
+ return error;
}
const AVStream * MediaReader::GetVideoStream() const
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/FFmpeg.java
similarity index 85%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/FFmpeg.java
index 1151addb..0363221a 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/FFmpeg.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/FFmpeg.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import dev.onvoid.webrtc.internal.NativeLoader;
@@ -51,7 +51,7 @@ private FFmpeg() {
/**
* Returns the FFmpeg version the loaded native library was built against,
- * for example {@code 7.1.1}. Useful to confirm which FFmpeg an application
+ * for example {@code 8.1}. Useful to confirm which FFmpeg an application
* actually ended up with, since the libraries may be replaced.
*
* @return The FFmpeg version string.
@@ -88,29 +88,29 @@ private static String[] dependencies() {
if (osName.startsWith("windows")) {
return new String[] {
- "avutil-59.dll",
- "swresample-5.dll",
- "swscale-8.dll",
- "avcodec-61.dll",
- "avformat-61.dll"
+ "avutil-60.dll",
+ "swresample-6.dll",
+ "swscale-9.dll",
+ "avcodec-62.dll",
+ "avformat-62.dll"
};
}
if (osName.startsWith("mac os")) {
return new String[] {
- "libavutil.59.dylib",
- "libswresample.5.dylib",
- "libswscale.8.dylib",
- "libavcodec.61.dylib",
- "libavformat.61.dylib"
+ "libavutil.60.dylib",
+ "libswresample.6.dylib",
+ "libswscale.9.dylib",
+ "libavcodec.62.dylib",
+ "libavformat.62.dylib"
};
}
return new String[] {
- "libavutil.so.59",
- "libswresample.so.5",
- "libswscale.so.8",
- "libavcodec.so.61",
- "libavformat.so.61"
+ "libavutil.so.60",
+ "libswresample.so.6",
+ "libswscale.so.9",
+ "libavcodec.so.62",
+ "libavformat.so.62"
};
}
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaFileSource.java
similarity index 87%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaFileSource.java
index 1fced442..83869bb5 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSource.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaFileSource.java
@@ -14,10 +14,11 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import java.io.IOException;
import java.nio.file.Path;
+import java.time.Duration;
import dev.onvoid.webrtc.media.audio.CustomAudioSource;
import dev.onvoid.webrtc.media.video.CustomVideoSource;
@@ -82,12 +83,27 @@ public MediaFileSource(Path path) throws IOException {
/**
* Opens the given media source.
*
- * @param source The path or URL of the source to play.
+ * @param source The path or {@code rtsp://} URL of the source to play.
*
* @throws IOException if the source cannot be opened or decoded.
*/
public MediaFileSource(String source) throws IOException {
- MediaReader reader = new MediaReader(source);
+ this(source, MediaReader.DEFAULT_TIMEOUT);
+ }
+
+ /**
+ * Opens the given media source, allowing each operation on it the given
+ * time before it fails, as {@link MediaReader} describes.
+ *
+ * @param source The path or {@code rtsp://} URL of the source to play.
+ * @param timeout How long opening, and later any single read, may wait on
+ * the source. {@link Duration#ZERO} waits as long as it
+ * takes.
+ *
+ * @throws IOException if the source cannot be opened in time, or decoded.
+ */
+ public MediaFileSource(String source, Duration timeout) throws IOException {
+ MediaReader reader = new MediaReader(source, timeout);
// Read while the reader is still ours: the player takes it over.
info = reader.getInfo();
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaInfo.java
similarity index 99%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaInfo.java
index eb5d5ca6..b9d42269 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaInfo.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaInfo.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
/**
* What a media source contains: how long it runs, and the format of the video
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayer.java
similarity index 78%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayer.java
index cd09f8cc..0af5ee2c 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayer.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayer.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import java.io.IOException;
import java.util.Objects;
@@ -36,6 +36,10 @@
* The player takes over the reader it is given. That reader must not be used
* or closed afterwards; closing the player releases it.
*
+ * The player also keeps the native side of the given sources alive until it
+ * is closed, so disposing of a source, or of a track made from it, while the
+ * player still runs is safe.
+ *
* Example:
*
{@code
* CustomVideoSource videoSource = new CustomVideoSource();
@@ -70,6 +74,13 @@ public class MediaPlayer implements AutoCloseable {
/** Read on the native player's thread, so never a stale value. */
private volatile MediaPlayerListener listener;
+ /**
+ * How deep the current thread is in calls to the listener. Closing from
+ * in there cannot release the native player on the spot: the call came
+ * from the native player, which is still on the stack below it.
+ */
+ private final ThreadLocal callbackDepth = ThreadLocal.withInitial(() -> new int[1]);
+
/**
* Creates a player for the given source, delivering into the given custom
@@ -186,6 +197,12 @@ public MediaPlayerState getState() {
* Stops playback, releases the native player and the reader it took over,
* and waits for the player's thread to finish. Closing a player that is
* already closed does nothing.
+ *
+ * Closing from within a {@link MediaPlayerListener} call is allowed, but
+ * cannot wait: the player's thread is the one making that call. The player
+ * is closed at once, in that every later command does nothing and its
+ * state reads as closed, and it is released on another thread as soon as
+ * the listener call has returned.
*/
@Override
public void close() {
@@ -199,7 +216,31 @@ public void close() {
handle = 0;
}
- dispose(closing);
+ if (closing == 0) {
+ return;
+ }
+
+ if (callbackDepth.get()[0] == 0) {
+ dispose(closing);
+
+ return;
+ }
+
+ Thread releaser = new Thread(() -> {
+ // A command that was already running when the handle was cleared
+ // holds the lock, and may be what made the listener call that
+ // asked for this close. Taking the lock waits for it to return;
+ // releasing it before disposing keeps the listener free to call
+ // the player while the player's thread is being waited for.
+ synchronized (lock) {
+ // Nothing to do but wait.
+ }
+
+ dispose(closing);
+ }, "MediaPlayer-close");
+
+ releaser.setDaemon(true);
+ releaser.start();
}
/** Called by native code on the player's thread. */
@@ -207,7 +248,7 @@ private void onNativeStateChanged(int state) {
MediaPlayerListener current = listener;
if (current != null) {
- current.onStateChanged(MediaPlayerState.of(state));
+ notifyListener(() -> current.onStateChanged(MediaPlayerState.of(state)));
}
}
@@ -216,7 +257,7 @@ private void onNativeEndOfStream() {
MediaPlayerListener current = listener;
if (current != null) {
- current.onEndOfStream();
+ notifyListener(current::onEndOfStream);
}
}
@@ -225,7 +266,22 @@ private void onNativeError(String message) {
MediaPlayerListener current = listener;
if (current != null) {
- current.onError(message);
+ notifyListener(() -> current.onError(message));
+ }
+ }
+
+ private void notifyListener(Runnable call) {
+ int[] depth = callbackDepth.get();
+
+ depth[0]++;
+
+ try {
+ call.run();
+ }
+ finally {
+ if (--depth[0] == 0) {
+ callbackDepth.remove();
+ }
}
}
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerListener.java
similarity index 74%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerListener.java
index 7e7ab7fe..2a017e3a 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerListener.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerListener.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
/**
* What a {@link MediaPlayer} reports while it runs.
@@ -23,6 +23,10 @@
* playback, and that thread is the one decoding the media. An implementation
* must therefore return promptly and must not call back into the player in a
* way that waits for it.
+ *
+ * Closing the player from here, or the {@link MediaFileSource} it belongs to,
+ * is fine: the native player is then released once this call has returned,
+ * rather than during it.
*
* @author Alex Andres
*/
@@ -44,7 +48,10 @@ default void onEndOfStream() {
}
/**
- * Playback stopped because something went wrong.
+ * Playback stopped because something went wrong. The player is left
+ * {@link MediaPlayerState#PAUSED} where it failed: playing again carries
+ * on past what failed, and a seek moves away from it, though a source
+ * that cannot be read at all fails again.
*
* @param message What went wrong, as FFmpeg described it.
*/
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerState.java
similarity index 88%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerState.java
index 682b7344..26369140 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerState.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaPlayerState.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
/**
* What a {@link MediaPlayer} is currently doing.
@@ -30,8 +30,9 @@ public enum MediaPlayerState {
PLAYING,
/**
- * Playback is held where it is. Resuming carries on from there rather
- * than delivering everything that fell due in the meantime.
+ * Playback is held where it is, because it was paused or because it
+ * failed. Resuming carries on from there rather than delivering
+ * everything that fell due in the meantime.
*/
PAUSED,
diff --git a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java
similarity index 57%
rename from webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java
rename to webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java
index da50e429..d420eb4a 100644
--- a/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/ffmpeg/MediaReader.java
+++ b/webrtc-java-media/src/main/java/dev/onvoid/webrtc/media/player/MediaReader.java
@@ -14,18 +14,25 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import java.io.IOException;
import java.nio.file.Path;
+import java.time.Duration;
+import java.util.Objects;
/**
* An opened media source, and what it contains.
*
- * A source is anything FFmpeg can demux: a media file today, and an http, rtsp
- * or rtmp URL once those protocols are enabled in the build. Opening reads the
- * container and picks the video and audio stream that are meant to be played;
- * a source with neither fails to open.
+ * A source is a media file, or a live stream behind an {@code rtsp://} URL,
+ * such as an IP camera. Opening reads the container and picks the video and
+ * audio stream that are meant to be played; a source with neither fails to
+ * open.
+ *
+ * Any single operation that waits on the source gives up once the reader's
+ * timeout has passed: opening it, reading from it during playback, seeking in
+ * it and closing it. For a file that is never an issue; for a stream it is
+ * what keeps an unreachable or stalled server from blocking indefinitely.
*
* A reader holds a native resource and has to be closed. It is not safe to use
* from several threads at once, other than {@link #close()}, which may be
@@ -39,6 +46,11 @@ public class MediaReader implements AutoCloseable {
FFmpeg.load();
}
+ /**
+ * How long an operation on the source may wait when no timeout is given.
+ */
+ public static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(10);
+
/** The native reader, or 0 once this reader has been closed. */
private long handle;
@@ -58,13 +70,37 @@ public MediaReader(Path path) throws IOException {
/**
* Opens the given media source.
*
- * @param source The path or URL of the source to open.
+ * @param source The path or {@code rtsp://} URL of the source to open.
*
* @throws IOException if the source cannot be opened, or holds nothing
* that can be played.
*/
public MediaReader(String source) throws IOException {
- handle = open(source);
+ this(source, DEFAULT_TIMEOUT);
+ }
+
+ /**
+ * Opens the given media source, allowing each operation on it the given
+ * time before it fails.
+ *
+ * @param source The path or {@code rtsp://} URL of the source to open.
+ * @param timeout How long opening, and later any single read, seek or
+ * close, may wait on the source. {@link Duration#ZERO}
+ * waits as long as it takes.
+ *
+ * @throws IOException if the source cannot be opened in
+ * time, or holds nothing that can be
+ * played.
+ * @throws IllegalArgumentException if the timeout is negative.
+ */
+ public MediaReader(String source, Duration timeout) throws IOException {
+ Objects.requireNonNull(timeout, "Timeout is null");
+
+ if (timeout.isNegative()) {
+ throw new IllegalArgumentException("Timeout is negative: " + timeout);
+ }
+
+ handle = open(source, toMicros(timeout));
}
/**
@@ -111,7 +147,17 @@ synchronized long detach() {
return detaching;
}
- private static native long open(String source) throws IOException;
+ private static long toMicros(Duration duration) {
+ try {
+ return duration.toNanos() / 1000;
+ }
+ catch (ArithmeticException e) {
+ // Longer than nanoseconds can count, which is as good as never.
+ return 0;
+ }
+ }
+
+ private static native long open(String source, long timeoutUs) throws IOException;
private static native MediaInfo info(long handle);
diff --git a/webrtc-java-media/src/main/java/module-info.java b/webrtc-java-media/src/main/java/module-info.java
index e48c075b..7ea0e974 100644
--- a/webrtc-java-media/src/main/java/module-info.java
+++ b/webrtc-java-media/src/main/java/module-info.java
@@ -6,6 +6,6 @@
requires webrtc.java;
- exports dev.onvoid.webrtc.media.ffmpeg;
+ exports dev.onvoid.webrtc.media.player;
}
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/FFmpegTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/FFmpegTest.java
new file mode 100644
index 00000000..ed11fb9b
--- /dev/null
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/FFmpegTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.media.player;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+
+/**
+ * Tests which FFmpeg the module actually loads.
+ *
+ * @author Alex Andres
+ */
+class FFmpegTest {
+
+ @Test
+ void loadsPinnedRelease() {
+ // Set by the build from the version the submodule is pinned to.
+ String expected = System.getProperty("test.ffmpeg.version");
+
+ assertNotNull(expected, "test.ffmpeg.version is not set");
+
+ // A release build names itself after its tag, n.
+ assertEquals("n" + expected, FFmpeg.version());
+ }
+
+ @Test
+ void isLgpl() {
+ // What the module ships under; a GPL or non-free build must not be
+ // what ended up in the jar.
+ assertTrue(FFmpeg.license().startsWith("LGPL"), FFmpeg.license());
+ }
+
+}
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaFileSourceTest.java
similarity index 87%
rename from webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java
rename to webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaFileSourceTest.java
index 45fc37ff..4ac77d80 100644
--- a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaFileSourceTest.java
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaFileSourceTest.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -139,6 +139,29 @@ void closesTwice() throws Exception {
source.close();
}
+ @Test
+ void closesFromEndOfStream() throws Exception {
+ MediaFileSource source = new MediaFileSource(asset());
+ CountDownLatch closed = new CountDownLatch(1);
+
+ source.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onEndOfStream() {
+ // Releases the sources while the player is still finishing
+ // on this very thread.
+ source.close();
+ closed.countDown();
+ }
+ });
+
+ source.seek(2_500_000);
+ source.play();
+
+ assertTrue(closed.await(10, TimeUnit.SECONDS), "not closed");
+ assertEquals(MediaPlayerState.CLOSED, source.getState());
+ }
+
private static Path asset() throws Exception {
URL url = MediaFileSourceTest.class.getResource(ASSET);
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaPlayerTest.java
similarity index 53%
rename from webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java
rename to webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaPlayerTest.java
index e0786de0..0beb0156 100644
--- a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaPlayerTest.java
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaPlayerTest.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -22,12 +22,17 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.IOException;
import java.net.URL;
import java.nio.file.Path;
import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
import dev.onvoid.webrtc.PeerConnectionFactory;
@@ -44,6 +49,7 @@
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;
@@ -77,6 +83,9 @@ class MediaPlayerTest {
private AudioDeviceModule audioModule;
private PeerConnectionFactory factory;
+ @TempDir
+ Path tempDir;
+
@BeforeAll
void initFactory() {
@@ -128,6 +137,72 @@ void deliversWhatWebRtcTakes() throws Exception {
}
}
+ @Test
+ void playsAvi() throws Exception {
+ MediaReader reader = new MediaReader(MediaReaderTest.asset(MediaReaderTest.AVI_ASSET));
+
+ // Opening the player is what fails when a decoder for the file's
+ // codecs was left out of the FFmpeg build.
+ try (Playback playback = new Playback(reader)) {
+ playback.player.play();
+
+ assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream");
+
+ // The asset holds six frames, all of which have to come out.
+ assertEquals(6, playback.frames.get());
+ assertEquals(1920, playback.width.get());
+ assertEquals(1080, playback.height.get());
+
+ assertTrue(playback.chunks.get() > 0, "no audio");
+ assertEquals(2, playback.channels.get());
+ }
+ }
+
+ @Test
+ void coarseInterleavingKeepsVideoEven() throws Exception {
+ // All three seconds of audio are stored ahead of the video, so the
+ // video can only be decoded once the audio has been read. A player
+ // that stops reading while the audio queue is full gets to the video
+ // seconds late and delivers what is overdue in a burst that WebRTC's
+ // encoder answers by dropping all but the last frame of it.
+ MediaReader reader = new MediaReader(
+ MediaReaderTest.asset(MediaReaderTest.COARSE_ASSET));
+
+ try (Playback playback = new Playback(reader)) {
+ playback.player.play();
+
+ assertTrue(playback.ended.await(15, TimeUnit.SECONDS), "no end of stream");
+
+ List times;
+ synchronized (playback.frameTimes) {
+ times = new ArrayList<>(playback.frameTimes);
+ }
+
+ assertEquals(75, times.size());
+
+ // Frame i and the first audio chunk share a timeline, so frame i
+ // is due I frame intervals after that chunk arrived. How late the
+ // worst frame is tells a player that starved its video, seconds
+ // behind, from a busy machine that was merely slow for a moment,
+ // which a count of closely spaced frames cannot.
+ long start = Math.min(playback.firstChunkNs.get(), times.get(0));
+ long frameNs = TimeUnit.MILLISECONDS.toNanos(40);
+ long maxLateNs = 0;
+ StringBuilder offsets = new StringBuilder();
+
+ for (int i = 0; i < times.size(); i++) {
+ long offsetNs = times.get(i) - start;
+
+ maxLateNs = Math.max(maxLateNs, offsetNs - i * frameNs);
+ offsets.append(i == 0 ? "" : " ").append(offsetNs / 1_000_000);
+ }
+
+ assertTrue(maxLateNs < TimeUnit.SECONDS.toNanos(1),
+ "video was up to " + maxLateNs / 1_000_000 + " ms late; frames arrived at "
+ + offsets + " ms after the first audio");
+ }
+ }
+
@Test
void pacesInRealTime() throws Exception {
try (Playback playback = new Playback()) {
@@ -233,6 +308,200 @@ void adoptedReaderIsClosed() throws Exception {
}
}
+ @Test
+ void closesFromEndOfStream() throws Exception {
+ try (Sources sources = new Sources()) {
+ MediaPlayer player = new MediaPlayer(new MediaReader(asset()),
+ sources.video, sources.audio);
+ CountDownLatch closed = new CountDownLatch(1);
+
+ player.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onEndOfStream() {
+ // On the player's own thread, which is the one closing it
+ // has to wait for.
+ player.close();
+ closed.countDown();
+ }
+ });
+
+ player.seek(2_500_000);
+ player.play();
+
+ assertTrue(closed.await(10, TimeUnit.SECONDS), "not closed");
+ assertEquals(MediaPlayerState.CLOSED, player.getState());
+
+ player.close();
+ }
+ }
+
+ @Test
+ void closesFromStateChangeOfCommand() throws Exception {
+ try (Sources sources = new Sources()) {
+ MediaPlayer player = new MediaPlayer(new MediaReader(asset()),
+ sources.video, sources.audio);
+ AtomicInteger closes = new AtomicInteger();
+
+ player.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onStateChanged(MediaPlayerState state) {
+ // Called from within play(), on the calling thread.
+ if (state == MediaPlayerState.PLAYING) {
+ player.close();
+ closes.incrementAndGet();
+ }
+ }
+ });
+
+ player.play();
+
+ assertEquals(1, closes.get());
+ assertEquals(MediaPlayerState.CLOSED, player.getState());
+
+ // Commands after the close find nothing to act on.
+ player.play();
+ player.seek(0);
+ player.close();
+ }
+ }
+
+ @Test
+ void outlivesDisposedSources() throws Exception {
+ CustomVideoSource videoSource = new CustomVideoSource();
+ CustomAudioSource audioSource = new CustomAudioSource();
+ VideoTrack videoTrack = factory.createVideoTrack("video", videoSource);
+ AudioTrack audioTrack = factory.createAudioTrack("audio", audioSource);
+
+ try (MediaPlayer player = new MediaPlayer(new MediaReader(asset()),
+ videoSource, audioSource)) {
+ player.play();
+
+ Thread.sleep(300);
+
+ // Everything an application holds goes, the player carries on
+ // pushing into the sources it was given.
+ videoTrack.dispose();
+ audioTrack.dispose();
+ videoSource.dispose();
+ audioSource.dispose();
+
+ Thread.sleep(700);
+
+ assertEquals(MediaPlayerState.PLAYING, player.getState());
+ }
+ }
+
+ @Test
+ void failsWithoutDecoder() throws Exception {
+ Path file = TestMedia.muLawWav(tempDir);
+
+ try (Sources sources = new Sources()) {
+ // The reader opens, since the demuxer knows the format.
+ MediaReader reader = new MediaReader(file);
+
+ assertThrows(IOException.class,
+ () -> new MediaPlayer(reader, null, sources.audio));
+ }
+ }
+
+ @Test
+ void followsChannelChange() throws Exception {
+ Path file = TestMedia.channelSwitchingFlac(tempDir, 5, 10);
+
+ try (Playback playback = new Playback(new MediaReader(file))) {
+ playback.player.play();
+
+ assertTrue(playback.ended.await(10, TimeUnit.SECONDS), "no end of stream");
+
+ // Stereo out throughout, as the file started.
+ assertEquals(2, playback.channels.get());
+ assertTrue(Math.abs(playback.chunks.get() - 150) <= 2,
+ "audio chunks: " + playback.chunks.get());
+ assertEquals(0, playback.unevenChunks.get(),
+ "chunks with differing channels");
+ }
+ }
+
+ @Test
+ void pausesOnDecodeError() throws Exception {
+ Path file = TestMedia.corruptFlac(tempDir, 10, 4);
+
+ try (Playback playback = new Playback(new MediaReader(file))) {
+ CountDownLatch failed = new CountDownLatch(1);
+ CountDownLatch ended = new CountDownLatch(1);
+
+ playback.player.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onEndOfStream() {
+ ended.countDown();
+ }
+
+ @Override
+ public void onError(String message) {
+ failed.countDown();
+ }
+ });
+
+ playback.player.play();
+
+ assertTrue(failed.await(10, TimeUnit.SECONDS), "no error");
+
+ // Stopped where it failed, rather than claiming to play on.
+ assertEquals(MediaPlayerState.PAUSED, playback.player.getState());
+
+ int before = playback.chunks.get();
+
+ // Carries on past the frame that failed.
+ playback.player.play();
+
+ assertTrue(ended.await(10, TimeUnit.SECONDS), "no end of stream");
+ assertEquals(MediaPlayerState.ENDED, playback.player.getState());
+ assertTrue(playback.chunks.get() > before, "nothing played after resuming");
+ }
+ }
+
+ @Test
+ void deliversResamplerTail() throws Exception {
+ // One second at 44.1 kHz, which is exactly 48000 samples at 48 kHz.
+ Path file = TestMedia.constantFlac(tempDir, 44100, 10, (short) 8000);
+
+ try (Playback playback = new Playback(new MediaReader(file))) {
+ playback.player.play();
+
+ assertTrue(playback.ended.await(10, TimeUnit.SECONDS), "no end of stream");
+
+ assertEquals(100, playback.chunks.get());
+ assertEquals(48000, playback.nonZeroSamples.get());
+ }
+ }
+
+ private static int countNonZero(byte[] data) {
+ int count = 0;
+
+ for (int i = 0; i + 1 < data.length; i += 2) {
+ if (data[i] != 0 || data[i + 1] != 0) {
+ count++;
+ }
+ }
+
+ return count;
+ }
+
+ private static boolean channelsEqual(byte[] data) {
+ // Interleaved 16-bit frames: the left and right sample of a frame are
+ // the two bytes pairs of each four.
+ for (int i = 0; i + 3 < data.length; i += 4) {
+ if (data[i] != data[i + 2] || data[i + 1] != data[i + 3]) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
private static Path asset() throws Exception {
URL url = MediaPlayerTest.class.getResource(ASSET);
@@ -241,6 +510,22 @@ private static Path asset() throws Exception {
return Paths.get(url.toURI());
}
+ /**
+ * A pair of sources with no tracks, for tests that only need somewhere to
+ * deliver to.
+ */
+ private static final class Sources implements AutoCloseable {
+
+ final CustomVideoSource video = new CustomVideoSource();
+ final CustomAudioSource audio = new CustomAudioSource();
+
+ @Override
+ public void close() {
+ video.dispose();
+ audio.dispose();
+ }
+ }
+
/**
* One playback: the sources, the tracks made from them, the sinks that
* count what arrives, and the player feeding it all.
@@ -263,18 +548,30 @@ private final class Playback implements AutoCloseable {
final AtomicInteger channels = new AtomicInteger();
final AtomicInteger framesPerChunk = new AtomicInteger();
final AtomicReference error = new AtomicReference<>();
+ final List frameTimes = Collections.synchronizedList(new ArrayList<>());
+ final AtomicLong firstChunkNs = new AtomicLong();
+ final AtomicInteger unevenChunks = new AtomicInteger();
+ final AtomicLong nonZeroSamples = new AtomicLong();
private final VideoTrackSink videoSink = frame -> {
+ frameTimes.add(System.nanoTime());
frames.incrementAndGet();
width.set(frame.buffer.getWidth());
height.set(frame.buffer.getHeight());
};
private final AudioTrackSink audioSink = (data, bits, rate, ch, count) -> {
+ firstChunkNs.compareAndSet(0, System.nanoTime());
chunks.incrementAndGet();
sampleRate.set(rate);
channels.set(ch);
framesPerChunk.set(count);
+
+ if (ch == 2 && !channelsEqual(data)) {
+ unevenChunks.incrementAndGet();
+ }
+
+ nonZeroSamples.addAndGet(countNonZero(data));
};
private boolean closed;
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java
similarity index 72%
rename from webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java
rename to webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java
index b154b073..4bc0477e 100644
--- a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/ffmpeg/MediaReaderTest.java
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/MediaReaderTest.java
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package dev.onvoid.webrtc.media.ffmpeg;
+package dev.onvoid.webrtc.media.player;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
@@ -39,6 +39,19 @@ class MediaReaderTest {
/** The committed test asset: 320x240 VP8 at 15 fps, 48 kHz mono Opus. */
private static final String ASSET = "/media-test.webm";
+ /**
+ * The first quarter second of Big Buck Bunny (CC BY 3.0, Blender
+ * Foundation) as the 1080p AVI it is distributed in, cut without
+ * re-encoding: MS-MPEG4 v2 video at 24 fps, 48 kHz stereo MP3.
+ */
+ static final String AVI_ASSET = "/media-test.avi";
+
+ /**
+ * Three seconds of 160x120 MJPEG at 25 fps and 8 kHz mono PCM in
+ * Matroska, with all of the audio stored ahead of all of the video.
+ */
+ static final String COARSE_ASSET = "/media-test-coarse.mkv";
+
@Test
void readsVideoInfo() throws Exception {
@@ -74,6 +87,22 @@ void readsDuration() throws Exception {
}
}
+ @Test
+ void readsAviInfo() throws Exception {
+ try (MediaReader reader = new MediaReader(asset(AVI_ASSET))) {
+ MediaInfo info = reader.getInfo();
+
+ assertEquals(1920, info.getVideoWidth());
+ assertEquals(1080, info.getVideoHeight());
+ assertEquals(24.0, info.getFrameRate(), 0.01);
+ assertEquals("msmpeg4v2", info.getVideoCodec());
+
+ assertEquals(48000, info.getSampleRate());
+ assertEquals(2, info.getChannels());
+ assertEquals("mp3", info.getAudioCodec());
+ }
+ }
+
@Test
void missingSourceFails() {
IOException e = assertThrows(IOException.class,
@@ -102,9 +131,13 @@ void infoAfterCloseFails() throws Exception {
}
private static Path asset() throws Exception {
- URL url = MediaReaderTest.class.getResource(ASSET);
+ return asset(ASSET);
+ }
+
+ static Path asset(String name) throws Exception {
+ URL url = MediaReaderTest.class.getResource(name);
- assertNotNull(url, "Test asset " + ASSET + " is missing");
+ assertNotNull(url, "Test asset " + name + " is missing");
return Paths.get(url.toURI());
}
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java
new file mode 100644
index 00000000..1b44d2fc
--- /dev/null
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/RtspStreamTest.java
@@ -0,0 +1,275 @@
+/*
+ * 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.media.player;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.time.Duration;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+
+import dev.onvoid.webrtc.PeerConnectionFactory;
+import dev.onvoid.webrtc.media.audio.AudioDeviceModule;
+import dev.onvoid.webrtc.media.audio.AudioLayer;
+import dev.onvoid.webrtc.media.audio.AudioTrack;
+import dev.onvoid.webrtc.media.audio.AudioTrackSink;
+import dev.onvoid.webrtc.media.audio.CustomAudioSource;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.TestInstance;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+/**
+ * Tests playing live streams over RTSP, from a server in this process, and
+ * what keeps a stream that misbehaves from blocking: the timeouts, and closing
+ * a player that is waiting on one.
+ *
+ * @author Alex Andres
+ */
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+@Execution(ExecutionMode.SAME_THREAD)
+class RtspStreamTest {
+
+ private AudioDeviceModule audioModule;
+ private PeerConnectionFactory factory;
+
+
+ @BeforeAll
+ void initFactory() {
+ audioModule = new AudioDeviceModule(AudioLayer.kDummyAudio);
+ factory = new PeerConnectionFactory(audioModule);
+ }
+
+ @AfterAll
+ void disposeFactory() {
+ factory.dispose();
+ audioModule.dispose();
+ }
+
+ @Test
+ void describesStream() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.END, 100);
+ MediaReader reader = new MediaReader(server.url())) {
+ MediaInfo info = reader.getInfo();
+
+ assertFalse(info.hasVideo());
+ assertTrue(info.hasAudio());
+ assertEquals(TestRtspServer.SAMPLE_RATE, info.getSampleRate());
+ assertEquals(1, info.getChannels());
+ }
+ }
+
+ @Test
+ void playsStream() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.END, 100);
+ Playback playback = new Playback(new MediaReader(server.url()))) {
+ playback.player.play();
+
+ // One second of audio, sent in real time, and the end of the
+ // stream once the server hangs up.
+ assertTrue(playback.done.await(10, TimeUnit.SECONDS), "stream did not end");
+ assertTrue(playback.chunks.get() >= 90, "audio chunks: " + playback.chunks.get());
+ assertTrue(playback.nonSilent.get() > 0, "only silence arrived");
+ }
+ }
+
+ @Test
+ void liveStreamEndsDespiteLooping() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.END, 50);
+ Playback playback = new Playback(new MediaReader(server.url()))) {
+ // A live stream cannot start over, so looping it has to end like
+ // any stream, rather than try to rewind forever.
+ playback.player.setLooping(true);
+ playback.player.play();
+
+ assertTrue(playback.done.await(10, TimeUnit.SECONDS), "stream did not end");
+ assertEquals(MediaPlayerState.ENDED, playback.player.getState(),
+ "error: " + playback.error.get());
+ }
+ }
+
+ @Test
+ void openTimesOut() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.SILENT, 0)) {
+ long start = System.nanoTime();
+
+ IOException e = assertThrows(IOException.class,
+ () -> new MediaReader(server.url(), Duration.ofMillis(500)));
+
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ assertTrue(elapsedMs < 5000, "took " + elapsedMs + " ms");
+ assertTrue(e.getMessage().toLowerCase().contains("timed out"), e.getMessage());
+ }
+ }
+
+ @Test
+ void errorHidesCredentials() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.SILENT, 0)) {
+ String url = server.url().replace("rtsp://", "rtsp://admin:s3cret@");
+
+ IOException e = assertThrows(IOException.class,
+ () -> new MediaReader(url, Duration.ofMillis(300)));
+
+ // Messages end up in logs; the password must not.
+ assertFalse(e.getMessage().contains("s3cret"), e.getMessage());
+ assertFalse(e.getMessage().contains("admin"), e.getMessage());
+ assertTrue(e.getMessage().contains("127.0.0.1:" + server.port() + "/stream"),
+ e.getMessage());
+ }
+ }
+
+ @Test
+ void stalledStreamTimesOut() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.STALL, 30);
+ Playback playback = new Playback(new MediaReader(server.url(), Duration.ofSeconds(1)))) {
+ playback.player.play();
+
+ assertTrue(playback.done.await(10, TimeUnit.SECONDS), "stall not reported");
+
+ String error = playback.error.get();
+
+ assertTrue(error != null && error.toLowerCase().contains("timed out"),
+ "error: " + error);
+ assertEquals(MediaPlayerState.PAUSED, playback.player.getState());
+
+ // Reported, so not an error of the playback itself.
+ playback.error.set(null);
+ }
+ }
+
+ @Test
+ void closeInterruptsStalledStream() throws Exception {
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.STALL, 30);
+ Playback playback = new Playback(new MediaReader(server.url(), Duration.ofMinutes(5)))) {
+ playback.player.play();
+
+ // Let it run into the stall, where the player's thread waits on a
+ // read that would otherwise take five minutes to give up.
+ assertTrue(waitFor(() -> playback.chunks.get() >= 25, 10), "stream did not start");
+ Thread.sleep(500);
+
+ long start = System.nanoTime();
+
+ playback.player.close();
+
+ long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start);
+
+ assertTrue(elapsedMs < 3000, "close took " + elapsedMs + " ms");
+ }
+ }
+
+ @Test
+ void refusesOtherProtocols() throws Exception {
+ // The build carries HTTP for RTSP tunnelled over it, but a source must
+ // not be able to use it on its own.
+ try (TestRtspServer server = new TestRtspServer(TestRtspServer.Behavior.SILENT, 0)) {
+ assertThrows(IOException.class, () ->
+ new MediaReader("http://127.0.0.1:" + server.port() + "/file.mp4",
+ Duration.ofSeconds(2)));
+
+ assertEquals(0, server.connections(), "the server was contacted");
+ }
+ }
+
+ private static boolean waitFor(java.util.function.BooleanSupplier condition,
+ int seconds) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(seconds);
+
+ while (!condition.getAsBoolean()) {
+ if (System.nanoTime() > deadline) {
+ return false;
+ }
+
+ Thread.sleep(20);
+ }
+
+ return true;
+ }
+
+ /**
+ * An audio source fed by a player, and the track sink that counts what
+ * arrives.
+ */
+ private final class Playback implements AutoCloseable {
+
+ final CustomAudioSource audioSource = new CustomAudioSource();
+ final AudioTrack audioTrack = factory.createAudioTrack("audio", audioSource);
+ final MediaPlayer player;
+
+ final CountDownLatch done = new CountDownLatch(1);
+ final AtomicInteger chunks = new AtomicInteger();
+ final AtomicInteger nonSilent = new AtomicInteger();
+ final AtomicReference error = new AtomicReference<>();
+
+ private final AudioTrackSink sink = (data, bits, rate, channels, frames) -> {
+ chunks.incrementAndGet();
+
+ for (byte b : data) {
+ if (b != 0) {
+ nonSilent.incrementAndGet();
+ break;
+ }
+ }
+ };
+
+
+ Playback(MediaReader reader) throws IOException {
+ audioTrack.addSink(sink);
+
+ player = new MediaPlayer(reader, null, audioSource);
+ player.setListener(new MediaPlayerListener() {
+
+ @Override
+ public void onEndOfStream() {
+ done.countDown();
+ }
+
+ @Override
+ public void onError(String message) {
+ error.set(message);
+ done.countDown();
+ }
+ });
+ }
+
+ @Override
+ public void close() {
+ player.close();
+
+ audioTrack.removeSink(sink);
+ audioTrack.dispose();
+ audioSource.dispose();
+
+ String message = error.get();
+
+ if (message != null) {
+ throw new AssertionError("playback reported an error: " + message);
+ }
+ }
+ }
+
+}
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestMedia.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestMedia.java
new file mode 100644
index 00000000..09b8c287
--- /dev/null
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestMedia.java
@@ -0,0 +1,306 @@
+/*
+ * 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.media.player;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Arrays;
+import java.util.function.IntFunction;
+
+/**
+ * Writes small media files that the committed assets cannot provide, because
+ * what makes them useful is something no encoder would produce on purpose.
+ * They are written by hand here rather than committed, so that what they hold
+ * is spelled out next to the tests that rely on it.
+ *
+ * @author Alex Andres
+ */
+final class TestMedia {
+
+ /** The rate of the FLAC file, which is what WebRTC takes unchanged. */
+ static final int FLAC_SAMPLE_RATE = 48000;
+
+ /** 100 ms per FLAC frame. */
+ static final int FLAC_BLOCK_SIZE = 4800;
+
+
+ private TestMedia() {
+ // Static access only.
+ }
+
+ /**
+ * Writes one second of 8 kHz G.711 mu-law in a WAV file. The WAV demuxer
+ * is part of the FFmpeg build and knows the format, but its decoder is
+ * not, so this opens as a reader and fails as a player.
+ *
+ * @param directory Where to write the file.
+ *
+ * @return The file written.
+ */
+ static Path muLawWav(Path directory) throws IOException {
+ final int rate = 8000;
+ final int length = rate;
+
+ ByteBuffer buffer = ByteBuffer.allocate(44 + length)
+ .order(ByteOrder.LITTLE_ENDIAN);
+
+ buffer.put(ascii("RIFF")).putInt(36 + length).put(ascii("WAVE"));
+
+ buffer.put(ascii("fmt ")).putInt(16)
+ .putShort((short) 7) // WAVE_FORMAT_MULAW
+ .putShort((short) 1) // Channels
+ .putInt(rate) // Sample rate
+ .putInt(rate) // Byte rate
+ .putShort((short) 1) // Block align
+ .putShort((short) 8); // Bits per sample
+
+ buffer.put(ascii("data")).putInt(length);
+
+ while (buffer.hasRemaining()) {
+ // Silence in mu-law.
+ buffer.put((byte) 0xFF);
+ }
+
+ return Files.write(directory.resolve("mulaw.wav"), buffer.array());
+ }
+
+ /**
+ * Writes a 48 kHz, 16-bit FLAC file that starts in stereo and switches to
+ * mono part way through, which FLAC allows from one frame to the next.
+ *
+ * Every stereo frame carries the same signal in both channels, and every
+ * mono frame a sawtooth whose neighbouring samples always differ. Played
+ * correctly, as stereo output, the left and right channel are therefore
+ * equal throughout; a mono frame read as if it were still stereo splits
+ * its neighbouring samples between the two channels and makes them differ.
+ *
+ * @param directory Where to write the file.
+ * @param stereoFrames How many 100 ms frames of stereo to write first.
+ * @param monoFrames How many 100 ms frames of mono follow them.
+ *
+ * @return The file written.
+ */
+ static Path channelSwitchingFlac(Path directory, int stereoFrames,
+ int monoFrames) throws IOException {
+ int[] channels = new int[stereoFrames + monoFrames];
+
+ Arrays.fill(channels, 0, stereoFrames, 2);
+ Arrays.fill(channels, stereoFrames, channels.length, 1);
+
+ return Files.write(directory.resolve("channel-switch.flac"),
+ flac(FLAC_SAMPLE_RATE, FLAC_BLOCK_SIZE, channels, TestMedia::sawtooth, -1));
+ }
+
+ /**
+ * Writes a mono, 16-bit FLAC file holding a constant, non-zero value, at
+ * a rate other than the 48 kHz WebRTC takes, so that playing it means
+ * resampling it. Everything played from it is non-zero, apart from what
+ * is missing: resampled completely, it plays as exactly as many non-zero
+ * samples at 48 kHz as it lasts.
+ *
+ * @param directory Where to write the file.
+ * @param sampleRate 44100 or 48000.
+ * @param frames How many 100 ms frames to write.
+ * @param value The value of every sample.
+ *
+ * @return The file written.
+ */
+ static Path constantFlac(Path directory, int sampleRate, int frames,
+ short value) throws IOException {
+ int[] channels = new int[frames];
+
+ Arrays.fill(channels, 1);
+
+ return Files.write(directory.resolve("constant.flac"),
+ flac(sampleRate, sampleRate / 10, channels, n -> value, -1));
+ }
+
+ /**
+ * Writes a 48 kHz, mono, 16-bit FLAC file in which one frame cannot be
+ * decoded: its header is intact, so the file reads as usual, but its
+ * subframe uses a coding type FLAC reserves, which the decoder rejects.
+ *
+ * @param directory Where to write the file.
+ * @param frames How many 100 ms frames to write.
+ * @param corruptFrame Which of them to spoil.
+ *
+ * @return The file written.
+ */
+ static Path corruptFlac(Path directory, int frames, int corruptFrame)
+ throws IOException {
+ int[] channels = new int[frames];
+
+ Arrays.fill(channels, 1);
+
+ return Files.write(directory.resolve("corrupt.flac"),
+ flac(FLAC_SAMPLE_RATE, FLAC_BLOCK_SIZE, channels, TestMedia::sawtooth,
+ corruptFrame));
+ }
+
+ /**
+ * A sawtooth in steps of 37, so that no two neighbouring samples are ever
+ * equal.
+ */
+ private static short sawtooth(int n) {
+ return (short) ((n * 37) % 16000 - 8000);
+ }
+
+ /**
+ * A FLAC stream of fixed size frames, as many as channel counts are given.
+ * The stream starts with the channel count of its first frame.
+ */
+ private static byte[] flac(int sampleRate, int blockSize, int[] frameChannels,
+ IntFunction signal, int corruptFrame) {
+ // The frame number is written as a single byte below.
+ if (frameChannels.length > 127) {
+ throw new IllegalArgumentException("Too many frames: " + frameChannels.length);
+ }
+
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ writeBytes(out, ascii("fLaC"));
+
+ // The last metadata block, of type STREAMINFO, 34 bytes long.
+ out.write(0x80);
+ writeInt(out, 34, 3);
+
+ // Minimum and maximum block size, then unknown frame sizes.
+ writeInt(out, blockSize, 2);
+ writeInt(out, blockSize, 2);
+ writeInt(out, 0, 3);
+ writeInt(out, 0, 3);
+
+ // Sample rate, channels - 1, bits per sample - 1, total samples.
+ long totalSamples = (long) frameChannels.length * blockSize;
+ long packed = ((long) sampleRate << 44) | ((long) (frameChannels[0] - 1) << 41)
+ | (15L << 36) | totalSamples;
+
+ writeInt(out, packed, 8);
+
+ // No MD5 of the audio.
+ writeBytes(out, new byte[16]);
+
+ int sample = 0;
+
+ for (int frame = 0; frame < frameChannels.length; frame++) {
+ short[] samples = new short[blockSize];
+
+ for (int i = 0; i < samples.length; i++) {
+ samples[i] = signal.apply(sample++);
+ }
+
+ writeBytes(out, flacFrame(frame, sampleRate, frameChannels[frame], samples,
+ frame == corruptFrame));
+ }
+
+ return out.toByteArray();
+ }
+
+ /**
+ * One FLAC frame of verbatim subframes, every channel carrying the given
+ * samples.
+ */
+ private static byte[] flacFrame(int number, int sampleRate, int channels,
+ short[] samples, boolean corrupt) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ // Sync code, fixed block size.
+ out.write(0xFF);
+ out.write(0xF8);
+ // Block size as 16 bits at the end of the header; the sample rate.
+ out.write(0x70 | sampleRateCode(sampleRate));
+ // Mono or independent stereo; 16 bits per sample.
+ out.write(((channels - 1) << 4) | 0x08);
+ // The frame number, which fits a single byte of its UTF-8 coding.
+ out.write(number);
+ writeInt(out, samples.length - 1, 2);
+ out.write(crc8(out.toByteArray()));
+
+ for (int channel = 0; channel < channels; channel++) {
+ // A verbatim subframe, no wasted bits; or the first of the coding
+ // types FLAC reserves.
+ out.write(corrupt ? 0x04 : 0x02);
+
+ for (short value : samples) {
+ writeInt(out, value & 0xFFFF, 2);
+ }
+ }
+
+ writeInt(out, crc16(out.toByteArray()), 2);
+
+ return out.toByteArray();
+ }
+
+ private static int sampleRateCode(int sampleRate) {
+ switch (sampleRate) {
+ case 44100:
+ return 0x9;
+ case 48000:
+ return 0xA;
+ default:
+ throw new IllegalArgumentException("Unsupported sample rate: " + sampleRate);
+ }
+ }
+
+ private static void writeBytes(ByteArrayOutputStream out, byte[] bytes) {
+ out.write(bytes, 0, bytes.length);
+ }
+
+ private static void writeInt(ByteArrayOutputStream out, long value, int bytes) {
+ for (int shift = (bytes - 1) * 8; shift >= 0; shift -= 8) {
+ out.write((int) (value >>> shift) & 0xFF);
+ }
+ }
+
+ private static int crc8(byte[] data) {
+ int crc = 0;
+
+ for (byte b : data) {
+ crc ^= b & 0xFF;
+
+ for (int bit = 0; bit < 8; bit++) {
+ crc = (crc & 0x80) != 0 ? ((crc << 1) ^ 0x07) & 0xFF : (crc << 1) & 0xFF;
+ }
+ }
+
+ return crc;
+ }
+
+ private static int crc16(byte[] data) {
+ int crc = 0;
+
+ for (byte b : data) {
+ crc ^= (b & 0xFF) << 8;
+
+ for (int bit = 0; bit < 8; bit++) {
+ crc = (crc & 0x8000) != 0 ? ((crc << 1) ^ 0x8005) & 0xFFFF : (crc << 1) & 0xFFFF;
+ }
+ }
+
+ return crc;
+ }
+
+ private static byte[] ascii(String text) {
+ return text.getBytes(StandardCharsets.US_ASCII);
+ }
+
+}
diff --git a/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestRtspServer.java b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestRtspServer.java
new file mode 100644
index 00000000..3f9a2119
--- /dev/null
+++ b/webrtc-java-media/src/test/java/dev/onvoid/webrtc/media/player/TestRtspServer.java
@@ -0,0 +1,350 @@
+/*
+ * 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.media.player;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Locale;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Just enough of an RTSP server to play a live stream to the media module: it
+ * answers OPTIONS, DESCRIBE, SETUP and PLAY, and then streams 48 kHz mono
+ * 16-bit PCM (L16) in 10 ms RTP packets, interleaved on the RTSP connection.
+ *
+ * A library has to be a file to be loaded, so it is extracted to a temporary
+ * directory first. Where the file system lets a loaded library be deleted, as
+ * on Linux and macOS, that directory is gone again as soon as everything in it
+ * is loaded. Windows does not: a loaded library stays locked until the process
+ * ends, so the process cannot clean up after itself, and deleting on exit
+ * never succeeds. There the process keeps one directory, marked as in use by a
+ * lock it holds until it ends, and each process sweeps away the directories of
+ * processes that have ended before it extracts anything of its own.
*
* @author Alex Andres
*/
public class NativeLoader {
- private static final Set LOADED_LIB_SET = ConcurrentHashMap.newKeySet();
+ /** What every extraction directory is named with. */
+ static final String DIRECTORY_PREFIX = "webrtc-java-natives-";
+
+ /** The file whose lock marks an extraction directory as in use. */
+ static final String LOCK_FILE_NAME = ".lock";
+
+ /**
+ * How old an extraction directory has to be before a sweep considers it.
+ * A process that has only just created its directory may not hold its
+ * lock yet, and must not lose the directory in the meantime.
+ */
+ static final long SWEEP_MIN_AGE_MS = TimeUnit.MINUTES.toMillis(1);
+
+ /**
+ * What earlier versions of this loader left behind on Windows: the
+ * library itself under a randomly numbered temporary file name.
+ */
+ private static final Pattern LEGACY_FILE =
+ Pattern.compile("webrtc-java-windows-(x86_64|aarch64|aarch32)\\d+\\.dll");
+
+ private static final Set LOADED_LIB_SET = new HashSet<>();
+
+ /** Whether this process has swept up after earlier ones yet. */
+ private static boolean swept;
+
+ /**
+ * Where this process keeps its libraries, on a file system that cannot
+ * delete them while they are loaded. Created on first use.
+ */
+ private static Path processDirectory;
+
+ /**
+ * Held until the process ends, marking {@link #processDirectory} as in
+ * use. Kept reachable, since the lock goes with its channel.
+ */
+ @SuppressWarnings("unused")
+ private static FileLock processLock;
/**
@@ -47,46 +104,7 @@ public class NativeLoader {
* @see System#loadLibrary(String)
*/
public static void loadLibrary(final String libName) throws Exception {
- if (LOADED_LIB_SET.contains(libName)) {
- return;
- }
-
- String osFamily = getOSFamily();
- String osArch = getOSArch();
- String libFileName = System.mapLibraryName(libName + "-" + osFamily + "-" + osArch);
- String tempName = removeExtension(libFileName);
- String ext = getExtension(libFileName);
-
- Path tempPath = Files.createTempFile(tempName, ext);
- File tempFile = tempPath.toFile();
-
- try (InputStream is = NativeLoader.class.getClassLoader().getResourceAsStream(libFileName)) {
- Files.copy(is, tempPath, StandardCopyOption.REPLACE_EXISTING);
- }
- catch (Exception e) {
- tempFile.delete();
-
- throw e;
- }
-
- try {
- System.load(tempPath.toAbsolutePath().toString());
-
- LOADED_LIB_SET.add(libName);
- }
- catch (Exception e) {
- tempFile.delete();
-
- throw e;
- }
-
- if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) {
- // Assume POSIX compliant file system, library can be deleted after loading.
- tempFile.delete();
- }
- else {
- tempFile.deleteOnExit();
- }
+ loadLibrary(libName, new String[0]);
}
/**
@@ -108,30 +126,165 @@ public static void loadLibrary(final String libName) throws Exception {
*
* @throws Exception if one of the libraries could not be loaded.
*/
- public static void loadLibrary(final String libName, final String... dependencies)
- throws Exception {
+ public static synchronized void loadLibrary(final String libName,
+ final String... dependencies) throws Exception {
if (LOADED_LIB_SET.contains(libName)) {
return;
}
+ Path tempRoot = Paths.get(System.getProperty("java.io.tmpdir"));
+
+ if (!swept) {
+ swept = true;
+
+ sweep(tempRoot, System.currentTimeMillis());
+ }
+
String libFileName = System.mapLibraryName(
libName + "-" + getOSFamily() + "-" + getOSArch());
- Path tempDir = Files.createTempDirectory(libName);
- tempDir.toFile().deleteOnExit();
+ List fileNames = new ArrayList<>();
+
+ Collections.addAll(fileNames, dependencies);
+ fileNames.add(libFileName);
- for (String dependency : dependencies) {
- loadFromDirectory(tempDir, dependency);
+ if (canDeleteLoaded()) {
+ // A loaded library stays mapped after its file is gone, so the
+ // directory is only needed for as long as the loading takes.
+ Path directory = Files.createTempDirectory(tempRoot, DIRECTORY_PREFIX);
+
+ try {
+ for (String fileName : fileNames) {
+ loadFromDirectory(directory, fileName);
+ }
+ }
+ finally {
+ deleteDirectory(directory);
+ }
}
+ else {
+ Path directory = processDirectory(tempRoot);
- loadFromDirectory(tempDir, libFileName);
+ for (String fileName : fileNames) {
+ loadFromDirectory(directory, fileName);
+ }
+ }
LOADED_LIB_SET.add(libName);
}
+ /**
+ * Deletes what earlier processes left behind in the given directory:
+ * extraction directories no process holds any more, and the library
+ * files earlier versions of this loader extracted. Anything that cannot
+ * be deleted is left, since on Windows that means a process still has it
+ * loaded.
+ *
+ * @param root The directory to sweep, which is the temporary directory
+ * outside of tests.
+ * @param now The current time in milliseconds, which is what the age of
+ * an entry is measured against.
+ */
+ static void sweep(Path root, long now) {
+ try (DirectoryStream entries = Files.newDirectoryStream(root)) {
+ for (Path entry : entries) {
+ String name = entry.getFileName().toString();
+
+ try {
+ if (name.startsWith(DIRECTORY_PREFIX) && Files.isDirectory(entry)) {
+ if (isOldEnough(entry, now) && !isInUse(entry)) {
+ deleteDirectory(entry);
+ }
+ }
+ else if (LEGACY_FILE.matcher(name).matches()) {
+ if (isOldEnough(entry, now)) {
+ Files.deleteIfExists(entry);
+ }
+ }
+ }
+ catch (IOException | RuntimeException e) {
+ // In use, or already gone: either way not ours to delete.
+ }
+ }
+ }
+ catch (IOException e) {
+ // Nothing to sweep.
+ }
+ }
+
+ /**
+ * Whether a process still holds the given extraction directory. A
+ * directory without a lock file is not held: it belongs to a process that
+ * ended before it could create one, since one that is still starting is
+ * too young to be asked.
+ *
+ * @param directory The extraction directory.
+ *
+ * @return True if a process, this one included, holds its lock.
+ *
+ * @throws IOException if the lock file cannot be opened.
+ */
+ static boolean isInUse(Path directory) throws IOException {
+ Path lockFile = directory.resolve(LOCK_FILE_NAME);
+
+ if (!Files.exists(lockFile)) {
+ return false;
+ }
+
+ try (FileChannel channel = FileChannel.open(lockFile, StandardOpenOption.WRITE)) {
+ FileLock lock = channel.tryLock();
+
+ if (lock == null) {
+ // Another process holds it.
+ return true;
+ }
+
+ lock.release();
+
+ return false;
+ }
+ catch (OverlappingFileLockException e) {
+ // This process holds it.
+ return true;
+ }
+ }
+
+ /**
+ * Returns the directory of this process, and creates it, locked, the
+ * first time.
+ *
+ * @param tempRoot Where to create it.
+ *
+ * @return The directory of this process.
+ *
+ * @throws IOException if it cannot be created or locked.
+ */
+ static synchronized Path processDirectory(Path tempRoot) throws IOException {
+ if (processDirectory == null) {
+ Path directory = Files.createTempDirectory(tempRoot, DIRECTORY_PREFIX);
+ FileChannel channel = FileChannel.open(directory.resolve(LOCK_FILE_NAME),
+ StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE);
+
+ try {
+ processLock = channel.lock();
+ }
+ catch (IOException | RuntimeException e) {
+ channel.close();
+
+ throw e;
+ }
+
+ processDirectory = directory;
+ }
+
+ return processDirectory;
+ }
+
/**
* Extracts one library from the JAR into the given directory, keeping its
- * file name, and loads it.
+ * file name, and loads it. A library already extracted there under that
+ * name is loaded as it is: on Windows it is locked by having been loaded,
+ * and loading it again does nothing.
*
* @param directory The directory to extract into.
* @param fileName The resource name of the library, which is also the
@@ -144,60 +297,61 @@ private static void loadFromDirectory(Path directory, String fileName)
throws Exception {
Path libPath = directory.resolve(fileName);
- try (InputStream is = NativeLoader.class.getClassLoader()
- .getResourceAsStream(fileName)) {
- if (is == null) {
- throw new UnsatisfiedLinkError(
- "Native library '" + fileName + "' is not on the classpath");
- }
+ if (!Files.exists(libPath)) {
+ try (InputStream is = NativeLoader.class.getClassLoader()
+ .getResourceAsStream(fileName)) {
+ if (is == null) {
+ throw new UnsatisfiedLinkError(
+ "Native library '" + fileName + "' is not on the classpath");
+ }
- Files.copy(is, libPath, StandardCopyOption.REPLACE_EXISTING);
+ Files.copy(is, libPath);
+ }
}
- File libFile = libPath.toFile();
-
- libFile.deleteOnExit();
-
try {
System.load(libPath.toAbsolutePath().toString());
}
catch (Throwable e) {
- libFile.delete();
+ Files.deleteIfExists(libPath);
throw e;
}
}
- private static String getExtension(String fileName) {
- final int index = getExtensionIndex(fileName);
-
- if (index < 0) {
- return "";
- }
-
- return fileName.substring(index);
+ private static boolean isOldEnough(Path path, long now) throws IOException {
+ return now - Files.getLastModifiedTime(path).toMillis() >= SWEEP_MIN_AGE_MS;
}
- private static String removeExtension(String fileName) {
- final int index = getExtensionIndex(fileName);
+ /**
+ * Deletes the given directory and everything in it, as far as it can.
+ */
+ private static void deleteDirectory(Path directory) {
+ List paths;
- if (index < 0) {
- return fileName;
+ try (Stream walk = Files.walk(directory)) {
+ paths = walk.collect(Collectors.toList());
+ }
+ catch (IOException e) {
+ return;
}
- return fileName.substring(0, index);
- }
-
- private static int getExtensionIndex(String fileName) {
- final String file = fileName.replace("\\", "/");
- final int extSeparator = file.lastIndexOf(".");
- final int pathSeparator = file.lastIndexOf("/");
+ // Deepest first, so that every directory is empty by the time it is
+ // reached.
+ Collections.reverse(paths);
- if (pathSeparator > extSeparator) {
- return -1;
+ for (Path path : paths) {
+ try {
+ Files.deleteIfExists(path);
+ }
+ catch (IOException e) {
+ // Still loaded somewhere; a later sweep gets it.
+ }
}
+ }
- return extSeparator;
+ private static boolean canDeleteLoaded() {
+ return FileSystems.getDefault().supportedFileAttributeViews().contains("posix");
}
private static String getOSFamily() {
diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/CustomVideoSourceAdaptationTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/CustomVideoSourceAdaptationTests.java
new file mode 100644
index 00000000..b31f9f30
--- /dev/null
+++ b/webrtc/src/test/java/dev/onvoid/webrtc/CustomVideoSourceAdaptationTests.java
@@ -0,0 +1,160 @@
+/*
+ * 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;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.ByteBuffer;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Random;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+
+import dev.onvoid.webrtc.media.video.CustomVideoSource;
+import dev.onvoid.webrtc.media.video.NativeI420Buffer;
+import dev.onvoid.webrtc.media.video.VideoFrame;
+import dev.onvoid.webrtc.media.video.VideoTrack;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.parallel.Execution;
+import org.junit.jupiter.api.parallel.ExecutionMode;
+
+/**
+ * Tests that a CustomVideoSource adapts what it delivers to what the encoder
+ * asks for, the way a camera or a desktop source does. The encoder lowers the
+ * resolution when the bitrate cannot carry the frames it is given; a source
+ * that ignores that leaves the encoder dropping most frames instead, which
+ * shows up as a video that plays at a few frames a second.
+ */
+@Execution(ExecutionMode.SAME_THREAD)
+class CustomVideoSourceAdaptationTests extends TestBase {
+
+ private static final int WIDTH = 1280;
+ private static final int HEIGHT = 720;
+
+
+ @Test
+ void lowBitrateScalesDown() throws Exception {
+ CustomVideoSource source = new CustomVideoSource();
+ VideoTrack track = factory.createVideoTrack("video", source);
+
+ TestPeerConnection caller = new TestPeerConnection(factory);
+ TestPeerConnection callee = new TestPeerConnection(factory);
+
+ RTCRtpSender sender = caller.getPeerConnection()
+ .addTrack(track, Collections.singletonList("stream"));
+
+ caller.setRemotePeerConnection(callee);
+ callee.setRemotePeerConnection(caller);
+
+ callee.setRemoteDescription(caller.createOffer());
+ caller.setRemoteDescription(callee.createAnswer());
+
+ caller.waitUntilConnected();
+ callee.waitUntilConnected();
+
+ // Far too little for 720p of noise, so the encoder has to give way.
+ RTCRtpSendParameters parameters = sender.getParameters();
+ parameters.encodings.get(0).maxBitrate = 150_000;
+ sender.setParameters(parameters);
+
+ Random random = new Random(1);
+ byte[] noise = new byte[WIDTH * HEIGHT];
+ long frameUs = 1_000_000 / 30;
+ // Generous, for runners slow enough to take a while over encoding
+ // 720p of noise; a source that adapts gets there within a second or
+ // two everywhere else.
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(30);
+ long nextStatsNs = 0;
+ long sentWidth = WIDTH;
+ int pushed = 0;
+ Map outbound = Collections.emptyMap();
+
+ try {
+ while (System.nanoTime() < deadline && sentWidth >= WIDTH) {
+ NativeI420Buffer buffer = NativeI420Buffer.allocate(WIDTH, HEIGHT);
+
+ // Noise, because a flat picture costs next to nothing to encode
+ // and would never make the encoder ask for less.
+ random.nextBytes(noise);
+ ByteBuffer y = buffer.getDataY();
+ y.put(noise, 0, y.remaining());
+
+ VideoFrame frame = new VideoFrame(buffer, 0);
+ source.pushFrame(frame);
+ frame.release();
+ pushed++;
+
+ if (System.nanoTime() > nextStatsNs) {
+ nextStatsNs = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(500);
+ outbound = outboundVideoStats(caller.getPeerConnection());
+
+ Object width = outbound.get("frameWidth");
+
+ if (width instanceof Number) {
+ sentWidth = ((Number) width).longValue();
+ }
+ }
+
+ Thread.sleep(frameUs / 1000);
+ }
+
+ // What the sender reported last says whether it encoded at all and
+ // what held it back, which is what a failure on a platform that
+ // cannot be run locally has to go on.
+ assertTrue(sentWidth < WIDTH, String.format(
+ "the encoder kept receiving %dx%d frames; %d frames pushed, "
+ + "last outbound stats: framesEncoded=%s, framesSent=%s, "
+ + "framesPerSecond=%s, frameWidth=%s, targetBitrate=%s, "
+ + "qualityLimitationReason=%s, "
+ + "qualityLimitationResolutionChanges=%s, encoderImplementation=%s",
+ WIDTH, HEIGHT, pushed, outbound.get("framesEncoded"),
+ outbound.get("framesSent"), outbound.get("framesPerSecond"),
+ outbound.get("frameWidth"), outbound.get("targetBitrate"),
+ outbound.get("qualityLimitationReason"),
+ outbound.get("qualityLimitationResolutionChanges"),
+ outbound.get("encoderImplementation")));
+ }
+ finally {
+ sender.dispose();
+ caller.close();
+ callee.close();
+ track.dispose();
+ source.dispose();
+ }
+ }
+
+ /**
+ * Returns the sender's outbound video stats, or nothing while it has not
+ * reported any yet.
+ */
+ private static Map outboundVideoStats(RTCPeerConnection peerConnection)
+ throws Exception {
+ CompletableFuture future = new CompletableFuture<>();
+ peerConnection.getStats(future::complete);
+
+ for (RTCStats stats : future.get(2, TimeUnit.SECONDS).getStats().values()) {
+ if (stats.getType() == RTCStatsType.OUTBOUND_RTP
+ && "video".equals(stats.getAttributes().get("kind"))) {
+ return stats.getAttributes();
+ }
+ }
+
+ return Collections.emptyMap();
+ }
+}
diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java
index 3885a7ec..0987f52f 100644
--- a/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java
+++ b/webrtc/src/test/java/dev/onvoid/webrtc/RTCPeerConnectionTests.java
@@ -430,6 +430,17 @@ void getStats() throws InterruptedException {
assertNotNull(statsReport);
assertNotNull(statsReport.getStats());
assertFalse(statsReport.getStats().isEmpty());
+
+ // Each entry is built from several native arguments at once, so a
+ // mismatch in how they are passed shows up as a missing type, an id
+ // that is not the one the report files it under, or no timestamp,
+ // rather than as an empty report.
+ statsReport.getStats().forEach((id, stats) -> {
+ assertNotNull(stats.getType(), "no type for " + id);
+ assertEquals(id, stats.getId());
+ assertTrue(stats.getTimestamp() > 0, "no timestamp for " + id);
+ assertNotNull(stats.getAttributes(), "no attributes for " + id);
+ });
}
@Test
diff --git a/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeLoaderTests.java b/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeLoaderTests.java
new file mode 100644
index 00000000..43e16d0c
--- /dev/null
+++ b/webrtc/src/test/java/dev/onvoid/webrtc/internal/NativeLoaderTests.java
@@ -0,0 +1,147 @@
+/*
+ * 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.internal;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeFalse;
+
+import java.io.IOException;
+import java.nio.channels.FileChannel;
+import java.nio.channels.FileLock;
+import java.nio.file.FileSystems;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardOpenOption;
+import java.nio.file.attribute.FileTime;
+import java.util.stream.Stream;
+
+import dev.onvoid.webrtc.TestBase;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * Covers the cleanup of extracted native libraries. What is swept is an
+ * isolated directory set up here, never the real temporary directory, and the
+ * time a sweep runs at is given rather than read from the clock.
+ *
+ * @author Alex Andres
+ */
+class NativeLoaderTests extends TestBase {
+
+ /** Well past the age a sweep waits for. */
+ private static final long LATER = NativeLoader.SWEEP_MIN_AGE_MS * 2;
+
+ @TempDir
+ Path root;
+
+
+ @Test
+ void sweepsEndedProcess() throws Exception {
+ Path directory = extraction("ended", true);
+
+ NativeLoader.sweep(root, now() + LATER);
+
+ assertFalse(Files.exists(directory));
+ }
+
+ @Test
+ void sweepsUnlockedDirectory() throws Exception {
+ // A process that ended before it could create its lock file.
+ Path directory = extraction("unlocked", false);
+
+ NativeLoader.sweep(root, now() + LATER);
+
+ assertFalse(Files.exists(directory));
+ }
+
+ @Test
+ void keepsDirectoryInUse() throws Exception {
+ Path directory = extraction("in-use", true);
+
+ try (FileChannel channel = FileChannel.open(
+ directory.resolve(NativeLoader.LOCK_FILE_NAME), StandardOpenOption.WRITE);
+ FileLock lock = channel.lock()) {
+ NativeLoader.sweep(root, now() + LATER);
+
+ assertTrue(Files.exists(directory.resolve("library.dll")));
+ }
+ }
+
+ @Test
+ void keepsYoungDirectory() throws Exception {
+ // A process may not hold its lock yet right after creating it.
+ Path directory = extraction("young", false);
+
+ NativeLoader.sweep(root, now());
+
+ assertTrue(Files.exists(directory));
+ }
+
+ @Test
+ void sweepsLegacyFiles() throws Exception {
+ Path legacy = Files.createFile(root.resolve("webrtc-java-windows-x86_641234567890.dll"));
+ Path unrelated = Files.createFile(root.resolve("webrtc-java-windows-x86_64.dll"));
+
+ NativeLoader.sweep(root, now() + LATER);
+
+ assertFalse(Files.exists(legacy));
+ assertTrue(Files.exists(unrelated));
+ }
+
+ @Test
+ void holdsOwnDirectory() throws Exception {
+ // Elsewhere nothing of a loaded library is left to hold.
+ assumeFalse(FileSystems.getDefault().supportedFileAttributeViews().contains("posix"));
+
+ // Made by the load TestBase caused, so this only returns it.
+ Path directory = NativeLoader.processDirectory(
+ Paths.get(System.getProperty("java.io.tmpdir")));
+
+ try (Stream files = Files.list(directory)) {
+ // The library, next to the lock file.
+ assertTrue(files.count() > 1, "nothing extracted");
+ }
+
+ assertTrue(NativeLoader.isInUse(directory));
+ }
+
+ /**
+ * An extraction directory holding a library, made to look as old as a
+ * directory left behind by an ended process.
+ */
+ private Path extraction(String name, boolean withLockFile) throws IOException {
+ Path directory = Files.createDirectory(root.resolve(NativeLoader.DIRECTORY_PREFIX + name));
+
+ Files.createFile(directory.resolve("library.dll"));
+
+ if (withLockFile) {
+ Files.createFile(directory.resolve(NativeLoader.LOCK_FILE_NAME));
+ }
+
+ Files.setLastModifiedTime(directory, FileTime.fromMillis(now()));
+
+ return directory;
+ }
+
+ private static long now() {
+ return System.currentTimeMillis();
+ }
+
+}