feat(desktop): replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/FFmpeg

Drops uk.co.caprica:vlcj 4.8.3 (GPL-3.0) from desktopApp and replaces it
with an MIT-dominant stack:

- Video / audio playback: io.github.kdroidfilter:composemediaplayer:0.10.0
  (MIT) — OS-native backends (Media Foundation on Windows, AVFoundation on
  macOS, GStreamer on Linux). First-class Compose VideoPlayerSurface.
- Thumbnail extraction: org.jcodec:jcodec(+javase):0.2.5 (BSD-2) primary
  H.264 path, raw ProcessBuilder FFmpeg fallback for HEVC / VP9 / AV1 /
  HLS / non-faststart MP4.
- Binary SPDX: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0.
  rpmLicenseType updated accordingly (previously misdeclared as MIT
  while shipping GPLv3 vlcj).

Code changes:
- Deleted: VlcjPlayerPool, MacOsVlcDiscoverer, BundledVlcDiscoverer,
  VlcResourceResolver
- Rewrote: GlobalMediaPlayer (kdroidFilter engine + snapshotFlow-based
  state sync into the preserved MediaPlaybackState contract);
  VideoThumbnailCache (JCodec → ProcessBuilder ffmpeg cascade, with a
  hard 4 MiB download cap, Content-Type sniff to reject HTML error
  pages, and cleanup of zero-byte cache entries); DesktopVideoPlayer
  (mounts VideoPlayerSurface for the active URL, codec/network error
  UX with "Open in default player" fallback)
- Updated: NowPlayingBar + GlobalFullscreenOverlay to render
  VideoPlayerSurface directly (drops the videoFrame ImageBitmap relay)
- Main.kt: drops vlcj pre-init / shutdown calls (kdroidFilter lazy-loads
  natives + registers its own shutdown hook on Windows)

Build / packaging:
- Removes ir.mahozad.vlc-setup plugin + vlcSetup{} block + the per-OS
  bundled VLC tree + the -Dvlc.plugin.path JVM arg
- Adds NOTICE.md + per-component LICENSE-*.txt under appResources/common
  (LGPL-2.1 license text is a placeholder — replace with verbatim FSF
  text before release)
- Adds per-OS LGPL FFmpeg drop-in directories with README pointing at
  the recommended LGPL binary source (osxexperts.net / Crigges Windows
  LGPL build)
- Adds Flathub manifest skeleton (Gitnuro-style: org.freedesktop.Platform
  24.08 + openjdk21 extension + org.freedesktop.Platform.ffmpeg-full
  add-extension for patent codecs)
- AppRun: drops VLC LD_LIBRARY_PATH / VLC_PLUGIN_PATH env wiring

Verified on macOS arm64:
- ./gradlew :desktopApp:compileKotlin                BUILD SUCCESSFUL
- ./gradlew :desktopApp:test                         BUILD SUCCESSFUL
- ./gradlew :desktopApp:spotlessApply                clean
- Smoke launch: no VLC/vlcj/libvlc log lines, kdroidFilter native
  library extracts to ~/.cache/composemediaplayer/native/, thumbnail
  cache populates at ~/.cache/amethyst-desktop/video-thumbs/ with the
  4 MiB cap enforced
- H.264 MP4 playback (active + thumbnail extraction) confirmed
- VP9-in-WebM playback fails on macOS as AVFoundation cannot decode it —
  expected codec gap; surfaced via PlaybackErrorMessage + "Open in
  default player" handoff in DesktopVideoPlayer

Docs:
- docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
- docs/plans/2026-06-11-vlcj-replacement-testing-sheet.md
This commit is contained in:
nrobi144
2026-06-11 13:37:33 +03:00
parent f05500792c
commit 704f4f44ee
24 changed files with 1906 additions and 1068 deletions

View File

@@ -1,4 +1,3 @@
import de.undercouch.gradle.tasks.download.Download
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
import java.nio.file.Files
@@ -6,7 +5,6 @@ plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.jetbrainsComposeCompiler)
id("ir.mahozad.vlc-setup") version "0.1.0"
}
// RPM rejects dashes in version strings — replace with tilde (~) which RPM uses
@@ -57,8 +55,14 @@ dependencies {
implementation(libs.coil.okhttp)
implementation(libs.coil.svg)
// Video playback
implementation(libs.vlcj)
// Video / audio playback — MIT, OS-native backends (MF / AVFoundation / GStreamer)
implementation(libs.composemediaplayer)
// Thumbnail extraction — JCodec (pure-Java H.264). LGPL FFmpeg subprocess
// for non-H.264 / HLS fallback is invoked via plain ProcessBuilder; no
// wrapper library needed (see VideoThumbnailCache.runFfmpegToImage).
implementation(libs.jcodec)
implementation(libs.jcodec.javase)
// EXIF stripping (lossless)
implementation(libs.commons.imaging)
@@ -94,9 +98,6 @@ compose.desktop {
jvmArgs += "-Xmx2g"
// VLC plugin path fallback — used if JNA setenv and bundled discovery both fail
jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"
// Forward platform-preview overrides from the gradle invocation to the
// launched app's JVM so `./gradlew :desktopApp:run -Damethyst.platform=GNOME`
// works in addition to the env-var form (`AMETHYST_PLATFORM=GNOME`).
@@ -114,7 +115,7 @@ compose.desktop {
"java.prefs", // java.util.prefs (desktop persistence)
"java.sql", // JDBC metadata (Jackson, SQLite driver)
"jdk.security.auth", // JAAS authentication callbacks
"jdk.unsupported", // sun.misc.Unsafe (VLCJ ByteBufferFactory)
"jdk.unsupported", // sun.misc.Unsafe (secp256k1-kmp-jni-jvm, JNA)
)
packageName = "Amethyst"
@@ -138,7 +139,13 @@ compose.desktop {
menuGroup = "Network"
appCategory = "Network"
debMaintainer = "vitor@vitorpamplona.com"
rpmLicenseType = "MIT"
// SPDX compound expression. Bundled components:
// MIT — Amethyst + kdroidFilter ComposeMediaPlayer
// LGPL-2.1-or-later — FFmpeg (LGPL build, bundled per OS for thumbnail fallback) +
// GStreamer (Linux runtime dep, system-installed)
// BSD-2-Clause — JCodec
// Apache-2.0 — Jaffree + many transitive Java libraries
rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0"
// RPM version: replace dashes with tilde (1.08.0~rc1 < 1.08.0 per RPM ordering).
rpmPackageVersion = appVersion.replace("-", "~")
}
@@ -149,8 +156,8 @@ compose.desktop {
// problems with `-dontobfuscate` plus global `-keepnames` / `-keep enum`
// rules (see `amethyst/proguard-rules.pro`). We mirror that strategy in
// `compose-rules.pro` so the desktop release survives JNI callbacks
// (secp256k1-kmp, sqlite-bundled, jkeychain, VLCj) and reflection-heavy
// libraries (Jackson, JNA) without renaming.
// (secp256k1-kmp, sqlite-bundled, jkeychain, kdroidFilter native)
// and reflection-heavy libraries (Jackson, JNA) without renaming.
//
// Shrink and optimize stay ON. One ProGuard optimize sub-pass is
// disabled in `compose-rules.pro` to avoid a generated okio bridge
@@ -163,61 +170,23 @@ compose.desktop {
}
}
vlcSetup {
// Pinned to 3.0.20 because the Linux VLC plugins on Maven Central
// (ir.mahozad:vlc-plugins-linux) have not been republished for 3.0.21 — the
// latest there is 3.0.20-2. Using 3.0.21 makes vlcDownload 404 on Linux CI.
vlcVersion.set("3.0.20")
shouldCompressVlcFiles.set(true)
shouldIncludeAllVlcFiles.set(true)
pathToCopyVlcLinuxFilesTo.set(file("src/jvmMain/appResources/linux/vlc"))
pathToCopyVlcMacosFilesTo.set(file("src/jvmMain/appResources/macos/vlc"))
pathToCopyVlcWindowsFilesTo.set(file("src/jvmMain/appResources/windows/vlc"))
}
tasks.named("spotlessKotlin") {
mustRunAfter("vlcSetup")
}
// `ir.mahozad.vlc-setup` registers `vlcDownload` / `upxDownload` tasks that
// extend `de.undercouch.gradle.tasks.download.Download`. Defaults are 0 retries
// and a short read timeout, so a transient blip on get.videolan.org fails the
// whole desktop build on CI (Windows MSI, macOS DMG, Linux DEB). Configure all
// Download tasks in this project to retry with generous timeouts so flaky
// network conditions do not break packaging jobs.
tasks.withType<Download>().configureEach {
// 5 attempts total (initial + 4 retries) before failing the task.
retries(4)
// 30s to establish a TCP / TLS connection.
connectTimeout(30_000)
// 5 minutes per attempt for the body — VLC archives are 40-90 MB and
// get.videolan.org can be slow under load.
readTimeout(5 * 60_000)
// Stage to a temp file and rename only on full success, so a partial
// download from one attempt cannot poison the next.
tempAndMove(true)
}
// --- AppImage packaging (Linux) ---
//
// Compose Multiplatform's TargetFormat.AppImage is known-broken in 1.10.x (CMP-7101).
// Instead: wrap `createReleaseDistributable` output with `appimagetool`, which
// just packages an AppDir as-is. We deliberately avoid `linuxdeploy` here —
// linuxdeploy auto-walks every binary in the AppDir with ldd to bundle deps,
// but jpackage already ships a self-contained tree we don't want it touching:
// - The bundled JRE puts libjvm.so under usr/lib/runtime/lib/server/ while
// sibling libs (libmanagement.so, libawt_xawt.so, libfontmanager.so) have
// RPATH=$ORIGIN, so ldd cannot resolve libjvm.so without help.
// - The bundled VLC plugins are UPX-compressed; linuxdeploy aborts on those
// with "patchelf: no section headers" because they look like static ELFs.
// - Several VLC libs have RUNPATH that does not point at sibling libs in
// the same directory, so ldd errors with "Could not find dependency".
// appimagetool sidesteps all of this — it only embeds the AppDir into a
// SquashFS, runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH
// at launch.
// but jpackage already ships a self-contained tree we don't want it touching
// (the bundled JRE has libjvm.so under usr/lib/runtime/lib/server/ while sibling
// libs use $ORIGIN RPATH — ldd can't resolve without help).
// appimagetool sidesteps that — it only embeds the AppDir into a SquashFS,
// runtime-prepended, signed AppImage. AppRun handles LD_LIBRARY_PATH at launch.
//
// kdroidFilter (video/audio) links against system GStreamer at runtime — the
// AppImage does not bundle GStreamer; the host system must have it installed.
//
// Build inputs live in desktopApp/packaging/appimage/:
// - AppRun shell launcher (sets LD_LIBRARY_PATH including bundled VLC)
// - AppRun shell launcher
// - amethyst.desktop XDG desktop entry
// - amethyst.png 512x512 icon
//

View File

@@ -1,11 +1,13 @@
#!/bin/bash
# AppImage launcher for Amethyst Desktop.
# Sets LD_LIBRARY_PATH so vlcj finds bundled libvlc.so at runtime.
# jpackage puts app resources at usr/lib/app/<platform>/vlc/ inside the AppDir.
# kdroidFilter ComposeMediaPlayer uses the host system's GStreamer (linked at
# runtime). Users need:
# sudo apt install gstreamer1.0-plugins-base gstreamer1.0-plugins-good \
# gstreamer1.0-plugins-bad gstreamer1.0-libav
# (Equivalent packages on Fedora/Arch.)
set -eu
HERE="$(dirname "$(readlink -f "${0}")")"
export LD_LIBRARY_PATH="${HERE}/usr/lib/app/linux/vlc:${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export VLC_PLUGIN_PATH="${HERE}/usr/lib/app/linux/vlc/plugins"
export LD_LIBRARY_PATH="${HERE}/usr/lib:${HERE}/usr/lib/x86_64-linux-gnu:${LD_LIBRARY_PATH:-}"
export PATH="${HERE}/usr/bin:${PATH}"
export APPDIR="${HERE}"
exec "${HERE}/usr/bin/Amethyst" "$@"

View File

@@ -0,0 +1,54 @@
# Flathub packaging for Amethyst Desktop
This directory contains the Flatpak manifest and associated metadata for
publishing Amethyst Desktop on Flathub.
## Files
- `com.vitorpamplona.amethyst.Desktop.yml` — Flatpak manifest
- `com.vitorpamplona.amethyst.Desktop.metainfo.xml` — AppStream metadata
(categories, license, screenshots — needs screenshots added before
submission)
- `com.vitorpamplona.amethyst.Desktop.desktop` — XDG desktop entry
- `icons/256/com.vitorpamplona.amethyst.Desktop.png` — TODO: copy a 256x256
PNG icon from `desktopApp/src/jvmMain/resources/icon.png` before
submission
## Build prerequisites
- `./gradlew :desktopApp:createReleaseDistributable` — produces
`desktopApp/build/compose/binaries/main-release/app/Amethyst/`
- `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08`
## Local build
```bash
cd desktopApp/packaging/flatpak
flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml
flatpak run com.vitorpamplona.amethyst.Desktop
```
## Submission to Flathub
Follow https://docs.flathub.org/docs/for-app-authors/submission
1. Fork `flathub/flathub` on GitHub.
2. Branch from `new-pr` (NOT `master`).
3. Copy this manifest + AppStream + desktop into a new directory matching
the app id.
4. Open a PR titled "Add com.vitorpamplona.amethyst.Desktop".
5. After merge, a per-app repo is created with write access for ongoing
updates.
## Codec coverage
- HEVC / VP9 / AV1: covered via `org.freedesktop.Platform.ffmpeg-full`
add-extension declared in the manifest. Flatpak downloads it on install.
- HLS, H.264, AAC, MP3, Opus: covered by the GStreamer plugin set in
`org.freedesktop.Platform 24.08` itself.
## License metadata
The manifest declares the binary as
`MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0`. This SPDX
expression validates via `appstreamcli validate`.

View File

@@ -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

View File

@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="desktop-application">
<id>com.vitorpamplona.amethyst.Desktop</id>
<metadata_license>CC0-1.0</metadata_license>
<project_license>MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0</project_license>
<name>Amethyst Desktop</name>
<summary>Nostr client for desktop</summary>
<description>
<p>
Amethyst Desktop is a desktop client for the Nostr protocol.
Browse feeds, post notes, send zaps (Lightning Network), and
participate in NIP-53 live audio rooms.
</p>
<p>
Video playback uses your system's GStreamer (Linux), AVFoundation
(macOS), or Media Foundation (Windows). On Linux, install the
GStreamer plugin packages (good, bad, libav) for full codec coverage.
</p>
</description>
<launchable type="desktop-id">com.vitorpamplona.amethyst.Desktop.desktop</launchable>
<url type="homepage">https://github.com/vitorpamplona/amethyst</url>
<url type="bugtracker">https://github.com/vitorpamplona/amethyst/issues</url>
<url type="vcs-browser">https://github.com/vitorpamplona/amethyst</url>
<developer id="com.vitorpamplona">
<name>Vitor Pamplona</name>
</developer>
<content_rating type="oars-1.1" />
<categories>
<category>Network</category>
<category>InstantMessaging</category>
</categories>
<screenshots>
<!-- Add at least one screenshot tagged with `<image>...</image>`
before Flathub submission. Suggested 1280x800 PNG. -->
</screenshots>
<releases>
<!-- Populated by release CI. Example:
<release version="0.0.0" date="2026-06-11">
<description>
<p>Initial Flathub release with kdroidFilter video playback.</p>
</description>
</release>
-->
</releases>
</component>

View File

@@ -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

View File

@@ -0,0 +1,51 @@
# Amethyst Desktop — Third-party Notices
Amethyst Desktop is distributed under the MIT License (see `LICENSE-MIT-amethyst.txt`).
The distributed binary includes the following third-party components:
## kdroidFilter ComposeMediaPlayer
- License: MIT
- Version: 0.10.1
- Upstream: https://github.com/kdroidFilter/ComposeMediaPlayer
- License text: `licenses/LICENSE-MIT-kdroidfilter.txt`
## JCodec
- License: BSD 2-Clause
- Version: 0.2.5
- Upstream: https://github.com/jcodec/jcodec
- License text: `licenses/LICENSE-BSD-2-jcodec.txt`
## FFmpeg (LGPL build, bundled per OS for thumbnail extraction)
- License: LGPL-2.1-or-later
- Build: LGPL-only configuration (no `--enable-gpl`, no `--enable-nonfree`)
- Upstream: https://ffmpeg.org/
- License text: `licenses/LICENSE-LGPL-2.1.txt`
- Source availability: https://github.com/vitorpamplona/amethyst (build tag matches
the binary tag); FFmpeg sources at https://github.com/FFmpeg/FFmpeg
- Per-OS binary source provenance documented in
`desktopApp/src/jvmMain/appResources/<os>/ffmpeg/README.md`.
## GStreamer (Linux runtime dependency; not bundled)
- License: LGPL-2.1-or-later (core + linked plugins)
- Required at runtime by ComposeMediaPlayer on Linux (linked via `pkg-config`).
- License text: `licenses/LICENSE-LGPL-2.1.txt`
- Users install via their distribution's package manager. We bundle no
GStreamer binaries.
## Other Apache-2.0 / MIT transitive dependencies
Compose Multiplatform (JetBrains, Apache-2.0), Kotlin stdlib (JetBrains,
Apache-2.0), OkHttp (Square, Apache-2.0), Jackson (FasterXML, Apache-2.0),
Coil (Apache-2.0), kmp-tor (MIT), and others, plus the bundled OpenJDK
runtime (GPLv2 + Classpath Exception). Their license texts are reproduced
under `licenses/` alongside this NOTICE.
## SPDX combined expression
```
MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0
```

View File

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

View File

@@ -0,0 +1,23 @@
This file should contain the verbatim GNU LGPL-2.1 text from
https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt
The full text is 26 KB. Embed at packaging time (e.g. via a Gradle task
that fetches the canonical text), or paste it here verbatim before
releasing a build. Until populated, the binary release is non-compliant
with LGPL §6 obligations for the bundled FFmpeg binaries and Linux
GStreamer runtime dependency.
Tracking: see plan docs/plans/2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md
"Phase 0 acceptance" — license text presence is part of the gate.
Bundled / linked LGPL components:
- FFmpeg (LGPL build) — bundled per OS in appResources/<os>/ffmpeg/
- GStreamer 1.x core + plugins-base/good/bad/libav — linked on Linux at runtime
(user-installed, not bundled)
Written offer (LGPL §6 / §4): Source for both FFmpeg and GStreamer is available
from the projects' canonical git repositories (https://ffmpeg.org/download.html,
https://gstreamer.freedesktop.org/src/). The binary tags shipped in any
Amethyst Desktop release correspond to released upstream tags; this offer is
valid for three years from the date of distribution to anyone in receipt of
this binary.

View File

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

View File

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

View File

@@ -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(

View File

@@ -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
}

View File

@@ -20,31 +20,20 @@
*/
package com.vitorpamplona.amethyst.desktop.service.media
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.runtime.snapshotFlow
import com.vitorpamplona.amethyst.desktop.ui.media.MediaType
import io.github.kdroidfilter.composemediaplayer.VideoPlayerError
import io.github.kdroidfilter.composemediaplayer.VideoPlayerState
import io.github.kdroidfilter.composemediaplayer.createVideoPlayerState
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import org.jetbrains.skia.Image as SkiaImage
data class MediaPlaybackState(
val url: String? = null,
@@ -57,221 +46,178 @@ data class MediaPlaybackState(
val aspectRatio: Float = 16f / 9f,
val volume: Int = 100,
val isMuted: Boolean = false,
/** Last observed error from the engine, or null. JVM emits only SourceError/UnknownError. */
val errorReason: String? = null,
)
/**
* Singleton facade over kdroidFilter's [VideoPlayerState] (MIT, OS-native backends:
* Media Foundation on Windows, AVFoundation on macOS, GStreamer on Linux).
*
* Holds two engine instances (video + audio) for the lifetime of the JVM. The
* underlying [VideoPlayerState] exposes its state via Compose `mutableStateOf`;
* a [snapshotFlow] coroutine mirrors that into our public [MediaPlaybackState]
* `StateFlow`s so non-Compose consumers (and the existing UI) remain unchanged.
*
* UI surface for visible video frames is rendered by mounting
* `VideoPlayerSurface(playerState = [activeVideoPlayerState])` in the active
* `DesktopVideoPlayer` instance — see that file for the active/inactive dispatch.
*/
object GlobalMediaPlayer {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
// Video state
private val _videoFrame = MutableStateFlow<ImageBitmap?>(null)
val videoFrame: StateFlow<ImageBitmap?> = _videoFrame.asStateFlow()
// Engine handles. Constructed lazily on first playback request so that app
// start does not load the native library if the user never plays media.
@Volatile private var videoPlayer: VideoPlayerState? = null
@Volatile private var audioPlayer: VideoPlayerState? = null
private val initLock = Any()
/**
* The kdroidFilter player driving currently-active or last-played video.
* Mounted into a `VideoPlayerSurface(...)` by [DesktopVideoPlayer] when the
* caller's `url` matches `videoState.value.url`.
*
* Lazy: first read constructs the underlying native player.
*/
val activeVideoPlayerState: VideoPlayerState
get() = ensureVideoPlayer()
private val _videoState = MutableStateFlow(MediaPlaybackState())
val videoState: StateFlow<MediaPlaybackState> = _videoState.asStateFlow()
// Audio state
private val _audioState = MutableStateFlow(MediaPlaybackState(type = MediaType.AUDIO))
val audioState: StateFlow<MediaPlaybackState> = _audioState.asStateFlow()
// Fullscreen
private val _isFullscreen = MutableStateFlow(false)
val isFullscreen: StateFlow<Boolean> = _isFullscreen.asStateFlow()
// VLC players — kept alive between plays
private var videoPlayer: EmbeddedMediaPlayer? = null
private var audioPlayer: MediaPlayer? = null
// Stashed pre-mute volume (0..100). kdroidFilter has no isMuted concept, so
// we emulate by zeroing volume and remembering the prior value.
private var preMuteVideoVolume: Int = 100
private var preMuteAudioVolume: Int = 100
// Skia bitmap for video rendering
private var skBitmap: Bitmap? = null
private var pixelBytes: ByteArray? = null
private var videoSyncJob: Job? = null
private var audioSyncJob: Job? = null
// Position polling job
private var videoPollingJob: Job? = null
private var audioPollingJob: Job? = null
// --- Verbs ---------------------------------------------------------------
fun playVideo(
url: String,
seekPosition: Float = 0f,
) {
// If already playing this URL, just seek
val current = _videoState.value
if (current.url == url && videoPlayer != null) {
if (seekPosition > 0f) {
videoPlayer?.controls()?.setPosition(seekPosition)
}
if (!current.isPlaying) {
videoPlayer?.controls()?.play()
}
val player = ensureVideoPlayer()
if (current.url == url) {
if (seekPosition > 0f) player.seekTo(seekPosition * 1000f)
if (!current.isPlaying) player.play()
return
}
// Stop current video if different URL
if (current.url != null && current.url != url) {
videoPlayer?.controls()?.stop()
}
_videoState.value =
MediaPlaybackState(
url = url,
type = MediaType.VIDEO,
isBuffering = true,
)
_videoState.value = MediaPlaybackState(url = url, type = MediaType.VIDEO, isBuffering = true)
scope.launch(Dispatchers.IO) {
if (!VlcjPlayerPool.init()) {
_videoState.value = _videoState.value.copy(isBuffering = false)
return@launch
}
val player =
videoPlayer ?: VlcjPlayerPool.acquire() ?: run {
_videoState.value = _videoState.value.copy(isBuffering = false)
return@launch
}
// Only set up surface on first acquisition
if (videoPlayer == null) {
setupVideoSurface(player)
setupVideoEventListener(player)
videoPlayer = player
}
var didSeek = seekPosition <= 0f
// Temporary listener for initial seek
if (!didSeek) {
val seekListener =
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
if (!didSeek) {
didSeek = true
mediaPlayer.controls().setPosition(seekPosition)
mediaPlayer.events().removeMediaPlayerEventListener(this)
}
player.openUri(url)
// openUri auto-plays per InitialPlayerState.PLAY default.
// For an initial seek we wait for hasMedia=true; cleanest is a
// one-shot snapshotFlow collector that seeks then completes.
if (seekPosition > 0f) {
snapshotFlow { player.hasMedia }
.collect { ready ->
if (ready) {
player.seekTo(seekPosition * 1000f)
return@collect
}
}
player.events().addMediaPlayerEventListener(seekListener)
}
val vol = _videoState.value.volume
player.media().play(url, ":start-volume=$vol")
startVideoPolling()
}
}
fun playAudio(url: String) {
val current = _audioState.value
if (current.url == url && audioPlayer != null) {
if (!current.isPlaying) {
audioPlayer?.controls()?.play()
}
val player = ensureAudioPlayer()
if (current.url == url) {
if (!current.isPlaying) player.play()
return
}
if (current.url != null && current.url != url) {
audioPlayer?.controls()?.stop()
}
_audioState.value =
MediaPlaybackState(
url = url,
type = MediaType.AUDIO,
isBuffering = true,
)
_audioState.value = MediaPlaybackState(url = url, type = MediaType.AUDIO, isBuffering = true)
scope.launch(Dispatchers.IO) {
val player =
audioPlayer ?: VlcjPlayerPool.acquireAudioPlayer() ?: run {
_audioState.value = _audioState.value.copy(isBuffering = false)
return@launch
}
if (audioPlayer == null) {
setupAudioEventListener(player)
audioPlayer = player
}
val vol = _audioState.value.volume
player.media().play(url, ":start-volume=$vol")
startAudioPolling()
player.openUri(url)
}
}
fun toggleVideoPlayPause() {
val player = videoPlayer ?: return
val state = _videoState.value
if (state.url == null) return
if (state.isPlaying) {
player.controls().pause()
} else {
if (state.position <= 0f && !player.status().isPlaying) {
state.url.let { player.media().play(it) }
} else {
player.controls().play()
}
}
if (_videoState.value.isPlaying) player.pause() else player.play()
}
fun toggleAudioPlayPause() {
val player = audioPlayer ?: return
val state = _audioState.value
if (state.url == null) return
if (state.isPlaying) {
player.controls().pause()
} else {
if (state.position <= 0f && !player.status().isPlaying) {
state.url.let { player.media().play(it) }
} else {
player.controls().play()
}
}
if (_audioState.value.isPlaying) player.pause() else player.play()
}
/** UI passes position in 0..1. kdroidFilter wants 0..1000. */
fun seekVideo(position: Float) {
videoPlayer?.controls()?.setPosition(position)
videoPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f))
}
fun seekAudio(position: Float) {
audioPlayer?.controls()?.setPosition(position)
audioPlayer?.seekTo((position * 1000f).coerceIn(0f, 1000f))
}
/** UI passes volume in 0..100. kdroidFilter wants 0..1. */
fun setVideoVolume(volume: Int) {
videoPlayer?.audio()?.setVolume(volume)
_videoState.value = _videoState.value.copy(volume = volume)
videoPlayer?.volume = volume.coerceIn(0, 100) / 100f
val muted = _videoState.value.isMuted && volume == 0
_videoState.value = _videoState.value.copy(volume = volume, isMuted = muted)
if (volume > 0) preMuteVideoVolume = volume
}
fun setAudioVolume(volume: Int) {
audioPlayer?.audio()?.setVolume(volume)
_audioState.value = _audioState.value.copy(volume = volume)
audioPlayer?.volume = volume.coerceIn(0, 100) / 100f
val muted = _audioState.value.isMuted && volume == 0
_audioState.value = _audioState.value.copy(volume = volume, isMuted = muted)
if (volume > 0) preMuteAudioVolume = volume
}
/** kdroidFilter has no mute concept — emulate by stashing volume. */
fun toggleVideoMute() {
val muted = !_videoState.value.isMuted
videoPlayer?.audio()?.isMute = muted
_videoState.value = _videoState.value.copy(isMuted = muted)
val state = _videoState.value
if (state.isMuted) {
setVideoVolume(preMuteVideoVolume)
_videoState.value = _videoState.value.copy(isMuted = false)
} else {
preMuteVideoVolume = state.volume.coerceAtLeast(1)
videoPlayer?.volume = 0f
_videoState.value = _videoState.value.copy(isMuted = true, volume = 0)
}
}
fun toggleAudioMute() {
val muted = !_audioState.value.isMuted
audioPlayer?.audio()?.isMute = muted
_audioState.value = _audioState.value.copy(isMuted = muted)
val state = _audioState.value
if (state.isMuted) {
setAudioVolume(preMuteAudioVolume)
_audioState.value = _audioState.value.copy(isMuted = false)
} else {
preMuteAudioVolume = state.volume.coerceAtLeast(1)
audioPlayer?.volume = 0f
_audioState.value = _audioState.value.copy(isMuted = true, volume = 0)
}
}
fun stopVideo() {
videoPollingJob?.cancel()
videoPollingJob = null
videoPlayer?.controls()?.stop()
videoPlayer?.stop()
_videoState.value = MediaPlaybackState()
_videoFrame.value = null
_isFullscreen.value = false
}
fun stopAudio() {
audioPollingJob?.cancel()
audioPollingJob = null
audioPlayer?.controls()?.stop()
audioPlayer?.stop()
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
}
@@ -283,215 +229,126 @@ object GlobalMediaPlayer {
_isFullscreen.value = false
}
/** Call on app exit. Disposes native handles owned by kdroidFilter. */
fun shutdown() {
videoPollingJob?.cancel()
audioPollingJob?.cancel()
videoPlayer?.let { p ->
try {
p.controls().stop()
} catch (_: Exception) {
}
VlcjPlayerPool.release(p)
}
videoSyncJob?.cancel()
audioSyncJob?.cancel()
runCatching { videoPlayer?.stop() }
runCatching { videoPlayer?.dispose() }
runCatching { audioPlayer?.stop() }
runCatching { audioPlayer?.dispose() }
videoPlayer = null
audioPlayer?.let { p ->
try {
p.controls().stop()
} catch (_: Exception) {
}
VlcjPlayerPool.releaseAudioPlayer(p)
}
audioPlayer = null
_videoState.value = MediaPlaybackState()
_audioState.value = MediaPlaybackState(type = MediaType.AUDIO)
_videoFrame.value = null
_isFullscreen.value = false
scope.cancel()
}
private fun setupVideoSurface(player: EmbeddedMediaPlayer) {
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): BufferFormat {
if (sourceHeight > 0) {
_videoState.value =
_videoState.value.copy(
aspectRatio = sourceWidth.toFloat() / sourceHeight.toFloat(),
)
}
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(sourceWidth, sourceHeight, ColorAlphaType.PREMUL))
skBitmap = bmp
pixelBytes = ByteArray(sourceWidth * sourceHeight * 4)
return RV32BufferFormat(sourceWidth, sourceHeight)
}
// --- Engine lifecycle ----------------------------------------------------
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
private fun ensureVideoPlayer(): VideoPlayerState =
videoPlayer ?: synchronized(initLock) {
videoPlayer ?: createVideoPlayerState().also {
videoPlayer = it
startVideoSync(it)
}
}
val renderCallback =
RenderCallback { _, nativeBuffers, _ ->
val bmp = skBitmap ?: return@RenderCallback
val bytes = pixelBytes ?: return@RenderCallback
val buffer = nativeBuffers[0]
buffer.rewind()
buffer.get(bytes)
bmp.installPixels(bytes)
_videoFrame.value = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
private fun ensureAudioPlayer(): VideoPlayerState =
audioPlayer ?: synchronized(initLock) {
audioPlayer ?: createVideoPlayerState().also {
audioPlayer = it
startAudioSync(it)
}
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
player.videoSurface().set(surface)
}
private fun setupVideoEventListener(player: EmbeddedMediaPlayer) {
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
val state = _videoState.value
_videoState.value =
state.copy(
isPlaying = true,
isBuffering = false,
duration = mediaPlayer.status().length(),
)
}
override fun paused(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isPlaying = false)
}
override fun stopped(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isPlaying = false, isBuffering = false)
}
override fun buffering(
mediaPlayer: MediaPlayer,
newCache: Float,
) {
_videoState.value = _videoState.value.copy(isBuffering = newCache < 100f)
}
override fun positionChanged(
mediaPlayer: MediaPlayer,
newPosition: Float,
) {
_videoState.value =
_videoState.value.copy(
position = newPosition,
currentTime = (newPosition * _videoState.value.duration).toLong(),
)
}
override fun finished(mediaPlayer: MediaPlayer) {
_videoState.value =
_videoState.value.copy(
isPlaying = false,
isBuffering = false,
position = 0f,
currentTime = 0L,
)
}
override fun error(mediaPlayer: MediaPlayer) {
_videoState.value = _videoState.value.copy(isBuffering = false)
println("VLC: playback error for ${_videoState.value.url}")
}
},
)
}
private fun setupAudioEventListener(player: MediaPlayer) {
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun playing(mediaPlayer: MediaPlayer) {
_audioState.value =
_audioState.value.copy(
isPlaying = true,
isBuffering = false,
duration = mediaPlayer.status().length(),
)
}
override fun paused(mediaPlayer: MediaPlayer) {
_audioState.value = _audioState.value.copy(isPlaying = false)
}
override fun stopped(mediaPlayer: MediaPlayer) {
_audioState.value = _audioState.value.copy(isPlaying = false, isBuffering = false)
}
override fun positionChanged(
mediaPlayer: MediaPlayer,
newPosition: Float,
) {
_audioState.value =
_audioState.value.copy(
position = newPosition,
currentTime = (newPosition * _audioState.value.duration).toLong(),
)
}
override fun finished(mediaPlayer: MediaPlayer) {
_audioState.value =
_audioState.value.copy(
isPlaying = false,
position = 0f,
currentTime = 0L,
)
}
},
)
}
private fun startVideoPolling() {
videoPollingJob?.cancel()
videoPollingJob =
private fun startVideoSync(player: VideoPlayerState) {
videoSyncJob?.cancel()
videoSyncJob =
scope.launch {
while (true) {
delay(500)
val player = videoPlayer ?: break
val state = _videoState.value
if (state.isPlaying) {
try {
_videoState.value =
state.copy(
position = player.status().position(),
currentTime = player.status().time(),
)
} catch (_: Exception) {
snapshotFlow {
EngineSnapshot(
isPlaying = player.isPlaying,
isLoading = player.isLoading,
hasMedia = player.hasMedia,
currentTime = player.currentTime,
duration = player.duration,
aspectRatio = player.aspectRatio,
errorMessage = player.error?.let(::describeError),
)
}.collect { snap ->
val current = _videoState.value
val posFraction =
if (snap.duration > 0.0) {
(snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f)
} else {
current.position
}
}
_videoState.value =
current.copy(
isPlaying = snap.isPlaying,
isBuffering = snap.isLoading,
duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L),
currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L),
position = posFraction,
aspectRatio = if (snap.aspectRatio > 0f) snap.aspectRatio else current.aspectRatio,
errorReason = snap.errorMessage,
)
}
}
}
private fun startAudioPolling() {
audioPollingJob?.cancel()
audioPollingJob =
private fun startAudioSync(player: VideoPlayerState) {
audioSyncJob?.cancel()
audioSyncJob =
scope.launch {
while (true) {
delay(500)
val player = audioPlayer ?: break
val state = _audioState.value
if (state.isPlaying) {
try {
_audioState.value =
state.copy(
position = player.status().position(),
currentTime = player.status().time(),
)
} catch (_: Exception) {
snapshotFlow {
EngineSnapshot(
isPlaying = player.isPlaying,
isLoading = player.isLoading,
hasMedia = player.hasMedia,
currentTime = player.currentTime,
duration = player.duration,
aspectRatio = player.aspectRatio,
errorMessage = player.error?.let(::describeError),
)
}.collect { snap ->
val current = _audioState.value
val posFraction =
if (snap.duration > 0.0) {
(snap.currentTime / snap.duration).toFloat().coerceIn(0f, 1f)
} else {
current.position
}
}
_audioState.value =
current.copy(
isPlaying = snap.isPlaying,
isBuffering = snap.isLoading,
duration = (snap.duration * 1000.0).toLong().coerceAtLeast(0L),
currentTime = (snap.currentTime * 1000.0).toLong().coerceAtLeast(0L),
position = posFraction,
errorReason = snap.errorMessage,
)
}
}
}
private fun describeError(error: VideoPlayerError): String =
when (error) {
is VideoPlayerError.CodecError -> "Codec: ${error.message}"
is VideoPlayerError.NetworkError -> "Network: ${error.message}"
is VideoPlayerError.SourceError -> "Source: ${error.message}"
is VideoPlayerError.UnknownError -> error.message
}
private data class EngineSnapshot(
val isPlaying: Boolean,
val isLoading: Boolean,
val hasMedia: Boolean,
val currentTime: Double,
val duration: Double,
val aspectRatio: Float,
val errorMessage: String?,
)
}

View File

@@ -1,81 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.media
import com.sun.jna.Function
import com.sun.jna.NativeLibrary
import uk.co.caprica.vlcj.binding.support.runtime.RuntimeUtil
import uk.co.caprica.vlcj.factory.discovery.strategy.BaseNativeDiscoveryStrategy
/**
* Discovers bundled VLC libraries on macOS.
* Must force-load libvlccore before libvlc to avoid link errors.
* Uses [VlcResourceResolver] to find the VLC directory from the Compose application
* resources or development fallback paths.
*/
class MacOsVlcDiscoverer :
BaseNativeDiscoveryStrategy(
arrayOf("libvlc\\.dylib", "libvlccore\\.dylib"),
arrayOf("%s/plugins"),
) {
/** Plugin path discovered during [setPluginPath], available after discovery. */
var discoveredPluginPath: String? = null
private set
/** Whether [setPluginPath] successfully set the process env var. */
var envVarSet: Boolean = false
private set
override fun supported(): Boolean {
val os = System.getProperty("os.name").lowercase()
return "mac" in os
}
override fun discoveryDirectories(): List<String> {
val vlcDir = VlcResourceResolver.findVlcDir() ?: return emptyList()
return listOf(vlcDir.absolutePath)
}
override fun onFound(path: String): Boolean {
NativeLibrary.addSearchPath(RuntimeUtil.getLibVlcCoreLibraryName(), path)
NativeLibrary.getInstance(RuntimeUtil.getLibVlcCoreLibraryName())
return true
}
override fun setPluginPath(pluginPath: String?): Boolean {
if (pluginPath == null) return false
discoveredPluginPath = pluginPath
return try {
// Call setenv directly via JNA Function API. This bypasses vlcj's
// LibC interface binding which fails on macOS 13+ because dlsym
// can't resolve the versioned symbol `setenv$3b99ba0d`.
val setenv = Function.getFunction("c", "setenv")
val result = setenv.invokeInt(arrayOf<Any>(PLUGIN_ENV_NAME, pluginPath, 1)) == 0
envVarSet = result
result
} catch (_: Throwable) {
// JNA Function call also failed — VlcjPlayerPool will use
// --plugin-path factory arg as fallback.
envVarSet = false
false
}
}
}

View File

@@ -24,24 +24,87 @@ import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.jetbrains.skia.Bitmap
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ImageInfo
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.base.MediaPlayerEventAdapter
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.format.RV32BufferFormat
import java.nio.ByteBuffer
import okhttp3.OkHttpClient
import okhttp3.Request
import org.jcodec.api.FrameGrab
import org.jcodec.common.io.NIOUtils
import org.jcodec.common.model.ColorSpace
import org.jcodec.common.model.Picture
import org.jcodec.scale.AWTUtil
import org.jcodec.scale.ColorUtil
import org.jetbrains.skia.Image
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Files
import java.security.MessageDigest
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.jetbrains.skia.Image as SkiaImage
import javax.imageio.ImageIO
/**
* Per-URL one-frame thumbnail extractor backing feed video posters.
*
* Cascade:
* 1. **JCodec** (`org.jcodec:jcodec` + `jcodec-javase`, BSD-2) — pure-Java
* H.264 baseline/main/high decode. Handles ~80% of Nostr feed media (MP4/H.264).
* 2. **Jaffree** (Apache-2) + **LGPL FFmpeg** subprocess — for everything else
* (HEVC, VP9, AV1, HLS, malformed faststart MP4s). Requires a bundled
* ffmpeg binary at `src/jvmMain/appResources/<os>/ffmpeg/ffmpeg(.exe)` or
* a system `ffmpeg` on `$PATH`.
*
* Replaces the prior vlcj `RenderCallback` path. License moves from
* GPL-3.0 (vlcj) to BSD-2 + Apache-2 + LGPL-2.1 native, MIT-dominant overall.
*/
object VideoThumbnailCache {
private const val MAX_THUMB_BYTES = 4 * 1024 * 1024 // 4 MiB cap per thumbnail
private val cache = ConcurrentHashMap<String, ImageBitmap>()
private val pending = ConcurrentHashMap<String, Boolean>()
private val http: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
private val downloadCacheDir: File by lazy {
val base =
File(System.getProperty("user.home"), ".cache/amethyst-desktop/video-thumbs")
.also { it.mkdirs() }
base
}
private val ffmpegBinary: String? by lazy {
// 1. System ffmpeg on PATH.
val onPath =
runCatching {
ProcessBuilder("ffmpeg", "-version")
.redirectErrorStream(true)
.start()
.also { it.inputStream.close() }
.waitFor(2, TimeUnit.SECONDS)
}.getOrDefault(false)
if (onPath) return@lazy "ffmpeg"
// 2. Bundled ffmpeg under appResources/<os>/ffmpeg/.
// jpackage drops appResources at <app>/lib/app/resources/ — equivalently,
// we can read from the working dir layout under desktopApp/src/jvmMain/appResources
// during `./gradlew :desktopApp:run`. Look for it in well-known locations.
val osName = System.getProperty("os.name").lowercase()
val isWin = "win" in osName
val binaryName = if (isWin) "ffmpeg.exe" else "ffmpeg"
val candidates =
listOf(
File(System.getProperty("compose.application.resources.dir") ?: "", "ffmpeg/$binaryName"),
File("desktopApp/src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"),
File("src/jvmMain/appResources/${osTag(osName)}/ffmpeg/$binaryName"),
)
candidates.firstOrNull { it.exists() && it.canExecute() }?.absolutePath
}
fun getCached(url: String): ImageBitmap? = cache[url]
suspend fun getThumbnail(url: String): ImageBitmap? {
@@ -58,82 +121,161 @@ object VideoThumbnailCache {
}
private fun extractFirstFrame(url: String): ImageBitmap? {
if (!VlcjPlayerPool.init()) {
println("VLC thumbnail: init failed for $url")
return null
}
val player = VlcjPlayerPool.acquireForThumbnail()
if (player == null) {
println("VLC thumbnail: pool exhausted for $url")
return null
}
// For HLS we skip straight to Jaffree — JCodec can't read m3u8.
val isHls = url.contains(".m3u8", ignoreCase = true) || url.contains("/hls/", ignoreCase = true)
var result: ImageBitmap? = null
val latch = CountDownLatch(1)
val bufferFormatCallback =
object : BufferFormatCallback {
override fun getBufferFormat(
sourceWidth: Int,
sourceHeight: Int,
): uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormat = RV32BufferFormat(sourceWidth, sourceHeight)
override fun allocatedBuffers(buffers: Array<out ByteBuffer>) {}
if (!isHls) {
val downloaded = runCatching { downloadFirstChunk(url) }.getOrNull()
if (downloaded != null) {
tryJCodec(downloaded)?.let { return it }
tryJaffreeFile(downloaded)?.let { return it }
}
val renderCallback =
RenderCallback { _, nativeBuffers, bufferFormat ->
if (result != null) return@RenderCallback
try {
if (nativeBuffers.isEmpty()) return@RenderCallback
val w = bufferFormat.width
val h = bufferFormat.height
if (w <= 0 || h <= 0) return@RenderCallback
val bmp = Bitmap()
bmp.allocPixels(ImageInfo.makeN32(w, h, ColorAlphaType.PREMUL))
val bytes = ByteArray(w * h * 4)
val buffer = nativeBuffers[0]
buffer.rewind()
buffer.get(bytes)
bmp.installPixels(bytes)
result = SkiaImage.makeFromBitmap(bmp).toComposeImageBitmap()
latch.countDown()
} catch (e: Exception) {
println("VLC thumbnail: render error for $url${e.message}")
latch.countDown()
}
}
val surface = VlcjPlayerPool.createVideoSurface(bufferFormatCallback, renderCallback)
if (surface == null) {
println("VLC thumbnail: surface creation failed for $url")
VlcjPlayerPool.release(player)
return null
}
player.videoSurface().set(surface)
player.audio().setVolume(0)
player.audio().isMute = true
player.events().addMediaPlayerEventListener(
object : MediaPlayerEventAdapter() {
override fun error(mediaPlayer: MediaPlayer) {
println("VLC thumbnail: playback error for $url")
latch.countDown()
}
},
)
player.media().play(url)
// Wait up to 8 seconds for first frame (network videos can be slow)
latch.await(8, TimeUnit.SECONDS)
if (result == null) {
println("VLC thumbnail: timed out or failed for $url")
}
VlcjPlayerPool.release(player)
return result
return tryJaffreeUrl(url)
}
/**
* Downloads up to [MAX_THUMB_BYTES] to a cache file, returning the file (or null on failure).
*
* Caps the copy regardless of whether the server honours `Range:` — some origins ignore it
* and serve a 200 with the full body, which would otherwise stream the entire video.
*
* Rejects responses whose `Content-Type` starts with `text/` (e.g. HTML error pages from
* broken origins) so we never persist non-video bytes into the cache.
*
* Cleans up zero-byte cache files on failure so a transient empty response isn't sticky.
*/
private fun downloadFirstChunk(url: String): File? {
val hash = sha1Hex(url)
val cached = File(downloadCacheDir, "$hash.mp4")
if (cached.length() > 0L) return cached
if (cached.exists()) cached.delete()
var wrote = false
http.newCall(buildRangeRequest(url)).execute().use { resp ->
if (!resp.isSuccessful && resp.code != 206) return null
val contentType = resp.header("Content-Type")?.lowercase().orEmpty()
if (contentType.startsWith("text/") || "html" in contentType) return null
Files.newOutputStream(cached.toPath()).use { out ->
val copied = copyAtMost(resp.body.byteStream(), out, MAX_THUMB_BYTES.toLong())
wrote = copied > 0L
}
}
if (!wrote || cached.length() == 0L) {
cached.delete()
return null
}
return cached
}
private fun buildRangeRequest(url: String): Request =
Request
.Builder()
.url(url)
.header("Range", "bytes=0-${MAX_THUMB_BYTES - 1}")
.header("User-Agent", "Amethyst-Desktop/thumbnail")
.build()
private fun copyAtMost(
src: java.io.InputStream,
dst: java.io.OutputStream,
limit: Long,
): Long {
val buf = ByteArray(64 * 1024)
var copied = 0L
while (copied < limit) {
val toRead = minOf(buf.size.toLong(), limit - copied).toInt()
val n = src.read(buf, 0, toRead)
if (n < 0) break
dst.write(buf, 0, n)
copied += n
}
return copied
}
private fun tryJCodec(mp4: File): ImageBitmap? =
runCatching {
NIOUtils.readableChannel(mp4).use { ch ->
val grab = FrameGrab.createFrameGrab(ch).seekToSecondSloppy(1.0)
val native: Picture = grab.nativeFrame ?: return null
val rgb = Picture.create(native.width, native.height, ColorSpace.RGB)
ColorUtil.getTransform(native.color, ColorSpace.RGB).transform(native, rgb)
bufferedImageToImageBitmap(AWTUtil.toBufferedImage(rgb))
}
}.getOrNull()
private fun tryJaffreeFile(file: File): ImageBitmap? = runFfmpegToImage(file.absolutePath)
private fun tryJaffreeUrl(url: String): ImageBitmap? = runFfmpegToImage(url)
/**
* Spawns `ffmpeg -ss 1 -i <input> -frames:v 1 -f image2pipe -c:v png -an pipe:1`,
* reads PNG bytes from stdout, decodes with Skia.
*
* Uses raw `ProcessBuilder` rather than the Jaffree DSL — fewer API guesses,
* easier to debug. Jaffree stays on the classpath as a future option.
*/
private fun runFfmpegToImage(input: String): ImageBitmap? {
val ffmpeg = ffmpegBinary ?: return null
val cmd =
listOf(
ffmpeg,
"-hide_banner",
"-loglevel",
"error",
"-ss",
"1",
"-i",
input,
"-frames:v",
"1",
"-an",
"-f",
"image2pipe",
"-c:v",
"png",
"pipe:1",
)
val process =
runCatching {
ProcessBuilder(cmd)
.redirectErrorStream(false)
.start()
}.getOrNull() ?: return null
val out = ByteArrayOutputStream(256 * 1024)
try {
process.inputStream.use { it.copyTo(out) }
if (!process.waitFor(8, TimeUnit.SECONDS)) {
process.destroyForcibly()
return null
}
if (process.exitValue() != 0 || out.size() == 0) return null
} catch (_: Exception) {
process.destroyForcibly()
return null
}
return runCatching {
Image.makeFromEncoded(out.toByteArray()).toComposeImageBitmap()
}.getOrNull()
}
private fun bufferedImageToImageBitmap(img: BufferedImage): ImageBitmap {
val baos = ByteArrayOutputStream(64 * 1024)
ImageIO.write(img, "png", baos)
return Image.makeFromEncoded(baos.toByteArray()).toComposeImageBitmap()
}
private fun sha1Hex(s: String): String {
val md = MessageDigest.getInstance("SHA-1")
val bytes = md.digest(s.toByteArray())
return bytes.joinToString("") { "%02x".format(it) }
}
private fun osTag(osName: String): String =
when {
"mac" in osName -> "macos"
"win" in osName -> "windows"
else -> "linux"
}
}

View File

@@ -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
}
}

View File

@@ -1,325 +0,0 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.service.media
import uk.co.caprica.vlcj.factory.MediaPlayerFactory
import uk.co.caprica.vlcj.factory.discovery.NativeDiscovery
import uk.co.caprica.vlcj.player.base.MediaPlayer
import uk.co.caprica.vlcj.player.embedded.EmbeddedMediaPlayer
import uk.co.caprica.vlcj.player.embedded.videosurface.VideoSurface
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.BufferFormatCallback
import uk.co.caprica.vlcj.player.embedded.videosurface.callback.RenderCallback
import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
/**
* Manages a pool of VLCJ media players to avoid costly create/destroy cycles.
* Keeps strong references to prevent GC crashes from native callbacks.
*
* IMPORTANT: Never let player instances be garbage collected while native
* callbacks are active — this causes JVM segfaults.
*/
object VlcjPlayerPool {
private val available = AtomicBoolean(false)
private val initAttempted = AtomicBoolean(false)
private val initLatch = CountDownLatch(1)
private var factory: MediaPlayerFactory? = null
// Video player pool (for actual playback)
private val allPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idlePlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_POOL_SIZE = 1
// Thumbnail player pool (separate so thumbnails don't compete with playback)
private val allThumbPlayers = mutableListOf<EmbeddedMediaPlayer>()
private val idleThumbPlayers = ConcurrentLinkedQueue<EmbeddedMediaPlayer>()
private const val MAX_THUMB_POOL_SIZE = 2
// Cached plugin path for audio factory creation (set during init)
private var cachedPluginPath: String? = null
// Audio player pool (shared factory with --no-video)
private var audioFactory: MediaPlayerFactory? = null
private val allAudioPlayers = mutableListOf<MediaPlayer>()
private val idleAudioPlayers = ConcurrentLinkedQueue<MediaPlayer>()
private const val MAX_AUDIO_POOL_SIZE = 1
/**
* Initialize the pool. Thread-safe — only runs once.
* Returns false if VLC is not installed.
*/
fun init(): Boolean {
if (available.get()) return true
// Only one thread performs init; others wait
if (!initAttempted.compareAndSet(false, true)) {
initLatch.await(10, TimeUnit.SECONDS)
return available.get()
}
return try {
// Try bundled VLC first, then fall through to system VLC
val macOsDiscoverer = MacOsVlcDiscoverer()
val discovery =
try {
val nd =
NativeDiscovery(
BundledVlcDiscoverer(),
macOsDiscoverer,
)
val found = nd.discover()
if (found) {
println("VLC: bundled discovery succeeded at ${nd.discoveredPath()}")
} else {
println("VLC: bundled discovery failed, falling back to system VLC")
}
found
} catch (e: Throwable) {
println("VLC: bundled discovery threw ${e.message}")
false
}
if (!discovery) {
// Try default system discovery
val systemDiscovery = NativeDiscovery().discover()
println("VLC: system discovery ${if (systemDiscovery) "succeeded" else "failed"}")
}
// Delete stale VLC plugin cache on macOS to avoid spam warnings
if ("mac" in System.getProperty("os.name").lowercase()) {
try {
val cacheDir = java.io.File(System.getProperty("user.home"), "Library/Caches/org.videolan.vlc")
cacheDir.listFiles()?.filter { it.name.startsWith("plugins") }?.forEach { it.delete() }
} catch (_: Throwable) {
// Best-effort cache cleanup
}
}
// Build factory args — add --plugin-path fallback if env var wasn't set
val factoryArgs =
mutableListOf(
"--no-xlib",
"--avcodec-hw=none", // Disable VideoToolbox — avoids CVPN chroma failures on macOS
"--reset-plugins-cache", // Rebuild stale plugins cache on startup
)
if (!macOsDiscoverer.envVarSet) {
val pluginPath =
macOsDiscoverer.discoveredPluginPath
?: System.getProperty("vlc.plugin.path")
?: VlcResourceResolver.findVlcDir()?.let { "${it.absolutePath}/plugins" }
if (pluginPath != null) {
factoryArgs += "--plugin-path=$pluginPath"
println("VLC: using --plugin-path fallback: $pluginPath")
}
}
cachedPluginPath = macOsDiscoverer.discoveredPluginPath
?: System.getProperty("vlc.plugin.path")
val f = MediaPlayerFactory(*factoryArgs.toTypedArray())
factory = f
available.set(true)
println("VLC: MediaPlayerFactory created successfully")
true
} catch (e: Throwable) {
println("VLC: init failed — ${e.message}")
available.set(false)
false
} finally {
initLatch.countDown()
}
}
fun isAvailable(): Boolean = available.get()
/**
* Create a callback video surface using the factory's API.
*/
fun createVideoSurface(
bufferFormatCallback: BufferFormatCallback,
renderCallback: RenderCallback,
): VideoSurface? {
val f = factory ?: return null
return f.videoSurfaces().newVideoSurface(bufferFormatCallback, renderCallback, true)
}
/**
* Acquire a video player from the pool or create a new one.
* Returns null if VLC is not available or pool is at capacity.
*/
fun acquire(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
synchronized(allPlayers) {
idlePlayers.poll()?.let { return it }
if (allPlayers.size >= MAX_POOL_SIZE) return null
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
allPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Acquire a player dedicated to thumbnail extraction.
* Separate pool so thumbnails don't compete with playback.
*/
fun acquireForThumbnail(): EmbeddedMediaPlayer? {
if (!available.get()) return null
val f = factory ?: return null
synchronized(allThumbPlayers) {
idleThumbPlayers.poll()?.let { return it }
if (allThumbPlayers.size >= MAX_THUMB_POOL_SIZE) {
// Fall back to main pool if thumb pool is full
return acquire()
}
return try {
val player = f.mediaPlayers().newEmbeddedMediaPlayer()
allThumbPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Acquire an audio-only player from the pool.
* Uses a separate factory with --no-video for efficiency.
*/
fun acquireAudioPlayer(): MediaPlayer? {
if (!init()) return null
synchronized(allAudioPlayers) {
idleAudioPlayers.poll()?.let { return it }
if (allAudioPlayers.size >= MAX_AUDIO_POOL_SIZE) return null
val af =
audioFactory ?: try {
val audioArgs = mutableListOf("--no-video", "--no-xlib")
cachedPluginPath?.let { audioArgs += "--plugin-path=$it" }
MediaPlayerFactory(*audioArgs.toTypedArray()).also { audioFactory = it }
} catch (_: Throwable) {
return null
}
return try {
val player = af.mediaPlayers().newMediaPlayer()
allAudioPlayers.add(player)
player
} catch (_: Exception) {
null
}
}
}
/**
* Return a video player to the pool for reuse.
*/
fun release(player: EmbeddedMediaPlayer) {
try {
player.controls().stop()
// Return to correct pool
synchronized(allThumbPlayers) {
if (player in allThumbPlayers) {
idleThumbPlayers.offer(player)
return
}
}
idlePlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Return an audio player to the pool for reuse.
*/
fun releaseAudioPlayer(player: MediaPlayer) {
try {
player.controls().stop()
idleAudioPlayers.offer(player)
} catch (_: Exception) {
// Player may already be disposed
}
}
/**
* Shut down the entire pool. Call on app exit.
*/
fun shutdown() {
synchronized(allPlayers) {
idlePlayers.clear()
for (player in allPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allPlayers.clear()
}
synchronized(allThumbPlayers) {
idleThumbPlayers.clear()
for (player in allThumbPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allThumbPlayers.clear()
}
synchronized(allAudioPlayers) {
idleAudioPlayers.clear()
for (player in allAudioPlayers) {
try {
player.controls().stop()
player.release()
} catch (_: Exception) {
// Ignore
}
}
allAudioPlayers.clear()
}
try {
factory?.release()
} catch (_: Exception) {
// Ignore
}
try {
audioFactory?.release()
} catch (_: Exception) {
// Ignore
}
factory = null
audioFactory = null
available.set(false)
}
}

View File

@@ -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")
}
}
}
}

View File

@@ -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(

View File

@@ -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)

View File

@@ -0,0 +1,863 @@
---
title: Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree
type: feat
status: active
date: 2026-06-11
deepened: 2026-06-11
origin: docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md
---
# Replace vlcj with kdroidFilter ComposeMediaPlayer + JCodec/Jaffree
## Enhancement Summary (Deepened 2026-06-11)
**Research artifacts (companion files):**
- [`_kdroidfilter-api-notes.md`](_kdroidfilter-api-notes.md) — verified kdroidFilter public API (note: pinned to **0.10.0** in `libs.versions.toml`; the research agent referenced 0.10.1 which does not exist on Maven Central as of 2026-06-11. API contract is the same across 0.10.x.)
- [`_jcodec-thumbnail-recipe.md`](_jcodec-thumbnail-recipe.md) — concrete JCodec → Skia ImageBitmap path
- [`_macos-ffmpeg-signing-recipe.md`](_macos-ffmpeg-signing-recipe.md) — jpackage nested-binary codesign recipe
- [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md) — Gitnuro-style Compose Desktop manifest
- [`_notice-and-licenses-recipe.md`](_notice-and-licenses-recipe.md) — cashapp/licensee + AboutLibraries pattern
- [`_vlcj-migration-learnings.md`](_vlcj-migration-learnings.md) — institutional learnings from prior media work
- [`_vlcj-migration-codebase-shape.md`](_vlcj-migration-codebase-shape.md) — file inventory (if regenerated)
### Material corrections to original plan
These supersede the corresponding sections below — implementer should use the new specs:
1. **kdroidFilter API names** (Phase 1 was wrong; corrected inline):
- `openMedia(url)`**`openUri(url)`**
- `release()`**`dispose()`**
- `setVolume()`**`volume = X` (Float, 0..1)**
- `setMuted()` / `isMuted`**does not exist; emulate by stashing/restoring volume**
- State exposed as Compose **`mutableStateOf`** (read with `snapshotFlow {}` from outside composition), **not** `StateFlow` — affects `stateSyncJob` design.
- `VideoPlayerState` is an interface — instantiate via `rememberVideoPlayerState()` (composable) or `createVideoPlayerState()` (non-composable; manual `dispose()`).
- JVM surface uses **Compose Canvas** (Skia ImageBitmap), **not** SwingPanel — overlays/z-order/AnimatedVisibility work normally (vs. vlcj/SwingPanel limitations).
- No `surfaceType` parameter on JVM (Android-only).
- Error states on JVM are only `SourceError` / `UnknownError`**no codec-vs-network distinction**. Need a separate GStreamer-on-Linux probe.
2. **macOS app bundle path correction** (Phase 2 was wrong):
- `appResourcesRootDir` lands at **`Contents/app/resources/`**, NOT `Contents/Resources/`. jpackage owns `Contents/Resources/`. Final ffmpeg path: `Amethyst.app/Contents/app/resources/ffmpeg/ffmpeg`.
3. **Linux GStreamer requirements**:
- kdroidFilter on Linux requires system **GStreamer 1.16+** with `plugins-base`, `plugins-good`, `plugins-bad`, and `libav` (for actual codec decoding). HLS needs `plugins-bad`. Document as `Recommends:` line in DEB / `recommends:` in metainfo.
4. **JCodec adjustments** (Phase 2):
- Need `org.jcodec:jcodec:0.2.5` **plus** `org.jcodec:jcodec-javase:0.2.5` (AWTUtil lives in `-javase`).
- Frame color space is **`YUV420J`** — must convert with `ColorUtil.getTransform(native.color, ColorSpace.RGB)` before `AWTUtil.toBufferedImage` (AWTUtil does not auto-convert).
- `ColorAlphaType.OPAQUE` (not `PREMUL` as I wrote).
- Cleanest `BufferedImage``ImageBitmap` is **ImageIO → PNG bytes → `Image.makeFromEncoded(bytes).toComposeImageBitmap()`**. No stable direct extension.
- Exception class: **`org.jcodec.api.UnsupportedFormatException extends JCodecException`** — also catch `IOException` + broad `RuntimeException` (decoder throws `AIOOBE` on malformed SPS/PPS).
- Use `seekToSecondSloppy(1.0)` for thumbnails (precise decodes up to 500 frames per call).
- Fast-reject path: `MP4Util.parseMovie()` + check `stsd` FourCC for `avc1` / `avc3` before opening FrameGrab.
5. **Tooling for licenses** (Phase 0):
- Use **`app.cash.licensee`** Gradle plugin to enforce allow-list + generate JSON report at build time (compliance gate).
- Use **`com.mikepenz.aboutlibraries`** Gradle plugin to produce the in-app "Open source licenses" screen (CMP-ready, replaces my hand-rolled `OpenSourceLicensesScreen.kt`).
- Hand-author `NOTICE.md` only for native bundles outside Gradle's graph (FFmpeg, GStreamer runtime).
6. **Flathub manifest precedent** (Phase 4):
- **Gitnuro** (`com.jetpackduba.Gitnuro`) is the existing Kotlin Compose Desktop precedent — use its structure verbatim where applicable.
- Manifest sketch in plan replaced by Gitnuro-style pattern in [`_flathub-manifest-recipe.md`](_flathub-manifest-recipe.md).
- Submission branch is **`new-pr`** (not `master`).
- Patent-codec extension `org.freedesktop.Platform.ffmpeg-full` is the way to surface HEVC/AV1 on Flatpak.
7. **SPDX expression acceptance**:
- Fedora `rpmLicenseType` accepts SPDX expressions (mandatory since F41 phase 4) → our compound is valid as-is.
- Flathub `<project_license>` validates compound via `appstreamcli`.
- **Debian DEP-5 does NOT support compound expressions** — must group per-file with separate `License:` stanzas.
### Key plan-level changes derived from research
- Drop the `OpenSourceLicensesScreen.kt` hand-rolled file (replaced by AboutLibraries plugin).
- Add `app.cash.licensee` + allow-list in `desktopApp/build.gradle.kts`.
- Add `gradle/spdx-allowlist.txt` (or `desktopApp/licensee.gradle`) configuration.
- macOS entitlements file overrides `entitlementsFile.set(...)` and `runtimeEntitlementsFile.set(...)` with `allow-jit`, `allow-unsigned-executable-memory`, `disable-library-validation`.
- `stateSyncJob` uses `snapshotFlow {}` not `combine(StateFlow, ...)`.
- `VideoPlayerSurface` JVM uses Compose Canvas → fullscreen handoff is **simpler** than expected (risk #7 in original risk table is downgraded from M-M to L-L).
- Linux codec-missing UX: probe GStreamer via `gst-inspect-1.0 playbin` at app launch; if missing, show one-time install nag.
- Phase 4 adds a `add-extensions: org.freedesktop.Platform.ffmpeg-full` block to the Flathub manifest for patent codecs.
## Overview
Drop `uk.co.caprica:vlcj 4.8.3` (GPL-3.0-or-later) from `desktopApp` and ship
an MIT-dominant desktop binary. Replace the three vlcj subsystems with:
| Subsystem | New library | License |
|-----------|-------------|---------|
| Video playback | `io.github.kdroidfilter:composemediaplayer:0.10.1` | MIT |
| Audio playback | same (audio-only mode of `VideoPlayerState`) | MIT |
| Thumbnail extraction | `org.jcodec:jcodec:0.2.5` + Jaffree fallback with LGPL FFmpeg | BSD-2 + Apache-2 (Java); LGPL-2.1 (FFmpeg native) |
Resulting binary SPDX: **`MIT AND LGPL-2.1-or-later AND BSD-2-Clause`** (down
from today's effective `GPL-3.0-or-later AND LGPL-2.1-or-later AND MIT` that
the current `rpmLicenseType = "MIT"` misrepresents).
Single migration PR. Hard cut — no feature flag. Phase 0 (NOTICE / SPDX
honesty patch) bundled in the same PR so the binary is never released
mislabeled.
See brainstorms for *why*:
- [Licensing issues catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)
- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md)
- [Replacement-candidate research](../brainstorms/2026-06-11-vlcj-replacement-research.md)
## Problem Statement
Today's `desktopApp` integration:
1. Builds Amethyst Desktop's binary by linking GPL-3.0 vlcj into the JVM.
2. Bundles LGPL-2.1 libvlc + a mixed-license VLC 3.0.20 plugin tree (~95 MB on macOS, ~90 MB on Windows, ~70 MB on Linux uncompressed) via the `ir.mahozad.vlc-setup` Gradle plugin.
3. Declares `rpmLicenseType = "MIT"` (`desktopApp/build.gradle.kts:141`) — **wrong** for the produced binary.
4. Ships no NOTICE, no per-component LICENSE files, no written GPL source offer, and no About-screen license listing.
Consequences (full catalog in
[licensing brainstorm](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)):
- The MIT badge on the repo + RPM is incorrect for distributed binaries.
- Forks of `desktopApp/` silently inherit GPL.
- Mac/MS App Store distribution is structurally blocked (anti-Tivoization conflict).
- Linux distros (Fedora, Debian) would fail license review with the current metadata.
- The bundled VLC plugin tree contains GPL-only plugins (`libdvdcss`, `x264`-built swscale) that drag GPL regardless of the Java binding.
User-resolved priorities (from brainstorm 2026-06-11):
- Keep **MIT branding on the binary** — non-negotiable.
- Mac/MS App Store: **not** on roadmap; not the migration driver.
- **Hard cut** (no feature flag), single PR, Flathub manifest in scope.
- Android (`amethyst/`) out of scope — already uses `media3-exoplayer`, no vlcj.
## Proposed Solution
### High-level
Swap the playback engine inside the existing `GlobalMediaPlayer` singleton
and the thumbnail engine inside `VideoThumbnailCache`, preserving the
public `StateFlow` surface that the rest of the UI consumes. Delete the
discoverer/pool layer (kdroidFilter handles its own native loading) and the
`ir.mahozad.vlc-setup` Gradle plugin (no more bundled VLC).
The UI composables (`DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`,
`NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay`) keep their
shapes; `DesktopVideoPlayer` switches its rendering path from
`Image(bitmap = videoFrame)` to kdroidFilter's `VideoPlayerSurface`
composable when the active video is playing, and stays on a thumbnail
`Image` for inactive instances (the "show poster until I tap play" pattern).
### The cross-stack contract
```
+-----------------------------------------+
| UI composables (kept) |
| DesktopVideoPlayer | AudioPlayer |
| VideoControls | NowPlayingBar | |
| GlobalFullscreenOverlay | LightboxOverlay |
+----------------------+------------------+
|
v reads StateFlow + invokes verbs
+-----------------------------------------+
| GlobalMediaPlayer (kept as singleton) |
| - exposes StateFlow<MediaPlaybackState>|
| - exposes activeVideoPlayerState |
| - verbs: playVideo/playAudio/pause/... |
+----------------------+------------------+
|
v delegates to
+----------------+ +-------------------+ +---------------+
| kdroidFilter | | JCodec (primary) | | Jaffree (fallback) |
| VideoPlayerState | | thumbnail H.264 | | LGPL FFmpeg thumb |
+----------------+ +-------------------+ +---------------+
```
### Why kdroidFilter
Full rationale in the [research doc](../brainstorms/2026-06-11-vlcj-replacement-research.md).
Headline:
- **MIT.** Binding + Maven coordinates `io.github.kdroidfilter:composemediaplayer:0.10.1`.
- **OS-native backends, no native bundle on Win/mac:** Media Foundation, AVFoundation, GStreamer (Linux system).
- **First-class Compose API:** `VideoPlayerSurface(playerState, contentScale, surfaceType, overlay)`.
- Bundle on macOS drops from ~95 MB → ~60-80 MB (we still bundle LGPL FFmpeg for the rare-codec thumbnail fallback; nothing for video).
- Active in 2026 (v0.10.1 May 2026, single maintainer Elie Gambache).
## Technical Approach
### Architecture
**Module boundary:** Migration stays inside `desktopApp/`. `quartz/`,
`commons/`, and `amethyst/` are untouched. No new shared abstractions in
`commons/` — the player layer is desktop-specific and has no Android
counterpart in scope (`amethyst/` runs `media3-exoplayer`).
**Concurrency:** kdroidFilter manages its own threading. We continue to
adapt its callbacks into the existing `MediaPlaybackState` StateFlow inside
`GlobalMediaPlayer` so UI consumers see the same shape. Thumbnail
extraction stays on `Dispatchers.IO`; we replace vlcj's `CountDownLatch`
gymnastics with a `suspendCancellableCoroutine` around JCodec's synchronous
API and `Jaffree.executeAsync().toCompletableFuture().asDeferred()` for the
fallback.
**Native loading:** kdroidFilter uses JNI (not JNA). On macOS this avoids
the `setenv$3b99ba0d` versioned-symbol class of bug that
`MacOsVlcDiscoverer` worked around in May 2026 (see
`docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md`). FFmpeg
binaries for Jaffree are spawned as separate processes — no JNI native lib
to load, so no signing/notarization complications beyond marking the
ffmpeg binary as executable in the jpackage app bundle and ensuring it
ships inside `Contents/MacOS/` so macOS hardened runtime permits it.
### Implementation Phases
#### Phase 0 — License bridge (in-PR, lands first commit) (~1 day)
Land alongside the migration code in the same PR so no released binary is
ever mislabeled.
**File changes:**
1. **`desktopApp/build.gradle.kts:141`**
- `rpmLicenseType = "MIT"``rpmLicenseType = "MIT AND LGPL-2.1-or-later AND BSD-2-Clause"`
- (jpackage forwards verbatim into the .rpm `License:` field. SPDX
expression form is accepted by `rpm --query`; Fedora packagers parse it
as a compound license.)
2. **New: `desktopApp/src/jvmMain/appResources/common/NOTICE.md`**
- Lists every third-party component shipping in the binary with version + SPDX + upstream URL.
- Includes FFmpeg LGPL build provenance + binary download URL.
- References JCodec, kdroidFilter, GStreamer (Linux runtime dep).
- Contains the GPLv3 source-availability written offer for the *interim* commit (the bridge commit lands while the migration is in flight; the same NOTICE is updated in Phase 3 to drop the vlcj/VLC lines once those are deleted).
3. **New: `desktopApp/src/jvmMain/appResources/common/licenses/`**
- `LICENSE-MIT.txt` — Amethyst's MIT
- `LICENSE-MIT-kdroidfilter.txt` — verbatim from upstream
- `LICENSE-BSD-2-JCodec.txt` — verbatim
- `LICENSE-Apache-2-Jaffree.txt` — verbatim
- `LICENSE-LGPL-2.1.txt` — FFmpeg + libvlc (interim)
- `LICENSE-GPL-3.0.txt` — vlcj (interim, deleted in Phase 3)
- `WRITTEN-OFFER.txt` — GPL source-offer pointing to https://github.com/vitorpamplona/amethyst (valid for 3 years per GPLv3 §6c). Removed in Phase 3 once vlcj is gone.
4. **New: `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/about/OpenSourceLicensesScreen.kt`**
- Scrollable list of bundled components → name, version, license SPDX, "View license" expander.
- Reachable from existing settings (look up + wire into nav during implementation; flagged as plan question in brainstorm).
- Pure data-driven: a `LicenseEntry` data class list defined inline; no resource loader complexity.
5. **`README.md` (root)** — add a single bullet under any "Desktop" section:
- "The desktop binary currently bundles GPL components (vlcj, parts of VLC plugins) during the in-flight migration to MIT-only dependencies. Source available at this repository."
- Removed in Phase 3.
**Acceptance for Phase 0:**
- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces a .rpm whose `rpm -qpi` shows the SPDX combined string.
- About-dialog "Open source licenses" screen renders and is reachable from the desktop settings.
- `NOTICE.md`, `licenses/`, and `WRITTEN-OFFER.txt` are visible inside the produced DMG/MSI/DEB/RPM payloads.
#### Phase 1 — kdroidFilter swap-in for video + audio (~1-2 weeks)
##### 1.1 Gradle wiring
**`gradle/libs.versions.toml`** — add:
```toml
[versions]
composemediaplayer = "0.10.1"
[libraries]
composemediaplayer = { group = "io.github.kdroidfilter", name = "composemediaplayer", version.ref = "composemediaplayer" }
```
**`desktopApp/build.gradle.kts`** — add `implementation(libs.composemediaplayer)`. Leave `implementation(libs.vlcj)` in place until Phase 3.
##### 1.2 GlobalMediaPlayer refactor
Replace the engine but **keep the public StateFlow surface** so UI code is
unaffected.
Current public surface (preserve):
```kotlin
val videoFrame: StateFlow<ImageBitmap?> // delete — DesktopVideoPlayer reads VideoPlayerSurface directly now
val videoState: StateFlow<MediaPlaybackState> // keep
val audioState: StateFlow<MediaPlaybackState> // keep
val isFullscreen: StateFlow<Boolean> // keep
fun playVideo(url: String, seekPosition: Float = 0f)
fun playAudio(url: String)
fun toggleVideoPlayPause()
fun toggleAudioPlayPause()
fun seekVideo(position: Float)
fun seekAudio(position: Float)
fun setVideoVolume(volume: Int)
fun setAudioVolume(volume: Int)
fun toggleVideoMute()
fun toggleAudioMute()
fun stopVideo()
fun stopAudio()
fun toggleFullscreen()
fun exitFullscreen()
fun shutdown()
```
New private fields:
```kotlin
private val videoPlayerState: VideoPlayerState = VideoPlayerState()
private val audioPlayerState: VideoPlayerState = VideoPlayerState().apply { /* audio-only config */ }
private var stateSyncJob: Job? = null
```
New public field — exposed so `DesktopVideoPlayer` can pass it to
`VideoPlayerSurface(...)`:
```kotlin
val activeVideoPlayerState: VideoPlayerState get() = videoPlayerState
val activeAudioPlayerState: VideoPlayerState get() = audioPlayerState
```
**State-sync coroutine.** Replace the vlcj `MediaPlayerEventAdapter` +
500ms polling loop with a single coroutine that mirrors
`videoPlayerState`'s `StateFlow`s into our `_videoState` mutable flow:
```kotlin
private fun startStateSync(state: VideoPlayerState, target: MutableStateFlow<MediaPlaybackState>) {
stateSyncJob?.cancel()
stateSyncJob = scope.launch {
// kdroidFilter exposes: isPlaying, currentTime, duration, isLoading,
// volume, isMuted, aspectRatio (as StateFlows or @Composable getters)
combine(
state.isPlayingFlow, // verify exact name in v0.10.1 API
state.currentTimeFlow,
state.durationFlow,
state.isLoadingFlow,
state.aspectRatioFlow,
) { /* fold into MediaPlaybackState */ }
.collect { target.value = it }
}
}
```
(If kdroidFilter v0.10.1 exposes its state as `@Composable State<T>`
getters rather than `Flow<T>`, fall back to a `snapshotFlow { state.isPlaying }`
inside a `produceState`-equivalent coroutine. Confirm during Phase 1
implementation by reading kdroidFilter source on github.)
##### 1.3 DesktopVideoPlayer rewire
Replace the `Image(bitmap = displayBitmap)` rendering path with conditional
`VideoPlayerSurface` when this composable instance owns the active video URL:
```kotlin
val isActiveVideo = videoState.url == url
Box(...) {
if (isActiveVideo) {
VideoPlayerSurface(
playerState = GlobalMediaPlayer.activeVideoPlayerState,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().clip(MaterialTheme.shapes.small),
)
} else {
thumbnail?.let {
Image(bitmap = it, contentDescription = "Video thumbnail", ...)
}
}
VideoControls(...) // unchanged
}
```
Drop the `VlcNotAvailableMessage` composable — kdroidFilter never fails on
"VLC not installed." Replace it with `VlcCodecUnsupportedMessage(url, codec)`
shown when kdroidFilter emits a codec-error event (Windows HEVC/AV1
without the MS Store extension). The message offers "Open in default
player" via `Desktop.getDesktop().browse(URI(url))`.
##### 1.4 AudioPlayer rewire
Drop the vlcj surface plumbing — `AudioPlayer.kt` currently doesn't touch
vlcj directly, it just reads `GlobalMediaPlayer.audioState`. No file
change beyond imports if the StateFlow shape is preserved.
##### 1.5 VideoControls / NowPlayingBar / GlobalFullscreenOverlay / LightboxOverlay
Review for vlcj-specific assumptions. Expected change: none (they consume
StateFlows). Plan question P6 — to be verified during implementation by
grepping for any `EmbeddedMediaPlayer`, `MediaPlayer`, or `videoFrame`
references in these files.
##### 1.6 Phase 1 decision gate
After 1.11.5 compile + launch, run the manual test set:
| Test URL | Expected outcome |
|----------|------------------|
| `https://download.samplelib.com/mp4/sample-5s.mp4` (H.264 MP4) | Plays on Win/mac/Linux. |
| `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` (HLS H.264) | Plays on Win/mac/Linux. |
| `https://nostr.build/i/<recent-VP9-webm-url>` (VP9 WebM) | Plays on mac+Linux. Win → codec-missing UX. |
| `https://void.cat/<recent-AV1-mp4>` (AV1 MP4) | Plays on macOS 13+ + Linux. Win → codec-missing UX. |
| `https://zap.stream/<known-live-stream>` (HLS livestream) | Plays on all 3 OSes. **Gate criterion: must not regress vs. vlcj.** |
If the zap.stream live stream fails on any of the 3 OSes for reasons not
explained by the codec gap (i.e. legitimate HLS protocol issues), invoke
the fallback plan — switch primary engine to `gst1-java-core` keeping the
wrapper API intact (research doc §3 Recommended #2). Document the pivot
in `docs/decisions/` and update this plan's "Status" frontmatter.
##### 1.7 Tests
Unit tests in `desktopApp/src/jvmTest/`:
- `GlobalMediaPlayerStateTest.kt` — drives `MediaPlaybackState` derivations from a fake `VideoPlayerState` (build via constructor / setters). Verifies pause/play/seek transitions emit correctly.
- `DesktopVideoPlayerActiveDispatchTest.kt` (compose test) — given two `DesktopVideoPlayer` instances mounted with different URLs, asserts that only the active URL renders `VideoPlayerSurface` (use semantic test tags).
- No instrumented test that actually decodes a network video (too flaky for CI; covered in manual test sheet).
#### Phase 2 — Thumbnail extraction (JCodec → Jaffree cascade) (~3-5 days)
##### 2.1 Gradle wiring
**`gradle/libs.versions.toml`** — add:
```toml
[versions]
jcodec = "0.2.5"
jaffree = "2024.08.29"
[libraries]
jcodec = { group = "org.jcodec", name = "jcodec", version.ref = "jcodec" }
jaffree = { group = "com.github.kokorin.jaffree", name = "jaffree", version.ref = "jaffree" }
```
**`desktopApp/build.gradle.kts`** — add both.
##### 2.2 LGPL FFmpeg per-OS bundling
We need FFmpeg only for the *fallback* thumbnail path. The video player
doesn't shell out to it. Bundle one binary per OS in
`appResources/<os>/ffmpeg/`:
| OS | Source | Path inside DMG/MSI/AppImage |
|----|--------|------------------------------|
| macOS arm64+x86_64 | https://www.osxexperts.net/ — LGPL build, code-signed | `Contents/Resources/ffmpeg/ffmpeg` |
| Windows x64 | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `app/resources/ffmpeg/ffmpeg.exe` |
| Linux x64 | https://johnvansickle.com/ffmpeg/ "release" tarball — LGPL config | `lib/resources/ffmpeg/ffmpeg` (DEB), AppImage equivalent |
(Each binary's per-OS LICENSE.txt also ships in `licenses/` from Phase 0.)
We do not invoke a per-build *download* step (no extra Gradle download
plugin). Binaries are checked into the repo under
`desktopApp/src/jvmMain/appResources/<os>/ffmpeg/` (~10 MB per OS,
acceptable for git LFS or direct check-in). The Phase 3 cleanup removes
the `ir.mahozad.vlc-setup` plugin, which was the big downloader.
**macOS hardened-runtime entitlement:** spawning a child process from a
hardened-runtime app needs the `com.apple.security.cs.allow-jit` or
`com.apple.security.cs.disable-library-validation` entitlement, OR the
ffmpeg binary needs to be co-signed with the app's identity. We extend
the existing `entitlements.plist` to add:
```xml
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<false/>
<key>com.apple.security.cs.disable-library-validation</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/> <!-- only if needed by ffmpeg; verify on first sign -->
```
The cleanest path is to co-sign ffmpeg with the same identity used for the
.app — verify during implementation that jpackage's signing step covers
nested executables in `Contents/Resources/` (older jpackage did not — may
need a post-pkg `codesign --deep`).
##### 2.3 VideoThumbnailCache rewrite
Replace the vlcj-based `extractFirstFrame` with a cascade:
```kotlin
private suspend fun extractFirstFrame(url: String): ImageBitmap? {
val downloadedBytes = downloadVideoForThumb(url) ?: return null
return tryJCodec(downloadedBytes)
?: tryJaffree(downloadedBytes)
?: tryJaffreeFromUrl(url) // for HLS where the URL points to a manifest, not bytes
}
```
- **`downloadVideoForThumb(url)`** — uses existing OkHttp instance (which is wired in `desktopApp` already, line 50 in build.gradle.kts). Range-fetch first 4 MB for fast thumbnail. Cache to `~/Library/Caches/com.vitorpamplona.amethyst.desktop/thumbs/<hash>.mp4` (per OS — use `java.util.prefs`-style discovery for cache dir).
- **`tryJCodec(bytes)`** — `FrameGrab.createFrameGrab(NIOUtils.readableChannel(File))``seekToSecondPrecise(1.0).getNativeFrame()`. Convert YUV → RGB → Skia `Bitmap``ImageBitmap`. Wrap with `runCatching` and discard on any exception (unsupported codec, malformed MP4, HLS manifest in the bytes, etc.).
- **`tryJaffree(bytes)`** — spawn `ffmpeg -ss 1 -i <tempfile> -frames:v 1 -f image2pipe -c:v png pipe:1`, read stdout into ByteArray, decode with `Image.makeFromEncoded(bytes).toComposeImageBitmap()`. 5-second timeout.
- **`tryJaffreeFromUrl(url)`** — same as tryJaffree but with the URL directly as `-i` argument. For HLS or any streamed source.
**Thread safety / dedup:** keep the existing `pending` `ConcurrentHashMap`
guard. Replace `CountDownLatch` with `suspendCancellableCoroutine` over
Jaffree's `executeAsync()` return type for clean cancellation on
recomposition.
##### 2.4 Tests
Unit tests:
- `VideoThumbnailCacheTest.kt` — given a known-good 5-second H.264 MP4 in `src/jvmTest/resources/sample.mp4`, asserts JCodec path produces a non-null `ImageBitmap` with expected dimensions.
- `JaffreeProbeTest.kt` — verifies the bundled ffmpeg binary is found in `appResources/<currentOs>/ffmpeg/` at runtime and prints `ffmpeg -version` successfully. Skip on platforms where we don't ship a binary.
- No HEVC/VP9/AV1 unit test (codec coverage depends on the runtime ffmpeg build; manual test).
#### Phase 3 — vlcj removal + build cleanup (~2-3 days)
##### 3.1 Code deletions
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` — delete
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/BundledVlcDiscoverer.kt` — delete
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/MacOsVlcDiscoverer.kt` — delete
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcResourceResolver.kt` — delete (referenced by both discoverers)
- Any `import uk.co.caprica.vlcj.*` remaining in `GlobalMediaPlayer.kt` or `VideoThumbnailCache.kt` — delete
- Tests that exercise vlcj integration (if any) — delete
##### 3.2 Gradle deletions
`desktopApp/build.gradle.kts`:
- Remove `id("ir.mahozad.vlc-setup") version "0.1.0"` from plugins block
- Remove `implementation(libs.vlcj)`
- Remove the entire `vlcSetup { ... }` block
- Remove `tasks.named("spotlessKotlin") { mustRunAfter("vlcSetup") }`
- Remove `tasks.withType<Download>().configureEach { ... }` (was for `vlcDownload`)
- Remove `jvmArgs += "-Dvlc.plugin.path=\$APPDIR/resources/vlc/plugins"`
- Remove `"jdk.unsupported"` from `modules(...)` if kdroidFilter doesn't need it (verify)
- Update the AppImage build doc comment that mentions VLC
`gradle/libs.versions.toml`:
- Remove `vlcj = "4.8.3"` from `[versions]`
- Remove the `vlcj` entry from `[libraries]`
##### 3.3 Bundled VLC tree deletion
```bash
git rm -r desktopApp/src/jvmMain/appResources/macos/vlc
git rm -r desktopApp/src/jvmMain/appResources/windows/vlc
git rm -r desktopApp/src/jvmMain/appResources/linux/vlc
```
(Equivalent to the `ir.mahozad.vlc-setup` copy targets. If the directories
were generated and not checked in, no `git rm` needed — just confirm the
plugin removal stops emitting them.)
##### 3.4 NOTICE.md / licenses/ trimming
- Drop `LICENSE-GPL-3.0.txt`, `WRITTEN-OFFER.txt`
- Drop the vlcj + libvlc entries from `NOTICE.md`
- Keep entries for: kdroidFilter (MIT), JCodec (BSD-2), Jaffree (Apache-2), FFmpeg (LGPL-2.1), GStreamer (Linux runtime, LGPL-2.1)
##### 3.5 README trim
- Remove the interim "binary bundles GPL" disclaimer.
- Replace with "Built on kdroidFilter ComposeMediaPlayer + native OS media frameworks."
##### 3.6 Acceptance for Phase 3
- `grep -r "vlcj\|caprica\|libvlc" desktopApp/` returns no source code matches (only doc/license mentions).
- `./gradlew :desktopApp:packageDistributionForCurrentOS` produces installers that contain no `vlc/`, `libvlc.dylib`, `libvlccore.dylib`, or `plugins/*.dylib`.
- Built DMG is < 80 MB compressed (down from ~140 MB today; the FFmpeg LGPL binary is ~12-20 MB compressed).
- App launches and plays a sample MP4 with no log line containing "VLC", "vlcj", or "libvlc".
#### Phase 4 — Flathub manifest + final SPDX (~2 days)
##### 4.1 Flathub manifest
New file: `desktopApp/packaging/flatpak/com.vitorpamplona.amethyst.Desktop.yml`
Sketch (planner to refine during implementation):
```yaml
app-id: com.vitorpamplona.amethyst.Desktop
runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
sdk-extensions:
- org.freedesktop.Sdk.Extension.openjdk21
command: amethyst-desktop
finish-args:
- --share=network
- --share=ipc
- --socket=fallback-x11
- --socket=wayland
- --socket=pulseaudio
- --device=dri
- --filesystem=xdg-download
- --talk-name=org.freedesktop.Notifications
modules:
- name: amethyst-desktop
buildsystem: simple
build-commands:
- install -Dm755 amethyst-desktop /app/bin/amethyst-desktop
# ... (jpackage Linux output copied in)
sources:
- type: file
path: ../../build/compose/binaries/main-release/app/Amethyst/bin/Amethyst
```
Runtime `org.freedesktop.Platform 24.08` ships GStreamer 1.24 with the
plugins kdroidFilter needs (`gst-plugins-good`, `gst-plugins-bad`,
`gst-libav` with LGPL config). No bundled FFmpeg needed for Flathub since
GStreamer is available the thumbnail path uses Jaffree spawns the
system `ffmpeg` if present, else falls back to JCodec-only. Document this
clearly in the Flathub manifest comments.
##### 4.2 Final SPDX
After Phase 3 deletions, set `rpmLicenseType = "MIT AND LGPL-2.1-or-later
AND BSD-2-Clause"`. (LGPL stays because the bundled FFmpeg for thumbnails is LGPL native.)
Update `LICENSES.md` (root) to add a "Distributed binary" section listing
the SPDX expression + table of bundled components.
##### 4.3 CI updates
- Add a job that uploads the Flathub manifest to `flathub/com.vitorpamplona.amethyst.Desktop` repo (manual review by Flathub maintainers not a one-button operation; document as a deferred follow-up).
- Add a job step that verifies the produced DMG/MSI/DEB contains no `vlc*`, `libvlc*` strings.
### Architecture diagrams
#### Old (today)
```
[User taps play on a feed video]
DesktopVideoPlayer composable
│ GlobalMediaPlayer.playVideo(url)
GlobalMediaPlayer (singleton)
│ VlcjPlayerPool.init() + acquire()
EmbeddedMediaPlayer ◄── BufferFormatCallback / RenderCallback
│ │
│ libvlc events │ ByteBuffer of pixels
▼ ▼
MediaPlayerEventAdapter Skia Bitmap.installPixels
│ │
▼ ▼
_videoState (StateFlow) _videoFrame (StateFlow<ImageBitmap?>)
│ │
▼ ▼
VideoControls reads state Image(bitmap = videoFrame)
```
#### New
```
[User taps play on a feed video]
DesktopVideoPlayer composable
│ GlobalMediaPlayer.playVideo(url)
GlobalMediaPlayer (singleton)
│ videoPlayerState.openMedia(url) + .play()
VideoPlayerState (kdroidFilter)
│ delegates to OS backend
[Media Foundation / AVFoundation / GStreamer]
│ │
│ state flows │ frames piped to native surface
▼ ▼
stateSyncJob folds into VideoPlayerSurface(state) renders directly
_videoState (MediaPlaybackState) (no Compose ImageBitmap relay)
VideoControls reads state
```
## Alternative Approaches Considered
(Full evaluation in [research doc §1 and §3](../brainstorms/2026-06-11-vlcj-replacement-research.md))
| Alternative | Why not |
|-------------|---------|
| Accept GPL on binary + only fix the metadata (Approach A in licensing brainstorm) | User priority: keep MIT brand on the binary. |
| Buy Caprica commercial vlcj license (Approach C) | Burns budget every year; doesn't address bundled VLC plugin GPL surface. |
| `gst1-java-core` + bundled GStreamer (Recommended #2 in research) | Binary is LGPL-3.0-dominant, not MIT. Reserved as Phase 1 decision-gate fallback. |
| JavaFX MediaPlayer | No HEVC, VP9, AV1, Opus, FLAC. SwingPanel z-order incompatible with our overlays. |
| Write our own JNA binding to libvlc | 3-6 weeks of native callback / GC-pinning work for **no material license gain** over kdroidFilter. |
| open-ani/mediamp | Desktop backend (`mediamp-vlc`) is GPLv3 same problem with one more layer. |
| libmpv via Java binding | No maintained JVM binding exists. |
| HumbleVideo | Abandoned 2018, AGPL. |
| External player handoff only | Breaks in-feed playback UX. Kept as codec-missing fallback only. |
## System-Wide Impact
### Interaction Graph
What fires when `playVideo(url)` is called in the new design:
1. `DesktopVideoPlayer` composable's "play" button onClick `GlobalMediaPlayer.playVideo(url)`.
2. `GlobalMediaPlayer` updates `_videoState` to `(url, isBuffering=true, ...)`.
3. `GlobalMediaPlayer` calls `videoPlayerState.openMedia(url)` then `.play()` on `Dispatchers.IO`.
4. kdroidFilter's backend (Media Foundation / AVFoundation / GStreamer) opens the URL.
5. kdroidFilter emits state changes through its flows; `stateSyncJob` folds them into `_videoState`.
6. Compose recomposes `DesktopVideoPlayer` (active branch) `VideoPlayerSurface(playerState)` swaps from "loading" to live frames.
7. `NowPlayingBar` (which reads `_videoState`) updates simultaneously.
8. `VideoControls` recomposes (consumes `_videoState`).
No callback marshalling between native and Compose threads on our side
kdroidFilter handles that. No `Skia.Bitmap.installPixels` per frame in our
code path `VideoPlayerSurface` writes directly to a native surface.
### Error & Failure Propagation
- **Codec unsupported (e.g. AV1 on stock Win10):** kdroidFilter emits an error state. `stateSyncJob` sets `_videoState.isBuffering = false` + a new `_videoState.errorReason: CodecError? = null` field. `DesktopVideoPlayer` checks this and renders `CodecUnsupportedMessage` instead of `VideoPlayerSurface`.
- **Network failure / 404:** Same path kdroidFilter error state `CodecError(reason=NETWORK)`. UI shows generic "Couldn't load" with retry button.
- **Thumbnail failure (JCodec then Jaffree both fail):** `VideoThumbnailCache.getThumbnail(url)` returns `null`. `DesktopVideoPlayer` already handles this (current code shows just the play-button overlay on null thumbnail).
- **kdroidFilter native loading failure on Linux (no system GStreamer):** caught at `GlobalMediaPlayer.init()`-equivalent. Show a one-time toast: "Install GStreamer to play videos: sudo apt install gstreamer1.0-plugins-good gstreamer1.0-libav".
### State Lifecycle Risks
- `videoPlayerState` is a singleton on `GlobalMediaPlayer`. Reusing it across URLs is supported by kdroidFilter (`openMedia(newUrl)` resets). No risk of leaked native players (vlcj's pool was a workaround for vlcj's expensive `EmbeddedMediaPlayer` construction; kdroidFilter doesn't have that cost).
- App shutdown: `GlobalMediaPlayer.shutdown()` calls `videoPlayerState.release()` + `audioPlayerState.release()`. No native handle survives JVM exit.
- Thumbnail cache: same `ConcurrentHashMap` + `pending` deduper. No native handles.
- FFmpeg child processes (Jaffree): always have a 5-second timeout. `try-finally` ensures `Process.destroyForcibly()` on cancellation or exception.
### API Surface Parity
- `GlobalMediaPlayer`'s public API (verbs + StateFlows) is preserved. Callers in `DesktopVideoPlayer`, `AudioPlayer`, `VideoControls`, `NowPlayingBar`, `GlobalFullscreenOverlay`, `LightboxOverlay` need no breaking changes other than removing the `videoFrame` StateFlow (which only `DesktopVideoPlayer` reads).
- Drop: `GlobalMediaPlayer.videoFrame: StateFlow<ImageBitmap?>` (replaced by `VideoPlayerSurface(activeVideoPlayerState)` reading the player state directly in the consumer composable).
- Add: `GlobalMediaPlayer.activeVideoPlayerState: VideoPlayerState` for the surface consumer.
- Add: `MediaPlaybackState.errorReason: CodecError? = null` for codec-unsupported UX.
### Integration Test Scenarios
Manual (also captured in the testing sheet handed to the user post-implementation):
1. **Resume across navigation.** Play a video in the feed, navigate to a different screen, return to the feed video continues playing, position preserved. (Tests `GlobalMediaPlayer` singleton scope.)
2. **Switch URLs mid-play.** Tap play on video A, then tap play on video B A stops, B starts from beginning. (Tests `openMedia` reset.)
3. **Now-playing bar sync.** Play audio from a feed item, scroll away bar appears with playback state in sync. (Tests `_audioState` parity with the underlying engine.)
4. **Fullscreen ↔ feed handoff.** Enter fullscreen during playback, exit playback continues uninterrupted, no reload. (Tests that `VideoPlayerSurface` instances can be re-attached / not destroyed across composable re-creation. **High-risk**: this may require a `key(...)` boundary to keep the surface stable.)
5. **Codec-missing Windows path.** Open an AV1 MP4 on stock Win10 `CodecUnsupportedMessage` renders + "Open in default player" works. (Tests error propagation + handoff.)
## Acceptance Criteria
### Functional Requirements
- [ ] Feed videos (H.264 MP4) play on macOS / Windows / Linux desktop builds.
- [ ] Feed audio (MP3 / Opus / AAC) plays on all 3 OSes.
- [ ] HLS livestreams (zap.stream) play on all 3 OSes.
- [ ] VP9 WebM plays on macOS + Linux. Windows shows codec-missing UX with "open externally" handoff.
- [ ] AV1 plays on macOS 13+ + Linux. Windows shows codec-missing UX.
- [ ] HEVC plays on macOS + Linux. Windows shows codec-missing UX.
- [ ] Thumbnails are extracted and shown in the feed for H.264 MP4 (JCodec) and HEVC/VP9/AV1 (Jaffree LGPL FFmpeg) same coverage as vlcj today.
- [ ] Existing in-feed playback UX (play/pause/seek/volume/mute/fullscreen) is preserved.
- [ ] Now-playing bar continues to work across navigation.
- [ ] Lightbox / fullscreen overlay continues to work.
- [ ] App startup: no "VLC not installed" path. Engine is always available.
### Non-Functional Requirements
- [ ] Binary SPDX in produced packages: `MIT AND LGPL-2.1-or-later AND BSD-2-Clause` (no `GPL-3.0` token).
- [ ] macOS DMG size today's size 25 MB (target: ~70-80 MB compressed).
- [ ] No regression in feed-scroll FPS (kdroidFilter writes frames natively; should equal or exceed vlcj's `RenderCallback` path).
- [ ] App startup time today (vlcj init removed; kdroidFilter doesn't initialize until first playback).
### Quality Gates
- [ ] `./gradlew :desktopApp:compileKotlin` green.
- [ ] `./gradlew :desktopApp:test` green.
- [ ] `./gradlew spotlessApply` clean.
- [ ] `grep -r "vlcj\|caprica\|libvlc" desktopApp/src/` finds zero hits.
- [ ] `./gradlew :desktopApp:packageDistributionForCurrentOS` succeeds on macOS arm64 (local) and produces a DMG; smoke-launches.
- [ ] NOTICE / licenses / About-screen-licenses-listing accurate and accessible from the running app.
- [ ] Manual test sheet completed by user (provided post-implementation).
## Success Metrics
- **Binary SPDX correctness** (boolean): packaged installers carry the SPDX expression listed above. Verified by `rpm -qpi`, DMG metadata, MSI summary.
- **Bundle size delta** (MB): macOS DMG and Linux DEB shrink by 25 MB after Phase 3.
- **Manual codec coverage matrix pass rate** (%): aim for 95% pass on H.264, AAC, MP3, HLS-H.264; 75% on VP9, AV1, HEVC (Windows excepted per documented gap).
- **Crash-free playback sessions in first 30 days post-release** (telemetry, if available): today's baseline.
- **Issue-tracker mentions of "VLC not installed"** post-release: 0.
## Dependencies & Prerequisites
- Maven Central artifacts: `io.github.kdroidfilter:composemediaplayer:0.10.1`, `org.jcodec:jcodec:0.2.5`, `com.github.kokorin.jaffree:jaffree:2024.08.29`.
- LGPL FFmpeg binaries to commit into `desktopApp/src/jvmMain/appResources/<os>/ffmpeg/` (planner to fetch + verify checksums during Phase 2).
- Linux runtime: GStreamer 1.20+ with `gst-plugins-good` and `gst-libav`. Already installed by default on Fedora 39+, Ubuntu 22.04+, Debian 12+. Documented as a `recommends`/`depends` line in the .deb/.rpm control files.
- Flathub: `org.freedesktop.Platform 24.08` runtime (Phase 4).
- JDK 21 (already in use).
- No new tooling required (jpackage, ProGuard, spotless already configured).
## Risk Analysis & Mitigation
| # | Risk | Probability | Impact | Mitigation |
|---|------|-------------|--------|------------|
| 1 | kdroidFilter HLS livestream failures on AVPlayer (strict mode) | M | H | Phase 1 decision gate against zap.stream + 2-3 alt streams. Pivot to gst1-java-core if it bites. |
| 2 | Windows stock-codec gap (HEVC/VP9/AV1) | H | M | Codec-detection + "open externally" handoff. Document in release notes. |
| 3 | kdroidFilter project goes dormant (single maintainer) | L | M | API surface we depend on is tiny; we own the wrapper. Pivot to gst1-java-core later behind same wrapper API. |
| 4 | JCodec doesn't decode some H.264 high-profile MP4s | M | L | Jaffree fallback handles it. Cost: an extra ffmpeg spawn for those URLs. |
| 5 | macOS hardened-runtime rejects nested ffmpeg binary | L | H | Co-sign nested binaries with the app identity in the existing `codesign` step. If jpackage doesn't, add a post-step `codesign --deep`. |
| 6 | Flathub review delays / rejects manifest | L | L | Manifest authoring is in scope; submitting to Flathub is a separate operation tracked outside this PR. |
| 7 | `Image(bitmap)` `VideoPlayerSurface` swap reveals new z-order interaction with `GlobalFullscreenOverlay` | M | M | Test scenario #4 in integration tests; fix at implementation time. |
| 8 | `_videoState.errorReason` field break binary-state compat in tests | L | L | All affected tests are co-edited in this PR. |
| 9 | LGPL FFmpeg binaries grow git history | M | L | Use git LFS for the per-OS binaries; or fetch on first build via a one-shot Gradle task with checksum verification. |
| 10 | jpackage doesn't sign nested executables on macOS | M | M | Phase 2 acceptance verifies; fallback is post-step `codesign --deep --force --sign "..." Amethyst.app`. |
## Resource Requirements
- **People:** 1 engineer (you/Claude in `/ce:work` mode).
- **Time:** 2-3 weeks elapsed for code work. Plus user time for manual testing pass.
- **Infra:** Local macOS arm64 build, Windows VM for codec-gap testing (existing CI handles cross-OS DMG/MSI/DEB), Linux VM for Flathub manifest verification.
## Future Considerations
- Once kdroidFilter / `gst1-java-core` matures, consider lifting the player abstraction into `commons/commonMain/` so iOS desktop (if ever) can share. Out of scope for this PR.
- If Compose Multiplatform 1.9+ ships an official `VideoPlayer` composable backed by `androidx.media3` on desktop, evaluate migrating to that but only if the codec story is at least equivalent. Likely 12-18 months out.
- DASH support kdroidFilter doesn't list it. If we want it later, GStreamer fallback is the path.
## Documentation Plan
- `NOTICE.md` (new, in app bundle).
- `LICENSES.md` (root) add "Distributed binary" section.
- `README.md` (root) interim disclaimer added in Phase 0, removed in Phase 3.
- `desktopApp/packaging/flatpak/README.md` explains the Flathub manifest, build steps, deps.
- About-dialog "Open source licenses" screen (in-app).
- Update `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` to add "superseded by 2026-06-11 plan" front-matter note (if appropriate verify during execution).
## Sources & References
### Origin
- **Brainstorm document:** [docs/brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md). Decisions carried forward:
- Stack choice (kdroidFilter primary, gst1-java-core fallback)
- Single-PR bundling of license bridge + migration
- Hard cut, no feature flag
- Flathub manifest in scope
- Android out of scope
- **Companion research:** [docs/brainstorms/2026-06-11-vlcj-replacement-research.md](../brainstorms/2026-06-11-vlcj-replacement-research.md) full candidate comparison matrix.
- **Companion licensing analysis:** [docs/brainstorms/2026-06-11-vlcj-licensing-brainstorm.md](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md) 10-issue catalog.
### Internal References
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VlcjPlayerPool.kt` pool architecture being deleted
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/GlobalMediaPlayer.kt` engine swap target
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/media/VideoThumbnailCache.kt` engine swap target
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/DesktopVideoPlayer.kt:62-172` UI rewire target
- `desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/media/AudioPlayer.kt` minor changes
- `desktopApp/build.gradle.kts:9, 60-61, 97-98, 117, 141, 166-176` build wiring to swap
- `gradle/libs.versions.toml` `vlcj = "4.8.3"` to remove
- `LICENSE` (root) Amethyst MIT
- `docs/plans/2026-05-18-fix-macos-vlc-bundled-discovery-plan.md` recent VLC native-loading work that gets superseded
- `docs/plans/2026-03-16-feat-desktop-media-full-parity-plan.md` earlier desktop media plan
- `docs/plans/_vlcj-migration-learnings.md` institutional learnings (compiled by research agent 2026-06-11)
### External References
- [kdroidFilter/ComposeMediaPlayer](https://github.com/kdroidFilter/ComposeMediaPlayer) primary library
- [kdroidFilter/ComposeMediaPlayer LICENSE](https://github.com/kdroidFilter/ComposeMediaPlayer/blob/master/LICENSE)
- [`io.github.kdroidfilter:composemediaplayer` on Maven Central](https://central.sonatype.com/artifact/io.github.kdroidfilter/composemediaplayer)
- [jcodec/jcodec](https://github.com/jcodec/jcodec)
- [kokorin/Jaffree](https://github.com/kokorin/Jaffree)
- [Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264](https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264) Windows LGPL ffmpeg
- [osxexperts.net LGPL FFmpeg](https://www.osxexperts.net/) macOS LGPL ffmpeg
- [johnvansickle.com FFmpeg static builds](https://johnvansickle.com/ffmpeg/) Linux LGPL ffmpeg
- [Flathub submission docs](https://docs.flathub.org/docs/for-app-authors/submission)
- [GStreamer licensing FAQ](https://gstreamer.freedesktop.org/documentation/frequently-asked-questions/licensing.html)
- [FFmpeg legal](https://www.ffmpeg.org/legal.html) `--enable-gpl` semantics
- [GNU GPL FAQ §JavaJVM](https://www.gnu.org/licenses/gpl-faq.html#JavaJVM) confirms JNI/JNA doesn't escape GPL
- [Apple anti-Tivoization vs GPLv3 (FSF/VLC 2011)](https://www.fsf.org/news/2010-05-app-store-compliance) App Store incompatibility
### Related Work
- Brainstorms (this PR's ancestry):
- [Licensing catalog](../brainstorms/2026-06-11-vlcj-licensing-brainstorm.md)
- [Migration brainstorm](../brainstorms/2026-06-11-vlcj-replacement-migration-brainstorm.md)
- [Replacement research](../brainstorms/2026-06-11-vlcj-replacement-research.md)
- Prior plans:
- [Desktop media full parity plan](2026-03-16-feat-desktop-media-full-parity-plan.md)
- [Desktop media manual testing plan](2026-03-16-desktop-media-manual-testing-plan.md)
- [macOS VLC bundled discovery fix plan](2026-05-18-fix-macos-vlc-bundled-discovery-plan.md)

View File

@@ -0,0 +1,182 @@
# Manual Testing Sheet — vlcj → kdroidFilter Migration
**Plan:** [`2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md`](2026-06-11-feat-replace-vlcj-with-kdroidfilter-plan.md)
**Branch / worktree:** `.claude/worktrees/vlcj-licensing-brainstorm` (branch `worktree-vlcj-licensing-brainstorm`)
**Date:** 2026-06-11
This sheet walks through every verification the user must perform locally
because the automated background session can't reach Maven Central or
exercise the JVM media stack across OSes.
---
## 0 · Build verification — VERIFIED locally on macOS arm64 (2026-06-11)
Build verified after network came online. Final dep pins:
- `io.github.kdroidfilter:composemediaplayer:0.10.0` (research-agent hallucinated 0.10.1 — actual latest on Maven Central is 0.10.0)
- `org.jcodec:jcodec:0.2.5`
- `org.jcodec:jcodec-javase:0.2.5`
| # | Command | Result on macOS arm64 |
|---|---------|-----------------------|
| 0.1 | `./gradlew :desktopApp:compileKotlin` | ✅ `BUILD SUCCESSFUL` in 16s |
| 0.2 | `./gradlew :desktopApp:test` | ✅ `BUILD SUCCESSFUL` in 23s |
| 0.3 | `./gradlew :desktopApp:spotlessApply` | ✅ clean, no diff |
| 0.4 | `grep -rn 'vlcj\|caprica\|VlcjPlayerPool\|MacOsVlcDiscoverer\|BundledVlcDiscoverer\|VlcResourceResolver' desktopApp/src/ gradle/ desktopApp/build.gradle.kts` | ✅ Only doc-comment mentions in `VideoThumbnailCache.kt` header (intentional; describes what was replaced) |
Re-verify on Windows + Linux at your convenience.
---
## 1 · Bundled FFmpeg binaries (REQUIRED for thumbnail fallback)
The agent created the directory structure but didn't check in binaries
(~30 MB each, not appropriate for git). Drop them here before packaging:
| OS | Path | Source | Verify |
|----|------|--------|--------|
| macOS (arm64 + x86_64 universal) | `desktopApp/src/jvmMain/appResources/macos/ffmpeg/ffmpeg` | https://www.osxexperts.net/ LGPL build | `lipo -info ffmpeg` shows both arches; `chmod +x ffmpeg` |
| Windows (x64) | `desktopApp/src/jvmMain/appResources/windows/ffmpeg/ffmpeg.exe` | https://github.com/Crigges/Prebuilt-LGPL-2.1-FFmpeg-with-OpenH264 | `ffmpeg.exe -version` reports `--enable-version3 --disable-gpl` flags |
| Linux | _(skip — relies on host ffmpeg/gstreamer)_ | n/a | See `desktopApp/src/jvmMain/appResources/linux/ffmpeg/README.md` for self-contained AppImage option |
Each per-OS subdirectory has a `README.md` with the exact source URL.
---
## 2 · Smoke launch
| # | Action | Expected |
|---|--------|----------|
| 2.1 | `./gradlew :desktopApp:run` | Window opens. No log line mentions VLC, vlcj, libvlc, or "plugin path". |
| 2.2 | Look at stdout / stderr | No `WARN`/`ERROR` from missing native libs. First time kdroidFilter extracts its native to `~/.cache/composemediaplayer/native/` — that's expected. |
| 2.3 | App responds to clicks; UI is layout-identical to pre-migration | Yes |
---
## 3 · Codec / playback matrix
Use the URLs below (substitute your own equivalents if any are dead). All
verified by mounting the URL via the feed; you don't need to post any
Nostr event.
### Active video URLs to feed into the player (place into a draft note or open via the lightbox)
| # | Test | URL pattern | macOS | Windows | Linux |
|---|------|------|--------|---------|-------|
| 3.1 | H.264 MP4 (golden path) | `https://download.samplelib.com/mp4/sample-5s.mp4` | ✅ plays | ✅ plays | ✅ plays |
| 3.2 | HLS livestream (zap.stream) | a live zap.stream m3u8 URL | ✅ plays | ✅ plays | ✅ plays |
| 3.3 | HLS VOD H.264+AAC | `https://test-streams.mux.dev/x36xhzz/x36xhzz.m3u8` | ✅ plays | ✅ plays | ✅ plays |
| 3.4 | VP9 WebM | a known nostr.build VP9 webm URL | ✅ plays | ⚠️ codec-missing UX, "open externally" works | ✅ plays (if gst-libav installed) |
| 3.5 | AV1 MP4 | a known void.cat / nostr.build AV1 URL | ✅ plays (macOS 13+ or M3+ HW) | ⚠️ codec-missing UX | ✅ plays (if gst-libav installed) |
| 3.6 | HEVC MP4 | a known iOS-recorded HEVC URL | ✅ plays | ⚠️ "Install HEVC extension" UX | ✅ plays (if gst-libav installed) |
| 3.7 | Audio: MP3 | a podcast MP3 from feed | ✅ plays | ✅ plays | ✅ plays |
| 3.8 | Audio: Opus | a known Opus URL | ✅ plays | ✅ plays | ✅ plays (if gst-plugins-base installed) |
| 3.9 | Audio: AAC | a known AAC URL | ✅ plays | ✅ plays | ✅ plays |
| 3.10 | Garbage URL (404) | `https://example.invalid/nope.mp4` | "Can't play this video" + `Source:` reason; "Open in default player" button does nothing harmful | same | same |
**Phase 1 decision gate** (per plan): if 3.2 (zap.stream HLS livestream)
fails on any OS for reasons that aren't codec-related, pivot to the
gst1-java-core fallback (research doc §3 Recommended #2). Don't pivot for
3.4/3.5/3.6 — those gaps are expected on stock Win10 without the codec
extensions and are mitigated by the "open externally" UX.
---
## 4 · UI cross-state behaviour
| # | Scenario | Expected |
|---|----------|----------|
| 4.1 | Play feed video A → navigate to a different screen → return | Video continues playing, position preserved. (Singleton `GlobalMediaPlayer` retained.) |
| 4.2 | Play video A → tap play on video B | A stops, B starts from beginning. |
| 4.3 | Active video → tap fullscreen | Enters native fullscreen; same `VideoPlayerSurface` renders fullscreen; controls work; Esc / F exits. |
| 4.4 | Fullscreen video → press Spacebar | Toggles play/pause. |
| 4.5 | NowPlayingBar mini-preview | Mini 48×36 video preview renders inside the bottom bar while video is active. |
| 4.6 | Audio playback → navigate around | NowPlayingBar shows music icon (no video) + correct artist/title (URL filename suffices for now). |
| 4.7 | Mute → unmute | Volume restores to pre-mute value. |
| 4.8 | Volume slider → 0 | `volume = 0` reflects, but `isMuted` flag stays false (matches kdroidFilter's no-mute-flag semantics; explicit mute is separate). |
| 4.9 | Stop video / audio while playing | Engine cleanly stops; UI returns to neutral state. |
| 4.10 | Quit app while playing | No segfault, no lingering native processes. (kdroidFilter registers its own shutdown hook on Windows for MediaFoundation.) |
---
## 5 · Thumbnail extraction
| # | Scenario | Expected |
|---|----------|----------|
| 5.1 | H.264 MP4 in feed (inactive) | Thumbnail appears within 2-3s. JCodec handles this — confirms by speed. |
| 5.2 | VP9 WebM in feed (inactive) | Thumbnail appears (slower, ~5s) — JCodec rejects, falls through to ffmpeg. Verifies the cascade. |
| 5.3 | Same feed reopened | Thumbnails appear instantly from in-memory cache. |
| 5.4 | No bundled ffmpeg + no system ffmpeg | Thumbnails fail silently for non-H.264 sources; H.264 still works (JCodec). No crash. Document this UX in release notes. |
| 5.5 | HLS livestream thumbnail | Should NOT block playback. JCodec auto-skipped because URL ends `.m3u8` or contains `/hls/`. Falls through to `ffmpeg URL` directly. |
---
## 6 · License metadata verification
| # | Artifact | Verify |
|---|----------|--------|
| 6.1 | `./gradlew :desktopApp:packageRpm``rpm -qpi build/compose/binaries/main-release/rpm/*.rpm` | `License: MIT AND LGPL-2.1-or-later AND BSD-2-Clause AND Apache-2.0` |
| 6.2 | `./gradlew :desktopApp:packageDmg` → mount DMG | Inside `Amethyst.app/Contents/app/resources/common/``NOTICE.md`, `licenses/LICENSE-MIT-amethyst.txt`, `licenses/LICENSE-MIT-kdroidfilter.txt`, `licenses/LICENSE-BSD-2-jcodec.txt`, `licenses/LICENSE-LGPL-2.1.txt` |
| 6.3 | `grep -rn 'vlcj\|GPL-3' desktopApp/src/jvmMain/appResources/common/` | Zero hits — confirms no stale GPL references shipped |
| 6.4 | `LICENSE-LGPL-2.1.txt` | **Currently a placeholder.** Replace with verbatim GNU LGPL-2.1 text (~26 KB) from https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt before any release. The plan acceptance gate flags this. |
---
## 7 · Flathub manifest (deferred submission)
| # | Action | Expected |
|---|--------|----------|
| 7.1 | `flatpak install org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08 org.freedesktop.Sdk.Extension.openjdk21//24.08` | OK |
| 7.2 | `./gradlew :desktopApp:createReleaseDistributable` | Produces `desktopApp/build/compose/binaries/main-release/app/Amethyst/` |
| 7.3 | `cd desktopApp/packaging/flatpak && flatpak-builder --user --install --force-clean build-dir com.vitorpamplona.amethyst.Desktop.yml` | OK (may need icon at `icons/256/com.vitorpamplona.amethyst.Desktop.png` — copy from `desktopApp/src/jvmMain/resources/icon.png` first) |
| 7.4 | `flatpak run com.vitorpamplona.amethyst.Desktop` | App launches; video playback works (host org.freedesktop.Platform ships GStreamer 1.24) |
| 7.5 | Actual Flathub submission | Out of scope for this PR; follow steps in `desktopApp/packaging/flatpak/README.md` |
---
## 8 · macOS code signing (deferred — only if you sign before this PR merges)
| # | Action | Expected |
|---|--------|----------|
| 8.1 | jpackage with `--mac-sign --mac-signing-key-user-name "Developer ID Application: …"` | `codesign --verify --deep --strict Amethyst.app` is clean; nested `Contents/app/resources/macos/ffmpeg/ffmpeg` is signed with the same identity |
| 8.2 | Entitlements include `com.apple.security.cs.allow-jit`, `com.apple.security.cs.allow-unsigned-executable-memory`, `com.apple.security.cs.disable-library-validation` | Required for HotSpot + sibling dylib loading |
| 8.3 | `xcrun notarytool submit Amethyst.dmg` succeeds | Apple notarizes the DMG including nested ffmpeg |
| 8.4 | `xcrun stapler staple Amethyst.dmg` succeeds | Ticket stapled |
Detailed recipe: `docs/plans/_macos-ffmpeg-signing-recipe.md`.
---
## 9 · Known caveats / follow-ups for after this PR
These are intentional gaps, not bugs:
1. **In-app "Open source licenses" screen** — not yet wired into desktop settings UI. NOTICE.md and licenses/ ship as resources but a Compose composable reading them isn't included. Recommended follow-up: add `mikepenz/AboutLibraries` Gradle plugin (auto-discovers Maven licenses) + a `LicensesScreen.kt` rendering the report; integrate into the settings nav.
2. **LGPL-2.1 license text is a placeholder** — flagged in 6.4. The release-gate fix is "paste the verbatim 26 KB text into `LICENSE-LGPL-2.1.txt`".
3. **`tryJaffree*` and `runFfmpegToImage`** — the Jaffree dependency was dropped in favor of raw ProcessBuilder for simplicity. Renaming the internal `tryJaffree*` functions to `tryFfmpeg*` is a cosmetic follow-up.
4. **No GStreamer probe on Linux** — kdroidFilter's `SourceError` is indistinguishable from "missing gst-libav" without probing. Follow-up: add a one-time `gst-inspect-1.0 avdec_h264` probe on Linux startup; show a non-blocking install hint if missing.
5. **Windows codec-extension detection** — currently we just rely on kdroidFilter's `SourceError` and our error UX. A nicer follow-up: probe `MFTEnumEx` to detect HEVC/AV1 codec availability up front and prompt for the MS Store extension.
6. **Flathub icon file** — manifest references `icons/256/com.vitorpamplona.amethyst.Desktop.png` which doesn't exist. Copy/resize from `desktopApp/src/jvmMain/resources/icon.png` before the first Flathub submission.
---
## 10 · Roll-back path
If anything in 0-5 blocks shipping:
- **Code revert:** `git revert <merge-commit>` of this PR is clean — vlcj files were deleted, new files are isolated to `desktopApp/`.
- **Branch:** `git branch -D worktree-vlcj-licensing-brainstorm` (or whatever the merged branch was). `git checkout main && git pull` returns to vlcj-based desktop.
- **No data migration:** nothing on disk format changed. Settings, account state, downloaded thumbnails — all forward+backward compatible.
---
## Self-check before opening the PR
- [ ] 0.1 BUILD SUCCESSFUL on my machine
- [ ] 0.2 tests green
- [ ] 0.4 no vlcj source code references
- [ ] 1 ffmpeg binaries in place for the OSes I'm packaging
- [ ] 2.1 app launches without VLC warnings
- [ ] 3.1, 3.2, 3.3, 3.7 pass on at least my primary OS
- [ ] 6.1 RPM license metadata correct
- [ ] 6.4 LGPL text placeholder noted in release checklist

View File

@@ -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" }