diff --git a/.github/workflows/android-release.yml b/.github/workflows/android-release.yml index dd31e3ca4..ca227aa1c 100644 --- a/.github/workflows/android-release.yml +++ b/.github/workflows/android-release.yml @@ -43,6 +43,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Update environment variables env: BASE_64_SIGNING_KEY: ${{ secrets.BASE_64_SIGNING_KEY }} @@ -127,19 +135,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: | @@ -154,17 +156,29 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - 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') }} - - - name: Populate vlc-natives for all OSes - run: ./gradlew :composeApp:vlcSetupAll --no-configuration-cache + 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 diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index c6099dc5a..ca13b3789 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -49,6 +49,14 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - name: Generate aboutLibraries.json run: ./gradlew exportLibraryDefinitions diff --git a/.github/workflows/desktop-package.yml b/.github/workflows/desktop-package.yml index b8335856b..b2dd02917 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: | @@ -79,17 +73,25 @@ jobs: echo 'SENTRY_DSN=${{ secrets.SENTRY_DSN_JVM }}' > ./local.properties echo "SENTRY_AUTH_TOKEN=${{ secrets.SENTRY_AUTH_TOKEN }}" >> ./local.properties + - name: Update Last.fm secrets + env: + LASTFM_API_KEY: ${{ secrets.LASTFM_API_KEY }} + LASTFM_SECRET: ${{ secrets.LASTFM_SECRET }} + run: | + echo "LASTFM_API_KEY=${{ secrets.LASTFM_API_KEY }}" >> ./local.properties + echo "LASTFM_SECRET=${{ secrets.LASTFM_SECRET }}" >> ./local.properties + - 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..8dc746cb3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,8 +33,10 @@ sentry.properties /.claude/settings.local.json /androidApp/build/ /androidApp/cache/ -# VLC native libraries (downloaded by vlc-setup plugin) -/vlc-natives/ +/lastfm/build/ +/lastfm-empty/build/ +# 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..5471d616f 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/** @@ -118,6 +118,14 @@ Service modules: - **crashlytics-empty/**: FOSS version without tracking #### 5. **cast/** & **cast-empty/** +#### 6. **lastfm/** & **lastfm-empty/** +- **lastfm/**: direct Last.fm scrobbling for the Full build. KMP (android + jvm + ios), package `org.simpmusic.lastfm`. Signs `api_sig` with okio's MD5; talks to `ws.audioscrobbler.com/2.0/` over form-urlencoded POST +- **lastfm-empty/**: FOSS no-op stub with the identical public API — `isLastfmAvailable()` returns `false`, which hides the whole settings block. A FOSS build ships no API secret, so it ships no Last.fm code either +- Selected via `isFullBuild` in `core/data/build.gradle.kts` (playback hooks) and `composeApp/build.gradle.kts` (UI); credentials come from `LASTFM_API_KEY`/`LASTFM_SECRET` in `local.properties` via BuildKonfig, and are handed in with `configLastfm(key, secret)` at startup — the same shape as `configCrashlytics(context, dsn)` +- Auth is Last.fm's **web flow** on every platform: open `last.fm/api/auth/?api_key=X` with **no token**, the user approves in their own browser, Last.fm redirects to the callback with `?token=`, then `auth.getSession`. The app never sees a password. **Do not switch to the desktop flow** (`auth.getToken` first, then open the same URL with `&token=` on it): that tells Last.fm the app already holds the token, so it renders a "return to the application" page and the callback is never called — which looks exactly like a broken redirect +- The callback registered on the API account is `wordbyword://lastfm-auth`, handled by an intent-filter on Android and by Conveyor `url-schemes` + `WindowsProtocolRegistrar` on Desktop; the login screen also accepts the callback URL pasted by hand, for hosts where no scheme handler exists + +#### 7. **cast/** & **cast-empty/** - **cast/**: Google Cast support for the Full build (`media3-cast` + `play-services-cast-framework`, `CastOptionsProvider`, `CastIconButton` Compose wrapper for `MediaRouteButton`) - **cast-empty/**: FOSS no-op stub with identical public API (package `org.simpmusic.cast`), keeping GMS out of F-Droid builds - Selected via the `isFullBuild` Gradle property (same pattern as crashlytics) in `core/media/media3/build.gradle.kts` and `composeApp/build.gradle.kts` androidMain @@ -135,8 +143,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 +269,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 +340,8 @@ 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) +- **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) @@ -340,7 +349,6 @@ Before implementing code, researching code, or answering technical questions, th - Custom title bar (disabled in VM environments) - **Limitations**: - No offline playback - - No video playback ### External APIs - YouTube Music: Hidden/unofficial API (may change anytime) @@ -351,18 +359,20 @@ 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 +- `MpvVideoFrameSource.kt` — mpv SW render API → immutable `BufferedImage` snapshots published via `StateFlow`, drawn by plain Compose `Image` (`MpvVideoFrames` in `media-jvm-ui`); replaced the `SwingPanel`-embedded `MpvVideoSurfacePanel` on 2026-08-01 +- `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` (Linux slice is compiled from source — `scripts/mpv-linux/`) - Supports crossfade transition with dual-player approach #### Crossfade Transition (Desktop) - Configurable duration: 1-15 seconds (default: 5 seconds) -- Audio-only: Crossfade is skipped for video playback +- Skipped when the NEXT track will play as video (`isVideo()` + watch-video setting on) — same rule as Android since 2026-08-01 - Settings persisted via DataStore ### Android Player (Media3/ExoPlayer) @@ -408,7 +418,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 +491,32 @@ 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)**: 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), 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. + - **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. +- **Last.fm scrobbling (2026-07-30, Full build only)**: `lastfm`/`lastfm-empty` module pair gated by `isFullBuild`, following the `cast`/`crashlytics` shape. `LastfmScrobbler` (in `core/data/.../lastfm/`) lives in `commonMain` and is driven by both player handlers, because Android and Desktop run entirely separate ones. It sends `track.updateNowPlaying` where the Discord RPC is updated, and `track.scrobble` off the existing 5-second position-persist tick — a track over 30s scrobbles at half its length or 4 minutes, whichever comes first. + - **`status="ok"` does not mean accepted.** Last.fm answers OK while discarding a scrobble and only says so in `ignoredMessage`: code 1 = artist name filtered, 2 = track name filtered, 3/4 = timestamp too far past/future, 5 = daily limit. Codes 1 and 2 are how bad metadata surfaces, so they are logged loudly rather than dropped. + - **The two auth flows are not interchangeable, and picking the wrong one silently kills the callback.** Web flow: send the user to `last.fm/api/auth/?api_key=X` with no token; Last.fm mints it and redirects to the registered callback with `?token=`. Desktop flow: call `auth.getToken`, then open that URL with `&token=` already on it; Last.fm then shows "return to the application" and never redirects. SimpMusic uses the **web** flow because it has a registered callback and deep-link handlers on every platform. + - **The callback token does NOT travel through navigation.** `App.kt` hands it straight to `SharedViewModel.completeLastfmLogin()`, and `LastfmLoginScreen` closes itself by watching the stored session key. Navigating to the login screen with the token instead pushes a *second* copy on top of the one the user opened their browser from, so the `navigateUp()` after a successful login only peels off that copy and lands back on a login screen — it looks exactly like "logged in but still stuck on the login screen". The other three login screens never hit this because they embed a WebView and never leave the app; Desktop has no real WebView (`Cookies.jvm.kt` is a placeholder), which is why Last.fm uses the system browser at all. + - **`toSortedMap()` does not exist in common Kotlin** (it is a JDK collection) — sort the signature parameters with `entries.sortedBy { it.key }`. + - **`format` must be excluded from `api_sig`.** Parameters are sorted by name, concatenated ``, secret appended, MD5'd — but signing `format` (or `callback`) yields "Invalid method signature supplied" (code 13) on every request. + - Error codes worth branching on: `9` invalid session key → clear the stored session and make the user log in again; `11`/`16`/`29` → transient, retryable; everything else is a malformed request. + - Two places where Last.fm's own docs contradict themselves, resolved conservatively: `timestamp` is the time the track **started** (the method page says started, the scrobbling guide says finished — every scrobbler in the wild sends the start), and `duration` is **always sent** (optional on one page, required on the other). + - Responses are parsed as loose `JsonObject`s, not `@Serializable` classes: Last.fm's JSON is a translation of its XML, so numbers arrive as strings, attributes hide under `@attr`, and a field is an object with one entry but an array with several. +- **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`. +- **Desktop URL schemes were never actually registered (2026-07-31)**: `url-schemes` belongs at the **top level** of `app` in `conveyor.conf`. Conveyor binds it on `AppConfig`, not on `MacConfig`/`WindowsConfig`/`LinuxConfig` — compare `MacConfigAccess.getUrlSchemes()` (reads `appConfig`) with the `getFileAssociations()` beside it (reads `mac`). It had been written as `mac.url-schemes` / `windows.url-schemes` / `linux.url-schemes` since May 2026; HOCON accepts unknown keys silently, so all three sat inert and **every packaged build shipped with no `CFBundleURLTypes` at all** — macOS never routed `simpmusic://` either, not just the Last.fm callback. Proven by diffing two `mac-app` builds that differed only in where the key was written. The same misplacement had parked `desktop-file.Categories` / `Comment[en]` / `StartupWMClass` *beside* the `"Desktop Entry"` group instead of inside it, so those never reached the generated `.desktop` either. Two more links in the same chain: the argv filter in `runDesktopApp` matched a fixed list (`simpmusic://`, `http://`, `https://`) and therefore discarded `wordbyword://lastfm-auth?token=…` on Windows and Linux — it now matches any `scheme://` — and the AppImage's own `.desktop` (written by `packageConveyorAppImage`, which is what actually reaches users since AppRun installs it into `~/.local/share/applications`) now declares `x-scheme-handler/wordbyword` alongside `simpmusic`. +- **Bundled glib disabled `java.awt.Desktop` on Linux (2026-07-31)**: `mpv-natives/linux-x64/lib/libglib-2.0.so.0` is glib 2.72 (built on Ubuntu 22.04) and is missing from `SYSTEM_LIBS` in `scripts/mpv-linux/stage.sh`, so it ships in the bundle and claims the glib soname the moment JNA loads libmpv at startup. AWT's `XDesktopPeer.init()` can then no longer dlopen the **system** `libgio-2.0.so.0`: on a glib 2.80 host (Ubuntu 24.04) it dies with `libgobject-2.0.so.0: undefined symbol: g_dir_unref`, and the JDK reports the whole Desktop API unsupported for the rest of the process. All 23 external-link call sites broke at once — `openUrl()` was an `if` with no `else` so it silently did nothing, while Compose's `LocalUriHandler` calls `Desktop.getDesktop()` on its first line and threw `UnsupportedOperationException` straight out of the click handler, crashing the app. Arrived with the from-source Linux mpv build (2026-07-28); before that JNA quietly fell back to a system-wide libmpv, so the system glib was the only one mapped and links worked. Worked around by calling `Desktop.isDesktopSupported()` at the top of `runDesktopApp` — `XDesktopPeer` caches that probe, so running it before libmpv loads lets the system gio/gobject win the soname race. `OpenUrl.jvm.kt` additionally gained a per-OS launcher fallback (`xdg-open` → `gio open` → `$BROWSER`) and a toast, so it can no longer fail in silence. **The actual cure is to stop bundling glib** — add it to `SYSTEM_LIBS`, which needs the Linux tarball rebuilt, republished and re-pinned in `mpvNativesChecksums`. + +- **Crossfade skips video tracks (2026-08-01)**: both `CrossfadeExoPlayerAdapter` (Android) and `MpvPlayerAdapter` (Desktop) skip the crossfade path when the NEXT track will play as video (`isVideo()` + watch-video setting on — the same condition that builds a merged audio+video source). The merged two-URL source is error-prone to prepare mid-fade and used to cut the outgoing song short or jump straight to the video at 0:00; such transitions now take the normal (non-crossfade) path. The Android check for the CURRENT track being video stays removed (commit `9da155d7`). +- **Desktop video renders through Compose, SwingPanel removed (2026-08-01)**: `MpvVideoSurfacePanel` (JPanel + `SwingPanel` embedding) became `MpvVideoFrameSource` — the mpv SW render loop is unchanged, but finished frames are published as immutable `BufferedImage` snapshots on a `StateFlow` and drawn by a plain Compose `Image` (`MpvVideoFrames` in `media-jvm-ui`, converted with `toComposeImageBitmap()` off the UI thread; the UI reports its size via `setTargetSize()`). This kills the whole SwingPanel bug class: always-on-top z-order, one-frame-late repositioning while scrolling (the flicker that exposed the transparent window), and AWT's single-parent rule that made NowPlaying/Fullscreen/Artist screens fight over the one panel (video "randomly missing until next/prev"). `MpvPlayerAdapter.currentVideoSurface: StateFlow` is now `currentVideoFrames: StateFlow` and is set unconditionally during crossfade — the old null-guard kept a dead panel from a released player on screen (the "black video" bug). ## 🔄 CLAUDE.md Auto-Update Rule (MANDATORY) @@ -505,6 +542,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-08-01 **Project version**: Check latest release on GitHub **Maintained by**: maxrave-dev and contributors diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index fb9a57766..2a7347faa 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -159,6 +159,19 @@ + + + + + + + + + + diff --git a/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt index 6e73ae3e6..be6627209 100644 --- a/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt +++ b/androidApp/src/main/java/com/maxrave/simpmusic/SimpMusicApplication.kt @@ -35,6 +35,7 @@ import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin import org.koin.core.logger.Level import org.simpmusic.crashlytics.configCrashlytics +import org.simpmusic.lastfm.configLastfm import java.lang.reflect.Field class SimpMusicApplication : @@ -50,6 +51,7 @@ class SimpMusicApplication : super.onCreate() AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) configCrashlytics(this, BuildKonfig.sentryDsn) + configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret) startKoin { androidLogger(level = Level.DEBUG) androidContext(this@SimpMusicApplication) diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 270c8c11f..e9f0eb612 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 = @@ -25,7 +26,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 @@ -122,6 +122,15 @@ kotlin { api(projects.domain) implementation(projects.data) + // Last.fm (gated: real scrobbler for full builds, no-op stub for FOSS builds). + // `api` rather than `implementation` so :androidApp can hand it the credentials from + // BuildKonfig at startup, the same way it does for Sentry. + if (isFullBuild) { + api(projects.lastfm) + } else { + api(projects.lastfmEmpty) + } + // Navigation Compose implementation(libs.navigation.compose) @@ -175,7 +184,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 +201,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() - } - } - } -} +// 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. -// ============================================================================ -// 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") - -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 +241,553 @@ 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 +// =========================================================================== +// 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 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 +// @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, +// 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. +// +// 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. +// =========================================================================== +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" +// 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 +// 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(" ")}" } +} + +/** 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 -> + val buffer = ByteArray(1 shl 16) + while (true) { + val read = stream.read(buffer) + if (read <= 0) break + digest.update(buffer, 0, read) } - // 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) + } + return digest.digest().joinToString("") { "%02x".format(it) } +} + +// --------------------------------------------------------------------------- +// 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 +} + +/** + * 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) } } -// 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") +/** + * 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() } + + // 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) + } + 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) + } + 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() - // 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 + project.copy { + from(macOsDir.resolve("lib")) + into(outputDir.resolve("lib")) } - check(macOsDir.isDirectory) { - "VLC.app/Contents/MacOS not found at ${macOsDir.absolutePath}" + 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/") } - 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() + // 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) } -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/") + inputs.property("mpvVersion", mpvVersion) 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/") + inputs.property("mpvVersion", mpvVersion) 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/") + inputs.property("mpvWinBuildSuffix", mpvWinBuildSuffix) outputs.dir(outputDir) - doLast { extractWindowsVlcSlice("win64", outputDir) } + doLast { extractWindowsMpvSlice("x86_64", outputDir) } } -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/") +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 { extractWindowsVlcSlice("winarm64", outputDir) } + doLast { extractWindowsMpvSlice("aarch64", outputDir) } } -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." +// --------------------------------------------------------------------------- +// Linux +// --------------------------------------------------------------------------- + +val mpvSetupLinuxCi by tasks.registering { + group = "mpv-multi" + 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/") + val dockerDir = rootDir.resolve("scripts/mpv-linux") + inputs.dir(dockerDir) + inputs.property("mpvVersion", mpvVersion) + outputs.dir(outputDir) + doLast { + // 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: docker is not on PATH. " + + "The other slices are unaffected.", + ) + return@doLast + } + + 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) + } + + // 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") + 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 + $libs shared objects " + + "(${outputDir.walkTopDown().filter { it.isFile }.sumOf { it.length() } / 1048576} MB)", + ) + } +} + +// =========================================================================== +// 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] 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 " + + "--repo $mpvNativesRepo --title \"Desktop natives (mpv $mpvVersion)\" --notes \"...\"", + ) + } +} + +/** + * 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 "55e8118a8c4ef201a3b72a71eb20e8854b04c3793d0c9ea0cd7b17ac77aaeee7", + "macos-arm64" to "e527daac8f6cc196324ea6f0ce54d119d04450af9795ff2856c1891806c1d5e0", + "macos-x64" to "95170ea54e1f637fdee148a9efd97993c305a92e233b8478d3764fbecce3eb02", + "windows-x64" to "256f17cf402c7583b8684d5a7cf585ad1b59695469219671f4887b9d8d272a99", + "windows-arm64" to "30e04a117de0b7d6abc5f86d4231e9b4bffa3637f282ff9efe3dc66e6cc4fcba", + ) + + +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 -> + // 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}" } + + // 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/") + } } buildkonfig { @@ -561,12 +814,28 @@ buildkonfig { "sentryDsn", properties.getProperty("SENTRY_DSN") ?: "", ) + buildConfigField( + STRING, + "lastfmApiKey", + properties.getProperty("LASTFM_API_KEY") ?: "", + ) + buildConfigField( + STRING, + "lastfmSecret", + properties.getProperty("LASTFM_SECRET") ?: "", + ) } catch (e: Exception) { - println("Failed to load SENTRY_DSN from local.properties: ${e.message}") + println("Failed to load secrets from local.properties: ${e.message}") buildConfigField(STRING, "sentryDsn", "") + buildConfigField(STRING, "lastfmApiKey", "") + buildConfigField(STRING, "lastfmSecret", "") } } else { buildConfigField(STRING, "sentryDsn", "") + // A FOSS build ships no Last.fm credentials, so `isLastfmAvailable()` stays false and + // the feature hides itself. The stub module is linked in this flavour anyway. + buildConfigField(STRING, "lastfmApiKey", "") + buildConfigField(STRING, "lastfmSecret", "") } } } diff --git a/composeApp/src/commonMain/composeResources/values-az/strings.xml b/composeApp/src/commonMain/composeResources/values-az/strings.xml index a3fe1205c..f4e1d6b58 100644 --- a/composeApp/src/commonMain/composeResources/values-az/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-az/strings.xml @@ -366,13 +366,9 @@ Təkrar göstərmə Yeniləmə üçün avtomatik yoxla Tətbiqi açdığınızda yeniləmə yoxlanılır - Tam ekran lirika effektini tutqunlaşdır - Tam ekran lirika ekran effekti arxa planı, tutqunlaşır Səslənmə sürəti Ucalıq - Səsləndirici arxa planın tutqunlaşdır - İndi səslənən ekran effekti fonunu tutqunlaşdır Səs və videonun birləşdirilməsi Xəta baş verdi Səs endirilir: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-bg/strings.xml b/composeApp/src/commonMain/composeResources/values-bg/strings.xml index 0559b6592..7f11b1462 100644 --- a/composeApp/src/commonMain/composeResources/values-bg/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bg/strings.xml @@ -385,13 +385,9 @@ Не показвай отново Автоматична проверка за актуализации Проверка за актуализации когато отворите приложението - Ефект на замъгляване на текстовете в цял екран - Замъгляване на фона на текстовете в цял екран Скорост на възпроизвеждане Тон - Замъгли на фона на плейъра - Замъгляване на фона на плейъра Смесване на звук и видео Възникна грешка Сваляне на аудио: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ca/strings.xml b/composeApp/src/commonMain/composeResources/values-ca/strings.xml index bf644da30..999e10f6f 100644 --- a/composeApp/src/commonMain/composeResources/values-ca/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ca/strings.xml @@ -395,14 +395,10 @@ No ho tornis a mostrar Busca actualitzacions automàticament Busca actualitzacions cada cop que l'aplicació s'obre - Efecte de desenfocament amb la lletra a pantalla completa - Effecte de desenfoc del fons de la pantalla de lletra de la cançó a pantalla completa Velocitat de reproducció To - Desenfoca el fons del reproductor - Efecte de desenfocament del fons de la pantalla \"Reproduint\" Fusionant àudio i vídeo S'ha produït un error Descarregant àudio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 132360720..c12fda416 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -386,14 +386,10 @@ Nicht erneut anzeigen Automatisches Überprüfen auf Updates Überprüfung auf Updates beim App Start - Verwischen Sie den Texteffekt im Vollbildmodus - Den Hintergrund des Vollbild-Textbildschirmeffekts unscharf machen Wiedergabegeschwindigkeit Tonhöhe - Player Hintergrund verwischen - Verwischt den Hintergrund des ''spielt gerade'' Bildschirmeffekts Audio und Video zusammenfügen Es ist ein Fehler aufgetreten Audio wird heruntergeladen: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index a313165f7..168d09e39 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -395,14 +395,10 @@ No mostrar de nuevo Comprobar actualizaciones automáticamente Comprobando actualizaciones al abrir la aplicación - Efecto de desenfoque de letra a pantalla completa - Efecto de desenfoque del fondo de la pantalla de letras en pantalla completa Velocidad de reproducción Tono - Desenfocar el fondo del reproductor - Efecto de desenfoque del fondo de la pantalla de reproducción actual Combinando audio y vídeo Ocurrió un error Descargando audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 1ff2dcc7c..6b6f2ddca 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -392,13 +392,9 @@ دوباره نشان نده بررسی خودکار بروزرسانی بررسی به روز رسانی هنگام باز کردن برنامه - افکت محو کردن متن آهنگ در حالت تمام صفحه - محو کردن پس‌زمینه‌ی صفحه‌ی کامل متن آهنگ سرعت پخش گام صدا - تار کردن پس‌زمینه‌ پخش کننده - تار کردن پس‌زمینه‌ی صفحه‌ی در حال پخش ادغام صدا و ویدیو خطایی رخ داد در حال دانلود صدا: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index b1d74a43e..7cf7ba492 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -395,14 +395,10 @@ Ne plus afficher Vérification automatique des mises à jour Vérification des mises à jour lorsque vous ouvrez l'application - Effet de flou sur les paroles en plein écran - Flouter l'arrière-plan de l'effet d'écran des paroles en plein écran Vitesse de lecture Hauteur - Flouter l'arrière-plan du lecteur - Effet de floutage de l'arrière-plan de l'écran en cours de lecture Fusionner l'audio et la vidéo Une erreur s'est produite Téléchargement audio : %1$s diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 3d2cf4663..5b502127a 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -382,13 +382,9 @@ दोबारा न दिखाएं अपडेट की स्वतः जाँच ऐप खोलते समय अपडेट की जाँच करना - पूर्ण स्क्रीन गीत प्रभाव को धुंधला करें - पूर्ण स्क्रीन गीत प्रभाव को धुंधला किया जा रहा है प्लेबैक गति पिच - प्लेयर बैकग्राउंड धुंधला करें - नाओ प्लेयिंग स्क्रीन प्रभाव की पृष्ठभूमि को धुंधला किया गया ऑडियो और वीडियो का संयोजन त्रुटि हुई ऑडियो डाउनलोड हो रहा है: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 1dbf0e69a..31f787936 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -394,14 +394,10 @@ Jangan tampilkan lagi Pemeriksaan pembaruan otomatis Memeriksa pembaruan saat Anda membuka aplikasi - Buramkan efek lirik layar penuh - Efek memburamkan latar belakang layar lirik Layar Penuh Kecepatan pemutaran Nada - Buramkan latar belakang pemutar - Efek memburamkan latar belakang layar yang sedang diputar Menggabungkan audio dan video Terjadi kesalahan Mengunduh audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index c69ae0256..7c5e1e4a3 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -395,14 +395,10 @@ Non mostrare più Controllo automatico degli aggiornamenti Controllo degli aggiornamenti all'apertura dell'app - Sfoca effetto testo a schermo intero - Sfoca lo sfondo dell'effetto schermo intero del testo Velocità di riproduzione Tono - Sfoca lo sfondo del lettore - Sfoca lo sfondo dell'effetto della schermata di riproduzione Unione di audio e video C'è stato un problema Download dell'audio in corso: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index 713e9e4b1..0e5784bfa 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -379,13 +379,9 @@ 再び表示しない 最新版を自動で確認 アプリを開いた時に更新を確認します - 全画面の歌詞でぼかす効果 - 全画面の歌詞画面の背景をぼかす 再生速度 ピッチ - プレーヤーの背景をぼかす - 再生中の画面の背景をぼかす 音声と動画を統合中 エラーが発生しました 音声をダウンロード中: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index a6c9afd05..3293e2200 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -394,14 +394,10 @@ 다시 보지 않기 업데이트 자동 확인 앱을 열 때 업데이트 유무 확인 - 전체화면 가사 배경 흐리게 - 전체화면 가사의 배경을 흐리게 처리 재생 속도 음정 - 플레이어 배경 흐리게 - 재생 화면의 배경을 흐리게 처리 오디오와 동영상 병합 중 오류 발생함 오디오 다운로드 중: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 7e652afb4..873fcb913 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -386,13 +386,9 @@ Nie pokazuj ponownie Automatyczne sprawdzanie aktualizacji Sprawdzanie aktualizacji po otwarciu aplikacji - Efekt rozmycia tekstu piosenki na pełnym ekranie - Rozmycie tła efektu pełnoekranowego ekranu tekstu Szybkość odtwarzania Tonacja - Rozmyj tło odtwarzacza - Rozmycie tła efektu pełnoekranowego ekranu obecnie odtwarzanego utworu Scalanie audio i wideo Wystąpił błąd Pobieranie audio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 834ff1dfa..b682d8e49 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -395,14 +395,10 @@ Não mostrar novamente Verificar atualizações automaticamente Procurar atualizações quando abre o aplicativo - Desfocar o efeito letras em tela cheia - Desfocando o fundo do efeito de tela em letras em tela cheia Velocidade de reprodução Tonalidade - Desfocar fundo do ‘player’ - Desfocando o fundo do efeito de tela do tocando agora Mesclando áudio e vídeo Ocorreu um erro Baixando áudio: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index df6222675..a17540421 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -397,14 +397,10 @@ Больше не показывать Автоматически проверять обновления Проверьте наличие обновлений при открытии приложения - Эффект размытия экрана текста песен - Размытие фона в полноэкранном просмотре текстов песен Скорость воспроизведения Тон - Размыть фон плеера - Эффект размытия фона текущего экрана воспроизведения Объединение аудио и видео Произошла ошибка Загрузка аудио: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 2e922de31..21ebfd781 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -396,14 +396,10 @@ อย่าแสดงอีก ตรวจสอบการอัปเดตอัตโนมัติ ตรวจสอบการอัปเดตทุกครั้งที่คุณเปิดแอป - เอฟเฟกต์เบลอในหน้าจอเนื้อเพลงเต็มจอ - เอฟเฟกต์เบลอพื้นหลังหน้าจอเมื่อเปิดเนื้อเพลงแบบเต็มจอ ความเร็วในการเล่น ความสูง-ต่ำของเสียง - เบลอพื้นหลังของตัวเล่น - การเบลอพื้นหลังของเอฟเฟกต์หน้าจอขณะเล่น รวมเสียงและวิดีโอ เกิดข้อผิดพลาด กำลังดาวน์โหลดเสียง:%1$s diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 7f6841257..9cb522ede 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -388,14 +388,10 @@ Tekrar gösterme Güncellemeleri otomatik denetle Uygulamayı açtığında güncellemeleri denetle - Tam ekran bulanık şarkı sözü efekti - Tam Ekran şarkı sözleri ekran efektinin arka planını bulanıklaştır Oynatma hızı Pitch - Oynatıcı arka planını bulanıklaştır - Şu anda oynatılan ekran efektinin arka planını bulanıklaştır Ses ve videoyu birleştir Hata oluştu Ses indiriliyor: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index ad8f85430..5cc1463ed 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -397,14 +397,10 @@ Більше не показувати Автоматично перевіряти оновлення Перевірка наявності оновлень, коли ви відкриєте застосунок - Ефект розмиття екрана тексту пісень - Розмиття фону в повноекранному перегляді текстів пісень Швидкість відтворення Тон - Розмити фон плеєра - Розмиття фону екрана, що зараз відтворюється Об'єднання аудіо та відео Сталася помилка Завантаження аудіо: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index d24b97d4f..acde4042b 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -394,14 +394,10 @@ Không hiển thị lại Kiểm tra cập nhật tự động Kiểm tra cập nhật mỗi khi bạn mở ứng dụng - Làm mờ màn hình lời bài hát toàn màn hình - Hiệu ứng làm mờ nền màn hình lời bài hát toàn màn hình Tốc độ phát lại Cao độ - Làm mờ nền trình phát - Hiệu ứng làm mờ nền màn hình đang phát Đang ghép tệp âm thanh vào video Đã xảy ra lỗi Đang tải xuống âm thanh: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml index 48ba7e5e6..9376f1ffd 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rCN/strings.xml @@ -394,14 +394,10 @@ 不再显示 自动检查更新 当您打开应用程序时检查更新 - 模糊全屏歌词特效 - 模糊全屏歌词界面的背景效果 播放速度 音调 - 播放界面背景模糊 - “正在播放”界面的背景模糊效果 正在合并音频和视频 出错了 正在下载音频: %1$s diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index ad0f2fbc3..cc94d43f5 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -394,14 +394,10 @@ 以後不再提示 自動檢查更新 啓動時檢查更新 - 模糊全螢幕歌詞 - 模糊全螢幕歌詞畫面的背景 回放速度 音調 - 模糊播放器背景 - 模糊正在播放畫面的背景 音訊與視訊合併 發生錯誤 下載音頻中...%1$s diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 402562921..75c6d8cab 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -410,14 +410,10 @@ Do not show again Automatic check for update Checking for update when you open app - Blur fullscreen lyrics effect - Blurring the background of the Fullscreen lyrics screen effect Playback speed Pitch - Blur player background - Blurring the background of the now playing screen effect Merging audio and video Error occurred Downloading audio: %1$s @@ -521,6 +517,22 @@ Log in to enable Discord Rich Presence Enable rich presence Show media session info on your Discord profile + Log out from Discord + Last.fm + Log in to Last.fm + Log out from Last.fm + You will have to log in again to use this feature. + Log in to scrobble what you listen to + Enable scrobbling + Send what you play to your Last.fm profile + Open the Last.fm page and grant SimpMusic access to your account. + SimpMusic opens again on its own once you have granted access. + Open Last.fm + Could not log in to Last.fm. Please try again. + Did not come back automatically? + Paste the link your browser was redirected to + Continue with this link + Logged in as %1$s YouTube Liked Music Keep service alive Keep music player service alive to avoid being killed by system @@ -572,4 +584,12 @@ Listen to downloaded music Playing on %1$s Not available while casting + Import playlists + Import playlists converted from Spotify or another app at simpmusic.org + Reading file… + %1$d of %2$d songs + Created %1$d playlists and imported %2$d songs. + %1$d entries were skipped because their song was missing from the file. + Import failed + This file is not a valid SimpMusic import file. Convert your library at simpmusic.org first. \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt index 19078fa6a..2ae75604a 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/App.kt @@ -34,7 +34,6 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -83,7 +82,7 @@ import com.maxrave.simpmusic.ui.screen.MiniPlayer import com.maxrave.simpmusic.ui.screen.player.NowPlayingScreen import com.maxrave.simpmusic.ui.screen.player.NowPlayingScreenContent import com.maxrave.simpmusic.ui.theme.AppTheme -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent import com.maxrave.simpmusic.ui.theme.parseThemeColorHex import com.maxrave.simpmusic.ui.theme.fontFamily import com.maxrave.simpmusic.ui.theme.typo @@ -181,6 +180,18 @@ fun App(viewModel: SharedViewModel = koinInject()) { navController.navigate( NotificationDestination, ) + } else if (data.scheme == "wordbyword" && data.host == "lastfm-auth") { + // Last.fm sends the user back here after they approve access, carrying the request + // token: wordbyword://lastfm-auth?token=xxx. The callback is fixed on the API + // account, which is why the scheme is not "simpmusic". + val token = data.getQueryParameter("token") + Logger.d("MainActivity", "Last.fm callback, token present: ${!token.isNullOrEmpty()}") + viewModel.setIntent(null) + // Deliberately no navigation: the login screen is almost certainly already open — + // the browser was opened from it — and navigating would stack a second copy on top + // of it. The token is handed straight to the shared view model, and the screen + // closes itself when it sees a session key appear. + token?.let { viewModel.completeLastfmLogin(it) } } else if (data.host == "simpmusic.org" || data.scheme == "simpmusic") { // https://simpmusic.org/app/watch?v=VIDEO_ID // https://simpmusic.org/app/playlist?list=PLAYLIST_ID @@ -518,13 +529,15 @@ fun App(viewModel: SharedViewModel = koinInject()) { RoundedCornerShape(12.dp), ), ) { - NowPlayingScreenContent( - navController = navController, - sharedViewModel = viewModel, - isExpanded = true, - dismissIcon = Icons.AutoMirrored.Rounded.ArrowForwardIos, - ) { - isShowNowPlaylistScreen = false + ForceDarkContent { + NowPlayingScreenContent( + navController = navController, + sharedViewModel = viewModel, + isExpanded = true, + dismissIcon = Icons.AutoMirrored.Rounded.ArrowForwardIos, + ) { + isShowNowPlaylistScreen = false + } } } } @@ -534,7 +547,7 @@ fun App(viewModel: SharedViewModel = koinInject()) { } if (isShowNowPlaylistScreen && !isTabletLandscape) { - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { NowPlayingScreen( navController = navController, ) { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt index 1386c4719..803bddeb3 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/di/ViewModelModule.kt @@ -4,6 +4,7 @@ import com.maxrave.simpmusic.viewModel.AlbumViewModel import com.maxrave.simpmusic.viewModel.AnalyticsViewModel import com.maxrave.simpmusic.viewModel.ArtistViewModel import com.maxrave.simpmusic.viewModel.HomeViewModel +import com.maxrave.simpmusic.viewModel.ImportViewModel import com.maxrave.simpmusic.viewModel.LibraryDynamicPlaylistViewModel import com.maxrave.simpmusic.viewModel.LibraryViewModel import com.maxrave.simpmusic.viewModel.LocalPlaylistViewModel @@ -67,6 +68,11 @@ val viewModelModule = get(), ) } + viewModel { + ImportViewModel( + get(), + ) + } viewModel { AlbumViewModel( get(), diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt index 5966ff252..49c260931 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/LyricsView.kt @@ -103,12 +103,6 @@ import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.NowPlayingScreenData import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch @@ -248,7 +242,6 @@ fun LyricsView( modifier: Modifier = Modifier, showScrollShadows: Boolean = false, backgroundColor: Color = Color(0xFF242424), - hasBlurBackground: Boolean = false, ) { val listState = rememberLazyListState() val current by timeLine.collectAsStateWithLifecycle() @@ -568,7 +561,6 @@ private fun AnimatedWord( } } -@OptIn(ExperimentalHazeMaterialsApi::class) @ExperimentalMaterial3Api @ExperimentalFoundationApi @Composable @@ -576,7 +568,6 @@ fun FullscreenLyricsSheet( sharedViewModel: SharedViewModel, navController: NavController, color: Color = Color(0xFF242424), - shouldHaze: Boolean, onDismiss: () -> Unit, ) { val screenDataState by sharedViewModel.nowPlayingScreenData.collectAsStateWithLifecycle() @@ -612,79 +603,67 @@ fun FullscreenLyricsSheet( // Dynamic gradient animation - MULTIPLE DIRECTIONS // Replaces the previous `while(true) { delay(16) }` loop with a Compose - // infinite transition. When haze is ON the gradient is hidden, so we keep - // values at 0f and skip the transition entirely. - val gradientAngle: Float - val gradientOffsetX: Float - val gradientOffsetY: Float - if (!shouldHaze) { - val gradientTransition = rememberInfiniteTransition(label = "lyricsGradient") - val animatedAngle by gradientTransition.animateFloat( - initialValue = -45f, - targetValue = 45f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 6000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientAngle", - ) - val animatedOffsetX by gradientTransition.animateFloat( - initialValue = -1500f, - targetValue = 1500f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 8000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientOffsetX", - ) - val animatedOffsetY by gradientTransition.animateFloat( - initialValue = -1000f, - targetValue = 1000f, - animationSpec = - infiniteRepeatable( - animation = tween(durationMillis = 8000, easing = LinearEasing), - repeatMode = RepeatMode.Reverse, - ), - label = "lyricsGradientOffsetY", - ) - gradientAngle = animatedAngle - gradientOffsetX = animatedOffsetX - gradientOffsetY = animatedOffsetY - } else { - gradientAngle = 0f - gradientOffsetX = 0f - gradientOffsetY = 0f - } + // infinite transition. + val gradientTransition = rememberInfiniteTransition(label = "lyricsGradient") + val animatedAngle by gradientTransition.animateFloat( + initialValue = -45f, + targetValue = 45f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 6000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientAngle", + ) + val animatedOffsetX by gradientTransition.animateFloat( + initialValue = -1500f, + targetValue = 1500f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientOffsetX", + ) + val animatedOffsetY by gradientTransition.animateFloat( + initialValue = -1000f, + targetValue = 1000f, + animationSpec = + infiniteRepeatable( + animation = tween(durationMillis = 8000, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "lyricsGradientOffsetY", + ) + val gradientAngle = animatedAngle + val gradientOffsetX = animatedOffsetX + val gradientOffsetY = animatedOffsetY - // Smooth color animation based on lyrics color — only when haze is OFF - LaunchedEffect(color, shouldHaze) { - if (!shouldHaze) { - launch { - startColor.animateTo( - targetValue = color, - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - midColor1.animateTo( - targetValue = color.copy(alpha = 0.95f), - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - midColor2.animateTo( - targetValue = color.copy(alpha = 0.85f), - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } - launch { - endColor.animateTo( - targetValue = Color.Black, - animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), - ) - } + // Smooth color animation based on lyrics color + LaunchedEffect(color) { + launch { + startColor.animateTo( + targetValue = color, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + midColor1.animateTo( + targetValue = color.copy(alpha = 0.95f), + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + midColor2.animateTo( + targetValue = color.copy(alpha = 0.85f), + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) + } + launch { + endColor.animateTo( + targetValue = Color.Black, + animationSpec = tween(durationMillis = 1200, easing = FastOutSlowInEasing), + ) } } @@ -758,83 +737,41 @@ fun FullscreenLyricsSheet( label = "sliderCrossfadeColor", ) Box(modifier = Modifier.fillMaxSize()) { - // ── Haze state (used only when shouldHaze = true) ───────────────── - val hazeState = rememberHazeState(blurEnabled = true) - - if (shouldHaze) { - // Full-screen album art as haze SOURCE — blurred poster background - Box( - modifier = - Modifier - .fillMaxSize() - .hazeSource(hazeState), - ) { - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(screenDataState.thumbnailURL) - .crossfade(300) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(screenDataState.thumbnailURL) - .build(), - contentDescription = null, - contentScale = ContentScale.FillHeight, - modifier = Modifier.fillMaxSize(), - ) - } - } else { - // Animated gradient background — only shown when haze is OFF - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = - listOf( - startColor.value, - midColor1.value, - midColor2.value, - endColor.value.copy(alpha = 0.9f), - endColor.value, - ), - start = - Offset( - x = gradientOffsetX + (cos(gradientAngle * PI.toFloat() / 180f) * 800f), - y = gradientOffsetY + (sin(gradientAngle * PI.toFloat() / 180f) * 800f), - ), - end = - Offset( - x = gradientOffsetX + 2500f + (cos((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), - y = gradientOffsetY + 2500f + (sin((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), - ), - ), + // Animated gradient background + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = + listOf( + startColor.value, + midColor1.value, + midColor2.value, + endColor.value.copy(alpha = 0.9f), + endColor.value, + ), + start = + Offset( + x = gradientOffsetX + (cos(gradientAngle * PI.toFloat() / 180f) * 800f), + y = gradientOffsetY + (sin(gradientAngle * PI.toFloat() / 180f) * 800f), + ), + end = + Offset( + x = gradientOffsetX + 2500f + (cos((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), + y = gradientOffsetY + 2500f + (sin((gradientAngle + 180f) * PI.toFloat() / 180f) * 800f), + ), ), - ) - } + ), + ) // ── Foreground content column ───────────────────────────────────── Column( modifier = Modifier .fillMaxSize() - // Apply frosted glass haze effect over the poster when enabled - .then( - if (shouldHaze) { - Modifier.hazeEffect( - hazeState, - style = CupertinoMaterials.regular(), - ) { - blurEnabled = true - // Force-dark: the poster artwork can be bright, so pin a dark tint over - // the blur so the white lyrics stay readable in every app theme. - tints = listOf(HazeTint(Color.Black.copy(alpha = 0.5f))) - } - } else { - Modifier - }, - ).padding( + .padding( bottom = with(localDensity) { windowInsets.getBottom(localDensity).toDp() @@ -985,7 +922,6 @@ fun FullscreenLyricsSheet( modifier = Modifier.fillMaxSize(), showScrollShadows = true, backgroundColor = startColor.value, - hasBlurBackground = shouldHaze, ) } } else { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt index d6c743569..3400c1faf 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/component/ModalBottomSheet.kt @@ -1685,6 +1685,10 @@ fun NowPlayingBottomSheet( Text( text = uiState.songUIState.title, style = typo().labelMedium, + // typo() bakes a colour into the style, computed from the app's own + // scheme — on this always-dark sheet that reads as washed out next + // to the ActionButton rows below, which take their colour from here. + color = rememberSurfaceDarkColors().content, maxLines = 1, modifier = Modifier @@ -1698,6 +1702,7 @@ fun NowPlayingBottomSheet( .toListName() .connectArtists(), style = typo().bodyMedium, + color = rememberSurfaceDarkColors().subtitle, maxLines = 1, modifier = Modifier @@ -1789,8 +1794,20 @@ fun NowPlayingBottomSheet( } ActionButton( icon = painterResource(Res.drawable.baseline_album_24), - text = if (uiState.songUIState.album == null) Res.string.no_album else null, - textString = uiState.songUIState.album?.name, + // Three states, not two. A track can carry an album ID with no title: the + // row it was parsed from links an album but never spells its name out. + // That case still navigates, so it must not read "No album" — but the name + // is genuinely unknown, so fall back to the generic label rather than + // showing a blank row. The parser deliberately leaves the name empty + // instead of inventing one, because a made-up title would travel out to + // MediaSession and into external scrobblers. + text = + when { + uiState.songUIState.album == null -> Res.string.no_album + uiState.songUIState.album?.name.isNullOrBlank() -> Res.string.album + else -> null + }, + textString = uiState.songUIState.album?.name?.takeIf { it.isNotBlank() }, enable = uiState.songUIState.album != null, ) { uiState.songUIState.album?.id?.let { id -> @@ -1979,6 +1996,9 @@ fun CheckBoxActionButton( stringResource(Res.string.like) }, style = typo().labelSmall, + // Matches [ActionButton], which this sits directly above in every sheet that uses + // both — without it the label alone falls back to the colour typo() carries. + color = rememberSurfaceDarkColors().content, modifier = Modifier .padding(start = 10.dp) diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt new file mode 100644 index 000000000..826f51bc5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/destination/login/LastfmLoginDestination.kt @@ -0,0 +1,13 @@ +package com.maxrave.simpmusic.ui.navigation.destination.login + +import kotlinx.serialization.Serializable + +/** + * Carries nothing on purpose. + * + * The Last.fm callback is handled by `SharedViewModel` when the browser returns, not routed here — + * passing the token through navigation would open a second copy of this screen on top of the one + * the user opened their browser from. + */ +@Serializable +data object LastfmLoginDestination diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt index cf68a5ab4..1490308c8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/AppNavigationGraph.kt @@ -8,12 +8,11 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import com.maxrave.simpmusic.ui.navigation.destination.home.HomeDestination -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent import com.maxrave.simpmusic.ui.navigation.destination.library.LibraryDestination import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination import com.maxrave.simpmusic.ui.navigation.destination.search.SearchDestination @@ -70,7 +69,7 @@ fun AppNavigationGraph( ) } composable { - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { FullscreenPlayer( navController, hideNavBar = hideNavBar, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt index a70712a64..89f130624 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/ListScreenGraph.kt @@ -3,7 +3,6 @@ package com.maxrave.simpmusic.ui.navigation.graph import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.layout.PaddingValues import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.runtime.CompositionLocalProvider import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable @@ -20,7 +19,7 @@ import com.maxrave.simpmusic.ui.screen.other.ArtistScreen import com.maxrave.simpmusic.ui.screen.other.MoreAlbumsScreen import com.maxrave.simpmusic.ui.screen.other.PlaylistScreen import com.maxrave.simpmusic.ui.screen.other.PodcastScreen -import com.maxrave.simpmusic.ui.theme.LocalForceDarkText +import com.maxrave.simpmusic.ui.theme.ForceDarkContent @ExperimentalMaterial3Api @ExperimentalFoundationApi @@ -30,7 +29,7 @@ fun NavGraphBuilder.listScreenGraph( ) { composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { AlbumScreen( browseId = data.browseId, navController = navController, @@ -39,7 +38,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { ArtistScreen( channelId = data.channelId, navController = navController, @@ -48,7 +47,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { LocalPlaylistScreen( id = data.id, navController = navController, @@ -66,7 +65,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { PlaylistScreen( playlistId = data.playlistId, isYourYouTubePlaylist = data.isYourYouTubePlaylist, @@ -76,7 +75,7 @@ fun NavGraphBuilder.listScreenGraph( } composable { entry -> val data = entry.toRoute() - CompositionLocalProvider(LocalForceDarkText provides true) { + ForceDarkContent { PodcastScreen( podcastId = data.podcastId, navController = navController, diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt index a2c89c9b1..2d6b5d3ce 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/navigation/graph/LoginScreenGraph.kt @@ -5,9 +5,11 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.composable import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestination +import com.maxrave.simpmusic.ui.navigation.destination.login.LastfmLoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.LoginDestination import com.maxrave.simpmusic.ui.navigation.destination.login.SpotifyLoginDestination import com.maxrave.simpmusic.ui.screen.login.DiscordLoginScreen +import com.maxrave.simpmusic.ui.screen.login.LastfmLoginScreen import com.maxrave.simpmusic.ui.screen.login.LoginScreen import com.maxrave.simpmusic.ui.screen.login.SpotifyLoginScreen @@ -43,4 +45,13 @@ fun NavGraphBuilder.loginScreenGraph( showBottomNavigation = showBottomBar, ) } + + composable { + LastfmLoginScreen( + innerPadding = innerPadding, + navController = navController, + hideBottomNavigation = hideBottomBar, + showBottomNavigation = showBottomBar, + ) + } } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt index d09f96111..60f38212b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/MiniPlayer.kt @@ -2,14 +2,22 @@ package com.maxrave.simpmusic.ui.screen import androidx.compose.animation.Animatable import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.Crossfade import androidx.compose.animation.SizeTransform import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -21,7 +29,9 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.hoverable import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -42,6 +52,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.VolumeOff import androidx.compose.material.icons.automirrored.filled.VolumeUp +import androidx.compose.material.icons.automirrored.rounded.QueueMusic import androidx.compose.material.icons.filled.VolumeOff import androidx.compose.material.icons.filled.VolumeUp import androidx.compose.material.icons.outlined.OpenInNew @@ -98,20 +109,22 @@ import com.maxrave.domain.data.entities.SongEntity import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.utils.connectArtists import com.maxrave.logger.Logger -import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.PlatformBackdrop import com.maxrave.simpmusic.expect.ui.toImageBitmap import com.maxrave.simpmusic.extension.formatDuration import com.maxrave.simpmusic.extension.getColorFromPalette +import com.maxrave.simpmusic.extension.hsvToColor import com.maxrave.simpmusic.extension.toResizedBitmap import com.maxrave.simpmusic.getPlatform import com.maxrave.simpmusic.ui.component.ExplicitBadge import com.maxrave.simpmusic.ui.component.HeartCheckBox import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout +import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.liquidGlass +import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.ui.theme.LocalIsDarkTheme import com.maxrave.simpmusic.ui.theme.typo import com.maxrave.simpmusic.viewModel.SharedViewModel @@ -560,12 +573,40 @@ fun MiniPlayer( // Desktop bottom bar surface follows the theme (haze over content), so text and controls // use the theme foreground token instead of the artwork-luminance colour. val textColor = MaterialTheme.colorScheme.onBackground + + // Crossfade: RGB rainbow color cycling while transitioning between tracks, mirroring the + // Now Playing screen so the desktop bar signals a crossfade the same way. + val crossfadeTransition = rememberInfiniteTransition(label = "miniPlayerCrossfadeRainbow") + val rainbowHue by crossfadeTransition.animateFloat( + initialValue = 0f, + targetValue = 360f, + animationSpec = + infiniteRepeatable( + animation = tween(1000, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "miniPlayerRainbowHue", + ) + val progressColor by animateColorAsState( + targetValue = + if (timelineState.isCrossfading) { + hsvToColor(rainbowHue, 1f, 1f) + } else { + textColor + }, + animationSpec = tween(300), + label = "miniPlayerCrossfadeColor", + ) + var isSliding by rememberSaveable { mutableStateOf(false) } var sliderValue by rememberSaveable { mutableFloatStateOf(0f) } + var showQueueBottomSheet by rememberSaveable { + mutableStateOf(false) + } LaunchedEffect(key1 = timelineState, key2 = isSliding) { if (!isSliding) { sliderValue = @@ -576,6 +617,13 @@ fun MiniPlayer( } } } + if (showQueueBottomSheet) { + QueueBottomSheet( + onDismiss = { + showQueueBottomSheet = false + }, + ) + } Box( modifier.then( Modifier.clickable { @@ -738,10 +786,16 @@ fun MiniPlayer( ).clip( RoundedCornerShape(8.dp), ), - color = textColor, + // Three levels have to stay apart on one bar: the + // slider above draws played position solid, so + // buffered-but-unplayed must be dimmed and the + // unbuffered remainder dimmer still. At full + // buffer a solid colour here would blend into the + // played part and the bar would look uniform. + color = textColor.copy(alpha = 0.35f), trackColor = textColor.copy( - alpha = 0.4f, + alpha = 0.15f, ), strokeCap = StrokeCap.Round, drawStopIndicator = {}, @@ -780,8 +834,8 @@ fun MiniPlayer( sliderState = sliderState, colors = SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, + thumbColor = progressColor, + activeTrackColor = progressColor, inactiveTrackColor = Color.Transparent, ), thumbTrackGapSize = 0.dp, @@ -805,8 +859,8 @@ fun MiniPlayer( }, colors = SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, + thumbColor = progressColor, + activeTrackColor = progressColor, inactiveTrackColor = Color.Transparent, ), enabled = true, @@ -832,6 +886,19 @@ fun MiniPlayer( sharedViewModel.onUIEvent(UIEvent.ToggleLike) } Spacer(Modifier.width(4.dp)) + // Queue Button + IconButton( + onClick = { + showQueueBottomSheet = true + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.QueueMusic, + tint = textColor, + contentDescription = "", + ) + } + Spacer(Modifier.width(2.dp)) // Desktop mini player button (JVM only) if (getPlatform() == Platform.Desktop) { IconButton(onClick = { toggleMiniPlayer() }) { @@ -841,24 +908,6 @@ fun MiniPlayer( ) } } - IconButton( - onClick = { - // Toggle mute/unmute - val newVolume = if (controllerState.volume > 0f) 0f else 1f - sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume)) - }, - ) { - Icon( - imageVector = - if (controllerState.volume > 0f) { - Icons.AutoMirrored.Filled.VolumeUp - } else { - Icons.AutoMirrored.Filled.VolumeOff - }, - contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", - ) - } - Spacer(Modifier.width(2.dp)) var isVolumeSliding by rememberSaveable { mutableStateOf(false) } @@ -870,66 +919,103 @@ fun MiniPlayer( volumeValue = controllerState.volume } } - CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { - Slider( - value = volumeValue, - onValueChangeFinished = { - isVolumeSliding = false - sharedViewModel.onUIEvent( - UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)), - ) - }, - onValueChange = { - isVolumeSliding = true - volumeValue = it - }, - valueRange = 0f..1f, - modifier = - Modifier - .padding(top = 3.dp) - .width(64.dp), - track = { sliderState -> - SliderDefaults.Track( - modifier = - Modifier - .height(5.dp), - enabled = true, - sliderState = sliderState, - colors = - SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, - inactiveTrackColor = textColor.copy(alpha = 0.3f), - ), - thumbTrackGapSize = 0.dp, - drawTick = { _, _ -> }, - drawStopIndicator = null, - ) + // The slider only claims space while the pointer is over the volume cluster. + // `hoverable` sits on the Row wrapping both the icon and the slider so moving + // between them never drops the hover, and an in-progress drag keeps it open + // even when the pointer slips outside. + val volumeInteractionSource = remember { MutableInteractionSource() } + val isVolumeHovered by volumeInteractionSource.collectIsHoveredAsState() + Row( + modifier = Modifier.hoverable(volumeInteractionSource), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton( + onClick = { + // Toggle mute/unmute + val newVolume = if (controllerState.volume > 0f) 0f else 1f + sharedViewModel.onUIEvent(UIEvent.UpdateVolume(newVolume)) }, - thumb = { - SliderDefaults.Thumb( - modifier = - Modifier - .height(18.dp) - .width(8.dp) - .padding( - vertical = 4.dp, - ), - thumbSize = DpSize(8.dp, 8.dp), - interactionSource = - remember { - MutableInteractionSource() + ) { + Icon( + imageVector = + if (controllerState.volume > 0f) { + Icons.AutoMirrored.Filled.VolumeUp + } else { + Icons.AutoMirrored.Filled.VolumeOff + }, + contentDescription = if (controllerState.volume > 0f) "Mute" else "Unmute", + ) + } + AnimatedVisibility( + visible = isVolumeHovered || isVolumeSliding, + enter = expandHorizontally() + fadeIn(), + exit = shrinkHorizontally() + fadeOut(), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer(Modifier.width(2.dp)) + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides Dp.Unspecified) { + Slider( + value = volumeValue, + onValueChangeFinished = { + isVolumeSliding = false + sharedViewModel.onUIEvent( + UIEvent.UpdateVolume(volumeValue.coerceIn(0f, 1f)), + ) }, - colors = - SliderDefaults.colors().copy( - thumbColor = textColor, - activeTrackColor = textColor, - inactiveTrackColor = textColor.copy(alpha = 0.3f), - ), - enabled = true, - ) - }, - ) + onValueChange = { + isVolumeSliding = true + volumeValue = it + }, + valueRange = 0f..1f, + modifier = + Modifier + .padding(top = 3.dp) + .width(64.dp), + track = { sliderState -> + SliderDefaults.Track( + modifier = + Modifier + .height(5.dp), + enabled = true, + sliderState = sliderState, + colors = + SliderDefaults.colors().copy( + thumbColor = textColor, + activeTrackColor = textColor, + inactiveTrackColor = textColor.copy(alpha = 0.3f), + ), + thumbTrackGapSize = 0.dp, + drawTick = { _, _ -> }, + drawStopIndicator = null, + ) + }, + thumb = { + SliderDefaults.Thumb( + modifier = + Modifier + .height(18.dp) + .width(8.dp) + .padding( + vertical = 4.dp, + ), + thumbSize = DpSize(8.dp, 8.dp), + interactionSource = + remember { + MutableInteractionSource() + }, + colors = + SliderDefaults.colors().copy( + thumbColor = textColor, + activeTrackColor = textColor, + inactiveTrackColor = textColor.copy(alpha = 0.3f), + ), + enabled = true, + ) + }, + ) + } + } + } } Spacer(Modifier.width(4.dp)) IconButton(onClick = { onClose() }) { 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 c6e67ca29..1c8665b24 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 @@ -48,6 +48,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.RadioButton @@ -83,6 +84,7 @@ import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withLink import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController @@ -101,10 +103,12 @@ import com.maxrave.common.VIDEO_QUALITY import com.maxrave.domain.extension.now import com.maxrave.domain.manager.DataStoreManager import com.maxrave.domain.manager.DataStoreManager.Values.TRUE +import com.maxrave.domain.repository.ImportProgress 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 @@ -118,13 +122,14 @@ import com.maxrave.simpmusic.ui.component.RippleIconButton import com.maxrave.simpmusic.ui.component.SettingItem import com.maxrave.simpmusic.ui.navigation.destination.home.CreditDestination import com.maxrave.simpmusic.ui.navigation.destination.login.DiscordLoginDestination +import com.maxrave.simpmusic.ui.navigation.destination.login.LastfmLoginDestination 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 +import com.maxrave.simpmusic.viewModel.ImportViewModel import com.maxrave.simpmusic.viewModel.SettingAlertState import com.maxrave.simpmusic.viewModel.SettingBasicAlertState import com.maxrave.simpmusic.viewModel.SettingsViewModel @@ -184,10 +189,6 @@ import simpmusic.composeapp.generated.resources.baseline_playlist_add_24 import simpmusic.composeapp.generated.resources.better_lyrics import simpmusic.composeapp.generated.resources.blog_notification_description import simpmusic.composeapp.generated.resources.blog_notification_title -import simpmusic.composeapp.generated.resources.blur_fullscreen_lyrics -import simpmusic.composeapp.generated.resources.blur_fullscreen_lyrics_description -import simpmusic.composeapp.generated.resources.blur_player_background -import simpmusic.composeapp.generated.resources.blur_player_background_description import simpmusic.composeapp.generated.resources.buy_me_a_coffee import simpmusic.composeapp.generated.resources.cancel import simpmusic.composeapp.generated.resources.canvas_info @@ -211,8 +212,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 @@ -236,7 +237,20 @@ import simpmusic.composeapp.generated.resources.guest import simpmusic.composeapp.generated.resources.help_build_lyrics_database import simpmusic.composeapp.generated.resources.help_build_lyrics_database_description import simpmusic.composeapp.generated.resources.http +import simpmusic.composeapp.generated.resources.import_data +import simpmusic.composeapp.generated.resources.import_failed +import simpmusic.composeapp.generated.resources.import_playlists_from_other_apps +import simpmusic.composeapp.generated.resources.import_progress_songs +import simpmusic.composeapp.generated.resources.import_reading_file +import simpmusic.composeapp.generated.resources.import_result +import simpmusic.composeapp.generated.resources.import_result_skipped +import simpmusic.composeapp.generated.resources.enable_scrobbling import simpmusic.composeapp.generated.resources.intro_login_to_discord +import simpmusic.composeapp.generated.resources.intro_login_to_lastfm +import simpmusic.composeapp.generated.resources.lastfm_integration +import simpmusic.composeapp.generated.resources.log_in_to_lastfm +import simpmusic.composeapp.generated.resources.logged_in_as +import simpmusic.composeapp.generated.resources.scrobbling_info import simpmusic.composeapp.generated.resources.intro_login_to_spotify import simpmusic.composeapp.generated.resources.invalid import simpmusic.composeapp.generated.resources.invalid_api_key @@ -260,6 +274,9 @@ import simpmusic.composeapp.generated.resources.local_tracking_title import simpmusic.composeapp.generated.resources.log_in_to_discord import simpmusic.composeapp.generated.resources.log_in_to_spotify import simpmusic.composeapp.generated.resources.log_out +import simpmusic.composeapp.generated.resources.log_out_from_discord +import simpmusic.composeapp.generated.resources.log_out_from_lastfm +import simpmusic.composeapp.generated.resources.log_out_from_spotify import simpmusic.composeapp.generated.resources.log_out_warning import simpmusic.composeapp.generated.resources.logged_in import simpmusic.composeapp.generated.resources.lrclib @@ -272,6 +289,7 @@ import simpmusic.composeapp.generated.resources.never import simpmusic.composeapp.generated.resources.no_account import simpmusic.composeapp.generated.resources.normalize_volume import simpmusic.composeapp.generated.resources.not_available_while_casting +import simpmusic.composeapp.generated.resources.ok import simpmusic.composeapp.generated.resources.open_system_equalizer import simpmusic.composeapp.generated.resources.openai import simpmusic.composeapp.generated.resources.openai_api_compatible @@ -408,6 +426,23 @@ fun SettingScreen( } } + // Import playlists converted on the web. Unlike restore, the file is read through Calf's + // KmpFile rather than a Uri, so no expect/actual is needed. The type stays All because a + // converted .json arrives with whatever MIME its source assigned it, and an application/json + // filter would hide it on some hosts. + val importViewModel: ImportViewModel = koinViewModel() + val importState by importViewModel.importState.collectAsStateWithLifecycle() + val importLauncher = + rememberFilePickerLauncher( + type = + FilePickerFileType.All, + selectionMode = FilePickerSelectionMode.Single, + ) { file -> + file.firstOrNull()?.let { + importViewModel.import(it, pl) + } + } + // Open equalizer val resultLauncher = openEqResult(viewModel.getAudioSessionId()) @@ -451,8 +486,6 @@ fun SettingScreen( val proxyUsername by viewModel.proxyUsername.collectAsStateWithLifecycle() val proxyPassword by viewModel.proxyPassword.collectAsStateWithLifecycle() val autoCheckUpdate by viewModel.autoCheckUpdate.collectAsStateWithLifecycle() - val blurFullscreenLyrics by viewModel.blurFullscreenLyrics.collectAsStateWithLifecycle() - val blurPlayerBackground by viewModel.blurPlayerBackground.collectAsStateWithLifecycle() val aiProvider by viewModel.aiProvider.collectAsStateWithLifecycle() val isHasApiKey by viewModel.isHasApiKey.collectAsStateWithLifecycle() val useAITranslation by viewModel.useAITranslation.collectAsStateWithLifecycle() @@ -474,6 +507,9 @@ fun SettingScreen( val customThemeColorHex by sharedViewModel.getCustomThemeColor().collectAsStateWithLifecycle(DataStoreManager.DEFAULT_THEME_COLOR_HEX) var showColorPickerDialog by rememberSaveable { mutableStateOf(false) } val discordLoggedIn by viewModel.discordLoggedIn.collectAsStateWithLifecycle() + val lastfmLoggedIn by viewModel.lastfmLoggedIn.collectAsStateWithLifecycle() + val lastfmUsername by viewModel.lastfmUsername.collectAsStateWithLifecycle() + val lastfmScrobbleEnabled by viewModel.lastfmScrobbleEnabled.collectAsStateWithLifecycle() val richPresenceEnabled by viewModel.richPresenceEnabled.collectAsStateWithLifecycle() val keepServiceAlive by viewModel.keepServiceAlive.collectAsStateWithLifecycle() @@ -614,18 +650,6 @@ fun SettingScreen( smallSubtitle = true, switch = (enableTranslucentNavBar to { viewModel.setTranslucentBottomBar(it) }), ) - SettingItem( - title = stringResource(Res.string.blur_fullscreen_lyrics), - subtitle = stringResource(Res.string.blur_fullscreen_lyrics_description), - smallSubtitle = true, - switch = (blurFullscreenLyrics to { viewModel.setBlurFullscreenLyrics(it) }), - ) - SettingItem( - title = stringResource(Res.string.blur_player_background), - subtitle = stringResource(Res.string.blur_player_background_description), - smallSubtitle = true, - switch = (blurPlayerBackground to { viewModel.setBlurPlayerBackground(it) }), - ) if (getPlatform() == Platform.Android) { SettingItem( title = stringResource(Res.string.enable_liquid_glass_effect), @@ -1164,20 +1188,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, + ) +// } } } } @@ -1350,7 +1374,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 = @@ -1533,7 +1562,14 @@ fun SettingScreen( modifier = Modifier.padding(vertical = 8.dp), ) SettingItem( - title = stringResource(Res.string.log_in_to_spotify), + // The title follows the state: a row that still reads "Log in" while logged in + // gives no clue that tapping it signs you out. + title = + if (spotifyLoggedIn) { + stringResource(Res.string.log_out_from_spotify) + } else { + stringResource(Res.string.log_in_to_spotify) + }, subtitle = if (spotifyLoggedIn) { stringResource(Res.string.logged_in) @@ -1542,7 +1578,9 @@ fun SettingScreen( }, onClick = { if (spotifyLoggedIn) { - viewModel.setSpotifyLogIn(false) + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_spotify) }, + ) { viewModel.setSpotifyLogIn(false) } } else { navController.navigate(SpotifyLoginDestination) } @@ -1581,7 +1619,12 @@ fun SettingScreen( modifier = Modifier.padding(vertical = 8.dp), ) SettingItem( - title = stringResource(Res.string.log_in_to_discord), + title = + if (discordLoggedIn) { + stringResource(Res.string.log_out_from_discord) + } else { + stringResource(Res.string.log_in_to_discord) + }, subtitle = if (discordLoggedIn) { stringResource(Res.string.logged_in) @@ -1590,7 +1633,9 @@ fun SettingScreen( }, onClick = { if (discordLoggedIn) { - viewModel.logOutDiscord() + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_discord) }, + ) { viewModel.logOutDiscord() } } else { navController.navigate(DiscordLoginDestination) } @@ -1609,6 +1654,54 @@ fun SettingScreen( ) } } + // Hidden entirely when the build carries no Last.fm credentials — a FOSS build, or a full + // build whose local.properties has no key. + if (viewModel.lastfmAvailable) { + item(key = "lastfm") { + Column { + Text( + text = stringResource(Res.string.lastfm_integration), + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(vertical = 8.dp), + ) + SettingItem( + title = + if (lastfmLoggedIn) { + stringResource(Res.string.log_out_from_lastfm) + } else { + stringResource(Res.string.log_in_to_lastfm) + }, + subtitle = + if (lastfmLoggedIn) { + stringResource(Res.string.logged_in_as, lastfmUsername) + } else { + stringResource(Res.string.intro_login_to_lastfm) + }, + onClick = { + if (lastfmLoggedIn) { + viewModel.confirmLogOut( + confirmLabel = runBlocking { getString(Res.string.log_out_from_lastfm) }, + ) { viewModel.logOutLastfm() } + } else { + navController.navigate(LastfmLoginDestination) + } + }, + ) + SettingItem( + title = stringResource(Res.string.enable_scrobbling), + subtitle = stringResource(Res.string.scrobbling_info), + switch = (lastfmScrobbleEnabled to { viewModel.setLastfmScrobbleEnabled(it) }), + isEnable = lastfmLoggedIn, + onDisable = { + if (lastfmScrobbleEnabled) { + viewModel.setLastfmScrobbleEnabled(false) + } + }, + ) + } + } + } item(key = "sponsor_block") { Column { Text( @@ -2136,6 +2229,15 @@ fun SettingScreen( } }, ) + SettingItem( + title = stringResource(Res.string.import_data), + subtitle = stringResource(Res.string.import_playlists_from_other_apps), + onClick = { + coroutineScope.launch { + importLauncher.launch() + } + }, + ) } } item(key = "about_us") { @@ -2241,6 +2343,12 @@ fun SettingScreen( EndOfPage() } } + importState?.let { progress -> + ImportProgressDialog( + progress = progress, + onDismiss = importViewModel::dismiss, + ) + } val basisAlertData by viewModel.basicAlertData.collectAsStateWithLifecycle() if (basisAlertData != null) { val alertBasicState = basisAlertData ?: return @@ -2281,8 +2389,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) @@ -2723,7 +2839,7 @@ fun SettingScreen( }, containerColor = MaterialTheme.colorScheme.surface, dragHandle = {}, - scrimColor = Color.Black, + scrimColor = Color.Black.copy(alpha = .5f), sheetState = sheetState, contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, shape = RectangleShape, @@ -2822,4 +2938,96 @@ fun SettingScreen( containerColor = Color.Transparent, ), ) +} + +/** + * Progress and outcome of a playlist import. + * + * Only dismissible once the import has finished — cancelling mid-write would leave the database + * half-populated with no way to tell the user which half. + */ +@Composable +private fun ImportProgressDialog( + progress: ImportProgress, + onDismiss: () -> Unit, +) { + val finished = progress is ImportProgress.Success || progress is ImportProgress.Error + AlertDialog( + onDismissRequest = { if (finished) onDismiss() }, + properties = + DialogProperties( + dismissOnBackPress = finished, + dismissOnClickOutside = finished, + ), + title = { + Text( + text = + stringResource( + if (progress is ImportProgress.Error) Res.string.import_failed else Res.string.import_data, + ), + style = typo().titleSmall, + ) + }, + text = { + Column { + when (progress) { + is ImportProgress.Preparing -> { + Text( + text = stringResource(Res.string.import_reading_file), + style = typo().bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + + is ImportProgress.Importing -> { + Text( + text = stringResource(Res.string.import_progress_songs, progress.processed, progress.total), + style = typo().bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + LinearProgressIndicator( + progress = { + if (progress.total > 0) progress.processed.toFloat() / progress.total else 0f + }, + modifier = Modifier.fillMaxWidth(), + ) + } + + is ImportProgress.Success -> { + Text( + text = + stringResource( + Res.string.import_result, + progress.result.playlistsCreated, + progress.result.songsImported, + ), + style = typo().bodyMedium, + ) + if (progress.result.skippedEntries > 0) { + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(Res.string.import_result_skipped, progress.result.skippedEntries), + style = typo().bodySmall, + ) + } + } + + is ImportProgress.Error -> { + Text( + text = progress.message, + style = typo().bodyMedium, + ) + } + } + } + }, + confirmButton = { + if (finished) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(Res.string.ok)) + } + } + }, + ) } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt new file mode 100644 index 000000000..13cca2377 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/LastfmLoginScreen.kt @@ -0,0 +1,287 @@ +package com.maxrave.simpmusic.ui.screen.login + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavController +import com.maxrave.simpmusic.ui.component.RippleIconButton +import com.maxrave.simpmusic.ui.theme.typo +import com.maxrave.simpmusic.viewModel.LastfmLoginState +import com.maxrave.simpmusic.viewModel.LogInViewModel +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.viewmodel.koinViewModel +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.baseline_arrow_back_ios_new_24 +import simpmusic.composeapp.generated.resources.lastfm_login_failed +import simpmusic.composeapp.generated.resources.lastfm_login_step_1 +import simpmusic.composeapp.generated.resources.lastfm_login_step_2 +import simpmusic.composeapp.generated.resources.lastfm_open_authorize_page +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_confirm +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_hint +import simpmusic.composeapp.generated.resources.lastfm_paste_callback_title +import simpmusic.composeapp.generated.resources.log_in_to_lastfm +import simpmusic.composeapp.generated.resources.login_success +import simpmusic.composeapp.generated.resources.scrobbling_info + +/** Desktop is far wider than any reading measure — the column stops here and centres. */ +private val CONTENT_MAX_WIDTH = 420.dp + +/** + * Last.fm's desktop auth flow, which is what SimpMusic uses on every platform. + * + * There is no WebView here on purpose — unlike the Discord and Spotify screens, this never sees the + * user's password. SimpMusic asks Last.fm for a request token, sends the user to Last.fm's own page + * in their browser, and trades the approved token for a session key when they come back. + * + * @param token supplied when the user returns through the `wordbyword://lastfm-auth` callback; the + * login then finishes on its own and the screen closes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LastfmLoginScreen( + innerPadding: PaddingValues, + navController: NavController, + viewModel: LogInViewModel = koinViewModel(), + hideBottomNavigation: () -> Unit, + showBottomNavigation: () -> Unit, +) { + val state by viewModel.lastfmState.collectAsStateWithLifecycle() + val loggedIn by viewModel.lastfmLoggedIn.collectAsStateWithLifecycle() + val uriHandler = LocalUriHandler.current + var callbackInput by rememberSaveable { mutableStateOf("") } + + LaunchedEffect(Unit) { + hideBottomNavigation() + } + + DisposableEffect(Unit) { + onDispose { + viewModel.resetLastfmState() + showBottomNavigation() + } + } + + // Closes on a stored session key rather than on this screen's own state, because the callback + // is handled by SharedViewModel — the browser redirect never reaches this screen. + LaunchedEffect(loggedIn) { + if (loggedIn) { + navController.navigateUp() + } + } + + LaunchedEffect(state) { + when (val current = state) { + is LastfmLoginState.AwaitingApproval -> uriHandler.openUri(current.authorizeUrl) + is LastfmLoginState.LoggedIn -> viewModel.makeToast(getString(Res.string.login_success)) + is LastfmLoginState.Failed -> viewModel.makeToast(getString(Res.string.lastfm_login_failed)) + else -> Unit + } + } + + val busy = state is LastfmLoginState.CompletingLogin + + Box(modifier = Modifier.fillMaxSize()) { + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(Modifier.height(innerPadding.calculateTopPadding() + 96.dp)) + + Column( + modifier = Modifier.widthIn(max = CONTENT_MAX_WIDTH), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = stringResource(Res.string.scrobbling_info), + style = typo().bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.height(32.dp)) + + if (busy) { + CircularProgressIndicator() + } else { + NumberedStep( + number = "1", + text = stringResource(Res.string.lastfm_login_step_1), + ) + Spacer(Modifier.height(12.dp)) + Button( + onClick = { viewModel.startLastfmLogin() }, + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = CircleShape, + ) { + Text( + text = stringResource(Res.string.lastfm_open_authorize_page), + // typo() bakes a colour into the style, so a Text inside a filled + // button keeps the body colour and disappears into the container + // unless the colour is set here. + style = typo().labelMedium, + color = MaterialTheme.colorScheme.onPrimary, + ) + } + + // Everything below only matters once the browser is open. + if (state is LastfmLoginState.AwaitingApproval) { + Spacer(Modifier.height(28.dp)) + NumberedStep( + number = "2", + text = stringResource(Res.string.lastfm_login_step_2), + ) + + Spacer(Modifier.height(28.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(Modifier.height(20.dp)) + + // Last resort when the redirect never reaches the app at all: no handler + // registered for the scheme (common on Linux), or a browser that will not + // hand off to a local app. The address bar still shows the callback, so let + // the user bring it over by hand. + Text( + text = stringResource(Res.string.lastfm_paste_callback_title), + style = typo().labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(10.dp)) + OutlinedTextField( + value = callbackInput, + onValueChange = { callbackInput = it }, + singleLine = true, + textStyle = typo().bodySmall, + placeholder = { + Text( + text = stringResource(Res.string.lastfm_paste_callback_hint), + style = typo().bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(10.dp)) + OutlinedButton( + onClick = { viewModel.completeLastfmLoginFromCallback(callbackInput) }, + enabled = callbackInput.isNotBlank(), + modifier = Modifier.fillMaxWidth().height(48.dp), + shape = CircleShape, + ) { + Text( + text = stringResource(Res.string.lastfm_paste_callback_confirm), + style = typo().labelSmall, + color = + if (callbackInput.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + } + + Spacer(Modifier.height(48.dp)) + } + + TopAppBar( + modifier = Modifier.align(Alignment.TopCenter), + colors = + TopAppBarDefaults.topAppBarColors( + containerColor = Color.Transparent, + ), + title = { + Text( + text = stringResource(Res.string.log_in_to_lastfm), + style = typo().titleMedium, + ) + }, + navigationIcon = { + Box(Modifier.padding(horizontal = 5.dp)) { + RippleIconButton( + Res.drawable.baseline_arrow_back_ios_new_24, + Modifier.size(32.dp), + true, + ) { + navController.navigateUp() + } + } + }, + ) + } +} + +/** A step marker plus its instruction, so the two actions read as an ordered pair. */ +@Composable +private fun NumberedStep( + number: String, + text: String, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primaryContainer, + modifier = Modifier.size(24.dp), + ) { + Box(contentAlignment = Alignment.Center) { + Text( + text = number, + style = typo().labelSmall, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + Text( + text = text, + style = typo().bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt index d0dd892a8..3e83dcc13 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/login/SpotifyLoginScreen.kt @@ -172,7 +172,11 @@ fun SpotifyLoginScreen( } viewModel.setFullSpotifyCookies(cookies) } - val statusUrl = Regex("^https://accounts\\.spotify\\.com/(?:[^/]+/)?status$") + // Some login flows land on the status page with a query string appended + // (e.g. /en/status?flow_ctx=...). Anchoring right after `status` made those + // URLs fail the match silently, so sp_dc was never saved and auto-login + // appeared to do nothing. + val statusUrl = Regex("^https://accounts\\.spotify\\.com/(?:[^/]+/)?status(?:\\?.*)?$") if (statusUrl.matches(url)) { cookie .takeIf { diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt index 8910205ce..4ceb264b7 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/screen/player/NowPlayingScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize @@ -107,7 +106,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape @@ -136,7 +137,6 @@ import com.maxrave.common.Config.MAIN_PLAYER import com.maxrave.domain.mediaservice.handler.MediaPlayerHandler import com.maxrave.domain.mediaservice.handler.RepeatState import com.maxrave.logger.Logger -import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.Platform import com.maxrave.simpmusic.expect.toggleMiniPlayer import com.maxrave.simpmusic.expect.ui.MediaPlayerView @@ -167,6 +167,7 @@ import com.maxrave.simpmusic.ui.component.PlayPauseButton import com.maxrave.simpmusic.ui.component.PlayerControlLayout import com.maxrave.simpmusic.ui.component.QueueBottomSheet import com.maxrave.simpmusic.ui.component.VoteLyricsDialog +import com.maxrave.simpmusic.ui.component.rememberHolderPainter import com.maxrave.simpmusic.ui.navigation.destination.list.ArtistDestination import com.maxrave.simpmusic.ui.navigation.destination.player.FullscreenDestination import com.maxrave.simpmusic.ui.theme.blackMoreOverlay @@ -177,12 +178,6 @@ import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetUIEvent import com.maxrave.simpmusic.viewModel.NowPlayingBottomSheetViewModel import com.maxrave.simpmusic.viewModel.SharedViewModel import com.maxrave.simpmusic.viewModel.UIEvent -import dev.chrisbanes.haze.HazeTint -import dev.chrisbanes.haze.hazeEffect -import dev.chrisbanes.haze.hazeSource -import dev.chrisbanes.haze.materials.CupertinoMaterials -import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi -import dev.chrisbanes.haze.rememberHazeState import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.distinctUntilChanged @@ -220,8 +215,22 @@ import kotlin.math.roundToLong private const val TAG = "NowPlayingScreen" private val RICH_SYNC_TIMESTAMP_REGEX = Regex("""<\d{2}:\d{2}\.\d{2,3}>\s*""") - -@OptIn(ExperimentalFoundationApi::class, ExperimentalHazeMaterialsApi::class) +private val WHITESPACE_REGEX = Regex("""\s+""") + +// Word-by-word lyrics carry a timestamp per word; replace each with a space +// (not ""), then collapse — otherwise the words run together. +private fun String.stripRichSyncTimestamps(): String = + replace(RICH_SYNC_TIMESTAMP_REGEX, " ") + .replace(WHITESPACE_REGEX, " ") + .trim() + +// Backdrop behind the player. A dark surface rather than pure black: #000000 reads as a hole +// next to the artwork-tinted gradient and cards, which is why Spotify sits its player on a +// near-black surface instead. Used for the gradient's end colour, the fade-to target and the +// area below the gradient so all three match exactly and leave no seam. +private val PlayerBackdropColor = Color(0xFF121212) + +@OptIn(ExperimentalFoundationApi::class) @ExperimentalMaterial3Api @Composable fun NowPlayingScreen( @@ -251,7 +260,7 @@ fun NowPlayingScreen( }, containerColor = Color.Black, dragHandle = {}, - scrimColor = Color.Black, + scrimColor = Color.Black.copy(alpha = .5f), sheetState = sheetState, contentWindowInsets = { WindowInsets(0, 0, 0, 0) }, shape = RectangleShape, @@ -268,7 +277,7 @@ fun NowPlayingScreen( } } -@OptIn(ExperimentalFoundationApi::class, ExperimentalHazeMaterialsApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun NowPlayingScreenContent( sharedViewModel: SharedViewModel = koinInject(), @@ -449,8 +458,6 @@ fun NowPlayingScreenContent( mutableStateOf(Color.White) } - val blurBg by sharedViewModel.blurBg.collectAsStateWithLifecycle() - LaunchedEffect(screenDataState) { Logger.d(TAG, "ScreenDataState: $screenDataState") showHideMiddleLayout = screenDataState.canvasData == null @@ -467,7 +474,9 @@ fun NowPlayingScreenContent( .collectLatest { spotShadowColor = it.getColorFromPalette() startColor.animateTo(it.getColorFromPalette()) - endColor.animateTo(Color.Black) + // Lands on the same backdrop colour the fade and the area below the gradient + // use, so the palette ramp resolves into the surface instead of a black patch. + endColor.animateTo(PlayerBackdropColor) } } @@ -610,7 +619,7 @@ fun NowPlayingScreenContent( mutableStateOf(false) } - var canvasSubtitleLineIndex by rememberSaveable { + var currentLyricLineIndex by rememberSaveable { mutableIntStateOf(-1) } @@ -625,7 +634,7 @@ fun NowPlayingScreenContent( LaunchedEffect(timelineState, screenDataState.lyricsData?.lyrics) { val lyrics = screenDataState.lyricsData?.lyrics if (lyrics == null || lyrics.syncType == "UNSYNCED" || lyrics.syncType == null) { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 return@LaunchedEffect } val lines = lyrics.lines ?: return@LaunchedEffect @@ -644,16 +653,16 @@ fun NowPlayingScreenContent( startTimeMs + 60000 } if (timelineState.current in startTimeMs..endTimeMs) { - canvasSubtitleLineIndex = i + currentLyricLineIndex = i } } if (lines.isNotEmpty() && timelineState.current in 0..(lines.getOrNull(0)?.startTimeMs?.toLongOrNull() ?: 0L) ) { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 } } else { - canvasSubtitleLineIndex = -1 + currentLyricLineIndex = -1 } } @@ -677,7 +686,6 @@ fun NowPlayingScreenContent( sharedViewModel = sharedViewModel, navController = navController, // <-- ADD THIS LINE color = startColor.value, - shouldHaze = sharedViewModel.blurFullscreenLyrics(), ) { showFullscreenLyrics = false } @@ -757,36 +765,10 @@ fun NowPlayingScreenContent( ) } - val hazeState = - rememberHazeState( - blurEnabled = true, - ) - if (screenDataState.lyricsData != null && controllerState.isPlaying) { KeepScreenOn() } Box { - if (blurBg && screenDataState.canvasData == null) { - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(screenDataState.thumbnailURL) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(screenDataState.thumbnailURL + "BIGGER") - .crossfade(550) - .build(), - contentDescription = "", - contentScale = ContentScale.FillHeight, - placeholder = rememberHolderPainter(), - error = rememberHolderPainter(), - modifier = - Modifier - .align(Alignment.Center) - .fillMaxSize() - .hazeSource(hazeState), - ) - } Column( Modifier .verticalScroll( @@ -798,31 +780,45 @@ fun NowPlayingScreenContent( // drags fall through to the Pager. .then( if (showHideMiddleLayout) { - if (blurBg && screenDataState.canvasData == null) { - Modifier - .background(Color.Transparent) - .hazeEffect(hazeState, style = CupertinoMaterials.thin()) { - blurEnabled = true - // The player must stay dark in every app theme. Haze samples the app - // content behind the sheet (white in light theme), so pin a black - // backdrop + tint — otherwise the blurred background washes out to white. - backgroundColor = Color.Black - tints = listOf(HazeTint(Color.Black.copy(alpha = 0.7f))) - } - } else { - Modifier - .background( - Brush.linearGradient( - colors = - listOf( - startColor.value, - endColor.value, - ), - start = gradientOffset.start, - end = gradientOffset.end, - ), + Modifier + // The backdrop fills the whole scrollable content, then the gradient + // is drawn over just the first screen height. Using background() for + // the gradient instead would stretch it across the entire content, + // which is what made it run on forever while scrolling. + .background(PlayerBackdropColor) + .drawBehind { + val gradientHeight = screenInfo.hPX.toFloat() + val area = Size(size.width, gradientHeight) + // Palette gradient, keeping its diagonal angle. + drawRect( + brush = + Brush.linearGradient( + colors = + listOf( + startColor.value, + endColor.value, + ), + start = gradientOffset.start, + end = gradientOffset.end, + ), + size = area, ) - } + // Vertical fade to the backdrop colour, fully opaque from 90% + // down, so the bottom edge meets the area underneath seamlessly + // across the whole width. Adding the same colour as a stop to + // the diagonal gradient above could not do that — it would only + // arrive in one corner and leave a visible diagonal seam. + drawRect( + brush = + Brush.verticalGradient( + 0f to Color.Transparent, + 0.95f to PlayerBackdropColor, + startY = 0f, + endY = gradientHeight, + ), + size = area, + ) + } } else { Modifier.background(Color.Black) }, @@ -851,7 +847,7 @@ fun NowPlayingScreenContent( val isCurrentArtworkPage = page == currentOrderIndex val pageHasCanvas = isCurrentArtworkPage && screenDataState.canvasData != null - // Per-page palette state for the !blurBg gradient backdrop. + // Per-page palette state for the gradient backdrop. // The bitmap is fed in by Layer 2's adjacent-thumbnail AsyncImage // (onSuccess), so we use the SAME bitmap that's painted on screen — // matches the outer Column's palette extraction characteristics. @@ -897,60 +893,30 @@ fun NowPlayingScreenContent( ), ) { // ── Layer 0: per-page backdrop (adjacent pages only) ── - // Mirrors the outer Column's bg branching so the backdrop logic stays - // consistent during a swipe: - // - blurBg=true → dim track thumbnail (matches the haze look) - // - blurBg=false → palette gradient (startColor → endColor) so the - // adjacent page never falls back to a flat dark void - // when the user disables blur. + // Palette gradient (startColor → endColor) so the adjacent page never + // falls back to a flat dark void during a swipe. // The CURRENT page deliberately skips this layer so the existing - // hazeEffect blur / gradient / canvas on the Column stays visible. + // gradient / canvas on the Column stays visible. if (!isCurrentArtworkPage && pageTrack != null) { - if (blurBg) { - val backdropUrl = - pageTrack.thumbnails - ?.maxByOrNull { it.width * it.height } - ?.url - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(backdropUrl) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(backdropUrl) - .crossfade(300) - .build(), - contentDescription = null, - contentScale = ContentScale.Crop, - placeholder = rememberHolderPainter(), - error = rememberHolderPainter(), - modifier = - Modifier - .fillMaxSize() - // Dim slightly so the centered thumbnail above stands out. - .alpha(0.35f), - ) - } else { - // Palette is fed by Layer 2's adjacent-thumbnail AsyncImage - // (see below) so the gradient color stays consistent with - // the bitmap actually painted for that page. - Box( - modifier = - Modifier - .fillMaxSize() - .background( - Brush.linearGradient( - colors = - listOf( - pageStartColor.value, - Color.Black, - ), - start = gradientOffset.start, - end = gradientOffset.end, - ), + // Palette is fed by Layer 2's adjacent-thumbnail AsyncImage + // (see below) so the gradient color stays consistent with + // the bitmap actually painted for that page. + Box( + modifier = + Modifier + .fillMaxSize() + .background( + Brush.linearGradient( + colors = + listOf( + pageStartColor.value, + Color.Black, + ), + start = gradientOffset.start, + end = gradientOffset.end, ), - ) - } + ), + ) } // ── Layer 1: fullscreen canvas backdrop (current track + canvas data) ── @@ -1347,15 +1313,16 @@ fun NowPlayingScreenContent( .value .toInt() } - }, + }.padding( + top = with(localDensity) { WindowInsets.statusBars.getTop(localDensity).toDp() }, + ), colors = TopAppBarDefaults.topAppBarColors().copy( containerColor = Color.Transparent, ), - windowInsets = - TopAppBarDefaults.windowInsets.only( - WindowInsetsSides.Top, - ), + // Position-aware insets shrink per frame while the sheet is dragged (pinned + // bar + layout jitter) — status-bar space is static padding on the modifier. + windowInsets = WindowInsets(0, 0, 0, 0), title = { Column( modifier = Modifier.fillMaxWidth(), @@ -1396,16 +1363,6 @@ fun NowPlayingScreenContent( } }, actions = { - // Desktop mini player button (JVM only) - if (getPlatform() == Platform.Desktop) { - IconButton(onClick = { toggleMiniPlayer() }) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.OpenInNew, - contentDescription = "Mini Player", - tint = Color.White, - ) - } - } IconButton(onClick = { showSheet = true }) { @@ -1458,14 +1415,61 @@ fun NowPlayingScreenContent( }.aspectRatio(1f), ) - Spacer( + // Spotify-style current lyric line — vertically centered in the gap + // between the artwork and the info layout below. This Box replaces the + // plain gap Spacer at the same middleLayoutPaddingDp height, so the + // info layout position never moves; the line just lives inside the gap. + Box( + contentAlignment = Alignment.Center, modifier = Modifier .animateContentSize() .height( middleLayoutPaddingDp.dp, ).fillMaxWidth(), - ) + ) { + val inlineLyrics = screenDataState.lyricsData?.lyrics + val hasSyncedLyrics = + inlineLyrics != null && + inlineLyrics.syncType != null && + inlineLyrics.syncType != "UNSYNCED" && + inlineLyrics.lines != null + // Canvas mode has its own subtitle overlay — never show both. + val currentLyricLineText = + if (!hasSyncedLyrics || + screenDataState.canvasData != null || + currentLyricLineIndex < 0 + ) { + "" + } else { + inlineLyrics + ?.lines + ?.getOrNull(currentLyricLineIndex) + ?.words + ?.stripRichSyncTimestamps() + .orEmpty() + } + Crossfade( + targetState = currentLyricLineText, + animationSpec = tween(durationMillis = 300), + label = "inlineLyricLine", + ) { lineText -> + Text( + text = lineText, + style = typo().labelSmall, + color = Color.White, + maxLines = 1, + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + .basicMarquee( + iterations = Int.MAX_VALUE, + animationMode = MarqueeAnimationMode.Immediately, + ).focusable(), + ) + } + } // Info Layout Box { @@ -1941,7 +1945,7 @@ fun NowPlayingScreenContent( .animateContentSize(), ) { this@Column.AnimatedVisibility( - visible = canvasSubtitleLineIndex > -1, + visible = currentLyricLineIndex > -1, enter = fadeIn() + expandVertically(), exit = fadeOut() + shrinkVertically(), ) { @@ -1950,10 +1954,9 @@ fun NowPlayingScreenContent( screenDataState.lyricsData ?.lyrics ?.lines - ?.getOrNull(canvasSubtitleLineIndex) + ?.getOrNull(currentLyricLineIndex) ?.words - ?.replace(RICH_SYNC_TIMESTAMP_REGEX, "") - ?.trim() + ?.stripRichSyncTimestamps() if (!lineText.isNullOrBlank()) { Column( modifier = Modifier.fillMaxWidth(), @@ -1978,10 +1981,9 @@ fun NowPlayingScreenContent( ?.translatedLyrics ?.first ?.lines - ?.getOrNull(canvasSubtitleLineIndex) + ?.getOrNull(currentLyricLineIndex) ?.words - ?.replace(RICH_SYNC_TIMESTAMP_REGEX, "") - ?.trim() + ?.stripRichSyncTimestamps() if (!translatedLineText.isNullOrBlank()) { Text( modifier = @@ -2323,65 +2325,79 @@ fun NowPlayingScreenContent( shape = RoundedCornerShape(8.dp), colors = CardDefaults.elevatedCardColors().copy( - containerColor = startColor.value, + containerColor = Color(0xFF212121), ), ) { - Box( - modifier = - Modifier - .fillMaxWidth() - .height(250.dp), - ) { - val thumb = screenDataState.songInfoData?.authorThumbnail - AsyncImage( - model = - ImageRequest - .Builder(LocalPlatformContext.current) - .data(thumb) - .diskCachePolicy(CachePolicy.ENABLED) - .diskCacheKey(thumb) - .crossfade(550) - .build(), - placeholder = rememberHolderPainter(isVideo = true), - error = rememberHolderPainter(isVideo = true), - contentDescription = null, - contentScale = ContentScale.Crop, + Column(modifier = Modifier.fillMaxWidth()) { + // Artwork occupies the top of the card; only the section + // label sits on top of it. Name and subscriber count moved + // below onto the solid card surface so they stay readable + // regardless of how bright the artist photo is. + Box( modifier = Modifier - .fillMaxSize() - .alpha(0.8f) - .clip( - RoundedCornerShape(8.dp), - ), - ) - Box( + .fillMaxWidth() + .height(250.dp), + ) { + val thumb = screenDataState.songInfoData?.authorThumbnail + AsyncImage( + model = + ImageRequest + .Builder(LocalPlatformContext.current) + .data(thumb) + .diskCachePolicy(CachePolicy.ENABLED) + .diskCacheKey(thumb) + .crossfade(550) + .build(), + placeholder = rememberHolderPainter(isVideo = true), + error = rememberHolderPainter(isVideo = true), + contentDescription = null, + contentScale = ContentScale.Crop, + // No explicit clip: the ElevatedCard already clips to + // its 8.dp shape, so only the card's top corners round + // and the image meets the panel below flush. + modifier = Modifier.fillMaxSize(), + ) + // Scrim behind the label: artist photos are often bright + // at the top, which swallowed the white text. + Box( + modifier = + Modifier + .matchParentSize() + .background( + Brush.verticalGradient( + 0f to Color.Black.copy(alpha = 0.6f), + 0.4f to Color.Transparent, + ), + ), + ) + Text( + text = stringResource(Res.string.artists), + style = typo().labelMedium, + color = Color.White, + modifier = + Modifier + .align(Alignment.TopStart) + .padding(15.dp), + ) + } + Column( modifier = Modifier - .padding(15.dp) - .fillMaxSize(), + .fillMaxWidth() + .padding(horizontal = 15.dp, vertical = 12.dp), ) { - Column(Modifier.align(Alignment.TopStart)) { - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = stringResource(Res.string.artists), - style = typo().labelMedium, - color = Color.White, - ) - } - Column(Modifier.align(Alignment.BottomStart)) { - Text( - text = screenDataState.songInfoData?.author ?: "", - style = typo().labelMedium, - color = Color.White, - ) - Spacer(modifier = Modifier.height(5.dp)) - Text( - text = screenDataState.songInfoData?.subscribers ?: "", - style = typo().bodySmall, - textAlign = TextAlign.End, - ) - Spacer(modifier = Modifier.height(5.dp)) - } + Text( + text = screenDataState.songInfoData?.author ?: "", + style = typo().titleMedium, + color = Color.White, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = screenDataState.songInfoData?.subscribers ?: "", + style = typo().bodySmall, + color = Color.White.copy(alpha = 0.7f), + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt index 4de0635c6..a63db25e8 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/ui/theme/Theme.kt @@ -5,6 +5,7 @@ import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialExpressiveTheme +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable @@ -52,6 +53,12 @@ val LocalAppColors = staticCompositionLocalOf { DarkAppColors } /** True in dark theme. Provided by [AppTheme] so platform glass (blur/tint) can adapt reliably. */ val LocalIsDarkTheme = staticCompositionLocalOf { true } +/** + * The dark scheme to use for immersive screens while the app itself is on the light theme. + * Provided by [AppTheme], consumed by [ForceDarkContent]; null only outside of [AppTheme]. + */ +val LocalForcedDarkColorScheme = staticCompositionLocalOf { null } + /** Parses "RRGGBB" or "AARRGGBB" (optionally "#"-prefixed) into a [Color]; null if malformed. */ fun parseThemeColorHex(hex: String): Color? { val clean = hex.trim().removePrefix("#") @@ -130,6 +137,19 @@ fun AppTheme( style = PaletteStyle.TonalSpot, modifyColorScheme = { cs -> if (isDark) cs else cs.withNeutralLightSurfaces() }, ) + // Immersive screens stay dark even at light theme (see [ForceDarkContent]). Resolve their scheme + // once here instead of letting every such subtree build a palette of its own. + val forcedDarkScheme = + if (isDark) { + colorScheme + } else { + rememberDynamicColorScheme( + seedColor = seedColor, + isDark = true, + isAmoled = true, + style = PaletteStyle.TonalSpot, + ) + } SystemBarAppearanceEffect(isDark) MaterialExpressiveTheme( colorScheme = colorScheme, @@ -138,9 +158,38 @@ fun AppTheme( LocalContentColor provides colorScheme.onSurfaceVariant, LocalAppColors provides if (isDark) DarkAppColors else LightAppColors, LocalIsDarkTheme provides isDark, + LocalForcedDarkColorScheme provides forcedDarkScheme, content = content, ) }, typography = typo(colorScheme), ) } + +/** + * Wraps immersive screens — artist, album, playlist, local playlist, podcast and the players — which + * always draw over dark artwork and must therefore render dark even on the light theme. + * + * [LocalForceDarkText] alone is not enough: it only recolors text through [typo]. Icons, buttons and + * every other Material default read [LocalContentColor] and [MaterialTheme.colorScheme], which at + * light theme resolve to dark-on-light and turn grey and unreadable over the artwork. Providing the + * dark scheme here fixes the whole subtree in one place. + */ +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ForceDarkContent(content: @Composable () -> Unit) { + val darkScheme = LocalForcedDarkColorScheme.current ?: MaterialTheme.colorScheme + MaterialExpressiveTheme( + colorScheme = darkScheme, + content = { + CompositionLocalProvider( + LocalForceDarkText provides true, + LocalIsDarkTheme provides true, + LocalContentColor provides darkScheme.onSurfaceVariant, + LocalAppColors provides DarkAppColors, + content = content, + ) + }, + typography = typo(darkScheme, forceDark = true), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt new file mode 100644 index 000000000..4d32981fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/ImportViewModel.kt @@ -0,0 +1,66 @@ +package com.maxrave.simpmusic.viewModel + +import androidx.lifecycle.viewModelScope +import com.maxrave.domain.repository.ImportProgress +import com.maxrave.domain.repository.ImportRepository +import com.maxrave.simpmusic.viewModel.base.BaseViewModel +import com.mohamedrejeb.calf.core.PlatformContext +import com.mohamedrejeb.calf.io.KmpFile +import com.mohamedrejeb.calf.io.readByteArray +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import simpmusic.composeapp.generated.resources.Res +import simpmusic.composeapp.generated.resources.import_invalid_file + +/** + * Drives an import of a file produced by the SimpMusic web converter. + * + * The picked file is read here rather than in the repository because only the app module knows + * what a picked file is. [KmpFile] comes from the same Calf picker the backup/restore flow uses, + * and its `readByteArray` is already cross-platform, so no expect/actual is needed. + */ +class ImportViewModel( + private val importRepository: ImportRepository, +) : BaseViewModel() { + private val _importState: MutableStateFlow = MutableStateFlow(null) + + /** `null` while idle; otherwise the latest step of the running or finished import. */ + val importState: StateFlow = _importState.asStateFlow() + + private var importJob: Job? = null + + fun import( + file: KmpFile, + context: PlatformContext, + ) { + importJob?.cancel() + importJob = + viewModelScope.launch { + _importState.value = ImportProgress.Preparing + val invalidFileMessage = getString(Res.string.import_invalid_file) + val json = + withContext(Dispatchers.IO) { + runCatching { file.readByteArray(context).decodeToString() } + }.getOrElse { throwable -> + log("import: cannot read picked file - ${throwable.message}") + _importState.value = ImportProgress.Error(invalidFileMessage) + return@launch + } + importRepository.import(json, invalidFileMessage).collect { progress -> + _importState.value = progress + } + } + } + + /** Back to idle, which is what dismisses the progress/result dialog. */ + fun dismiss() { + importJob?.cancel() + importJob = null + _importState.value = null + } +} diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt index 00858b80d..733d4cf1b 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/LogInViewModel.kt @@ -5,8 +5,13 @@ import com.maxrave.domain.manager.DataStoreManager import com.maxrave.simpmusic.viewModel.base.BaseViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import org.simpmusic.lastfm.authorizeUrl +import org.simpmusic.lastfm.completeLogin class LogInViewModel( private val dataStoreManager: DataStoreManager, @@ -64,4 +69,101 @@ class LogInViewModel( dataStoreManager.setDiscordToken(token) } } + + private val _lastfmState: MutableStateFlow = MutableStateFlow(LastfmLoginState.Idle) + val lastfmState: StateFlow get() = _lastfmState.asStateFlow() + + /** + * True once a session key is stored, whoever put it there. + * + * The callback can be handled outside this screen — [SharedViewModel.completeLastfmLogin] takes + * it when the browser returns — so the screen watches the stored result instead of only its own + * state. That is what lets it close itself in both paths: the redirect, and the pasted link. + */ + val lastfmLoggedIn: StateFlow = + dataStoreManager.lastfmSessionKey + .map { it.isNotEmpty() } + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) + + /** + * Sends the user to Last.fm to approve access. + * + * Nothing is fetched first: in the web flow Last.fm mints the token itself and hands it back on + * the callback, so the app has no token to hold on to until the user returns. + */ + fun startLastfmLogin() { + val url = authorizeUrl() + _lastfmState.value = + if (url != null) { + LastfmLoginState.AwaitingApproval(authorizeUrl = url) + } else { + LastfmLoginState.Failed + } + } + + /** Exchanges the token that arrived on the callback for a session key. */ + fun completeLastfmLogin(token: String) { + viewModelScope.launch { + if (token.isEmpty()) return@launch + _lastfmState.value = LastfmLoginState.CompletingLogin + val session = completeLogin(token) + if (session != null) { + dataStoreManager.setLastfmSession( + sessionKey = session.sessionKey, + username = session.username, + ) + _lastfmState.value = LastfmLoginState.LoggedIn(session.username) + } else { + _lastfmState.value = LastfmLoginState.Failed + } + } + } + + /** + * Finishes the login from whatever the user pasted back. + * + * The callback deep link cannot be relied on: a Linux desktop may have no handler registered for + * the scheme, a browser may refuse to hand off to a local app, and the approval may even have + * happened on a different device. In all of those the user can still see the redirect target in + * their address bar, so accept it directly. + * + * Takes either the whole callback URL (`wordbyword://lastfm-auth?token=abc`) or a bare token, + * because which of the two a user manages to copy is not something to be strict about. + */ + fun completeLastfmLoginFromCallback(input: String) { + val trimmed = input.trim() + if (trimmed.isEmpty()) return + val token = + if (trimmed.contains("token=")) { + trimmed.substringAfter("token=").substringBefore("&").trim() + } else { + trimmed + } + if (token.isEmpty()) { + _lastfmState.value = LastfmLoginState.Failed + return + } + completeLastfmLogin(token) + } + + fun resetLastfmState() { + _lastfmState.value = LastfmLoginState.Idle + } +} + +sealed interface LastfmLoginState { + data object Idle : LastfmLoginState + + /** The browser is open and the user has not come back yet. */ + data class AwaitingApproval( + val authorizeUrl: String, + ) : LastfmLoginState + + data object CompletingLogin : LastfmLoginState + + data class LoggedIn( + val username: String, + ) : LastfmLoginState + + data object Failed : LastfmLoginState } \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt index cd7064c26..f92e79243 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.kt @@ -40,16 +40,20 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import org.koin.core.component.inject +import org.simpmusic.lastfm.isLastfmAvailable import simpmusic.composeapp.generated.resources.Res import simpmusic.composeapp.generated.resources.backup_create_failed import simpmusic.composeapp.generated.resources.backup_create_success import simpmusic.composeapp.generated.resources.backup_in_progress +import simpmusic.composeapp.generated.resources.cancel import simpmusic.composeapp.generated.resources.clear_canvas_cache import simpmusic.composeapp.generated.resources.clear_downloaded_cache import simpmusic.composeapp.generated.resources.clear_player_cache import simpmusic.composeapp.generated.resources.clear_thumbnail_cache +import simpmusic.composeapp.generated.resources.log_out_confirm_message import simpmusic.composeapp.generated.resources.restore_failed import simpmusic.composeapp.generated.resources.restore_in_progress +import simpmusic.composeapp.generated.resources.warning class SettingsViewModel( private val dataStoreManager: DataStoreManager, @@ -121,10 +125,6 @@ class SettingsViewModel( val autoCheckUpdate: StateFlow = _autoCheckUpdate private var _updateChannel: MutableStateFlow = MutableStateFlow(DataStoreManager.GITHUB) val updateChannel: StateFlow = _updateChannel - private var _blurFullscreenLyrics = MutableStateFlow(false) - val blurFullscreenLyrics: StateFlow = _blurFullscreenLyrics - private var _blurPlayerBackground = MutableStateFlow(false) - val blurPlayerBackground: StateFlow = _blurPlayerBackground private val _aiProvider = MutableStateFlow(DataStoreManager.AI_PROVIDER_OPENAI) val aiProvider: StateFlow = _aiProvider private val _isHasApiKey = MutableStateFlow(false) @@ -166,6 +166,22 @@ class SettingsViewModel( private val _richPresenceEnabled = MutableStateFlow(false) val richPresenceEnabled: StateFlow = _richPresenceEnabled + /** + * False in a FOSS build, and in a full build with no API key in `local.properties`. The whole + * Last.fm block in settings is hidden when it is false, rather than offering a login that + * could never succeed. + */ + val lastfmAvailable: Boolean = isLastfmAvailable() + + private val _lastfmUsername = MutableStateFlow("") + val lastfmUsername: StateFlow = _lastfmUsername + + private val _lastfmLoggedIn = MutableStateFlow(false) + val lastfmLoggedIn: StateFlow = _lastfmLoggedIn + + private val _lastfmScrobbleEnabled = MutableStateFlow(false) + val lastfmScrobbleEnabled: StateFlow = _lastfmScrobbleEnabled + private val _keepServiceAlive = MutableStateFlow(false) val keepServiceAlive: StateFlow = _keepServiceAlive @@ -260,8 +276,6 @@ class SettingsViewModel( getCanvasCache() getTranslucentBottomBar() getAutoCheckUpdate() - getBlurFullscreenLyrics() - getBlurPlayerBackground() getAIProvider() getAIApiKey() getAITranslation() @@ -279,6 +293,8 @@ class SettingsViewModel( getExplicitContentEnabled() getDiscordLoggedIn() getDiscordRichPresenceEnabled() + getLastfmSession() + getLastfmScrobbleEnabled() getKeepServiceAlive() getKeepYouTubePlaylistOffline() getCombineLocalAndYouTubeLiked() @@ -467,6 +483,39 @@ class SettingsViewModel( } } + private fun getLastfmSession() { + viewModelScope.launch { + dataStoreManager.lastfmSessionKey.collect { key -> + _lastfmLoggedIn.value = key.isNotEmpty() + } + } + viewModelScope.launch { + dataStoreManager.lastfmUsername.collect { username -> + _lastfmUsername.value = username + } + } + } + + fun logOutLastfm() { + viewModelScope.launch { + dataStoreManager.setLastfmSession(sessionKey = "", username = "") + } + } + + private fun getLastfmScrobbleEnabled() { + viewModelScope.launch { + dataStoreManager.lastfmScrobbleEnabled.collect { enabled -> + _lastfmScrobbleEnabled.value = enabled == DataStoreManager.TRUE + } + } + } + + fun setLastfmScrobbleEnabled(enabled: Boolean) { + viewModelScope.launch { + dataStoreManager.setLastfmScrobbleEnabled(enabled) + } + } + fun logOutDiscord() { viewModelScope.launch { dataStoreManager.setDiscordToken("") @@ -728,36 +777,6 @@ class SettingsViewModel( } } - private fun getBlurFullscreenLyrics() { - viewModelScope.launch { - dataStoreManager.blurFullscreenLyrics.collect { blurFullscreenLyrics -> - _blurFullscreenLyrics.value = blurFullscreenLyrics == DataStoreManager.TRUE - } - } - } - - fun setBlurFullscreenLyrics(blurFullscreenLyrics: Boolean) { - viewModelScope.launch { - dataStoreManager.setBlurFullscreenLyrics(blurFullscreenLyrics) - getBlurFullscreenLyrics() - } - } - - private fun getBlurPlayerBackground() { - viewModelScope.launch { - dataStoreManager.blurPlayerBackground.collect { blurPlayerBackground -> - _blurPlayerBackground.value = blurPlayerBackground == DataStoreManager.TRUE - } - } - } - - fun setBlurPlayerBackground(blurPlayerBackground: Boolean) { - viewModelScope.launch { - dataStoreManager.setBlurPlayerBackground(blurPlayerBackground) - getBlurPlayerBackground() - } - } - private fun getAutoCheckUpdate() { viewModelScope.launch { dataStoreManager.autoCheckForUpdates.collect { autoCheckUpdate -> @@ -787,6 +806,33 @@ class SettingsViewModel( _basicAlertData.value = alertData } + /** + * Asks before signing out of a linked account. + * + * Every one of these rows sits inside a long settings list and does its work on a single tap, + * with no undo — and getting back in is not symmetric with getting out: Last.fm sends the user + * through a browser again, YouTube and Spotify through a full web login. The confirmation is + * cheap next to that. + * + * @param confirmLabel names the service on the confirming button, because the dialog is the + * only thing on screen at that moment and "Log out" alone does not say out of what. + */ + fun confirmLogOut( + confirmLabel: String, + onConfirm: () -> Unit, + ) { + viewModelScope.launch { + setBasicAlertData( + SettingBasicAlertState( + title = getString(Res.string.warning), + message = getString(Res.string.log_out_confirm_message), + confirm = confirmLabel to onConfirm, + dismiss = getString(Res.string.cancel), + ), + ) + } + } + private fun getUsingProxy() { viewModelScope.launch { dataStoreManager.usingProxy.collectLatest { usingProxy -> diff --git a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt index 45ffb5c9a..3a0dfe7cd 100644 --- a/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt +++ b/composeApp/src/commonMain/kotlin/com/maxrave/simpmusic/viewModel/SharedViewModel.kt @@ -91,10 +91,14 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext +import org.jetbrains.compose.resources.getString +import org.simpmusic.lastfm.completeLogin import simpmusic.composeapp.generated.resources.Res import simpmusic.composeapp.generated.resources.added_to_queue import simpmusic.composeapp.generated.resources.added_to_youtube_liked import simpmusic.composeapp.generated.resources.error +import simpmusic.composeapp.generated.resources.lastfm_login_failed +import simpmusic.composeapp.generated.resources.login_success import simpmusic.composeapp.generated.resources.play_next import simpmusic.composeapp.generated.resources.removed_from_youtube_liked import simpmusic.composeapp.generated.resources.shared @@ -163,15 +167,6 @@ class SharedViewModel( val castState: StateFlow get() = mediaPlayerHandler.castState - val blurBg: StateFlow = - dataStoreManager.blurPlayerBackground - .map { it == TRUE } - .stateIn( - scope = viewModelScope, - started = SharingStarted.WhileSubscribed(500L), - initialValue = false, - ) - private var _controllerState = MutableStateFlow( ControlState( @@ -468,6 +463,32 @@ class SharedViewModel( _intent.value = intent } + /** + * Finishes a Last.fm login from the `wordbyword://lastfm-auth` callback. + * + * It lands here, and not on the login screen, because the callback arrives at the app rather + * than at any one screen: the user left for their browser, and the screen they left from may + * not even exist any more if the process was killed. Routing the token onwards through + * navigation would push a second copy of the login screen on top of the one already open. + * + * The screen learns it succeeded by watching the stored session key, not by being told. + */ + fun completeLastfmLogin(token: String) { + if (token.isEmpty()) return + viewModelScope.launch { + val session = completeLogin(token) + if (session != null) { + dataStoreManager.setLastfmSession( + sessionKey = session.sessionKey, + username = session.username, + ) + makeToast(getString(Res.string.login_success)) + } else { + makeToast(getString(Res.string.lastfm_login_failed)) + } + } + } + fun showNotificationPermissionDialog() { _showNotificationPermissionDialog.value = true } @@ -479,8 +500,6 @@ class SharedViewModel( } } - fun blurFullscreenLyrics(): Boolean = runBlocking { dataStoreManager.blurFullscreenLyrics.first() == TRUE } - private fun getLikeStatus(videoId: String?) { viewModelScope.launch { if (videoId != null) { @@ -828,8 +847,10 @@ class SharedViewModel( is UIEvent.UpdateVolume -> { val newVolume = uiEvent.newVolume - dataStoreManager.setPlayerVolume(newVolume) + // Apply to the player first: persisting to DataStore is a suspending disk write + // and must not sit between the user's gesture and the audible change. mediaPlayerHandler.onPlayerEvent(PlayerEvent.UpdateVolume(newVolume)) + dataStoreManager.setPlayerVolume(newVolume) } } } diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt index b37c79629..30d70c445 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopApp.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -52,6 +53,7 @@ import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource import org.koin.core.context.loadKoinModules import org.koin.core.context.startKoin +import org.simpmusic.lastfm.configLastfm import org.koin.java.KoinJavaComponent.inject import org.koin.mp.KoinPlatform.getKoin import simpmusic.composeapp.generated.resources.Res @@ -64,34 +66,87 @@ import simpmusic.composeapp.generated.resources.open_miniplayer import simpmusic.composeapp.generated.resources.quit_app import simpmusic.composeapp.generated.resources.time_out_check_internet_connection_or_change_piped_instance_in_settings +/** + * Any `scheme://…` command-line argument. RFC 3986 §3.1 allows ALPHA followed by + * ALPHA / DIGIT / "+" / "-" / ".". + */ +private val DEEP_LINK_ARG = Regex("^[A-Za-z][A-Za-z0-9+.\\-]*://.+") + @OptIn(ExperimentalMaterial3Api::class) fun runDesktopApp(args: Array = emptyArray()) { // Install crash dialog handler first — catches all uncaught exceptions CrashDialog.install() + // Force AWT to probe the Desktop API now, BEFORE anything can load libmpv. + // + // On Linux the bundled libmpv drags in its own libglib-2.0.so.0 (2.72, built on + // Ubuntu 22.04) through the $ORIGIN/lib rpath. Once that soname is taken, AWT's + // XDesktopPeer.init() can no longer dlopen the SYSTEM libgio-2.0.so.0 — on a host + // with newer glib (2.80 on Ubuntu 24.04) it dies with + // "libgobject-2.0.so.0: undefined symbol: g_dir_unref" — and the JDK then reports + // the whole Desktop API as unsupported for the rest of the process. Every external + // link breaks: openUrl() silently does nothing, and Compose's LocalUriHandler + // throws UnsupportedOperationException out of the click handler and crashes the app. + // + // XDesktopPeer caches the result of that probe on the first call, so calling it here + // — while only the system glib is mapped — pins it to "supported" and lets the system + // gio/gobject win the soname race. AWT is already initialized at this point anyway + // (forceLinuxWmClass() in Main.kt touches the toolkit before we get here), so this + // costs nothing. + // + // This is a workaround, not the cure: the real fix is to stop bundling glib in + // scripts/mpv-linux/stage.sh, which needs the Linux mpv tarball rebuilt and + // republished. Keep this call until that lands. + java.awt.Desktop.isDesktopSupported() + System.setProperty("compose.swing.render.on.graphics", "true") System.setProperty("compose.interop.blending", "true") System.setProperty("compose.layers.type", "COMPONENT") + // Skiko's vsync wait can park the EDT forever after a display change: + // macOS waits on a CVDisplayLink-signalled NSConditionLock with no timeout + // (DisplayLinkThrottler.mm), Linux blocks inside the vsync'd glXSwapBuffers + // when X11 loses its sync source (CMP-9725 / CMP-10019). Both surface as + // "UI frozen, audio keeps playing" when the window moves to another + // monitor. With vsync off, skiko paces frames with its refresh-rate frame + // limiter (skiko.vsync.framelimit.fallback, on by default) instead. + // Windows renders via DirectX and shows no such freeze — leave it alone. + if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) { + System.setProperty("skiko.vsync.enabled", "false") + } + // Handle deep link URIs // macOS: receives URI via Desktop open URI handler (app already running or launched via scheme) // Windows/Linux: receives URI as command-line argument val isMacOS = System.getProperty("os.name", "").contains("Mac", ignoreCase = true) if (isMacOS && java.awt.Desktop.isDesktopSupported()) { + val desktop = java.awt.Desktop.getDesktop() try { - java.awt.Desktop.getDesktop().setOpenURIHandler { event -> + desktop.setOpenURIHandler { event -> DesktopDeepLinkHandler.onNewUri(event.uri.toString()) } } catch (_: UnsupportedOperationException) { // Shouldn't happen on macOS, but handle gracefully } + // Clicking the Dock icon of a running app arrives as a "reopen" Apple + // Event — macOS never spawns a second instance, so the + // SingleInstanceManager restore path can't fire for it. Route it + // through the same restore signal the tray and second instances use. + if (desktop.isSupported(java.awt.Desktop.Action.APP_EVENT_REOPENED)) { + desktop.addAppEventListener( + java.awt.desktop.AppReopenedListener { DesktopRestoreSignal.request() }, + ) + } } // Handle URI passed as command-line argument (Windows/Linux, or explicit invocation) // Note: macOS does NOT pass URI as args — it uses Apple Events via setOpenURIHandler - val deepLinkArg = - args.firstOrNull()?.takeIf { arg -> - arg.startsWith("simpmusic://") || arg.startsWith("http://") || arg.startsWith("https://") - } + // + // Matched by shape rather than against a fixed list of schemes. The list used to be + // simpmusic:// + http:// + https://, which silently dropped the Last.fm callback + // (wordbyword://lastfm-auth?token=…): the OS launched us with the token, this filter + // discarded it, and the app merely came to the foreground while the login sat there + // waiting forever. Any scheme we register with the OS must survive this line. + val deepLinkArg = args.firstOrNull()?.takeIf { DEEP_LINK_ARG.matches(it) } // Single-instance guard — MUST run before startKoin. The DataStore Koin // singleton is `createdAtStart`, so a second Windows instance would touch // ~/.simpmusic/settings.preferences_pb and crash with an "Unable to rename @@ -130,6 +185,7 @@ fun runDesktopApp(args: Array = emptyArray()) { changeLanguageNative(language) VersionManager.initialize() + configLastfm(BuildKonfig.lastfmApiKey, BuildKonfig.lastfmSecret) if (BuildKonfig.sentryDsn.isNotEmpty()) { Sentry.init { options -> options.dsn = BuildKonfig.sentryDsn @@ -193,18 +249,37 @@ fun runDesktopApp(args: Array = emptyArray()) { val quitAppString = stringResource(Res.string.quit_app) val openMiniPlayer = stringResource(Res.string.open_miniplayer) val closeMiniPlayer = stringResource(Res.string.close_miniplayer) + val appName = stringResource(Res.string.app_name) + val nowPlayingData by sharedViewModel.nowPlayingScreenData.collectAsState() + val nowPlayingTitle = nowPlayingData.nowPlayingTitle + val nowPlayingArtist = nowPlayingData.artistName + val hasTrack = nowPlayingTitle.isNotBlank() + // Tray menus are narrow, so long titles would stretch the popup. + val trayLabel: (String) -> String = { if (it.length > 40) it.take(39).trimEnd() + "…" else it } Tray( icon = painterResource(Res.drawable.circle_app_icon), - tooltip = stringResource(Res.string.app_name), + tooltip = + when { + hasTrack && nowPlayingArtist.isNotBlank() -> "$nowPlayingTitle — $nowPlayingArtist" + hasTrack -> nowPlayingTitle + else -> appName + }, primaryAction = { - isVisible = true - windowState.isMinimized = false + DesktopRestoreSignal.request() }, ) { + // Disabled entries: while the window is hidden the tray is the only place showing what + // is playing. + if (hasTrack) { + Item(trayLabel(nowPlayingTitle), isEnabled = false) + if (nowPlayingArtist.isNotBlank()) { + Item(trayLabel(nowPlayingArtist), isEnabled = false) + } + Divider() + } if (!isVisible) { Item(openAppString) { - isVisible = true - windowState.isMinimized = false + DesktopRestoreSignal.request() } } if (MiniPlayerManager.isOpen) { @@ -289,6 +364,14 @@ fun runDesktopApp(args: Array = emptyArray()) { state = windowState, visible = isVisible, ) { + // Restore requests (Dock reopen, tray, second instance) also need a + // z-order raise; visibility/minimized are reset by the + // application-level collector, but toFront needs the AWT window. + LaunchedEffect(Unit) { + DesktopRestoreSignal.requests.collect { + window.toFront() + } + } Column( modifier = Modifier diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt index d35645d45..66a356b92 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/DesktopDeepLinkHandler.kt @@ -133,6 +133,11 @@ object DesktopDeepLinkHandler { Uri.parse(convertedUrl) } + // wordbyword://lastfm-auth?token=xxx → pass through untouched. It must NOT be rewritten + // to simpmusic.org like the branch above does: App.kt matches on this exact scheme to + // read the Last.fm request token. + parsed.scheme == "wordbyword" -> parsed + // https://simpmusic.org/app/... or YouTube URLs → pass through else -> parsed } diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt index 1fe25da48..82ec32eb2 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/WindowsProtocolRegistrar.kt @@ -20,7 +20,14 @@ import com.maxrave.logger.Logger object WindowsProtocolRegistrar { private const val TAG = "WindowsProtocolRegistrar" private const val SCHEME = "simpmusic" - private const val REG_KEY = "HKCU\\Software\\Classes\\$SCHEME" + + /** + * The Last.fm auth callback. Its scheme is fixed by the callback URL registered on the Last.fm + * API account, so it cannot be folded into "simpmusic". + */ + private const val LASTFM_SCHEME = "wordbyword" + + private fun regKeyOf(scheme: String) = "HKCU\\Software\\Classes\\$scheme" fun register() { if (!System.getProperty("os.name", "").contains("Windows", ignoreCase = true)) return @@ -30,33 +37,46 @@ object WindowsProtocolRegistrar { return } + register(SCHEME, "URL:SimpMusic Protocol", exePath) + register(LASTFM_SCHEME, "URL:SimpMusic Last.fm Callback", exePath) + } + + private fun register( + scheme: String, + description: String, + exePath: String, + ) { + val regKey = regKeyOf(scheme) try { - if (isAlreadyRegistered(exePath)) { - Logger.d(TAG, "Protocol handler already registered with correct path") + if (isAlreadyRegistered(scheme, exePath)) { + Logger.d(TAG, "$scheme:// already registered with correct path") return } - Logger.d(TAG, "Registering simpmusic:// protocol handler -> $exePath") + Logger.d(TAG, "Registering $scheme:// protocol handler -> $exePath") // Main key with protocol description - regAdd(REG_KEY, null, "URL:SimpMusic Protocol") - regAdd(REG_KEY, "URL Protocol", "") + regAdd(regKey, null, description) + regAdd(regKey, "URL Protocol", "") // DefaultIcon - regAdd("$REG_KEY\\DefaultIcon", null, "\"$exePath\",0") + regAdd("$regKey\\DefaultIcon", null, "\"$exePath\",0") // shell\open\command - regAdd("$REG_KEY\\shell\\open\\command", null, "\"$exePath\" \"%1\"") + regAdd("$regKey\\shell\\open\\command", null, "\"$exePath\" \"%1\"") - Logger.d(TAG, "Protocol handler registered successfully") + Logger.d(TAG, "$scheme:// registered successfully") } catch (e: Exception) { - Logger.e(TAG, "Failed to register protocol handler: ${e.message}") + Logger.e(TAG, "Failed to register $scheme:// handler: ${e.message}") } } - private fun isAlreadyRegistered(currentExePath: String): Boolean { + private fun isAlreadyRegistered( + scheme: String, + currentExePath: String, + ): Boolean { return try { - val result = regQuery("$REG_KEY\\shell\\open\\command", null) + val result = regQuery("${regKeyOf(scheme)}\\shell\\open\\command", null) // Registry stores path with quotes: "C:\path\to\SimpMusic.exe" "%1" // Normalize both for comparison val normalizedExe = currentExePath.replace("\\", "/").lowercase() diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt index d445919c0..106d09f32 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/expect/OpenUrl.jvm.kt @@ -1,5 +1,6 @@ package com.maxrave.simpmusic.expect +import com.maxrave.logger.Logger import multiplatform.network.cmptoast.showToast import java.awt.Desktop import java.awt.Toolkit @@ -7,9 +8,67 @@ import java.awt.datatransfer.Clipboard import java.awt.datatransfer.StringSelection import java.net.URI +private const val TAG = "OpenUrl" + +/** + * Opens [url] in the user's browser. Never throws, and never fails silently. + * + * `java.awt.Desktop` is tried first because it is the one path that honours the user's + * configured default handler on every OS — but it cannot be relied on. On Linux it hangs off a + * dlopen of the system libgio, which fails outright once another native library has claimed the + * libglib soname (see the Desktop API warm-up at the top of `runDesktopApp`). This used to be + * the ONLY path, wrapped in an `if` with no `else`, so the moment the Desktop API went + * unsupported every external link in the app quietly did nothing — no error, no log, no toast. + * + * Hence the per-OS launcher below it, and a toast when even that fails. + */ actual fun openUrl(url: String) { - if (Desktop.isDesktopSupported() && Desktop.getDesktop().isSupported(Desktop.Action.BROWSE)) { - Desktop.getDesktop().browse(URI(url)) + if (openWithDesktopApi(url)) return + if (openWithSystemLauncher(url)) return + + Logger.e(TAG, "Could not open $url by any means") + showToast("Could not open the link") +} + +private fun openWithDesktopApi(url: String): Boolean = + runCatching { + if (!Desktop.isDesktopSupported()) return@runCatching false + val desktop = Desktop.getDesktop() + if (!desktop.isSupported(Desktop.Action.BROWSE)) return@runCatching false + desktop.browse(URI(url)) + true + }.getOrElse { error -> + Logger.w(TAG, "Desktop.browse failed for $url: ${error.message}") + false + } + +/** + * Hands the URL to whatever the platform ships for the job. + * + * The Linux list is ordered by how likely each one is to be present: `xdg-open` is the + * convention but is not guaranteed to be installed, `gio` comes with glib so it is on every + * desktop that runs GTK at all, and `$BROWSER` is the last thing a minimal setup may still + * honour. A successful `start()` only proves the binary exists — waiting for an exit code would + * block the caller (these run on the AWT event thread), so we take that as good enough. + */ +private fun openWithSystemLauncher(url: String): Boolean { + val os = System.getProperty("os.name", "").lowercase() + val candidates = + when { + os.contains("mac") -> listOf(listOf("open", url)) + os.contains("windows") -> listOf(listOf("rundll32", "url.dll,FileProtocolHandler", url)) + else -> + listOfNotNull( + listOf("xdg-open", url), + listOf("gio", "open", url), + System.getenv("BROWSER")?.takeIf { it.isNotBlank() }?.let { listOf(it, url) }, + ) + } + + return candidates.any { command -> + runCatching { ProcessBuilder(command).start() } + .onFailure { error -> Logger.w(TAG, "${command.first()} failed for $url: ${error.message}") } + .isSuccess } } @@ -21,4 +80,4 @@ actual fun shareUrl( val clipboard: Clipboard = Toolkit.getDefaultToolkit().systemClipboard clipboard.setContents(stringSelection, null) showToast("Copied to clipboard") -} \ No newline at end of file +} diff --git a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt index 442f7c4bc..951b1cae5 100644 --- a/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt +++ b/composeApp/src/jvmMain/kotlin/com/maxrave/simpmusic/viewModel/SettingsViewModel.jvm.kt @@ -23,7 +23,6 @@ import java.io.FileOutputStream import java.util.Locale import java.util.zip.ZipEntry import java.util.zip.ZipInputStream -import java.util.zip.ZipOutputStream import kotlin.system.exitProcess actual suspend fun calculateDataFraction(cacheRepository: CacheRepository): SettingsStorageSectionFraction? = null @@ -79,9 +78,7 @@ actual suspend fun backupNative( uri: Uri, backupDownloaded: Boolean, ) { - ZipOutputStream( - FileOutputStream(File(uri.toString())) - ).use { + FileOutputStream(File(uri.toString())).use { it.buffered().zipOutputStream().use { outputStream -> File(getHomeFolderPath(listOf(".simpmusic")), "$SETTINGS_FILENAME.preferences_pb") .inputStream() @@ -98,7 +95,6 @@ actual suspend fun backupNative( inputStream.copyTo(outputStream) } } - } } diff --git a/conveyor.conf b/conveyor.conf index d0b26499f..12e9f04fa 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 { @@ -66,13 +67,29 @@ app { consistency-checks = warn } + // Custom URI schemes to register the app for. "simpmusic" backs the deep links + // from simpmusic.org; "wordbyword" is the Last.fm auth callback, whose scheme is + // fixed by the callback URL registered on the Last.fm API account. + // + // This key MUST stay at the top level of `app`. Conveyor binds url-schemes on + // AppConfig, not on MacConfig/WindowsConfig/LinuxConfig — compare + // `MacConfigAccess.getUrlSchemes()` (reads appConfig) with the + // `getFileAssociations()` right next to it (reads mac). We previously set + // `mac.url-schemes` / `windows.url-schemes` / `linux.url-schemes`; HOCON accepts + // unknown keys silently, so all three sat inert and the packaged apps carried NO + // CFBundleURLTypes at all — verified by diffing two mac-app builds that differed + // only in where this key was written. Nothing ever registered, not even + // "simpmusic", from May 2026 until this was moved. + url-schemes = [ "simpmusic", "wordbyword" ] + // 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 +104,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 ----------------------------------------------------- @@ -119,22 +135,60 @@ app { mac { info-plist.LSApplicationCategoryType = "public.app-category.music" info-plist.UIBackgroundModes = [ "audio", "fetch", "processing" ] - url-schemes = [ "simpmusic" ] + + // Turn off Apple's "nano" malloc zone — the macOS counterpart of the + // MALLOC_ARENA_MAX cap the AppImage's AppRun sets for glibc. + // + // The nano zone serves allocations under 256 bytes from its own region and + // has a history of not coalescing what it frees, so the region only grows; + // Electron and Chrome both hit this. mpv/FFmpeg allocate small structures + // constantly on every track change, which is exactly that pattern. + // + // LSEnvironment is applied by LaunchServices, so it covers Finder/Dock/ + // Spotlight/`open` launches — i.e. every normal launch of an installed + // .app — but NOT running Contents/MacOS/... directly from a shell. + // + // Declaring this dict also pins the process PATH to the bare + // "/usr/bin:/bin:/usr/sbin:/sbin". Checked before adding: the only + // ProcessBuilder use on this path is the Windows-only powershell/wmic VM + // probe in DesktopApp.kt, and link opening goes through Desktop.browse(), + // which LaunchServices resolves without consulting PATH. Revisit this if + // the macOS build ever shells out to a binary outside those four dirs. + info-plist.LSEnvironment.MallocNanoZone = "0" + + // 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 { 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 { - desktop-file."Comment[en]" = "A FOSS YouTube Music client" - desktop-file.Categories = "AudioVideo;Audio;Player;Music;" - // Must match the runtime WM_CLASS forced by forceLinuxWmClass() in - // desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke - // once AWT started initializing on a coroutine worker thread). - desktop-file.StartupWMClass = "SimpMusic" - url-schemes = [ "simpmusic" ] + // Entries belong INSIDE the "Desktop Entry" group — that group is the .desktop + // file's real section, and it is where Conveyor puts Name/Exec/Icon/Comment. + // These three used to sit one level up (desktop-file.Categories, etc.), which + // put them beside the group rather than in it, so none of them reached the + // generated file — same silent-misplacement bug as url-schemes above. + desktop-file."Desktop Entry" { + "Comment[en]" = "A FOSS YouTube Music client" + Categories = "AudioVideo;Audio;Player;Music;" + // Must match the runtime WM_CLASS forced by forceLinuxWmClass() in + // desktopApp/.../Main.kt. Was "java-lang-Thread" (a stale guess that broke + // once AWT started initializing on a coroutine worker thread). + StartupWMClass = "SimpMusic" + } } // --- Build settings ------------------------------------------------------- @@ -143,11 +197,31 @@ 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" + + // 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" + + // Hand the unused half of the heap back to the OS. + // + // The cap above bounds how large the heap may GROW; it says nothing about giving committed + // pages back. Measured mid-session: G1 was holding 204 MB committed while only 104 MB was + // live, and that 100 MB counts against RSS just like anything else. + // + // G1 only uncommits at the end of a concurrent cycle or a full GC. A music player allocates + // so little that neither ever fires on its own — the heap simply ratchets to its high-water + // mark and stays there. G1PeriodicGCInterval is the missing half: it runs a cycle when the + // VM has been idle for the interval, which is precisely when trimming is free. Setting the + // free-ratio bounds without it would be a no-op. + options += "-XX:MinHeapFreeRatio=20" + options += "-XX:MaxHeapFreeRatio=40" + options += "-XX:G1PeriodicGCInterval=60000" } jvm.mac.options += "--add-opens=java.desktop/sun.awt=ALL-UNNAMED" diff --git a/core b/core index 63c9b8bde..11d12b1f9 160000 --- a/core +++ b/core @@ -1 +1 @@ -Subproject commit 63c9b8bdefe2b97758c5f62b253a49875613dae0 +Subproject commit 11d12b1f9643384a04ab0f817277109fdb70310b diff --git a/desktopApp/MEMORY_TUNING.md b/desktopApp/MEMORY_TUNING.md new file mode 100644 index 000000000..53ac54286 --- /dev/null +++ b/desktopApp/MEMORY_TUNING.md @@ -0,0 +1,298 @@ +# Desktop memory tuning + +Why the desktop app's RAM use was cut, what each change does, and how to undo any of it. + +Investigated 2026-07-29 against `45260568` (dev). Every number below was measured on a live +nightly AppImage, not estimated. + +--- + +## The problem + +The desktop app settled at roughly **1 GB resident on all three platforms**, and on Linux it kept +climbing the longer a listening session ran — reaching **1.90 GB after 1h40m**. + +This had been reported across Windows, macOS and Linux, which was the clue that mattered: a bug +confined to one platform's code would not show up identically on three different OSes. + +## What it was NOT + +Two plausible explanations were tested and **disproved**, so nobody has to re-investigate them: + +| Suspicion | How it was ruled out | +| --- | --- | +| Java heap growing unbounded | `-Xmx512m` was already in force and honoured. `jcmd GC.heap_info` showed the heap **using only 121 MB of its 512 MB cap** while RSS was 1.9 GB. | +| Leaked `MpvPlayer` handles (precache / crossfade) | Each live handle owns an `Mpv-Event-Pump` thread, so handles are directly countable. Over a 30-minute, 60-sample run the count **stayed flat at 3** and thread totals oscillated (117–158) without accumulating. Handles are released correctly. | + +## What it actually was + +**Allocator fragmentation.** The native memory had been `free()`d correctly — the C allocator was +simply holding onto the pages instead of returning them to the OS. + +The decisive test was calling `malloc_trim(0)` on the running process: + +``` +RSS before : 920 MB +$1 = 1 <- glibc reports it released memory +RSS after : 753 MB <- 167 MB handed straight back to the OS +``` + +Memory that can be returned on demand was, by definition, already free. That is fragmentation, not +a leak. + +On Linux the amplifier was glibc's per-thread arenas: + +``` +nproc = 20 -> glibc arena ceiling = 8 x nproc = 160 +anon mappings aligned to a 64 MB boundary, counted = 161 (the arena signature) +RSS held by those mappings = 1451 MB +everything else (Skia, JNA, thread stacks, libs) = 233 MB +``` + +glibc spawns a fresh arena whenever it sees allocator lock contention, up to `8 x nproc`, and never +gives one back. mpv/FFmpeg allocate and free large demuxer/decoder buffers on every track, and with +150+ threads in the process those allocations spread across every arena available. + +**This is why RAM use depends on the user's core count** — a 4-core machine tops out at 32 arenas, +a 20-core machine at 160. It also explains why the three platforms differ in severity while sharing +the same symptom: Windows' NT heap and macOS' libmalloc have their own per-core caching, they just +scale less aggressively. + +--- + +## The changes + +Four changes across two repositories, each scoped to the platforms listed below. + +| # | Change | File | Platforms | +| --- | --- | --- | --- | +| 1 | `MemoryTrimmer` — returns free pages to the OS when playback goes idle | `core/media/media-jvm/.../memory/MemoryTrimmer.kt` (new) + `.../mpv/MpvPlayerAdapter.kt` | Linux, Windows | +| 2 | Let G1 uncommit unused heap | `conveyor.conf` (`jvm` block) | all | +| 3 | Disable Apple's nano malloc zone | `conveyor.conf` (`mac` block) | macOS | +| 4 | Cap glibc arenas | `desktopApp/build.gradle.kts` (`AppRun`) | Linux | + +Files 1 live in the **`core/` submodule**; the rest are in the parent repository. + +### 1. `MemoryTrimmer` + +Every desktop allocator can be asked to return free pages; only the spelling differs. + +| OS | Call | Available since | +| --- | --- | --- | +| Linux | `malloc_trim(0)` | glibc | +| Windows | `HeapSetInformation(NULL, HeapOptimizeResources, ...)` | Windows 8.1 | + +**macOS is deliberately excluded.** It does have an equivalent — `malloc_zone_pressure_relief(NULL, 0)` +— and it shipped briefly, but it was traced to a startup crash and removed. A null zone means *every +registered zone*, not just the one mpv/FFmpeg allocate from, so the call also asks the zones behind +Metal, QuartzCore and Skia to give pages back. Running off a background dispatcher it can land inside +a `CATransaction` commit on the main thread; the process then dies with an uncaught NSException +raised in `-[MTLLayer blitCallback]`, which macOS 26+ turns into a hard crash. + +The window is narrow enough that anything perturbing timing hides it — attaching `log stream` was +enough — so it only reproduced on a real Finder/Dock launch, where activation and the window +animation hold the main thread inside CoreAnimation longer. Change 3 below already covers allocator +growth on macOS, so nothing is lost. **Do not re-add the macOS branch.** + +Bound through JNA, which the module already uses for libmpv. Called from the `PAUSED` and `IDLE` +transitions in `MpvPlayerAdapter`, throttled to at most once per 60 s, dispatched off the service +thread. + +**It must only run while idle.** `malloc_trim` walks the heap holding the allocator lock, so every +thread that calls `malloc` — mpv's decoder threads included — blocks until it returns. Calling it +during playback would be audible. + +### 2. G1 heap uncommit + +``` +-XX:MinHeapFreeRatio=20 +-XX:MaxHeapFreeRatio=40 +-XX:G1PeriodicGCInterval=60000 +``` + +Measured mid-session: G1 held **204 MB committed while only 104 MB was live**. `-Xmx` bounds how far +the heap may grow, not how much it hands back. + +The periodic-GC flag is load-bearing, not decoration. G1 only uncommits at the end of a concurrent +cycle or a full GC, and a music player allocates so little that neither ever fires — the heap simply +ratchets to its high-water mark and stays. **Setting the free-ratio bounds without +`G1PeriodicGCInterval` is a no-op.** + +### 3. macOS nano zone + +``` +info-plist.LSEnvironment.MallocNanoZone = "0" +``` + +The nano zone serves sub-256-byte allocations from its own region and has a history of not +coalescing what it frees. Electron and Chrome both hit this. + +Two properties of `LSEnvironment` worth knowing: + +- It is applied by **LaunchServices**, so it covers Finder / Dock / Spotlight / `open` launches — + every normal launch of an installed `.app` — but **not** running `Contents/MacOS/...` directly + from a shell. +- Declaring it **pins `PATH`** to the bare `/usr/bin:/bin:/usr/sbin:/sbin`. This was checked before + adding: the only `ProcessBuilder` use on the macOS path is the Windows-only `powershell`/`wmic` VM + probe, and link opening goes through `Desktop.browse()`, which LaunchServices resolves without + consulting `PATH`. **Re-check this if the macOS build ever shells out to a binary outside those + four directories.** + +### 4. glibc arena cap (Linux) + +```sh +export MALLOC_ARENA_MAX=2 +``` + +Added to the generated `AppRun`, so it is Linux-only by construction — that file only exists inside +the AppImage. + +Measured effect: arenas **161 → 5**, and RSS over a comparable session went from 1.90 GB to roughly +0.93 GB. The cost is more allocator lock contention, which this workload does not appear to notice: +the audio path is native and its buffers are long-lived. + +### Deliberately not changed + +- **Windows Segment Heap.** Microsoft's docs state the segment heap "has, by default, backed all + process heaps for packaged apps since its inception", and this project ships Windows **only** as + `.msix` — so it always runs with package identity and is already opted in. The + `heap:HeapPolicy` manifest element is an opt-**out**. Worth confirming once on a real build with + System Informer's heap-flags column or WinDbg `!heap`. +- **`demuxer-max-bytes`.** Looks like an easy 32 MB per handle, but it is a *ceiling*, not an + allocation. With `cache-secs = 10`, a 320 kbps stream prefetches about **400 KB** — the ceiling is + never approached, so lowering it would save nothing and only risk underruns. +- **Coil's in-memory cache.** Left at its default. + +--- + +## Rollback + +Each change is independent. Undo only the one whose symptom you see. + +### Symptom → suspect + +| Symptom | Suspect | Go to | +| --- | --- | --- | +| Audio glitches or stutters **when pausing / stopping** | `MemoryTrimmer` | A | +| macOS: hard crash a few seconds after a Finder/Dock launch, stack inside `-[MTLLayer blitCallback]` | `MemoryTrimmer`'s macOS branch is back | A | +| General sluggishness on Linux, higher CPU under load | `MALLOC_ARENA_MAX=2` (lock contention) | D | +| CPU spikes while the app sits idle | `G1PeriodicGCInterval` | B | +| macOS feels slower overall | `MallocNanoZone=0` | C | +| macOS: "command not found" / a helper process fails to launch | `LSEnvironment` pinned `PATH` | C | + +### A. Disable `MemoryTrimmer` + +Least invasive first — comment out the two call sites in `MpvPlayerAdapter.transitionToState` +(submodule `core/`); the class can stay: + +```kotlin +InternalState.PAUSED -> { + ... + // trimNativeMemory("paused") +} +InternalState.IDLE -> { + ... + // trimNativeMemory("idle") +} +``` + +To keep trimming but make it rarer, raise `MIN_INTERVAL_MS` in `MemoryTrimmer` instead. To remove it +entirely: delete `core/media/media-jvm/.../memory/MemoryTrimmer.kt`, plus the import, the +`trimNativeMemory` helper and both calls in `MpvPlayerAdapter.kt`. + +### B. Revert G1 heap flags + +In `conveyor.conf`, delete these three lines from the `jvm` block. **Keep `-Xmx512m`** — that is +older and unrelated: + +``` +options += "-XX:MinHeapFreeRatio=20" +options += "-XX:MaxHeapFreeRatio=40" +options += "-XX:G1PeriodicGCInterval=60000" +``` + +### C. Revert the macOS nano zone + +In `conveyor.conf`, delete this line from the `mac` block: + +``` +info-plist.LSEnvironment.MallocNanoZone = "0" +``` + +Removing it also restores normal `PATH` inheritance, so this is the fix for both macOS symptoms +above. If you want the memory benefit but need `PATH` back, keep the line and add `PATH` explicitly +to the same dict rather than dropping it. + +### D. Revert or relax the arena cap + +In `desktopApp/build.gradle.kts`, inside the `AppRun` heredoc, remove: + +```sh +export MALLOC_ARENA_MAX=2 +``` + +Prefer **relaxing before removing**: `2` is the aggressive end. Try `4`, then `8`. Even `8` is far +below the 160 this machine class defaults to, and it keeps most of the benefit while cutting +contention. + +### Revert everything + +```bash +git -C core checkout -- media/media-jvm/src/main/java/com/simpmusic/media_jvm/mpv/MpvPlayerAdapter.kt +rm -rf core/media/media-jvm/src/main/java/com/simpmusic/media_jvm/memory +git checkout -- conveyor.conf desktopApp/build.gradle.kts +``` + +(Adjust if these changes have already been committed — then it is a revert of those commits instead.) + +--- + +## Measuring again + +Take the reading **after listening for ~30 minutes**, not at startup. The whole point is behaviour +over a session. + +**Linux** + +```bash +pid=$(pgrep -f -i simpmusic | while read p; do [ "$(cat /proc/$p/comm)" = simpmusic ] && echo $p; done | head -1) +grep VmRSS /proc/$pid/status +jcmd $pid GC.heap_info +# count glibc arenas (64 MB-aligned anonymous mappings) +awk '/^[0-9a-f]/{split($1,a,"-"); if ($6=="") print a[1]}' /proc/$pid/maps | + while read x; do python3 -c "print(1 if int('$x',16)%(64*1024*1024)==0 else 0)"; done | grep -c 1 +``` + +**macOS** + +```bash +pid=$(pgrep -f SimpMusic | head -1) +ps -o rss= -p $pid | awk '{print "RSS: "int($1/1024)" MB"}' +vmmap -summary $pid | head -25 +jcmd $pid GC.heap_info +``` + +**Windows (PowerShell)** + +```powershell +Get-Process SimpMusic | Select-Object Name,Id,@{n="WorkingSet_MB";e={[int]($_.WorkingSet64/1MB)}} +jcmd GC.heap_info +``` + +### Reading the result + +The number that matters is **the ratio of heap to RSS**, not RSS alone. + +- Heap small (~150 MB) but RSS large → native memory dominates; the changes above are the relevant + lever. +- Heap large, close to the 512 MB cap → a Java-side problem instead; allocator tuning will not help + and the investigation should start from `GC.heap_info` and a heap dump. + +### Reference points (before any of these changes) + +| Platform | RSS | Notes | +| --- | --- | --- | +| Linux, 20 cores, 1h40m session | 1.90 GB | heap using 121 MB; 161 arenas holding 1451 MB | +| Linux, with `MALLOC_ARENA_MAX=2`, 19 min | 0.93 GB | 5 arenas; still grew ~11 MB/min | +| Linux, after a manual `malloc_trim(0)` | 0.75 GB | 167 MB returned instantly | +| macOS / Windows | ~1 GB | reported; not yet broken down | diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 46992d5bf..d5d52c63c 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", @@ -368,6 +370,12 @@ tasks.register("packageConveyorAppImage") { val versionName = libs.versions.version.name.get() val desktopFile = appDir.resolve("simpmusic.desktop") + // This file, not Conveyor's, is what actually reaches the user: AppRun installs it into + // ~/.local/share/applications and runs update-desktop-database. So every scheme listed in + // `app.url-schemes` in conveyor.conf has to be repeated here as an x-scheme-handler MIME + // type, or xdg-open finds no handler for it and the redirect dies in the browser. + // "wordbyword" is the Last.fm auth callback and was missing, which is why Last.fm login + // could never come back to the app on Linux. desktopFile.writeText( """[Desktop Entry] |Type=Application @@ -379,7 +387,7 @@ tasks.register("packageConveyorAppImage") { |Terminal=false |Categories=Audio;AudioVideo; |StartupWMClass=SimpMusic - |MimeType=x-scheme-handler/simpmusic; + |MimeType=x-scheme-handler/simpmusic;x-scheme-handler/wordbyword; | """.trimMargin(), ) @@ -406,6 +414,19 @@ tasks.register("packageConveyorAppImage") { |sed "s|Exec=bin/simpmusic|Exec=${'$'}APPIMAGE_PATH|" "${'$'}HERE/simpmusic.desktop" > "${'$'}DESKTOP_DIR/com-maxrave-simpmusic-MainKt.desktop" |update-desktop-database "${'$'}DESKTOP_DIR" 2>/dev/null || true | + |# Cap glibc's per-thread malloc arenas. + |# + |# glibc spawns a fresh 64 MB-aligned arena whenever it sees mutex contention, up to + |# 8 x nproc of them, and never gives an arena back to the OS. Measured on a 20-core + |# box: 161 arenas holding 1.45 GB of a 1.9 GB RSS, while the JVM heap was using only + |# 121 MB of its 512 MB cap. Pinning the count to 2 took that to ~5 arenas. The cost is + |# more allocator lock contention, which this workload does not notice - the audio path + |# is native and its buffers are long-lived. + |# + |# Linux-only by construction: this file only exists inside the AppImage. macOS and + |# Windows tune their allocators elsewhere, and MemoryTrimmer covers all three at runtime. + |export MALLOC_ARENA_MAX=2 + | |cd "${'$'}HERE" |exec bin/simpmusic "${'$'}@" | @@ -438,11 +459,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 +476,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 +490,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 +503,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 +520,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 +533,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 +541,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/docs/import-format-v1.md b/docs/import-format-v1.md new file mode 100644 index 000000000..3f2883455 --- /dev/null +++ b/docs/import-format-v1.md @@ -0,0 +1,130 @@ +# SimpMusic import format v1 + +This is the contract between the **SimpMusic web converter** and the **SimpMusic app**. + +The web converter takes an export from another service — an Exportify CSV from Spotify, a backup +from another YouTube Music client, and so on — matches every track to a YouTube Music `videoId`, +and emits a single JSON file in the shape below. The app does **no matching of its own**: it parses +the file and writes rows. If a track has no `videoId` by the time the file is written, it must not +appear in the file at all. + +The file is produced by the converter at and consumed by +`ImportRepository` in the app (see `core/domain/.../repository/ImportRepository.kt`). + +## Envelope + +```json +{ + "songs": [ ... ], + "playlists": [ ... ] +} +``` + +There is deliberately **no** `version` field and **no** `source` field. Do not add envelope fields; +the app parses with `ignoreUnknownKeys = true`, so extra keys are silently dropped rather than +rejected, and a converter that relies on them will fail silently. + +Both keys are optional in the parser and default to an empty list, but a file where **both** lists +are empty is rejected by the app as "not a valid import file" — that is what makes picking the +wrong file produce an error instead of a silent "imported 0 songs". + +## `songs[]` + +`songs` is the deduplicated catalogue: **each `videoId` appears exactly once**. Playlists never +inline a song; they reference it by `videoId`. + +```json +{ + "videoId": "dQw4w9WgXcQ", + "title": "Never Gonna Give You Up", + "artistName": ["Rick Astley"], + "artistId": ["UCuAXFkgsw1L7xaCfnd5JJOw"], + "albumName": "Whenever You Need Somebody", + "albumId": "MPREb_xxx", + "duration": "3:33", + "durationSeconds": 213, + "isExplicit": false, + "thumbnails": "https://lh3.googleusercontent.com/...", + "videoType": "MUSIC_VIDEO_TYPE_ATV" +} +``` + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `videoId` | string | **yes** | YouTube video id. Primary key of the `song` table. Must be unique across `songs`. | +| `title` | string | **yes** | Track title as it should be shown. | +| `artistName` | string[] | no | Display names, in order. `null`/absent when unknown. | +| `artistId` | string[] | no | YouTube channel ids, **positionally aligned with `artistName`**. See the length rule below. | +| `albumName` | string | no | Album title. Do not emit the literal string `"Album"` — older app builds used it as a placeholder and the app treats it as "no album". | +| `albumId` | string | no | YouTube browse id of the album (`MPREb_…`). | +| `duration` | string | no, defaults `""` | Human-readable duration, `m:ss` or `h:mm:ss`. Shown as-is in the UI. | +| `durationSeconds` | int | no, defaults `0` | Duration in seconds. Used for sorting and for scrobble thresholds. | +| `isExplicit` | bool | no, defaults `false` | Explicit marker. | +| `thumbnails` | string | no | A **single** URL, not a list. Prefer the largest square art available (`w544-h544` for songs). | +| `videoType` | string | no, defaults `""` | YouTube Music video type, e.g. `MUSIC_VIDEO_TYPE_ATV` (audio track) or `MUSIC_VIDEO_TYPE_OMV` (official video). Drives whether the app can play it as video. | + +### The `artistId` length rule (important) + +`artistId` must either be **absent/`null`**, or have **exactly the same length as `artistName`**. + +The app pairs the two lists by index when it converts a stored song back into a track. A non-null +`artistId` that is shorter than `artistName` would read past its end. The app defends itself — it +drops `artistId` entirely whenever the sizes disagree — but that silently loses every channel id +for that track, so the converter should never emit a partial list. If only some artists resolved to +a channel id, emit `artistId: null` and keep the names. + +## `playlists[]` + +```json +{ + "title": "Chill mix", + "thumbnail": "https://...", + "videoIds": ["dQw4w9WgXcQ", "abc123xyz00"] +} +``` + +| Field | Type | Required | Meaning | +|---|---|---|---| +| `title` | string | **yes** | Playlist name. Created as a **local playlist**, not synced to YouTube. | +| `thumbnail` | string | no | Cover art URL. | +| `videoIds` | string[] | no, defaults `[]` | Track order. Each entry should appear in `songs`. | + +Order matters: the app stores the position of each track from the order of this array. + +A `videoId` in `videoIds` that has no matching entry in `songs` is **skipped**, and the app reports +how many were skipped at the end of the import. The remaining tracks are then re-numbered from 0 so +positions stay contiguous. Emitting dangling ids is therefore not fatal, but it is a converter bug. + +## Caps + +The web converter enforces these limits, and the app relies on them by parsing the whole file into +memory in one pass (no streaming decoder): + +- **10,000** entries in `songs` +- **500** entries in `playlists` + +A file beyond these caps is the converter's problem to reject, not the app's. + +## What the app fills in itself + +These are **not** in the file, and a converter must not try to supply them: + +- `likeStatus` — set to `""`; imported songs are not liked. +- `liked` — `false`. +- `isAvailable` — `true`. +- `category`, `resultType` — `null`; these only mean something for search results. +- `totalPlayTime` — `0`. +- `downloadState` — not downloaded. +- `inLibrary`, `favoriteAt`, `downloadedAt` — set to the moment of import. +- `canvasUrl`, `canvasThumbUrl` — left empty; fetched later from Spotify at playback time. +- Playlist `id` — assigned by the database. +- Playlist `youtubePlaylistId` / sync state — imported playlists are local-only and unsynced. +- Track positions inside a playlist — derived from the order of `videoIds`. + +## Write behaviour + +- A song whose `videoId` is already in the database is **not overwritten**; the existing row keeps + its play count, liked state and download state. The import only fills in an album name or artist + list that the stored row is missing or that an older parse got wrong. +- Playlists are always created fresh. Importing the same file twice creates a second copy of every + playlist; it does not merge into the first. diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index e6ea456e4..8918ccd51 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -1,13 +1,13 @@ #This file is generated by updateDaemonJvm -toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5ad03827a75ecec71a5e787edfc1bca4/redirect -toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/3c9b911fffca526315550d45861cdc3f/redirect -toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d1cdf34033d69f8d4f43c91ee68af29f/redirect -toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/edffc3a65db006275a86e12a20758477/redirect -toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/5ea5df3bae5cc1f1132a244de5c3feda/redirect -toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/fe06cac13d0fc1321b8e034d546e8a06/redirect +toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c02bd9659ddf98ccc4a6e505ef8f115a/redirect +toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/7a3a7d66b6f767017b5cd881cb98a25a/redirect +toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/3d1170c4a2befa97615d1af000be56d5/redirect +toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/ca83b5b0bec882d8b275143b559827cc/redirect +toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/94348e25b52626019fb834c17f3e4f7f/redirect +toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/b0969eea61055d4bb6628e83c68a2359/redirect toolchainVendor=MICROSOFT toolchainVersion=21 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" } diff --git a/lastfm-empty/build.gradle.kts b/lastfm-empty/build.gradle.kts new file mode 100644 index 000000000..6d1423e8d --- /dev/null +++ b/lastfm-empty/build.gradle.kts @@ -0,0 +1,44 @@ +import com.android.build.gradle.internal.tasks.CompileArtProfileTask + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) +} + +kotlin { + android { + namespace = "org.simpmusic.lastfm" + compileSdk = 37 + minSdk = 26 + } + val xcfName = "lastfmKit" + + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + // Logger only — no Ktor, no okio, no API key. A FOSS build carries no Last.fm code. + implementation(projects.common) + } + } + } +} + +tasks.withType { + enabled = false +} diff --git a/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt b/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt new file mode 100644 index 000000000..1e509f602 --- /dev/null +++ b/lastfm-empty/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt @@ -0,0 +1,80 @@ +package org.simpmusic.lastfm + +import com.maxrave.logger.Logger + +// NON-LASTFM build: direct scrobbling is not available in this build flavour, because a FOSS build +// ships no API secret. Every declaration mirrors the real module so callers never need to branch on +// build flavour — `isLastfmAvailable()` returning false is what hides the feature in the UI. + +data class LastfmSession( + val username: String, + val sessionKey: String, +) + +data class LastfmTrack( + val artist: String, + val track: String, + val album: String? = null, + val albumArtist: String? = null, + val durationSeconds: Int? = null, +) + +sealed interface LastfmOutcome { + data object Ok : LastfmOutcome + + data class Ignored( + val code: Int, + val message: String, + ) : LastfmOutcome + + data class Error( + val code: Int, + val message: String, + ) : LastfmOutcome { + val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION + + val retryable: Boolean get() = code == 11 || code == 16 || code == 29 + } + + companion object { + const val ERROR_INVALID_SESSION = 9 + } +} + +private const val TAG = "Lastfm" + +private const val UNAVAILABLE = "NON-LASTFM build: Last.fm is not available" + +fun configLastfm( + key: String, + secret: String, +) { + Logger.d(TAG, UNAVAILABLE) +} + +fun isLastfmAvailable(): Boolean = false + +fun authorizeUrl(): String? = null + +suspend fun completeLogin(token: String): LastfmSession? { + Logger.d(TAG, UNAVAILABLE) + return null +} + +suspend fun updateNowPlaying( + sessionKey: String, + track: LastfmTrack, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) + +suspend fun scrobble( + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) + +suspend fun setLoved( + sessionKey: String, + artist: String, + track: String, + loved: Boolean, +): LastfmOutcome = LastfmOutcome.Error(0, UNAVAILABLE) diff --git a/lastfm/build.gradle.kts b/lastfm/build.gradle.kts new file mode 100644 index 000000000..ae31647a3 --- /dev/null +++ b/lastfm/build.gradle.kts @@ -0,0 +1,52 @@ +import com.android.build.gradle.internal.tasks.CompileArtProfileTask + +plugins { + alias(libs.plugins.kotlin.multiplatform) + alias(libs.plugins.android.kotlin.multiplatform.library) + alias(libs.plugins.android.lint) + alias(libs.plugins.kotlin.serialization) +} + +kotlin { + android { + namespace = "org.simpmusic.lastfm" + compileSdk = 37 + minSdk = 26 + } + val xcfName = "lastfmKit" + + iosArm64 { + binaries.framework { + baseName = xcfName + } + } + + iosSimulatorArm64 { + binaries.framework { + baseName = xcfName + } + } + + jvm() + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + implementation(projects.common) + implementation(projects.ktorExt) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.kotlinx.serialization.json) + // MD5 for api_sig. okio hashes on every target this module builds for, + // so no expect/actual is needed just to sign a request. + implementation(libs.okio) + } + } + } +} + +tasks.withType { + enabled = false +} diff --git a/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt new file mode 100644 index 000000000..08115927e --- /dev/null +++ b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/Lastfm.kt @@ -0,0 +1,147 @@ +package org.simpmusic.lastfm + +import com.maxrave.logger.Logger + +// LAST.FM build: the real scrobbler. `lastfm-empty` mirrors every declaration in this file as a +// no-op, so callers never branch on the build flavour — see `cast` / `cast-empty` for the same +// arrangement. + +/** A logged-in Last.fm account. The session key has no expiry; the user revokes it from last.fm. */ +data class LastfmSession( + val username: String, + val sessionKey: String, +) + +/** What SimpMusic knows about a track, in the shape Last.fm's parameters expect. */ +data class LastfmTrack( + val artist: String, + val track: String, + val album: String? = null, + val albumArtist: String? = null, + val durationSeconds: Int? = null, +) + +/** + * The outcome of a write call. + * + * [Ignored] is the case worth spelling out: Last.fm answers `status="ok"` even when it threw the + * scrobble away, and only says so in `ignoredMessage`. Treating a 200 as success would hide bad + * metadata forever — codes 1 and 2 mean the artist or track name failed Last.fm's filter, which is + * exactly what a polluted artist like "13M plays" produces. + */ +sealed interface LastfmOutcome { + data object Ok : LastfmOutcome + + data class Ignored( + val code: Int, + val message: String, + ) : LastfmOutcome + + data class Error( + val code: Int, + val message: String, + ) : LastfmOutcome { + /** Code 9 means the stored session key is dead — the user has to log in again. */ + val needsReauth: Boolean get() = code == ERROR_INVALID_SESSION + + /** 11 offline, 16 temporary, 29 rate limited. Every other code is a malformed request. */ + val retryable: Boolean get() = code == 11 || code == 16 || code == 29 + } + + companion object { + const val ERROR_INVALID_SESSION = 9 + } +} + +private const val TAG = "Lastfm" + +private var apiKey: String = "" +private var sharedSecret: String = "" + +/** + * Hands the module its credentials. + * + * They arrive from the caller rather than living here because BuildKonfig generates into + * `composeApp`, which depends on this module and not the other way round — the same reason + * `configCrashlytics` takes the Sentry DSN as a parameter. + */ +fun configLastfm( + key: String, + secret: String, +) { + apiKey = key + sharedSecret = secret + Logger.d(TAG, "Last.fm configured: ${if (isLastfmAvailable()) "credentials present" else "no credentials"}") +} + +/** + * Whether this build can talk to Last.fm at all. + * + * False in a FOSS build, where the empty module answers for this function, and also false in a full + * build whose `local.properties` carries no key — so the settings entry hides itself rather than + * offering a login that could never succeed. + */ +fun isLastfmAvailable(): Boolean = apiKey.isNotEmpty() && sharedSecret.isNotEmpty() + +/** + * Step one: where to send the user so they can grant access. + * + * This is Last.fm's **web** flow, and the missing `token` parameter is the whole point of it. + * Last.fm generates the token itself and redirects to the callback registered on the API account + * with `?token=` appended. The desktop flow — call `auth.getToken` first, then open this URL with + * `&token=` already on it — tells Last.fm the app is holding the token, so it renders a "return to + * the application" page and never calls the callback at all. + * + * No `cb` parameter is passed either: the callback on the API account is the one to use. + */ +fun authorizeUrl(): String? { + if (!isLastfmAvailable()) return null + return "https://www.last.fm/api/auth/?api_key=$apiKey" +} + +/** + * Step two: trade the token that came back on the callback for a session key. + * + * A token is user- and account-specific, lives 60 minutes, and is consumed by this call — so a + * failure here means sending the user through [authorizeUrl] again, not retrying the same token. + */ +suspend fun completeLogin(token: String): LastfmSession? { + if (!isLastfmAvailable()) return null + return LastfmClient.getSession(apiKey, sharedSecret, token) +} + +/** Tells Last.fm what is playing right now. Does not affect charts, and is not worth retrying. */ +suspend fun updateNowPlaying( + sessionKey: String, + track: LastfmTrack, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.updateNowPlaying(apiKey, sharedSecret, sessionKey, track) +} + +/** + * Records a play. + * + * [startedAtEpochSeconds] is when playback STARTED. Last.fm's own docs disagree with themselves + * here — the method page says the track started, the scrobbling guide says it finished — and every + * scrobbler in the wild sends the start time, so that is what this takes. + */ +suspend fun scrobble( + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.scrobble(apiKey, sharedSecret, sessionKey, track, startedAtEpochSeconds) +} + +/** Mirrors a like onto Last.fm's loved tracks. */ +suspend fun setLoved( + sessionKey: String, + artist: String, + track: String, + loved: Boolean, +): LastfmOutcome { + if (!isLastfmAvailable()) return LastfmOutcome.Error(0, "Last.fm is not configured") + return LastfmClient.setLoved(apiKey, sharedSecret, sessionKey, artist, track, loved) +} diff --git a/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt new file mode 100644 index 000000000..f5f9eddf4 --- /dev/null +++ b/lastfm/src/commonMain/kotlin/org/simpmusic/lastfm/LastfmClient.kt @@ -0,0 +1,222 @@ +package org.simpmusic.lastfm + +import com.maxrave.ktorext.getEngine +import com.maxrave.logger.Logger +import io.ktor.client.HttpClient +import io.ktor.client.plugins.HttpTimeout +import io.ktor.client.request.forms.submitForm +import io.ktor.client.statement.bodyAsText +import io.ktor.http.Parameters +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import okio.ByteString.Companion.encodeUtf8 + +private const val TAG = "LastfmClient" +private const val ENDPOINT = "https://ws.audioscrobbler.com/2.0/" + +/** + * Every call Last.fm needs, signed and parsed. + * + * Responses are read as loose [JsonObject]s instead of `@Serializable` classes on purpose. Last.fm's + * JSON is a mechanical translation of its XML: numbers arrive as strings, attributes hide under + * `@attr`, and a field is an object with one element but an array with several. A schema that + * mirrors all of that breaks the moment a response has two entries instead of one, and there is + * nothing here worth reading past a handful of fields. + */ +internal object LastfmClient { + private val json = Json { ignoreUnknownKeys = true } + + private val client = + HttpClient(getEngine()) { + install(HttpTimeout) { + connectTimeoutMillis = 15_000 + requestTimeoutMillis = 15_000 + socketTimeoutMillis = 15_000 + } + } + + /** + * Builds `api_sig`. + * + * Parameters are ordered by name, concatenated as ``, the shared secret is appended + * and the whole thing is MD5'd. `format` is excluded because the spec says so — signing it is + * the classic reason every request comes back "Invalid method signature supplied" (code 13). + */ + private fun sign( + params: Map, + secret: String, + ): String = + params + .filterKeys { it != "format" && it != "callback" } + .entries + // sortedBy, not toSortedMap: the latter is a JDK collection and does not exist in + // common Kotlin. Parameter names are ASCII, so String ordering is the alphabetical + // ordering the spec asks for. + .sortedBy { it.key } + .joinToString(separator = "") { "${it.key}${it.value}" } + .plus(secret) + .encodeUtf8() + .md5() + .hex() + + /** + * Signs [params], posts them as a form and hands back the parsed body. + * + * Everything goes over POST, including the two auth calls: the per-method pages call them GET + * but the authentication spec calls them POST, and POST is accepted for both. + */ + private suspend fun call( + params: Map, + secret: String, + ): JsonObject? = + runCatching { + val signed = params + ("api_sig" to sign(params, secret)) + ("format" to "json") + val body = + client + .submitForm( + url = ENDPOINT, + formParameters = + Parameters.build { + signed.forEach { (key, value) -> append(key, value) } + }, + ).bodyAsText() + json.parseToJsonElement(body).jsonObject + }.onFailure { + Logger.e(TAG, "Request failed: ${it.message}") + }.getOrNull() + + /** Reads a field that Last.fm may send as either a JSON string or a JSON number. */ + private fun JsonObject.intOrNull(key: String): Int? = this[key]?.jsonPrimitive?.content?.toIntOrNull() + + private fun JsonObject.stringOrNull(key: String): String? = this[key]?.jsonPrimitive?.content + + /** Turns an `{"error": N, "message": "..."}` body into an outcome, or null when the call worked. */ + private fun JsonObject.asErrorOrNull(): LastfmOutcome.Error? { + val code = intOrNull("error") ?: return null + return LastfmOutcome.Error(code, stringOrNull("message") ?: "Unknown Last.fm error") + } + + /** + * Reads the `ignoredMessage` that rides along with an otherwise successful write. + * + * Code 0 means nothing was ignored. 1 and 2 mean the artist or track name failed Last.fm's + * filter, 3 and 4 mean the timestamp was too far in the past or future, 5 means the daily + * scrobble limit is spent. + */ + private fun JsonObject.ignoredOrNull(): LastfmOutcome.Ignored? { + val ignored = this["ignoredMessage"]?.jsonObject ?: return null + val code = ignored.intOrNull("code") ?: return null + if (code == 0) return null + return LastfmOutcome.Ignored(code, ignored.stringOrNull("#text").orEmpty()) + } + + suspend fun getSession( + apiKey: String, + secret: String, + token: String, + ): LastfmSession? { + val response = + call( + mapOf("method" to "auth.getSession", "api_key" to apiKey, "token" to token), + secret, + ) ?: return null + response.asErrorOrNull()?.let { + Logger.e(TAG, "auth.getSession failed: ${it.code} ${it.message}") + return null + } + val session = response["session"]?.jsonObject ?: return null + val name = session.stringOrNull("name") ?: return null + val key = session.stringOrNull("key") ?: return null + return LastfmSession(username = name, sessionKey = key) + } + + suspend fun updateNowPlaying( + apiKey: String, + secret: String, + sessionKey: String, + track: LastfmTrack, + ): LastfmOutcome { + val params = + buildMap { + put("method", "track.updateNowPlaying") + put("api_key", apiKey) + put("sk", sessionKey) + put("artist", track.artist) + put("track", track.track) + track.album?.takeIf { it.isNotBlank() }?.let { put("album", it) } + track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist", it) } + track.durationSeconds?.takeIf { it > 0 }?.let { put("duration", it.toString()) } + } + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + response.asErrorOrNull()?.let { return it } + val nowPlaying = response["nowplaying"]?.jsonObject ?: return LastfmOutcome.Ok + return nowPlaying.ignoredOrNull() ?: LastfmOutcome.Ok + } + + /** + * Sends one play. + * + * The parameters are indexed (`artist[0]`) even though a single scrobble may be sent with plain + * names. Indexing one entry is the same shape a batch uses, so growing this into an offline + * queue later — Last.fm takes up to 50 per request — costs no rewrite. + * + * `duration` is always sent: the method page lists it as optional but the scrobbling guide lists + * it as required, and sending it satisfies both. + */ + suspend fun scrobble( + apiKey: String, + secret: String, + sessionKey: String, + track: LastfmTrack, + startedAtEpochSeconds: Long, + ): LastfmOutcome { + val params = + buildMap { + put("method", "track.scrobble") + put("api_key", apiKey) + put("sk", sessionKey) + put("artist[0]", track.artist) + put("track[0]", track.track) + put("timestamp[0]", startedAtEpochSeconds.toString()) + track.album?.takeIf { it.isNotBlank() }?.let { put("album[0]", it) } + track.albumArtist?.takeIf { it.isNotBlank() }?.let { put("albumArtist[0]", it) } + track.durationSeconds?.takeIf { it > 0 }?.let { put("duration[0]", it.toString()) } + put("chosenByUser[0]", "1") + } + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + response.asErrorOrNull()?.let { return it } + + val scrobbles = response["scrobbles"]?.jsonObject ?: return LastfmOutcome.Ok + // One scrobble comes back as an object, several as an array — read whichever arrived. + val first = + when (val entry = scrobbles["scrobble"]) { + is JsonArray -> entry.firstOrNull()?.jsonObject + is JsonObject -> entry + else -> null + } + return first?.ignoredOrNull() ?: LastfmOutcome.Ok + } + + suspend fun setLoved( + apiKey: String, + secret: String, + sessionKey: String, + artist: String, + track: String, + loved: Boolean, + ): LastfmOutcome { + val params = + mapOf( + "method" to if (loved) "track.love" else "track.unlove", + "api_key" to apiKey, + "sk" to sessionKey, + "artist" to artist, + "track" to track, + ) + val response = call(params, secret) ?: return LastfmOutcome.Error(-1, "Network error") + return response.asErrorOrNull() ?: LastfmOutcome.Ok + } +} 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 diff --git a/settings.gradle.kts b/settings.gradle.kts index b49aca15a..20eb71f0b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,6 +60,8 @@ include( ":crashlytics-empty", ":cast", ":cast-empty", + ":lastfm", + ":lastfm-empty", ":kizzy", )