Compare commits

..

1 Commits

Author SHA1 Message Date
Brian López Medina
e461f7547f
Merge 9785c93b6a into 2c1148a126 2026-10-29 22:37:45 -06:00
78 changed files with 183 additions and 451 deletions

219
README.md
View File

@ -2,8 +2,7 @@
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
A cross-platform extensible open-source music streaming platform.<br>
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!
<a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
@ -17,16 +16,17 @@ yourself. A small step towards the decentralized music streaming era!
---
![Spotube Desktop](assets/feature-graphics/readme-hero.png)
![Spotube Mobile](assets/feature-graphics/readme-mobile.png)
![Spotube Desktop](assets/branding/spotube-screenshot.png)
****</div>
![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg)
</div>
## 🌃 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,183 +41,132 @@ 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).
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly).
## 🕳️ Building from source
<details>
<summary>
<h3 style="display: inline;">🕳️ Building from source [Click to expand]</h3>
</summary>
<hr/>
<a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
Spotube is a Compose Multiplatform app.
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.
### 1. Prerequisites
### Prerequisites
- **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).
- **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).
- **Android SDK**, only for Android builds.
- **[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`:
- **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):
| 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` |
| 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) |
On Ubuntu 24.04+/Debian 13, `libgtk-3-0` and `libasound2` are `libgtk-3-0t64` and `libasound2t64`;
apt resolves this itself.
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.
### 2. Run in development
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
Desktop (JVM), fastest edit/run loop
```bash
./gradlew :composeApp:run
./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop
./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug)
```
Android debug APK
```bash
./gradlew :composeApp:assembleStableDebug
```
> Use `./gradlew :composeApp:assembleNightlyDebug` for the nightly channel.
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).
Development builds stay fast: the release-only optimizations below are skipped.
### Building the desktop app (GraalVM native image)
### 3. Build the desktop app
#### Native 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`.
[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:
**Native toolchain** (Nucleus downloads GraalVM CE on the first build; set `GRAALVM_HOME` to use
your own):
| 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. |
| OS | Requirements |
| ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Linux | `gcc`, `zlib` headers, `patchelf`; `rpm` for `.rpm`, `fuse` + `zstd` for `.AppImage`. <br/>Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. <br/>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) |
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`.
**Build and run the app folder:**
#### Build and run
```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/
Build and launch in one step
```bash
# Run that folder's binary directly
./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube
# Or build and launch in one step
./gradlew :composeApp:runGraalvmNativeDistributable
```
Keep the `spotube` binary next to the libraries it was built with; it loads them from its own
folder.
Keep the `spotube` binary next to the `.so`/`.dll`/`lib/` files it was built with; it loads them from its own folder.
**Installers** (each OS builds only 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-<format>`
Each OS can only build its own installers:
> [!Tip]
> Add `-Pnightly=true` for the nightly channel (separate icons and name).
| 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/` |
<details>
<summary>How the Linux AppImage bundle works</summary>
Add `-Pnightly=true` for the nightly channel (separate icons and name).
- [`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`.
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)).
</details>
#### What the AppImage bundles
### 4. Native build breaks, JVM works
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.
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).
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.
Symptoms: `ClassNotFoundException`, `NoSuchMethodException` or `MissingReflectionRegistrationError`
in the native binary, while `:composeApp:run` works.
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`.
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.
#### Reflection metadata (when a feature breaks only in the native build)
> [!Tip]
> `./gradlew :composeApp:runGraalvmNative` is a faster debug loop: a quick `-Ob` build that names the
> missing registration.
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).
> [!Warning]
> Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry, or playback fails to initialize.
If something works in `:composeApp:run` but throws `ClassNotFoundException`, `NoSuchMethodException`, `MissingReflectionRegistrationError` or similar in the native binary, record what's missing:
### 5. Production vs development builds
```bash
./gradlew :composeApp:runWithNativeAgent
```
Production mode turns on automatically for `package*`, `create*`, `notarize*` and
`nativeImageCompile` tasks. Force it with `-Pproduction=true|false`.
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.
| 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 |
`./gradlew :composeApp:runGraalvmNative` gives a faster debugging loop: a quick-build (`-Ob`) native image that reports exactly which registration is missing.
Android picks the Rust profile from its build type.
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.
### 6. Releases
### Production vs development builds
Run [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) manually
(`workflow_dispatch`) as `stable`, `beta` or `nightly`.
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`.
- **Beta:** the stable build as a draft, tagged `v<version>-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.
| | 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`) |
</details>
<hr/>
Android picks the Rust profile from its build type (`debug` or `release`) on its own.
## 📚 Resources
### Releases
**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
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<version>-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.
## 👥 The Spotube team

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 828 KiB

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

View File

@ -82,16 +82,10 @@ 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).also { NewPipe.init(it) }
instance = NewPipeDownloader(cookieJar)
NewPipe.init(instance)
}
}
}

View File

@ -23,7 +23,6 @@ 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
@ -62,8 +61,6 @@ import spotube.composeapp.generated.resources.notification_playback_channel_name
@OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService(), KoinComponent {
private var taskRemoved = false
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
private val audioPlayerQueue: AudioPlayerQueue by inject()
@ -164,13 +161,9 @@ 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()
}

View File

@ -553,7 +553,6 @@
<string name="jam_error_broker_host_empty">Broker host is empty</string>
<string name="jam_error_connection_failed">Connection failed</string>
<string name="jam_error_connection_lost">Connection lost</string>
<string name="jam_error_broker_unreachable">Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam.</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>

View File

@ -33,14 +33,12 @@ 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
@ -49,7 +47,6 @@ 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
@ -138,15 +135,8 @@ class JamRoomClient : KoinComponent {
val mqtt = startClient(broker, clientId)
client = mqtt
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)
withTimeout(broker.connectionTimeoutSeconds.seconds) {
mqtt.awaitConnectivity()
}
_connectionError.value = null
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }

View File

@ -149,11 +149,8 @@ class PluginManager(
val state: StateFlow<PluginManagerStates> = database.settingsDataStore.data
.map { p ->
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
// 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()
val defaultSelectedPlugins = mapOf(
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN)
if (json == null) {
PluginManagerStates.Data(
plugins = BUILT_IN_PLUGINS,

View File

@ -18,6 +18,15 @@
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
@ -51,6 +60,9 @@ 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
@ -65,32 +77,17 @@ 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
@ -111,7 +108,6 @@ 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
@ -123,38 +119,46 @@ 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_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_no_search_types
import spotube.composeapp.generated.resources.search_start_typing
import spotube.composeapp.generated.resources.search_no_results
import spotube.composeapp.generated.resources.search_section_tracks
import spotube.composeapp.generated.resources.search_section_users
import spotube.composeapp.generated.resources.search_see_all
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_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_warm_music_gods
import spotube.composeapp.generated.resources.search_warm_records
import spotube.composeapp.generated.resources.search_warm_cosmos
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
@ -179,7 +183,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
var currentUserId by remember { mutableStateOf<String?>(null) }
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
emptyList()
@ -203,6 +207,10 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
}
}
LaunchedEffect(Unit) {
currentUserId = libraryRepository.currentUser()?.id
}
val focusManager = LocalFocusManager.current
fun playSingleTrack(track: MetadataTrack) {

View File

@ -30,9 +30,6 @@ 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
@ -84,8 +81,7 @@ data class SearchScreenState(
class SearchScreenViewModel(
private val repository: SearchRepository,
private val savedTracksRepository: SavedTracksRepository,
private val libraryRepository: LibraryRepository,
private val database: Database) : ViewModel(), KoinComponent {
private val database: Database) : ViewModel() {
companion object {
private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches")
private val SEARCH_TYPES_ORDER = listOf(
@ -101,16 +97,11 @@ class SearchScreenViewModel(
private const val ALL_ROW_PAGE_SIZE = 12
}
private val logger by injectLogger<SearchScreenViewModel>()
private val json = Json { ignoreUnknownKeys = true }
private val _state = MutableStateFlow(SearchScreenState())
val state: StateFlow<SearchScreenState> = _state.asStateFlow()
private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
private var queryJob: Job? = null
init {
@ -122,15 +113,6 @@ 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) {

View File

@ -98,9 +98,9 @@ data class UserSettings(
@Serializable
data class JamBroker(
val name: String = "",
val host: String = "broker.hivemq.com",
val port: Int = 8883,
val useTls: Boolean = true,
val host: String = "test.mosquitto.org",
val port: Int = 1883,
val useTls: Boolean = false,
val username: String? = null,
val password: String? = null,
val clientIdPrefix: String = "spotube",

View File

@ -37,23 +37,17 @@ 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
@ -329,15 +323,6 @@ 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)
},
@ -364,39 +349,16 @@ internal fun CompactPlayerOverlay(
val sheetState = key(peekHeight) {
rememberBottomSheetState(
initialDetent = partialDetent,
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)
detents = listOf(partialDetent, SheetDetent.FullyExpanded)
)
}
// 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
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))
Box(modifier = Modifier.fillMaxSize()) {
content()
@ -407,52 +369,44 @@ internal fun CompactPlayerOverlay(
) {
Sheet(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
if (expandedPlayerComposed) {
expandedPlayer(
Modifier
if (uiState.showExpandedPlayer) {
AppExpandedPlayer(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha
},
{ scope.launch { sheetState.animateTo(partialDetent) } }
.graphicsLayer { alpha = uiState.expandedPlayerAlpha },
onCollapse = {
scope.launch { sheetState.animateTo(partialDetent) }
},
onQueue = onQueue,
onAlternativeSource = onAlternativeSource,
onExpandLyrics = onExpandLyrics
)
}
// 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) {
Column(modifier = Modifier.fillMaxWidth()) {
floatingPlayer(
Modifier
.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)
})
if (uiState.floatingPlayerAlpha > 0.01f) {
bottomBar(
Modifier
.fillMaxWidth()
.align(Alignment.BottomCenter)
.onSizeChanged { size ->
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
}
.graphicsLayer {
alpha = uiState.floatingPlayerAlpha
})
}
}
}
/**
* 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) {}
}

View File

@ -1,113 +0,0 @@
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")
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

View File

@ -8,9 +8,8 @@ command: spotube
finish-args:
- --share=ipc
- --share=network
# The window is a Tao (Wayland/X11) one, so Wayland is used natively and X11 only as a fallback
- --socket=wayland
- --socket=fallback-x11
# Compose renders through AWT/Skiko, which needs X11 (XWayland on Wayland sessions)
- --socket=x11
- --socket=pulseaudio
- --device=dri
# Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads

View File

@ -55,28 +55,8 @@
<screenshots>
<screenshot type="default">
<caption>Browse music from the service your plugin connects to</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/home-desktop-dark.png</image>
</screenshot>
<screenshot>
<caption>Synced lyrics</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/lyrics-desktop-dark.png</image>
</screenshot>
<screenshot>
<caption>Your playlists</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/playlists-desktop-dark.png</image>
</screenshot>
<screenshot>
<caption>Search tracks, albums, artists, playlists and users</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/search-desktop-dark.png</image>
</screenshot>
<screenshot>
<caption>Local folders and downloads</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/local-tracks-desktop-dark.png</image>
</screenshot>
<screenshot>
<caption>Group Jam rooms</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/screenshots/group-jam-desktop-dark.png</image>
<caption>Spotube on the desktop</caption>
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/branding/spotube-screenshot.png</image>
</screenshot>
</screenshots>