From 704f4f44ee3086ac74601abc1e2219e3a3c673db Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Thu, 11 Jun 2026 13:37:33 +0300 Subject: [PATCH] feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- desktopApp/build.gradle.kts | 85 +- desktopApp/packaging/appimage/AppRun | 10 +- desktopApp/packaging/flatpak/README.md | 54 ++ ...com.vitorpamplona.amethyst.Desktop.desktop | 9 + ...itorpamplona.amethyst.Desktop.metainfo.xml | 53 ++ .../com.vitorpamplona.amethyst.Desktop.yml | 77 ++ .../src/jvmMain/appResources/common/NOTICE.md | 51 ++ .../common/licenses/LICENSE-BSD-2-jcodec.txt | 24 + .../common/licenses/LICENSE-LGPL-2.1.txt | 23 + .../common/licenses/LICENSE-MIT-amethyst.txt | 21 + .../licenses/LICENSE-MIT-kdroidfilter.txt | 21 + .../vitorpamplona/amethyst/desktop/Main.kt | 9 +- .../service/media/BundledVlcDiscoverer.kt | 44 - .../service/media/GlobalMediaPlayer.kt | 537 ++++------- .../service/media/MacOsVlcDiscoverer.kt | 81 -- .../service/media/VideoThumbnailCache.kt | 310 +++++-- .../service/media/VlcResourceResolver.kt | 63 -- .../desktop/service/media/VlcjPlayerPool.kt | 325 ------- .../desktop/ui/media/DesktopVideoPlayer.kt | 95 +- .../ui/media/GlobalFullscreenOverlay.kt | 18 +- .../desktop/ui/media/NowPlayingBar.kt | 12 +- ...eat-replace-vlcj-with-kdroidfilter-plan.md | 863 ++++++++++++++++++ ...26-06-11-vlcj-replacement-testing-sheet.md | 182 ++++ gradle/libs.versions.toml | 7 +- 24 files changed, 1906 insertions(+), 1068 deletions(-) create mode 100644 desktopApp/packaging/flatpak/README.md create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml create mode 100644 desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml create mode 100644 desktopApp/src/jvmMain/appResources/common/NOTICE.md create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt create mode 100644 desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt delete mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt create mode 100644 docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md create mode 100644 docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index c59128bc26..a3ab990308 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -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().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 // diff --git a/desktopApp/packaging/appimage/AppRun b/desktopApp/packaging/appimage/AppRun index d4065af016..c42b59c001 100755 --- a/desktopApp/packaging/appimage/AppRun +++ b/desktopApp/packaging/appimage/AppRun @@ -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//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" "$@" diff --git a/desktopApp/packaging/flatpak/README.md b/desktopApp/packaging/flatpak/README.md new file mode 100644 index 0000000000..12bf3f3c7a --- /dev/null +++ b/desktopApp/packaging/flatpak/README.md @@ -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`. diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop new file mode 100644 index 0000000000..cc52ce21e4 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.desktop @@ -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 diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml new file mode 100644 index 0000000000..6ff45dc294 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.metainfo.xml @@ -0,0 +1,53 @@ + + + com.vitorpamplona.amethyst.Desktop + CC0-1.0 + MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0 + Amethyst Desktop + Nostr client for desktop + + +

+ 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. +

+

+ 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. +

+
+ + com.vitorpamplona.amethyst.Desktop.desktop + + https://github.com/vitorpamplona/amethyst + https://github.com/vitorpamplona/amethyst/issues + https://github.com/vitorpamplona/amethyst + + + Vitor Pamplona + + + + + + Network + InstantMessaging + + + + + + + + + +
diff --git a/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml new file mode 100644 index 0000000000..8ed6f86207 --- /dev/null +++ b/desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml @@ -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 diff --git a/desktopApp/src/jvmMain/appResources/common/NOTICE.md b/desktopApp/src/jvmMain/appResources/common/NOTICE.md new file mode 100644 index 0000000000..23547e8bf2 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/NOTICE.md @@ -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//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 +``` diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt new file mode 100644 index 0000000000..5d3569e640 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-BSD-2-jcodec.txt @@ -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. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt new file mode 100644 index 0000000000..319d0e6d79 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-LGPL-2.1.txt @@ -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//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. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt new file mode 100644 index 0000000000..988afa646d --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-amethyst.txt @@ -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. diff --git a/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt new file mode 100644 index 0000000000..5d4359a9e1 --- /dev/null +++ b/desktopApp/src/jvmMain/appResources/common/licenses/LICENSE-MIT-kdroidfilter.txt @@ -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. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 16d487cb20..39b24355d9 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt deleted file mode 100644 index ee1930fb01..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt +++ /dev/null @@ -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 -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt index dc59b159cd..db77e7e46e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt @@ -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(null) - val videoFrame: StateFlow = _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 = _videoState.asStateFlow() - // Audio state private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO)) val audioState: StateFlow = _audioState.asStateFlow() - // Fullscreen private val _isFullscreen = MutableStateFlow(false) val isFullscreen: StateFlow = _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) {} + 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?, + ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt deleted file mode 100644 index 58d7f9fa54..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt +++ /dev/null @@ -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 { - 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(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 - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt index 6ecaabe158..2deb546773 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt @@ -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//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() private val pending = ConcurrentHashMap() + 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//ffmpeg/. + // jpackage drops appResources at /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) {} + 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 -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" + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt deleted file mode 100644 index c53af47faa..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt +++ /dev/null @@ -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 - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt deleted file mode 100644 index 4c73e918cd..0000000000 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt +++ /dev/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() - private val idlePlayers = ConcurrentLinkedQueue() - private const val MAX_POOL_SIZE = 1 - - // Thumbnail player pool (separate so thumbnails don't compete with playback) - private val allThumbPlayers = mutableListOf() - private val idleThumbPlayers = ConcurrentLinkedQueue() - 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() - private val idleAudioPlayers = ConcurrentLinkedQueue() - 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) - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt index a71c4024df..7618e8293a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt @@ -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") + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt index 18a59732f8..7aaea40218 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/GlobalFullscreenOverlay.kt @@ -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( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt index ffe53568a2..6d88aff5fd 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/NowPlayingBar.kt @@ -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) diff --git a/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md new file mode 100644 index 0000000000..5401640a1d --- /dev/null +++ b/docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md @@ -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 `` 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| +| - 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 // delete — DesktopVideoPlayer reads VideoPlayerSurface directly now +val videoState: StateFlow // keep +val audioState: StateFlow // keep +val isFullscreen: StateFlow // 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) { + 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` +getters rather than `Flow`, 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/` (VP9 WebM) | Plays on mac+Linux. Win → codec-missing UX. | +| `https://void.cat/` (AV1 MP4) | Plays on macOS 13+ + Linux. Win → codec-missing UX. | +| `https://zap.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//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//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 +com.apple.security.cs.allow-unsigned-executable-memory + +com.apple.security.cs.disable-library-validation + +com.apple.security.cs.allow-jit + +``` + +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/.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 -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//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().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) + │ │ + ▼ ▼ + 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` (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//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) diff --git a/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md new file mode 100644 index 0000000000..b45ae2c1cb --- /dev/null +++ b/docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md @@ -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 ` 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f965547bdd..737ec6acf4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }