diff --git a/CHANGELOG.md b/CHANGELOG.md index 5bfa5a20..3c57ce82 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -290,8 +290,8 @@ - **desktop**: double titlebar in local library folders and massive space in overlay player - lastfm form broken in other locales #2447 -- spotify login broken due to new totp requirement #2494 -- spotify authentication 429 errors +- Greedy Green Corp login broken due to new totp requirement #2494 +- Greedy Green Corp authentication 429 errors ### Features @@ -326,7 +326,7 @@ - **android**: home widget support (#2148) - rewrite entire app in shadcn-ui replacing material eww - grid/list customizable playbutton view -- flag to hide spotify generated images with patterns +- flag to hide Greedy Green Corp generated images with patterns - show placeholder images where there is no item or on empty page - pause playback when no internet connection - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) @@ -552,7 +552,7 @@ [#1463](https://github.com/team-spotube/spotube/issues/1463) [#1505](https://github.com/team-spotube/spotube/issues/1505) ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) -- spotify friends and user profile icon (mobile) showing when not authenticated +- Greedy Green Corp friends and user profile icon (mobile) showing when not authenticated [#1410](https://github.com/team-spotube/spotube/issues/1410) ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) - **updater:** dead link @@ -576,7 +576,7 @@ ### Features -- add Spotify homepage personalized recommendations +- add Greedy Green Corp homepage personalized recommendations ([#1402](https://github.com/team-spotube/spotube/issues/1402)) ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) - add user profile page @@ -684,7 +684,7 @@ - add create playlist button in add playlist dialog ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) -- add spotify friends activity +- add Greedy Green Corp friends activity ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) - **deep-link:** add track opening page @@ -1436,7 +1436,7 @@ ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) - **macos:** crashing on startup ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) -- spotify query hooks overriding default query params +- Greedy Green Corp query hooks overriding default query params ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) @@ -1469,7 +1469,7 @@ ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) - individual shuffle and repeat/loop button of player ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) -- **lyrics:** use official spotify API for fetching lyrics and add zoom controls +- **lyrics:** use official Greedy Green Corp API for fetching lyrics and add zoom controls ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) - **mobile:** pull to refresh support in all refreshable list views ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) @@ -1921,7 +1921,7 @@ - private playlists of current user aren't shown fix (https://github.com/team-spotube/spotube/issues/92) -- refresh token error causing re-login (culprit: internal lib spotify-dart) +- refresh token error causing re-login (culprit: internal lib Greedy Green Corp-dart) - Typo in Login instructions URL # v2.1.0 @@ -1937,7 +1937,7 @@ ### Improved -- Spotify track to youtube track algorithm +- Greedy Green Corp track to youtube track algorithm - Genius lyrics matching algorithm - Download track. Checks if already exists & replaces on user command - Wide screen responsiveness & adaptation @@ -1950,7 +1950,7 @@ (https://github.com/team-spotube/spotube/issues/46) - Theme modification state doesn't persist (https://github.com/team-spotube/spotube/issues/54) -- Wrong URI path for "Login with Spotify" tutorial +- Wrong URI path for "Login with Greedy Green Corp" tutorial (https://github.com/team-spotube/spotube/issues/69) - Card shadow showing in the background of TitleBar & Searchbar @@ -2124,17 +2124,17 @@ ### Bug Fixes -- `access_token not found` Error after OAuth Login with Spotify credentials +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials (used to need a restart of the app to load the access_token) -- `access_token not found` Error after OAuth Login with Spotify credentials +- `access_token not found` Error after OAuth Login with Greedy Green Corp credentials (used to need a restart of the app to load the access_token) - Volume level wasn't cached even after changing volume # v0.0.1 -Spotube v0.0.1 - initial release of the open source software for playing Spotify +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp music using Youtube public API -Spotube v0.0.1 - initial release of the open source software for playing Spotify +Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp music using Youtube public API ### New @@ -2143,7 +2143,7 @@ music using Youtube public API - Playback Queue - Save to Liked Tracks/Playlists - Bypass API rate limitation on basic usage using personal developer Apps for - spotify API + Greedy Green Corp API - Bypass API rate limitation on basic usage using personal developer Apps for - spotify API + Greedy Green Corp API - Youtube search & get handled using scrape-yt diff --git a/README.md b/README.md index 2cc7453b..c65c9e2a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,8 @@ Spotube Logo A cross-platform extensible open-source music streaming platform.
-Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! +Bring your own music metadata/playlist/audio-source with plugins created by community or by +yourself. A small step towards the decentralized music streaming era! Visit the website Discord Server @@ -16,17 +17,16 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm --- -![Spotube Desktop](assets/branding/spotube-screenshot.png) +![Spotube Desktop](assets/feature-graphics/readme-hero.png) +![Spotube Mobile](assets/feature-graphics/readme-mobile.png) -![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg) - - +**** ## 🌃 Features - 🧩 Plugin powered, supports any platform or custom music service through plugins. - 🗺️ Community driven plugins for popular platforms or create your own. -- ⬇️ Freely downloadable tracks with tagged metadata. +- ⬇️ Freely downloadable tracks with tagged **metadata**. - 🖥️ 📱 Cross-platform support. - 🪶 Small size & less data usage. - 🕒 Time synced lyrics regardless of the plugin support. @@ -41,132 +41,183 @@ Download Spotube from the Official Website: https://spotube.cc ### 🔄 Nightly Builds -Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). - -## 🕳️ Building from source +Grab the latest nightly builds of +Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). +
+ +

🕳️ Building from source [Click to expand]

+
+
GitHub Workflow Status -Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app is built with the [Nucleus](https://nucleusframework.dev) Gradle plugin and released as a **GraalVM native image**: an ahead-of-time compiled binary that needs no JVM installed. +Spotube is a Compose Multiplatform app. -### Prerequisites +### 1. Prerequisites -- **JDK 21** to run Gradle. The Gradle daemon JDK and the GraalVM toolchain are provisioned automatically. -- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). +- **JDK 21** to run Gradle (the Gradle daemon JDK and GraalVM are provisioned automatically). +- **Rust** (`rustup` + `cargo`), for the `composeApp` library built + through [Gobley](https://gobley.dev). - **Android SDK**, only for Android builds. -- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: - - [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) -- **Linux runtime libraries** (needed to run the app, including `:composeApp:run`). The `.deb`/`.rpm` install these as dependencies from your distro. For a source build, install them yourself, **including VLC**. Only the `.AppImage` is self-contained (it bundles VLC, GTK 3 and WebKitGTK, and uses just the host's glibc, graphics, X11 and sound libraries): +- **[ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview)**, which isn't on + Maven Central yet. Clone it at the commit pinned as `COMPOSE_WEBVIEW_COMMIT` in [ + `spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) and run + `./gradlew publishToMavenLocal` in it. +- **Linux only: runtime libraries, including VLC.** Needed to run the app, including + `:composeApp:run`: - | Distro | Command | - |---|---| - | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | - | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. | - | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | - | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) | + | Distro | Command | + | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | + | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender` (`vlc-libs` needs [RPM Fusion](https://rpmfusion.org/Configuration)) | + | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | + | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` | - Arch-based distros and openSUSE have no native package here. Use the `.AppImage`. The `.rpm` is built with Fedora package names, so it won't resolve on openSUSE. + On Ubuntu 24.04+/Debian 13, `libgtk-3-0` and `libasound2` are `libgtk-3-0t64` and `libasound2t64`; + apt resolves this itself. - On Ubuntu 24.04+/Debian 13 `libgtk-3-0` and `libasound2` are named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically. - -Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). - -### Development +### 2. Run in development +Desktop (JVM), fastest edit/run loop ```bash -./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop -./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug) +./gradlew :composeApp:run ``` -Development builds stay fast. The release-only optimizations (the Rust release profile with LTO and symbol stripping, the native image, native-library pruning) are only applied when you build something to ship. See [Production vs development builds](#production-vs-development-builds). +Android debug APK +```bash +./gradlew :composeApp:assembleStableDebug +``` +> Use `./gradlew :composeApp:assembleNightlyDebug` for the nightly channel. -### Building the desktop app (GraalVM native image) +Development builds stay fast: the release-only optimizations below are skipped. -#### Native toolchain +### 3. Build the desktop app -[Nucleus downloads and caches GraalVM CE](https://nucleusframework.dev/en/docs/performance/graalvm/) under `~/.gradle/nucleus/graalvm` on the first native build. Set `GRAALVM_HOME` to use your own GraalVM instead. Each OS also needs a native C toolchain: +A native build takes **about 10 minutes**, uses **every CPU core** and peaks at **~15 GB of RAM**. +It only happens for the tasks below, never for `run`. -| OS | Requirements | -|---|---| -| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) | -| macOS | Xcode Command Line Tools (`xcode-select --install`) | -| Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | +**Native toolchain** (Nucleus downloads GraalVM CE on the first build; set `GRAALVM_HOME` to use +your own): -A native build is a whole-program compile. It takes **about 10 minutes**, uses **every CPU core**, and peaks at roughly **15 GB of RAM** (it gets slower, not broken, with less). That only happens for the native tasks below, never for `run`. +| OS | Requirements | +| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Linux | `gcc`, `zlib` headers, `patchelf`; `rpm` for `.rpm`, `fuse` + `zstd` for `.AppImage`.
Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`.
Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` | +| macOS | Xcode Command Line Tools (`xcode-select --install`) | +| Windows | Visual Studio Build Tools with "Desktop development with C++" (use an "x64 Native Tools" prompt if `native-image` can't find MSVC) | -#### Build and run +**Build and run the app folder:** ```bash -# App folder: binary + its native libs, in composeApp/build/compose/binaries/main/graalvm-app/ ./gradlew :composeApp:createGraalvmNativeDistributable +``` +> Output folder: composeApp/build/compose/binaries/main/graalvm-app/ -# Run that folder's binary directly -./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube - -# Or build and launch in one step +Build and launch in one step +```bash ./gradlew :composeApp:runGraalvmNativeDistributable ``` -Keep the `spotube` binary next to the `.so`/`.dll`/`lib/` files it was built with; it loads them from its own folder. +Keep the `spotube` binary next to the libraries it was built with; it loads them from its own +folder. -#### Installers +**Installers** (each OS builds only its own): -Each OS can only build its own installers: +| OS | Command | +| ---------------- | ----------------------------------------------------------------------- | +| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` | +| Linux (AppImage) | `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | +| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | +| macOS | `./gradlew :composeApp:packageGraalvmDmg` | +> Output folder: `composeApp/build/compose/binaries/main/graalvm-` -| OS | Command | Output | -|---|---|---| -| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` | -| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` | -| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | +> [!Tip] +> Add `-Pnightly=true` for the nightly channel (separate icons and name). -Add `-Pnightly=true` for the nightly channel (separate icons and name). +
+How the Linux AppImage bundle works -The `.deb` and `.rpm` use your distro's shared libraries and declare them as dependencies, so the package manager installs them (on Fedora, `vlc-libs` needs RPM Fusion enabled). The `.AppImage` is the self-contained one: it bundles VLC, GTK 3 and WebKitGTK (see [What the AppImage bundles](#what-the-appimage-bundles)). +- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) + downloads the Ubuntu 24.04 packages (audio-only VLC via + [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin), GTK 3, WebKitGTK 4.1, + glib-networking), keeps only what GTK/WebKit reach and relocates the libraries with `patchelf`. + Needs `ar`, `tar`, `zstd`, `readelf`, `patchelf`. +- WebKitGTK has its helper-process path compiled in, so + [`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt) + patches it to a relative path and moves to that directory at startup. It also sets + `GIO_EXTRA_MODULES` and `WEBKIT_DISABLE_DMABUF_RENDERER=1` (the bundled WebKitGTK 2.44 never + paints against a newer host Mesa). +- `main()` must call `GraalVmInitializer.initialize()` first: the VLC loader and the environment + setup read `compose.application.resources.dir`. -#### What the AppImage bundles +
-Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do. +### 4. Native build breaks, JVM works -The bundle follows the [AppImage exclude list](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist) and what Tauri apps ship. It carries VLC (audio-only, from the [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) plugin's `bundleVlc` option), GTK 3, WebKitGTK 4.1, GLib/GIO, pango/cairo and their dependencies, plus glib-networking for HTTPS in the webview. It leaves the host's glibc, `libstdc++`, `libz`, GL/EGL/DRM, X11/xcb, ALSA, fontconfig/freetype/harfbuzz and the Wayland libraries alone, so the host's graphics, display and sound stack is used. Everything is taken from the Ubuntu 24.04 archive, so the AppImage needs glibc 2.38 or newer. +A native image is a closed world: everything reached through reflection, JNI or JNA (vlcj, Zipline, +NewPipe, ktor, datastore, ...) must be declared at build time. Nucleus generates most of it; the +rest lives in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). -How it works, in short: -- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`. -- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either. -- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`. +Symptoms: `ClassNotFoundException`, `NoSuchMethodException` or `MissingReflectionRegistrationError` +in the native binary, while `:composeApp:run` works. -#### Reflection metadata (when a feature breaks only in the native build) +1. `./gradlew :composeApp:runWithNativeAgent` starts the app on the JVM with GraalVM's tracing agent. +2. Use the broken feature, then **close the window normally**. Ctrl+C discards the recording. +3. Commit the updated JSON in `composeApp/graalvm/` and rebuild. -A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). +> [!Tip] +> `./gradlew :composeApp:runGraalvmNative` is a faster debug loop: a quick `-Ob` build that names the +> missing registration. -If something works in `:composeApp:run` but throws `ClassNotFoundException`, `NoSuchMethodException`, `MissingReflectionRegistrationError` or similar in the native binary, record what's missing: +> [!Warning] +> Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry, or playback fails to initialize. -```bash -./gradlew :composeApp:runWithNativeAgent -``` +### 5. Production vs development builds -The app starts on the JVM with GraalVM's tracing agent attached. Use the broken feature (and anything near it), then **close the window normally**: the recorded entries are merged into `composeApp/graalvm/` only on a clean exit, not with Ctrl+C. Commit the updated JSON and rebuild the native image. +Production mode turns on automatically for `package*`, `create*`, `notarize*` and +`nativeImageCompile` tasks. Force it with `-Pproduction=true|false`. -`./gradlew :composeApp:runGraalvmNative` gives a faster debugging loop: a quick-build (`-Ob`) native image that reports exactly which registration is missing. +| Area | Development | Production | +| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Rust lib | fast `debug` profile | `release` with LTO and stripping (31 MB → 1.8 MB) | +| Desktop app | hot-reloadable JVM app | size-optimized (`-Os`) GraalVM native image | +| Native libs | all | host OS/CPU only; Skia loaded from `lib/`; on Linux ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB one | -Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry. JNA's direct mapping looks up its `native` methods through reflection, and without the entry, playback fails to initialize. +Android picks the Rust profile from its build type. -### Production vs development builds +### 6. Releases -Production mode is switched on automatically when you ask Gradle for a shippable artifact: any `package*`, `create*`, `notarize*` or `nativeImageCompile` task. You can force it either way with `-Pproduction=true` or `-Pproduction=false`. +Run [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) manually +(`workflow_dispatch`) as `stable`, `beta` or `nightly`. -| | Development (`run`, debug builds) | Production (installers, native image) | -|---|---|---| -| Rust library (desktop) | `debug` profile, fast incremental compile | `release` profile with LTO, single codegen unit, stripped (31 MB → 1.8 MB) | -| Desktop app | JVM, hot-reloadable | GraalVM native image, `-Os` (size-optimized) | -| Native libraries | everything on the classpath | only the host OS/CPU's natives; other OSes' and CPUs' libraries are pruned from the uber JAR the image is compiled from | -| Skia (Skiko) | extracted from the JAR | loaded from `lib/` next to the binary, not embedded a second time | -| JDK runtime libs (Linux) | n/a | native-image's ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's (28 MB `libjvm.so`) | +- **Beta:** the stable build as a draft, tagged `v-beta.N`. Set `betaNumber` in + `composeApp/build.gradle.kts` to N, and back to `null` for stable. +- **Desktop:** each platform (Linux x86_64 and aarch64, Windows, macOS) builds its installers on its + own runner. +- **Android:** a signed APK. -Android picks the Rust profile from its build type (`debug` or `release`) on its own. +
+
-### Releases +## 📚 Resources -Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable`, `beta` or `nightly`. A beta is the stable build released like a stable release (draft, not a pre-release), tagged `v-beta.N` (set `betaNumber` in `composeApp/build.gradle.kts` to N, and back to `null` for stable). Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. +**Core stack** +- [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [Compose Hot Reload](https://github.com/JetBrains/compose-hot-reload) +- [Koin for KMP](https://insert-koin.io/docs/reference/koin-mp/kmp) +- [Ktor client](https://ktor.io/docs/client-create-multiplatform-application.html) +- [DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore) + +**Desktop and native** +- [Nucleus](https://github.com/kdroidFilter/Nucleus): desktop packaging and GraalVM integration +- [ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview) +- [GraalVM Native Image](https://www.graalvm.org/latest/reference-manual/native-image/) and its + [reachability metadata](https://www.graalvm.org/latest/reference-manual/native-image/metadata/) +- [Gobley](https://gobley.dev): Rust in Kotlin Multiplatform +- [vlcj](https://github.com/caprica/vlcj): VLC bindings for audio playback + +**Plugins** +- [Zipline](https://github.com/cashapp/zipline): runs the JS plugins ## 👥 The Spotube team diff --git a/assets/branding/mobile-screenshots/android-1.jpg b/assets/branding/mobile-screenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/assets/branding/mobile-screenshots/android-1.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-2.jpg b/assets/branding/mobile-screenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/assets/branding/mobile-screenshots/android-2.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-3.jpg b/assets/branding/mobile-screenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/assets/branding/mobile-screenshots/android-3.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-4.jpg b/assets/branding/mobile-screenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/assets/branding/mobile-screenshots/android-4.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/android-5.jpg b/assets/branding/mobile-screenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/assets/branding/mobile-screenshots/android-5.jpg and /dev/null differ diff --git a/assets/branding/mobile-screenshots/combined.jpg b/assets/branding/mobile-screenshots/combined.jpg deleted file mode 100644 index 03740580..00000000 Binary files a/assets/branding/mobile-screenshots/combined.jpg and /dev/null differ diff --git a/assets/branding/spotube-hero-banner.png b/assets/branding/spotube-hero-banner.png index c5309b92..d0975f88 100644 Binary files a/assets/branding/spotube-hero-banner.png and b/assets/branding/spotube-hero-banner.png differ diff --git a/assets/branding/spotube-screenshot.png b/assets/branding/spotube-screenshot.png deleted file mode 100644 index c708e240..00000000 Binary files a/assets/branding/spotube-screenshot.png and /dev/null differ diff --git a/assets/feature-graphics/play-feature-graphic.png b/assets/feature-graphics/play-feature-graphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/assets/feature-graphics/play-feature-graphic.png differ diff --git a/assets/feature-graphics/readme-hero.png b/assets/feature-graphics/readme-hero.png new file mode 100644 index 00000000..c7807f01 Binary files /dev/null and b/assets/feature-graphics/readme-hero.png differ diff --git a/assets/feature-graphics/readme-mobile.png b/assets/feature-graphics/readme-mobile.png new file mode 100644 index 00000000..a4275e32 Binary files /dev/null and b/assets/feature-graphics/readme-mobile.png differ diff --git a/assets/feature-graphics/store-desktop-01-home.png b/assets/feature-graphics/store-desktop-01-home.png new file mode 100644 index 00000000..edc82da0 Binary files /dev/null and b/assets/feature-graphics/store-desktop-01-home.png differ diff --git a/assets/feature-graphics/store-desktop-02-lyrics.png b/assets/feature-graphics/store-desktop-02-lyrics.png new file mode 100644 index 00000000..ddce019b Binary files /dev/null and b/assets/feature-graphics/store-desktop-02-lyrics.png differ diff --git a/assets/feature-graphics/store-desktop-03-playlists.png b/assets/feature-graphics/store-desktop-03-playlists.png new file mode 100644 index 00000000..53c8d403 Binary files /dev/null and b/assets/feature-graphics/store-desktop-03-playlists.png differ diff --git a/assets/feature-graphics/store-desktop-04-search.png b/assets/feature-graphics/store-desktop-04-search.png new file mode 100644 index 00000000..bed9cf37 Binary files /dev/null and b/assets/feature-graphics/store-desktop-04-search.png differ diff --git a/assets/feature-graphics/store-desktop-05-local-tracks.png b/assets/feature-graphics/store-desktop-05-local-tracks.png new file mode 100644 index 00000000..648a515e Binary files /dev/null and b/assets/feature-graphics/store-desktop-05-local-tracks.png differ diff --git a/assets/feature-graphics/store-desktop-06-group-jam.png b/assets/feature-graphics/store-desktop-06-group-jam.png new file mode 100644 index 00000000..1178633c Binary files /dev/null and b/assets/feature-graphics/store-desktop-06-group-jam.png differ diff --git a/assets/feature-graphics/store-mobile-01-home.png b/assets/feature-graphics/store-mobile-01-home.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/assets/feature-graphics/store-mobile-01-home.png differ diff --git a/assets/feature-graphics/store-mobile-02-player.png b/assets/feature-graphics/store-mobile-02-player.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/assets/feature-graphics/store-mobile-02-player.png differ diff --git a/assets/feature-graphics/store-mobile-03-library.png b/assets/feature-graphics/store-mobile-03-library.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/assets/feature-graphics/store-mobile-03-library.png differ diff --git a/assets/feature-graphics/store-mobile-04-devices.png b/assets/feature-graphics/store-mobile-04-devices.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/assets/feature-graphics/store-mobile-04-devices.png differ diff --git a/assets/feature-graphics/store-mobile-05-group-jam.png b/assets/feature-graphics/store-mobile-05-group-jam.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/assets/feature-graphics/store-mobile-05-group-jam.png differ diff --git a/assets/screenshots/devices-desktop-dark.png b/assets/screenshots/devices-desktop-dark.png new file mode 100644 index 00000000..0b53b385 Binary files /dev/null and b/assets/screenshots/devices-desktop-dark.png differ diff --git a/assets/screenshots/devices-mobile-light.png b/assets/screenshots/devices-mobile-light.png new file mode 100644 index 00000000..8d27ee61 Binary files /dev/null and b/assets/screenshots/devices-mobile-light.png differ diff --git a/assets/screenshots/downloads-desktop-light.png b/assets/screenshots/downloads-desktop-light.png new file mode 100644 index 00000000..d96385e2 Binary files /dev/null and b/assets/screenshots/downloads-desktop-light.png differ diff --git a/assets/screenshots/group-jam-desktop-dark.png b/assets/screenshots/group-jam-desktop-dark.png new file mode 100644 index 00000000..14720aff Binary files /dev/null and b/assets/screenshots/group-jam-desktop-dark.png differ diff --git a/assets/screenshots/group-jam-mobile-light.png b/assets/screenshots/group-jam-mobile-light.png new file mode 100644 index 00000000..7e80e0fa Binary files /dev/null and b/assets/screenshots/group-jam-mobile-light.png differ diff --git a/assets/screenshots/home-desktop-dark.png b/assets/screenshots/home-desktop-dark.png new file mode 100644 index 00000000..4f3a8a0d Binary files /dev/null and b/assets/screenshots/home-desktop-dark.png differ diff --git a/assets/screenshots/home-desktop-light.png b/assets/screenshots/home-desktop-light.png new file mode 100644 index 00000000..253ac4da Binary files /dev/null and b/assets/screenshots/home-desktop-light.png differ diff --git a/assets/screenshots/home-mobile-dark.png b/assets/screenshots/home-mobile-dark.png new file mode 100644 index 00000000..91bbb132 Binary files /dev/null and b/assets/screenshots/home-mobile-dark.png differ diff --git a/assets/screenshots/home-mobile-light.png b/assets/screenshots/home-mobile-light.png new file mode 100644 index 00000000..4ee5e6c2 Binary files /dev/null and b/assets/screenshots/home-mobile-light.png differ diff --git a/assets/screenshots/home-player-dark.png b/assets/screenshots/home-player-dark.png new file mode 100644 index 00000000..651ec5bc Binary files /dev/null and b/assets/screenshots/home-player-dark.png differ diff --git a/assets/screenshots/home-player-light.png b/assets/screenshots/home-player-light.png new file mode 100644 index 00000000..2fcd6ae5 Binary files /dev/null and b/assets/screenshots/home-player-light.png differ diff --git a/assets/screenshots/library-mobile-light.png b/assets/screenshots/library-mobile-light.png new file mode 100644 index 00000000..0ae4ddcc Binary files /dev/null and b/assets/screenshots/library-mobile-light.png differ diff --git a/assets/screenshots/local-tracks-desktop-dark.png b/assets/screenshots/local-tracks-desktop-dark.png new file mode 100644 index 00000000..9dea91bf Binary files /dev/null and b/assets/screenshots/local-tracks-desktop-dark.png differ diff --git a/assets/screenshots/local-tracks-desktop-light.png b/assets/screenshots/local-tracks-desktop-light.png new file mode 100644 index 00000000..548dbb38 Binary files /dev/null and b/assets/screenshots/local-tracks-desktop-light.png differ diff --git a/assets/screenshots/lyrics-desktop-dark.png b/assets/screenshots/lyrics-desktop-dark.png new file mode 100644 index 00000000..c2518be4 Binary files /dev/null and b/assets/screenshots/lyrics-desktop-dark.png differ diff --git a/assets/screenshots/lyrics-desktop-light.png b/assets/screenshots/lyrics-desktop-light.png new file mode 100644 index 00000000..5d80ec6f Binary files /dev/null and b/assets/screenshots/lyrics-desktop-light.png differ diff --git a/assets/screenshots/playlists-desktop-dark.png b/assets/screenshots/playlists-desktop-dark.png new file mode 100644 index 00000000..13d1adff Binary files /dev/null and b/assets/screenshots/playlists-desktop-dark.png differ diff --git a/assets/screenshots/playlists-desktop-light.png b/assets/screenshots/playlists-desktop-light.png new file mode 100644 index 00000000..0fd2622b Binary files /dev/null and b/assets/screenshots/playlists-desktop-light.png differ diff --git a/assets/screenshots/search-desktop-dark.png b/assets/screenshots/search-desktop-dark.png new file mode 100644 index 00000000..c432b317 Binary files /dev/null and b/assets/screenshots/search-desktop-dark.png differ diff --git a/assets/screenshots/search-desktop-light.png b/assets/screenshots/search-desktop-light.png new file mode 100644 index 00000000..33a24fb6 Binary files /dev/null and b/assets/screenshots/search-desktop-light.png differ diff --git a/assets/screenshots/search-mobile-light.png b/assets/screenshots/search-mobile-light.png new file mode 100644 index 00000000..cae0e677 Binary files /dev/null and b/assets/screenshots/search-mobile-light.png differ diff --git a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt index fc0fa47e..5e3def2b 100644 --- a/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt +++ b/composeApp/src/androidJvmMain/kotlin/org/spotube/spotube/core/newpipe/NewPipeDownloader.kt @@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader companion object { private var instance: NewPipeDownloader? = null + /** + * Safe to call on every Activity creation: the process (and so this singleton) outlives + * the Activity, and a second [PersistentCookieJar] would open a second DataStore on the + * same cookies file, which DataStore rejects. + */ + @Synchronized fun init(paths: Paths) { + if (instance != null) return val cookieJar = PersistentCookieJar(paths) - instance = NewPipeDownloader(cookieJar) - NewPipe.init(instance) + instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) } } } } diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt index bc3fdaf1..74df39e7 100644 --- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt @@ -23,6 +23,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent import android.os.Build +import android.os.Process import android.util.Log import androidx.annotation.OptIn import androidx.core.app.NotificationCompat @@ -61,6 +62,8 @@ import spotube.composeapp.generated.resources.notification_playback_channel_name @OptIn(UnstableApi::class) class PlaybackService : MediaLibraryService(), KoinComponent { + private var taskRemoved = false + private val audioPlayer = inject().value as AudioPlayer private val audioPlayerQueue: AudioPlayerQueue by inject() @@ -161,9 +164,13 @@ class PlaybackService : MediaLibraryService(), KoinComponent { removeSession(librarySession) librarySession.release() super.onDestroy() + // Swiping the app out of recents should fully close it: Android otherwise keeps this + // process cached with the proxy server, MQTT client and every singleton still alive. + if (taskRemoved) Process.killProcess(Process.myPid()) } override fun onTaskRemoved(rootIntent: Intent?) { + taskRemoved = true pauseAllPlayersAndStopSelf() } diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml index 6671d6d9..9eb21d8c 100644 --- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml @@ -402,7 +402,7 @@ كيفية استخدام Spotube أربع خطوات سريعة وتبدأ الاستماع. تسجيل الدخول - اربط حسابك على Spotify، أو اختر إضافة الكتالوج الخاصة بك. + اربط حسابك، أو اختر إضافة الكتالوج الخاصة بك. ابحث وشغّل اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل. استمع بلا إنترنت diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index a3c26ed2..268cdf95 100644 --- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml @@ -386,7 +386,7 @@ Spotube যেভাবে ব্যবহার করবেন চারটি সহজ ধাপ, তারপরই গান শুনুন। সাইন ইন - আপনার Spotify অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। + আপনার অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন। খুঁজুন ও প্লে করুন যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন। অফলাইনে শুনুন diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 4a4115dd..2f965807 100644 --- a/composeApp/src/commonMain/composeResources/values-de/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -386,7 +386,7 @@ So nutzt du Spotube Vier schnelle Schritte und du hörst Musik. Anmelden - Verbinde dein Spotify-Konto oder wähle ein eigenes Katalog-Plugin. + Verbinde dein Konto oder wähle ein eigenes Katalog-Plugin. Suchen und abspielen Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen. Offline hören diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index 48c78bf3..b4ae5fa1 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -390,7 +390,7 @@ Cómo usar Spotube Cuatro pasos rápidos y a escuchar. Inicia sesión - Conecta tu cuenta de Spotify o elige tu propio plugin de catálogo. + Conecta tu cuenta o elige tu propio plugin de catálogo. Busca y reproduce Encuentra cualquier canción, álbum o artista y dale al play. Escucha sin conexión diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml index 8a5659ed..51374e7e 100644 --- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml @@ -386,7 +386,7 @@ نحوهٔ استفاده از Spotube چهار گام سریع و مشغول شنیدن هستید. ورود - حساب Spotify خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. + حساب خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید. جستجو و پخش هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید. گوش دادن آفلاین diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 2737c4bc..45a31bd6 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -390,7 +390,7 @@ Comment utiliser Spotube Quatre étapes rapides et c\'est parti. Se connecter - Connectez votre compte Spotify ou choisissez votre propre plugin de catalogue. + Connectez votre compte ou choisissez votre propre plugin de catalogue. Rechercher et écouter Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture. Écouter hors connexion diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml index 2ed9b459..29e2b1d2 100644 --- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml @@ -385,7 +385,7 @@ Spotube कैसे इस्तेमाल करें चार आसान स्टेप और आप सुनना शुरू कर सकते हैं। साइन इन करें - अपना Spotify अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। + अपना अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें। खोजें और चलाएं कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं। ऑफ़लाइन सुनें diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index f3e1ca6a..3f46bc82 100644 --- a/composeApp/src/commonMain/composeResources/values-id/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml @@ -382,7 +382,7 @@ Cara menggunakan Spotube Empat langkah singkat dan kamu siap mendengarkan. Masuk - Hubungkan akun Spotify-mu, atau pilih plugin katalogmu sendiri. + Hubungkan akunmu, atau pilih plugin katalogmu sendiri. Cari dan putar Temukan lagu, album, atau artis apa pun, lalu tekan putar. Dengarkan offline diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index b6925383..2d956a2c 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -390,7 +390,7 @@ Come usare Spotube Quattro rapidi passaggi e sei pronto ad ascoltare. Accedi - Collega il tuo account Spotify o scegli il tuo plugin di catalogo. + Collega il tuo account o scegli il tuo plugin di catalogo. Cerca e riproduci Trova qualsiasi brano, album o artista, poi premi play. Ascolta offline diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml index a915d409..27ea528a 100644 --- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml @@ -382,7 +382,7 @@ Spotubeの使い方 簡単な4ステップで、すぐに聴けます。 サインイン - Spotifyアカウントを連携するか、お好みのカタログプラグインを選んでください。 + アカウントを連携するか、お好みのカタログプラグインを選んでください。 検索して再生 曲、アルバム、アーティストを探して再生しましょう。 オフラインで聴く diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 5464dc07..67fb729e 100644 --- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml @@ -382,7 +382,7 @@ Spotube 사용 방법 간단한 네 단계면 바로 감상할 수 있어요. 로그인 - Spotify 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. + 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요. 검색하고 재생하기 노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요. 오프라인으로 감상하기 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index 337ad03c..a5e4197e 100644 --- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml @@ -386,7 +386,7 @@ Zo gebruik je Spotube Vier snelle stappen en je luistert al. Inloggen - Koppel je Spotify-account of kies je eigen catalogusplug-in. + Koppel je account of kies je eigen catalogusplug-in. Zoeken en afspelen Vind elk nummer, album of elke artiest en druk op afspelen. Offline luisteren diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index f5b66965..5af41a10 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -394,7 +394,7 @@ Jak korzystać ze Spotube Cztery szybkie kroki i już słuchasz. Zaloguj się - Połącz konto Spotify lub wybierz własną wtyczkę katalogu. + Połącz konto lub wybierz własną wtyczkę katalogu. Szukaj i odtwarzaj Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz. Słuchaj offline diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index fed0a741..ce987e2b 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -390,7 +390,7 @@ Como usar o Spotube Quatro passos rápidos e você já está ouvindo. Entrar - Conecte sua conta do Spotify ou escolha seu próprio plugin de catálogo. + Conecte sua conta ou escolha seu próprio plugin de catálogo. Busque e toque Encontre qualquer música, álbum ou artista e aperte o play. Ouça offline diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml index e499d01d..8128a8ce 100644 --- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml @@ -394,7 +394,7 @@ Как пользоваться Spotube Четыре простых шага — и музыка уже играет. Вход - Подключите аккаунт Spotify или выберите свой плагин каталога. + Подключите аккаунт или выберите свой плагин каталога. Ищите и слушайте Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести». Слушайте офлайн diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index 5c5b861d..ab470a92 100644 --- a/composeApp/src/commonMain/composeResources/values-th/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml @@ -382,7 +382,7 @@ วิธีใช้ Spotube แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย เข้าสู่ระบบ - เชื่อมต่อบัญชี Spotify ของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง + เชื่อมต่อบัญชีของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง ค้นหาและเล่น ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น ฟังแบบออฟไลน์ diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 4999b886..54d5ed93 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -386,7 +386,7 @@ Spotube nasıl kullanılır Dört hızlı adım ve dinlemeye başlıyorsun. Giriş yap - Spotify hesabını bağla veya kendi katalog eklentini seç. + hesabını bağla veya kendi katalog eklentini seç. Ara ve çal Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas. Çevrimdışı dinle diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml index 1f07bb5d..0fd2d19b 100644 --- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml @@ -394,7 +394,7 @@ Як користуватися Spotube Чотири прості кроки — і музика вже грає. Вхід - Підключіть акаунт Spotify або виберіть власний плагін каталогу. + Підключіть акаунт або виберіть власний плагін каталогу. Шукайте й слухайте Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити». Слухайте офлайн diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index b6d4f5ea..39ae5edc 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -382,7 +382,7 @@ Cách dùng Spotube Bốn bước nhanh là bạn đã có thể nghe nhạc. Đăng nhập - Kết nối tài khoản Spotify hoặc chọn plugin danh mục của riêng bạn. + Kết nối tài khoản hoặc chọn plugin danh mục của riêng bạn. Tìm kiếm và phát Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát. Nghe ngoại tuyến diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml index f5907323..2f59a04d 100644 --- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml @@ -382,7 +382,7 @@ 如何使用 Spotube 只要四個簡單步驟,就能開始聆聽。 登入 - 連結你的 Spotify 帳戶,或選擇你自己的目錄外掛程式。 + 連結你的帳戶,或選擇你自己的目錄外掛程式。 搜尋並播放 找到任何歌曲、專輯或藝人,然後按下播放。 離線聆聽 diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml index d154c6a2..774ab6ef 100644 --- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml @@ -382,7 +382,7 @@ 如何使用 Spotube 只需四个简单步骤,即可开始聆听。 登录 - 连接你的 Spotify 帐号,或选择你自己的曲库插件。 + 连接你的帐号,或选择你自己的曲库插件。 搜索并播放 查找任意歌曲、专辑或艺人,然后点击播放。 离线收听 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index a5481e2e..2fbae5e0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -404,7 +404,7 @@ How to use Spotube Four quick steps and you’re listening. Sign in - Connect your Spotify account, or pick your own catalog plugin. + Connect your account, or pick your own catalog plugin. Search and play Find any song, album, or artist, then press play. Listen offline @@ -553,6 +553,7 @@ Broker host is empty Connection failed Connection lost + Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam. Broker diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt index 85085f0f..07810202 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt @@ -33,12 +33,14 @@ import org.spotube.spotube.modules.settings.JamBroker import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch @@ -47,6 +49,7 @@ import kotlinx.serialization.json.Json import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.jam_error_broker_host_empty +import spotube.composeapp.generated.resources.jam_error_broker_unreachable import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.jam_error_connection_lost import spotube.composeapp.generated.resources.jam_error_no_broker @@ -135,8 +138,15 @@ class JamRoomClient : KoinComponent { val mqtt = startClient(broker, clientId) client = mqtt - withTimeout(broker.connectionTimeoutSeconds.seconds) { - mqtt.awaitConnectivity() + try { + withTimeout(broker.connectionTimeoutSeconds.seconds) { + // connectionState replays its current value, unlike awaitConnectivity's + // broadcast flow, which loses a CONNACK that lands before it is collected. + mqtt.connectionState.first { it is ConnectionState.Connected } + } + } catch (e: TimeoutCancellationException) { + throw IllegalStateException( + getString(Res.string.jam_error_broker_unreachable, broker.host, broker.port), e) } _connectionError.value = null log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt index 13d1f380..9977f96c 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/plugin/PluginManager.kt @@ -149,8 +149,11 @@ class PluginManager( val state: StateFlow = database.settingsDataStore.data .map { p -> val json = p[DatabaseKeys.PLUGINS_STATE_KEY] - val defaultSelectedPlugins = mapOf( - PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN) + // Each category starts out with the built-in plugin that provides it. + val defaultSelectedPlugins = BUILT_IN_PLUGINS + .flatMap { plugin -> plugin.abilities.map { it to plugin } } + .distinctBy { it.first } + .toMap() if (json == null) { PluginManagerStates.Data( plugins = BUILT_IN_PLUGINS, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt index 03227191..fdcdd734 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreen.kt @@ -18,15 +18,6 @@ package org.spotube.spotube.modules.search -import org.spotube.millennium.composables.MillenniumMarqueeProgress -import org.spotube.millennium.composables.MillenniumIcon -import org.spotube.millennium.composables.MillenniumButton -import org.spotube.millennium.composables.MillenniumButtonStyle -import org.spotube.millennium.composables.MillenniumListTile -import org.spotube.millennium.composables.MillenniumAutocomplete -import org.spotube.millennium.composables.MillenniumText -import org.spotube.millennium.composables.MillenniumTextField -import org.spotube.millennium.theme.MillenniumTheme import androidx.compose.animation.AnimatedContent import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode @@ -60,9 +51,6 @@ import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.text.input.rememberTextFieldState - -import org.spotube.spotube.core.ui.theming.AppTheme -import com.composeunstyled.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -77,17 +65,32 @@ import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.composeunstyled.Text import com.composeunstyled.currentWindowWidthBreakpoint +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.koin.compose.koinInject +import org.koin.compose.viewmodel.koinViewModel +import org.spotube.millennium.adaptive.WindowBreakpoints +import org.spotube.millennium.composables.MillenniumAutocomplete +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumIcon +import org.spotube.millennium.composables.MillenniumListTile +import org.spotube.millennium.composables.MillenniumMarqueeProgress +import org.spotube.millennium.composables.MillenniumText +import org.spotube.millennium.theme.MillenniumTheme import org.spotube.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbum import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import org.spotube.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylist import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser -import org.spotube.spotube.Platform import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.jam.JamRole @@ -108,6 +111,7 @@ import org.spotube.spotube.core.ui.component.UserCard import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.dragScrollable import org.spotube.spotube.core.ui.misc.SkeletonTree +import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.getPlatform import org.spotube.spotube.isDesktop import org.spotube.spotube.modules.blacklist.BlacklistRepository @@ -119,46 +123,38 @@ import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxSearchBroken import org.spotube.spotube.resources.iconsax.IconsaxTrash import org.spotube.spotube.resources.iconsax.InconsaxClock -import org.jetbrains.compose.resources.stringResource import spotube.composeapp.generated.resources.Res -import spotube.composeapp.generated.resources.search_clear_history -import spotube.composeapp.generated.resources.search_placeholder import spotube.composeapp.generated.resources.library_tab_albums import spotube.composeapp.generated.resources.library_tab_artists import spotube.composeapp.generated.resources.library_tab_playlists -import spotube.composeapp.generated.resources.search_results_title -import spotube.composeapp.generated.resources.search_no_search_types -import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_clear_history +import spotube.composeapp.generated.resources.search_loading_albums +import spotube.composeapp.generated.resources.search_loading_artists +import spotube.composeapp.generated.resources.search_loading_playlists +import spotube.composeapp.generated.resources.search_loading_tracks +import spotube.composeapp.generated.resources.search_loading_users +import spotube.composeapp.generated.resources.search_no_albums +import spotube.composeapp.generated.resources.search_no_artists +import spotube.composeapp.generated.resources.search_no_playlists import spotube.composeapp.generated.resources.search_no_results +import spotube.composeapp.generated.resources.search_no_search_types +import spotube.composeapp.generated.resources.search_no_users +import spotube.composeapp.generated.resources.search_placeholder +import spotube.composeapp.generated.resources.search_results_title +import spotube.composeapp.generated.resources.search_searching import spotube.composeapp.generated.resources.search_section_tracks import spotube.composeapp.generated.resources.search_section_users -import spotube.composeapp.generated.resources.search_start_typing_tracks -import spotube.composeapp.generated.resources.search_loading_tracks -import spotube.composeapp.generated.resources.search_no_albums -import spotube.composeapp.generated.resources.search_loading_albums -import spotube.composeapp.generated.resources.search_no_artists -import spotube.composeapp.generated.resources.search_loading_artists -import spotube.composeapp.generated.resources.search_no_playlists -import spotube.composeapp.generated.resources.search_loading_playlists -import spotube.composeapp.generated.resources.search_no_users -import spotube.composeapp.generated.resources.search_loading_users import spotube.composeapp.generated.resources.search_see_all -import spotube.composeapp.generated.resources.search_searching -import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_start_typing +import spotube.composeapp.generated.resources.search_start_typing_tracks +import spotube.composeapp.generated.resources.search_tab_all +import spotube.composeapp.generated.resources.search_warm_antennas import spotube.composeapp.generated.resources.search_warm_cosmos +import spotube.composeapp.generated.resources.search_warm_crates +import spotube.composeapp.generated.resources.search_warm_music_gods +import spotube.composeapp.generated.resources.search_warm_records import spotube.composeapp.generated.resources.search_warm_speakers import spotube.composeapp.generated.resources.search_warm_vinyl -import spotube.composeapp.generated.resources.search_warm_music_gods -import spotube.composeapp.generated.resources.search_warm_antennas -import spotube.composeapp.generated.resources.search_warm_records -import spotube.composeapp.generated.resources.search_tab_all -import org.jetbrains.compose.resources.StringResource -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import org.koin.compose.koinInject -import org.koin.compose.viewmodel.koinViewModel -import org.spotube.millennium.adaptive.WindowBreakpoints private val GridMinCellSize = 180.dp @@ -183,7 +179,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle() var showAddToPlaylistPicker by remember { mutableStateOf(false) } var tracksToAddToPlaylist by remember { mutableStateOf>(emptyList()) } - var currentUserId by remember { mutableStateOf(null) } + val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle( emptyList() @@ -207,10 +203,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) { } } - LaunchedEffect(Unit) { - currentUserId = libraryRepository.currentUser()?.id - } - val focusManager = LocalFocusManager.current fun playSingleTrack(track: MetadataTrack) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt index d1a8d836..be097f37 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/search/SearchScreenViewModel.kt @@ -30,6 +30,9 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupport import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser import org.spotube.spotube.core.db.Database +import org.spotube.spotube.core.di.injectLogger +import org.koin.core.component.KoinComponent +import org.spotube.spotube.modules.library.LibraryRepository import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.Job @@ -81,7 +84,8 @@ data class SearchScreenState( class SearchScreenViewModel( private val repository: SearchRepository, private val savedTracksRepository: SavedTracksRepository, - private val database: Database) : ViewModel() { + private val libraryRepository: LibraryRepository, + private val database: Database) : ViewModel(), KoinComponent { companion object { private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches") private val SEARCH_TYPES_ORDER = listOf( @@ -97,11 +101,16 @@ class SearchScreenViewModel( private const val ALL_ROW_PAGE_SIZE = 12 } + private val logger by injectLogger() + private val json = Json { ignoreUnknownKeys = true } private val _state = MutableStateFlow(SearchScreenState()) val state: StateFlow = _state.asStateFlow() + private val _currentUserId = MutableStateFlow(null) + val currentUserId: StateFlow = _currentUserId.asStateFlow() + private var queryJob: Job? = null init { @@ -113,6 +122,15 @@ class SearchScreenViewModel( } loadSupportedSearchTypes() + viewModelScope.launch { loadCurrentUser() } + } + + private suspend fun loadCurrentUser() { + runCatching { + _currentUserId.value = libraryRepository.currentUser()?.id + }.onFailure { e -> + logger.e(e) { "Failed to load current user" } + } } fun onQueryChange(query: String) { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index f22688a3..90f87e3e 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -98,9 +98,9 @@ data class UserSettings( @Serializable data class JamBroker( val name: String = "", - val host: String = "test.mosquitto.org", - val port: Int = 1883, - val useTls: Boolean = false, + val host: String = "broker.hivemq.com", + val port: Int = 8883, + val useTls: Boolean = true, val username: String? = null, val password: String? = null, val clientIdPrefix: String = "spotube", diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt index 484fe48b..b8f3d388 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/shell/AppShell.kt @@ -32,17 +32,23 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.Spring import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.unit.dp @@ -299,6 +305,15 @@ internal fun CompactPlayerOverlay( onAlternativeSource: () -> Unit, onExpandLyrics: () -> Unit, floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) }, + expandedPlayer: @Composable (modifier: Modifier, onCollapse: () -> Unit) -> Unit = { modifier, onCollapse -> + AppExpandedPlayer( + modifier = modifier, + onCollapse = onCollapse, + onQueue = onQueue, + onAlternativeSource = onAlternativeSource, + onExpandLyrics = onExpandLyrics + ) + }, bottomBar: @Composable (Modifier) -> Unit = { AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it) }, @@ -325,16 +340,39 @@ internal fun CompactPlayerOverlay( val sheetState = key(peekHeight) { rememberBottomSheetState( initialDetent = partialDetent, - detents = listOf(partialDetent, SheetDetent.FullyExpanded) + detents = listOf(partialDetent, SheetDetent.FullyExpanded), + // A spring carries the fling velocity into the settle, so releasing mid-drag + // glides to the nearest detent instead of stalling and then easing like a tween. + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow) ) } - val progress = if (!sheetState.offset.isFinite() || - (sheetState.isIdle && sheetState.currentDetent == partialDetent) - ) 0f else runCatching { - sheetState.progress(partialDetent, SheetDetent.FullyExpanded) - }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f - val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f)) + // Read only inside draw/derived scopes: the sheet offset changes every drag frame, and + // reading it in composition would recompose the whole player on each one. + val progress by remember(sheetState, partialDetent) { + derivedStateOf { + val value = if (!sheetState.offset.isFinite() || + (sheetState.isIdle && sheetState.currentDetent == partialDetent) + ) 0f else runCatching { + sheetState.progress(partialDetent, SheetDetent.FullyExpanded) + }.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f + value.coerceIn(0f, 1f) + } + } + // Keyed on the sheet state: it is recreated when the peek height changes, and a derived + // value left reading the previous sheet's progress would never update again. + val expandedPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).showExpandedPlayer } + } + val floatingPlayerVisible by remember(sheetState) { + derivedStateOf { viewModel.compactSheetUiState(progress).floatingPlayerAlpha > 0.01f } + } + // Once shown, keep the expanded player composed: composing it (view models, lyrics, cover + // art) on the first drag frame of every gesture is the hitch the user feels as lag. + var expandedPlayerComposed by remember { mutableStateOf(false) } + if (expandedPlayerVisible) expandedPlayerComposed = true Box(modifier = Modifier.fillMaxSize()) { content() @@ -345,44 +383,52 @@ internal fun CompactPlayerOverlay( ) { Sheet(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) { - if (uiState.showExpandedPlayer) { - AppExpandedPlayer( - modifier = Modifier + if (expandedPlayerComposed) { + expandedPlayer( + Modifier .fillMaxSize() - .graphicsLayer { alpha = uiState.expandedPlayerAlpha }, - onCollapse = { - scope.launch { sheetState.animateTo(partialDetent) } - }, - onQueue = onQueue, - onAlternativeSource = onAlternativeSource, - onExpandLyrics = onExpandLyrics + .graphicsLayer { + alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha + }, + { scope.launch { sheetState.animateTo(partialDetent) } } ) } - if (uiState.floatingPlayerAlpha > 0.01f) { - Column(modifier = Modifier.fillMaxWidth()) { - floatingPlayer( - Modifier - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }) - } + // Always composed: building it (view model, cover art, player state) only once + // progress drops below the fade-out point stalls the collapse mid-drag. + Column(modifier = Modifier.fillMaxWidth()) { + floatingPlayer( + Modifier + .zeroSizeUnless(floatingPlayerVisible) + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) } } } } - if (uiState.floatingPlayerAlpha > 0.01f) { - bottomBar( - Modifier - .fillMaxWidth() - .align(Alignment.BottomCenter) - .onSizeChanged { size -> - viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) - } - .graphicsLayer { - alpha = uiState.floatingPlayerAlpha - }) - } + bottomBar( + Modifier + .fillMaxWidth() + .align(Alignment.BottomCenter) + .zeroSizeUnless(floatingPlayerVisible) + .onSizeChanged { size -> + viewModel.onBottomBarMeasured(with(density) { size.height.toDp() }) + } + .graphicsLayer { + alpha = viewModel.floatingPlayerAlpha(progress) + }) } } + +/** + * Keeps the content composed but takes it out of layout, drawing and hit testing while it is + * fully faded out, so it neither steals touches from what is above it nor costs a re-composition + * when it comes back. + */ +private fun Modifier.zeroSizeUnless(visible: Boolean): Modifier = + if (visible) this else layout { measurable, constraints -> + measurable.measure(constraints) + layout(0, 0) {} + } diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt index 4607265d..785657bf 100644 --- a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/webview/SafariUserAgent.kt @@ -30,7 +30,7 @@ import platform.WebKit.WKWebViewConfiguration * Mobile Safari's exact User-Agent on this device. * * WKWebView's default UA ends in `Mobile/15E148` without Safari's `Version/x … Safari/604.1`, - * which sites like accounts.spotify.com treat as an embedded browser ("Oops, something went + * which sites like accounts.greedygreencorp.com treat as an embedded browser ("Oops, something went * wrong"). Hand-building the string isn't safe either: Safari freezes the OS part (iOS 26 * reports `iPhone OS 18_7`). So WebKit builds it: a probe WKWebView gets Safari's application * name and reports `navigator.userAgent`, which is cached for every web view. diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt new file mode 100644 index 00000000..d11f6e89 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/shell/CompactPlayerOverlayDragTest.kt @@ -0,0 +1,113 @@ +package org.spotube.spotube.modules.shell + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.ImageComposeScene +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.unit.dp +import androidx.compose.ui.use +import org.spotube.spotube.core.navigation.Navigator +import org.spotube.spotube.core.navigation.Routes +import org.spotube.spotube.core.navigation.TOP_LEVEL_ROUTES +import org.spotube.spotube.core.navigation.rememberNavigationState +import org.spotube.spotube.core.ui.theming.SpotubeTheme +import org.spotube.spotube.modules.settings.Theme +import org.spotube.spotube.modules.settings.UserSettings +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@OptIn(ExperimentalComposeUiApi::class) +class CompactPlayerOverlayDragTest { + @Test + fun draggingTheSheetUpShowsTheExpandedPlayer() { + var expandedComposed = false + var floatingCompositions = 0 + var floatingPlaced = false + val width = 400 + val height = 800 + ImageComposeScene(width, height) { + SpotubeTheme(UserSettings(theme = Theme.DARK)) { + val navigation = rememberNavigationState(Routes.Home, TOP_LEVEL_ROUTES) + val navigator = remember(navigation) { Navigator(navigation) } + CompactPlayerOverlay( + navigator = navigator, + navigationState = navigation, + viewModel = remember { AppShellViewModel() }, + onQueue = {}, onAlternativeSource = {}, onExpandLyrics = {}, + floatingPlayer = { modifier -> + DisposableEffect(Unit) { + floatingCompositions++ + onDispose {} + } + Box( + modifier.fillMaxWidth().height(64.dp).background(Color.Gray) + .onGloballyPositioned { floatingPlaced = true }) + }, + expandedPlayer = { modifier, _ -> + DisposableEffect(Unit) { + expandedComposed = true + onDispose { expandedComposed = false } + } + Box(modifier.fillMaxSize().background(Color.Red)) + }, + bottomBar = { modifier -> + Box(modifier.fillMaxWidth().height(56.dp).background(Color.Blue)) + } + ) { Box(Modifier.fillMaxSize()) } + } + }.use { scene -> + var t = 0L + fun frame() { scene.render(t); t += 16_000_000L } + repeat(10) { frame() } + assertTrue(floatingPlaced, "floating player should show while collapsed") + assertFalse(expandedComposed, "expanded player should not be composed while collapsed") + + // grab the floating player (just above the bottom bar) and drag to the top + val startY = height - 120f + 20f + val start = Offset(width / 2f, startY) + scene.sendPointerEvent(PointerEventType.Press, start, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var y = startY + repeat(30) { + y -= (startY - 40f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, y), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + + assertTrue(expandedComposed, "expanded player should be composed after dragging up") + // out of layout while the expanded player covers it, but never re-composed + floatingPlaced = false + repeat(5) { frame() } + assertFalse(floatingPlaced, "floating player should be out of layout when fully expanded") + + // drag back down: the floating player must return without being composed again + val downStart = Offset(width / 2f, 60f) + scene.sendPointerEvent(PointerEventType.Press, downStart, type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(5) { frame() } + var dy = downStart.y + repeat(30) { + dy += (height - 160f) / 30f + scene.sendPointerEvent(PointerEventType.Move, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + frame() + } + scene.sendPointerEvent(PointerEventType.Release, Offset(width / 2f, dy), type = PointerType.Touch, timeMillis = t / 1_000_000L) + repeat(120) { frame() } + assertTrue(floatingPlaced, "floating player should be back after collapsing") + assertTrue(floatingCompositions == 1, "floating player was composed $floatingCompositions times, expected 1") + } + } +} diff --git a/metadata/en-US/images/featureGraphic.png b/metadata/en-US/images/featureGraphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/metadata/en-US/images/featureGraphic.png differ diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/metadata/en-US/images/phoneScreenshots/5.png b/metadata/en-US/images/phoneScreenshots/5.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/metadata/en-US/images/phoneScreenshots/5.png differ diff --git a/metadata/en-US/images/phoneScreenshots/android-1.jpg b/metadata/en-US/images/phoneScreenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-1.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-2.jpg b/metadata/en-US/images/phoneScreenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-2.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-3.jpg b/metadata/en-US/images/phoneScreenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-3.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-4.jpg b/metadata/en-US/images/phoneScreenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-4.jpg and /dev/null differ diff --git a/metadata/en-US/images/phoneScreenshots/android-5.jpg b/metadata/en-US/images/phoneScreenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/metadata/en-US/images/phoneScreenshots/android-5.jpg and /dev/null differ diff --git a/metadata/tr/images/featureGraphic.png b/metadata/tr/images/featureGraphic.png new file mode 100644 index 00000000..42a5c41c Binary files /dev/null and b/metadata/tr/images/featureGraphic.png differ diff --git a/metadata/tr/images/phoneScreenshots/1.png b/metadata/tr/images/phoneScreenshots/1.png new file mode 100644 index 00000000..adbafe11 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/1.png differ diff --git a/metadata/tr/images/phoneScreenshots/2.png b/metadata/tr/images/phoneScreenshots/2.png new file mode 100644 index 00000000..e4e91416 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/2.png differ diff --git a/metadata/tr/images/phoneScreenshots/3.png b/metadata/tr/images/phoneScreenshots/3.png new file mode 100644 index 00000000..b488d8c9 Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/3.png differ diff --git a/metadata/tr/images/phoneScreenshots/4.png b/metadata/tr/images/phoneScreenshots/4.png new file mode 100644 index 00000000..32dc9f3a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/4.png differ diff --git a/metadata/tr/images/phoneScreenshots/5.png b/metadata/tr/images/phoneScreenshots/5.png new file mode 100644 index 00000000..3ad3c81a Binary files /dev/null and b/metadata/tr/images/phoneScreenshots/5.png differ diff --git a/metadata/tr/images/phoneScreenshots/android-1.jpg b/metadata/tr/images/phoneScreenshots/android-1.jpg deleted file mode 100644 index 16debbd7..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-1.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-2.jpg b/metadata/tr/images/phoneScreenshots/android-2.jpg deleted file mode 100644 index d594227e..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-2.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-3.jpg b/metadata/tr/images/phoneScreenshots/android-3.jpg deleted file mode 100644 index ed2d61f2..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-3.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-4.jpg b/metadata/tr/images/phoneScreenshots/android-4.jpg deleted file mode 100644 index 36a3549f..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-4.jpg and /dev/null differ diff --git a/metadata/tr/images/phoneScreenshots/android-5.jpg b/metadata/tr/images/phoneScreenshots/android-5.jpg deleted file mode 100644 index 29be481b..00000000 Binary files a/metadata/tr/images/phoneScreenshots/android-5.jpg and /dev/null differ diff --git a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache index 7c952522..f8f510d5 100644 --- a/packaging/linux/org.spotube.spotube.metainfo.xml.mustache +++ b/packaging/linux/org.spotube.spotube.metainfo.xml.mustache @@ -55,8 +55,28 @@ - Spotube on the desktop - https://raw.githubusercontent.com/team-spotube/spotube/master/assets/branding/spotube-screenshot.png + Browse music from the service your plugin connects to + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/home-desktop-dark.png + + + Synced lyrics + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/lyrics-desktop-dark.png + + + Your playlists + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/playlists-desktop-dark.png + + + Search tracks, albums, artists, playlists and users + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/search-desktop-dark.png + + + Local folders and downloads + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/local-tracks-desktop-dark.png + + + Group Jam rooms + https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/group-jam-desktop-dark.png diff --git a/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt index 10e45bc6..bd012db7 100644 --- a/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt +++ b/plugin_interfaces/src/commonMain/kotlin/org/spotube/plugin_interfaces/plugin_apis/scrobble/ScrobbleModels.kt @@ -28,6 +28,6 @@ data class ScrobbleTrack( val trackName: String, val artistName: String, val albumName: String?, - val streamingProvider: String, // e.g. "spotify", "apple_music" etc + val streamingProvider: String, // e.g. "greedy_green_corp", "apple_music" etc val durationMs: Long? = null, ) \ No newline at end of file