From a57a3c1bba3f6178244e6307eacdaddd0dfbb1d2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 17:05:38 +0700 Subject: [PATCH 1/9] chore(core): bump submodule for mpv desktop backend --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index f9f1fb5e8..9a4ff00a3 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit f9f1fb5e8682220954707adc4e2b7ad77a394919 +Subproject commit 9a4ff00a3280abf9d106f7f643071ca852a4600f From 160f9ff62b6a3a7e1ae1ce9bfcde45202fb5261d Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 17:41:25 +0700 Subject: [PATCH 2/9] chore(core): bump submodule for mpv DJ crossfade --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 9a4ff00a3..b1afe0940 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 9a4ff00a3280abf9d106f7f643071ca852a4600f +Subproject commit b1afe09409d36fb5c2afa785db97997c5c9c86ef From 88be088bc290b16418830460ef24a02ff9bfc609 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 21:19:07 +0700 Subject: [PATCH 3/9] chore(desktop): replace VLC natives with prebuilt libmpv slices --- .github/workflows/android-release.yml | 9 - .github/workflows/desktop-package.yml | 24 +- .gitignore | 4 +- CLAUDE.md | 27 +- composeApp/build.gradle.kts | 865 ++++++++++++------ .../simpmusic/ui/screen/home/SettingScreen.kt | 51 +- conveyor.conf | 42 +- core | 2 +- desktopApp/build.gradle.kts | 66 +- .../kotlin/com/maxrave/simpmusic/Main.kt | 2 +- gradle/libs.versions.toml | 4 - 11 files changed, 696 insertions(+), 400 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index dd31e3ca4..5a108c42e 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -157,15 +157,6 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions - - name: Cache vlc-natives - uses: actions/cache@v4 - with: - path: vlc-natives - key: vlc-natives-${{ hashFiles('gradle/libs.versions.toml') }} - - - name: Populate vlc-natives for all OSes - run: ./gradlew :composeApp:vlcSetupAll --no-configuration-cache - - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads # the static generated.conveyor.conf and never tastes Gradle stdout diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index b8335856b..283edabb8 100644 --- a/.github/workflows/desktop-package.yml +++ b/.github/workflows/desktop-package.yml @@ -52,19 +52,13 @@ jobs: distribution: "microsoft" cache: 'gradle' - - name: Install 7-Zip 24.x + libfuse2 - # Ubuntu's p7zip-full (16.02) can't extract HFS+ DMG content fully — - # outer kolyDMG opens but the inner HFS+ partition stays opaque, so - # VLC.app never materializes. Use the official Igor Pavlov build, - # which has full HFS+ support like the modern Linux/macOS package. - # libfuse2 is needed by appimagetool when wrapping the Linux app. + - name: Install libfuse2 + # Needed by appimagetool when wrapping the Linux app. Nothing else is + # required here: mpvSetupAll only downloads and untars the prebuilt + # native slices, so no 7-Zip, patchelf or dwarfs toolchain on the runner. run: | sudo apt-get update sudo apt-get install -y libfuse2 - curl -fsSL https://github.com/ip7z/7zip/releases/download/26.01/7z2601-linux-x64.tar.xz -o /tmp/7zz.tar.xz - sudo tar -xf /tmp/7zz.tar.xz -C /usr/local/bin/ 7zz - sudo ln -sf /usr/local/bin/7zz /usr/local/bin/7z - 7z i | head -3 - name: Update build product flavor run: | @@ -82,14 +76,14 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions - - name: Cache vlc-natives + - name: Cache mpv-natives uses: actions/cache@v4 with: - path: vlc-natives - key: vlc-natives-${{ hashFiles('gradle/libs.versions.toml') }} + path: mpv-natives + key: mpv-natives-${{ hashFiles('composeApp/build.gradle.kts') }} - - name: Populate vlc-natives for all OSes - run: ./gradlew :composeApp:vlcSetupAll --no-configuration-cache + - name: Populate mpv-natives for all OSes + run: ./gradlew :composeApp:mpvSetupAll --no-configuration-cache - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads diff --git a/.gitignore b/.gitignore index fb8e7edc5..c6077e489 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,8 @@ sentry.properties /.claude/settings.local.json /androidApp/build/ /androidApp/cache/ -# VLC native libraries (downloaded by vlc-setup plugin) -/vlc-natives/ +# libmpv native libraries (downloaded by `./gradlew :composeApp:mpvSetupAll`) +/mpv-natives/ /.omc/ /desktopApp/build/ # Conveyor auto-generated config (run `./gradlew :desktopApp:writeConveyorConfig`) diff --git a/CLAUDE.md b/CLAUDE.md index 665297767..928a41800 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -100,7 +100,7 @@ Contains core modules organized by functionality: ##### **core/media/** - **media3/**: Media3 ExoPlayer integration (includes `CrossfadeExoPlayerAdapter` for DJ-style crossfade on Android) - **media3-ui/**: Media3 UI components -- **media-jvm/**: JVM media playback (VLCJ - replaced GStreamer post-1.0.4) +- **media-jvm/**: JVM media playback (libmpv via JNA — replaced VLCJ, which replaced GStreamer post-1.0.4) - **media-jvm-ui/**: JVM media UI components ##### **core/service/** @@ -135,8 +135,8 @@ Service modules: ### Desktop - **Compose for Desktop**: UI -- **VLCJ**: Audio playback (replaced GStreamer since post-1.0.4) -- VLC native libraries are bundled per platform via `vlc-setup` Gradle plugin +- **libmpv** (mpv's C client API, bound with JNA): audio + video playback. Replaced VLCJ, which had replaced GStreamer post-1.0.4 +- libmpv natives are bundled per platform via `./gradlew :composeApp:mpvSetupAll` into `mpv-natives/-/` ### Networking & APIs - **Ktor Client**: HTTP client @@ -261,7 +261,7 @@ Before implementing code, researching code, or answering technical questions, th ### 5. Work with Media Playback **Location**: `core/media/media3/` (Android) or `core/media/media-jvm/` (Desktop) - Media3/ExoPlayer + CrossfadeExoPlayerAdapter for Android -- VLCJ (VlcPlayerAdapter) for Desktop +- libmpv (MpvPlayerAdapter / MpvPlayer / MpvLibrary) for Desktop - Queue management in `core/data/src/.../mediaservice/` - Playback controls @@ -332,7 +332,7 @@ Before implementing code, researching code, or answering technical questions, th #### Desktop - **Required Dependencies**: - - VLCJ: Audio playback (bundled via vlc-setup plugin) + - libmpv: audio + video playback (bundled via `mpvSetupAll`; falls back to a system-wide libmpv when `mpv-natives/` has not been staged) - **Features**: - Deep link support (`simpmusic://` and `simpmusic.org`) - Mini Player window (always-on-top, resizable, draggable) @@ -351,13 +351,15 @@ Before implementing code, researching code, or answering technical questions, th ## 🎵 Media Playback Architecture -### Desktop Player (VLCJ - replaced GStreamer post-1.0.4) +### Desktop Player (libmpv — replaced VLCJ 2026-07-27) -**Location**: `core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/VlcPlayerAdapter.kt` +**Location**: `core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/` -- Uses **VLCJ** library for audio playback (GStreamer was removed) -- VLC native libraries bundled per platform via `vlc-setup` Gradle plugin in `composeApp/build.gradle.kts` -- Bundled natives stored in `vlc-natives/{linux,macos,windows}/` +- `MpvLibrary.kt` — JNA binding for libmpv's C client API, hand-mapped against client API 2.x. Struct layouts are read by raw offset, so a MAJOR client-API bump needs them re-verified +- `MpvPlayer.kt` — one handle per media item; `vo=libmpv` + software render context +- `MpvVideoSurfacePanel.kt` — mpv SW render API → `BufferedImage` → Swing, embedded in Compose via `SwingPanel` +- `MpvPlayerAdapter.kt` — the `MediaPlayerInterface` implementation; separate YouTube audio/video URLs are merged into ONE source with an `edl://...;!new_stream;...` URL (mpv's equivalent of Android's `MergingMediaSource`) +- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) @@ -408,7 +410,8 @@ See `CODE_OF_CONDUCT.md` - [Media3 (ExoPlayer)](https://developer.android.com/guide/topics/media/media3) - [Room Database](https://developer.android.com/training/data-storage/room) - [Ktor Client](https://ktor.io/docs/client.html) -- [VLCJ](https://github.com/caprica/vlcj) +- [libmpv client API](https://github.com/mpv-player/mpv/blob/master/include/mpv/client.h) +- [mpv EDL format](https://github.com/mpv-player/mpv/blob/master/DOCS/edl-mpv.rst) ### Community - Website: https://simpmusic.org @@ -480,6 +483,8 @@ if (getPlatform() == Platform.Android) { - **VM environment detection**: Disable transparency and custom titlebar in VMs - **Google Cast (2026-07, Full build only)**: `cast`/`cast-empty` module pair gated by `isFullBuild`; unified Media3 `CastPlayer` wraps the session `ForwardingPlayer`; `CastHandoffManager` pushes resolved-URL queue windows to the receiver with 403/expiry retry; Cast button in Now Playing top bar, "Playing on " pill, crossfade/DJ/EQ settings gray out while casting; FOSS build stays GMS-free - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. +- **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. +- **Bundled libmpv (2026-07-27)**: `./gradlew :composeApp:mpvSetupAll` stages libmpv + its dependency closure into `mpv-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/`, wired into installers via `mpv-natives` inputs in `conveyor.conf` (`to = "mpv"`). Sources: shinchiro `mpv-dev-*.7z` (Windows, self-contained), pkgforge-dev mpv-AppImage (Linux, sharun `$ORIGIN`), Homebrew + `dylibbundler` (macOS). **The macOS slice needs a macOS runner** — one per architecture — because the closure is gathered from a Homebrew install; `mpvSetupMacCi` skips itself on other hosts. Do NOT try to lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. `MpvLibrary.bundledLibraryDirs()` resolves the staged folder (`mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`), mirroring `DefaultVlcDiscoverer`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 270c8c11f..b66b5fdd8 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -25,7 +25,6 @@ plugins { alias(libs.plugins.build.config) alias(libs.plugins.osdetector) alias(libs.plugins.packagedeps) - alias(libs.plugins.vlc.setup) } // composeApp uses the `android.kotlin.multiplatform.library` plugin, so with the @@ -175,7 +174,7 @@ kotlin { implementation(libs.kotlin.test) } jvmMain.dependencies { - // Desktop app entry (main.kt), VLC setup, jpackage/Conveyor + // Desktop app entry (main.kt), jpackage/Conveyor // packaging, and tray icon live in :desktopApp per the // JetBrains 2026 KMP default structure. This module keeps the // shared JVM UI + expect/actuals and their direct dependencies. @@ -192,94 +191,22 @@ kotlin { // linuxDebConfig{}, the custom AppImage tooling, and Conveyor packaging // live in :desktopApp per the JetBrains 2026 KMP default structure. // -// vlcSetup{} stays in :composeApp — moving it to :desktopApp fails -// because vlc.setup eagerly iterates tasks at apply time, which force- -// realizes Conveyor's lazily-registered writeConveyorConfig task before -// kotlin.multiplatform has created jvmJar → "Task with name 'jar' not -// found" (reproduced 2026-05-21 with vlc.setup placed last in the -// plugins block — plugin order does not help). composeApp has no -// Conveyor so the conflict cannot occur here. -// -// Run `./gradlew :composeApp:vlcSetup --no-configuration-cache` to -// populate vlc-natives/{linux-x64,macos-,windows-x64}/. Layout -// is per-arch so Conveyor can bundle the right native slice into each -// per-machine installer (universal Mac dylibs almost doubled artifact -// size pre-split — see commit message for context). -val hostMacArchDir = if (System.getProperty("os.arch").lowercase().contains("aarch64")) { - "macos-arm64" -} else { - "macos-x64" -} -val hostWinArchDir = if (System.getProperty("os.arch").lowercase().contains("aarch64")) { - "windows-arm64" -} else { - "windows-x64" -} -vlcSetup { - vlcVersion = libs.versions.vlc.get() - shouldCompressVlcFiles = false - shouldIncludeAllVlcFiles = true - pathToCopyVlcLinuxFilesTo = rootDir.resolve("vlc-natives/linux-x64/") - pathToCopyVlcMacosFilesTo = rootDir.resolve("vlc-natives/$hostMacArchDir/") - pathToCopyVlcWindowsFilesTo = rootDir.resolve("vlc-natives/$hostWinArchDir/") -} - -// Flatten vlc-natives//vlc/plugins → vlc-natives//plugins after -// vlc-setup copies files. The plugin ships a nested vlc/ subdir for VLC's -// own path resolution, but Conveyor then copies both the nested tree AND -// extracts the .so files flat at the parent → 2× duplication (~348 MB -// extra) in the packaged AppImage. Flat layout keeps Conveyor lean while -// VLCJ still resolves libs via DefaultVlcDiscoverer. -tasks.named("vlcSetup").configure { - doLast { - listOf("linux-x64", hostMacArchDir, hostWinArchDir).forEach { archDir -> - val root = rootDir.resolve("vlc-natives/$archDir") - val nested = root.resolve("vlc") - if (nested.isDirectory) { - nested.listFiles()?.forEach { child -> - val target = root.resolve(child.name) - if (target.exists()) target.deleteRecursively() - child.renameTo(target) - } - nested.deleteRecursively() - } - } - } -} - -// ============================================================================ -// Cross-OS VLC natives downloader (single-runner CI) -// ============================================================================ -// The `vlc-setup` plugin above only registers `vlcSetup` for the HOST OS -// (LinuxTasksConfigure / MacTasksConfigure / WindowsTasksConfigure each -// gate on `getCurrentOs() == OS.X`). To package multi-OS artifacts from a -// single CI runner (Linux), we replicate the plugin's download + filter + -// copy logic for the OTHER two OSes here. -// -// Resulting layout matches the upstream plugin so VLCJ's -// DefaultVlcDiscoverer keeps working unchanged. -// -// Local dev: keep using `./gradlew :composeApp:vlcSetup` (host-OS only). -// Cross-OS CI: use `./gradlew :composeApp:vlcSetupAll`. -// -// Mac DMG extraction needs the 7z tool: -// Ubuntu CI: sudo apt-get install -y p7zip-full -// macOS local: brew install p7zip -val vlcCacheDir = layout.buildDirectory.dir("vlc-cache") +// Native-library staging (libmpv) lives here in :composeApp — see the +// mpv-natives block below. The layout is per-arch so Conveyor bundles only +// the slice each per-machine installer actually needs. -fun downloadIfMissing(url: String, target: java.io.File) { +fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mpv-multi") { if (target.exists() && target.length() > 0) { - logger.lifecycle("[vlc-multi] Cached: ${target.name}") + logger.lifecycle("[$logPrefix] Cached: ${target.name}") return } - logger.lifecycle("[vlc-multi] Downloading $url") + logger.lifecycle("[$logPrefix] Downloading $url") target.parentFile.mkdirs() - // Use curl instead of Java's URL.openStream() because get.videolan.org - // returns a 302 redirect to a random mirror per request, and Java's - // default HttpURLConnection redirect handling is fragile — if the - // mirror lands on a cross-protocol redirect or returns an HTML error - // page, openStream() silently saves the HTML response as the target - // file, producing a "Cannot expand ZIP" downstream. curl's `-L` + // Use curl instead of Java's URL.openStream(): release downloads answer + // with a redirect to a CDN, and Java's default HttpURLConnection redirect + // handling is fragile — on a cross-protocol redirect or an HTML error + // page, openStream() silently saves the error body as the target file, + // which only surfaces as a corrupt-archive failure later. curl's `-L` // follows redirects robustly across protocols + mirrors, `--fail` // exits non-zero on HTTP errors instead of saving error bodies, and // `-o` writes atomically via tmp file. The downloaded artifact is @@ -304,237 +231,611 @@ fun downloadIfMissing(url: String, target: java.io.File) { } } -val vlcSetupLinuxCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/linux-x64/ with .so files." - val outputDir = rootDir.resolve("vlc-natives/linux-x64/") - outputs.dir(outputDir) - doLast { - // Pinned upstream — Linux artifact is a custom Maven package whose - // version is independent of the desktop VLC release. - val linuxVersion = "3.0.20-2" - val cache = vlcCacheDir.get().asFile - val jar = cache.resolve("vlc-plugins-linux-$linuxVersion.jar") - downloadIfMissing( - "https://repo1.maven.org/maven2/ir/mahozad/vlc-plugins-linux/$linuxVersion/vlc-plugins-linux-$linuxVersion.jar", - jar, - ) - outputDir.walk().filter { it.extension == "so" }.forEach { it.delete() } - project.copy { - from(zipTree(jar)) - into(outputDir) - // Ship the full VLC plugin set (matches the v1.2.1 release). - // A curated subset based on upstream vlc-setup defaults turned - // out to be insufficient for SimpMusic — YT Music streaming - // depends on HTTP/HTTPS access + MP4/WebM demuxers that the - // upstream music-app preset doesn't cover. `**/` is needed - // because include() evaluates against the original jar paths - // (which include the `vlc-plugins-linux-/` top-level dir) - // before the eachFile drop(1) transformation kicks in. - include("**/*.so", "**/*.so.*") - // Strip the top-level dir inside the jar (matches upstream plugin). - eachFile { - if (relativePath.segments.size > 1) { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } - includeEmptyDirs = false - } - // Flatten vlc-natives/linux-x64/vlc/* → vlc-natives/linux-x64/* (same - // as the host-OS flatten task above) so Conveyor doesn't duplicate - // plugins. - val nested = outputDir.resolve("vlc") - if (nested.isDirectory) { - nested.listFiles()?.forEach { child -> - val target = outputDir.resolve(child.name) - if (target.exists()) target.deleteRecursively() - child.renameTo(target) +// =========================================================================== +// mpv natives (libmpv) — staged into mpv-natives/-/ for packaging. +// +// mpv publishes no portable libmpv of its own and there is no Gradle plugin +// that fetches one, so each OS lifts libmpv out of a prebuilt artifact that +// ALREADY ships a relocatable dependency closure. That is what keeps this +// cheap: gathering ffmpeg / libplacebo / libass / luajit by hand and +// rewriting their install names is precisely the work these upstreams have +// already done and keep doing on every release. +// +// Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` +// → libmpv-2.dll with ffmpeg linked in. Nothing to patch. +// Linux pkgforge-dev/mpv-AppImage +// → libmpv.so.2 + closure under shared/lib with RPATH=$ORIGIN +// (sharun), so nothing to patch there either. +// macOS Homebrew's mpv + dylibbundler, run on a macOS host. +// +// macOS is the odd one out and deliberately so. The obvious shortcut — lifting +// IINA.app/Contents/Frameworks straight out of IINA's .dmg — DOES NOT WORK, +// and fails in a way worth recording so nobody retries it. In IINA 1.4.4 the +// bundled pair is version-skewed: +// +// nm -u libmpv.2.dylib → _pl_log_create_349 (libplacebo API 349) +// nm -gU libplacebo.338.dylib → _pl_log_create_338 +// +// on BOTH slices of the fat binaries, with only one libplacebo in the bundle +// and the reference not weak ("(undefined) external ... (from libplacebo.338)"). +// dlopen() of that libmpv fails outright — verified with RTLD_NOW *and* +// RTLD_LAZY — and libmpv has 136 undefined _pl_* symbols riding on it. Whatever +// makes IINA itself work, that closure is not self-sufficient, and MpvLibrary +// loads with RTLD_NOW by design, so there is no flag to hide behind. +// +// Homebrew resolves mpv and libplacebo as one dependency graph, so its closure +// is self-consistent by construction. dylibbundler then copies that closure and +// rewrites every dependency to @loader_path/... in one pass. The dylibs must be +// re-signed ad-hoc afterwards: mutating a Mach-O invalidates its signature and +// macOS refuses to load an invalidly-signed dylib (hard failure on Apple +// Silicon). Cost of this route: the macOS slice needs a macOS runner, one per +// architecture — it cannot be produced from the Linux runner that builds +// every other slice. +// +// Conveyor stages one slice per machine — see the mpv-natives inputs in +// conveyor.conf. +// =========================================================================== +val mpvCacheDir = layout.buildDirectory.dir("mpv-cache") + +// Pinned upstream artifacts. Bump deliberately: MpvLibrary.kt hand-maps the +// libmpv struct layouts by raw offset, so a client-API MAJOR bump means the +// structs must be re-verified before these pins move. mpv 0.41.x is client +// API 2.5; MpvLibrary.kt was written against 2.2 and only guards the major. +// mpv's own tagged release — the source of the macOS slices. +val mpvVersion = "0.41.0" +val mpvWinBuildTag = "20260610" +val mpvWinBuildSuffix = "20260610-git-304426c" +// Percent-encoded because the release tag embeds an '@'. Kept as a literal rather than +// URLEncoder.encode(): in a build script `java` resolves to the JavaPluginExtension +// accessor, so `java.net.URLEncoder` is unresolvable in expression position. +val mpvAppImageTagEncoded = "v0.41.0%402026-07-01_1782914175" +val mpvAppImageVersion = "v0.41.0" +// Reads the AppImage's DwarFS payload. 0.15.6 handles DwarFS v2.5, which is what this +// AppImage carries; an older dwarfs reports "unsupported major version". +val dwarfsVersion = "0.15.6" + +// Every extractor below finds the directory that actually holds the libmpv +// artifact and copies its whole sibling set, rather than hard-coding upstream +// tree shapes. Those layouts drift between releases; "the folder libmpv lives +// in" does not. +fun findDirContaining(root: java.io.File, namePredicate: (String) -> Boolean): java.io.File? = + root.walkTopDown() + .firstOrNull { it.isFile && namePredicate(it.name) } + ?.parentFile + +/** True when [tool] can be executed at all (i.e. it exists on PATH). */ +fun toolAvailable(tool: String): Boolean = + try { + ProcessBuilder(tool) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() + true + } catch (e: java.io.IOException) { + false + } + +fun runChecked(vararg command: String) { + val exit = ProcessBuilder(*command).inheritIO().start().waitFor() + check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } +} + +// --------------------------------------------------------------------------- +// macOS +// --------------------------------------------------------------------------- + +/** + * Rewrite every `@executable_path/lib/` load-command entry to [prefix]``. + * + * Patches bytes instead of shelling out to `install_name_tool`, which is what keeps the macOS + * slice buildable on ANY host — the whole point, since every other slice comes off the same + * Linux runner. + * + * Safe because the replacement is always SHORTER than what it replaces (`@executable_path/lib/` + * is 21 chars; `@loader_path/lib/` is 17 and `@loader_path/` is 13), so the tail is NUL-padded: + * a Mach-O dylib path is a C string inside a load command of fixed `cmdsize`, and it ends at the + * first NUL. Growing a path would need the command resized, which this deliberately never does. + * + * @return how many entries were rewritten. + */ +fun rewriteExecutablePathRefs(file: java.io.File, prefix: String): Int { + val needle = "@executable_path/lib/".toByteArray(Charsets.US_ASCII) + val prefixBytes = prefix.toByteArray(Charsets.US_ASCII) + check(prefixBytes.size <= needle.size) { + "prefix '$prefix' is longer than '@executable_path/lib/' — paths can only be shortened in place" + } + val data = file.readBytes() + var rewritten = 0 + var i = 0 + outer@ while (i <= data.size - needle.size) { + for (j in needle.indices) { + if (data[i + j] != needle[j]) { + i++ + continue@outer } - nested.deleteRecursively() } + // The entry runs from the match to its terminating NUL. + var end = i + needle.size + while (end < data.size && data[end] != 0.toByte()) end++ + val name = data.copyOfRange(i + needle.size, end) + val replacement = prefixBytes + name + check(replacement.size <= end - i) + replacement.copyInto(data, i) + for (k in i + replacement.size until end) data[k] = 0 + rewritten++ + i = end } + if (rewritten > 0) file.writeBytes(data) + return rewritten } -// Shared Mac DMG download + extraction logic. Each per-arch task calls -// this with its slice's DMG suffix ("arm64" or "intel64") and output -// folder. We pull per-arch DMGs (48-55 MB each) instead of the universal -// DMG (84.9 MB = arm64 + intel64 fat binary), so each per-machine zip -// only ships its own slice — saves ~25-40 MB per user download. -fun extractMacVlcSlice( - archSuffix: String, - outputDir: java.io.File, -) { - val macVersion = libs.versions.vlc.get() - val cache = vlcCacheDir.get().asFile - val dmg = cache.resolve("vlc-$macVersion-$archSuffix.dmg") +/** + * Re-sign every Mach-O under [dir] ad-hoc, recursively. + * + * Rewriting load commands invalidates the signature mpv's CI applied, and macOS refuses to load + * an invalidly-signed Mach-O (a hard failure on Apple Silicon). `codesign` only exists on macOS, + * so on a Linux runner this warns instead: Conveyor signs the macOS bundle it produces, which is + * what makes the staged slice loadable in the shipped app. A locally staged slice built on Linux + * and run directly, without going through Conveyor, would not load. + */ +fun codesignAdhoc(dir: java.io.File) { + val machO = dir.walkTopDown().filter { it.isFile && (it.name.endsWith(".dylib") || it.extension.isEmpty()) }.toList() + check(machO.isNotEmpty()) { "Nothing to sign in ${dir.absolutePath}" } + + // Patching load commands invalidates mpv's own signature and macOS refuses to load an + // invalidly-signed Mach-O, so re-signing is mandatory. `codesign` is macOS-only, which is + // fine: these tasks build the published archives and are run on a Mac, not in CI. + check(toolAvailable("codesign")) { + "codesign is required to re-sign the patched macOS slice — run this task on a Mac. " + + "CI does not: it downloads the prebuilt archives instead." + } + logger.lifecycle("[mpv-multi] Ad-hoc signing ${machO.size} Mach-O files in ${dir.name}") + machO.forEach { runChecked("codesign", "--force", "--sign", "-", it.absolutePath) } +} + +/** + * Stage one macOS slice straight out of mpv's own release build. + * + * mpv's macOS artifacts are app bundles that link libmpv STATICALLY into + * `mpv.app/Contents/MacOS/mpv`, so there is no libmpv.dylib to copy — but that binary is a PIE + * Mach-O exporting the whole client API (54 `_mpv_*` symbols, checked with `nm -gU`), and macOS + * `dlopen()` accepts a PIE executable. Renaming it to `libmpv.dylib` is what lets JNA find it: + * `Native.load("mpv")` maps to exactly that filename on macOS (NativeLibrary.mapSharedLibraryName). + * + * Its dependency closure ships alongside in `Contents/MacOS/lib/` already relocatable via + * `@executable_path/lib/...`; only the anchor has to change, because the loading process here is + * the JVM rather than mpv itself. Two different prefixes are needed: libmpv sits one level above + * `lib/`, while the closure sits inside it. + * + * Do NOT swap this for IINA's .dmg. IINA 1.4.4 ships a libmpv that needs libplacebo API 349 next + * to a libplacebo.338 exporting only 338, on both slices and not weakly referenced, so its libmpv + * fails dlopen under RTLD_NOW *and* RTLD_LAZY. + */ +fun extractMacMpvSlice(assetArch: String, outputDir: java.io.File) { + val cache = mpvCacheDir.get().asFile + val zip = cache.resolve("mpv-$mpvVersion-$assetArch.zip") downloadIfMissing( - "https://get.videolan.org/vlc/$macVersion/macosx/vlc-$macVersion-$archSuffix.dmg", - dmg, + "https://github.com/mpv-player/mpv/releases/download/v$mpvVersion/mpv-v$mpvVersion-$assetArch.zip", + zip, + logPrefix = "mpv-multi", ) - outputDir.walk().filter { it.extension == "dylib" }.forEach { it.delete() } - outputDir.mkdirs() - // Pick the extractor that's native to the host: - // • macOS → hdiutil (built-in, no install required for local dev) - // • Linux/Windows CI → 7z (cross-platform HFS+ support, needs - // p7zip-full / official 7-Zip 23+ installed on the runner) - // Both paths drop a directory containing the VLC.app payload at - // `macOsDir`, ready for the curated copy step below. - val isMacHost = System.getProperty("os.name").lowercase().contains("mac") - val macOsDir: java.io.File - val cleanupMount: (() -> Unit)? - if (isMacHost) { - val mountPoint = cache.resolve("vlc-mount-$macVersion-$archSuffix") - mountPoint.deleteRecursively() - mountPoint.mkdirs() - val attachExit = ProcessBuilder( - "hdiutil", "attach", - "-mountpoint", mountPoint.absolutePath, - "-nobrowse", "-quiet", - dmg.absolutePath, - ).inheritIO().start().waitFor() - check(attachExit == 0) { - "hdiutil attach failed with exit code $attachExit for $dmg" - } - macOsDir = mountPoint.resolve("VLC.app/Contents/MacOS") - cleanupMount = { - ProcessBuilder("hdiutil", "detach", "-quiet", mountPoint.absolutePath) - .inheritIO().start().waitFor() - } - } else { - val extractDir = cache.resolve("vlc-mac-$macVersion-$archSuffix-extract") - extractDir.deleteRecursively() - extractDir.mkdirs() - // 7z returns exit code 2 because the DMG contains a "VLC media - // player/Applications → /Applications" drag-to-install symlink - // that 7z refuses to extract (dangerous absolute link). The - // VLC.app payload extracts fine, so we verify by directory - // presence below rather than trusting the exit code. - val sevenZipExit = ProcessBuilder( - "7z", "x", "-y", "-bso0", "-bsp0", - "-o${extractDir.absolutePath}", - dmg.absolutePath, - ).inheritIO().start().waitFor() - macOsDir = extractDir.walkTopDown() - .firstOrNull { - it.isDirectory && - it.name == "MacOS" && - it.parentFile?.name == "Contents" && - it.parentFile?.parentFile?.name == "VLC.app" - } - ?: error( - "VLC.app/Contents/MacOS/ not found inside extracted DMG at $extractDir " + - "(7z exit code $sevenZipExit)", - ) - cleanupMount = null + + // The published .zip wraps a .tar.gz, so this unpacks twice. Both steps are plain Gradle file + // operations — no 7z, no hdiutil, nothing host-specific. + val stage = cache.resolve("mac-$assetArch-extract") + stage.deleteRecursively() + stage.mkdirs() + project.copy { + from(zipTree(zip)) + into(stage) } - check(macOsDir.isDirectory) { - "VLC.app/Contents/MacOS not found at ${macOsDir.absolutePath}" + val innerTar = stage.walkTopDown().firstOrNull { it.isFile && it.name.endsWith(".tar.gz") } + ?: error("No inner tarball inside ${zip.name}") + project.copy { + from(tarTree(resources.gzip(innerTar))) + into(stage) } - try { - project.copy { - from(macOsDir) - into(outputDir) - // Ship the full VLC plugin set (matches v1.2.1 release). - // Curated music-app preset from upstream vlc-setup didn't - // include HTTP/HTTPS access + MP4/WebM demuxers needed for - // YT Music streaming. - include("lib/libvlc.dylib", "lib/libvlccore.dylib", "plugins/**") - // Flatten lib/ → root (matches upstream Mac VlcSetupTask). - eachFile { - if (relativePath.segments.firstOrNull() == "lib") { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } - includeEmptyDirs = false - } - } finally { - cleanupMount?.invoke() + val macOsDir = stage.walkTopDown().firstOrNull { + it.isDirectory && it.name == "MacOS" && it.parentFile?.name == "Contents" + } ?: error("mpv.app/Contents/MacOS not found inside ${zip.name}") + val binary = macOsDir.resolve("mpv") + check(binary.isFile) { "mpv binary missing from ${macOsDir.absolutePath}" } + + outputDir.deleteRecursively() + outputDir.mkdirs() + project.copy { + from(macOsDir.resolve("lib")) + into(outputDir.resolve("lib")) + } + val staged = outputDir.resolve("libmpv.dylib") + binary.copyTo(staged, overwrite = true) + staged.setWritable(true) + + var rewritten = rewriteExecutablePathRefs(staged, "@loader_path/lib/") + outputDir.resolve("lib").listFiles()?.filter { it.isFile && it.name.endsWith(".dylib") }?.forEach { dylib -> + dylib.setWritable(true) + rewritten += rewriteExecutablePathRefs(dylib, "@loader_path/") } + logger.lifecycle("[mpv-multi] $assetArch: rewrote $rewritten install-name entries") + codesignAdhoc(outputDir) } -val vlcSetupMacArmCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/macos-arm64/ with Apple Silicon .dylib files." - val outputDir = rootDir.resolve("vlc-natives/macos-arm64/") +val mpvSetupMacArmCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/macos-arm64/ with libmpv + its dylib closure." + val outputDir = rootDir.resolve("mpv-natives/macos-arm64/") outputs.dir(outputDir) - doLast { extractMacVlcSlice("arm64", outputDir) } + doLast { extractMacMpvSlice("macos-15-arm", outputDir) } } -val vlcSetupMacX64Ci by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/macos-x64/ with Intel .dylib files." - val outputDir = rootDir.resolve("vlc-natives/macos-x64/") +val mpvSetupMacX64Ci by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/macos-x64/ with Intel libmpv + its dylib closure." + val outputDir = rootDir.resolve("mpv-natives/macos-x64/") outputs.dir(outputDir) - doLast { extractMacVlcSlice("intel64", outputDir) } + doLast { extractMacMpvSlice("macos-15-intel", outputDir) } } -// Shared Windows VLC zip extraction. VideoLAN ships separate per-arch -// zips (win64/ for x64, winarm64/ for ARM64) — we mirror that layout -// in vlc-natives/ so Conveyor bundles the right slice per msix. -fun extractWindowsVlcSlice( - archSuffix: String, - outputDir: java.io.File, -) { - val winVersion = libs.versions.vlc.get() - val cache = vlcCacheDir.get().asFile - // VideoLAN URL layout for Windows: - // x64: /vlc//win64/vlc--win64.zip - // arm: /vlc//winarm64/vlc--winarm64.zip - // Both zips share the same internal tree shape, so once downloaded - // the rest of the pipeline is identical. - val subDir = if (archSuffix == "winarm64") "winarm64" else "win64" - val zip = cache.resolve("vlc-$winVersion-$archSuffix.zip") +// --------------------------------------------------------------------------- +// Windows +// --------------------------------------------------------------------------- + +fun extractWindowsMpvSlice(arch: String, outputDir: java.io.File) { + // `7zz` (the official 7-Zip binary) is preferred over `7z`, which on many machines is p7zip + // — a fork last released in 2017. shinchiro's aarch64 archive uses the ARM64 BCJ filter that + // 7-Zip only gained in 21.07, so p7zip fails it with "Unsupported Method : libmpv-2.dll" + // while extracting the x86_64 one just fine. The CI image installs 7-Zip 26.x for the same + // reason. + val sevenZip = listOf("7zz", "7z").firstOrNull(::toolAvailable) + ?: error( + "7-Zip is required to unpack shinchiro's .7z builds and must be 21.07 or newer. " + + "macOS: `brew install sevenzip` (provides 7zz). Ubuntu: install the official " + + "7-Zip build — distro p7zip is too old for the ARM64 archive.", + ) + val cache = mpvCacheDir.get().asFile + val archive = cache.resolve("mpv-dev-$arch-$mpvWinBuildSuffix.7z") downloadIfMissing( - "https://get.videolan.org/vlc/$winVersion/$subDir/vlc-$winVersion-$archSuffix.zip", - zip, + "https://github.com/shinchiro/mpv-winbuild-cmake/releases/download/" + + "$mpvWinBuildTag/mpv-dev-$arch-$mpvWinBuildSuffix.7z", + archive, + logPrefix = "mpv-multi", ) - outputDir.walk().filter { it.extension == "dll" }.forEach { it.delete() } + val extractDir = cache.resolve("mpv-dev-$arch-extract") + extractDir.deleteRecursively() + extractDir.mkdirs() + // Output is left visible: when the extractor is too old it fails with "Unsupported Method", + // and silencing that turns a one-line diagnosis into a bare non-zero exit code. + runChecked(sevenZip, "x", "-y", "-o${extractDir.absolutePath}", archive.absolutePath) + + // The dev package is headers + import lib + the runtime DLL. Only the DLL + // is shipped; JNA resolves it by name ("libmpv-2" is in MpvLibrary's + // CANDIDATE_NAMES), and ffmpeg is linked into it, so it stands alone. + val dllDir = findDirContaining(extractDir) { it.startsWith("libmpv") && it.endsWith(".dll") } + ?: error("No libmpv*.dll inside ${archive.name}") + outputDir.deleteRecursively() + outputDir.mkdirs() project.copy { - from(zipTree(zip)) + from(dllDir) into(outputDir) - // Ship the full VLC plugin set (matches v1.2.1 release). The - // music-app preset from upstream vlc-setup turned out to be - // missing HTTP/HTTPS access + MP4/WebM demuxers needed for YT - // Music streaming. `**/` is required because include() runs - // against the original `vlc-/...` paths inside the zip - // before the eachFile drop(1) transformation. - include("**/*.dll") - // Strip top-level `vlc-/` prefix dir. - eachFile { - if (relativePath.segments.size > 1) { - relativePath = RelativePath(true, *relativePath.segments.drop(1).toTypedArray()) - } - } + include("*.dll") includeEmptyDirs = false } } -val vlcSetupWindowsX64Ci by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/windows-x64/ with .dll files." - val outputDir = rootDir.resolve("vlc-natives/windows-x64/") +val mpvSetupWindowsX64Ci by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/windows-x64/ with libmpv-2.dll." + val outputDir = rootDir.resolve("mpv-natives/windows-x64/") outputs.dir(outputDir) - doLast { extractWindowsVlcSlice("win64", outputDir) } + doLast { extractWindowsMpvSlice("x86_64", outputDir) } +} + +val mpvSetupWindowsArmCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/windows-arm64/ with ARM64 libmpv-2.dll." + val outputDir = rootDir.resolve("mpv-natives/windows-arm64/") + outputs.dir(outputDir) + doLast { extractWindowsMpvSlice("aarch64", outputDir) } +} + +// --------------------------------------------------------------------------- +// Linux +// --------------------------------------------------------------------------- + +/** + * Offset of the payload appended after an ELF file, i.e. the end of the ELF proper. + * + * AppImages are an ELF runtime with a filesystem image concatenated onto it, and the image starts + * exactly where the section-header table ends: `e_shoff + e_shnum * e_shentsize`. + * + * Do NOT try to find the payload by scanning for its magic instead. This runtime embeds the + * strings `DWARFS_BLOCK_SIZE`, `DWARFS_CACHE_SIZE` and friends as environment-variable names, so + * the first `DWARFS` hit lands ~1.1 MB before the real image and every extractor then reports + * "unsupported major version". + */ +fun elfPayloadOffset(file: java.io.File): Long { + val header = ByteArray(64) + file.inputStream().use { check(it.read(header) == 64) { "${file.name} is too small to be an ELF" } } + check(header[0] == 0x7f.toByte() && header[1] == 'E'.code.toByte()) { "${file.name} is not an ELF file" } + // Hand-rolled little-endian reads rather than java.nio.ByteBuffer: inside a build script + // `java` resolves to the JavaPluginExtension accessor, so java.* only works in type position. + fun le(offset: Int, size: Int): Long { + var value = 0L + for (i in size - 1 downTo 0) value = (value shl 8) or (header[offset + i].toLong() and 0xff) + return value + } + val shoff = le(0x28, 8) + val shentsize = le(0x3a, 2) + val shnum = le(0x3c, 2) + return shoff + shentsize * shnum +} + +/** + * `DT_NEEDED` entries of an ELF file, i.e. the shared objects it links against directly. + * + * Enough of a parser to walk a dependency closure: section headers → `.dynamic` → `.dynstr`. + * Returns empty for anything that isn't an ELF with section headers. + */ +fun elfNeeded(file: java.io.File): List { + val d = file.readBytes() + if (d.size < 64 || d[0] != 0x7f.toByte() || d[1] != 'E'.code.toByte()) return emptyList() + fun le(off: Int, size: Int): Long { + var v = 0L + for (i in size - 1 downTo 0) v = (v shl 8) or (d[off + i].toLong() and 0xff) + return v + } + val shoff = le(0x28, 8).toInt() + val shentsize = le(0x3a, 2).toInt() + val shnum = le(0x3c, 2).toInt() + val shstrndx = le(0x3e, 2).toInt() + if (shnum == 0 || shoff == 0) return emptyList() + fun sectionName(i: Int) = le(shoff + i * shentsize, 4).toInt() + fun sectionOff(i: Int) = le(shoff + i * shentsize + 0x18, 8).toInt() + fun sectionSize(i: Int) = le(shoff + i * shentsize + 0x20, 8).toInt() + fun cstr(base: Int, offset: Int): String { + var e = base + offset + while (e < d.size && d[e] != 0.toByte()) e++ + return String(d, base + offset, e - (base + offset), Charsets.US_ASCII) + } + val shstrBase = sectionOff(shstrndx) + var dynamicIdx = -1 + var dynstrIdx = -1 + for (i in 0 until shnum) { + when (cstr(shstrBase, sectionName(i))) { + ".dynamic" -> dynamicIdx = i + ".dynstr" -> dynstrIdx = i + } + } + if (dynamicIdx < 0 || dynstrIdx < 0) return emptyList() + val dynOff = sectionOff(dynamicIdx) + val strBase = sectionOff(dynstrIdx) + val result = mutableListOf() + for (i in 0 until sectionSize(dynamicIdx) / 16) { + val tag = le(dynOff + i * 16, 8) + val value = le(dynOff + i * 16 + 8, 8).toInt() + if (tag == 0L) break + if (tag == 1L) result += cstr(strBase, value) // DT_NEEDED + } + return result } -val vlcSetupWindowsArmCi by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/windows-arm64/ with ARM64 .dll files." - val outputDir = rootDir.resolve("vlc-natives/windows-arm64/") +/** + * Delete everything in `lib/` that [root] does not actually reach. + * + * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is + * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be + * dead weight in every Linux installer. + */ +fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { + val present = libDir.listFiles()?.filter { it.isFile }?.associateBy { it.name }.orEmpty() + val reachable = mutableSetOf() + val queue = ArrayDeque() + queue += root + while (queue.isNotEmpty()) { + elfNeeded(queue.removeFirst()).forEach { name -> + if (reachable.add(name)) present[name]?.let { queue += it } + } + } + var freed = 0L + present.forEach { (name, file) -> + if (name !in reachable) { + freed += file.length() + file.delete() + } + } + logger.lifecycle( + "[mpv-multi] Pruned ${present.size - reachable.size} unreachable shared objects " + + "(${freed / 1048576} MB); kept ${reachable.size}", + ) +} + +val mpvSetupLinuxCi by tasks.registering { + group = "mpv-multi" + description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." + val outputDir = rootDir.resolve("mpv-natives/linux-x64/") outputs.dir(outputDir) - doLast { extractWindowsVlcSlice("winarm64", outputDir) } + doLast { + // patchelf is the one genuinely host-specific step. The binary carries NO + // DT_RPATH/DT_RUNPATH at all — inside the AppImage a sharun wrapper sets + // LD_LIBRARY_PATH instead — so an rpath must be added before it can be dlopen()ed + // straight out of the staged folder. + // + // Skip rather than fail when it is missing: `mpvSetupAll` is normally run on a dev's + // own machine to get the app running, and a hard failure there would take the macOS + // and Windows slices down with it for a slice that machine cannot use anyway. CI runs + // on Linux, where patchelf is one apt package away. + if (!toolAvailable("patchelf")) { + logger.warn( + "[mpv-multi] Skipping the Linux slice: patchelf is not on PATH " + + "(`sudo apt-get install -y patchelf`, or `brew install patchelf` locally). " + + "The other slices are unaffected.", + ) + return@doLast + } + val cache = mpvCacheDir.get().asFile + val appImage = cache.resolve("mpv-$mpvAppImageVersion-x86_64.AppImage") + downloadIfMissing( + "https://github.com/pkgforge-dev/mpv-AppImage/releases/download/" + + "$mpvAppImageTagEncoded/mpv-$mpvAppImageVersion-anylinux-x86_64.AppImage", + appImage, + logPrefix = "mpv-multi", + ) + + // The payload is DwarFS, not SquashFS, and this runtime does not implement the classic + // `--appimage-extract` flag (no `--appimage-*` string appears anywhere in the binary), so + // neither unsquashfs nor self-extraction works. dwarfsextract reads it directly, and its + // upstream Linux build is a self-contained tarball — no apt package needed. + // On the Linux runner, fetch upstream's self-contained build so no distro package is + // needed. Anywhere else that binary cannot execute, so fall back to a dwarfsextract + // already on PATH (`brew install dwarfs`) and skip the slice if there is none. + val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") + val dwarfsExtract: String = + if (isLinuxHost) { + val dwarfsTar = cache.resolve("dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz") + downloadIfMissing( + "https://github.com/mhx/dwarfs/releases/download/v$dwarfsVersion/" + + "dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz", + dwarfsTar, + logPrefix = "mpv-multi", + ) + val toolsDir = cache.resolve("dwarfs-tools") + val binary = + toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } ?: run { + toolsDir.deleteRecursively() + toolsDir.mkdirs() + runChecked("tar", "-xf", dwarfsTar.absolutePath, "-C", toolsDir.absolutePath) + toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } + ?: error("dwarfsextract not found inside ${dwarfsTar.name}") + } + binary.setExecutable(true) + binary.absolutePath + } else { + if (!toolAvailable("dwarfsextract")) { + logger.warn( + "[mpv-multi] Skipping the Linux slice: dwarfsextract is not on PATH " + + "(`brew install dwarfs`). The other slices are unaffected.", + ) + return@doLast + } + "dwarfsextract" + } + + val extractDir = cache.resolve("mpv-appimage-extract") + extractDir.deleteRecursively() + extractDir.mkdirs() + val offset = elfPayloadOffset(appImage) + logger.lifecycle("[mpv-multi] DwarFS payload starts at offset $offset") + runChecked( + dwarfsExtract, + "-i", appImage.absolutePath, + "-O", offset.toString(), + "-o", extractDir.absolutePath, + ) + + // sharun keeps the real binary in shared/bin and the closure in shared/lib; shared/bin/mpv + // is the 24 MB PIE that statically links libmpv and exports the full client API (54 + // `mpv_*` dynamic symbols), while bin/mpv is only the ~230 KB sharun launcher. + val sharedDir = extractDir.walkTopDown().firstOrNull { + it.isDirectory && it.name == "shared" && it.resolve("bin/mpv").isFile + } ?: error("shared/bin/mpv not found inside the extracted AppImage") + + outputDir.deleteRecursively() + outputDir.mkdirs() + project.copy { + from(sharedDir.resolve("lib")) + into(outputDir.resolve("lib")) + } + // Named libmpv.so.2 because that is one of MpvLibrary's CANDIDATE_NAMES; JNA passes a + // versioned .so name through unchanged on Linux. + val staged = outputDir.resolve("libmpv.so.2") + sharedDir.resolve("bin/mpv").copyTo(staged, overwrite = true) + staged.setWritable(true) + staged.setExecutable(true) + runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) + pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) + logger.lifecycle( + "[mpv-multi] linux-x64: staged libmpv.so.2 + " + + "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", + ) + } } -val vlcSetupAll by tasks.registering { - group = "vlc-multi" - description = "Cross-OS: populate vlc-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/ from any host. Use in CI." +// =========================================================================== +// Two entry points, deliberately split. +// +// Everything above turns upstream mpv builds into loadable native slices, and it needs a Mac +// (codesign) plus 7-Zip 21.07+, patchelf and dwarfsextract. Running that in CI would drag all +// of it onto the Ubuntu runner for artifacts that never change between commits. +// +// So it runs ONCE per mpv bump, on a Mac, via `mpvBundleAll` — which also packs the result into +// per-slice tarballs that get attached to a GitHub release. CI then calls `mpvSetupAll`, which +// only downloads and unpacks them: no toolchain, no host requirements, same shape as the old +// vlcSetupAll. +// =========================================================================== +// Kept in a repo of its own rather than SimpMusic's own releases: these archives are ~196 MB per +// mpv bump and would otherwise sit in the release list users browse for the app itself. +val mpvNativesRepo = "maxrave-dev/simpmusic-files" +val mpvNativesTag = "abc" +val mpvSlices = listOf("linux-x64", "macos-arm64", "macos-x64", "windows-x64", "windows-arm64") + +val mpvBundleAll by tasks.registering { + group = "mpv-bundle" + description = "Mac only: build every native slice and pack them into build/mpv-dist/ for a GitHub release." dependsOn( - vlcSetupLinuxCi, - vlcSetupMacArmCi, - vlcSetupMacX64Ci, - vlcSetupWindowsX64Ci, - vlcSetupWindowsArmCi, + mpvSetupLinuxCi, + mpvSetupMacArmCi, + mpvSetupMacX64Ci, + mpvSetupWindowsX64Ci, + mpvSetupWindowsArmCi, ) + val distDir = layout.buildDirectory.dir("mpv-dist") + outputs.dir(distDir) + doLast { + val dist = distDir.get().asFile + dist.deleteRecursively() + dist.mkdirs() + mpvSlices.forEach { slice -> + val sliceDir = rootDir.resolve("mpv-natives/$slice") + check(sliceDir.isDirectory && sliceDir.listFiles()?.isNotEmpty() == true) { + "mpv-natives/$slice is missing or empty — cannot pack an incomplete set" + } + runChecked( + "tar", "-czf", dist.resolve("mpv-natives-$slice.tar.gz").absolutePath, + "-C", rootDir.resolve("mpv-natives").absolutePath, slice, + ) + } + logger.lifecycle("[mpv-bundle] Packed ${mpvSlices.size} slices into ${dist.absolutePath}") + logger.lifecycle("[mpv-bundle] Publish with:") + logger.lifecycle( + " gh release create $mpvNativesTag ${dist.absolutePath}/*.tar.gz " + + "--repo $mpvNativesRepo --title \"Desktop natives (mpv $mpvVersion)\" --notes \"...\"", + ) + } +} + +val mpvSetupAll by tasks.registering { + group = "mpv-multi" + description = "Populate mpv-natives/ from the prebuilt release tarballs. Runs anywhere; this is what CI uses." + val outputRoot = rootDir.resolve("mpv-natives") + outputs.dir(outputRoot) + doLast { + val cache = mpvCacheDir.get().asFile + mpvSlices.forEach { slice -> + val archive = cache.resolve("mpv-natives-$slice-$mpvVersion.tar.gz") + downloadIfMissing( + "https://github.com/$mpvNativesRepo/releases/download/$mpvNativesTag/mpv-natives-$slice.tar.gz", + archive, + logPrefix = "mpv-multi", + ) + val target = outputRoot.resolve(slice) + target.deleteRecursively() + outputRoot.mkdirs() + runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) + check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } + } + logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} native slices into mpv-natives/") + } } buildkonfig { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt index 18100c242..97feb54e4 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/home/SettingScreen.kt @@ -105,6 +105,7 @@ import com.maxrave.domain.utils.LocalResource import com.maxrave.logger.Logger import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.ui.fileSaverResult +import com.maxrave.simpmusic.expect.ui.isWallpaperDynamicColorSupported import com.maxrave.simpmusic.expect.ui.openEqResult import com.maxrave.simpmusic.extension.bytesToMB import com.maxrave.simpmusic.extension.displayString @@ -121,7 +122,6 @@ import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestina import com.maxrave.simpmusic.ui.navigation.destination.login.LoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.SpotifyLoginDestination import com.maxrave.simpmusic.ui.theme.md_theme_dark_primary -import com.maxrave.simpmusic.expect.ui.isWallpaperDynamicColorSupported import com.maxrave.simpmusic.ui.theme.parseThemeColorHex import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.utils.VersionManager @@ -207,8 +207,8 @@ import simpmusic.composeapp.generated.resources.crossfade_description import simpmusic.composeapp.generated.resources.crossfade_dj_mode import simpmusic.composeapp.generated.resources.crossfade_dj_mode_description import simpmusic.composeapp.generated.resources.crossfade_duration -import simpmusic.composeapp.generated.resources.custom_color import simpmusic.composeapp.generated.resources.custom_ai_model_id +import simpmusic.composeapp.generated.resources.custom_color import simpmusic.composeapp.generated.resources.custom_model_id_messages import simpmusic.composeapp.generated.resources.daily import simpmusic.composeapp.generated.resources.database @@ -1146,20 +1146,20 @@ fun SettingScreen( ) }, ) - if (getPlatform() == Platform.Android) { - SettingItem( - title = stringResource(Res.string.crossfade_dj_mode), - subtitle = - if (castState.isRemote) { - stringResource(Res.string.not_available_while_casting) - } else { - stringResource(Res.string.crossfade_dj_mode_description) - }, - smallSubtitle = true, - switch = ((crossfadeDjMode) to { viewModel.setCrossfadeDjMode(it) }), - isEnable = !castState.isRemote, - ) - } +// if (getPlatform() == Platform.Android) { + SettingItem( + title = stringResource(Res.string.crossfade_dj_mode), + subtitle = + if (castState.isRemote) { + stringResource(Res.string.not_available_while_casting) + } else { + stringResource(Res.string.crossfade_dj_mode_description) + }, + smallSubtitle = true, + switch = ((crossfadeDjMode) to { viewModel.setCrossfadeDjMode(it) }), + isEnable = !castState.isRemote, + ) +// } } } } @@ -1332,7 +1332,12 @@ fun SettingScreen( } item(key = "AI") { Column { - Text(text = stringResource(Res.string.ai), style = typo().labelMedium, color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(vertical = 8.dp)) + Text( + text = stringResource(Res.string.ai), + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 8.dp), + ) SettingItem( title = stringResource(Res.string.ai_provider), subtitle = @@ -2263,8 +2268,16 @@ fun SettingScreen( if (showColorPickerDialog) { val presetColors = listOf( - "FF8ECAE6", "FF4C82EF", "FF9B72CF", "FFEF6C9B", "FFEF5350", - "FFF4A340", "FFFFCA28", "FF66BB6A", "FF26A69A", "FFBDBDBD", + "FF8ECAE6", + "FF4C82EF", + "FF9B72CF", + "FFEF6C9B", + "FFEF5350", + "FFF4A340", + "FFFFCA28", + "FF66BB6A", + "FF26A69A", + "FFBDBDBD", ) var pendingHex by rememberSaveable { mutableStateOf(customThemeColorHex.takeLast(6)) } val parsedColor = parseThemeColorHex(pendingHex) diff --git a/conveyor.conf b/conveyor.conf index d0b26499f..3de99f212 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -21,9 +21,10 @@ include required("desktopApp/generated.conveyor.conf") // NOTE: deliberately NOT including hydraulic's extract-native-libraries.conf. // That helper extracts every JAR-embedded native into the top-level lib/app -// AND keeps the source tree intact — duplicating our 348 MB VLC plugin set -// (output ballooned from 989 MB to 1.4 GB). VLCJ + JNA load fine because: -// 1. DefaultVlcDiscoverer walks up from the JAR to find vlc/ at runtime +// AND keeps the source tree intact, so every bundled native gets duplicated +// (with the old VLC plugin set that took output from 989 MB to 1.4 GB). +// Nothing needs it: +// 1. MpvLibrary walks up from the JAR to find mpv/ at runtime // 2. JNA's own per-JAR native unpacking still runs without the helper app { @@ -69,10 +70,11 @@ app { // Explicit machines list. Two architectures explicitly dropped because // of missing native dependencies upstream: // - // • linux.aarch64.glibc — VideoLAN does not ship a prebuilt VLC - // binary for Linux ARM ("Linux uses distro package manager"), and - // the mahozad/vlc-plugins-linux Maven artifact is amd64-only. - // Shipping a Linux ARM AppImage would crash on first VLCJ load. + // • linux.aarch64.glibc — dropped when VLC was the backend, because + // VideoLAN ships no prebuilt Linux ARM binary and the + // mahozad/vlc-plugins-linux artifact was amd64-only. That blocker + // died with VLC: mpv-AppImage does publish an aarch64 build, so this + // is worth revisiting once an ARM libmpv slice is staged. // // • windows.aarch64 — androidx.sqlite:sqlite-bundled-jvm 2.6.2 (and // even 2.7.0-alpha05) lacks a windows_arm64 sqliteJni.dll. The @@ -87,16 +89,15 @@ app { "linux.amd64.glibc", ] - // Per-arch VLC native libraries staged by - // `./gradlew :composeApp:vlcSetupAll`. VideoLAN ships separate per-arch - // binaries for Mac (arm64.dmg vs intel64.dmg) and Windows (win64.zip vs - // winarm64.zip), so each per-machine installer only carries the slice - // it actually needs — saves ~25-40 MB per Mac user download vs the - // pre-split universal layout. - windows.amd64.inputs += { from = "vlc-natives/windows-x64", to = "vlc" } - mac.amd64.inputs += { from = "vlc-natives/macos-x64", to = "vlc" } - mac.aarch64.inputs += { from = "vlc-natives/macos-arm64", to = "vlc" } - linux.amd64.inputs += { from = "vlc-natives/linux-x64", to = "vlc" } + // Per-arch libmpv staged by `./gradlew :composeApp:mpvSetupAll`. Each + // per-machine installer carries only the slice it actually needs. + // MpvLibrary resolves this `mpv/` folder by walking up from the JAR + // location — Conveyor does not set compose.application.resources.dir, so + // the packaged app cannot rely on that property. + windows.amd64.inputs += { from = "mpv-natives/windows-x64", to = "mpv" } + mac.amd64.inputs += { from = "mpv-natives/macos-x64", to = "mpv" } + mac.aarch64.inputs += { from = "mpv-natives/macos-arm64", to = "mpv" } + linux.amd64.inputs += { from = "mpv-natives/linux-x64", to = "mpv" } // --- Per-OS overrides ----------------------------------------------------- @@ -143,10 +144,9 @@ app { jvm { modules = "ALL-MODULE-PATH" - // extract-native-libraries = true // disabled: duplicates VLC plugins - // (348 MB) by extracting them flat alongside keeping the original - // tree. VLCJ + JNA load fine without this because DefaultVlcDiscoverer - // walks up from the JAR location to find vlc/ at runtime. + // extract-native-libraries = true // disabled: duplicates the bundled + // natives by extracting them flat while keeping the original tree. + // Not needed — MpvLibrary walks up from the JAR location to find mpv/. options += "--add-opens=java.base/java.nio=ALL-UNNAMED" } diff --git a/core b/core index b1afe0940..359419a41 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit b1afe09409d36fb5c2afa785db97997c5c9c86ef +Subproject commit 359419a41b56dbc2ea5d0abfe5f06f24269c20b5 diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 46992d5bf..36ef0a25b 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -17,7 +17,7 @@ import java.time.format.DateTimeFormatter // * the JVM main() entry // * compose.desktop.application packaging (jpackage path, still used // until Conveyor cutover lands in Task 14) -// * VLC native bundling (vlc-setup) +// * desktop packaging pipelines (Conveyor, AppImage wrapping) // * desktop-only UI (CustomTitleBar, MiniPlayerWindow, CrashDialog, etc.) // // composeApp remains a pure KMP library — its src/jvmMain only carries the @@ -37,15 +37,6 @@ plugins { alias(libs.plugins.conveyor) alias(libs.plugins.compose.compiler) alias(libs.plugins.kotlin.multiplatform) - // NOTE: `vlc.setup` lives in :composeApp (not here) because its eager - // task iteration at apply time triggers Conveyor's writeConveyorConfig - // creation, which then fails with "Task with name 'jar' not found" — - // jvmJar isn't created until after the script body's `kotlin {}` block - // runs. Plugin order tricks (vlc.setup last, Bifrost ordering) don't - // help because vlc.setup's iteration force-realizes EVERY existing - // task, including the lazily-registered Conveyor ones. Confirmed by - // retry on 2026-05-21: same error reproduced. Run vlcSetup via - // `./gradlew :composeApp:vlcSetup --no-configuration-cache`. } version = libs.versions.version.name.get().removeSuffix("-hf") @@ -101,7 +92,7 @@ kotlin { } // Workaround the Gradle "Cannot mutate configuration after observation" error -// hit when Conveyor 2.0's per-arch deps mix with VLC-setup / compose plugins +// hit when Conveyor 2.0's per-arch deps mix with Conveyor / compose plugins // that resolve runtimeClasspath at configuration time. Creating a sibling // `desktopRuntimeClasspath` configuration shifts Conveyor's resolution off // the primary jvmRuntimeClasspath, breaking the lock chain. @@ -150,18 +141,13 @@ tasks.named("writeConveyorCon } } -// vlcSetup block disabled with the plugin above. VLC natives in -// vlc-natives/{linux,macos,windows}/ are already on disk from prior runs. -// TODO: replace with a simple Gradle download task that doesn't iterate -// tasks at apply time, so Conveyor + vlc-setup can coexist. - compose.desktop { application { mainClass = "com.maxrave.simpmusic.MainKt" jvmArgs += "--add-opens=java.base/java.nio=ALL-UNNAMED" nativeDistributions { - appResourcesRootDir = rootDir.resolve("vlc-natives/") + appResourcesRootDir = rootDir.resolve("mpv-natives/") val listTarget = mutableListOf() if (org.gradle.internal.os.OperatingSystem .current() @@ -252,7 +238,7 @@ afterEvaluate { jvmArgs("--add-opens", "java.desktop/java.awt.peer=ALL-UNNAMED") jvmArgs("--add-opens", "java.base/java.nio=ALL-UNNAMED") - // Pass bundled VLC natives path to the runtime for `./gradlew desktopApp:run`. + // Pass the bundled natives path to the runtime for `./gradlew desktopApp:run`. val osArch = System.getProperty("os.arch").lowercase() val osSubDir = when { @@ -262,8 +248,24 @@ afterEvaluate { if (osArch.contains("aarch64")) "windows-arm64" else "windows-x64" else -> "linux-x64" } - val vlcNativesPath = rootDir.resolve("vlc-natives/$osSubDir").absolutePath - systemProperty("vlc.bundled.path", vlcNativesPath) + // libmpv is staged by `./gradlew :composeApp:mpvSetupAll`. + // MpvLibrary reads this property and feeds it to jna.library.path. + // + // Without it, JNA cannot find a system libmpv on macOS either: its + // default search list is /usr/lib + /lib, and dyld's leaf-name + // fallback is /usr/local/lib + /usr/lib, so a Homebrew install under + // /opt/homebrew/lib is invisible to both. The bundled path avoids the + // question entirely; `brew install mpv` is only a fallback for a + // checkout that hasn't run mpvSetup yet. + val mpvNativesPath = rootDir.resolve("mpv-natives/$osSubDir") + if (mpvNativesPath.isDirectory) { + systemProperty("mpv.bundled.path", mpvNativesPath.absolutePath) + } else { + logger.info( + "[mpv] ${mpvNativesPath.name} not staged yet — run " + + "`./gradlew :composeApp:mpvSetupAll`. Falling back to a system libmpv.", + ) + } if (System.getProperty("os.name").contains("Mac")) { jvmArgs("--add-opens", "java.desktop/sun.awt=ALL-UNNAMED") @@ -292,7 +294,7 @@ afterEvaluate { val conveyorMakeLinuxApp = tasks.register("conveyorMakeLinuxApp") { group = "distribution" description = "Run `conveyor make linux-app` for Linux x86_64 (glibc)." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -438,11 +440,11 @@ tasks.register("packageConveyorAppImage") { } } -// End-to-end: vlcSetup → conveyor make linux-app → wrap as .AppImage. +// End-to-end: mpvSetupAll → conveyor make linux-app → wrap as .AppImage. // Single command for users: `./gradlew :desktopApp:buildLinuxAppImage --no-configuration-cache` tasks.register("buildLinuxAppImage") { group = "distribution" - description = "Full SimpMusic Desktop Linux AppImage build pipeline (vlcSetup → conveyor → AppImage)." + description = "Full SimpMusic Desktop Linux AppImage build pipeline (mpvSetupAll → conveyor → AppImage)." dependsOn(conveyorMakeLinuxApp) finalizedBy("packageConveyorAppImage") } @@ -455,7 +457,7 @@ tasks.register("buildLinuxAppImage") { val conveyorMakeMacZipAmd64 = tasks.register("conveyorMakeMacZipAmd64") { group = "distribution" description = "Run `conveyor make unnotarized-mac-zip` for macOS Intel." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -469,7 +471,7 @@ val conveyorMakeMacZipAmd64 = tasks.register("conveyorMakeMacZipAmd64") { val conveyorMakeMacZipAarch64 = tasks.register("conveyorMakeMacZipAarch64") { group = "distribution" description = "Run `conveyor make unnotarized-mac-zip` for macOS Apple Silicon." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -482,13 +484,13 @@ val conveyorMakeMacZipAarch64 = tasks.register("conveyorMakeMacZipAarch64" tasks.register("buildMacZipAmd64") { group = "distribution" - description = "Full SimpMusic Desktop macOS Intel .zip pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop macOS Intel .zip pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeMacZipAmd64) } tasks.register("buildMacZipAarch64") { group = "distribution" - description = "Full SimpMusic Desktop macOS Apple Silicon .zip pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop macOS Apple Silicon .zip pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeMacZipAarch64) } @@ -499,7 +501,7 @@ tasks.register("buildMacZipAarch64") { val conveyorMakeWindowsMsix = tasks.register("conveyorMakeWindowsMsix") { group = "distribution" description = "Run `conveyor make windows-msix` for Windows x86_64." - dependsOn(":composeApp:vlcSetup") + dependsOn(":composeApp:mpvSetupAll") workingDir = rootDir commandLine( "conveyor", @@ -512,7 +514,7 @@ val conveyorMakeWindowsMsix = tasks.register("conveyorMakeWindowsMsix") { tasks.register("buildWindowsMsix") { group = "distribution" - description = "Full SimpMusic Desktop Windows .msix pipeline (vlcSetup → conveyor)." + description = "Full SimpMusic Desktop Windows .msix pipeline (mpvSetupAll → conveyor)." dependsOn(conveyorMakeWindowsMsix) } @@ -520,12 +522,6 @@ tasks.withType().configureEach { notCompatibleWithConfigurationCache("Compose Desktop JPackage tasks are not yet compatible with configuration cache") } -listOf("vlcExtract", "vlcFilterPlugins", "vlcSetup", "clean").forEach { taskName -> - tasks.findByName(taskName)?.let { - it.notCompatibleWithConfigurationCache("vlc-setup plugin tasks are not yet compatible with configuration cache") - } -} - private fun downloadFile( url: String, destFile: java.io.File, diff --git a/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt b/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt index 33f7df75c..fe9af8d3c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/maxrave/simpmusic/Main.kt @@ -5,7 +5,7 @@ import java.awt.Toolkit /** * Thin entry point for SimpMusic Desktop. * - * All window setup, VLC bootstrap, Sentry init, Koin loading, deep link + * All window setup, Sentry init, Koin loading, deep link * handling, mini-player wiring, and tray integration live in * `composeApp/src/jvmMain/.../main.kt` as `fun runDesktopApp()`. That keeps * the shared module self-contained (it can still be launched directly diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 76400f0fa..ac9eb8d9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -71,8 +71,6 @@ composeMultiplatform = "1.11.1" datetime = "0.8.0" sqlite = "2.6.2" okio = "3.17.0" -vlcj = "4.12.1" -vlc = "3.0.23" material3-multiplatform = "1.12.0-alpha01" # alpha01 = skiko 0.148.1 (has Matrix33.makeTranslate) to match compottie 1.12 snapshot; alpha02 bumps skiko 0.148.2 which removed it → compottie crash adaptive = "1.2.0" material-icons-multiplatform = "1.7.3" @@ -219,7 +217,6 @@ file-picker = { module = "com.mohamedrejeb.calf:calf-file-picker", version.ref = native-tray = { module = "io.github.kdroidfilter:composenativetray", version.ref = "tray" } #JVM -vlcj = { module = "uk.co.caprica:vlcj", version.ref = "vlcj" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jnaPlatform" } nowplaying = { module = "org.simpmusic:nowplayingcenter", version = "0.0.3" } @@ -271,5 +268,4 @@ packagedeps = { id = "io.github.kdroidfilter.compose.linux.packagedeps", version # JVM osdetector = { id = "com.google.osdetector", version.ref = "osdetector" } -vlc-setup = { id = "ir.mahozad.vlc-setup", version = "0.1.0" } conveyor = { id = "dev.hydraulic.conveyor", version.ref = "conveyor" } From dde01ee4e28bee576b6c1588a4ad861fdb5a42e4 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Mon, 27 Jul 2026 21:53:01 +0700 Subject: [PATCH 4/9] fix(desktop): raise macOS floor to 15.0 for bundled libmpv --- CLAUDE.md | 1 + conveyor.conf | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 928a41800..64e9bd0c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -333,6 +333,7 @@ Before implementing code, researching code, or answering technical questions, th #### Desktop - **Required Dependencies**: - libmpv: audio + video playback (bundled via `mpvSetupAll`; falls back to a system-wide libmpv when `mpv-natives/` has not been staged) +- **Minimum macOS: 15.0** — raised from 11.0 when VLC was replaced by mpv. mpv's macOS release builds target macOS 15 (96/98 arm64 dylibs declare `minos 15.0`; on Intel `libmpv` itself does), and Conveyor rejects a lower `LSMinimumSystemVersion`. No mpv artifact covers both architectures below 15. - **Features**: - Deep link support (`simpmusic://` and `simpmusic.org`) - Mini Player window (always-on-top, resizable, draggable) diff --git a/conveyor.conf b/conveyor.conf index 3de99f212..0f829fe9a 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -121,6 +121,14 @@ app { info-plist.LSApplicationCategoryType = "public.app-category.music" info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] url-schemes = [ "simpmusic" ] + + // Raised from the implicit 11.0 when the desktop backend moved from VLC to mpv. + // mpv's own macOS release builds target macOS 15: 96 of the 98 bundled arm64 dylibs + // declare minos 15.0, and on Intel libmpv itself does — so Conveyor refuses to package + // an app claiming 11.0 ("Required macOS version mismatch"). VideoLAN still built for + // older systems, which is the only reason 11.0 worked before. There is no mpv artifact + // for macOS < 15 covering both architectures, so this is the floor. + info-plist.LSMinimumSystemVersion = 15.0 } windows { From 1712819ecb6eb5888cf82782adc9ff8eacfa78b2 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 00:34:16 +0700 Subject: [PATCH 5/9] fix(desktop): stage natives in release workflow and verify libmpv bundles --- .github/workflows/android-release.yml | 27 ++-- CLAUDE.md | 6 +- composeApp/build.gradle.kts | 190 +++++++++++++++++++++++--- conveyor.conf | 12 ++ core | 2 +- 5 files changed, 206 insertions(+), 31 deletions(-) diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index 5a108c42e..b3590e74e 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -127,19 +127,13 @@ jobs: distribution: "microsoft" cache: 'gradle' - - name: Install 7-Zip 24.x + libfuse2 - # Ubuntu's p7zip-full (16.02) can't extract HFS+ DMG content fully — - # outer kolyDMG opens but the inner HFS+ partition stays opaque, so - # VLC.app never materializes. Use the official Igor Pavlov build, - # which has full HFS+ support like the modern Linux/macOS package. - # libfuse2 is needed by appimagetool when wrapping the Linux app. + - name: Install libfuse2 + # Needed by appimagetool when wrapping the Linux app. Nothing else is + # required here: mpvSetupAll only downloads and untars the prebuilt + # native slices, so no 7-Zip, patchelf or dwarfs toolchain on the runner. run: | sudo apt-get update sudo apt-get install -y libfuse2 - curl -fsSL https://github.com/ip7z/7zip/releases/download/26.01/7z2601-linux-x64.tar.xz -o /tmp/7zz.tar.xz - sudo tar -xf /tmp/7zz.tar.xz -C /usr/local/bin/ 7zz - sudo ln -sf /usr/local/bin/7zz /usr/local/bin/7z - 7z i | head -3 - name: Update build product flavor run: | @@ -157,6 +151,19 @@ jobs: - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions + - name: Cache mpv-natives + uses: actions/cache@v4 + with: + path: mpv-natives + key: mpv-natives-${{ hashFiles('composeApp/build.gradle.kts') }} + + - name: Populate mpv-natives for all OSes + # Conveyor is invoked by its own action further down, NOT through Gradle, + # so the dependsOn(":composeApp:mpvSetupAll") wired into the conveyor* + # tasks never fires on this path — the natives have to be staged here + # explicitly or the installers ship without libmpv. + run: ./gradlew :composeApp:mpvSetupAll --no-configuration-cache + - name: Generate Conveyor config # Run writeConveyorConfig BEFORE Conveyor so the parser only reads # the static generated.conveyor.conf and never tastes Gradle stdout diff --git a/CLAUDE.md b/CLAUDE.md index 64e9bd0c7..b9ed0d577 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -485,7 +485,11 @@ if (getPlatform() == Platform.Android) { - **Google Cast (2026-07, Full build only)**: `cast`/`cast-empty` module pair gated by `isFullBuild`; unified Media3 `CastPlayer` wraps the session `ForwardingPlayer`; `CastHandoffManager` pushes resolved-URL queue windows to the receiver with 403/expiry retry; Cast button in Now Playing top bar, "Playing on " pill, crossfade/DJ/EQ settings gray out while casting; FOSS build stays GMS-free - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. - **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. -- **Bundled libmpv (2026-07-27)**: `./gradlew :composeApp:mpvSetupAll` stages libmpv + its dependency closure into `mpv-natives/{linux-x64,macos-arm64,macos-x64,windows-x64,windows-arm64}/`, wired into installers via `mpv-natives` inputs in `conveyor.conf` (`to = "mpv"`). Sources: shinchiro `mpv-dev-*.7z` (Windows, self-contained), pkgforge-dev mpv-AppImage (Linux, sharun `$ORIGIN`), Homebrew + `dylibbundler` (macOS). **The macOS slice needs a macOS runner** — one per architecture — because the closure is gathered from a Homebrew install; `mpvSetupMacCi` skips itself on other hosts. Do NOT try to lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. `MpvLibrary.bundledLibraryDirs()` resolves the staged folder (`mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`), mirroring `DefaultVlcDiscoverer`. +- **Bundled libmpv (2026-07-27)**: two entry points, deliberately split. `:composeApp:mpvBundleAll` runs **on a Mac, once per mpv bump** — it turns upstream mpv builds into loadable slices in `mpv-natives/-/`, packs them into tarballs and prints their SHA-256. Those are published to `maxrave-dev/simpmusic-files`. `:composeApp:mpvSetupAll` is what **CI** runs: it downloads those tarballs, verifies them against the digests pinned in `mpvNativesChecksums`, and unpacks them — no toolchain needed on the runner. Both workflows must call it before Conveyor, which is invoked by its own action and so never triggers the Gradle `dependsOn`. + - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), pkgforge-dev mpv-AppImage (Linux, DwarFS payload). On macOS and Linux **libmpv is statically linked into the `mpv` executable**; those PIE binaries export the full client API and are renamed to `libmpv.dylib` / `libmpv.so.2`, with load-command paths repointed to `@loader_path` / `$ORIGIN`. + - Do NOT lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. + - The Linux pruner must seed its reachability walk from the subdirectory plugins too (`lib/pulseaudio/`, `lib/alsa-lib/`, …) — they are `dlopen`ed, so no `DT_NEEDED` names them, and seeding only from libmpv once deleted `libsndfile`/`libasyncns` and shipped an unloadable slice. It asserts afterwards that every retained object resolves. + - `MpvLibrary.bundledLibraryDirs()` resolves the staged folder: `mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index b66b5fdd8..d112c4cfb 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -6,6 +6,7 @@ import org.gradle.api.file.RelativePath import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.JvmTarget import java.net.URI +import java.security.MessageDigest import java.util.Properties val isFullBuild: Boolean = @@ -244,9 +245,18 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` // → libmpv-2.dll with ffmpeg linked in. Nothing to patch. // Linux pkgforge-dev/mpv-AppImage -// → libmpv.so.2 + closure under shared/lib with RPATH=$ORIGIN -// (sharun), so nothing to patch there either. -// macOS Homebrew's mpv + dylibbundler, run on a macOS host. +// → no libmpv.so either; shared/bin/mpv is a PIE exporting the API, +// renamed to libmpv.so.2 and given an $ORIGIN/lib rpath. +// macOS mpv's own tagged release .zip (macos-15-arm / macos-15-intel) +// → Contents/MacOS/mpv renamed to libmpv.dylib, its +// @executable_path/lib/... load commands rewritten to +// @loader_path, then re-signed ad-hoc. +// +// On macOS and Linux mpv links libmpv STATICALLY into the `mpv` executable, so +// there is no shared library to copy. Those executables are PIE and export the +// full client API (54 `mpv_*` symbols), which is why renaming them works at +// all — and why the rename matters: JNA maps Native.load("mpv") to +// libmpv.dylib / libmpv.so. Only Windows publishes a real `mpv-dev` package. // // macOS is the odd one out and deliberately so. The obvious shortcut — lifting // IINA.app/Contents/Frameworks straight out of IINA's .dmg — DOES NOT WORK, @@ -263,14 +273,12 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // makes IINA itself work, that closure is not self-sufficient, and MpvLibrary // loads with RTLD_NOW by design, so there is no flag to hide behind. // -// Homebrew resolves mpv and libplacebo as one dependency graph, so its closure -// is self-consistent by construction. dylibbundler then copies that closure and -// rewrites every dependency to @loader_path/... in one pass. The dylibs must be -// re-signed ad-hoc afterwards: mutating a Mach-O invalidates its signature and -// macOS refuses to load an invalidly-signed dylib (hard failure on Apple -// Silicon). Cost of this route: the macOS slice needs a macOS runner, one per -// architecture — it cannot be produced from the Linux runner that builds -// every other slice. +// mpv's own release zip avoids that entirely: libmpv and its closure come out +// of one build, so they cannot be version-skewed against each other. The +// dylibs must be re-signed ad-hoc after patching — mutating a Mach-O +// invalidates its signature and macOS refuses to load an invalidly-signed +// dylib (a hard failure on Apple Silicon). That signing step is the only +// reason these tasks need a Mac. // // Conveyor stages one slice per machine — see the mpv-natives inputs in // conveyor.conf. @@ -321,6 +329,19 @@ fun runChecked(vararg command: String) { check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } } +fun sha256(file: java.io.File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { stream -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) + } + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + // --------------------------------------------------------------------------- // macOS // --------------------------------------------------------------------------- @@ -457,6 +478,13 @@ fun extractMacMpvSlice(assetArch: String, outputDir: java.io.File) { dylib.setWritable(true) rewritten += rewriteExecutablePathRefs(dylib, "@loader_path/") } + // Assert rather than just log: if upstream ever switches to @rpath/ or @loader_path/ install + // names this silently rewrites nothing, signs the result, and publishes a slice whose dylibs + // resolve against an @executable_path that means nothing under the JVM. + check(rewritten > 0) { + "No @executable_path/lib/ entries found in $assetArch — mpv's macOS layout changed, " + + "rewriteExecutablePathRefs() needs updating before this slice can be published." + } logger.lifecycle("[mpv-multi] $assetArch: rewrote $rewritten install-name entries") codesignAdhoc(outputDir) } @@ -465,6 +493,7 @@ val mpvSetupMacArmCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/macos-arm64/ with libmpv + its dylib closure." val outputDir = rootDir.resolve("mpv-natives/macos-arm64/") + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { extractMacMpvSlice("macos-15-arm", outputDir) } } @@ -473,6 +502,7 @@ val mpvSetupMacX64Ci by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/macos-x64/ with Intel libmpv + its dylib closure." val outputDir = rootDir.resolve("mpv-natives/macos-x64/") + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { extractMacMpvSlice("macos-15-intel", outputDir) } } @@ -527,6 +557,7 @@ val mpvSetupWindowsX64Ci by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/windows-x64/ with libmpv-2.dll." val outputDir = rootDir.resolve("mpv-natives/windows-x64/") + inputs.property("mpvWinBuildSuffix", mpvWinBuildSuffix) outputs.dir(outputDir) doLast { extractWindowsMpvSlice("x86_64", outputDir) } } @@ -535,6 +566,7 @@ val mpvSetupWindowsArmCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/windows-arm64/ with ARM64 libmpv-2.dll." val outputDir = rootDir.resolve("mpv-natives/windows-arm64/") + inputs.property("mpvWinBuildSuffix", mpvWinBuildSuffix) outputs.dir(outputDir) doLast { extractWindowsMpvSlice("aarch64", outputDir) } } @@ -627,33 +659,113 @@ fun elfNeeded(file: java.io.File): List { * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be * dead weight in every Linux installer. */ +/** + * Shared objects the host always provides, so a `DT_NEEDED` naming one is not a missing bundle + * entry. Deliberately short: anything outside the glibc/libgcc core is expected to ship with us. + */ +val systemProvidedSharedObjects = + setOf( + "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", + "libresolv.so.2", "libutil.so.1", "libnsl.so.1", "libcrypt.so.1", + "libgcc_s.so.1", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1", + ) + +/** + * Give every shared object in [libDir] an rpath that reaches the rest of the bundle. + * + * sharun's tree is not flat: plugin hosts keep their real implementation in a subdirectory + * (`lib/pulseaudio/libpulsecommon-*.so`, `lib/alsa-lib/`, `lib/pipewire-0.3/`, `lib/spa-0.2/`, + * `lib/gbm/`). On a normal system those are found through each library's own RUNPATH; the copies + * here have none, so without this `libpulse.so.0` cannot see `libpulsecommon` even though both + * are in the bundle. + */ +fun setBundleRpaths(libDir: java.io.File) { + val subDirs = libDir.listFiles()?.filter { it.isDirectory }?.map { it.name }.orEmpty().sorted() + val topRpath = (listOf("\$ORIGIN") + subDirs.map { "\$ORIGIN/$it" }).joinToString(":") + libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.forEach { so -> + val rpath = if (so.parentFile == libDir) topRpath else "\$ORIGIN:\$ORIGIN/.." + so.setWritable(true) + // Objects that are not ELF (stray data files) make patchelf fail; skip them quietly + // rather than aborting the whole slice. + ProcessBuilder("patchelf", "--set-rpath", rpath, so.absolutePath) + .redirectOutput(ProcessBuilder.Redirect.DISCARD) + .redirectError(ProcessBuilder.Redirect.DISCARD) + .start() + .waitFor() + } +} + +/** + * Delete top-level shared objects in [libDir] that nothing in the bundle links against. + * + * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is + * 350 shared objects / 373 MB. libmpv's own closure is a fraction of that, and the rest would be + * dead weight in every Linux installer. + * + * Two things this must get right, both learned the hard way: + * + * - The walk seeds from [root] **and from every shared object in a subdirectory**. Those + * subdirectory objects are plugins loaded with `dlopen` at runtime, so nothing names them in a + * `DT_NEEDED` — but their own dependencies are very much needed. Seeding only from [root] + * deleted `libsndfile.so.1` and `libasyncns.so.0`, which `libpulsecommon-17.0.so` needs, and + * shipped a `libmpv.so.2` that failed to load. + * - It asserts afterwards that every retained object resolves. A pruner that guesses wrong + * should fail here, on the machine building the bundle, not on a user's machine. + */ fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { - val present = libDir.listFiles()?.filter { it.isFile }?.associateBy { it.name }.orEmpty() + val allObjects = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + val byName = allObjects.associateBy { it.name } + val topLevel = allObjects.filter { it.parentFile == libDir } + val reachable = mutableSetOf() val queue = ArrayDeque() queue += root + // Plugins in subdirectories are dlopen'ed, never named in a DT_NEEDED — seed them explicitly + // so their dependencies survive the prune. + allObjects.filter { it.parentFile != libDir }.forEach { + reachable += it.name + queue += it + } while (queue.isNotEmpty()) { elfNeeded(queue.removeFirst()).forEach { name -> - if (reachable.add(name)) present[name]?.let { queue += it } + if (reachable.add(name)) byName[name]?.let { queue += it } } } + var freed = 0L - present.forEach { (name, file) -> - if (name !in reachable) { + var deleted = 0 + topLevel.forEach { file -> + if (file.name !in reachable) { freed += file.length() + deleted++ file.delete() } } logger.lifecycle( - "[mpv-multi] Pruned ${present.size - reachable.size} unreachable shared objects " + - "(${freed / 1048576} MB); kept ${reachable.size}", + "[mpv-multi] Pruned $deleted unreachable shared objects (${freed / 1048576} MB); " + + "kept ${allObjects.size - deleted}", ) + + // Post-condition: nothing left behind may reference something that is no longer here. + val remaining = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + root + val remainingNames = remaining.map { it.name }.toSet() + val dangling = + remaining.flatMap { obj -> + elfNeeded(obj) + .filter { it !in remainingNames && it !in systemProvidedSharedObjects } + .map { "${obj.name} → $it" } + } + check(dangling.isEmpty()) { + "Pruning left unresolvable dependencies — the slice would fail to load at runtime:\n" + + dangling.joinToString("\n") { " $it" } + } } val mpvSetupLinuxCi by tasks.registering { group = "mpv-multi" description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." val outputDir = rootDir.resolve("mpv-natives/linux-x64/") + inputs.property("mpvAppImageTag", mpvAppImageTagEncoded) outputs.dir(outputDir) doLast { // patchelf is the one genuinely host-specific step. The binary carries NO @@ -754,6 +866,7 @@ val mpvSetupLinuxCi by tasks.registering { staged.setExecutable(true) runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) + setBundleRpaths(outputDir.resolve("lib")) logger.lifecycle( "[mpv-multi] linux-x64: staged libmpv.so.2 + " + "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", @@ -806,6 +919,10 @@ val mpvBundleAll by tasks.registering { ) } logger.lifecycle("[mpv-bundle] Packed ${mpvSlices.size} slices into ${dist.absolutePath}") + logger.lifecycle("[mpv-bundle] Paste these into mpvNativesChecksums:") + mpvSlices.forEach { slice -> + logger.lifecycle(" \"$slice\" to \"${sha256(dist.resolve("mpv-natives-$slice.tar.gz"))}\",") + } logger.lifecycle("[mpv-bundle] Publish with:") logger.lifecycle( " gh release create $mpvNativesTag ${dist.absolutePath}/*.tar.gz " + @@ -814,27 +931,62 @@ val mpvBundleAll by tasks.registering { } } +/** + * SHA-256 of every published tarball, filled in by `mpvBundleAll` after a bump. + * + * Pinned in the build script on purpose, rather than read from the release's own `SHA256SUMS` + * asset: a checksum served from the same place as the artifact catches corruption but not anyone + * able to replace release assets — and these files are unpacked straight into the tree Conveyor + * signs. The release tag is mutable, so this is the only thing actually pinning what gets shipped. + */ +val mpvNativesChecksums = + mapOf( + "linux-x64" to "ec64ce2c75c134b4283968a3f9993e40bb05589f406206a0e1d4536e08f62570", + "macos-arm64" to "9a44626c14526ed92ef913e876c2f2ef6fa640d946ab5e1b2bb4041d32893006", + "macos-x64" to "df4e0cc5f80b261a6e616febbca49c20f15f66f0c197dc1e3996f4e28dd5ac8f", + "windows-x64" to "7a3da0d920261d016a07ffed119e8f604084c7c0b4610301a8ec45445abe21f9", + "windows-arm64" to "1b4762a10e7aecfe3c12669df2e2ff7e19d374dd99c193b80889cfaf36cbe93d", + ) + + val mpvSetupAll by tasks.registering { group = "mpv-multi" description = "Populate mpv-natives/ from the prebuilt release tarballs. Runs anywhere; this is what CI uses." val outputRoot = rootDir.resolve("mpv-natives") + // Without declared inputs Gradle treats an existing output directory as up to date, so bumping + // the tag or the mpv version would silently keep shipping the previous natives. + inputs.property("mpvNativesTag", mpvNativesTag) + inputs.property("mpvNativesChecksums", mpvNativesChecksums) outputs.dir(outputRoot) doLast { val cache = mpvCacheDir.get().asFile mpvSlices.forEach { slice -> - val archive = cache.resolve("mpv-natives-$slice-$mpvVersion.tar.gz") + // Cache key includes the tag, not just the version: re-publishing corrected natives + // under a new tag at the same mpv version must not reuse the stale download. + val archive = cache.resolve("mpv-natives-$slice-$mpvNativesTag.tar.gz") downloadIfMissing( "https://github.com/$mpvNativesRepo/releases/download/$mpvNativesTag/mpv-natives-$slice.tar.gz", archive, logPrefix = "mpv-multi", ) + val expected = mpvNativesChecksums.getValue(slice) + val actual = sha256(archive) + check(expected != "PENDING") { + "No checksum pinned for $slice. Run `:composeApp:mpvBundleAll`, publish the " + + "archives, then paste the printed digests into mpvNativesChecksums." + } + check(actual == expected) { + // Delete it so a genuinely corrupt download can be retried rather than cached. + archive.delete() + "Checksum mismatch for mpv-natives-$slice.tar.gz\n expected $expected\n actual $actual" + } val target = outputRoot.resolve(slice) target.deleteRecursively() outputRoot.mkdirs() runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } } - logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} native slices into mpv-natives/") + logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} verified native slices into mpv-natives/") } } diff --git a/conveyor.conf b/conveyor.conf index 0f829fe9a..c8e5b0ee7 100644 --- a/conveyor.conf +++ b/conveyor.conf @@ -134,6 +134,12 @@ app { windows { start-menu.group = "SimpMusic" url-schemes = [ "simpmusic" ] + + // DEBUG ONLY — remove before shipping a release. + // A Windows GUI binary has no console attached, so everything the app writes to + // stdout/stderr is discarded and a startup failure leaves nothing to look at. + // This attaches a console window that shows both. + console = true } linux { @@ -156,6 +162,12 @@ app { // natives by extracting them flat while keeping the original tree. // Not needed — MpvLibrary walks up from the JAR location to find mpv/. options += "--add-opens=java.base/java.nio=ALL-UNNAMED" + + // Without an explicit ceiling the JVM sizes its heap at 1/4 of physical RAM, + // so on a 32 GB machine it will happily grow to several GB before the GC feels + // any pressure — which is why resident memory sat around 1 GB. A music player's + // live set is nowhere near that; the cap makes the GC collect on a sane cadence. + options += "-Xmx512m" } jvm.mac.options += "--add-opens=java.desktop/sun.awt=ALL-UNNAMED" diff --git a/core b/core index 359419a41..06089d22d 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 359419a41b56dbc2ea5d0abfe5f06f24269c20b5 +Subproject commit 06089d22d956b1abe4e964dfebd4a7f9dbe3654c From 004519e9c1ed63997f4f18207c7c8428b0d39a26 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 11:35:21 +0700 Subject: [PATCH 6/9] feat(desktop): build Linux libmpv from source instead of extracting the AppImage --- composeApp/build.gradle.kts | 367 +++++++---------------------------- scripts/mpv-linux/Dockerfile | 100 ++++++++++ scripts/mpv-linux/stage.sh | 101 ++++++++++ 3 files changed, 271 insertions(+), 297 deletions(-) create mode 100644 scripts/mpv-linux/Dockerfile create mode 100755 scripts/mpv-linux/stage.sh diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index d112c4cfb..3815b2d25 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -244,9 +244,16 @@ fun downloadIfMissing(url: String, target: java.io.File, logPrefix: String = "mp // // Windows shinchiro/mpv-winbuild-cmake `mpv-dev-.7z` // → libmpv-2.dll with ffmpeg linked in. Nothing to patch. -// Linux pkgforge-dev/mpv-AppImage -// → no libmpv.so either; shared/bin/mpv is a PIE exporting the API, -// renamed to libmpv.so.2 and given an $ORIGIN/lib rpath. +// Linux built from source in a container — see scripts/mpv-linux/. +// → the ONE platform with no usable upstream. Every prebuilt Linux +// mpv targets "run mpv as its own process": the AppImage ships its +// own glibc + loader and exports the API from a PIE *executable*, +// which glibc flatly refuses to dlopen, and whose glibc would +// collide with the one the JVM has already mapped. Distro packages +// trade that for a version floor set by the distro. Building +// against Ubuntu 22.04 gives a real libmpv.so.2 that needs only +// glibc 2.34, so it loads in-process from Ubuntu 22.04 / Debian 11 +// upwards. // macOS mpv's own tagged release .zip (macos-15-arm / macos-15-intel) // → Contents/MacOS/mpv renamed to libmpv.dylib, its // @executable_path/lib/... load commands rewritten to @@ -293,14 +300,8 @@ val mpvCacheDir = layout.buildDirectory.dir("mpv-cache") val mpvVersion = "0.41.0" val mpvWinBuildTag = "20260610" val mpvWinBuildSuffix = "20260610-git-304426c" -// Percent-encoded because the release tag embeds an '@'. Kept as a literal rather than -// URLEncoder.encode(): in a build script `java` resolves to the JavaPluginExtension -// accessor, so `java.net.URLEncoder` is unresolvable in expression position. -val mpvAppImageTagEncoded = "v0.41.0%402026-07-01_1782914175" -val mpvAppImageVersion = "v0.41.0" -// Reads the AppImage's DwarFS payload. 0.15.6 handles DwarFS v2.5, which is what this -// AppImage carries; an older dwarfs reports "unsupported major version". -val dwarfsVersion = "0.15.6" +// Linux has no upstream pin: that slice is compiled from source by +// scripts/mpv-linux/Dockerfile, which pins mpv, FFmpeg and libplacebo itself. // Every extractor below finds the directory that actually holds the libmpv // artifact and copies its whole sibling set, rather than hard-coding upstream @@ -329,6 +330,15 @@ fun runChecked(vararg command: String) { check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } } +/** Like [runChecked], but returns the command's trimmed stdout instead of forwarding it. */ +fun runCapturing(vararg command: String): String { + val process = ProcessBuilder(*command).redirectErrorStream(false).start() + val output = process.inputStream.bufferedReader().readText() + val exit = process.waitFor() + check(exit == 0) { "Command failed (exit $exit): ${command.joinToString(" ")}" } + return output.trim() +} + fun sha256(file: java.io.File): String { val digest = MessageDigest.getInstance("SHA-256") file.inputStream().use { stream -> @@ -575,301 +585,64 @@ val mpvSetupWindowsArmCi by tasks.registering { // Linux // --------------------------------------------------------------------------- -/** - * Offset of the payload appended after an ELF file, i.e. the end of the ELF proper. - * - * AppImages are an ELF runtime with a filesystem image concatenated onto it, and the image starts - * exactly where the section-header table ends: `e_shoff + e_shnum * e_shentsize`. - * - * Do NOT try to find the payload by scanning for its magic instead. This runtime embeds the - * strings `DWARFS_BLOCK_SIZE`, `DWARFS_CACHE_SIZE` and friends as environment-variable names, so - * the first `DWARFS` hit lands ~1.1 MB before the real image and every extractor then reports - * "unsupported major version". - */ -fun elfPayloadOffset(file: java.io.File): Long { - val header = ByteArray(64) - file.inputStream().use { check(it.read(header) == 64) { "${file.name} is too small to be an ELF" } } - check(header[0] == 0x7f.toByte() && header[1] == 'E'.code.toByte()) { "${file.name} is not an ELF file" } - // Hand-rolled little-endian reads rather than java.nio.ByteBuffer: inside a build script - // `java` resolves to the JavaPluginExtension accessor, so java.* only works in type position. - fun le(offset: Int, size: Int): Long { - var value = 0L - for (i in size - 1 downTo 0) value = (value shl 8) or (header[offset + i].toLong() and 0xff) - return value - } - val shoff = le(0x28, 8) - val shentsize = le(0x3a, 2) - val shnum = le(0x3c, 2) - return shoff + shentsize * shnum -} - -/** - * `DT_NEEDED` entries of an ELF file, i.e. the shared objects it links against directly. - * - * Enough of a parser to walk a dependency closure: section headers → `.dynamic` → `.dynstr`. - * Returns empty for anything that isn't an ELF with section headers. - */ -fun elfNeeded(file: java.io.File): List { - val d = file.readBytes() - if (d.size < 64 || d[0] != 0x7f.toByte() || d[1] != 'E'.code.toByte()) return emptyList() - fun le(off: Int, size: Int): Long { - var v = 0L - for (i in size - 1 downTo 0) v = (v shl 8) or (d[off + i].toLong() and 0xff) - return v - } - val shoff = le(0x28, 8).toInt() - val shentsize = le(0x3a, 2).toInt() - val shnum = le(0x3c, 2).toInt() - val shstrndx = le(0x3e, 2).toInt() - if (shnum == 0 || shoff == 0) return emptyList() - fun sectionName(i: Int) = le(shoff + i * shentsize, 4).toInt() - fun sectionOff(i: Int) = le(shoff + i * shentsize + 0x18, 8).toInt() - fun sectionSize(i: Int) = le(shoff + i * shentsize + 0x20, 8).toInt() - fun cstr(base: Int, offset: Int): String { - var e = base + offset - while (e < d.size && d[e] != 0.toByte()) e++ - return String(d, base + offset, e - (base + offset), Charsets.US_ASCII) - } - val shstrBase = sectionOff(shstrndx) - var dynamicIdx = -1 - var dynstrIdx = -1 - for (i in 0 until shnum) { - when (cstr(shstrBase, sectionName(i))) { - ".dynamic" -> dynamicIdx = i - ".dynstr" -> dynstrIdx = i - } - } - if (dynamicIdx < 0 || dynstrIdx < 0) return emptyList() - val dynOff = sectionOff(dynamicIdx) - val strBase = sectionOff(dynstrIdx) - val result = mutableListOf() - for (i in 0 until sectionSize(dynamicIdx) / 16) { - val tag = le(dynOff + i * 16, 8) - val value = le(dynOff + i * 16 + 8, 8).toInt() - if (tag == 0L) break - if (tag == 1L) result += cstr(strBase, value) // DT_NEEDED - } - return result -} - -/** - * Delete everything in `lib/` that [root] does not actually reach. - * - * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is - * 350 shared objects / 373 MB. libmpv's own closure is 91 of them / 54 MB, and the rest would be - * dead weight in every Linux installer. - */ -/** - * Shared objects the host always provides, so a `DT_NEEDED` naming one is not a missing bundle - * entry. Deliberately short: anything outside the glibc/libgcc core is expected to ship with us. - */ -val systemProvidedSharedObjects = - setOf( - "libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", - "libresolv.so.2", "libutil.so.1", "libnsl.so.1", "libcrypt.so.1", - "libgcc_s.so.1", "ld-linux-x86-64.so.2", "ld-linux-aarch64.so.1", - ) - -/** - * Give every shared object in [libDir] an rpath that reaches the rest of the bundle. - * - * sharun's tree is not flat: plugin hosts keep their real implementation in a subdirectory - * (`lib/pulseaudio/libpulsecommon-*.so`, `lib/alsa-lib/`, `lib/pipewire-0.3/`, `lib/spa-0.2/`, - * `lib/gbm/`). On a normal system those are found through each library's own RUNPATH; the copies - * here have none, so without this `libpulse.so.0` cannot see `libpulsecommon` even though both - * are in the bundle. - */ -fun setBundleRpaths(libDir: java.io.File) { - val subDirs = libDir.listFiles()?.filter { it.isDirectory }?.map { it.name }.orEmpty().sorted() - val topRpath = (listOf("\$ORIGIN") + subDirs.map { "\$ORIGIN/$it" }).joinToString(":") - libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.forEach { so -> - val rpath = if (so.parentFile == libDir) topRpath else "\$ORIGIN:\$ORIGIN/.." - so.setWritable(true) - // Objects that are not ELF (stray data files) make patchelf fail; skip them quietly - // rather than aborting the whole slice. - ProcessBuilder("patchelf", "--set-rpath", rpath, so.absolutePath) - .redirectOutput(ProcessBuilder.Redirect.DISCARD) - .redirectError(ProcessBuilder.Redirect.DISCARD) - .start() - .waitFor() - } -} - -/** - * Delete top-level shared objects in [libDir] that nothing in the bundle links against. - * - * sharun bundles whatever the mpv *player* needs — X11, wayland, pulse, GTK and more — which is - * 350 shared objects / 373 MB. libmpv's own closure is a fraction of that, and the rest would be - * dead weight in every Linux installer. - * - * Two things this must get right, both learned the hard way: - * - * - The walk seeds from [root] **and from every shared object in a subdirectory**. Those - * subdirectory objects are plugins loaded with `dlopen` at runtime, so nothing names them in a - * `DT_NEEDED` — but their own dependencies are very much needed. Seeding only from [root] - * deleted `libsndfile.so.1` and `libasyncns.so.0`, which `libpulsecommon-17.0.so` needs, and - * shipped a `libmpv.so.2` that failed to load. - * - It asserts afterwards that every retained object resolves. A pruner that guesses wrong - * should fail here, on the machine building the bundle, not on a user's machine. - */ -fun pruneUnreachableSharedObjects(root: java.io.File, libDir: java.io.File) { - val allObjects = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() - val byName = allObjects.associateBy { it.name } - val topLevel = allObjects.filter { it.parentFile == libDir } - - val reachable = mutableSetOf() - val queue = ArrayDeque() - queue += root - // Plugins in subdirectories are dlopen'ed, never named in a DT_NEEDED — seed them explicitly - // so their dependencies survive the prune. - allObjects.filter { it.parentFile != libDir }.forEach { - reachable += it.name - queue += it - } - while (queue.isNotEmpty()) { - elfNeeded(queue.removeFirst()).forEach { name -> - if (reachable.add(name)) byName[name]?.let { queue += it } - } - } - - var freed = 0L - var deleted = 0 - topLevel.forEach { file -> - if (file.name !in reachable) { - freed += file.length() - deleted++ - file.delete() - } - } - logger.lifecycle( - "[mpv-multi] Pruned $deleted unreachable shared objects (${freed / 1048576} MB); " + - "kept ${allObjects.size - deleted}", - ) - - // Post-condition: nothing left behind may reference something that is no longer here. - val remaining = libDir.walkTopDown().filter { it.isFile && it.name.contains(".so") }.toList() + root - val remainingNames = remaining.map { it.name }.toSet() - val dangling = - remaining.flatMap { obj -> - elfNeeded(obj) - .filter { it !in remainingNames && it !in systemProvidedSharedObjects } - .map { "${obj.name} → $it" } - } - check(dangling.isEmpty()) { - "Pruning left unresolvable dependencies — the slice would fail to load at runtime:\n" + - dangling.joinToString("\n") { " $it" } - } -} - val mpvSetupLinuxCi by tasks.registering { group = "mpv-multi" - description = "Cross-OS: populate mpv-natives/linux-x64/ with libmpv + its .so closure." + description = "Cross-OS: build a real libmpv.so.2 in a container and stage it with its .so closure." val outputDir = rootDir.resolve("mpv-natives/linux-x64/") - inputs.property("mpvAppImageTag", mpvAppImageTagEncoded) + val dockerDir = rootDir.resolve("scripts/mpv-linux") + inputs.dir(dockerDir) + inputs.property("mpvVersion", mpvVersion) outputs.dir(outputDir) doLast { - // patchelf is the one genuinely host-specific step. The binary carries NO - // DT_RPATH/DT_RUNPATH at all — inside the AppImage a sharun wrapper sets - // LD_LIBRARY_PATH instead — so an rpath must be added before it can be dlopen()ed - // straight out of the staged folder. - // - // Skip rather than fail when it is missing: `mpvSetupAll` is normally run on a dev's - // own machine to get the app running, and a hard failure there would take the macOS - // and Windows slices down with it for a slice that machine cannot use anyway. CI runs - // on Linux, where patchelf is one apt package away. - if (!toolAvailable("patchelf")) { + // Docker, not a host toolchain. The point of the container is the OLD base image: + // linking against Ubuntu 22.04 pins the glibc floor at 2.34 no matter how new the + // machine running this is. Building on the host would silently bake in that host's + // glibc and produce a slice that only runs on equally-new systems — the exact trap + // the upstream AppImage fell into. + if (!toolAvailable("docker")) { logger.warn( - "[mpv-multi] Skipping the Linux slice: patchelf is not on PATH " + - "(`sudo apt-get install -y patchelf`, or `brew install patchelf` locally). " + + "[mpv-multi] Skipping the Linux slice: docker is not on PATH. " + "The other slices are unaffected.", ) return@doLast } - val cache = mpvCacheDir.get().asFile - val appImage = cache.resolve("mpv-$mpvAppImageVersion-x86_64.AppImage") - downloadIfMissing( - "https://github.com/pkgforge-dev/mpv-AppImage/releases/download/" + - "$mpvAppImageTagEncoded/mpv-$mpvAppImageVersion-anylinux-x86_64.AppImage", - appImage, - logPrefix = "mpv-multi", - ) - - // The payload is DwarFS, not SquashFS, and this runtime does not implement the classic - // `--appimage-extract` flag (no `--appimage-*` string appears anywhere in the binary), so - // neither unsquashfs nor self-extraction works. dwarfsextract reads it directly, and its - // upstream Linux build is a self-contained tarball — no apt package needed. - // On the Linux runner, fetch upstream's self-contained build so no distro package is - // needed. Anywhere else that binary cannot execute, so fall back to a dwarfsextract - // already on PATH (`brew install dwarfs`) and skip the slice if there is none. - val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux") - val dwarfsExtract: String = - if (isLinuxHost) { - val dwarfsTar = cache.resolve("dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz") - downloadIfMissing( - "https://github.com/mhx/dwarfs/releases/download/v$dwarfsVersion/" + - "dwarfs-$dwarfsVersion-Linux-x86_64.tar.xz", - dwarfsTar, - logPrefix = "mpv-multi", - ) - val toolsDir = cache.resolve("dwarfs-tools") - val binary = - toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } ?: run { - toolsDir.deleteRecursively() - toolsDir.mkdirs() - runChecked("tar", "-xf", dwarfsTar.absolutePath, "-C", toolsDir.absolutePath) - toolsDir.walkTopDown().firstOrNull { it.isFile && it.name == "dwarfsextract" } - ?: error("dwarfsextract not found inside ${dwarfsTar.name}") - } - binary.setExecutable(true) - binary.absolutePath - } else { - if (!toolAvailable("dwarfsextract")) { - logger.warn( - "[mpv-multi] Skipping the Linux slice: dwarfsextract is not on PATH " + - "(`brew install dwarfs`). The other slices are unaffected.", - ) - return@doLast - } - "dwarfsextract" - } - - val extractDir = cache.resolve("mpv-appimage-extract") - extractDir.deleteRecursively() - extractDir.mkdirs() - val offset = elfPayloadOffset(appImage) - logger.lifecycle("[mpv-multi] DwarFS payload starts at offset $offset") - runChecked( - dwarfsExtract, - "-i", appImage.absolutePath, - "-O", offset.toString(), - "-o", extractDir.absolutePath, - ) - // sharun keeps the real binary in shared/bin and the closure in shared/lib; shared/bin/mpv - // is the 24 MB PIE that statically links libmpv and exports the full client API (54 - // `mpv_*` dynamic symbols), while bin/mpv is only the ~230 KB sharun launcher. - val sharedDir = extractDir.walkTopDown().firstOrNull { - it.isDirectory && it.name == "shared" && it.resolve("bin/mpv").isFile - } ?: error("shared/bin/mpv not found inside the extracted AppImage") - - outputDir.deleteRecursively() - outputDir.mkdirs() - project.copy { - from(sharedDir.resolve("lib")) - into(outputDir.resolve("lib")) + val tag = "simpmusic-libmpv:$mpvVersion" + logger.lifecycle("[mpv-multi] Building $tag (libplacebo + FFmpeg + mpv from source, ~20-40 min cold)") + runChecked("docker", "build", "-t", tag, dockerDir.absolutePath) + + // `docker create` + `cp` rather than `run`: nothing needs to execute, and this works + // the same whether or not the daemon can run x86-64 images interactively. + val container = runCapturing("docker", "create", tag) + check(container.isNotEmpty()) { "docker create returned no container id" } + try { + outputDir.deleteRecursively() + outputDir.mkdirs() + runChecked("docker", "cp", "$container:/out/.", outputDir.absolutePath) + } finally { + runChecked("docker", "rm", container) } - // Named libmpv.so.2 because that is one of MpvLibrary's CANDIDATE_NAMES; JNA passes a - // versioned .so name through unchanged on Linux. + + // The container already proved the slice loads (stage.sh runs a dlopen + + // mpv_initialize smoke test and fails the build otherwise). What it cannot prove is + // the file type, and that is the one thing the previous approach got wrong: it + // staged a PIE executable that no glibc will ever dlopen. Cheap to assert, so assert. val staged = outputDir.resolve("libmpv.so.2") - sharedDir.resolve("bin/mpv").copyTo(staged, overwrite = true) - staged.setWritable(true) - staged.setExecutable(true) - runChecked("patchelf", "--set-rpath", "\$ORIGIN/lib", staged.absolutePath) - pruneUnreachableSharedObjects(staged, outputDir.resolve("lib")) - setBundleRpaths(outputDir.resolve("lib")) + check(staged.isFile) { "libmpv.so.2 missing from the container output" } + val elfType = staged.inputStream().use { stream -> + val header = ByteArray(18) + check(stream.read(header) == header.size) { "libmpv.so.2 is truncated" } + // e_type is a little-endian u16 at offset 0x10. ET_DYN (3) covers both shared + // objects and PIE executables; PIE additionally carries a PT_INTERP segment, + // which is what dlopen rejects. A shared object has none. + (header[0x10].toInt() and 0xFF) or ((header[0x11].toInt() and 0xFF) shl 8) + } + check(elfType == 3) { "libmpv.so.2 is not ET_DYN (e_type=$elfType) — it cannot be dlopen()ed" } + + val libs = outputDir.resolve("lib").listFiles()?.size ?: 0 logger.lifecycle( - "[mpv-multi] linux-x64: staged libmpv.so.2 + " + - "${outputDir.resolve("lib").listFiles()?.size ?: 0} shared objects", + "[mpv-multi] linux-x64: staged libmpv.so.2 + $libs shared objects " + + "(${outputDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } / 1048576} MB)", ) } } @@ -941,11 +714,11 @@ val mpvBundleAll by tasks.registering { */ val mpvNativesChecksums = mapOf( - "linux-x64" to "ec64ce2c75c134b4283968a3f9993e40bb05589f406206a0e1d4536e08f62570", - "macos-arm64" to "9a44626c14526ed92ef913e876c2f2ef6fa640d946ab5e1b2bb4041d32893006", - "macos-x64" to "df4e0cc5f80b261a6e616febbca49c20f15f66f0c197dc1e3996f4e28dd5ac8f", - "windows-x64" to "7a3da0d920261d016a07ffed119e8f604084c7c0b4610301a8ec45445abe21f9", - "windows-arm64" to "1b4762a10e7aecfe3c12669df2e2ff7e19d374dd99c193b80889cfaf36cbe93d", + "linux-x64" to "55e8118a8c4ef201a3b72a71eb20e8854b04c3793d0c9ea0cd7b17ac77aaeee7", + "macos-arm64" to "e527daac8f6cc196324ea6f0ce54d119d04450af9795ff2856c1891806c1d5e0", + "macos-x64" to "95170ea54e1f637fdee148a9efd97993c305a92e233b8478d3764fbecce3eb02", + "windows-x64" to "256f17cf402c7583b8684d5a7cf585ad1b59695469219671f4887b9d8d272a99", + "windows-arm64" to "30e04a117de0b7d6abc5f86d4231e9b4bffa3637f282ff9efe3dc66e6cc4fcba", ) diff --git a/scripts/mpv-linux/Dockerfile b/scripts/mpv-linux/Dockerfile new file mode 100644 index 000000000..faa897c18 --- /dev/null +++ b/scripts/mpv-linux/Dockerfile @@ -0,0 +1,100 @@ +# Builds a REAL libmpv.so.2 shared library against glibc 2.35 (Ubuntu 22.04). +# +# Why not reuse the upstream mpv AppImage: that bundle ships its own glibc and +# loader because it is meant to run mpv as its own process. We dlopen libmpv +# into a JVM that already has the system glibc mapped, and two glibcs cannot +# coexist in one process. The AppImage binary is also a PIE executable, which +# glibc refuses to dlopen outright. +# +# Ubuntu 22.04 is the oldest base that still builds mpv 0.41 without heroics, +# so the resulting slice runs on Ubuntu 22.04 / Debian 12 and newer. +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive + +# Build toolchain. meson from apt is 0.61 (mpv needs >=1.3), so it comes from pip. +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential git ca-certificates curl pkg-config python3 python3-pip \ + ninja-build nasm yasm patchelf \ + libass-dev libfreetype6-dev libfribidi-dev libharfbuzz-dev \ + libfontconfig1-dev \ + libasound2-dev libpulse-dev \ + libxml2-dev zlib1g-dev libbz2-dev liblzma-dev \ + libssl-dev \ + libdav1d-dev \ + && rm -rf /var/lib/apt/lists/* \ + && pip3 install --no-cache-dir "meson>=1.3.0" + +WORKDIR /build + +# --------------------------------------------------------------------------- +# libplacebo — mpv 0.41 hard-requires >=6.338.2; 22.04 ships 4.192. +# +# Built WITHOUT vulkan/shaderc/glslang/d3d11. Those exist to compile shaders for +# hardware renderers; SimpMusic drives mpv through the software render API +# (MPV_RENDER_API_TYPE_SW), so none of it is reachable. Dropping them also drops +# libshaderc/libglslang/libSPIRV-Tools from the closure — the single biggest +# chunk of the old AppImage-derived bundle. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch v7.351.0 --recursive \ + https://code.videolan.org/videolan/libplacebo.git && \ + cd libplacebo && \ + meson setup build \ + --prefix=/usr/local \ + --buildtype=release \ + -Ddefault_library=shared \ + -Dvulkan=disabled -Dopengl=disabled -Dd3d11=disabled \ + -Dshaderc=disabled -Dglslang=disabled \ + -Ddemos=false -Dtests=false && \ + ninja -C build -j$(nproc) && ninja -C build install && \ + cd .. && rm -rf libplacebo + +# --------------------------------------------------------------------------- +# FFmpeg — 22.04 ships 4.4, mpv 0.41 needs libavcodec >=60.31 (FFmpeg 6.1+). +# +# Decoders/demuxers are trimmed to what YouTube Music actually serves, plus the +# protocol stack for https. Encoders are dropped entirely: SimpMusic only ever +# plays back. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch n7.1.1 https://github.com/FFmpeg/FFmpeg.git ffmpeg && \ + cd ffmpeg && \ + ./configure \ + --prefix=/usr/local \ + --enable-shared --disable-static \ + --disable-programs --disable-doc --disable-debug \ + --disable-encoders --disable-muxers --disable-devices \ + --disable-filters --enable-filter=aresample,atempo,rubberband,scale,format,aformat,anull,null,volume,lavfi,acompressor,firequalizer,equalizer,highpass,lowpass,dynaudnorm,loudnorm,aecho \ + --enable-openssl --enable-protocol=https,http,tls,file,pipe,crypto,hls,data \ + --enable-libdav1d \ + --enable-version3 \ + && make -j$(nproc) && make install && \ + cd .. && rm -rf ffmpeg + +# --------------------------------------------------------------------------- +# mpv itself — the only artifact that matters is libmpv.so.2. +# +# libmpv is what we load; the mpv CLI player is not built. Video output goes +# through the software render API, so X11/Wayland/GPU backends are all off. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch v0.41.0 https://github.com/mpv-player/mpv.git && \ + cd mpv && \ + PKG_CONFIG_PATH=/usr/local/lib/x86_64-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig \ + meson setup build \ + --prefix=/usr/local \ + --buildtype=release \ + -Dlibmpv=true -Dcplayer=false \ + -Dgpl=false \ + -Dx11=disabled -Dwayland=disabled -Ddrm=disabled -Dgbm=disabled \ + -Degl=disabled -Dgl=disabled -Dvulkan=disabled \ + -Dalsa=enabled -Dpulse=enabled \ + -Dlua=disabled -Djavascript=disabled \ + -Dlibarchive=disabled -Dlibbluray=disabled -Ddvdnav=disabled \ + -Dcdda=disabled -Duchardet=disabled \ + -Drubberband=disabled -Dzimg=disabled \ + -Dmanpage-build=disabled && \ + ninja -C build -j$(nproc) && ninja -C build install + +# Stage libmpv + its non-system closure into /out, laid out the way +# mpv-natives/linux-x64/ expects: libmpv.so.2 at the top, everything else in lib/. +COPY stage.sh /stage.sh +RUN bash /stage.sh diff --git a/scripts/mpv-linux/stage.sh b/scripts/mpv-linux/stage.sh new file mode 100755 index 000000000..f9ecd1e6b --- /dev/null +++ b/scripts/mpv-linux/stage.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Collect libmpv.so.2 and every non-system shared object it needs into /out. +# +# "System" here means the set every glibc-based desktop is guaranteed to have. +# Bundling those would be actively harmful: the JVM has already mapped the +# host's libc/libm/libpthread, and a second copy in one process is exactly the +# failure that killed the AppImage approach. +set -euo pipefail + +OUT=/out +mkdir -p "$OUT/lib" + +# libplacebo and FFmpeg installed into /usr/local/lib, which is not in the +# loader cache on a bare Ubuntu image. Without this, ldd reports every +# libav*/libplacebo as "not found" and they silently miss the closure — the +# staged slice then looks complete but cannot load. +echo "/usr/local/lib" > /etc/ld.so.conf.d/local.conf +echo "/usr/local/lib/x86_64-linux-gnu" >> /etc/ld.so.conf.d/local.conf +ldconfig + +SYSTEM_LIBS=" +libc.so.6 libm.so.6 libdl.so.2 libpthread.so.0 librt.so.1 libutil.so.1 +ld-linux-x86-64.so.2 libgcc_s.so.1 libstdc++.so.6 libresolv.so.2 +libz.so.1 libbz2.so.1.0 liblzma.so.5 +" + +is_system() { + local name="$1" + for s in $SYSTEM_LIBS; do [[ "$name" == "$s" ]] && return 0; done + return 1 +} + +LIBMPV=$(find /usr/local/lib -name 'libmpv.so.2*' -type f | head -1) +[[ -n "$LIBMPV" ]] || { echo "libmpv.so.2 not found after install"; exit 1; } +cp "$LIBMPV" "$OUT/libmpv.so.2" +chmod +w "$OUT/libmpv.so.2" + +# Walk the DT_NEEDED graph. ldd resolves transitively already, so one pass over +# its output is enough — but ldd also lists the system libs, hence the filter. +collect() { + ldd "$1" 2>/dev/null | awk '/=>/ {print $1, $3}' | while read -r name path; do + [[ -z "$path" || "$path" == "not" ]] && continue + is_system "$name" && continue + [[ -f "$OUT/lib/$name" ]] && continue + cp -L "$path" "$OUT/lib/$name" + chmod +w "$OUT/lib/$name" + collect "$OUT/lib/$name" + done +} +collect "$OUT/libmpv.so.2" + +# DT_RPATH, not DT_RUNPATH: RUNPATH applies only to the object that carries it, +# so a dependency-of-a-dependency would not be found. RPATH is inherited down +# the whole chain, which is what lets one entry here cover the entire closure. +patchelf --force-rpath --set-rpath '$ORIGIN/lib' "$OUT/libmpv.so.2" +for so in "$OUT"/lib/*.so*; do + patchelf --force-rpath --set-rpath '$ORIGIN' "$so" +done + +echo "=== staged ===" +ls -la "$OUT" +echo "--- lib/ ($(ls "$OUT/lib" | wc -l) objects, $(du -sh "$OUT/lib" | cut -f1)) ---" +ls "$OUT/lib" +# Fail here, in the builder, rather than on a user's machine. A slice that looks +# complete but cannot resolve its own closure is exactly what shipped before. +echo "=== resolution check ===" +missing=$(ldd "$OUT/libmpv.so.2" 2>/dev/null | grep "not found" || true) +for so in "$OUT"/lib/*.so*; do + missing+=$(ldd "$so" 2>/dev/null | grep "not found" || true) +done +if [[ -n "$missing" ]]; then + echo "UNRESOLVED dependencies remain:" + echo "$missing" | sort -u + exit 1 +fi +echo "(all resolved via \$ORIGIN — no LD_LIBRARY_PATH needed)" + +# Prove the staged library actually loads and initialises, in this container, +# with nothing but $ORIGIN to find its dependencies. +cat > /tmp/smoke.c <<'EOF' +#include +#include +int main(void) { + void *h = dlopen("/out/libmpv.so.2", RTLD_NOW | RTLD_LOCAL); + if (!h) { printf("dlopen FAILED: %s\n", dlerror()); return 1; } + unsigned long (*ver)(void) = dlsym(h, "mpv_client_api_version"); + void *(*create)(void) = dlsym(h, "mpv_create"); + int (*init)(void *) = dlsym(h, "mpv_initialize"); + if (!ver || !create || !init) { printf("dlsym FAILED\n"); return 1; } + unsigned long v = ver(); + printf("client api = %lu.%lu\n", v >> 16, v & 0xffff); + void *ctx = create(); + if (!ctx) { printf("mpv_create returned NULL\n"); return 1; } + int rc = init(ctx); + printf("mpv_initialize = %d %s\n", rc, rc == 0 ? "(OK)" : "(FAILED)"); + return rc == 0 ? 0 : 1; +} +EOF +gcc -o /tmp/smoke /tmp/smoke.c -ldl +echo "=== smoke test ===" +LC_NUMERIC=C /tmp/smoke From 1661786bf1d9557e9378e9eacb93d5eb5fdd47cc Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 11:35:21 +0700 Subject: [PATCH 7/9] chore(core): bump submodule for libmpv load fixes --- core | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core b/core index 06089d22d..596e26e9e 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 06089d22d956b1abe4e964dfebd4a7f9dbe3654c +Subproject commit 596e26e9eaba051455eccf9163163150b43abc4c From 153c015339fd65582e5f1e3a4da298496bbb3807 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 13:42:46 +0700 Subject: [PATCH 8/9] fix(desktop): strip AppleDouble sidecars from mpv native slices --- composeApp/build.gradle.kts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 3815b2d25..fac0a31e6 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -758,6 +758,24 @@ val mpvSetupAll by tasks.registering { outputRoot.mkdirs() runChecked("tar", "-xzf", archive.absolutePath, "-C", outputRoot.absolutePath) check(target.isDirectory) { "$slice missing after unpacking ${archive.name}" } + + // Drop AppleDouble sidecars — this is a correctness fix, not tidiness. + // + // Tarring a slice on macOS writes each file's xattrs out as a companion "._name" + // entry. Conveyor then signs them as ordinary bundle members and lists all of them + // in _CodeSignature/CodeResources. But when the user unzips the .app, macOS folds + // every "._name" back into the xattrs of "name" and deletes the sidecar — so the + // bundle that gets launched is missing 200 files the seal still expects, and + // Gatekeeper reports the app as "damaged and can't be opened". + // + // Only the macOS slices are ever affected in practice, but strip unconditionally: + // these files carry nothing but com.apple.provenance, and any slice can pick them + // up the moment it is packed on a Mac. + val appleDouble = target.walkTopDown().filter { it.isFile && it.name.startsWith("._") }.toList() + appleDouble.forEach { it.delete() } + if (appleDouble.isNotEmpty()) { + logger.lifecycle("[mpv-multi] $slice: stripped ${appleDouble.size} AppleDouble sidecars") + } } logger.lifecycle("[mpv-multi] Unpacked ${mpvSlices.size} verified native slices into mpv-natives/") } From e027443c621038ad8e2f0308f8b6add38524ce81 Mon Sep 17 00:00:00 2001 From: maxrave-dev Date: Tue, 28 Jul 2026 14:03:45 +0700 Subject: [PATCH 9/9] docs: document the from-source Linux libmpv build and macOS signing pitfall --- CLAUDE.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b9ed0d577..3f53dd169 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -360,7 +360,7 @@ Before implementing code, researching code, or answering technical questions, th - `MpvPlayer.kt` — one handle per media item; `vo=libmpv` + software render context - `MpvVideoSurfacePanel.kt` — mpv SW render API → `BufferedImage` → Swing, embedded in Compose via `SwingPanel` - `MpvPlayerAdapter.kt` — the `MediaPlayerInterface` implementation; separate YouTube audio/video URLs are merged into ONE source with an `edl://...;!new_stream;...` URL (mpv's equivalent of Android's `MergingMediaSource`) -- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` +- Natives bundled per platform in `mpv-natives/-/`, staged by `mpvSetupAll` (Linux slice is compiled from source — `scripts/mpv-linux/`) - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) @@ -486,10 +486,16 @@ if (getPlatform() == Platform.Android) { - **Windows SMTC (2026-07)**: System Media Transport Controls on Windows via `jmtc`/`nowplayingcenter` 0.0.3 (forked JMTC). The native `SMTCAdapter.dll` was hardened against the 1.0.x crash (Sentry SIMPMUSIC-DESKTOP-7, ~95k events): COM apartment tolerates `RPC_E_CHANGED_MODE`, `MediaPlayer` kept alive process-wide, and every exported call is exception-guarded so nothing crosses the JNA boundary as "Invalid memory access". JMTC is confined to a dedicated thread (off the AWT EDT), and `MediaType.Music` is set before display properties so title/artist render (not just the app name). Enabled in `JvmMediaPlayerHandlerImpl` for `Platform.Windows` (Linux MPRIS unchanged; macOS uses NowPlayingCenter). DLL built by GitHub Actions (`windows-latest`) in the NowPlayingCenter repo. - **VLC removed entirely (2026-07-27)**: `VlcPlayerAdapter`, `DefaultVlcDiscoverer`, `MacOsVlcDiscoverer` and `VlcModule` are deleted; `VlcModule.kt` became `DesktopPlayerModule.kt` (`loadVlcModule()` → `loadDesktopPlayerModule()`). The `vlcj` dependency, the `vlc-setup` Gradle plugin, every `vlcSetup*` task, the `vlc-natives/` tree and the VLC Conveyor inputs are all gone. `appResourcesRootDir` now points at `mpv-natives/`. libmpv is the only desktop backend. - **Bundled libmpv (2026-07-27)**: two entry points, deliberately split. `:composeApp:mpvBundleAll` runs **on a Mac, once per mpv bump** — it turns upstream mpv builds into loadable slices in `mpv-natives/-/`, packs them into tarballs and prints their SHA-256. Those are published to `maxrave-dev/simpmusic-files`. `:composeApp:mpvSetupAll` is what **CI** runs: it downloads those tarballs, verifies them against the digests pinned in `mpvNativesChecksums`, and unpacks them — no toolchain needed on the runner. Both workflows must call it before Conveyor, which is invoked by its own action and so never triggers the Gradle `dependsOn`. - - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), pkgforge-dev mpv-AppImage (Linux, DwarFS payload). On macOS and Linux **libmpv is statically linked into the `mpv` executable**; those PIE binaries export the full client API and are renamed to `libmpv.dylib` / `libmpv.so.2`, with load-command paths repointed to `@loader_path` / `$ORIGIN`. + - Sources: shinchiro `mpv-dev-*.7z` (Windows — the only one shipping a real `libmpv-2.dll`), mpv's own release `.zip` (macOS), and **for Linux a from-source container build** (see below). On macOS **libmpv is statically linked into the `mpv` executable**; that PIE binary exports the full client API and is renamed to `libmpv.dylib`, with load-command paths repointed to `@loader_path`. - Do NOT lift `IINA.app/Contents/Frameworks` instead: IINA 1.4.4 ships a version-skewed pair (libmpv needs `_pl_log_create_349`, bundled `libplacebo.338.dylib` exports `_pl_log_create_338`) and that libmpv fails `dlopen` under both RTLD_NOW and RTLD_LAZY. - - The Linux pruner must seed its reachability walk from the subdirectory plugins too (`lib/pulseaudio/`, `lib/alsa-lib/`, …) — they are `dlopen`ed, so no `DT_NEEDED` names them, and seeding only from libmpv once deleted `libsndfile`/`libasyncns` and shipped an unloadable slice. It asserts afterwards that every retained object resolves. + - **Every `._*` sidecar must be stripped after unpacking** (`mpvSetupAll` does this). Tarring a slice on macOS writes each file's xattrs out as a companion `._name`; Conveyor then signs them as ordinary bundle members and seals them in `_CodeSignature/CodeResources`, but macOS folds `._name` back into the xattrs of `name` and deletes the sidecar the moment Finder touches the app — unzipping it **or** dragging it out of the DMG. The launched bundle is then missing every sidecar the seal expects and Gatekeeper reports "SimpMusic is damaged and can't be opened" (`codesign --strict`: `a sealed resource is missing or invalid`). Only macOS is affected: it alone seals the whole app directory and re-checks it at launch. - `MpvLibrary.bundledLibraryDirs()` resolves the staged folder: `mpv.bundled.path` → `compose.application.resources.dir` → `mpv/` found by walking up from the JAR → `mpv-natives/-`. +- **Linux libmpv built from source (2026-07-28)**: the AppImage route is gone — `scripts/mpv-linux/Dockerfile` now compiles libplacebo 7.351 + FFmpeg 7.1.1 + mpv 0.41.0 on **Ubuntu 22.04**, and `mpvSetupLinuxCi` runs that container and copies `/out`. This deleted ~186 lines of DwarFS extraction, closure pruning and rpath rewriting from `composeApp/build.gradle.kts`. + - **Why the AppImage could never work**: every prebuilt Linux mpv targets "run mpv as its own process". `mpv-AppImage` ships its own glibc + `ld-linux`, and its "libmpv.so.2" was really the `mpv` **PIE executable** — glibc refuses to `dlopen` a PIE outright (`DF_1_PIE`), and even patched past that, its glibc 2.43 collides with the one the JVM already mapped. It only ever appeared to work on dev machines because JNA silently fell back to a system-wide libmpv. **Always log the resolved path (`NativeLibrary.getInstance(name).file`)** — that is the only thing distinguishing "using the bundle" from "quietly using /usr/lib". + - The container build targets glibc **2.34** → runs on Ubuntu 22.04 / Debian 11 and newer. Vulkan/shaderc/glslang/D3D11 are disabled in libplacebo and X11/Wayland/GPU in mpv, since playback goes through the software render API; that also drops `libshaderc`/`libglslang`/`libSPIRV-Tools` (the bulk of the old bundle) and removes libsixel entirely, which had been aborting the JVM. + - `stage.sh` deliberately does **not** bundle `libc`/`libm`/`libstdc++`/`ld-linux`, sets `DT_RPATH` (not `DT_RUNPATH` — RUNPATH is not inherited by transitive dependencies), and fails the build unless a `dlopen` + `mpv_initialize` smoke test passes. + - mpv built with `-Dlua=disabled` has no `ytdl_hook`, so the `ytdl` option genuinely does not exist there; `MpvPlayer` uses `optionalOption()` to treat `MPV_ERROR_OPTION_NOT_FOUND` as success. +- **JNA open flags are POSIX-only (2026-07-28)**: `MpvLibrary` passes `OPTION_OPEN_FLAGS = 2` (RTLD_NOW without RTLD_GLOBAL) **only when not on Windows**. JNA forwards the value verbatim to `LoadLibraryEx`, where `2` means `LOAD_LIBRARY_AS_DATAFILE`: the DLL maps as plain data, imports never resolve, and `GetProcAddress` returns nothing — surfacing as the misleading `Error looking up function 'mpv_client_api_version': The specified module could not be found`. ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) @@ -515,6 +521,6 @@ After completing any of the following types of changes, the AI agent **MUST** up *This document helps AI Agents quickly understand the SimpMusic project. Update regularly when there are major changes to architecture or structure.* -**Last updated**: 2026-07-18 +**Last updated**: 2026-07-28 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors