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