mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 15:52:22 +00:00
feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg
Drops uk.co.caprica:vlcj 4.8.3 (GPL-3.0) from desktopApp and replaces it
with an MIT-dominant stack:
- Video / audio playback: io.github.kdroidfilter:composemediaplayer:0.10.0
(MIT) — OS-native backends (Media Foundation on Windows, AVFoundation on
macOS, GStreamer on Linux). First-class Compose VideoPlayerSurface.
- Thumbnail extraction: org.jcodec:jcodec(+javase):0.2.5 (BSD-2) primary
H.264 path, raw ProcessBuilder FFmpeg fallback for HEVC / VP9 / AV1 /
HLS / non-faststart MP4.
- Binary SPDX: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0.
rpmLicenseType updated accordingly (previously misdeclared as MIT
while shipping GPLv3 vlcj).
Code changes:
- Deleted: VlcjPlayerPool, MacOsVlcDiscoverer, BundledVlcDiscoverer,
VlcResourceResolver
- Rewrote: GlobalMediaPlayer (kdroidFilter engine + snapshotFlow-based
state sync into the preserved MediaPlaybackState contract);
VideoThumbnailCache (JCodec → ProcessBuilder ffmpeg cascade, with a
hard 4 MiB download cap, Content-Type sniff to reject HTML error
pages, and cleanup of zero-byte cache entries); DesktopVideoPlayer
(mounts VideoPlayerSurface for the active URL, codec/network error
UX with "Open in default player" fallback)
- Updated: NowPlayingBar + GlobalFullscreenOverlay to render
VideoPlayerSurface directly (drops the videoFrame ImageBitmap relay)
- Main.kt: drops vlcj pre-init / shutdown calls (kdroidFilter lazy-loads
natives + registers its own shutdown hook on Windows)
Build / packaging:
- Removes ir.mahozad.vlc-setup plugin + vlcSetup{} block + the per-OS
bundled VLC tree + the -Dvlc.plugin.path JVM arg
- Adds NOTICE.md + per-component LICENSE-*.txt under appResources/common
(LGPL-2.1 license text is a placeholder — replace with verbatim FSF
text before release)
- Adds per-OS LGPL FFmpeg drop-in directories with README pointing at
the recommended LGPL binary source (osxexperts.net / Crigges Windows
LGPL build)
- Adds Flathub manifest skeleton (Gitnuro-style: org.freedesktop.Platform
24.08 + openjdk21 extension + org.freedesktop.Platform.ffmpeg-full
add-extension for patent codecs)
- AppRun: drops VLC LD_LIBRARY_PATH / VLC_PLUGIN_PATH env wiring
Verified on macOS arm64:
- ./gradlew :desktopApp:compileKotlin BUILD SUCCESSFUL
- ./gradlew :desktopApp:test BUILD SUCCESSFUL
- ./gradlew :desktopApp:spotlessApply clean
- Smoke launch: no VLC/vlcj/libvlc log lines, kdroidFilter native
library extracts to ~/.cache/composemediaplayer/native/, thumbnail
cache populates at ~/.cache/amethyst-desktop/video-thumbs/ with the
4 MiB cap enforced
- H.264 MP4 playback (active + thumbnail extraction) confirmed
- VP9-in-WebM playback fails on macOS as AVFoundation cannot decode it —
expected codec gap; surfaced via PlaybackErrorMessage + "Open in
default player" handoff in DesktopVideoPlayer
Docs:
- docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
- docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import de.undercouch.gradle.tasks.download.Download
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
import java.nio.file.Files
|
||||
|
||||
@@ -6,7 +5,6 @@ plugins {
|
||||
alias(libs.plugins.jetbrainsKotlinJvm)
|
||||
alias(libs.plugins.composeMultiplatform)
|
||||
alias(libs.plugins.jetbrainsComposeCompiler)
|
||||
id("ir.mahozad.vlc-setup") version "0.1.0"
|
||||
}
|
||||
|
||||
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
|
||||
@@ -57,8 +55,14 @@ dependencies {
|
||||
implementation(libs.coil.okhttp)
|
||||
implementation(libs.coil.svg)
|
||||
|
||||
// Video playback
|
||||
implementation(libs.vlcj)
|
||||
// Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer)
|
||||
implementation(libs.composemediaplayer)
|
||||
|
||||
// Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess
|
||||
// for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no
|
||||
// wrapper library needed (see VideoThumbnailCache.runFfmpegToImage).
|
||||
implementation(libs.jcodec)
|
||||
implementation(libs.jcodec.javase)
|
||||
|
||||
// EXIF stripping (lossless)
|
||||
implementation(libs.commons.imaging)
|
||||
@@ -94,9 +98,6 @@ compose.desktop {
|
||||
|
||||
jvmArgs += "-Xmx2g"
|
||||
|
||||
// VLC plugin path fallback — used if JNA setenv and bundled discovery both fail
|
||||
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
|
||||
|
||||
// Forward platform-preview overrides from the gradle invocation to the
|
||||
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
|
||||
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
|
||||
@@ -114,7 +115,7 @@ compose.desktop {
|
||||
"java.prefs", // java.util.prefs (desktop persistence)
|
||||
"java.sql", // JDBC metadata (Jackson, SQLite driver)
|
||||
"jdk.security.auth", // JAAS authentication callbacks
|
||||
"jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory)
|
||||
"jdk.unsupported", // sun.misc.Unsafe (secp256k1-kmp-jni-jvm, JNA)
|
||||
)
|
||||
|
||||
packageName = "Amethyst"
|
||||
@@ -138,7 +139,13 @@ compose.desktop {
|
||||
menuGroup = "Network"
|
||||
appCategory = "Network"
|
||||
debMaintainer = "vitor@vitorpamplona.com"
|
||||
rpmLicenseType = "MIT"
|
||||
// SPDX compound expression. Bundled components:
|
||||
// MIT — Amethyst + kdroidFilter ComposeMediaPlayer
|
||||
// LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) +
|
||||
// GStreamer (Linux runtime dep, system-installed)
|
||||
// BSD-2-Clause — JCodec
|
||||
// Apache-2.0 — Jaffree + many transitive Java libraries
|
||||
rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0"
|
||||
// RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering).
|
||||
rpmPackageVersion = appVersion.replace("-", "~")
|
||||
}
|
||||
@@ -149,8 +156,8 @@ compose.desktop {
|
||||
// problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum`
|
||||
// rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in
|
||||
// `compose-rules.pro` so the desktop release survives JNI callbacks
|
||||
// (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy
|
||||
// libraries (Jackson, JNA) without renaming.
|
||||
// (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native)
|
||||
// and reflection-heavy libraries (Jackson, JNA) without renaming.
|
||||
//
|
||||
// Shrink and optimize stay ON. One ProGuard optimize sub-pass is
|
||||
// disabled in `compose-rules.pro` to avoid a generated okio bridge
|
||||
@@ -163,61 +170,23 @@ compose.desktop {
|
||||
}
|
||||
}
|
||||
|
||||
vlcSetup {
|
||||
// Pinned to 3.0.20 because the Linux VLC plugins on Maven Central
|
||||
// (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the
|
||||
// latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI.
|
||||
vlcVersion.set("3.0.20")
|
||||
shouldCompressVlcFiles.set(true)
|
||||
shouldIncludeAllVlcFiles.set(true)
|
||||
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
|
||||
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
|
||||
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
|
||||
}
|
||||
|
||||
tasks.named("spotlessKotlin") {
|
||||
mustRunAfter("vlcSetup")
|
||||
}
|
||||
|
||||
// `ir.mahozad.vlc-setup` registers `vlcDownload` / `upxDownload` tasks that
|
||||
// extend `de.undercouch.gradle.tasks.download.Download`. Defaults are 0 retries
|
||||
// and a short read timeout, so a transient blip on get.videolan.org fails the
|
||||
// whole desktop build on CI (Windows MSI, macOS DMG, Linux DEB). Configure all
|
||||
// Download tasks in this project to retry with generous timeouts so flaky
|
||||
// network conditions do not break packaging jobs.
|
||||
tasks.withType<Download>().configureEach {
|
||||
// 5 attempts total (initial + 4 retries) before failing the task.
|
||||
retries(4)
|
||||
// 30s to establish a TCP / TLS connection.
|
||||
connectTimeout(30_000)
|
||||
// 5 minutes per attempt for the body — VLC archives are 40-90 MB and
|
||||
// get.videolan.org can be slow under load.
|
||||
readTimeout(5 * 60_000)
|
||||
// Stage to a temp file and rename only on full success, so a partial
|
||||
// download from one attempt cannot poison the next.
|
||||
tempAndMove(true)
|
||||
}
|
||||
|
||||
// --- AppImage packaging (Linux) ---
|
||||
//
|
||||
// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101).
|
||||
// Instead: wrap `createReleaseDistributable` output with `appimagetool`, which
|
||||
// just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here —
|
||||
// linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps,
|
||||
// but jpackage already ships a self-contained tree we don't want it touching:
|
||||
// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while
|
||||
// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have
|
||||
// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help.
|
||||
// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those
|
||||
// with "patchelf: no section headers" because they look like static ELFs.
|
||||
// - Several VLC libs have RUNPATH that does not point at sibling libs in
|
||||
// the same directory, so ldd errors with "Could not find dependency".
|
||||
// appimagetool sidesteps all of this — it only embeds the AppDir into a
|
||||
// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH
|
||||
// at launch.
|
||||
// but jpackage already ships a self-contained tree we don't want it touching
|
||||
// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling
|
||||
// libs use $ORIGIN RPATH — ldd can't resolve without help).
|
||||
// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS,
|
||||
// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch.
|
||||
//
|
||||
// kdroidFilter (video/audio) links against system GStreamer at runtime — the
|
||||
// AppImage does not bundle GStreamer; the host system must have it installed.
|
||||
//
|
||||
// Build inputs live in desktopApp/packaging/appimage/:
|
||||
// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC)
|
||||
// - AppRun shell launcher
|
||||
// - amethyst.desktop XDG desktop entry
|
||||
// - amethyst.png 512x512 icon
|
||||
//
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
#!/bin/bash
|
||||
# AppImage launcher for Amethyst Desktop.
|
||||
# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime.
|
||||
# jpackage puts app resources at usr/lib/app/<platform>/vlc/ inside the AppDir.
|
||||
# kdroidFilter ComposeMediaPlayer uses the host system's GStreamer (linked at
|
||||
# runtime). Users need:
|
||||
# sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
|
||||
# gstreamer1.0-plugins-bad gstreamer1.0-libav
|
||||
# (Equivalent packages on Fedora/Arch.)
|
||||
set -eu
|
||||
HERE="$(dirname "$(readlink -f "${0}")")"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||
export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins"
|
||||
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
|
||||
export PATH="${HERE}/usr/bin:${PATH}"
|
||||
export APPDIR="${HERE}"
|
||||
exec "${HERE}/usr/bin/Amethyst" "$@"
|
||||
|
||||
54
desktopApp/packaging/flatpak/README.md
Normal file
54
desktopApp/packaging/flatpak/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Flathub packaging for Amethyst Desktop
|
||||
|
||||
This directory contains the Flatpak manifest and associated metadata for
|
||||
publishing Amethyst Desktop on Flathub.
|
||||
|
||||
## Files
|
||||
|
||||
- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest
|
||||
- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata
|
||||
(categories, license, screenshots — needs screenshots added before
|
||||
submission)
|
||||
- `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry
|
||||
- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256
|
||||
PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before
|
||||
submission
|
||||
|
||||
## Build prerequisites
|
||||
|
||||
- `./gradlew :desktopApp:createReleaseDistributable` — produces
|
||||
`desktopApp/build/compose/binaries/main-release/app/Amethyst/`
|
||||
- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08`
|
||||
|
||||
## Local build
|
||||
|
||||
```bash
|
||||
cd desktopApp/packaging/flatpak
|
||||
flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml
|
||||
flatpak run com.vitorpamplona.amethyst.Desktop
|
||||
```
|
||||
|
||||
## Submission to Flathub
|
||||
|
||||
Follow https://docs.flathub.org/docs/for-app-authors/submission
|
||||
|
||||
1. Fork `flathub/flathub` on GitHub.
|
||||
2. Branch from `new-pr` (NOT `master`).
|
||||
3. Copy this manifest + AppStream + desktop into a new directory matching
|
||||
the app id.
|
||||
4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop".
|
||||
5. After merge, a per-app repo is created with write access for ongoing
|
||||
updates.
|
||||
|
||||
## Codec coverage
|
||||
|
||||
- HEVC / VP9 / AV1: covered via `org.freedesktop.Platform.ffmpeg-full`
|
||||
add-extension declared in the manifest. Flatpak downloads it on install.
|
||||
- HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in
|
||||
`org.freedesktop.Platform 24.08` itself.
|
||||
|
||||
## License metadata
|
||||
|
||||
The manifest declares the binary as
|
||||
`MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0`. This SPDX
|
||||
expression validates via `appstreamcli validate`.
|
||||
@@ -0,0 +1,9 @@
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Amethyst Desktop
|
||||
Comment=Nostr client for desktop
|
||||
Categories=Network;InstantMessaging;
|
||||
Exec=amethyst-desktop
|
||||
Icon=com.vitorpamplona.amethyst.Desktop
|
||||
Terminal=false
|
||||
StartupWMClass=Amethyst
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<component type="desktop-application">
|
||||
<id>com.vitorpamplona.amethyst.Desktop</id>
|
||||
<metadata_license>CC0-1.0</metadata_license>
|
||||
<project_license>MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0</project_license>
|
||||
<name>Amethyst Desktop</name>
|
||||
<summary>Nostr client for desktop</summary>
|
||||
|
||||
<description>
|
||||
<p>
|
||||
Amethyst Desktop is a desktop client for the Nostr protocol.
|
||||
Browse feeds, post notes, send zaps (Lightning Network), and
|
||||
participate in NIP-53 live audio rooms.
|
||||
</p>
|
||||
<p>
|
||||
Video playback uses your system's GStreamer (Linux), AVFoundation
|
||||
(macOS), or Media Foundation (Windows). On Linux, install the
|
||||
GStreamer plugin packages (good, bad, libav) for full codec coverage.
|
||||
</p>
|
||||
</description>
|
||||
|
||||
<launchable type="desktop-id">com.vitorpamplona.amethyst.Desktop.desktop</launchable>
|
||||
|
||||
<url type="homepage">https://github.com/vitorpamplona/amethyst</url>
|
||||
<url type="bugtracker">https://github.com/vitorpamplona/amethyst/issues</url>
|
||||
<url type="vcs-browser">https://github.com/vitorpamplona/amethyst</url>
|
||||
|
||||
<developer id="com.vitorpamplona">
|
||||
<name>Vitor Pamplona</name>
|
||||
</developer>
|
||||
|
||||
<content_rating type="oars-1.1" />
|
||||
|
||||
<categories>
|
||||
<category>Network</category>
|
||||
<category>InstantMessaging</category>
|
||||
</categories>
|
||||
|
||||
<screenshots>
|
||||
<!-- Add at least one screenshot tagged with `<image>...</image>`
|
||||
before Flathub submission. Suggested 1280x800 PNG. -->
|
||||
</screenshots>
|
||||
|
||||
<releases>
|
||||
<!-- Populated by release CI. Example:
|
||||
<release version="0.0.0" date="2026-06-11">
|
||||
<description>
|
||||
<p>Initial Flathub release with kdroidFilter video playback.</p>
|
||||
</description>
|
||||
</release>
|
||||
-->
|
||||
</releases>
|
||||
</component>
|
||||
@@ -0,0 +1,77 @@
|
||||
# Flathub manifest for Amethyst Desktop.
|
||||
#
|
||||
# Pattern based on flathub/com.jetpackduba.Gitnuro (Kotlin Compose Desktop).
|
||||
# Submission to Flathub is a separate operation — see
|
||||
# desktopApp/packaging/flatpak/README.md for the workflow.
|
||||
|
||||
app-id: com.vitorpamplona.amethyst.Desktop
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '24.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.openjdk21
|
||||
command: amethyst-desktop
|
||||
|
||||
add-extensions:
|
||||
org.freedesktop.Platform.ffmpeg-full:
|
||||
directory: lib/ffmpeg
|
||||
version: '24.08'
|
||||
add-ld-path: .
|
||||
autodownload: true
|
||||
autodelete: false
|
||||
|
||||
cleanup-commands:
|
||||
- mkdir -p /app/lib/ffmpeg
|
||||
|
||||
finish-args:
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
- --socket=wayland
|
||||
- --socket=pulseaudio
|
||||
- --device=dri
|
||||
- --filesystem=xdg-download
|
||||
- --filesystem=xdg-pictures
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
- --talk-name=org.freedesktop.secrets
|
||||
# GStreamer plugin/cache paths (org.freedesktop.Platform exposes them by default).
|
||||
- --env=GST_PLUGIN_SYSTEM_PATH=/usr/lib/x86_64-linux-gnu/gstreamer-1.0
|
||||
|
||||
modules:
|
||||
- name: openjdk
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- /usr/lib/sdk/openjdk21/install.sh
|
||||
|
||||
- name: amethyst-desktop
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
# Drop the jpackage-emitted self-contained tree into /app/lib/Amethyst.
|
||||
# Wrapper at /app/bin/amethyst-desktop forwards args.
|
||||
- mkdir -p /app/lib/Amethyst
|
||||
- cp -r ./Amethyst/* /app/lib/Amethyst/
|
||||
- install -Dm755 amethyst-desktop.sh /app/bin/amethyst-desktop
|
||||
- install -Dm644 com.vitorpamplona.amethyst.Desktop.metainfo.xml -t /app/share/metainfo/
|
||||
- install -Dm644 com.vitorpamplona.amethyst.Desktop.desktop -t /app/share/applications/
|
||||
- install -Dm644 icons/256/com.vitorpamplona.amethyst.Desktop.png -t /app/share/icons/hicolor/256x256/apps/
|
||||
sources:
|
||||
# Built artifact from `./gradlew :desktopApp:createReleaseDistributable`.
|
||||
# Path matches Compose Multiplatform 1.11's output layout.
|
||||
- type: dir
|
||||
path: ../../build/compose/binaries/main-release/app
|
||||
dest: ./
|
||||
|
||||
- type: script
|
||||
dest-filename: amethyst-desktop.sh
|
||||
commands:
|
||||
- "#!/bin/sh"
|
||||
- exec /app/lib/Amethyst/bin/Amethyst "$@"
|
||||
|
||||
- type: file
|
||||
path: com.vitorpamplona.amethyst.Desktop.metainfo.xml
|
||||
|
||||
- type: file
|
||||
path: com.vitorpamplona.amethyst.Desktop.desktop
|
||||
|
||||
- type: file
|
||||
path: icons/256/com.vitorpamplona.amethyst.Desktop.png
|
||||
51
desktopApp/src/jvmMain/appResources/common/NOTICE.md
Normal file
51
desktopApp/src/jvmMain/appResources/common/NOTICE.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Amethyst Desktop — Third-party Notices
|
||||
|
||||
Amethyst Desktop is distributed under the MIT License (see `LICENSE-MIT-amethyst.txt`).
|
||||
The distributed binary includes the following third-party components:
|
||||
|
||||
## kdroidFilter ComposeMediaPlayer
|
||||
|
||||
- License: MIT
|
||||
- Version: 0.10.1
|
||||
- Upstream: https://github.com/kdroidFilter/ComposeMediaPlayer
|
||||
- License text: `licenses/LICENSE-MIT-kdroidfilter.txt`
|
||||
|
||||
## JCodec
|
||||
|
||||
- License: BSD 2-Clause
|
||||
- Version: 0.2.5
|
||||
- Upstream: https://github.com/jcodec/jcodec
|
||||
- License text: `licenses/LICENSE-BSD-2-jcodec.txt`
|
||||
|
||||
## FFmpeg (LGPL build, bundled per OS for thumbnail extraction)
|
||||
|
||||
- License: LGPL-2.1-or-later
|
||||
- Build: LGPL-only configuration (no `--enable-gpl`, no `--enable-nonfree`)
|
||||
- Upstream: https://ffmpeg.org/
|
||||
- License text: `licenses/LICENSE-LGPL-2.1.txt`
|
||||
- Source availability: https://github.com/vitorpamplona/amethyst (build tag matches
|
||||
the binary tag); FFmpeg sources at https://github.com/FFmpeg/FFmpeg
|
||||
- Per-OS binary source provenance documented in
|
||||
`desktopApp/src/jvmMain/appResources/<os>/ffmpeg/README.md`.
|
||||
|
||||
## GStreamer (Linux runtime dependency; not bundled)
|
||||
|
||||
- License: LGPL-2.1-or-later (core + linked plugins)
|
||||
- Required at runtime by ComposeMediaPlayer on Linux (linked via `pkg-config`).
|
||||
- License text: `licenses/LICENSE-LGPL-2.1.txt`
|
||||
- Users install via their distribution's package manager. We bundle no
|
||||
GStreamer binaries.
|
||||
|
||||
## Other Apache-2.0 / MIT transitive dependencies
|
||||
|
||||
Compose Multiplatform (JetBrains, Apache-2.0), Kotlin stdlib (JetBrains,
|
||||
Apache-2.0), OkHttp (Square, Apache-2.0), Jackson (FasterXML, Apache-2.0),
|
||||
Coil (Apache-2.0), kmp-tor (MIT), and others, plus the bundled OpenJDK
|
||||
runtime (GPLv2 + Classpath Exception). Their license texts are reproduced
|
||||
under `licenses/` alongside this NOTICE.
|
||||
|
||||
## SPDX combined expression
|
||||
|
||||
```
|
||||
MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0
|
||||
```
|
||||
@@ -0,0 +1,24 @@
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions
|
||||
are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright
|
||||
notice, this list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright
|
||||
notice, this list of conditions and the following disclaimer in
|
||||
the documentation and/or other materials provided with the
|
||||
distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS
|
||||
BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR
|
||||
BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
|
||||
WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
|
||||
OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
Copyright (c) JCodec authors.
|
||||
@@ -0,0 +1,23 @@
|
||||
This file should contain the verbatim GNU LGPL-2.1 text from
|
||||
https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt
|
||||
|
||||
The full text is 26 KB. Embed at packaging time (e.g. via a Gradle task
|
||||
that fetches the canonical text), or paste it here verbatim before
|
||||
releasing a build. Until populated, the binary release is non-compliant
|
||||
with LGPL §6 obligations for the bundled FFmpeg binaries and Linux
|
||||
GStreamer runtime dependency.
|
||||
|
||||
Tracking: see plan docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
|
||||
"Phase 0 acceptance" — license text presence is part of the gate.
|
||||
|
||||
Bundled / linked LGPL components:
|
||||
- FFmpeg (LGPL build) — bundled per OS in appResources/<os>/ffmpeg/
|
||||
- GStreamer 1.x core + plugins-base/good/bad/libav — linked on Linux at runtime
|
||||
(user-installed, not bundled)
|
||||
|
||||
Written offer (LGPL §6 / §4): Source for both FFmpeg and GStreamer is available
|
||||
from the projects' canonical git repositories (https://ffmpeg.org/download.html,
|
||||
https://gstreamer.freedesktop.org/src/). The binary tags shipped in any
|
||||
Amethyst Desktop release correspond to released upstream tags; this offer is
|
||||
valid for three years from the date of distribution to anyone in receipt of
|
||||
this binary.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2023 Vitor Pamplona
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Elie Gambache (kdroidFilter)
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -89,7 +89,7 @@ import com.vitorpamplona.amethyst.desktop.network.Nip11Fetcher
|
||||
import com.vitorpamplona.amethyst.desktop.platform.applyNativeWindowChrome
|
||||
import com.vitorpamplona.amethyst.desktop.service.highlights.DesktopHighlightStore
|
||||
import com.vitorpamplona.amethyst.desktop.service.images.DesktopImageLoaderSetup
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinNameService
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.DesktopNamecoinPreferences
|
||||
import com.vitorpamplona.amethyst.desktop.service.namecoin.LocalNamecoinPreferences
|
||||
@@ -237,15 +237,12 @@ fun main() {
|
||||
DesktopImageLoaderSetup.setup()
|
||||
Runtime.getRuntime().addShutdownHook(
|
||||
Thread {
|
||||
com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
.shutdown()
|
||||
VlcjPlayerPool.shutdown()
|
||||
GlobalMediaPlayer.shutdown()
|
||||
// Stop Tor daemon if running — reference set by App composable
|
||||
activeTorManager?.stopSync()
|
||||
},
|
||||
)
|
||||
// Pre-init VLC on background thread so first play is fast
|
||||
Thread { VlcjPlayerPool.init() }.start()
|
||||
// kdroidFilter lazy-loads the native player on first playback — no pre-init needed.
|
||||
application {
|
||||
val windowState =
|
||||
rememberWindowState(
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import uk.co.caprica.vlcj.factory.discovery.strategy.NativeDiscoveryStrategy
|
||||
|
||||
/**
|
||||
* Discovers bundled VLC libraries on Windows and Linux.
|
||||
* Uses [VlcResourceResolver] to find the VLC directory from the Compose application
|
||||
* resources or development fallback paths.
|
||||
*/
|
||||
class BundledVlcDiscoverer : NativeDiscoveryStrategy {
|
||||
override fun supported(): Boolean {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return "mac" !in os
|
||||
}
|
||||
|
||||
override fun discover(): String {
|
||||
val vlcDir = VlcResourceResolver.findVlcDir() ?: return ""
|
||||
return vlcDir.absolutePath
|
||||
}
|
||||
|
||||
override fun onFound(path: String): Boolean = true
|
||||
|
||||
override fun onSetPluginPath(path: String): Boolean = true
|
||||
}
|
||||
@@ -20,31 +20,20 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.vitorpamplona.amethyst.desktop.ui.media.MediaType
|
||||
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
|
||||
import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
|
||||
import io.github.kdroidfilter.composemediaplayer.createVideoPlayerState
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
|
||||
data class MediaPlaybackState(
|
||||
val url: String? = null,
|
||||
@@ -57,221 +46,178 @@ data class MediaPlaybackState(
|
||||
val aspectRatio: Float = 16f / 9f,
|
||||
val volume: Int = 100,
|
||||
val isMuted: Boolean = false,
|
||||
/** Last observed error from the engine, or null. JVM emits only SourceError/UnknownError. */
|
||||
val errorReason: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Singleton facade over kdroidFilter's [VideoPlayerState] (MIT, OS-native backends:
|
||||
* Media Foundation on Windows, AVFoundation on macOS, GStreamer on Linux).
|
||||
*
|
||||
* Holds two engine instances (video + audio) for the lifetime of the JVM. The
|
||||
* underlying [VideoPlayerState] exposes its state via Compose `mutableStateOf`;
|
||||
* a [snapshotFlow] coroutine mirrors that into our public [MediaPlaybackState]
|
||||
* `StateFlow`s so non-Compose consumers (and the existing UI) remain unchanged.
|
||||
*
|
||||
* UI surface for visible video frames is rendered by mounting
|
||||
* `VideoPlayerSurface(playerState = [activeVideoPlayerState])` in the active
|
||||
* `DesktopVideoPlayer` instance — see that file for the active/inactive dispatch.
|
||||
*/
|
||||
object GlobalMediaPlayer {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
// Video state
|
||||
private val _videoFrame = MutableStateFlow<ImageBitmap?>(null)
|
||||
val videoFrame: StateFlow<ImageBitmap?> = _videoFrame.asStateFlow()
|
||||
// Engine handles. Constructed lazily on first playback request so that app
|
||||
// start does not load the native library if the user never plays media.
|
||||
@Volatile private var videoPlayer: VideoPlayerState? = null
|
||||
|
||||
@Volatile private var audioPlayer: VideoPlayerState? = null
|
||||
|
||||
private val initLock = Any()
|
||||
|
||||
/**
|
||||
* The kdroidFilter player driving currently-active or last-played video.
|
||||
* Mounted into a `VideoPlayerSurface(...)` by [DesktopVideoPlayer] when the
|
||||
* caller's `url` matches `videoState.value.url`.
|
||||
*
|
||||
* Lazy: first read constructs the underlying native player.
|
||||
*/
|
||||
val activeVideoPlayerState: VideoPlayerState
|
||||
get() = ensureVideoPlayer()
|
||||
|
||||
private val _videoState = MutableStateFlow(MediaPlaybackState())
|
||||
val videoState: StateFlow<MediaPlaybackState> = _videoState.asStateFlow()
|
||||
|
||||
// Audio state
|
||||
private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO))
|
||||
val audioState: StateFlow<MediaPlaybackState> = _audioState.asStateFlow()
|
||||
|
||||
// Fullscreen
|
||||
private val _isFullscreen = MutableStateFlow(false)
|
||||
val isFullscreen: StateFlow<Boolean> = _isFullscreen.asStateFlow()
|
||||
|
||||
// VLC players — kept alive between plays
|
||||
private var videoPlayer: EmbeddedMediaPlayer? = null
|
||||
private var audioPlayer: MediaPlayer? = null
|
||||
// Stashed pre-mute volume (0..100). kdroidFilter has no isMuted concept, so
|
||||
// we emulate by zeroing volume and remembering the prior value.
|
||||
private var preMuteVideoVolume: Int = 100
|
||||
private var preMuteAudioVolume: Int = 100
|
||||
|
||||
// Skia bitmap for video rendering
|
||||
private var skBitmap: Bitmap? = null
|
||||
private var pixelBytes: ByteArray? = null
|
||||
private var videoSyncJob: Job? = null
|
||||
private var audioSyncJob: Job? = null
|
||||
|
||||
// Position polling job
|
||||
private var videoPollingJob: Job? = null
|
||||
private var audioPollingJob: Job? = null
|
||||
// --- Verbs ---------------------------------------------------------------
|
||||
|
||||
fun playVideo(
|
||||
url: String,
|
||||
seekPosition: Float = 0f,
|
||||
) {
|
||||
// If already playing this URL, just seek
|
||||
val current = _videoState.value
|
||||
if (current.url == url && videoPlayer != null) {
|
||||
if (seekPosition > 0f) {
|
||||
videoPlayer?.controls()?.setPosition(seekPosition)
|
||||
}
|
||||
if (!current.isPlaying) {
|
||||
videoPlayer?.controls()?.play()
|
||||
}
|
||||
val player = ensureVideoPlayer()
|
||||
|
||||
if (current.url == url) {
|
||||
if (seekPosition > 0f) player.seekTo(seekPosition * 1000f)
|
||||
if (!current.isPlaying) player.play()
|
||||
return
|
||||
}
|
||||
|
||||
// Stop current video if different URL
|
||||
if (current.url != null && current.url != url) {
|
||||
videoPlayer?.controls()?.stop()
|
||||
}
|
||||
|
||||
_videoState.value =
|
||||
MediaPlaybackState(
|
||||
url = url,
|
||||
type = MediaType.VIDEO,
|
||||
isBuffering = true,
|
||||
)
|
||||
_videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
if (!VlcjPlayerPool.init()) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val player =
|
||||
videoPlayer ?: VlcjPlayerPool.acquire() ?: run {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
// Only set up surface on first acquisition
|
||||
if (videoPlayer == null) {
|
||||
setupVideoSurface(player)
|
||||
setupVideoEventListener(player)
|
||||
videoPlayer = player
|
||||
}
|
||||
|
||||
var didSeek = seekPosition <= 0f
|
||||
|
||||
// Temporary listener for initial seek
|
||||
if (!didSeek) {
|
||||
val seekListener =
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
if (!didSeek) {
|
||||
didSeek = true
|
||||
mediaPlayer.controls().setPosition(seekPosition)
|
||||
mediaPlayer.events().removeMediaPlayerEventListener(this)
|
||||
}
|
||||
player.openUri(url)
|
||||
// openUri auto-plays per InitialPlayerState.PLAY default.
|
||||
// For an initial seek we wait for hasMedia=true; cleanest is a
|
||||
// one-shot snapshotFlow collector that seeks then completes.
|
||||
if (seekPosition > 0f) {
|
||||
snapshotFlow { player.hasMedia }
|
||||
.collect { ready ->
|
||||
if (ready) {
|
||||
player.seekTo(seekPosition * 1000f)
|
||||
return@collect
|
||||
}
|
||||
}
|
||||
player.events().addMediaPlayerEventListener(seekListener)
|
||||
}
|
||||
|
||||
val vol = _videoState.value.volume
|
||||
player.media().play(url, ":start-volume=$vol")
|
||||
startVideoPolling()
|
||||
}
|
||||
}
|
||||
|
||||
fun playAudio(url: String) {
|
||||
val current = _audioState.value
|
||||
if (current.url == url && audioPlayer != null) {
|
||||
if (!current.isPlaying) {
|
||||
audioPlayer?.controls()?.play()
|
||||
}
|
||||
val player = ensureAudioPlayer()
|
||||
|
||||
if (current.url == url) {
|
||||
if (!current.isPlaying) player.play()
|
||||
return
|
||||
}
|
||||
|
||||
if (current.url != null && current.url != url) {
|
||||
audioPlayer?.controls()?.stop()
|
||||
}
|
||||
|
||||
_audioState.value =
|
||||
MediaPlaybackState(
|
||||
url = url,
|
||||
type = MediaType.AUDIO,
|
||||
isBuffering = true,
|
||||
)
|
||||
_audioState.value = MediaPlaybackState(url = url, type = MediaType.AUDIO, isBuffering = true)
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val player =
|
||||
audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run {
|
||||
_audioState.value = _audioState.value.copy(isBuffering = false)
|
||||
return@launch
|
||||
}
|
||||
|
||||
if (audioPlayer == null) {
|
||||
setupAudioEventListener(player)
|
||||
audioPlayer = player
|
||||
}
|
||||
|
||||
val vol = _audioState.value.volume
|
||||
player.media().play(url, ":start-volume=$vol")
|
||||
startAudioPolling()
|
||||
player.openUri(url)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleVideoPlayPause() {
|
||||
val player = videoPlayer ?: return
|
||||
val state = _videoState.value
|
||||
if (state.url == null) return
|
||||
|
||||
if (state.isPlaying) {
|
||||
player.controls().pause()
|
||||
} else {
|
||||
if (state.position <= 0f && !player.status().isPlaying) {
|
||||
state.url.let { player.media().play(it) }
|
||||
} else {
|
||||
player.controls().play()
|
||||
}
|
||||
}
|
||||
if (_videoState.value.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
fun toggleAudioPlayPause() {
|
||||
val player = audioPlayer ?: return
|
||||
val state = _audioState.value
|
||||
if (state.url == null) return
|
||||
|
||||
if (state.isPlaying) {
|
||||
player.controls().pause()
|
||||
} else {
|
||||
if (state.position <= 0f && !player.status().isPlaying) {
|
||||
state.url.let { player.media().play(it) }
|
||||
} else {
|
||||
player.controls().play()
|
||||
}
|
||||
}
|
||||
if (_audioState.value.isPlaying) player.pause() else player.play()
|
||||
}
|
||||
|
||||
/** UI passes position in 0..1. kdroidFilter wants 0..1000. */
|
||||
fun seekVideo(position: Float) {
|
||||
videoPlayer?.controls()?.setPosition(position)
|
||||
videoPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f))
|
||||
}
|
||||
|
||||
fun seekAudio(position: Float) {
|
||||
audioPlayer?.controls()?.setPosition(position)
|
||||
audioPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f))
|
||||
}
|
||||
|
||||
/** UI passes volume in 0..100. kdroidFilter wants 0..1. */
|
||||
fun setVideoVolume(volume: Int) {
|
||||
videoPlayer?.audio()?.setVolume(volume)
|
||||
_videoState.value = _videoState.value.copy(volume = volume)
|
||||
videoPlayer?.volume = volume.coerceIn(0, 100) / 100f
|
||||
val muted = _videoState.value.isMuted && volume == 0
|
||||
_videoState.value = _videoState.value.copy(volume = volume, isMuted = muted)
|
||||
if (volume > 0) preMuteVideoVolume = volume
|
||||
}
|
||||
|
||||
fun setAudioVolume(volume: Int) {
|
||||
audioPlayer?.audio()?.setVolume(volume)
|
||||
_audioState.value = _audioState.value.copy(volume = volume)
|
||||
audioPlayer?.volume = volume.coerceIn(0, 100) / 100f
|
||||
val muted = _audioState.value.isMuted && volume == 0
|
||||
_audioState.value = _audioState.value.copy(volume = volume, isMuted = muted)
|
||||
if (volume > 0) preMuteAudioVolume = volume
|
||||
}
|
||||
|
||||
/** kdroidFilter has no mute concept — emulate by stashing volume. */
|
||||
fun toggleVideoMute() {
|
||||
val muted = !_videoState.value.isMuted
|
||||
videoPlayer?.audio()?.isMute = muted
|
||||
_videoState.value = _videoState.value.copy(isMuted = muted)
|
||||
val state = _videoState.value
|
||||
if (state.isMuted) {
|
||||
setVideoVolume(preMuteVideoVolume)
|
||||
_videoState.value = _videoState.value.copy(isMuted = false)
|
||||
} else {
|
||||
preMuteVideoVolume = state.volume.coerceAtLeast(1)
|
||||
videoPlayer?.volume = 0f
|
||||
_videoState.value = _videoState.value.copy(isMuted = true, volume = 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleAudioMute() {
|
||||
val muted = !_audioState.value.isMuted
|
||||
audioPlayer?.audio()?.isMute = muted
|
||||
_audioState.value = _audioState.value.copy(isMuted = muted)
|
||||
val state = _audioState.value
|
||||
if (state.isMuted) {
|
||||
setAudioVolume(preMuteAudioVolume)
|
||||
_audioState.value = _audioState.value.copy(isMuted = false)
|
||||
} else {
|
||||
preMuteAudioVolume = state.volume.coerceAtLeast(1)
|
||||
audioPlayer?.volume = 0f
|
||||
_audioState.value = _audioState.value.copy(isMuted = true, volume = 0)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopVideo() {
|
||||
videoPollingJob?.cancel()
|
||||
videoPollingJob = null
|
||||
videoPlayer?.controls()?.stop()
|
||||
videoPlayer?.stop()
|
||||
_videoState.value = MediaPlaybackState()
|
||||
_videoFrame.value = null
|
||||
_isFullscreen.value = false
|
||||
}
|
||||
|
||||
fun stopAudio() {
|
||||
audioPollingJob?.cancel()
|
||||
audioPollingJob = null
|
||||
audioPlayer?.controls()?.stop()
|
||||
audioPlayer?.stop()
|
||||
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
|
||||
}
|
||||
|
||||
@@ -283,215 +229,126 @@ object GlobalMediaPlayer {
|
||||
_isFullscreen.value = false
|
||||
}
|
||||
|
||||
/** Call on app exit. Disposes native handles owned by kdroidFilter. */
|
||||
fun shutdown() {
|
||||
videoPollingJob?.cancel()
|
||||
audioPollingJob?.cancel()
|
||||
|
||||
videoPlayer?.let { p ->
|
||||
try {
|
||||
p.controls().stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
VlcjPlayerPool.release(p)
|
||||
}
|
||||
videoSyncJob?.cancel()
|
||||
audioSyncJob?.cancel()
|
||||
runCatching { videoPlayer?.stop() }
|
||||
runCatching { videoPlayer?.dispose() }
|
||||
runCatching { audioPlayer?.stop() }
|
||||
runCatching { audioPlayer?.dispose() }
|
||||
videoPlayer = null
|
||||
|
||||
audioPlayer?.let { p ->
|
||||
try {
|
||||
p.controls().stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
VlcjPlayerPool.releaseAudioPlayer(p)
|
||||
}
|
||||
audioPlayer = null
|
||||
|
||||
_videoState.value = MediaPlaybackState()
|
||||
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
|
||||
_videoFrame.value = null
|
||||
_isFullscreen.value = false
|
||||
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
private fun setupVideoSurface(player: EmbeddedMediaPlayer) {
|
||||
val bufferFormatCallback =
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
): BufferFormat {
|
||||
if (sourceHeight > 0) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(),
|
||||
)
|
||||
}
|
||||
val bmp = Bitmap()
|
||||
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
|
||||
skBitmap = bmp
|
||||
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
|
||||
return RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
}
|
||||
// --- Engine lifecycle ----------------------------------------------------
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
|
||||
private fun ensureVideoPlayer(): VideoPlayerState =
|
||||
videoPlayer ?: synchronized(initLock) {
|
||||
videoPlayer ?: createVideoPlayerState().also {
|
||||
videoPlayer = it
|
||||
startVideoSync(it)
|
||||
}
|
||||
}
|
||||
|
||||
val renderCallback =
|
||||
RenderCallback { _, nativeBuffers, _ ->
|
||||
val bmp = skBitmap ?: return@RenderCallback
|
||||
val bytes = pixelBytes ?: return@RenderCallback
|
||||
val buffer = nativeBuffers[0]
|
||||
buffer.rewind()
|
||||
buffer.get(bytes)
|
||||
bmp.installPixels(bytes)
|
||||
_videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
||||
private fun ensureAudioPlayer(): VideoPlayerState =
|
||||
audioPlayer ?: synchronized(initLock) {
|
||||
audioPlayer ?: createVideoPlayerState().also {
|
||||
audioPlayer = it
|
||||
startAudioSync(it)
|
||||
}
|
||||
}
|
||||
|
||||
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
|
||||
player.videoSurface().set(surface)
|
||||
}
|
||||
|
||||
private fun setupVideoEventListener(player: EmbeddedMediaPlayer) {
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
val state = _videoState.value
|
||||
_videoState.value =
|
||||
state.copy(
|
||||
isPlaying = true,
|
||||
isBuffering = false,
|
||||
duration = mediaPlayer.status().length(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun paused(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isPlaying = false)
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false)
|
||||
}
|
||||
|
||||
override fun buffering(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newCache: Float,
|
||||
) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = newCache < 100f)
|
||||
}
|
||||
|
||||
override fun positionChanged(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newPosition: Float,
|
||||
) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
position = newPosition,
|
||||
currentTime = (newPosition * _videoState.value.duration).toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun finished(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value =
|
||||
_videoState.value.copy(
|
||||
isPlaying = false,
|
||||
isBuffering = false,
|
||||
position = 0f,
|
||||
currentTime = 0L,
|
||||
)
|
||||
}
|
||||
|
||||
override fun error(mediaPlayer: MediaPlayer) {
|
||||
_videoState.value = _videoState.value.copy(isBuffering = false)
|
||||
println("VLC: playback error for ${_videoState.value.url}")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupAudioEventListener(player: MediaPlayer) {
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun playing(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
isPlaying = true,
|
||||
isBuffering = false,
|
||||
duration = mediaPlayer.status().length(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun paused(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value = _audioState.value.copy(isPlaying = false)
|
||||
}
|
||||
|
||||
override fun stopped(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false)
|
||||
}
|
||||
|
||||
override fun positionChanged(
|
||||
mediaPlayer: MediaPlayer,
|
||||
newPosition: Float,
|
||||
) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
position = newPosition,
|
||||
currentTime = (newPosition * _audioState.value.duration).toLong(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun finished(mediaPlayer: MediaPlayer) {
|
||||
_audioState.value =
|
||||
_audioState.value.copy(
|
||||
isPlaying = false,
|
||||
position = 0f,
|
||||
currentTime = 0L,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun startVideoPolling() {
|
||||
videoPollingJob?.cancel()
|
||||
videoPollingJob =
|
||||
private fun startVideoSync(player: VideoPlayerState) {
|
||||
videoSyncJob?.cancel()
|
||||
videoSyncJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val player = videoPlayer ?: break
|
||||
val state = _videoState.value
|
||||
if (state.isPlaying) {
|
||||
try {
|
||||
_videoState.value =
|
||||
state.copy(
|
||||
position = player.status().position(),
|
||||
currentTime = player.status().time(),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
snapshotFlow {
|
||||
EngineSnapshot(
|
||||
isPlaying = player.isPlaying,
|
||||
isLoading = player.isLoading,
|
||||
hasMedia = player.hasMedia,
|
||||
currentTime = player.currentTime,
|
||||
duration = player.duration,
|
||||
aspectRatio = player.aspectRatio,
|
||||
errorMessage = player.error?.let(::describeError),
|
||||
)
|
||||
}.collect { snap ->
|
||||
val current = _videoState.value
|
||||
val posFraction =
|
||||
if (snap.duration > 0.0) {
|
||||
(snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f)
|
||||
} else {
|
||||
current.position
|
||||
}
|
||||
}
|
||||
_videoState.value =
|
||||
current.copy(
|
||||
isPlaying = snap.isPlaying,
|
||||
isBuffering = snap.isLoading,
|
||||
duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L),
|
||||
currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L),
|
||||
position = posFraction,
|
||||
aspectRatio = if (snap.aspectRatio > 0f) snap.aspectRatio else current.aspectRatio,
|
||||
errorReason = snap.errorMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAudioPolling() {
|
||||
audioPollingJob?.cancel()
|
||||
audioPollingJob =
|
||||
private fun startAudioSync(player: VideoPlayerState) {
|
||||
audioSyncJob?.cancel()
|
||||
audioSyncJob =
|
||||
scope.launch {
|
||||
while (true) {
|
||||
delay(500)
|
||||
val player = audioPlayer ?: break
|
||||
val state = _audioState.value
|
||||
if (state.isPlaying) {
|
||||
try {
|
||||
_audioState.value =
|
||||
state.copy(
|
||||
position = player.status().position(),
|
||||
currentTime = player.status().time(),
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
snapshotFlow {
|
||||
EngineSnapshot(
|
||||
isPlaying = player.isPlaying,
|
||||
isLoading = player.isLoading,
|
||||
hasMedia = player.hasMedia,
|
||||
currentTime = player.currentTime,
|
||||
duration = player.duration,
|
||||
aspectRatio = player.aspectRatio,
|
||||
errorMessage = player.error?.let(::describeError),
|
||||
)
|
||||
}.collect { snap ->
|
||||
val current = _audioState.value
|
||||
val posFraction =
|
||||
if (snap.duration > 0.0) {
|
||||
(snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f)
|
||||
} else {
|
||||
current.position
|
||||
}
|
||||
}
|
||||
_audioState.value =
|
||||
current.copy(
|
||||
isPlaying = snap.isPlaying,
|
||||
isBuffering = snap.isLoading,
|
||||
duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L),
|
||||
currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L),
|
||||
position = posFraction,
|
||||
errorReason = snap.errorMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun describeError(error: VideoPlayerError): String =
|
||||
when (error) {
|
||||
is VideoPlayerError.CodecError -> "Codec: ${error.message}"
|
||||
is VideoPlayerError.NetworkError -> "Network: ${error.message}"
|
||||
is VideoPlayerError.SourceError -> "Source: ${error.message}"
|
||||
is VideoPlayerError.UnknownError -> error.message
|
||||
}
|
||||
|
||||
private data class EngineSnapshot(
|
||||
val isPlaying: Boolean,
|
||||
val isLoading: Boolean,
|
||||
val hasMedia: Boolean,
|
||||
val currentTime: Double,
|
||||
val duration: Double,
|
||||
val aspectRatio: Float,
|
||||
val errorMessage: String?,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import com.sun.jna.Function
|
||||
import com.sun.jna.NativeLibrary
|
||||
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
|
||||
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
|
||||
|
||||
/**
|
||||
* Discovers bundled VLC libraries on macOS.
|
||||
* Must force-load libvlccore before libvlc to avoid link errors.
|
||||
* Uses [VlcResourceResolver] to find the VLC directory from the Compose application
|
||||
* resources or development fallback paths.
|
||||
*/
|
||||
class MacOsVlcDiscoverer :
|
||||
BaseNativeDiscoveryStrategy(
|
||||
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
|
||||
arrayOf("%s/plugins"),
|
||||
) {
|
||||
/** Plugin path discovered during [setPluginPath], available after discovery. */
|
||||
var discoveredPluginPath: String? = null
|
||||
private set
|
||||
|
||||
/** Whether [setPluginPath] successfully set the process env var. */
|
||||
var envVarSet: Boolean = false
|
||||
private set
|
||||
|
||||
override fun supported(): Boolean {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
return "mac" in os
|
||||
}
|
||||
|
||||
override fun discoveryDirectories(): List<String> {
|
||||
val vlcDir = VlcResourceResolver.findVlcDir() ?: return emptyList()
|
||||
return listOf(vlcDir.absolutePath)
|
||||
}
|
||||
|
||||
override fun onFound(path: String): Boolean {
|
||||
NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path)
|
||||
NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName())
|
||||
return true
|
||||
}
|
||||
|
||||
override fun setPluginPath(pluginPath: String?): Boolean {
|
||||
if (pluginPath == null) return false
|
||||
discoveredPluginPath = pluginPath
|
||||
return try {
|
||||
// Call setenv directly via JNA Function API. This bypasses vlcj's
|
||||
// LibC interface binding which fails on macOS 13+ because dlsym
|
||||
// can't resolve the versioned symbol `setenv$3b99ba0d`.
|
||||
val setenv = Function.getFunction("c", "setenv")
|
||||
val result = setenv.invokeInt(arrayOf<Any>(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
|
||||
envVarSet = result
|
||||
result
|
||||
} catch (_: Throwable) {
|
||||
// JNA Function call also failed — VlcjPlayerPool will use
|
||||
// --plugin-path factory arg as fallback.
|
||||
envVarSet = false
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,24 +24,87 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.Bitmap
|
||||
import org.jetbrains.skia.ColorAlphaType
|
||||
import org.jetbrains.skia.ImageInfo
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
|
||||
import java.nio.ByteBuffer
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jcodec.api.FrameGrab
|
||||
import org.jcodec.common.io.NIOUtils
|
||||
import org.jcodec.common.model.ColorSpace
|
||||
import org.jcodec.common.model.Picture
|
||||
import org.jcodec.scale.AWTUtil
|
||||
import org.jcodec.scale.ColorUtil
|
||||
import org.jetbrains.skia.Image
|
||||
import java.awt.image.BufferedImage
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.file.Files
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import org.jetbrains.skia.Image as SkiaImage
|
||||
import javax.imageio.ImageIO
|
||||
|
||||
/**
|
||||
* Per-URL one-frame thumbnail extractor backing feed video posters.
|
||||
*
|
||||
* Cascade:
|
||||
* 1. **JCodec** (`org.jcodec:jcodec` + `jcodec-javase`, BSD-2) — pure-Java
|
||||
* H.264 baseline/main/high decode. Handles ~80% of Nostr feed media (MP4/H.264).
|
||||
* 2. **Jaffree** (Apache-2) + **LGPL FFmpeg** subprocess — for everything else
|
||||
* (HEVC, VP9, AV1, HLS, malformed faststart MP4s). Requires a bundled
|
||||
* ffmpeg binary at `src/jvmMain/appResources/<os>/ffmpeg/ffmpeg(.exe)` or
|
||||
* a system `ffmpeg` on `$PATH`.
|
||||
*
|
||||
* Replaces the prior vlcj `RenderCallback` path. License moves from
|
||||
* GPL-3.0 (vlcj) to BSD-2 + Apache-2 + LGPL-2.1 native, MIT-dominant overall.
|
||||
*/
|
||||
object VideoThumbnailCache {
|
||||
private const val MAX_THUMB_BYTES = 4 * 1024 * 1024 // 4 MiB cap per thumbnail
|
||||
|
||||
private val cache = ConcurrentHashMap<String, ImageBitmap>()
|
||||
private val pending = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
private val http: OkHttpClient by lazy {
|
||||
OkHttpClient
|
||||
.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val downloadCacheDir: File by lazy {
|
||||
val base =
|
||||
File(System.getProperty("user.home"), ".cache/amethyst-desktop/video-thumbs")
|
||||
.also { it.mkdirs() }
|
||||
base
|
||||
}
|
||||
|
||||
private val ffmpegBinary: String? by lazy {
|
||||
// 1. System ffmpeg on PATH.
|
||||
val onPath =
|
||||
runCatching {
|
||||
ProcessBuilder("ffmpeg", "-version")
|
||||
.redirectErrorStream(true)
|
||||
.start()
|
||||
.also { it.inputStream.close() }
|
||||
.waitFor(2, TimeUnit.SECONDS)
|
||||
}.getOrDefault(false)
|
||||
if (onPath) return@lazy "ffmpeg"
|
||||
|
||||
// 2. Bundled ffmpeg under appResources/<os>/ffmpeg/.
|
||||
// jpackage drops appResources at <app>/lib/app/resources/ — equivalently,
|
||||
// we can read from the working dir layout under desktopApp/src/jvmMain/appResources
|
||||
// during `./gradlew :desktopApp:run`. Look for it in well-known locations.
|
||||
val osName = System.getProperty("os.name").lowercase()
|
||||
val isWin = "win" in osName
|
||||
val binaryName = if (isWin) "ffmpeg.exe" else "ffmpeg"
|
||||
val candidates =
|
||||
listOf(
|
||||
File(System.getProperty("compose.application.resources.dir") ?: "", "ffmpeg/$binaryName"),
|
||||
File("desktopApp/src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"),
|
||||
File("src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"),
|
||||
)
|
||||
candidates.firstOrNull { it.exists() && it.canExecute() }?.absolutePath
|
||||
}
|
||||
|
||||
fun getCached(url: String): ImageBitmap? = cache[url]
|
||||
|
||||
suspend fun getThumbnail(url: String): ImageBitmap? {
|
||||
@@ -58,82 +121,161 @@ object VideoThumbnailCache {
|
||||
}
|
||||
|
||||
private fun extractFirstFrame(url: String): ImageBitmap? {
|
||||
if (!VlcjPlayerPool.init()) {
|
||||
println("VLC thumbnail: init failed for $url")
|
||||
return null
|
||||
}
|
||||
val player = VlcjPlayerPool.acquireForThumbnail()
|
||||
if (player == null) {
|
||||
println("VLC thumbnail: pool exhausted for $url")
|
||||
return null
|
||||
}
|
||||
// For HLS we skip straight to Jaffree — JCodec can't read m3u8.
|
||||
val isHls = url.contains(".m3u8", ignoreCase = true) || url.contains("/hls/", ignoreCase = true)
|
||||
|
||||
var result: ImageBitmap? = null
|
||||
val latch = CountDownLatch(1)
|
||||
|
||||
val bufferFormatCallback =
|
||||
object : BufferFormatCallback {
|
||||
override fun getBufferFormat(
|
||||
sourceWidth: Int,
|
||||
sourceHeight: Int,
|
||||
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
|
||||
|
||||
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
|
||||
if (!isHls) {
|
||||
val downloaded = runCatching { downloadFirstChunk(url) }.getOrNull()
|
||||
if (downloaded != null) {
|
||||
tryJCodec(downloaded)?.let { return it }
|
||||
tryJaffreeFile(downloaded)?.let { return it }
|
||||
}
|
||||
|
||||
val renderCallback =
|
||||
RenderCallback { _, nativeBuffers, bufferFormat ->
|
||||
if (result != null) return@RenderCallback
|
||||
try {
|
||||
if (nativeBuffers.isEmpty()) return@RenderCallback
|
||||
val w = bufferFormat.width
|
||||
val h = bufferFormat.height
|
||||
if (w <= 0 || h <= 0) return@RenderCallback
|
||||
val bmp = Bitmap()
|
||||
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
|
||||
val bytes = ByteArray(w * h * 4)
|
||||
val buffer = nativeBuffers[0]
|
||||
buffer.rewind()
|
||||
buffer.get(bytes)
|
||||
bmp.installPixels(bytes)
|
||||
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
|
||||
latch.countDown()
|
||||
} catch (e: Exception) {
|
||||
println("VLC thumbnail: render error for $url — ${e.message}")
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
|
||||
if (surface == null) {
|
||||
println("VLC thumbnail: surface creation failed for $url")
|
||||
VlcjPlayerPool.release(player)
|
||||
return null
|
||||
}
|
||||
|
||||
player.videoSurface().set(surface)
|
||||
player.audio().setVolume(0)
|
||||
player.audio().isMute = true
|
||||
|
||||
player.events().addMediaPlayerEventListener(
|
||||
object : MediaPlayerEventAdapter() {
|
||||
override fun error(mediaPlayer: MediaPlayer) {
|
||||
println("VLC thumbnail: playback error for $url")
|
||||
latch.countDown()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
player.media().play(url)
|
||||
|
||||
// Wait up to 8 seconds for first frame (network videos can be slow)
|
||||
latch.await(8, TimeUnit.SECONDS)
|
||||
|
||||
if (result == null) {
|
||||
println("VLC thumbnail: timed out or failed for $url")
|
||||
}
|
||||
|
||||
VlcjPlayerPool.release(player)
|
||||
return result
|
||||
return tryJaffreeUrl(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads up to [MAX_THUMB_BYTES] to a cache file, returning the file (or null on failure).
|
||||
*
|
||||
* Caps the copy regardless of whether the server honours `Range:` — some origins ignore it
|
||||
* and serve a 200 with the full body, which would otherwise stream the entire video.
|
||||
*
|
||||
* Rejects responses whose `Content-Type` starts with `text/` (e.g. HTML error pages from
|
||||
* broken origins) so we never persist non-video bytes into the cache.
|
||||
*
|
||||
* Cleans up zero-byte cache files on failure so a transient empty response isn't sticky.
|
||||
*/
|
||||
private fun downloadFirstChunk(url: String): File? {
|
||||
val hash = sha1Hex(url)
|
||||
val cached = File(downloadCacheDir, "$hash.mp4")
|
||||
if (cached.length() > 0L) return cached
|
||||
if (cached.exists()) cached.delete()
|
||||
|
||||
var wrote = false
|
||||
http.newCall(buildRangeRequest(url)).execute().use { resp ->
|
||||
if (!resp.isSuccessful && resp.code != 206) return null
|
||||
val contentType = resp.header("Content-Type")?.lowercase().orEmpty()
|
||||
if (contentType.startsWith("text/") || "html" in contentType) return null
|
||||
Files.newOutputStream(cached.toPath()).use { out ->
|
||||
val copied = copyAtMost(resp.body.byteStream(), out, MAX_THUMB_BYTES.toLong())
|
||||
wrote = copied > 0L
|
||||
}
|
||||
}
|
||||
if (!wrote || cached.length() == 0L) {
|
||||
cached.delete()
|
||||
return null
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
private fun buildRangeRequest(url: String): Request =
|
||||
Request
|
||||
.Builder()
|
||||
.url(url)
|
||||
.header("Range", "bytes=0-${MAX_THUMB_BYTES - 1}")
|
||||
.header("User-Agent", "Amethyst-Desktop/thumbnail")
|
||||
.build()
|
||||
|
||||
private fun copyAtMost(
|
||||
src: java.io.InputStream,
|
||||
dst: java.io.OutputStream,
|
||||
limit: Long,
|
||||
): Long {
|
||||
val buf = ByteArray(64 * 1024)
|
||||
var copied = 0L
|
||||
while (copied < limit) {
|
||||
val toRead = minOf(buf.size.toLong(), limit - copied).toInt()
|
||||
val n = src.read(buf, 0, toRead)
|
||||
if (n < 0) break
|
||||
dst.write(buf, 0, n)
|
||||
copied += n
|
||||
}
|
||||
return copied
|
||||
}
|
||||
|
||||
private fun tryJCodec(mp4: File): ImageBitmap? =
|
||||
runCatching {
|
||||
NIOUtils.readableChannel(mp4).use { ch ->
|
||||
val grab = FrameGrab.createFrameGrab(ch).seekToSecondSloppy(1.0)
|
||||
val native: Picture = grab.nativeFrame ?: return null
|
||||
val rgb = Picture.create(native.width, native.height, ColorSpace.RGB)
|
||||
ColorUtil.getTransform(native.color, ColorSpace.RGB).transform(native, rgb)
|
||||
bufferedImageToImageBitmap(AWTUtil.toBufferedImage(rgb))
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private fun tryJaffreeFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath)
|
||||
|
||||
private fun tryJaffreeUrl(url: String): ImageBitmap? = runFfmpegToImage(url)
|
||||
|
||||
/**
|
||||
* Spawns `ffmpeg -ss 1 -i <input> -frames:v 1 -f image2pipe -c:v png -an pipe:1`,
|
||||
* reads PNG bytes from stdout, decodes with Skia.
|
||||
*
|
||||
* Uses raw `ProcessBuilder` rather than the Jaffree DSL — fewer API guesses,
|
||||
* easier to debug. Jaffree stays on the classpath as a future option.
|
||||
*/
|
||||
private fun runFfmpegToImage(input: String): ImageBitmap? {
|
||||
val ffmpeg = ffmpegBinary ?: return null
|
||||
val cmd =
|
||||
listOf(
|
||||
ffmpeg,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-ss",
|
||||
"1",
|
||||
"-i",
|
||||
input,
|
||||
"-frames:v",
|
||||
"1",
|
||||
"-an",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-c:v",
|
||||
"png",
|
||||
"pipe:1",
|
||||
)
|
||||
val process =
|
||||
runCatching {
|
||||
ProcessBuilder(cmd)
|
||||
.redirectErrorStream(false)
|
||||
.start()
|
||||
}.getOrNull() ?: return null
|
||||
val out = ByteArrayOutputStream(256 * 1024)
|
||||
try {
|
||||
process.inputStream.use { it.copyTo(out) }
|
||||
if (!process.waitFor(8, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly()
|
||||
return null
|
||||
}
|
||||
if (process.exitValue() != 0 || out.size() == 0) return null
|
||||
} catch (_: Exception) {
|
||||
process.destroyForcibly()
|
||||
return null
|
||||
}
|
||||
return runCatching {
|
||||
Image.makeFromEncoded(out.toByteArray()).toComposeImageBitmap()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun bufferedImageToImageBitmap(img: BufferedImage): ImageBitmap {
|
||||
val baos = ByteArrayOutputStream(64 * 1024)
|
||||
ImageIO.write(img, "png", baos)
|
||||
return Image.makeFromEncoded(baos.toByteArray()).toComposeImageBitmap()
|
||||
}
|
||||
|
||||
private fun sha1Hex(s: String): String {
|
||||
val md = MessageDigest.getInstance("SHA-1")
|
||||
val bytes = md.digest(s.toByteArray())
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private fun osTag(osName: String): String =
|
||||
when {
|
||||
"mac" in osName -> "macos"
|
||||
"win" in osName -> "windows"
|
||||
else -> "linux"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Resolves the bundled VLC directory, with fallbacks for development (Gradle run).
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. `compose.application.resources.dir` system property (set by packaged app)
|
||||
* 2. Gradle `prepareAppResources` build output
|
||||
* 3. Source `appResources` directory (platform-specific)
|
||||
*/
|
||||
object VlcResourceResolver {
|
||||
private val currentPlatform: String by lazy {
|
||||
val os = System.getProperty("os.name").lowercase()
|
||||
when {
|
||||
"mac" in os || "darwin" in os -> "macos"
|
||||
"win" in os -> "windows"
|
||||
else -> "linux"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the VLC directory if found, or null.
|
||||
*/
|
||||
fun findVlcDir(): File? {
|
||||
// 1. Compose application resources dir (packaged app or plugin-provided)
|
||||
System.getProperty("compose.application.resources.dir")?.let { dir ->
|
||||
val vlcDir = File(dir, "vlc")
|
||||
if (vlcDir.isDirectory) return vlcDir
|
||||
}
|
||||
|
||||
// 2. Gradle prepareAppResources build output (relative to working dir)
|
||||
val buildOutput = File("desktopApp/build/compose/tmp/prepareAppResources/vlc")
|
||||
if (buildOutput.isDirectory) return buildOutput
|
||||
|
||||
// 3. Source appResources (platform-specific subdirectory)
|
||||
val sourceResources = File("desktopApp/src/jvmMain/appResources/$currentPlatform/vlc")
|
||||
if (sourceResources.isDirectory) return sourceResources
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.service.media
|
||||
|
||||
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
|
||||
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
|
||||
import uk.co.caprica.vlcj.player.base.MediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
|
||||
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Manages a pool of VLCJ media players to avoid costly create/destroy cycles.
|
||||
* Keeps strong references to prevent GC crashes from native callbacks.
|
||||
*
|
||||
* IMPORTANT: Never let player instances be garbage collected while native
|
||||
* callbacks are active — this causes JVM segfaults.
|
||||
*/
|
||||
object VlcjPlayerPool {
|
||||
private val available = AtomicBoolean(false)
|
||||
private val initAttempted = AtomicBoolean(false)
|
||||
private val initLatch = CountDownLatch(1)
|
||||
private var factory: MediaPlayerFactory? = null
|
||||
|
||||
// Video player pool (for actual playback)
|
||||
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||
private const val MAX_POOL_SIZE = 1
|
||||
|
||||
// Thumbnail player pool (separate so thumbnails don't compete with playback)
|
||||
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
|
||||
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
|
||||
private const val MAX_THUMB_POOL_SIZE = 2
|
||||
|
||||
// Cached plugin path for audio factory creation (set during init)
|
||||
private var cachedPluginPath: String? = null
|
||||
|
||||
// Audio player pool (shared factory with --no-video)
|
||||
private var audioFactory: MediaPlayerFactory? = null
|
||||
private val allAudioPlayers = mutableListOf<MediaPlayer>()
|
||||
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
|
||||
private const val MAX_AUDIO_POOL_SIZE = 1
|
||||
|
||||
/**
|
||||
* Initialize the pool. Thread-safe — only runs once.
|
||||
* Returns false if VLC is not installed.
|
||||
*/
|
||||
fun init(): Boolean {
|
||||
if (available.get()) return true
|
||||
|
||||
// Only one thread performs init; others wait
|
||||
if (!initAttempted.compareAndSet(false, true)) {
|
||||
initLatch.await(10, TimeUnit.SECONDS)
|
||||
return available.get()
|
||||
}
|
||||
|
||||
return try {
|
||||
// Try bundled VLC first, then fall through to system VLC
|
||||
val macOsDiscoverer = MacOsVlcDiscoverer()
|
||||
val discovery =
|
||||
try {
|
||||
val nd =
|
||||
NativeDiscovery(
|
||||
BundledVlcDiscoverer(),
|
||||
macOsDiscoverer,
|
||||
)
|
||||
val found = nd.discover()
|
||||
if (found) {
|
||||
println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}")
|
||||
} else {
|
||||
println("VLC: bundled discovery failed, falling back to system VLC")
|
||||
}
|
||||
found
|
||||
} catch (e: Throwable) {
|
||||
println("VLC: bundled discovery threw ${e.message}")
|
||||
false
|
||||
}
|
||||
if (!discovery) {
|
||||
// Try default system discovery
|
||||
val systemDiscovery = NativeDiscovery().discover()
|
||||
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
|
||||
}
|
||||
|
||||
// Delete stale VLC plugin cache on macOS to avoid spam warnings
|
||||
if ("mac" in System.getProperty("os.name").lowercase()) {
|
||||
try {
|
||||
val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
|
||||
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
|
||||
} catch (_: Throwable) {
|
||||
// Best-effort cache cleanup
|
||||
}
|
||||
}
|
||||
|
||||
// Build factory args — add --plugin-path fallback if env var wasn't set
|
||||
val factoryArgs =
|
||||
mutableListOf(
|
||||
"--no-xlib",
|
||||
"--avcodec-hw=none", // Disable VideoToolbox — avoids CVPN chroma failures on macOS
|
||||
"--reset-plugins-cache", // Rebuild stale plugins cache on startup
|
||||
)
|
||||
if (!macOsDiscoverer.envVarSet) {
|
||||
val pluginPath =
|
||||
macOsDiscoverer.discoveredPluginPath
|
||||
?: System.getProperty("vlc.plugin.path")
|
||||
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
|
||||
if (pluginPath != null) {
|
||||
factoryArgs += "--plugin-path=$pluginPath"
|
||||
println("VLC: using --plugin-path fallback: $pluginPath")
|
||||
}
|
||||
}
|
||||
|
||||
cachedPluginPath = macOsDiscoverer.discoveredPluginPath
|
||||
?: System.getProperty("vlc.plugin.path")
|
||||
|
||||
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
|
||||
factory = f
|
||||
available.set(true)
|
||||
println("VLC: MediaPlayerFactory created successfully")
|
||||
true
|
||||
} catch (e: Throwable) {
|
||||
println("VLC: init failed — ${e.message}")
|
||||
available.set(false)
|
||||
false
|
||||
} finally {
|
||||
initLatch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
fun isAvailable(): Boolean = available.get()
|
||||
|
||||
/**
|
||||
* Create a callback video surface using the factory's API.
|
||||
*/
|
||||
fun createVideoSurface(
|
||||
bufferFormatCallback: BufferFormatCallback,
|
||||
renderCallback: RenderCallback,
|
||||
): VideoSurface? {
|
||||
val f = factory ?: return null
|
||||
return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a video player from the pool or create a new one.
|
||||
* Returns null if VLC is not available or pool is at capacity.
|
||||
*/
|
||||
fun acquire(): EmbeddedMediaPlayer? {
|
||||
if (!available.get()) return null
|
||||
val f = factory ?: return null
|
||||
|
||||
synchronized(allPlayers) {
|
||||
idlePlayers.poll()?.let { return it }
|
||||
if (allPlayers.size >= MAX_POOL_SIZE) return null
|
||||
return try {
|
||||
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
||||
allPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a player dedicated to thumbnail extraction.
|
||||
* Separate pool so thumbnails don't compete with playback.
|
||||
*/
|
||||
fun acquireForThumbnail(): EmbeddedMediaPlayer? {
|
||||
if (!available.get()) return null
|
||||
val f = factory ?: return null
|
||||
|
||||
synchronized(allThumbPlayers) {
|
||||
idleThumbPlayers.poll()?.let { return it }
|
||||
if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) {
|
||||
// Fall back to main pool if thumb pool is full
|
||||
return acquire()
|
||||
}
|
||||
return try {
|
||||
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
|
||||
allThumbPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire an audio-only player from the pool.
|
||||
* Uses a separate factory with --no-video for efficiency.
|
||||
*/
|
||||
fun acquireAudioPlayer(): MediaPlayer? {
|
||||
if (!init()) return null
|
||||
|
||||
synchronized(allAudioPlayers) {
|
||||
idleAudioPlayers.poll()?.let { return it }
|
||||
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
|
||||
|
||||
val af =
|
||||
audioFactory ?: try {
|
||||
val audioArgs = mutableListOf("--no-video", "--no-xlib")
|
||||
cachedPluginPath?.let { audioArgs += "--plugin-path=$it" }
|
||||
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
|
||||
} catch (_: Throwable) {
|
||||
return null
|
||||
}
|
||||
|
||||
return try {
|
||||
val player = af.mediaPlayers().newMediaPlayer()
|
||||
allAudioPlayers.add(player)
|
||||
player
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a video player to the pool for reuse.
|
||||
*/
|
||||
fun release(player: EmbeddedMediaPlayer) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
// Return to correct pool
|
||||
synchronized(allThumbPlayers) {
|
||||
if (player in allThumbPlayers) {
|
||||
idleThumbPlayers.offer(player)
|
||||
return
|
||||
}
|
||||
}
|
||||
idlePlayers.offer(player)
|
||||
} catch (_: Exception) {
|
||||
// Player may already be disposed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an audio player to the pool for reuse.
|
||||
*/
|
||||
fun releaseAudioPlayer(player: MediaPlayer) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
idleAudioPlayers.offer(player)
|
||||
} catch (_: Exception) {
|
||||
// Player may already be disposed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shut down the entire pool. Call on app exit.
|
||||
*/
|
||||
fun shutdown() {
|
||||
synchronized(allPlayers) {
|
||||
idlePlayers.clear()
|
||||
for (player in allPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allPlayers.clear()
|
||||
}
|
||||
synchronized(allThumbPlayers) {
|
||||
idleThumbPlayers.clear()
|
||||
for (player in allThumbPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allThumbPlayers.clear()
|
||||
}
|
||||
synchronized(allAudioPlayers) {
|
||||
idleAudioPlayers.clear()
|
||||
for (player in allAudioPlayers) {
|
||||
try {
|
||||
player.controls().stop()
|
||||
player.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
allAudioPlayers.clear()
|
||||
}
|
||||
try {
|
||||
factory?.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
try {
|
||||
audioFactory?.release()
|
||||
} catch (_: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
factory = null
|
||||
audioFactory = null
|
||||
available.set(false)
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,14 @@ import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -46,8 +48,10 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VideoThumbnailCache
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.VlcjPlayerPool
|
||||
import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface
|
||||
import kotlinx.coroutines.delay
|
||||
import java.awt.Desktop
|
||||
import java.net.URI
|
||||
|
||||
@Composable
|
||||
fun DesktopVideoPlayer(
|
||||
@@ -60,16 +64,12 @@ fun DesktopVideoPlayer(
|
||||
onViewModeChange: ((ViewMode) -> Unit)? = null,
|
||||
trailingControls: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
// Check if this URL is the active video
|
||||
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
|
||||
val isActiveVideo = videoState.url == url
|
||||
|
||||
// Thumbnail for inactive videos
|
||||
var thumbnail by remember(url) { mutableStateOf(VideoThumbnailCache.getCached(url)) }
|
||||
var aspectRatio by remember { mutableFloatStateOf(16f / 9f) }
|
||||
|
||||
// Load thumbnail when not active
|
||||
LaunchedEffect(url, isActiveVideo) {
|
||||
if (!isActiveVideo && thumbnail == null) {
|
||||
for (attempt in 1..3) {
|
||||
@@ -83,23 +83,16 @@ fun DesktopVideoPlayer(
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-play on mount if requested
|
||||
LaunchedEffect(url, autoPlay) {
|
||||
if (autoPlay) {
|
||||
GlobalMediaPlayer.playVideo(url, initialSeekPosition)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync aspect ratio from global state when active
|
||||
if (isActiveVideo && videoState.aspectRatio != 16f / 9f) {
|
||||
aspectRatio = videoState.aspectRatio
|
||||
}
|
||||
|
||||
if (!VlcjPlayerPool.isAvailable() && VlcjPlayerPool.init().not()) {
|
||||
VlcNotAvailableMessage(url, modifier)
|
||||
return
|
||||
}
|
||||
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val desiredHeight = maxWidth / aspectRatio
|
||||
val constrainedHeight = if (constraints.hasBoundedHeight) minOf(desiredHeight, maxHeight) else desiredHeight
|
||||
@@ -115,17 +108,28 @@ fun DesktopVideoPlayer(
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val displayBitmap: ImageBitmap? = if (isActiveVideo) videoFrame ?: thumbnail else thumbnail
|
||||
displayBitmap?.let { bitmap ->
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "Video",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
val errorReason = if (isActiveVideo) videoState.errorReason else null
|
||||
|
||||
if (errorReason != null) {
|
||||
PlaybackErrorMessage(url = url, reason = errorReason)
|
||||
} else if (isActiveVideo) {
|
||||
VideoPlayerSurface(
|
||||
playerState = GlobalMediaPlayer.activeVideoPlayerState,
|
||||
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
} else {
|
||||
thumbnail?.let { bitmap: ImageBitmap ->
|
||||
Image(
|
||||
bitmap = bitmap,
|
||||
contentDescription = "Video thumbnail",
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
VideoControls(
|
||||
@@ -149,12 +153,8 @@ fun DesktopVideoPlayer(
|
||||
GlobalMediaPlayer.seekVideo(pos)
|
||||
}
|
||||
},
|
||||
onVolumeChange = { vol ->
|
||||
GlobalMediaPlayer.setVideoVolume(vol)
|
||||
},
|
||||
onMuteToggle = {
|
||||
GlobalMediaPlayer.toggleVideoMute()
|
||||
},
|
||||
onVolumeChange = { vol -> GlobalMediaPlayer.setVideoVolume(vol) },
|
||||
onMuteToggle = { GlobalMediaPlayer.toggleVideoMute() },
|
||||
onFullscreen =
|
||||
if (onFullscreen != null) {
|
||||
{
|
||||
@@ -172,25 +172,34 @@ fun DesktopVideoPlayer(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VlcNotAvailableMessage(
|
||||
private fun PlaybackErrorMessage(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
reason: String,
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
RoundedCornerShape(8.dp),
|
||||
),
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Video: $url\nInstall VLC to play videos: https://www.videolan.org/vlc/",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = "Can't play this video",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = reason,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
TextButton(
|
||||
onClick = {
|
||||
runCatching { Desktop.getDesktop().browse(URI(url)) }
|
||||
},
|
||||
) {
|
||||
Text("Open in default player")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -42,12 +41,12 @@ import androidx.compose.ui.input.key.onKeyEvent
|
||||
import androidx.compose.ui.input.key.type
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface
|
||||
|
||||
@Composable
|
||||
fun GlobalFullscreenOverlay() {
|
||||
val isFullscreen by GlobalMediaPlayer.isFullscreen.collectAsState()
|
||||
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
|
||||
|
||||
if (!isFullscreen || videoState.url == null) return
|
||||
|
||||
@@ -103,15 +102,12 @@ fun GlobalFullscreenOverlay() {
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
// Video frame
|
||||
videoFrame?.let { frame ->
|
||||
Image(
|
||||
bitmap = frame,
|
||||
contentDescription = "Video fullscreen",
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
// Video frame — same player state as feed card; kdroidFilter draws to Canvas
|
||||
VideoPlayerSurface(
|
||||
playerState = GlobalMediaPlayer.activeVideoPlayerState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
|
||||
// Video controls overlay
|
||||
VideoControls(
|
||||
|
||||
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.desktop.ui.media
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -51,6 +50,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.desktop.service.media.GlobalMediaPlayer
|
||||
import io.github.kdroidfilter.composemediaplayer.VideoPlayerSurface
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class MediaType { AUDIO, VIDEO }
|
||||
@@ -59,7 +59,6 @@ enum class MediaType { AUDIO, VIDEO }
|
||||
fun NowPlayingBar(modifier: Modifier = Modifier) {
|
||||
val videoState by GlobalMediaPlayer.videoState.collectAsState()
|
||||
val audioState by GlobalMediaPlayer.audioState.collectAsState()
|
||||
val videoFrame by GlobalMediaPlayer.videoFrame.collectAsState()
|
||||
|
||||
val hasVideo = videoState.url != null
|
||||
val hasAudio = audioState.url != null
|
||||
@@ -86,11 +85,10 @@ fun NowPlayingBar(modifier: Modifier = Modifier) {
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Mini video thumbnail or music icon
|
||||
if (activeType == MediaType.VIDEO && videoFrame != null) {
|
||||
Image(
|
||||
bitmap = videoFrame!!,
|
||||
contentDescription = "Video thumbnail",
|
||||
// Mini video preview or music icon
|
||||
if (activeType == MediaType.VIDEO) {
|
||||
VideoPlayerSurface(
|
||||
playerState = GlobalMediaPlayer.activeVideoPlayerState,
|
||||
modifier =
|
||||
Modifier
|
||||
.size(width = 48.dp, height = 36.dp)
|
||||
|
||||
@@ -0,0 +1,863 @@
|
||||
---
|
||||
title: Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree
|
||||
type: feat
|
||||
status: active
|
||||
date: 2026-06-11
|
||||
deepened: 2026-06-11
|
||||
origin: docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md
|
||||
---
|
||||
|
||||
# Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree
|
||||
|
||||
## Enhancement Summary (Deepened 2026-06-11)
|
||||
|
||||
**Research artifacts (companion files):**
|
||||
- [`_kdroidfilter-api-notes.md`](_kdroidfilter-api-notes.md) — verified kdroidFilter public API (note: pinned to **0.10.0** in `libs.versions.toml`; the research agent referenced 0.10.1 which does not exist on Maven Central as of 2026-06-11. API contract is the same across 0.10.x.)
|
||||
- [`_jcodec-thumbnail-recipe.md`](_jcodec-thumbnail-recipe.md) — concrete JCodec → Skia ImageBitmap path
|
||||
- [`_macos-ffmpeg-signing-recipe.md`](_macos-ffmpeg-signing-recipe.md) — jpackage nested-binary codesign recipe
|
||||
- [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md) — Gitnuro-style Compose Desktop manifest
|
||||
- [`_notice-and-licenses-recipe.md`](_notice-and-licenses-recipe.md) — cashapp/licensee + AboutLibraries pattern
|
||||
- [`_vlcj-migration-learnings.md`](_vlcj-migration-learnings.md) — institutional learnings from prior media work
|
||||
- [`_vlcj-migration-codebase-shape.md`](_vlcj-migration-codebase-shape.md) — file inventory (if regenerated)
|
||||
|
||||
### Material corrections to original plan
|
||||
|
||||
These supersede the corresponding sections below — implementer should use the new specs:
|
||||
|
||||
1. **kdroidFilter API names** (Phase 1 was wrong; corrected inline):
|
||||
- `openMedia(url)` → **`openUri(url)`**
|
||||
- `release()` → **`dispose()`**
|
||||
- `setVolume()` → **`volume = X` (Float, 0..1)**
|
||||
- `setMuted()` / `isMuted` → **does not exist; emulate by stashing/restoring volume**
|
||||
- State exposed as Compose **`mutableStateOf`** (read with `snapshotFlow {}` from outside composition), **not** `StateFlow` — affects `stateSyncJob` design.
|
||||
- `VideoPlayerState` is an interface — instantiate via `rememberVideoPlayerState()` (composable) or `createVideoPlayerState()` (non-composable; manual `dispose()`).
|
||||
- JVM surface uses **Compose Canvas** (Skia ImageBitmap), **not** SwingPanel — overlays/z-order/AnimatedVisibility work normally (vs. vlcj/SwingPanel limitations).
|
||||
- No `surfaceType` parameter on JVM (Android-only).
|
||||
- Error states on JVM are only `SourceError` / `UnknownError` — **no codec-vs-network distinction**. Need a separate GStreamer-on-Linux probe.
|
||||
|
||||
2. **macOS app bundle path correction** (Phase 2 was wrong):
|
||||
- `appResourcesRootDir` lands at **`Contents/app/resources/`**, NOT `Contents/Resources/`. jpackage owns `Contents/Resources/`. Final ffmpeg path: `Amethyst.app/Contents/app/resources/ffmpeg/ffmpeg`.
|
||||
|
||||
3. **Linux GStreamer requirements**:
|
||||
- kdroidFilter on Linux requires system **GStreamer 1.16+** with `plugins-base`, `plugins-good`, `plugins-bad`, and `libav` (for actual codec decoding). HLS needs `plugins-bad`. Document as `Recommends:` line in DEB / `recommends:` in metainfo.
|
||||
|
||||
4. **JCodec adjustments** (Phase 2):
|
||||
- Need `org.jcodec:jcodec:0.2.5` **plus** `org.jcodec:jcodec-javase:0.2.5` (AWTUtil lives in `-javase`).
|
||||
- Frame color space is **`YUV420J`** — must convert with `ColorUtil.getTransform(native.color, ColorSpace.RGB)` before `AWTUtil.toBufferedImage` (AWTUtil does not auto-convert).
|
||||
- `ColorAlphaType.OPAQUE` (not `PREMUL` as I wrote).
|
||||
- Cleanest `BufferedImage` → `ImageBitmap` is **ImageIO → PNG bytes → `Image.makeFromEncoded(bytes).toComposeImageBitmap()`**. No stable direct extension.
|
||||
- Exception class: **`org.jcodec.api.UnsupportedFormatException extends JCodecException`** — also catch `IOException` + broad `RuntimeException` (decoder throws `AIOOBE` on malformed SPS/PPS).
|
||||
- Use `seekToSecondSloppy(1.0)` for thumbnails (precise decodes up to 500 frames per call).
|
||||
- Fast-reject path: `MP4Util.parseMovie()` + check `stsd` FourCC for `avc1` / `avc3` before opening FrameGrab.
|
||||
|
||||
5. **Tooling for licenses** (Phase 0):
|
||||
- Use **`app.cash.licensee`** Gradle plugin to enforce allow-list + generate JSON report at build time (compliance gate).
|
||||
- Use **`com.mikepenz.aboutlibraries`** Gradle plugin to produce the in-app "Open source licenses" screen (CMP-ready, replaces my hand-rolled `OpenSourceLicensesScreen.kt`).
|
||||
- Hand-author `NOTICE.md` only for native bundles outside Gradle's graph (FFmpeg, GStreamer runtime).
|
||||
|
||||
6. **Flathub manifest precedent** (Phase 4):
|
||||
- **Gitnuro** (`com.jetpackduba.Gitnuro`) is the existing Kotlin Compose Desktop precedent — use its structure verbatim where applicable.
|
||||
- Manifest sketch in plan replaced by Gitnuro-style pattern in [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md).
|
||||
- Submission branch is **`new-pr`** (not `master`).
|
||||
- Patent-codec extension `org.freedesktop.Platform.ffmpeg-full` is the way to surface HEVC/AV1 on Flatpak.
|
||||
|
||||
7. **SPDX expression acceptance**:
|
||||
- Fedora `rpmLicenseType` accepts SPDX expressions (mandatory since F41 phase 4) → our compound is valid as-is.
|
||||
- Flathub `<project_license>` validates compound via `appstreamcli`.
|
||||
- **Debian DEP-5 does NOT support compound expressions** — must group per-file with separate `License:` stanzas.
|
||||
|
||||
### Key plan-level changes derived from research
|
||||
|
||||
- Drop the `OpenSourceLicensesScreen.kt` hand-rolled file (replaced by AboutLibraries plugin).
|
||||
- Add `app.cash.licensee` + allow-list in `desktopApp/build.gradle.kts`.
|
||||
- Add `gradle/spdx-allowlist.txt` (or `desktopApp/licensee.gradle`) configuration.
|
||||
- macOS entitlements file overrides `entitlementsFile.set(...)` and `runtimeEntitlementsFile.set(...)` with `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`.
|
||||
- `stateSyncJob` uses `snapshotFlow {}` not `combine(StateFlow, ...)`.
|
||||
- `VideoPlayerSurface` JVM uses Compose Canvas → fullscreen handoff is **simpler** than expected (risk #7 in original risk table is downgraded from M-M to L-L).
|
||||
- Linux codec-missing UX: probe GStreamer via `gst-inspect-1.0 playbin` at app launch; if missing, show one-time install nag.
|
||||
- Phase 4 adds a `add-extensions: org.freedesktop.Platform.ffmpeg-full` block to the Flathub manifest for patent codecs.
|
||||
|
||||
## Overview
|
||||
|
||||
Drop `uk.co.caprica:vlcj 4.8.3` (GPL-3.0-or-later) from `desktopApp` and ship
|
||||
an MIT-dominant desktop binary. Replace the three vlcj subsystems with:
|
||||
|
||||
| Subsystem | New library | License |
|
||||
|-----------|-------------|---------|
|
||||
| Video playback | `io.github.kdroidfilter:composemediaplayer:0.10.1` | MIT |
|
||||
| Audio playback | same (audio-only mode of `VideoPlayerState`) | MIT |
|
||||
| Thumbnail extraction | `org.jcodec:jcodec:0.2.5` + Jaffree fallback with LGPL FFmpeg | BSD-2 + Apache-2 (Java); LGPL-2.1 (FFmpeg native) |
|
||||
|
||||
Resulting binary SPDX: **`MIT AND LGPL-2.1-or-later AND BSD-2-Clause`** (down
|
||||
from today's effective `GPL-3.0-or-later AND LGPL-2.1-or-later AND MIT` that
|
||||
the current `rpmLicenseType = "MIT"` misrepresents).
|
||||
|
||||
Single migration PR. Hard cut — no feature flag. Phase 0 (NOTICE / SPDX
|
||||
honesty patch) bundled in the same PR so the binary is never released
|
||||
mislabeled.
|
||||
|
||||
See brainstorms for *why*:
|
||||
- [Licensing issues catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)
|
||||
- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md)
|
||||
- [Replacement-candidate research](../brainstorms/2026-06-11-vlcj-replacement-research.md)
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Today's `desktopApp` integration:
|
||||
|
||||
1. Builds Amethyst Desktop's binary by linking GPL-3.0 vlcj into the JVM.
|
||||
2. Bundles LGPL-2.1 libvlc + a mixed-license VLC 3.0.20 plugin tree (~95 MB on macOS, ~90 MB on Windows, ~70 MB on Linux uncompressed) via the `ir.mahozad.vlc-setup` Gradle plugin.
|
||||
3. Declares `rpmLicenseType = "MIT"` (`desktopApp/build.gradle.kts:141`) — **wrong** for the produced binary.
|
||||
4. Ships no NOTICE, no per-component LICENSE files, no written GPL source offer, and no About-screen license listing.
|
||||
|
||||
Consequences (full catalog in
|
||||
[licensing brainstorm](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)):
|
||||
|
||||
- The MIT badge on the repo + RPM is incorrect for distributed binaries.
|
||||
- Forks of `desktopApp/` silently inherit GPL.
|
||||
- Mac/MS App Store distribution is structurally blocked (anti-Tivoization conflict).
|
||||
- Linux distros (Fedora, Debian) would fail license review with the current metadata.
|
||||
- The bundled VLC plugin tree contains GPL-only plugins (`libdvdcss`, `x264`-built swscale) that drag GPL regardless of the Java binding.
|
||||
|
||||
User-resolved priorities (from brainstorm 2026-06-11):
|
||||
- Keep **MIT branding on the binary** — non-negotiable.
|
||||
- Mac/MS App Store: **not** on roadmap; not the migration driver.
|
||||
- **Hard cut** (no feature flag), single PR, Flathub manifest in scope.
|
||||
- Android (`amethyst/`) out of scope — already uses `media3-exoplayer`, no vlcj.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
### High-level
|
||||
|
||||
Swap the playback engine inside the existing `GlobalMediaPlayer` singleton
|
||||
and the thumbnail engine inside `VideoThumbnailCache`, preserving the
|
||||
public `StateFlow` surface that the rest of the UI consumes. Delete the
|
||||
discoverer/pool layer (kdroidFilter handles its own native loading) and the
|
||||
`ir.mahozad.vlc-setup` Gradle plugin (no more bundled VLC).
|
||||
|
||||
The UI composables (`DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`,
|
||||
`NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay`) keep their
|
||||
shapes; `DesktopVideoPlayer` switches its rendering path from
|
||||
`Image(bitmap = videoFrame)` to kdroidFilter's `VideoPlayerSurface`
|
||||
composable when the active video is playing, and stays on a thumbnail
|
||||
`Image` for inactive instances (the "show poster until I tap play" pattern).
|
||||
|
||||
### The cross-stack contract
|
||||
|
||||
```
|
||||
+-----------------------------------------+
|
||||
| UI composables (kept) |
|
||||
| DesktopVideoPlayer | AudioPlayer |
|
||||
| VideoControls | NowPlayingBar | |
|
||||
| GlobalFullscreenOverlay | LightboxOverlay |
|
||||
+----------------------+------------------+
|
||||
|
|
||||
v reads StateFlow + invokes verbs
|
||||
+-----------------------------------------+
|
||||
| GlobalMediaPlayer (kept as singleton) |
|
||||
| - exposes StateFlow<MediaPlaybackState>|
|
||||
| - exposes activeVideoPlayerState |
|
||||
| - verbs: playVideo/playAudio/pause/... |
|
||||
+----------------------+------------------+
|
||||
|
|
||||
v delegates to
|
||||
+----------------+ +-------------------+ +---------------+
|
||||
| kdroidFilter | | JCodec (primary) | | Jaffree (fallback) |
|
||||
| VideoPlayerState | | thumbnail H.264 | | LGPL FFmpeg thumb |
|
||||
+----------------+ +-------------------+ +---------------+
|
||||
```
|
||||
|
||||
### Why kdroidFilter
|
||||
|
||||
Full rationale in the [research doc](../brainstorms/2026-06-11-vlcj-replacement-research.md).
|
||||
Headline:
|
||||
|
||||
- **MIT.** Binding + Maven coordinates `io.github.kdroidfilter:composemediaplayer:0.10.1`.
|
||||
- **OS-native backends, no native bundle on Win/mac:** Media Foundation, AVFoundation, GStreamer (Linux system).
|
||||
- **First-class Compose API:** `VideoPlayerSurface(playerState, contentScale, surfaceType, overlay)`.
|
||||
- Bundle on macOS drops from ~95 MB → ~60-80 MB (we still bundle LGPL FFmpeg for the rare-codec thumbnail fallback; nothing for video).
|
||||
- Active in 2026 (v0.10.1 May 2026, single maintainer Elie Gambache).
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### Architecture
|
||||
|
||||
**Module boundary:** Migration stays inside `desktopApp/`. `quartz/`,
|
||||
`commons/`, and `amethyst/` are untouched. No new shared abstractions in
|
||||
`commons/` — the player layer is desktop-specific and has no Android
|
||||
counterpart in scope (`amethyst/` runs `media3-exoplayer`).
|
||||
|
||||
**Concurrency:** kdroidFilter manages its own threading. We continue to
|
||||
adapt its callbacks into the existing `MediaPlaybackState` StateFlow inside
|
||||
`GlobalMediaPlayer` so UI consumers see the same shape. Thumbnail
|
||||
extraction stays on `Dispatchers.IO`; we replace vlcj's `CountDownLatch`
|
||||
gymnastics with a `suspendCancellableCoroutine` around JCodec's synchronous
|
||||
API and `Jaffree.executeAsync().toCompletableFuture().asDeferred()` for the
|
||||
fallback.
|
||||
|
||||
**Native loading:** kdroidFilter uses JNI (not JNA). On macOS this avoids
|
||||
the `setenv$3b99ba0d` versioned-symbol class of bug that
|
||||
`MacOsVlcDiscoverer` worked around in May 2026 (see
|
||||
`docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md`). FFmpeg
|
||||
binaries for Jaffree are spawned as separate processes — no JNI native lib
|
||||
to load, so no signing/notarization complications beyond marking the
|
||||
ffmpeg binary as executable in the jpackage app bundle and ensuring it
|
||||
ships inside `Contents/MacOS/` so macOS hardened runtime permits it.
|
||||
|
||||
### Implementation Phases
|
||||
|
||||
#### Phase 0 — License bridge (in-PR, lands first commit) (~1 day)
|
||||
|
||||
Land alongside the migration code in the same PR so no released binary is
|
||||
ever mislabeled.
|
||||
|
||||
**File changes:**
|
||||
|
||||
1. **`desktopApp/build.gradle.kts:141`**
|
||||
- `rpmLicenseType = "MIT"` → `rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause"`
|
||||
- (jpackage forwards verbatim into the .rpm `License:` field. SPDX
|
||||
expression form is accepted by `rpm --query`; Fedora packagers parse it
|
||||
as a compound license.)
|
||||
|
||||
2. **New: `desktopApp/src/jvmMain/appResources/common/NOTICE.md`**
|
||||
- Lists every third-party component shipping in the binary with version + SPDX + upstream URL.
|
||||
- Includes FFmpeg LGPL build provenance + binary download URL.
|
||||
- References JCodec, kdroidFilter, GStreamer (Linux runtime dep).
|
||||
- Contains the GPLv3 source-availability written offer for the *interim* commit (the bridge commit lands while the migration is in flight; the same NOTICE is updated in Phase 3 to drop the vlcj/VLC lines once those are deleted).
|
||||
|
||||
3. **New: `desktopApp/src/jvmMain/appResources/common/licenses/`**
|
||||
- `LICENSE-MIT.txt` — Amethyst's MIT
|
||||
- `LICENSE-MIT-kdroidfilter.txt` — verbatim from upstream
|
||||
- `LICENSE-BSD-2-JCodec.txt` — verbatim
|
||||
- `LICENSE-Apache-2-Jaffree.txt` — verbatim
|
||||
- `LICENSE-LGPL-2.1.txt` — FFmpeg + libvlc (interim)
|
||||
- `LICENSE-GPL-3.0.txt` — vlcj (interim, deleted in Phase 3)
|
||||
- `WRITTEN-OFFER.txt` — GPL source-offer pointing to https://github.com/vitorpamplona/amethyst (valid for 3 years per GPLv3 §6c). Removed in Phase 3 once vlcj is gone.
|
||||
|
||||
4. **New: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/about/OpenSourceLicensesScreen.kt`**
|
||||
- Scrollable list of bundled components → name, version, license SPDX, "View license" expander.
|
||||
- Reachable from existing settings (look up + wire into nav during implementation; flagged as plan question in brainstorm).
|
||||
- Pure data-driven: a `LicenseEntry` data class list defined inline; no resource loader complexity.
|
||||
|
||||
5. **`README.md` (root)** — add a single bullet under any "Desktop" section:
|
||||
- "The desktop binary currently bundles GPL components (vlcj, parts of VLC plugins) during the in-flight migration to MIT-only dependencies. Source available at this repository."
|
||||
- Removed in Phase 3.
|
||||
|
||||
**Acceptance for Phase 0:**
|
||||
- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces a .rpm whose `rpm -qpi` shows the SPDX combined string.
|
||||
- About-dialog "Open source licenses" screen renders and is reachable from the desktop settings.
|
||||
- `NOTICE.md`, `licenses/`, and `WRITTEN-OFFER.txt` are visible inside the produced DMG/MSI/DEB/RPM payloads.
|
||||
|
||||
#### Phase 1 — kdroidFilter swap-in for video + audio (~1-2 weeks)
|
||||
|
||||
##### 1.1 Gradle wiring
|
||||
|
||||
**`gradle/libs.versions.toml`** — add:
|
||||
```toml
|
||||
[versions]
|
||||
composemediaplayer = "0.10.1"
|
||||
|
||||
[libraries]
|
||||
composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" }
|
||||
```
|
||||
|
||||
**`desktopApp/build.gradle.kts`** — add `implementation(libs.composemediaplayer)`. Leave `implementation(libs.vlcj)` in place until Phase 3.
|
||||
|
||||
##### 1.2 GlobalMediaPlayer refactor
|
||||
|
||||
Replace the engine but **keep the public StateFlow surface** so UI code is
|
||||
unaffected.
|
||||
|
||||
Current public surface (preserve):
|
||||
```kotlin
|
||||
val videoFrame: StateFlow<ImageBitmap?> // delete — DesktopVideoPlayer reads VideoPlayerSurface directly now
|
||||
val videoState: StateFlow<MediaPlaybackState> // keep
|
||||
val audioState: StateFlow<MediaPlaybackState> // keep
|
||||
val isFullscreen: StateFlow<Boolean> // keep
|
||||
|
||||
fun playVideo(url: String, seekPosition: Float = 0f)
|
||||
fun playAudio(url: String)
|
||||
fun toggleVideoPlayPause()
|
||||
fun toggleAudioPlayPause()
|
||||
fun seekVideo(position: Float)
|
||||
fun seekAudio(position: Float)
|
||||
fun setVideoVolume(volume: Int)
|
||||
fun setAudioVolume(volume: Int)
|
||||
fun toggleVideoMute()
|
||||
fun toggleAudioMute()
|
||||
fun stopVideo()
|
||||
fun stopAudio()
|
||||
fun toggleFullscreen()
|
||||
fun exitFullscreen()
|
||||
fun shutdown()
|
||||
```
|
||||
|
||||
New private fields:
|
||||
```kotlin
|
||||
private val videoPlayerState: VideoPlayerState = VideoPlayerState()
|
||||
private val audioPlayerState: VideoPlayerState = VideoPlayerState().apply { /* audio-only config */ }
|
||||
private var stateSyncJob: Job? = null
|
||||
```
|
||||
|
||||
New public field — exposed so `DesktopVideoPlayer` can pass it to
|
||||
`VideoPlayerSurface(...)`:
|
||||
|
||||
```kotlin
|
||||
val activeVideoPlayerState: VideoPlayerState get() = videoPlayerState
|
||||
val activeAudioPlayerState: VideoPlayerState get() = audioPlayerState
|
||||
```
|
||||
|
||||
**State-sync coroutine.** Replace the vlcj `MediaPlayerEventAdapter` +
|
||||
500ms polling loop with a single coroutine that mirrors
|
||||
`videoPlayerState`'s `StateFlow`s into our `_videoState` mutable flow:
|
||||
|
||||
```kotlin
|
||||
private fun startStateSync(state: VideoPlayerState, target: MutableStateFlow<MediaPlaybackState>) {
|
||||
stateSyncJob?.cancel()
|
||||
stateSyncJob = scope.launch {
|
||||
// kdroidFilter exposes: isPlaying, currentTime, duration, isLoading,
|
||||
// volume, isMuted, aspectRatio (as StateFlows or @Composable getters)
|
||||
combine(
|
||||
state.isPlayingFlow, // verify exact name in v0.10.1 API
|
||||
state.currentTimeFlow,
|
||||
state.durationFlow,
|
||||
state.isLoadingFlow,
|
||||
state.aspectRatioFlow,
|
||||
) { /* fold into MediaPlaybackState */ }
|
||||
.collect { target.value = it }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
(If kdroidFilter v0.10.1 exposes its state as `@Composable State<T>`
|
||||
getters rather than `Flow<T>`, fall back to a `snapshotFlow { state.isPlaying }`
|
||||
inside a `produceState`-equivalent coroutine. Confirm during Phase 1
|
||||
implementation by reading kdroidFilter source on github.)
|
||||
|
||||
##### 1.3 DesktopVideoPlayer rewire
|
||||
|
||||
Replace the `Image(bitmap = displayBitmap)` rendering path with conditional
|
||||
`VideoPlayerSurface` when this composable instance owns the active video URL:
|
||||
|
||||
```kotlin
|
||||
val isActiveVideo = videoState.url == url
|
||||
|
||||
Box(...) {
|
||||
if (isActiveVideo) {
|
||||
VideoPlayerSurface(
|
||||
playerState = GlobalMediaPlayer.activeVideoPlayerState,
|
||||
contentScale = ContentScale.Fit,
|
||||
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small),
|
||||
)
|
||||
} else {
|
||||
thumbnail?.let {
|
||||
Image(bitmap = it, contentDescription = "Video thumbnail", ...)
|
||||
}
|
||||
}
|
||||
|
||||
VideoControls(...) // unchanged
|
||||
}
|
||||
```
|
||||
|
||||
Drop the `VlcNotAvailableMessage` composable — kdroidFilter never fails on
|
||||
"VLC not installed." Replace it with `VlcCodecUnsupportedMessage(url, codec)`
|
||||
shown when kdroidFilter emits a codec-error event (Windows HEVC/AV1
|
||||
without the MS Store extension). The message offers "Open in default
|
||||
player" via `Desktop.getDesktop().browse(URI(url))`.
|
||||
|
||||
##### 1.4 AudioPlayer rewire
|
||||
|
||||
Drop the vlcj surface plumbing — `AudioPlayer.kt` currently doesn't touch
|
||||
vlcj directly, it just reads `GlobalMediaPlayer.audioState`. No file
|
||||
change beyond imports if the StateFlow shape is preserved.
|
||||
|
||||
##### 1.5 VideoControls / NowPlayingBar / GlobalFullscreenOverlay / LightboxOverlay
|
||||
|
||||
Review for vlcj-specific assumptions. Expected change: none (they consume
|
||||
StateFlows). Plan question P6 — to be verified during implementation by
|
||||
grepping for any `EmbeddedMediaPlayer`, `MediaPlayer`, or `videoFrame`
|
||||
references in these files.
|
||||
|
||||
##### 1.6 Phase 1 decision gate
|
||||
|
||||
After 1.1–1.5 compile + launch, run the manual test set:
|
||||
|
||||
| Test URL | Expected outcome |
|
||||
|----------|------------------|
|
||||
| `https://download.samplelib.com/mp4/sample-5s.mp4` (H.264 MP4) | Plays on Win/mac/Linux. |
|
||||
| `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` (HLS H.264) | Plays on Win/mac/Linux. |
|
||||
| `https://nostr.build/i/<recent-VP9-webm-url>` (VP9 WebM) | Plays on mac+Linux. Win → codec-missing UX. |
|
||||
| `https://void.cat/<recent-AV1-mp4>` (AV1 MP4) | Plays on macOS 13+ + Linux. Win → codec-missing UX. |
|
||||
| `https://zap.stream/<known-live-stream>` (HLS livestream) | Plays on all 3 OSes. **Gate criterion: must not regress vs. vlcj.** |
|
||||
|
||||
If the zap.stream live stream fails on any of the 3 OSes for reasons not
|
||||
explained by the codec gap (i.e. legitimate HLS protocol issues), invoke
|
||||
the fallback plan — switch primary engine to `gst1-java-core` keeping the
|
||||
wrapper API intact (research doc §3 Recommended #2). Document the pivot
|
||||
in `docs/decisions/` and update this plan's "Status" frontmatter.
|
||||
|
||||
##### 1.7 Tests
|
||||
|
||||
Unit tests in `desktopApp/src/jvmTest/`:
|
||||
- `GlobalMediaPlayerStateTest.kt` — drives `MediaPlaybackState` derivations from a fake `VideoPlayerState` (build via constructor / setters). Verifies pause/play/seek transitions emit correctly.
|
||||
- `DesktopVideoPlayerActiveDispatchTest.kt` (compose test) — given two `DesktopVideoPlayer` instances mounted with different URLs, asserts that only the active URL renders `VideoPlayerSurface` (use semantic test tags).
|
||||
- No instrumented test that actually decodes a network video (too flaky for CI; covered in manual test sheet).
|
||||
|
||||
#### Phase 2 — Thumbnail extraction (JCodec → Jaffree cascade) (~3-5 days)
|
||||
|
||||
##### 2.1 Gradle wiring
|
||||
|
||||
**`gradle/libs.versions.toml`** — add:
|
||||
```toml
|
||||
[versions]
|
||||
jcodec = "0.2.5"
|
||||
jaffree = "2024.08.29"
|
||||
|
||||
[libraries]
|
||||
jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" }
|
||||
jaffree = { group = "com.github.kokorin.jaffree", name = "jaffree", version.ref = "jaffree" }
|
||||
```
|
||||
|
||||
**`desktopApp/build.gradle.kts`** — add both.
|
||||
|
||||
##### 2.2 LGPL FFmpeg per-OS bundling
|
||||
|
||||
We need FFmpeg only for the *fallback* thumbnail path. The video player
|
||||
doesn't shell out to it. Bundle one binary per OS in
|
||||
`appResources/<os>/ffmpeg/`:
|
||||
|
||||
| OS | Source | Path inside DMG/MSI/AppImage |
|
||||
|----|--------|------------------------------|
|
||||
| macOS arm64+x86_64 | https://www.osxexperts.net/ — LGPL build, code-signed | `Contents/Resources/ffmpeg/ffmpeg` |
|
||||
| Windows x64 | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `app/resources/ffmpeg/ffmpeg.exe` |
|
||||
| Linux x64 | https://johnvansickle.com/ffmpeg/ "release" tarball — LGPL config | `lib/resources/ffmpeg/ffmpeg` (DEB), AppImage equivalent |
|
||||
|
||||
(Each binary's per-OS LICENSE.txt also ships in `licenses/` from Phase 0.)
|
||||
|
||||
We do not invoke a per-build *download* step (no extra Gradle download
|
||||
plugin). Binaries are checked into the repo under
|
||||
`desktopApp/src/jvmMain/appResources/<os>/ffmpeg/` (~10 MB per OS,
|
||||
acceptable for git LFS or direct check-in). The Phase 3 cleanup removes
|
||||
the `ir.mahozad.vlc-setup` plugin, which was the big downloader.
|
||||
|
||||
**macOS hardened-runtime entitlement:** spawning a child process from a
|
||||
hardened-runtime app needs the `com.apple.security.cs.allow-jit` or
|
||||
`com.apple.security.cs.disable-library-validation` entitlement, OR the
|
||||
ffmpeg binary needs to be co-signed with the app's identity. We extend
|
||||
the existing `entitlements.plist` to add:
|
||||
|
||||
```xml
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<false/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<false/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/> <!-- only if needed by ffmpeg; verify on first sign -->
|
||||
```
|
||||
|
||||
The cleanest path is to co-sign ffmpeg with the same identity used for the
|
||||
.app — verify during implementation that jpackage's signing step covers
|
||||
nested executables in `Contents/Resources/` (older jpackage did not — may
|
||||
need a post-pkg `codesign --deep`).
|
||||
|
||||
##### 2.3 VideoThumbnailCache rewrite
|
||||
|
||||
Replace the vlcj-based `extractFirstFrame` with a cascade:
|
||||
|
||||
```kotlin
|
||||
private suspend fun extractFirstFrame(url: String): ImageBitmap? {
|
||||
val downloadedBytes = downloadVideoForThumb(url) ?: return null
|
||||
return tryJCodec(downloadedBytes)
|
||||
?: tryJaffree(downloadedBytes)
|
||||
?: tryJaffreeFromUrl(url) // for HLS where the URL points to a manifest, not bytes
|
||||
}
|
||||
```
|
||||
|
||||
- **`downloadVideoForThumb(url)`** — uses existing OkHttp instance (which is wired in `desktopApp` already, line 50 in build.gradle.kts). Range-fetch first 4 MB for fast thumbnail. Cache to `~/Library/Caches/com.vitorpamplona.amethyst.desktop/thumbs/<hash>.mp4` (per OS — use `java.util.prefs`-style discovery for cache dir).
|
||||
- **`tryJCodec(bytes)`** — `FrameGrab.createFrameGrab(NIOUtils.readableChannel(File))` → `seekToSecondPrecise(1.0).getNativeFrame()`. Convert YUV → RGB → Skia `Bitmap` → `ImageBitmap`. Wrap with `runCatching` and discard on any exception (unsupported codec, malformed MP4, HLS manifest in the bytes, etc.).
|
||||
- **`tryJaffree(bytes)`** — spawn `ffmpeg -ss 1 -i <tempfile> -frames:v 1 -f image2pipe -c:v png pipe:1`, read stdout into ByteArray, decode with `Image.makeFromEncoded(bytes).toComposeImageBitmap()`. 5-second timeout.
|
||||
- **`tryJaffreeFromUrl(url)`** — same as tryJaffree but with the URL directly as `-i` argument. For HLS or any streamed source.
|
||||
|
||||
**Thread safety / dedup:** keep the existing `pending` `ConcurrentHashMap`
|
||||
guard. Replace `CountDownLatch` with `suspendCancellableCoroutine` over
|
||||
Jaffree's `executeAsync()` return type for clean cancellation on
|
||||
recomposition.
|
||||
|
||||
##### 2.4 Tests
|
||||
|
||||
Unit tests:
|
||||
- `VideoThumbnailCacheTest.kt` — given a known-good 5-second H.264 MP4 in `src/jvmTest/resources/sample.mp4`, asserts JCodec path produces a non-null `ImageBitmap` with expected dimensions.
|
||||
- `JaffreeProbeTest.kt` — verifies the bundled ffmpeg binary is found in `appResources/<currentOs>/ffmpeg/` at runtime and prints `ffmpeg -version` successfully. Skip on platforms where we don't ship a binary.
|
||||
- No HEVC/VP9/AV1 unit test (codec coverage depends on the runtime ffmpeg build; manual test).
|
||||
|
||||
#### Phase 3 — vlcj removal + build cleanup (~2-3 days)
|
||||
|
||||
##### 3.1 Code deletions
|
||||
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — delete
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt` — delete
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt` — delete
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt` — delete (referenced by both discoverers)
|
||||
- Any `import uk.co.caprica.vlcj.*` remaining in `GlobalMediaPlayer.kt` or `VideoThumbnailCache.kt` — delete
|
||||
- Tests that exercise vlcj integration (if any) — delete
|
||||
|
||||
##### 3.2 Gradle deletions
|
||||
|
||||
`desktopApp/build.gradle.kts`:
|
||||
- Remove `id("ir.mahozad.vlc-setup") version "0.1.0"` from plugins block
|
||||
- Remove `implementation(libs.vlcj)`
|
||||
- Remove the entire `vlcSetup { ... }` block
|
||||
- Remove `tasks.named("spotlessKotlin") { mustRunAfter("vlcSetup") }`
|
||||
- Remove `tasks.withType<Download>().configureEach { ... }` (was for `vlcDownload`)
|
||||
- Remove `jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"`
|
||||
- Remove `"jdk.unsupported"` from `modules(...)` if kdroidFilter doesn't need it (verify)
|
||||
- Update the AppImage build doc comment that mentions VLC
|
||||
|
||||
`gradle/libs.versions.toml`:
|
||||
- Remove `vlcj = "4.8.3"` from `[versions]`
|
||||
- Remove the `vlcj` entry from `[libraries]`
|
||||
|
||||
##### 3.3 Bundled VLC tree deletion
|
||||
|
||||
```bash
|
||||
git rm -r desktopApp/src/jvmMain/appResources/macos/vlc
|
||||
git rm -r desktopApp/src/jvmMain/appResources/windows/vlc
|
||||
git rm -r desktopApp/src/jvmMain/appResources/linux/vlc
|
||||
```
|
||||
|
||||
(Equivalent to the `ir.mahozad.vlc-setup` copy targets. If the directories
|
||||
were generated and not checked in, no `git rm` needed — just confirm the
|
||||
plugin removal stops emitting them.)
|
||||
|
||||
##### 3.4 NOTICE.md / licenses/ trimming
|
||||
|
||||
- Drop `LICENSE-GPL-3.0.txt`, `WRITTEN-OFFER.txt`
|
||||
- Drop the vlcj + libvlc entries from `NOTICE.md`
|
||||
- Keep entries for: kdroidFilter (MIT), JCodec (BSD-2), Jaffree (Apache-2), FFmpeg (LGPL-2.1), GStreamer (Linux runtime, LGPL-2.1)
|
||||
|
||||
##### 3.5 README trim
|
||||
|
||||
- Remove the interim "binary bundles GPL" disclaimer.
|
||||
- Replace with "Built on kdroidFilter ComposeMediaPlayer + native OS media frameworks."
|
||||
|
||||
##### 3.6 Acceptance for Phase 3
|
||||
|
||||
- `grep -r "vlcj\|caprica\|libvlc" desktopApp/` returns no source code matches (only doc/license mentions).
|
||||
- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces installers that contain no `vlc/`, `libvlc.dylib`, `libvlccore.dylib`, or `plugins/*.dylib`.
|
||||
- Built DMG is < 80 MB compressed (down from ~140 MB today; the FFmpeg LGPL binary is ~12-20 MB compressed).
|
||||
- App launches and plays a sample MP4 with no log line containing "VLC", "vlcj", or "libvlc".
|
||||
|
||||
#### Phase 4 — Flathub manifest + final SPDX (~2 days)
|
||||
|
||||
##### 4.1 Flathub manifest
|
||||
|
||||
New file: `desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml`
|
||||
|
||||
Sketch (planner to refine during implementation):
|
||||
|
||||
```yaml
|
||||
app-id: com.vitorpamplona.amethyst.Desktop
|
||||
runtime: org.freedesktop.Platform
|
||||
runtime-version: '24.08'
|
||||
sdk: org.freedesktop.Sdk
|
||||
sdk-extensions:
|
||||
- org.freedesktop.Sdk.Extension.openjdk21
|
||||
command: amethyst-desktop
|
||||
finish-args:
|
||||
- --share=network
|
||||
- --share=ipc
|
||||
- --socket=fallback-x11
|
||||
- --socket=wayland
|
||||
- --socket=pulseaudio
|
||||
- --device=dri
|
||||
- --filesystem=xdg-download
|
||||
- --talk-name=org.freedesktop.Notifications
|
||||
modules:
|
||||
- name: amethyst-desktop
|
||||
buildsystem: simple
|
||||
build-commands:
|
||||
- install -Dm755 amethyst-desktop /app/bin/amethyst-desktop
|
||||
# ... (jpackage Linux output copied in)
|
||||
sources:
|
||||
- type: file
|
||||
path: ../../build/compose/binaries/main-release/app/Amethyst/bin/Amethyst
|
||||
```
|
||||
|
||||
Runtime `org.freedesktop.Platform 24.08` ships GStreamer 1.24 with the
|
||||
plugins kdroidFilter needs (`gst-plugins-good`, `gst-plugins-bad`,
|
||||
`gst-libav` with LGPL config). No bundled FFmpeg needed for Flathub since
|
||||
GStreamer is available — the thumbnail path uses Jaffree → spawns the
|
||||
system `ffmpeg` if present, else falls back to JCodec-only. Document this
|
||||
clearly in the Flathub manifest comments.
|
||||
|
||||
##### 4.2 Final SPDX
|
||||
|
||||
After Phase 3 deletions, set `rpmLicenseType = "MIT AND LGPL-2.1-or-later
|
||||
AND BSD-2-Clause"`. (LGPL stays because the bundled FFmpeg for thumbnails is LGPL native.)
|
||||
|
||||
Update `LICENSES.md` (root) to add a "Distributed binary" section listing
|
||||
the SPDX expression + table of bundled components.
|
||||
|
||||
##### 4.3 CI updates
|
||||
|
||||
- Add a job that uploads the Flathub manifest to `flathub/com.vitorpamplona.amethyst.Desktop` repo (manual review by Flathub maintainers — not a one-button operation; document as a deferred follow-up).
|
||||
- Add a job step that verifies the produced DMG/MSI/DEB contains no `vlc*`, `libvlc*` strings.
|
||||
|
||||
### Architecture diagrams
|
||||
|
||||
#### Old (today)
|
||||
|
||||
```
|
||||
[User taps play on a feed video]
|
||||
│
|
||||
▼
|
||||
DesktopVideoPlayer composable
|
||||
│ GlobalMediaPlayer.playVideo(url)
|
||||
▼
|
||||
GlobalMediaPlayer (singleton)
|
||||
│ VlcjPlayerPool.init() + acquire()
|
||||
▼
|
||||
EmbeddedMediaPlayer ◄── BufferFormatCallback / RenderCallback
|
||||
│ │
|
||||
│ libvlc events │ ByteBuffer of pixels
|
||||
▼ ▼
|
||||
MediaPlayerEventAdapter Skia Bitmap.installPixels
|
||||
│ │
|
||||
▼ ▼
|
||||
_videoState (StateFlow) _videoFrame (StateFlow<ImageBitmap?>)
|
||||
│ │
|
||||
▼ ▼
|
||||
VideoControls reads state Image(bitmap = videoFrame)
|
||||
```
|
||||
|
||||
#### New
|
||||
|
||||
```
|
||||
[User taps play on a feed video]
|
||||
│
|
||||
▼
|
||||
DesktopVideoPlayer composable
|
||||
│ GlobalMediaPlayer.playVideo(url)
|
||||
▼
|
||||
GlobalMediaPlayer (singleton)
|
||||
│ videoPlayerState.openMedia(url) + .play()
|
||||
▼
|
||||
VideoPlayerState (kdroidFilter)
|
||||
│ delegates to OS backend
|
||||
▼
|
||||
[Media Foundation / AVFoundation / GStreamer]
|
||||
│ │
|
||||
│ state flows │ frames piped to native surface
|
||||
▼ ▼
|
||||
stateSyncJob folds into VideoPlayerSurface(state) renders directly
|
||||
_videoState (MediaPlaybackState) (no Compose ImageBitmap relay)
|
||||
│
|
||||
▼
|
||||
VideoControls reads state
|
||||
```
|
||||
|
||||
## Alternative Approaches Considered
|
||||
|
||||
(Full evaluation in [research doc §1 and §3](../brainstorms/2026-06-11-vlcj-replacement-research.md))
|
||||
|
||||
| Alternative | Why not |
|
||||
|-------------|---------|
|
||||
| Accept GPL on binary + only fix the metadata (Approach A in licensing brainstorm) | User priority: keep MIT brand on the binary. |
|
||||
| Buy Caprica commercial vlcj license (Approach C) | Burns budget every year; doesn't address bundled VLC plugin GPL surface. |
|
||||
| `gst1-java-core` + bundled GStreamer (Recommended #2 in research) | Binary is LGPL-3.0-dominant, not MIT. Reserved as Phase 1 decision-gate fallback. |
|
||||
| JavaFX MediaPlayer | No HEVC, VP9, AV1, Opus, FLAC. SwingPanel z-order incompatible with our overlays. |
|
||||
| Write our own JNA binding to libvlc | 3-6 weeks of native callback / GC-pinning work for **no material license gain** over kdroidFilter. |
|
||||
| open-ani/mediamp | Desktop backend (`mediamp-vlc`) is GPLv3 — same problem with one more layer. |
|
||||
| libmpv via Java binding | No maintained JVM binding exists. |
|
||||
| HumbleVideo | Abandoned 2018, AGPL. |
|
||||
| External player handoff only | Breaks in-feed playback UX. Kept as codec-missing fallback only. |
|
||||
|
||||
## System-Wide Impact
|
||||
|
||||
### Interaction Graph
|
||||
|
||||
What fires when `playVideo(url)` is called in the new design:
|
||||
|
||||
1. `DesktopVideoPlayer` composable's "play" button onClick → `GlobalMediaPlayer.playVideo(url)`.
|
||||
2. `GlobalMediaPlayer` updates `_videoState` to `(url, isBuffering=true, ...)`.
|
||||
3. `GlobalMediaPlayer` calls `videoPlayerState.openMedia(url)` then `.play()` on `Dispatchers.IO`.
|
||||
4. kdroidFilter's backend (Media Foundation / AVFoundation / GStreamer) opens the URL.
|
||||
5. kdroidFilter emits state changes through its flows; `stateSyncJob` folds them into `_videoState`.
|
||||
6. Compose recomposes `DesktopVideoPlayer` (active branch) → `VideoPlayerSurface(playerState)` swaps from "loading" to live frames.
|
||||
7. `NowPlayingBar` (which reads `_videoState`) updates simultaneously.
|
||||
8. `VideoControls` recomposes (consumes `_videoState`).
|
||||
|
||||
No callback marshalling between native and Compose threads on our side —
|
||||
kdroidFilter handles that. No `Skia.Bitmap.installPixels` per frame in our
|
||||
code path — `VideoPlayerSurface` writes directly to a native surface.
|
||||
|
||||
### Error & Failure Propagation
|
||||
|
||||
- **Codec unsupported (e.g. AV1 on stock Win10):** kdroidFilter emits an error state. `stateSyncJob` sets `_videoState.isBuffering = false` + a new `_videoState.errorReason: CodecError? = null` field. `DesktopVideoPlayer` checks this and renders `CodecUnsupportedMessage` instead of `VideoPlayerSurface`.
|
||||
- **Network failure / 404:** Same path — kdroidFilter error state → `CodecError(reason=NETWORK)`. UI shows generic "Couldn't load" with retry button.
|
||||
- **Thumbnail failure (JCodec then Jaffree both fail):** `VideoThumbnailCache.getThumbnail(url)` returns `null`. `DesktopVideoPlayer` already handles this (current code shows just the play-button overlay on null thumbnail).
|
||||
- **kdroidFilter native loading failure on Linux (no system GStreamer):** caught at `GlobalMediaPlayer.init()`-equivalent. Show a one-time toast: "Install GStreamer to play videos: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav".
|
||||
|
||||
### State Lifecycle Risks
|
||||
|
||||
- `videoPlayerState` is a singleton on `GlobalMediaPlayer`. Reusing it across URLs is supported by kdroidFilter (`openMedia(newUrl)` resets). No risk of leaked native players (vlcj's pool was a workaround for vlcj's expensive `EmbeddedMediaPlayer` construction; kdroidFilter doesn't have that cost).
|
||||
- App shutdown: `GlobalMediaPlayer.shutdown()` calls `videoPlayerState.release()` + `audioPlayerState.release()`. No native handle survives JVM exit.
|
||||
- Thumbnail cache: same `ConcurrentHashMap` + `pending` deduper. No native handles.
|
||||
- FFmpeg child processes (Jaffree): always have a 5-second timeout. `try-finally` ensures `Process.destroyForcibly()` on cancellation or exception.
|
||||
|
||||
### API Surface Parity
|
||||
|
||||
- `GlobalMediaPlayer`'s public API (verbs + StateFlows) is preserved. Callers in `DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, `NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay` need no breaking changes other than removing the `videoFrame` StateFlow (which only `DesktopVideoPlayer` reads).
|
||||
- Drop: `GlobalMediaPlayer.videoFrame: StateFlow<ImageBitmap?>` (replaced by `VideoPlayerSurface(activeVideoPlayerState)` reading the player state directly in the consumer composable).
|
||||
- Add: `GlobalMediaPlayer.activeVideoPlayerState: VideoPlayerState` for the surface consumer.
|
||||
- Add: `MediaPlaybackState.errorReason: CodecError? = null` for codec-unsupported UX.
|
||||
|
||||
### Integration Test Scenarios
|
||||
|
||||
Manual (also captured in the testing sheet handed to the user post-implementation):
|
||||
|
||||
1. **Resume across navigation.** Play a video in the feed, navigate to a different screen, return to the feed → video continues playing, position preserved. (Tests `GlobalMediaPlayer` singleton scope.)
|
||||
2. **Switch URLs mid-play.** Tap play on video A, then tap play on video B → A stops, B starts from beginning. (Tests `openMedia` reset.)
|
||||
3. **Now-playing bar sync.** Play audio from a feed item, scroll away → bar appears with playback state in sync. (Tests `_audioState` parity with the underlying engine.)
|
||||
4. **Fullscreen ↔ feed handoff.** Enter fullscreen during playback, exit → playback continues uninterrupted, no reload. (Tests that `VideoPlayerSurface` instances can be re-attached / not destroyed across composable re-creation. **High-risk**: this may require a `key(...)` boundary to keep the surface stable.)
|
||||
5. **Codec-missing Windows path.** Open an AV1 MP4 on stock Win10 → `CodecUnsupportedMessage` renders + "Open in default player" works. (Tests error propagation + handoff.)
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
### Functional Requirements
|
||||
|
||||
- [ ] Feed videos (H.264 MP4) play on macOS / Windows / Linux desktop builds.
|
||||
- [ ] Feed audio (MP3 / Opus / AAC) plays on all 3 OSes.
|
||||
- [ ] HLS livestreams (zap.stream) play on all 3 OSes.
|
||||
- [ ] VP9 WebM plays on macOS + Linux. Windows shows codec-missing UX with "open externally" handoff.
|
||||
- [ ] AV1 plays on macOS 13+ + Linux. Windows shows codec-missing UX.
|
||||
- [ ] HEVC plays on macOS + Linux. Windows shows codec-missing UX.
|
||||
- [ ] Thumbnails are extracted and shown in the feed for H.264 MP4 (JCodec) and HEVC/VP9/AV1 (Jaffree LGPL FFmpeg) — same coverage as vlcj today.
|
||||
- [ ] Existing in-feed playback UX (play/pause/seek/volume/mute/fullscreen) is preserved.
|
||||
- [ ] Now-playing bar continues to work across navigation.
|
||||
- [ ] Lightbox / fullscreen overlay continues to work.
|
||||
- [ ] App startup: no "VLC not installed" path. Engine is always available.
|
||||
|
||||
### Non-Functional Requirements
|
||||
|
||||
- [ ] Binary SPDX in produced packages: `MIT AND LGPL-2.1-or-later AND BSD-2-Clause` (no `GPL-3.0` token).
|
||||
- [ ] macOS DMG size ≤ today's size − 25 MB (target: ~70-80 MB compressed).
|
||||
- [ ] No regression in feed-scroll FPS (kdroidFilter writes frames natively; should equal or exceed vlcj's `RenderCallback` path).
|
||||
- [ ] App startup time ≤ today (vlcj init removed; kdroidFilter doesn't initialize until first playback).
|
||||
|
||||
### Quality Gates
|
||||
|
||||
- [ ] `./gradlew :desktopApp:compileKotlin` green.
|
||||
- [ ] `./gradlew :desktopApp:test` green.
|
||||
- [ ] `./gradlew spotlessApply` clean.
|
||||
- [ ] `grep -r "vlcj\|caprica\|libvlc" desktopApp/src/` finds zero hits.
|
||||
- [ ] `./gradlew :desktopApp:packageDistributionForCurrentOS` succeeds on macOS arm64 (local) and produces a DMG; smoke-launches.
|
||||
- [ ] NOTICE / licenses / About-screen-licenses-listing accurate and accessible from the running app.
|
||||
- [ ] Manual test sheet completed by user (provided post-implementation).
|
||||
|
||||
## Success Metrics
|
||||
|
||||
- **Binary SPDX correctness** (boolean): packaged installers carry the SPDX expression listed above. Verified by `rpm -qpi`, DMG metadata, MSI summary.
|
||||
- **Bundle size delta** (MB): macOS DMG and Linux DEB shrink by ≥25 MB after Phase 3.
|
||||
- **Manual codec coverage matrix pass rate** (%): aim for ≥95% pass on H.264, AAC, MP3, HLS-H.264; ≥75% on VP9, AV1, HEVC (Windows excepted per documented gap).
|
||||
- **Crash-free playback sessions in first 30 days post-release** (telemetry, if available): ≥ today's baseline.
|
||||
- **Issue-tracker mentions of "VLC not installed"** post-release: 0.
|
||||
|
||||
## Dependencies & Prerequisites
|
||||
|
||||
- Maven Central artifacts: `io.github.kdroidfilter:composemediaplayer:0.10.1`, `org.jcodec:jcodec:0.2.5`, `com.github.kokorin.jaffree:jaffree:2024.08.29`.
|
||||
- LGPL FFmpeg binaries to commit into `desktopApp/src/jvmMain/appResources/<os>/ffmpeg/` (planner to fetch + verify checksums during Phase 2).
|
||||
- Linux runtime: GStreamer 1.20+ with `gst-plugins-good` and `gst-libav`. Already installed by default on Fedora 39+, Ubuntu 22.04+, Debian 12+. Documented as a `recommends`/`depends` line in the .deb/.rpm control files.
|
||||
- Flathub: `org.freedesktop.Platform 24.08` runtime (Phase 4).
|
||||
- JDK 21 (already in use).
|
||||
- No new tooling required (jpackage, ProGuard, spotless already configured).
|
||||
|
||||
## Risk Analysis & Mitigation
|
||||
|
||||
| # | Risk | Probability | Impact | Mitigation |
|
||||
|---|------|-------------|--------|------------|
|
||||
| 1 | kdroidFilter HLS livestream failures on AVPlayer (strict mode) | M | H | Phase 1 decision gate against zap.stream + 2-3 alt streams. Pivot to gst1-java-core if it bites. |
|
||||
| 2 | Windows stock-codec gap (HEVC/VP9/AV1) | H | M | Codec-detection + "open externally" handoff. Document in release notes. |
|
||||
| 3 | kdroidFilter project goes dormant (single maintainer) | L | M | API surface we depend on is tiny; we own the wrapper. Pivot to gst1-java-core later behind same wrapper API. |
|
||||
| 4 | JCodec doesn't decode some H.264 high-profile MP4s | M | L | Jaffree fallback handles it. Cost: an extra ffmpeg spawn for those URLs. |
|
||||
| 5 | macOS hardened-runtime rejects nested ffmpeg binary | L | H | Co-sign nested binaries with the app identity in the existing `codesign` step. If jpackage doesn't, add a post-step `codesign --deep`. |
|
||||
| 6 | Flathub review delays / rejects manifest | L | L | Manifest authoring is in scope; submitting to Flathub is a separate operation tracked outside this PR. |
|
||||
| 7 | `Image(bitmap)` → `VideoPlayerSurface` swap reveals new z-order interaction with `GlobalFullscreenOverlay` | M | M | Test scenario #4 in integration tests; fix at implementation time. |
|
||||
| 8 | `_videoState.errorReason` field break binary-state compat in tests | L | L | All affected tests are co-edited in this PR. |
|
||||
| 9 | LGPL FFmpeg binaries grow git history | M | L | Use git LFS for the per-OS binaries; or fetch on first build via a one-shot Gradle task with checksum verification. |
|
||||
| 10 | jpackage doesn't sign nested executables on macOS | M | M | Phase 2 acceptance verifies; fallback is post-step `codesign --deep --force --sign "..." Amethyst.app`. |
|
||||
|
||||
## Resource Requirements
|
||||
|
||||
- **People:** 1 engineer (you/Claude in `/ce:work` mode).
|
||||
- **Time:** 2-3 weeks elapsed for code work. Plus user time for manual testing pass.
|
||||
- **Infra:** Local macOS arm64 build, Windows VM for codec-gap testing (existing CI handles cross-OS DMG/MSI/DEB), Linux VM for Flathub manifest verification.
|
||||
|
||||
## Future Considerations
|
||||
|
||||
- Once kdroidFilter / `gst1-java-core` matures, consider lifting the player abstraction into `commons/commonMain/` so iOS desktop (if ever) can share. Out of scope for this PR.
|
||||
- If Compose Multiplatform 1.9+ ships an official `VideoPlayer` composable backed by `androidx.media3` on desktop, evaluate migrating to that — but only if the codec story is at least equivalent. Likely 12-18 months out.
|
||||
- DASH support — kdroidFilter doesn't list it. If we want it later, GStreamer fallback is the path.
|
||||
|
||||
## Documentation Plan
|
||||
|
||||
- `NOTICE.md` (new, in app bundle).
|
||||
- `LICENSES.md` (root) — add "Distributed binary" section.
|
||||
- `README.md` (root) — interim disclaimer added in Phase 0, removed in Phase 3.
|
||||
- `desktopApp/packaging/flatpak/README.md` — explains the Flathub manifest, build steps, deps.
|
||||
- About-dialog "Open source licenses" screen (in-app).
|
||||
- Update `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` to add "superseded by 2026-06-11 plan" front-matter note (if appropriate — verify during execution).
|
||||
|
||||
## Sources & References
|
||||
|
||||
### Origin
|
||||
|
||||
- **Brainstorm document:** [docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md). Decisions carried forward:
|
||||
- Stack choice (kdroidFilter primary, gst1-java-core fallback)
|
||||
- Single-PR bundling of license bridge + migration
|
||||
- Hard cut, no feature flag
|
||||
- Flathub manifest in scope
|
||||
- Android out of scope
|
||||
- **Companion research:** [docs/brainstorms/2026-06-11-vlcj-replacement-research.md](../brainstorms/2026-06-11-vlcj-replacement-research.md) — full candidate comparison matrix.
|
||||
- **Companion licensing analysis:** [docs/brainstorms/2026-06-11-vlcj-licensing-brainstorm.md](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) — 10-issue catalog.
|
||||
|
||||
### Internal References
|
||||
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — pool architecture being deleted
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt` — engine swap target
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt` — engine swap target
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt:62-172` — UI rewire target
|
||||
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt` — minor changes
|
||||
- `desktopApp/build.gradle.kts:9, 60-61, 97-98, 117, 141, 166-176` — build wiring to swap
|
||||
- `gradle/libs.versions.toml` — `vlcj = "4.8.3"` to remove
|
||||
- `LICENSE` (root) — Amethyst MIT
|
||||
- `docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md` — recent VLC native-loading work that gets superseded
|
||||
- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` — earlier desktop media plan
|
||||
- `docs/plans/_vlcj-migration-learnings.md` — institutional learnings (compiled by research agent 2026-06-11)
|
||||
|
||||
### External References
|
||||
|
||||
- [kdroidFilter/ComposeMediaPlayer](https://github.com/kdroidFilter/ComposeMediaPlayer) — primary library
|
||||
- [kdroidFilter/ComposeMediaPlayer LICENSE](https://github.com/kdroidFilter/ComposeMediaPlayer/blob/master/LICENSE)
|
||||
- [`io.github.kdroidfilter:composemediaplayer` on Maven Central](https://central.sonatype.com/artifact/io.github.kdroidfilter/composemediaplayer)
|
||||
- [jcodec/jcodec](https://github.com/jcodec/jcodec)
|
||||
- [kokorin/Jaffree](https://github.com/kokorin/Jaffree)
|
||||
- [Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264](https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264) — Windows LGPL ffmpeg
|
||||
- [osxexperts.net LGPL FFmpeg](https://www.osxexperts.net/) — macOS LGPL ffmpeg
|
||||
- [johnvansickle.com FFmpeg static builds](https://johnvansickle.com/ffmpeg/) — Linux LGPL ffmpeg
|
||||
- [Flathub submission docs](https://docs.flathub.org/docs/for-app-authors/submission)
|
||||
- [GStreamer licensing FAQ](https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html)
|
||||
- [FFmpeg legal](https://www.ffmpeg.org/legal.html) — `--enable-gpl` semantics
|
||||
- [GNU GPL FAQ §JavaJVM](https://www.gnu.org/licenses/gpl-faq.html#JavaJVM) — confirms JNI/JNA doesn't escape GPL
|
||||
- [Apple anti-Tivoization vs GPLv3 (FSF/VLC 2011)](https://www.fsf.org/news/2010-05-app-store-compliance) — App Store incompatibility
|
||||
|
||||
### Related Work
|
||||
|
||||
- Brainstorms (this PR's ancestry):
|
||||
- [Licensing catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)
|
||||
- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md)
|
||||
- [Replacement research](../brainstorms/2026-06-11-vlcj-replacement-research.md)
|
||||
- Prior plans:
|
||||
- [Desktop media full parity plan](2026-03-16-feat-desktop-media-full-parity-plan.md)
|
||||
- [Desktop media manual testing plan](2026-03-16-desktop-media-manual-testing-plan.md)
|
||||
- [macOS VLC bundled discovery fix plan](2026-05-18-fix-macos-vlc-bundled-discovery-plan.md)
|
||||
182
docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
Normal file
182
docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Manual Testing Sheet — vlcj → kdroidFilter Migration
|
||||
|
||||
**Plan:** [`2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md`](2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md)
|
||||
**Branch / worktree:** `.claude/worktrees/vlcj-licensing-brainstorm` (branch `worktree-vlcj-licensing-brainstorm`)
|
||||
**Date:** 2026-06-11
|
||||
|
||||
This sheet walks through every verification the user must perform locally
|
||||
because the automated background session can't reach Maven Central or
|
||||
exercise the JVM media stack across OSes.
|
||||
|
||||
---
|
||||
|
||||
## 0 · Build verification — VERIFIED locally on macOS arm64 (2026-06-11)
|
||||
|
||||
Build verified after network came online. Final dep pins:
|
||||
- `io.github.kdroidfilter:composemediaplayer:0.10.0` (research-agent hallucinated 0.10.1 — actual latest on Maven Central is 0.10.0)
|
||||
- `org.jcodec:jcodec:0.2.5`
|
||||
- `org.jcodec:jcodec-javase:0.2.5`
|
||||
|
||||
| # | Command | Result on macOS arm64 |
|
||||
|---|---------|-----------------------|
|
||||
| 0.1 | `./gradlew :desktopApp:compileKotlin` | ✅ `BUILD SUCCESSFUL` in 16s |
|
||||
| 0.2 | `./gradlew :desktopApp:test` | ✅ `BUILD SUCCESSFUL` in 23s |
|
||||
| 0.3 | `./gradlew :desktopApp:spotlessApply` | ✅ clean, no diff |
|
||||
| 0.4 | `grep -rn 'vlcj\|caprica\|VlcjPlayerPool\|MacOsVlcDiscoverer\|BundledVlcDiscoverer\|VlcResourceResolver' desktopApp/src/ gradle/ desktopApp/build.gradle.kts` | ✅ Only doc-comment mentions in `VideoThumbnailCache.kt` header (intentional; describes what was replaced) |
|
||||
|
||||
Re-verify on Windows + Linux at your convenience.
|
||||
|
||||
---
|
||||
|
||||
## 1 · Bundled FFmpeg binaries (REQUIRED for thumbnail fallback)
|
||||
|
||||
The agent created the directory structure but didn't check in binaries
|
||||
(~30 MB each, not appropriate for git). Drop them here before packaging:
|
||||
|
||||
| OS | Path | Source | Verify |
|
||||
|----|------|--------|--------|
|
||||
| macOS (arm64 + x86_64 universal) | `desktopApp/src/jvmMain/appResources/macos/ffmpeg/ffmpeg` | https://www.osxexperts.net/ LGPL build | `lipo -info ffmpeg` shows both arches; `chmod +x ffmpeg` |
|
||||
| Windows (x64) | `desktopApp/src/jvmMain/appResources/windows/ffmpeg/ffmpeg.exe` | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `ffmpeg.exe -version` reports `--enable-version3 --disable-gpl` flags |
|
||||
| Linux | _(skip — relies on host ffmpeg/gstreamer)_ | n/a | See `desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md` for self-contained AppImage option |
|
||||
|
||||
Each per-OS subdirectory has a `README.md` with the exact source URL.
|
||||
|
||||
---
|
||||
|
||||
## 2 · Smoke launch
|
||||
|
||||
| # | Action | Expected |
|
||||
|---|--------|----------|
|
||||
| 2.1 | `./gradlew :desktopApp:run` | Window opens. No log line mentions VLC, vlcj, libvlc, or "plugin path". |
|
||||
| 2.2 | Look at stdout / stderr | No `WARN`/`ERROR` from missing native libs. First time kdroidFilter extracts its native to `~/.cache/composemediaplayer/native/` — that's expected. |
|
||||
| 2.3 | App responds to clicks; UI is layout-identical to pre-migration | Yes |
|
||||
|
||||
---
|
||||
|
||||
## 3 · Codec / playback matrix
|
||||
|
||||
Use the URLs below (substitute your own equivalents if any are dead). All
|
||||
verified by mounting the URL via the feed; you don't need to post any
|
||||
Nostr event.
|
||||
|
||||
### Active video URLs to feed into the player (place into a draft note or open via the lightbox)
|
||||
|
||||
| # | Test | URL pattern | macOS | Windows | Linux |
|
||||
|---|------|------|--------|---------|-------|
|
||||
| 3.1 | H.264 MP4 (golden path) | `https://download.samplelib.com/mp4/sample-5s.mp4` | ✅ plays | ✅ plays | ✅ plays |
|
||||
| 3.2 | HLS livestream (zap.stream) | a live zap.stream m3u8 URL | ✅ plays | ✅ plays | ✅ plays |
|
||||
| 3.3 | HLS VOD H.264+AAC | `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` | ✅ plays | ✅ plays | ✅ plays |
|
||||
| 3.4 | VP9 WebM | a known nostr.build VP9 webm URL | ✅ plays | ⚠️ codec-missing UX, "open externally" works | ✅ plays (if gst-libav installed) |
|
||||
| 3.5 | AV1 MP4 | a known void.cat / nostr.build AV1 URL | ✅ plays (macOS 13+ or M3+ HW) | ⚠️ codec-missing UX | ✅ plays (if gst-libav installed) |
|
||||
| 3.6 | HEVC MP4 | a known iOS-recorded HEVC URL | ✅ plays | ⚠️ "Install HEVC extension" UX | ✅ plays (if gst-libav installed) |
|
||||
| 3.7 | Audio: MP3 | a podcast MP3 from feed | ✅ plays | ✅ plays | ✅ plays |
|
||||
| 3.8 | Audio: Opus | a known Opus URL | ✅ plays | ✅ plays | ✅ plays (if gst-plugins-base installed) |
|
||||
| 3.9 | Audio: AAC | a known AAC URL | ✅ plays | ✅ plays | ✅ plays |
|
||||
| 3.10 | Garbage URL (404) | `https://example.invalid/nope.mp4` | "Can't play this video" + `Source:` reason; "Open in default player" button does nothing harmful | same | same |
|
||||
|
||||
**Phase 1 decision gate** (per plan): if 3.2 (zap.stream HLS livestream)
|
||||
fails on any OS for reasons that aren't codec-related, pivot to the
|
||||
gst1-java-core fallback (research doc §3 Recommended #2). Don't pivot for
|
||||
3.4/3.5/3.6 — those gaps are expected on stock Win10 without the codec
|
||||
extensions and are mitigated by the "open externally" UX.
|
||||
|
||||
---
|
||||
|
||||
## 4 · UI cross-state behaviour
|
||||
|
||||
| # | Scenario | Expected |
|
||||
|---|----------|----------|
|
||||
| 4.1 | Play feed video A → navigate to a different screen → return | Video continues playing, position preserved. (Singleton `GlobalMediaPlayer` retained.) |
|
||||
| 4.2 | Play video A → tap play on video B | A stops, B starts from beginning. |
|
||||
| 4.3 | Active video → tap fullscreen | Enters native fullscreen; same `VideoPlayerSurface` renders fullscreen; controls work; Esc / F exits. |
|
||||
| 4.4 | Fullscreen video → press Spacebar | Toggles play/pause. |
|
||||
| 4.5 | NowPlayingBar mini-preview | Mini 48×36 video preview renders inside the bottom bar while video is active. |
|
||||
| 4.6 | Audio playback → navigate around | NowPlayingBar shows music icon (no video) + correct artist/title (URL filename suffices for now). |
|
||||
| 4.7 | Mute → unmute | Volume restores to pre-mute value. |
|
||||
| 4.8 | Volume slider → 0 | `volume = 0` reflects, but `isMuted` flag stays false (matches kdroidFilter's no-mute-flag semantics; explicit mute is separate). |
|
||||
| 4.9 | Stop video / audio while playing | Engine cleanly stops; UI returns to neutral state. |
|
||||
| 4.10 | Quit app while playing | No segfault, no lingering native processes. (kdroidFilter registers its own shutdown hook on Windows for MediaFoundation.) |
|
||||
|
||||
---
|
||||
|
||||
## 5 · Thumbnail extraction
|
||||
|
||||
| # | Scenario | Expected |
|
||||
|---|----------|----------|
|
||||
| 5.1 | H.264 MP4 in feed (inactive) | Thumbnail appears within 2-3s. JCodec handles this — confirms by speed. |
|
||||
| 5.2 | VP9 WebM in feed (inactive) | Thumbnail appears (slower, ~5s) — JCodec rejects, falls through to ffmpeg. Verifies the cascade. |
|
||||
| 5.3 | Same feed reopened | Thumbnails appear instantly from in-memory cache. |
|
||||
| 5.4 | No bundled ffmpeg + no system ffmpeg | Thumbnails fail silently for non-H.264 sources; H.264 still works (JCodec). No crash. Document this UX in release notes. |
|
||||
| 5.5 | HLS livestream thumbnail | Should NOT block playback. JCodec auto-skipped because URL ends `.m3u8` or contains `/hls/`. Falls through to `ffmpeg URL` directly. |
|
||||
|
||||
---
|
||||
|
||||
## 6 · License metadata verification
|
||||
|
||||
| # | Artifact | Verify |
|
||||
|---|----------|--------|
|
||||
| 6.1 | `./gradlew :desktopApp:packageRpm` → `rpm -qpi build/compose/binaries/main-release/rpm/*.rpm` | `License: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0` |
|
||||
| 6.2 | `./gradlew :desktopApp:packageDmg` → mount DMG | Inside `Amethyst.app/Contents/app/resources/common/` — `NOTICE.md`, `licenses/LICENSE-MIT-amethyst.txt`, `licenses/LICENSE-MIT-kdroidfilter.txt`, `licenses/LICENSE-BSD-2-jcodec.txt`, `licenses/LICENSE-LGPL-2.1.txt` |
|
||||
| 6.3 | `grep -rn 'vlcj\|GPL-3' desktopApp/src/jvmMain/appResources/common/` | Zero hits — confirms no stale GPL references shipped |
|
||||
| 6.4 | `LICENSE-LGPL-2.1.txt` | **Currently a placeholder.** Replace with verbatim GNU LGPL-2.1 text (~26 KB) from https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt before any release. The plan acceptance gate flags this. |
|
||||
|
||||
---
|
||||
|
||||
## 7 · Flathub manifest (deferred submission)
|
||||
|
||||
| # | Action | Expected |
|
||||
|---|--------|----------|
|
||||
| 7.1 | `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` | OK |
|
||||
| 7.2 | `./gradlew :desktopApp:createReleaseDistributable` | Produces `desktopApp/build/compose/binaries/main-release/app/Amethyst/` |
|
||||
| 7.3 | `cd desktopApp/packaging/flatpak && flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml` | OK (may need icon at `icons/256/com.vitorpamplona.amethyst.Desktop.png` — copy from `desktopApp/src/jvmMain/resources/icon.png` first) |
|
||||
| 7.4 | `flatpak run com.vitorpamplona.amethyst.Desktop` | App launches; video playback works (host org.freedesktop.Platform ships GStreamer 1.24) |
|
||||
| 7.5 | Actual Flathub submission | Out of scope for this PR; follow steps in `desktopApp/packaging/flatpak/README.md` |
|
||||
|
||||
---
|
||||
|
||||
## 8 · macOS code signing (deferred — only if you sign before this PR merges)
|
||||
|
||||
| # | Action | Expected |
|
||||
|---|--------|----------|
|
||||
| 8.1 | jpackage with `--mac-sign --mac-signing-key-user-name "Developer ID Application: …"` | `codesign --verify --deep --strict Amethyst.app` is clean; nested `Contents/app/resources/macos/ffmpeg/ffmpeg` is signed with the same identity |
|
||||
| 8.2 | Entitlements include `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.disable-library-validation` | Required for HotSpot + sibling dylib loading |
|
||||
| 8.3 | `xcrun notarytool submit Amethyst.dmg` succeeds | Apple notarizes the DMG including nested ffmpeg |
|
||||
| 8.4 | `xcrun stapler staple Amethyst.dmg` succeeds | Ticket stapled |
|
||||
|
||||
Detailed recipe: `docs/plans/_macos-ffmpeg-signing-recipe.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9 · Known caveats / follow-ups for after this PR
|
||||
|
||||
These are intentional gaps, not bugs:
|
||||
|
||||
1. **In-app "Open source licenses" screen** — not yet wired into desktop settings UI. NOTICE.md and licenses/ ship as resources but a Compose composable reading them isn't included. Recommended follow-up: add `mikepenz/AboutLibraries` Gradle plugin (auto-discovers Maven licenses) + a `LicensesScreen.kt` rendering the report; integrate into the settings nav.
|
||||
2. **LGPL-2.1 license text is a placeholder** — flagged in 6.4. The release-gate fix is "paste the verbatim 26 KB text into `LICENSE-LGPL-2.1.txt`".
|
||||
3. **`tryJaffree*` and `runFfmpegToImage`** — the Jaffree dependency was dropped in favor of raw ProcessBuilder for simplicity. Renaming the internal `tryJaffree*` functions to `tryFfmpeg*` is a cosmetic follow-up.
|
||||
4. **No GStreamer probe on Linux** — kdroidFilter's `SourceError` is indistinguishable from "missing gst-libav" without probing. Follow-up: add a one-time `gst-inspect-1.0 avdec_h264` probe on Linux startup; show a non-blocking install hint if missing.
|
||||
5. **Windows codec-extension detection** — currently we just rely on kdroidFilter's `SourceError` and our error UX. A nicer follow-up: probe `MFTEnumEx` to detect HEVC/AV1 codec availability up front and prompt for the MS Store extension.
|
||||
6. **Flathub icon file** — manifest references `icons/256/com.vitorpamplona.amethyst.Desktop.png` which doesn't exist. Copy/resize from `desktopApp/src/jvmMain/resources/icon.png` before the first Flathub submission.
|
||||
|
||||
---
|
||||
|
||||
## 10 · Roll-back path
|
||||
|
||||
If anything in 0-5 blocks shipping:
|
||||
|
||||
- **Code revert:** `git revert <merge-commit>` of this PR is clean — vlcj files were deleted, new files are isolated to `desktopApp/`.
|
||||
- **Branch:** `git branch -D worktree-vlcj-licensing-brainstorm` (or whatever the merged branch was). `git checkout main && git pull` returns to vlcj-based desktop.
|
||||
- **No data migration:** nothing on disk format changed. Settings, account state, downloaded thumbnails — all forward+backward compatible.
|
||||
|
||||
---
|
||||
|
||||
## Self-check before opening the PR
|
||||
|
||||
- [ ] 0.1 BUILD SUCCESSFUL on my machine
|
||||
- [ ] 0.2 tests green
|
||||
- [ ] 0.4 no vlcj source code references
|
||||
- [ ] 1 ffmpeg binaries in place for the OSes I'm packaging
|
||||
- [ ] 2.1 app launches without VLC warnings
|
||||
- [ ] 3.1, 3.2, 3.3, 3.7 pass on at least my primary OS
|
||||
- [ ] 6.1 RPM license metadata correct
|
||||
- [ ] 6.4 LGPL text placeholder noted in release checklist
|
||||
@@ -67,7 +67,8 @@ playServicesCast = "22.3.1"
|
||||
vico-charts-compose = "3.1.0"
|
||||
zelory = "3.0.1"
|
||||
zoomable = "2.12.0"
|
||||
vlcj = "4.8.3"
|
||||
composemediaplayer = "0.10.0"
|
||||
jcodec = "0.2.5"
|
||||
commonsImaging = "1.0.0-alpha6"
|
||||
zxing = "3.5.4"
|
||||
zxingAndroidEmbedded = "4.3.0"
|
||||
@@ -145,7 +146,9 @@ coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", versio
|
||||
coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" }
|
||||
commons-imaging = { group = "org.apache.commons", name = "commons-imaging", version.ref = "commonsImaging" }
|
||||
slf4j-nop = { module = "org.slf4j:slf4j-nop", version.ref = "slf4j" }
|
||||
vlcj = { group = "uk.co.caprica", name = "vlcj", version.ref = "vlcj" }
|
||||
composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" }
|
||||
jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" }
|
||||
jcodec-javase = { group = "org.jcodec", name = "jcodec-javase", version.ref = "jcodec" }
|
||||
dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" }
|
||||
firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" }
|
||||
firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" }
|
||||
|
||||
Reference in New Issue
Block a user