Compare commits
11 Commits
e461f7547f
...
42c2d0fa7e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c2d0fa7e | ||
|
|
b12e98a8c5 | ||
|
|
35700920b4 | ||
|
|
e04b3414e0 | ||
|
|
9f4ce11509 | ||
|
|
bba725a139 | ||
|
|
c058074cff | ||
|
|
2aaa0db81e | ||
|
|
14bc279653 | ||
|
|
9785c93b6a | ||
|
|
09edc5a182 |
211
README.md
@ -2,7 +2,8 @@
|
|||||||
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
|
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
|
||||||
|
|
||||||
A cross-platform extensible open-source music streaming platform.<br>
|
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://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>
|
<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>
|
||||||
@ -16,17 +17,16 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||

|

|
||||||
|

|
||||||
|
|
||||||

|
****</div>
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
## 🌃 Features
|
## 🌃 Features
|
||||||
|
|
||||||
- 🧩 Plugin powered, supports any platform or custom music service through plugins.
|
- 🧩 Plugin powered, supports any platform or custom music service through plugins.
|
||||||
- 🗺️ Community driven plugins for popular platforms or create your own.
|
- 🗺️ 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.
|
- 🖥️ 📱 Cross-platform support.
|
||||||
- 🪶 Small size & less data usage.
|
- 🪶 Small size & less data usage.
|
||||||
- 🕒 Time synced lyrics regardless of the plugin support.
|
- 🕒 Time synced lyrics regardless of the plugin support.
|
||||||
@ -41,132 +41,183 @@ Download Spotube from the Official Website: https://spotube.cc
|
|||||||
|
|
||||||
### 🔄 Nightly Builds
|
### 🔄 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>
|
<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 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.
|
- **JDK 21** to run Gradle (the Gradle daemon JDK and GraalVM are provisioned automatically).
|
||||||
- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev).
|
- **Rust** (`rustup` + `cargo`), for the `composeApp` library built
|
||||||
|
through [Gobley](https://gobley.dev).
|
||||||
- **Android SDK**, only for Android builds.
|
- **Android SDK**, only for Android builds.
|
||||||
- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it:
|
- **[ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview)**, which isn't on
|
||||||
- [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`)
|
Maven Central yet. Clone it at the commit pinned as `COMPOSE_WEBVIEW_COMMIT` in [
|
||||||
- **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):
|
`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 |
|
| Distro | Command |
|
||||||
|---|---|
|
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` |
|
| 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. |
|
| 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` |
|
| 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) |
|
| 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.
|
### 2. Run in development
|
||||||
|
|
||||||
Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows).
|
|
||||||
|
|
||||||
### Development
|
|
||||||
|
|
||||||
|
Desktop (JVM), fastest edit/run loop
|
||||||
```bash
|
```bash
|
||||||
./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop
|
./gradlew :composeApp:run
|
||||||
./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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`.
|
||||||
|
|
||||||
|
**Native toolchain** (Nucleus downloads GraalVM CE on the first build; set `GRAALVM_HOME` to use
|
||||||
|
your own):
|
||||||
|
|
||||||
| OS | Requirements |
|
| 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) |
|
| 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`) |
|
| 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. |
|
| 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
|
```bash
|
||||||
# App folder: binary + its native libs, in composeApp/build/compose/binaries/main/graalvm-app/
|
|
||||||
./gradlew :composeApp:createGraalvmNativeDistributable
|
./gradlew :composeApp:createGraalvmNativeDistributable
|
||||||
|
```
|
||||||
|
> Output folder: composeApp/build/compose/binaries/main/graalvm-app/
|
||||||
|
|
||||||
# Run that folder's binary directly
|
Build and launch in one step
|
||||||
./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube
|
```bash
|
||||||
|
|
||||||
# Or build and launch in one step
|
|
||||||
./gradlew :composeApp:runGraalvmNativeDistributable
|
./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-<format>`
|
||||||
|
|
||||||
| OS | Command | Output |
|
> [!Tip]
|
||||||
|---|---|---|
|
> Add `-Pnightly=true` for the nightly channel (separate icons and name).
|
||||||
| 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/` |
|
|
||||||
|
|
||||||
Add `-Pnightly=true` for the nightly channel (separate icons and name).
|
<details>
|
||||||
|
<summary>How the Linux AppImage bundle works</summary>
|
||||||
|
|
||||||
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
|
</details>
|
||||||
|
|
||||||
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:
|
Symptoms: `ClassNotFoundException`, `NoSuchMethodException` or `MissingReflectionRegistrationError`
|
||||||
- [`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`.
|
in the native binary, while `:composeApp:run` works.
|
||||||
- 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`.
|
|
||||||
|
|
||||||
#### 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
|
### 5. Production vs development builds
|
||||||
./gradlew :composeApp:runWithNativeAgent
|
|
||||||
```
|
|
||||||
|
|
||||||
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) |
|
- **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.
|
||||||
| Rust library (desktop) | `debug` profile, fast incremental compile | `release` profile with LTO, single codegen unit, stripped (31 MB → 1.8 MB) |
|
- **Desktop:** each platform (Linux x86_64 and aarch64, Windows, macOS) builds its installers on its
|
||||||
| Desktop app | JVM, hot-reloadable | GraalVM native image, `-Os` (size-optimized) |
|
own runner.
|
||||||
| 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 |
|
- **Android:** a signed APK.
|
||||||
| 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`) |
|
|
||||||
|
|
||||||
Android picks the Rust profile from its build type (`debug` or `release`) on its own.
|
</details>
|
||||||
|
<hr/>
|
||||||
|
|
||||||
### 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<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.
|
**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
|
## 👥 The Spotube team
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 172 KiB |
|
Before Width: | Height: | Size: 771 KiB After Width: | Height: | Size: 828 KiB |
|
Before Width: | Height: | Size: 771 KiB |
BIN
assets/feature-graphics/play-feature-graphic.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
assets/feature-graphics/readme-hero.png
Normal file
|
After Width: | Height: | Size: 570 KiB |
BIN
assets/feature-graphics/readme-mobile.png
Normal file
|
After Width: | Height: | Size: 317 KiB |
BIN
assets/feature-graphics/store-desktop-01-home.png
Normal file
|
After Width: | Height: | Size: 611 KiB |
BIN
assets/feature-graphics/store-desktop-02-lyrics.png
Normal file
|
After Width: | Height: | Size: 210 KiB |
BIN
assets/feature-graphics/store-desktop-03-playlists.png
Normal file
|
After Width: | Height: | Size: 627 KiB |
BIN
assets/feature-graphics/store-desktop-04-search.png
Normal file
|
After Width: | Height: | Size: 267 KiB |
BIN
assets/feature-graphics/store-desktop-05-local-tracks.png
Normal file
|
After Width: | Height: | Size: 175 KiB |
BIN
assets/feature-graphics/store-desktop-06-group-jam.png
Normal file
|
After Width: | Height: | Size: 145 KiB |
BIN
assets/feature-graphics/store-mobile-01-home.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
assets/feature-graphics/store-mobile-02-player.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
assets/feature-graphics/store-mobile-03-library.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
assets/feature-graphics/store-mobile-04-devices.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/feature-graphics/store-mobile-05-group-jam.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
assets/screenshots/devices-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 446 KiB |
BIN
assets/screenshots/devices-mobile-light.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
assets/screenshots/downloads-desktop-light.png
Normal file
|
After Width: | Height: | Size: 424 KiB |
BIN
assets/screenshots/group-jam-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
assets/screenshots/group-jam-mobile-light.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/screenshots/home-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
assets/screenshots/home-desktop-light.png
Normal file
|
After Width: | Height: | Size: 960 KiB |
BIN
assets/screenshots/home-mobile-dark.png
Normal file
|
After Width: | Height: | Size: 429 KiB |
BIN
assets/screenshots/home-mobile-light.png
Normal file
|
After Width: | Height: | Size: 431 KiB |
BIN
assets/screenshots/home-player-dark.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
BIN
assets/screenshots/home-player-light.png
Normal file
|
After Width: | Height: | Size: 133 KiB |
BIN
assets/screenshots/library-mobile-light.png
Normal file
|
After Width: | Height: | Size: 165 KiB |
BIN
assets/screenshots/local-tracks-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 427 KiB |
BIN
assets/screenshots/local-tracks-desktop-light.png
Normal file
|
After Width: | Height: | Size: 432 KiB |
BIN
assets/screenshots/lyrics-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
assets/screenshots/lyrics-desktop-light.png
Normal file
|
After Width: | Height: | Size: 564 KiB |
BIN
assets/screenshots/playlists-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 897 KiB |
BIN
assets/screenshots/playlists-desktop-light.png
Normal file
|
After Width: | Height: | Size: 901 KiB |
BIN
assets/screenshots/search-desktop-dark.png
Normal file
|
After Width: | Height: | Size: 538 KiB |
BIN
assets/screenshots/search-desktop-light.png
Normal file
|
After Width: | Height: | Size: 533 KiB |
BIN
assets/screenshots/search-mobile-light.png
Normal file
|
After Width: | Height: | Size: 143 KiB |
@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader
|
|||||||
companion object {
|
companion object {
|
||||||
private var instance: NewPipeDownloader? = null
|
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) {
|
fun init(paths: Paths) {
|
||||||
|
if (instance != null) return
|
||||||
val cookieJar = PersistentCookieJar(paths)
|
val cookieJar = PersistentCookieJar(paths)
|
||||||
instance = NewPipeDownloader(cookieJar)
|
instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) }
|
||||||
NewPipe.init(instance)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -23,6 +23,7 @@ import android.app.NotificationManager
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
|
import android.os.Process
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
@ -61,6 +62,8 @@ import spotube.composeapp.generated.resources.notification_playback_channel_name
|
|||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaLibraryService(), KoinComponent {
|
class PlaybackService : MediaLibraryService(), KoinComponent {
|
||||||
|
private var taskRemoved = false
|
||||||
|
|
||||||
|
|
||||||
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
|
||||||
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
private val audioPlayerQueue: AudioPlayerQueue by inject()
|
||||||
@ -161,9 +164,13 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
|
|||||||
removeSession(librarySession)
|
removeSession(librarySession)
|
||||||
librarySession.release()
|
librarySession.release()
|
||||||
super.onDestroy()
|
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?) {
|
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||||
|
taskRemoved = true
|
||||||
pauseAllPlayersAndStopSelf()
|
pauseAllPlayersAndStopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -553,6 +553,7 @@
|
|||||||
<string name="jam_error_broker_host_empty">Broker host is empty</string>
|
<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_failed">Connection failed</string>
|
||||||
<string name="jam_error_connection_lost">Connection lost</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 -->
|
<!-- Settings: Group Jam broker -->
|
||||||
<string name="settings_jam_broker_content_description">Broker</string>
|
<string name="settings_jam_broker_content_description">Broker</string>
|
||||||
@ -758,6 +759,8 @@
|
|||||||
<string name="player_collapse_content_description">Collapse player</string>
|
<string name="player_collapse_content_description">Collapse player</string>
|
||||||
<string name="player_expand_lyrics_content_description">Expand lyrics</string>
|
<string name="player_expand_lyrics_content_description">Expand lyrics</string>
|
||||||
<string name="player_now_playing">Now Playing</string>
|
<string name="player_now_playing">Now Playing</string>
|
||||||
|
<string name="player_fullscreen_open_content_description">Open full screen player</string>
|
||||||
|
<string name="player_fullscreen_close_content_description">Close full screen player</string>
|
||||||
<string name="player_lyrics">Lyrics</string>
|
<string name="player_lyrics">Lyrics</string>
|
||||||
<string name="player_alternative_source">Alternative Source</string>
|
<string name="player_alternative_source">Alternative Source</string>
|
||||||
<string name="player_alternative_source_content_description">Alternative source</string>
|
<string name="player_alternative_source_content_description">Alternative source</string>
|
||||||
@ -963,6 +966,8 @@
|
|||||||
<string name="app_window_maximize">Maximize</string>
|
<string name="app_window_maximize">Maximize</string>
|
||||||
<string name="app_window_restore">Restore</string>
|
<string name="app_window_restore">Restore</string>
|
||||||
<string name="app_window_close">Close</string>
|
<string name="app_window_close">Close</string>
|
||||||
|
<string name="app_window_enter_fullscreen">Enter full screen</string>
|
||||||
|
<string name="app_window_exit_fullscreen">Exit full screen</string>
|
||||||
|
|
||||||
<!-- Android playback notification -->
|
<!-- Android playback notification -->
|
||||||
<string name="notification_playback_channel_name">Playback</string>
|
<string name="notification_playback_channel_name">Playback</string>
|
||||||
|
|||||||
@ -33,12 +33,14 @@ import org.spotube.spotube.modules.settings.JamBroker
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.TimeoutCancellationException
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
import kotlinx.coroutines.flow.asSharedFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@ -47,6 +49,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
import spotube.composeapp.generated.resources.jam_error_broker_host_empty
|
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_failed
|
||||||
import spotube.composeapp.generated.resources.jam_error_connection_lost
|
import spotube.composeapp.generated.resources.jam_error_connection_lost
|
||||||
import spotube.composeapp.generated.resources.jam_error_no_broker
|
import spotube.composeapp.generated.resources.jam_error_no_broker
|
||||||
@ -135,8 +138,15 @@ class JamRoomClient : KoinComponent {
|
|||||||
|
|
||||||
val mqtt = startClient(broker, clientId)
|
val mqtt = startClient(broker, clientId)
|
||||||
client = mqtt
|
client = mqtt
|
||||||
|
try {
|
||||||
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
||||||
mqtt.awaitConnectivity()
|
// 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
|
_connectionError.value = null
|
||||||
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
|
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
|
||||||
|
|||||||
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
// Toggles the OS full screen mode of the app window. Renders nothing where the app doesn't own
|
||||||
|
// a window (mobile).
|
||||||
|
@Composable
|
||||||
|
expect fun WindowFullscreenButton()
|
||||||
@ -149,8 +149,11 @@ class PluginManager(
|
|||||||
val state: StateFlow<PluginManagerStates> = database.settingsDataStore.data
|
val state: StateFlow<PluginManagerStates> = database.settingsDataStore.data
|
||||||
.map { p ->
|
.map { p ->
|
||||||
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
|
val json = p[DatabaseKeys.PLUGINS_STATE_KEY]
|
||||||
val defaultSelectedPlugins = mapOf(
|
// Each category starts out with the built-in plugin that provides it.
|
||||||
PluginAbility.AUDIO to NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN)
|
val defaultSelectedPlugins = BUILT_IN_PLUGINS
|
||||||
|
.flatMap { plugin -> plugin.abilities.map { it to plugin } }
|
||||||
|
.distinctBy { it.first }
|
||||||
|
.toMap()
|
||||||
if (json == null) {
|
if (json == null) {
|
||||||
PluginManagerStates.Data(
|
PluginManagerStates.Data(
|
||||||
plugins = BUILT_IN_PLUGINS,
|
plugins = BUILT_IN_PLUGINS,
|
||||||
|
|||||||
@ -18,15 +18,6 @@
|
|||||||
package org.spotube.spotube.modules.search
|
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.AnimatedContent
|
||||||
import androidx.compose.animation.core.LinearEasing
|
import androidx.compose.animation.core.LinearEasing
|
||||||
import androidx.compose.animation.core.RepeatMode
|
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.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
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.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
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.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.Text
|
||||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
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.album.MetadataAlbum
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
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.playlist.MetadataPlaylist
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.search.MetadataSupportedSearchType
|
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.track.MetadataTrack
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
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.AudioPlayerQueue
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import org.spotube.spotube.core.jam.JamRole
|
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.cards.PlayableCard
|
||||||
import org.spotube.spotube.core.ui.component.dragScrollable
|
import org.spotube.spotube.core.ui.component.dragScrollable
|
||||||
import org.spotube.spotube.core.ui.misc.SkeletonTree
|
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.getPlatform
|
||||||
import org.spotube.spotube.isDesktop
|
import org.spotube.spotube.isDesktop
|
||||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
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.IconsaxSearchBroken
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxTrash
|
import org.spotube.spotube.resources.iconsax.IconsaxTrash
|
||||||
import org.spotube.spotube.resources.iconsax.InconsaxClock
|
import org.spotube.spotube.resources.iconsax.InconsaxClock
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
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_albums
|
||||||
import spotube.composeapp.generated.resources.library_tab_artists
|
import spotube.composeapp.generated.resources.library_tab_artists
|
||||||
import spotube.composeapp.generated.resources.library_tab_playlists
|
import spotube.composeapp.generated.resources.library_tab_playlists
|
||||||
import spotube.composeapp.generated.resources.search_results_title
|
import spotube.composeapp.generated.resources.search_clear_history
|
||||||
import spotube.composeapp.generated.resources.search_no_search_types
|
import spotube.composeapp.generated.resources.search_loading_albums
|
||||||
import spotube.composeapp.generated.resources.search_start_typing
|
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_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_tracks
|
||||||
import spotube.composeapp.generated.resources.search_section_users
|
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_see_all
|
||||||
import spotube.composeapp.generated.resources.search_searching
|
import spotube.composeapp.generated.resources.search_start_typing
|
||||||
import spotube.composeapp.generated.resources.search_warm_crates
|
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_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_speakers
|
||||||
import spotube.composeapp.generated.resources.search_warm_vinyl
|
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
|
private val GridMinCellSize = 180.dp
|
||||||
|
|
||||||
@ -183,7 +179,7 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
val savedTrackIds by viewModel.savedTrackIds.collectAsStateWithLifecycle()
|
||||||
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
var showAddToPlaylistPicker by remember { mutableStateOf(false) }
|
||||||
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
var tracksToAddToPlaylist by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) }
|
||||||
var currentUserId by remember { mutableStateOf<String?>(null) }
|
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
|
val blacklistedTracks by blacklistRepository.blacklistedTracks.collectAsStateWithLifecycle(
|
||||||
emptyList()
|
emptyList()
|
||||||
@ -207,10 +203,6 @@ fun SearchScreen(viewModel: SearchScreenViewModel = koinViewModel()) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
|
||||||
currentUserId = libraryRepository.currentUser()?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
|
|
||||||
fun playSingleTrack(track: MetadataTrack) {
|
fun playSingleTrack(track: MetadataTrack) {
|
||||||
|
|||||||
@ -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.track.MetadataTrack
|
||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.user.MetadataUser
|
||||||
import org.spotube.spotube.core.db.Database
|
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 org.spotube.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@ -81,7 +84,8 @@ data class SearchScreenState(
|
|||||||
class SearchScreenViewModel(
|
class SearchScreenViewModel(
|
||||||
private val repository: SearchRepository,
|
private val repository: SearchRepository,
|
||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val database: Database) : ViewModel() {
|
private val libraryRepository: LibraryRepository,
|
||||||
|
private val database: Database) : ViewModel(), KoinComponent {
|
||||||
companion object {
|
companion object {
|
||||||
private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches")
|
private val RECENT_SEARCHES_KEY = stringPreferencesKey("recent_searches")
|
||||||
private val SEARCH_TYPES_ORDER = listOf(
|
private val SEARCH_TYPES_ORDER = listOf(
|
||||||
@ -97,11 +101,16 @@ class SearchScreenViewModel(
|
|||||||
private const val ALL_ROW_PAGE_SIZE = 12
|
private const val ALL_ROW_PAGE_SIZE = 12
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val logger by injectLogger<SearchScreenViewModel>()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
private val _state = MutableStateFlow(SearchScreenState())
|
private val _state = MutableStateFlow(SearchScreenState())
|
||||||
val state: StateFlow<SearchScreenState> = _state.asStateFlow()
|
val state: StateFlow<SearchScreenState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
private val _currentUserId = MutableStateFlow<String?>(null)
|
||||||
|
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
|
||||||
|
|
||||||
private var queryJob: Job? = null
|
private var queryJob: Job? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -113,6 +122,15 @@ class SearchScreenViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadSupportedSearchTypes()
|
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) {
|
fun onQueryChange(query: String) {
|
||||||
|
|||||||
@ -98,9 +98,9 @@ data class UserSettings(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class JamBroker(
|
data class JamBroker(
|
||||||
val name: String = "",
|
val name: String = "",
|
||||||
val host: String = "test.mosquitto.org",
|
val host: String = "broker.hivemq.com",
|
||||||
val port: Int = 1883,
|
val port: Int = 8883,
|
||||||
val useTls: Boolean = false,
|
val useTls: Boolean = true,
|
||||||
val username: String? = null,
|
val username: String? = null,
|
||||||
val password: String? = null,
|
val password: String? = null,
|
||||||
val clientIdPrefix: String = "spotube",
|
val clientIdPrefix: String = "spotube",
|
||||||
|
|||||||
@ -0,0 +1,287 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusable
|
||||||
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawing
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.widthIn
|
||||||
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.draw.clipToBounds
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.input.key.Key
|
||||||
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
|
import androidx.compose.ui.input.key.key
|
||||||
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
|
import androidx.compose.ui.input.key.type
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import coil3.compose.AsyncImage
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||||
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
|
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||||
|
import org.spotube.spotube.core.ui.component.WindowFullscreenButton
|
||||||
|
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||||
|
import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
|
||||||
|
import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxArrowDown4
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxMusic
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.player_fullscreen_close_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_lyrics
|
||||||
|
import spotube.composeapp.generated.resources.queue_title
|
||||||
|
|
||||||
|
// The "now playing" view on large screens, opened from the large player. It covers the whole
|
||||||
|
// window with the album art in the middle over a blurred copy of it, like the expanded player
|
||||||
|
// does on small screens.
|
||||||
|
//
|
||||||
|
// The top bar has no title, so it stays clear of the macOS window buttons, and on the right it
|
||||||
|
// has the window full screen toggle and the button to close the view (Esc too)
|
||||||
|
// Below the album art the song title and artists are shown with the like button on the right
|
||||||
|
// Then the progress bar, and below it the queue and lyrics buttons on the left, the playback
|
||||||
|
// controls in the center and the volume slider on the right
|
||||||
|
@Composable
|
||||||
|
fun AppFullscreenPlayer(
|
||||||
|
onClose: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
onQueue: () -> Unit = {},
|
||||||
|
onLyrics: () -> Unit = {},
|
||||||
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
|
savedTracksViewModel: SavedTracksViewModel = koinViewModel<SavedTracksViewModel>(
|
||||||
|
key = SAVED_TRACKS_COLLECTION_ID,
|
||||||
|
parameters = { parametersOf() }
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val background = AppTheme.colors.background
|
||||||
|
|
||||||
|
// Takes the keyboard focus so Esc reaches this view
|
||||||
|
LaunchedEffect(focusRequester) {
|
||||||
|
focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.clipToBounds()
|
||||||
|
.background(background)
|
||||||
|
// Keeps clicks from reaching the screen underneath
|
||||||
|
.clickable(
|
||||||
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
|
indication = null,
|
||||||
|
onClick = {})
|
||||||
|
.onPreviewKeyEvent { event ->
|
||||||
|
if (event.type == KeyEventType.KeyDown && event.key == Key.Escape) {
|
||||||
|
onClose()
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
.focusable()
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = playerUiState.coverModel,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.blur(96.dp, BlurredEdgeTreatment.Rectangle),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
// Tones the art down towards the theme background, so the text stays readable in light
|
||||||
|
// and dark themes alike
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(
|
||||||
|
Brush.verticalGradient(
|
||||||
|
listOf(background.copy(alpha = 0.45f), background.copy(alpha = 0.9f))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.windowInsetsPadding(WindowInsets.safeDrawing),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
ApplicationMainBar(
|
||||||
|
actions = {
|
||||||
|
WindowFullscreenButton()
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onClose,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
Iconsax.IconsaxArrowDown4,
|
||||||
|
contentDescription = stringResource(Res.string.player_fullscreen_close_content_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
backButton = false,
|
||||||
|
transparent = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 48.dp, vertical = 16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 640.dp)
|
||||||
|
.aspectRatio(1f, matchHeightConstraintsFirst = true)
|
||||||
|
.shadow(elevation = 24.dp, shape = RoundedCornerShape(16.dp))
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(AppTheme.colors.surfaceContainerHighest)
|
||||||
|
) {
|
||||||
|
AsyncImage(
|
||||||
|
model = playerUiState.coverModel,
|
||||||
|
contentDescription = playerUiState.title,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 720.dp)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 32.dp, end = 32.dp, bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = playerUiState.title,
|
||||||
|
style = AppTheme.typography.headlineSmall,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
PlayerArtistsText(
|
||||||
|
artists = playerUiState.artistList,
|
||||||
|
onArtistClick = { artistId ->
|
||||||
|
navigationCommands.navigateTo(Routes.Artist(artistId))
|
||||||
|
// The page opens beneath this view, so get out of the way
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
style = AppTheme.typography.bodyLarge,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PlayerHeartButton(
|
||||||
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
|
savedTracksViewModel = savedTracksViewModel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerSeekBar(
|
||||||
|
playerUiState = playerUiState,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 20.dp),
|
||||||
|
audioPlayer = audioPlayer
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(top = 12.dp)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.align(Alignment.CenterStart),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onQueue,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(Iconsax.IconsaxMusicFilter, contentDescription = stringResource(Res.string.queue_title))
|
||||||
|
}
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onLyrics,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(Iconsax.IconsaxMusic, contentDescription = stringResource(Res.string.player_lyrics))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
PlayerTransportControls(
|
||||||
|
playerUiState = playerUiState,
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
playIconSize = 30.dp,
|
||||||
|
audioPlayer = audioPlayer
|
||||||
|
)
|
||||||
|
PlayerVolumeControl(
|
||||||
|
playerUiState = playerUiState,
|
||||||
|
modifier = Modifier.align(Alignment.CenterEnd),
|
||||||
|
sliderModifier = Modifier.width(120.dp),
|
||||||
|
audioPlayer = audioPlayer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -38,13 +38,7 @@ import androidx.compose.foundation.layout.width
|
|||||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -55,17 +49,13 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.composeunstyled.AnchorSide
|
import com.composeunstyled.AnchorSide
|
||||||
import com.composeunstyled.Text
|
import com.composeunstyled.Text
|
||||||
import com.composeunstyled.LocalTextStyle
|
|
||||||
import dev.chrisbanes.haze.HazeState
|
import dev.chrisbanes.haze.HazeState
|
||||||
import dev.chrisbanes.haze.blur.HazeColorEffect
|
import dev.chrisbanes.haze.blur.HazeColorEffect
|
||||||
import dev.chrisbanes.haze.blur.blurEffect
|
import dev.chrisbanes.haze.blur.blurEffect
|
||||||
import dev.chrisbanes.haze.hazeEffect
|
import dev.chrisbanes.haze.hazeEffect
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
import org.spotube.spotube.core.audioplayer.LoopState
|
|
||||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||||
import org.spotube.spotube.core.jam.JamRole
|
|
||||||
import org.spotube.spotube.core.jam.JamRoomService
|
|
||||||
import org.spotube.spotube.core.navigation.NavigationCommands
|
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||||
import org.spotube.spotube.core.navigation.Routes
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
import org.spotube.spotube.core.ui.theming.AppTheme
|
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||||
@ -76,22 +66,10 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksViewModel
|
|||||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore
|
import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive
|
import org.spotube.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxMusic
|
import org.spotube.spotube.resources.iconsax.IconsaxMusic
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
|
import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxNext
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxPause
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxPrevious
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxShuffle
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh
|
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow
|
|
||||||
import org.spotube.spotube.resources.iconsax.SwapHorizontal2
|
import org.spotube.spotube.resources.iconsax.SwapHorizontal2
|
||||||
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.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@ -101,29 +79,15 @@ import org.spotube.millennium.composables.MillenniumIcon
|
|||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import org.spotube.millennium.composables.MillenniumSeparator
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import org.spotube.millennium.composables.MillenniumSlider
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
import spotube.composeapp.generated.resources.player_go_to_album
|
import spotube.composeapp.generated.resources.player_go_to_album
|
||||||
import spotube.composeapp.generated.resources.player_sleep_timer
|
import spotube.composeapp.generated.resources.player_sleep_timer
|
||||||
import spotube.composeapp.generated.resources.player_track_details
|
import spotube.composeapp.generated.resources.player_track_details
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
|
||||||
import spotube.composeapp.generated.resources.player_alternative_source_content_description
|
import spotube.composeapp.generated.resources.player_alternative_source_content_description
|
||||||
import spotube.composeapp.generated.resources.player_disable_shuffle_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_download
|
import spotube.composeapp.generated.resources.player_download
|
||||||
import spotube.composeapp.generated.resources.player_enable_shuffle_content_description
|
import spotube.composeapp.generated.resources.player_fullscreen_open_content_description
|
||||||
import spotube.composeapp.generated.resources.player_loop_mode_all
|
|
||||||
import spotube.composeapp.generated.resources.player_loop_mode_none
|
|
||||||
import spotube.composeapp.generated.resources.player_loop_mode_one
|
|
||||||
import spotube.composeapp.generated.resources.player_lyrics
|
import spotube.composeapp.generated.resources.player_lyrics
|
||||||
import spotube.composeapp.generated.resources.player_more_options_content_description
|
import spotube.composeapp.generated.resources.player_more_options_content_description
|
||||||
import spotube.composeapp.generated.resources.player_mute_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_next_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_play_or_pause_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_previous_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_seek_label
|
|
||||||
import spotube.composeapp.generated.resources.player_unmute_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_volume_label
|
|
||||||
import spotube.composeapp.generated.resources.queue_title
|
import spotube.composeapp.generated.resources.queue_title
|
||||||
|
|
||||||
// The main player on large screens shown at the bottom of the screen permanently. It should be
|
// The main player on large screens shown at the bottom of the screen permanently. It should be
|
||||||
@ -134,6 +98,7 @@ import spotube.composeapp.generated.resources.queue_title
|
|||||||
// On the center it contains the playback controls and the progress bar
|
// On the center it contains the playback controls and the progress bar
|
||||||
// On the right it contains the queue btn, download button btn, alternative track source button
|
// On the right it contains the queue btn, download button btn, alternative track source button
|
||||||
// and three dots for options grouped together and below these buttons the volume slider will be shown
|
// and three dots for options grouped together and below these buttons the volume slider will be shown
|
||||||
|
// next to the button that opens the full screen player (AppFullscreenPlayer)
|
||||||
@Composable
|
@Composable
|
||||||
fun AppLargePlayer(
|
fun AppLargePlayer(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@ -142,6 +107,7 @@ fun AppLargePlayer(
|
|||||||
onAlternativeSource: () -> Unit = {},
|
onAlternativeSource: () -> Unit = {},
|
||||||
onMoreOptions: () -> Unit = {},
|
onMoreOptions: () -> Unit = {},
|
||||||
onLyrics: () -> Unit = {},
|
onLyrics: () -> Unit = {},
|
||||||
|
onFullscreen: () -> Unit = {},
|
||||||
audioPlayer: AudioPlayerInterface = koinInject(),
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
downloadsViewModel: DownloadsViewModel = koinViewModel(),
|
downloadsViewModel: DownloadsViewModel = koinViewModel(),
|
||||||
@ -152,77 +118,14 @@ fun AppLargePlayer(
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
val playerUiState = rememberPlayerUiState(audioPlayer, audioPlayerQueue)
|
||||||
val jamRoomService: JamRoomService = koinInject()
|
|
||||||
val isJamGuest by jamRoomService.role
|
|
||||||
.map { it == JamRole.Guest }
|
|
||||||
.collectAsStateWithLifecycle(initialValue = false)
|
|
||||||
val scope = rememberCoroutineScope()
|
|
||||||
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
val currentEntry by audioPlayerQueue.currentQueueEntryFlow.collectAsStateWithLifecycle()
|
||||||
val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
val currentTrack = (currentEntry as? QueueEntry.StreamingTrack)?.track
|
||||||
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
|
val playerOptionsUiState by playerOptionsViewModel.uiState.collectAsStateWithLifecycle()
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
var isSeeking by remember { mutableStateOf(false) }
|
|
||||||
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
|
||||||
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
|
|
||||||
val coverModel = playerUiState.coverModel
|
val coverModel = playerUiState.coverModel
|
||||||
|
|
||||||
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
|
PlayerOptionDialogs(viewModel = playerOptionsViewModel)
|
||||||
|
|
||||||
LaunchedEffect(playerUiState.progress, isSeeking) {
|
|
||||||
if (!isSeeking) {
|
|
||||||
seekProgress = playerUiState.progress
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(playerUiState.volume) {
|
|
||||||
if (playerUiState.volume > 0f) {
|
|
||||||
lastNonZeroVolume = playerUiState.volume
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onPlayPause() {
|
|
||||||
scope.launch {
|
|
||||||
if (playerUiState.isPlaying) {
|
|
||||||
audioPlayer.pause()
|
|
||||||
} else {
|
|
||||||
audioPlayer.play()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSkipPrevious() {
|
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToPrevious() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSkipNext() {
|
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.skipToNext() }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onShuffleToggle() {
|
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onLoopToggle() {
|
|
||||||
if (isJamGuest) return
|
|
||||||
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onSeekFinished() {
|
|
||||||
val durationMillis = playerUiState.seekDuration.inWholeMilliseconds
|
|
||||||
if (durationMillis <= 0L) return
|
|
||||||
scope.launch {
|
|
||||||
audioPlayer.seekTo(
|
|
||||||
(durationMillis * seekProgress.coerceIn(
|
|
||||||
0f,
|
|
||||||
1f
|
|
||||||
)).toLong().milliseconds
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A translucent tint (not near-opaque) so the blurred art behind still reads through, and
|
// A translucent tint (not near-opaque) so the blurred art behind still reads through, and
|
||||||
// the material's watery caustics sit on top of it.
|
// the material's watery caustics sit on top of it.
|
||||||
val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f)
|
val surfaceTint = AppTheme.colors.surface.copy(alpha = 0.6f)
|
||||||
@ -299,101 +202,17 @@ fun AppLargePlayer(
|
|||||||
modifier = Modifier.weight(1.1f),
|
modifier = Modifier.weight(1.1f),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
) {
|
) {
|
||||||
MillenniumSlider(
|
PlayerSeekBar(
|
||||||
value = seekProgress,
|
playerUiState = playerUiState,
|
||||||
onValueChange = {
|
|
||||||
seekProgress = it
|
|
||||||
isSeeking = true
|
|
||||||
},
|
|
||||||
onValueChangeFinished = {
|
|
||||||
isSeeking = false
|
|
||||||
onSeekFinished()
|
|
||||||
},
|
|
||||||
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
|
||||||
label = stringResource(Res.string.player_seek_label),
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
audioPlayer = audioPlayer
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = formatPlayerTime(playerUiState.position),
|
|
||||||
style = AppTheme.typography.labelSmall,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
Text(
|
PlayerTransportControls(
|
||||||
text = formatPlayerTime(playerUiState.displayDuration),
|
playerUiState = playerUiState,
|
||||||
style = AppTheme.typography.labelSmall,
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = AppTheme.colors.onSurfaceVariant
|
audioPlayer = audioPlayer
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = ::onShuffleToggle,
|
|
||||||
enabled = !isJamGuest,
|
|
||||||
style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet
|
|
||||||
) {
|
|
||||||
MillenniumIcon(
|
|
||||||
Iconsax.IconsaxShuffle,
|
|
||||||
contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description),
|
|
||||||
tint = if (playerUiState.isShuffling) {
|
|
||||||
AppTheme.colors.primary
|
|
||||||
} else {
|
|
||||||
AppTheme.colors.onSurfaceVariant
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = ::onSkipPrevious,
|
|
||||||
enabled = !isJamGuest,
|
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
|
||||||
) {
|
|
||||||
MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description))
|
|
||||||
}
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = ::onPlayPause,
|
|
||||||
style = MillenniumIconButtonStyle.Primary
|
|
||||||
) {
|
|
||||||
MillenniumIcon(
|
|
||||||
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
|
||||||
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description),
|
|
||||||
tint = LocalTextStyle.current.color
|
|
||||||
)
|
|
||||||
}
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = ::onSkipNext,
|
|
||||||
enabled = !isJamGuest,
|
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
|
||||||
) {
|
|
||||||
MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description))
|
|
||||||
}
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = ::onLoopToggle,
|
|
||||||
enabled = !isJamGuest,
|
|
||||||
style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary
|
|
||||||
) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = when (playerUiState.loopState) {
|
|
||||||
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
|
||||||
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
|
||||||
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
|
||||||
},
|
|
||||||
contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }),
|
|
||||||
tint = if (playerUiState.loopState == LoopState.NONE) {
|
|
||||||
AppTheme.colors.onSurfaceVariant
|
|
||||||
} else {
|
|
||||||
AppTheme.colors.primary
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@ -481,40 +300,19 @@ fun AppLargePlayer(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
PlayerVolumeControl(
|
||||||
|
playerUiState = playerUiState,
|
||||||
|
sliderModifier = Modifier.fillMaxWidth(0.62f),
|
||||||
|
audioPlayer = audioPlayer
|
||||||
|
)
|
||||||
MillenniumIconButton(
|
MillenniumIconButton(
|
||||||
style = MillenniumIconButtonStyle.Quiet,
|
style = MillenniumIconButtonStyle.Quiet, onClick = onFullscreen
|
||||||
onClick = {
|
|
||||||
scope.launch {
|
|
||||||
if (playerUiState.volume <= 0f) {
|
|
||||||
audioPlayer.setVolume(lastNonZeroVolume)
|
|
||||||
} else {
|
|
||||||
lastNonZeroVolume = playerUiState.volume
|
|
||||||
audioPlayer.setVolume(0f)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
) {
|
) {
|
||||||
val volumeIcon = when {
|
|
||||||
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
|
||||||
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
|
||||||
else -> Iconsax.IconsaxVolumeHigh
|
|
||||||
}
|
|
||||||
MillenniumIcon(
|
MillenniumIcon(
|
||||||
imageVector = volumeIcon,
|
Iconsax.IconsaxFullscreen,
|
||||||
contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description)
|
contentDescription = stringResource(Res.string.player_fullscreen_open_content_description)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MillenniumSlider(
|
|
||||||
value = playerUiState.volume,
|
|
||||||
onValueChange = {
|
|
||||||
lastNonZeroVolume = it
|
|
||||||
scope.launch {
|
|
||||||
audioPlayer.setVolume(it)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
label = stringResource(Res.string.player_volume_label),
|
|
||||||
modifier = Modifier.fillMaxWidth(0.62f)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,7 +17,12 @@
|
|||||||
|
|
||||||
package org.spotube.spotube.modules.shell
|
package org.spotube.spotube.modules.shell
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.slideInVertically
|
||||||
|
import androidx.compose.animation.slideOutVertically
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
@ -32,17 +37,23 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.navigationBars
|
import androidx.compose.foundation.layout.navigationBars
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
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.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.layout.layout
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@ -98,6 +109,7 @@ fun AppShell(
|
|||||||
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
val isQueueVisible by queueViewModel.isQueueVisible.collectAsState()
|
||||||
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
val isAlternativeVisible by alternativeViewModel.isAlternativeVisible.collectAsState()
|
||||||
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
val isLyricsOverlayVisible by viewModel.isLyricsOverlayVisible.collectAsState()
|
||||||
|
val isFullscreenPlayerVisible by viewModel.isFullscreenPlayerVisible.collectAsState()
|
||||||
|
|
||||||
LaunchedEffect(navigatorCommands, navigator) {
|
LaunchedEffect(navigatorCommands, navigator) {
|
||||||
launch {
|
launch {
|
||||||
@ -171,7 +183,25 @@ fun AppShell(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onQueue = queueViewModel::toggleQueueVisibility,
|
onQueue = queueViewModel::toggleQueueVisibility,
|
||||||
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
onAlternativeSource = alternativeViewModel::toggleAlternativeVisibility,
|
||||||
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) })
|
onLyrics = { navigatorCommands.navigateTo(Routes.Lyrics) },
|
||||||
|
onFullscreen = viewModel::showFullscreenPlayer)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Above the content and the large player, but below the queue and
|
||||||
|
// alternative source sheets so those can still be opened from it
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isFullscreenPlayerVisible,
|
||||||
|
enter = fadeIn() + slideInVertically { it / 8 },
|
||||||
|
exit = fadeOut() + slideOutVertically { it / 8 }
|
||||||
|
) {
|
||||||
|
AppFullscreenPlayer(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
onClose = viewModel::hideFullscreenPlayer,
|
||||||
|
onQueue = queueViewModel::toggleQueueVisibility,
|
||||||
|
onLyrics = {
|
||||||
|
viewModel.hideFullscreenPlayer()
|
||||||
|
navigatorCommands.navigateTo(Routes.Lyrics)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQueueVisible) {
|
if (isQueueVisible) {
|
||||||
@ -299,6 +329,15 @@ internal fun CompactPlayerOverlay(
|
|||||||
onAlternativeSource: () -> Unit,
|
onAlternativeSource: () -> Unit,
|
||||||
onExpandLyrics: () -> Unit,
|
onExpandLyrics: () -> Unit,
|
||||||
floatingPlayer: @Composable (Modifier) -> Unit = { AppFloatingPlayer(modifier = it) },
|
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 = {
|
bottomBar: @Composable (Modifier) -> Unit = {
|
||||||
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
AppBottombar(navigator = navigator, navigationState = navigationState, modifier = it)
|
||||||
},
|
},
|
||||||
@ -325,16 +364,39 @@ internal fun CompactPlayerOverlay(
|
|||||||
val sheetState = key(peekHeight) {
|
val sheetState = key(peekHeight) {
|
||||||
rememberBottomSheetState(
|
rememberBottomSheetState(
|
||||||
initialDetent = partialDetent,
|
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() ||
|
// 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)
|
(sheetState.isIdle && sheetState.currentDetent == partialDetent)
|
||||||
) 0f else runCatching {
|
) 0f else runCatching {
|
||||||
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
sheetState.progress(partialDetent, SheetDetent.FullyExpanded)
|
||||||
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
}.getOrDefault(0f).takeIf { it.isFinite() } ?: 0f
|
||||||
val uiState = viewModel.compactSheetUiState(progress.coerceIn(0f, 1f))
|
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()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
content()
|
content()
|
||||||
@ -345,44 +407,52 @@ internal fun CompactPlayerOverlay(
|
|||||||
) {
|
) {
|
||||||
Sheet(modifier = Modifier.fillMaxSize()) {
|
Sheet(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
if (uiState.showExpandedPlayer) {
|
if (expandedPlayerComposed) {
|
||||||
AppExpandedPlayer(
|
expandedPlayer(
|
||||||
modifier = Modifier
|
Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.graphicsLayer { alpha = uiState.expandedPlayerAlpha },
|
.graphicsLayer {
|
||||||
onCollapse = {
|
alpha = viewModel.compactSheetUiState(progress).expandedPlayerAlpha
|
||||||
scope.launch { sheetState.animateTo(partialDetent) }
|
|
||||||
},
|
},
|
||||||
onQueue = onQueue,
|
{ scope.launch { sheetState.animateTo(partialDetent) } }
|
||||||
onAlternativeSource = onAlternativeSource,
|
|
||||||
onExpandLyrics = onExpandLyrics
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
// 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()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
floatingPlayer(
|
floatingPlayer(
|
||||||
Modifier
|
Modifier
|
||||||
|
.zeroSizeUnless(floatingPlayerVisible)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = uiState.floatingPlayerAlpha
|
alpha = viewModel.floatingPlayerAlpha(progress)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (uiState.floatingPlayerAlpha > 0.01f) {
|
|
||||||
bottomBar(
|
bottomBar(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.align(Alignment.BottomCenter)
|
.align(Alignment.BottomCenter)
|
||||||
|
.zeroSizeUnless(floatingPlayerVisible)
|
||||||
.onSizeChanged { size ->
|
.onSizeChanged { size ->
|
||||||
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
viewModel.onBottomBarMeasured(with(density) { size.height.toDp() })
|
||||||
}
|
}
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = uiState.floatingPlayerAlpha
|
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) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -54,6 +54,7 @@ class AppShellViewModel : ViewModel() {
|
|||||||
val expandedPlayerVisibilityThreshold = 0.001f
|
val expandedPlayerVisibilityThreshold = 0.001f
|
||||||
|
|
||||||
private val lyricsOverlayVisible = MutableStateFlow(false)
|
private val lyricsOverlayVisible = MutableStateFlow(false)
|
||||||
|
private val fullscreenPlayerVisible = MutableStateFlow(false)
|
||||||
private val compactChrome = MutableStateFlow(CompactChromeUiState())
|
private val compactChrome = MutableStateFlow(CompactChromeUiState())
|
||||||
val compactChromeUiState = compactChrome.asStateFlow()
|
val compactChromeUiState = compactChrome.asStateFlow()
|
||||||
|
|
||||||
@ -62,6 +63,7 @@ class AppShellViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
|
val isLyricsOverlayVisible: StateFlow<Boolean> = lyricsOverlayVisible.asStateFlow()
|
||||||
|
val isFullscreenPlayerVisible: StateFlow<Boolean> = fullscreenPlayerVisible.asStateFlow()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun useSidebar(maxWidth: Dp? = null): Boolean {
|
fun useSidebar(maxWidth: Dp? = null): Boolean {
|
||||||
@ -113,4 +115,12 @@ class AppShellViewModel : ViewModel() {
|
|||||||
fun toggleLyricsOverlay() {
|
fun toggleLyricsOverlay() {
|
||||||
lyricsOverlayVisible.update { !it }
|
lyricsOverlayVisible.update { !it }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun showFullscreenPlayer() {
|
||||||
|
fullscreenPlayerVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
fun hideFullscreenPlayer() {
|
||||||
|
fullscreenPlayerVisible.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,312 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.shell
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.composeunstyled.LocalTextStyle
|
||||||
|
import com.composeunstyled.Text
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.koin.compose.koinInject
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumSlider
|
||||||
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
|
import org.spotube.spotube.core.audioplayer.LoopState
|
||||||
|
import org.spotube.spotube.core.jam.JamRole
|
||||||
|
import org.spotube.spotube.core.jam.JamRoomService
|
||||||
|
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxNext
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxPause
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxPlay
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxPrevious
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxShuffle
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.player_disable_shuffle_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_enable_shuffle_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_loop_mode_all
|
||||||
|
import spotube.composeapp.generated.resources.player_loop_mode_none
|
||||||
|
import spotube.composeapp.generated.resources.player_loop_mode_one
|
||||||
|
import spotube.composeapp.generated.resources.player_mute_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_next_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_pause_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_play_or_pause_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_previous_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_seek_label
|
||||||
|
import spotube.composeapp.generated.resources.player_unmute_content_description
|
||||||
|
import spotube.composeapp.generated.resources.player_volume_label
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
// Playback controls shared by the large player and the full screen player, so both drive the
|
||||||
|
// audio player the same way.
|
||||||
|
|
||||||
|
// The seek slider with the elapsed and total time below it
|
||||||
|
@Composable
|
||||||
|
internal fun PlayerSeekBar(
|
||||||
|
playerUiState: PlayerUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
audioPlayer: AudioPlayerInterface = koinInject()
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var isSeeking by remember { mutableStateOf(false) }
|
||||||
|
var seekProgress by remember { mutableFloatStateOf(playerUiState.progress) }
|
||||||
|
|
||||||
|
LaunchedEffect(playerUiState.progress, isSeeking) {
|
||||||
|
if (!isSeeking) {
|
||||||
|
seekProgress = playerUiState.progress
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSeekFinished() {
|
||||||
|
val durationMillis = playerUiState.seekDuration.inWholeMilliseconds
|
||||||
|
if (durationMillis <= 0L) return
|
||||||
|
scope.launch {
|
||||||
|
audioPlayer.seekTo(
|
||||||
|
(durationMillis * seekProgress.coerceIn(
|
||||||
|
0f,
|
||||||
|
1f
|
||||||
|
)).toLong().milliseconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = modifier) {
|
||||||
|
MillenniumSlider(
|
||||||
|
value = seekProgress,
|
||||||
|
onValueChange = {
|
||||||
|
seekProgress = it
|
||||||
|
isSeeking = true
|
||||||
|
},
|
||||||
|
onValueChangeFinished = {
|
||||||
|
isSeeking = false
|
||||||
|
onSeekFinished()
|
||||||
|
},
|
||||||
|
enabled = playerUiState.seekDuration.inWholeMilliseconds > 0L,
|
||||||
|
label = stringResource(Res.string.player_seek_label),
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = formatPlayerTime(playerUiState.position),
|
||||||
|
style = AppTheme.typography.labelSmall,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatPlayerTime(playerUiState.displayDuration),
|
||||||
|
style = AppTheme.typography.labelSmall,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shuffle, previous, play/pause, next and loop. Jam guests can only play/pause.
|
||||||
|
@Composable
|
||||||
|
internal fun PlayerTransportControls(
|
||||||
|
playerUiState: PlayerUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
horizontalArrangement: Arrangement.Horizontal = Arrangement.SpaceBetween,
|
||||||
|
playIconSize: Dp = Dp.Unspecified,
|
||||||
|
audioPlayer: AudioPlayerInterface = koinInject(),
|
||||||
|
jamRoomService: JamRoomService = koinInject()
|
||||||
|
) {
|
||||||
|
val isJamGuest by jamRoomService.role
|
||||||
|
.map { it == JamRole.Guest }
|
||||||
|
.collectAsStateWithLifecycle(initialValue = false)
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
fun onPlayPause() {
|
||||||
|
scope.launch {
|
||||||
|
if (playerUiState.isPlaying) {
|
||||||
|
audioPlayer.pause()
|
||||||
|
} else {
|
||||||
|
audioPlayer.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSkipPrevious() {
|
||||||
|
if (isJamGuest) return
|
||||||
|
scope.launch { audioPlayer.skipToPrevious() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSkipNext() {
|
||||||
|
if (isJamGuest) return
|
||||||
|
scope.launch { audioPlayer.skipToNext() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onShuffleToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
|
scope.launch { audioPlayer.shuffle(!playerUiState.isShuffling) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onLoopToggle() {
|
||||||
|
if (isJamGuest) return
|
||||||
|
scope.launch { audioPlayer.loop(playerUiState.loopState.next()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = horizontalArrangement,
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = ::onShuffleToggle,
|
||||||
|
enabled = !isJamGuest,
|
||||||
|
style = if (playerUiState.isShuffling) MillenniumIconButtonStyle.Secondary else MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
Iconsax.IconsaxShuffle,
|
||||||
|
contentDescription = stringResource(if (playerUiState.isShuffling) Res.string.player_disable_shuffle_content_description else Res.string.player_enable_shuffle_content_description),
|
||||||
|
tint = if (playerUiState.isShuffling) {
|
||||||
|
AppTheme.colors.primary
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = ::onSkipPrevious,
|
||||||
|
enabled = !isJamGuest,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(Iconsax.IconsaxPrevious, contentDescription = stringResource(Res.string.player_previous_content_description))
|
||||||
|
}
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = ::onPlayPause,
|
||||||
|
style = MillenniumIconButtonStyle.Primary
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
if (playerUiState.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
|
contentDescription = stringResource(if (playerUiState.isPlaying) Res.string.player_pause_content_description else Res.string.player_play_or_pause_content_description),
|
||||||
|
modifier = if (playIconSize == Dp.Unspecified) Modifier else Modifier.size(playIconSize),
|
||||||
|
tint = LocalTextStyle.current.color
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = ::onSkipNext,
|
||||||
|
enabled = !isJamGuest,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(Iconsax.IconsaxNext, contentDescription = stringResource(Res.string.player_next_content_description))
|
||||||
|
}
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = ::onLoopToggle,
|
||||||
|
enabled = !isJamGuest,
|
||||||
|
style = if (playerUiState.loopState == LoopState.NONE) MillenniumIconButtonStyle.Quiet else MillenniumIconButtonStyle.Secondary
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = when (playerUiState.loopState) {
|
||||||
|
LoopState.NONE -> Iconsax.IconsaxRepeateMusic
|
||||||
|
LoopState.ONE -> Iconsax.IconsaxRepeateOne
|
||||||
|
LoopState.ALL -> Iconsax.IconsaxRepeatMusic
|
||||||
|
},
|
||||||
|
contentDescription = stringResource(when (playerUiState.loopState) { LoopState.NONE -> Res.string.player_loop_mode_none; LoopState.ONE -> Res.string.player_loop_mode_one; LoopState.ALL -> Res.string.player_loop_mode_all }),
|
||||||
|
tint = if (playerUiState.loopState == LoopState.NONE) {
|
||||||
|
AppTheme.colors.onSurfaceVariant
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.primary
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The mute toggle followed by the volume slider. Unmuting restores the last non-zero volume.
|
||||||
|
@Composable
|
||||||
|
internal fun PlayerVolumeControl(
|
||||||
|
playerUiState: PlayerUiState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
sliderModifier: Modifier = Modifier,
|
||||||
|
audioPlayer: AudioPlayerInterface = koinInject()
|
||||||
|
) {
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var lastNonZeroVolume by remember { mutableFloatStateOf(if (playerUiState.volume > 0f) playerUiState.volume else 0.6f) }
|
||||||
|
|
||||||
|
LaunchedEffect(playerUiState.volume) {
|
||||||
|
if (playerUiState.volume > 0f) {
|
||||||
|
lastNonZeroVolume = playerUiState.volume
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(modifier = modifier, verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
if (playerUiState.volume <= 0f) {
|
||||||
|
audioPlayer.setVolume(lastNonZeroVolume)
|
||||||
|
} else {
|
||||||
|
lastNonZeroVolume = playerUiState.volume
|
||||||
|
audioPlayer.setVolume(0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
val volumeIcon = when {
|
||||||
|
playerUiState.volume <= 0f -> Iconsax.IconsaxVolumeCross
|
||||||
|
playerUiState.volume < 0.5f -> Iconsax.IconsaxVolumeLow
|
||||||
|
else -> Iconsax.IconsaxVolumeHigh
|
||||||
|
}
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = volumeIcon,
|
||||||
|
contentDescription = stringResource(if (playerUiState.volume <= 0f) Res.string.player_unmute_content_description else Res.string.player_mute_content_description)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
MillenniumSlider(
|
||||||
|
value = playerUiState.volume,
|
||||||
|
onValueChange = {
|
||||||
|
lastNonZeroVolume = it
|
||||||
|
scope.launch {
|
||||||
|
audioPlayer.setVolume(it)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
label = stringResource(Res.string.player_volume_label),
|
||||||
|
modifier = sliderModifier
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.IconsaxFullscreen: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_IconsaxFullscreen != null) {
|
||||||
|
return _IconsaxFullscreen!!
|
||||||
|
}
|
||||||
|
_IconsaxFullscreen = ImageVector.Builder(
|
||||||
|
name = "IconsaxFullscreen",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(3f, 9f)
|
||||||
|
verticalLineTo(5f)
|
||||||
|
curveTo(3f, 3.9f, 3.9f, 3f, 5f, 3f)
|
||||||
|
horizontalLineTo(9f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(15f, 3f)
|
||||||
|
horizontalLineTo(19f)
|
||||||
|
curveTo(20.1f, 3f, 21f, 3.9f, 21f, 5f)
|
||||||
|
verticalLineTo(9f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(21f, 15f)
|
||||||
|
verticalLineTo(19f)
|
||||||
|
curveTo(21f, 20.1f, 20.1f, 21f, 19f, 21f)
|
||||||
|
horizontalLineTo(15f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(9f, 21f)
|
||||||
|
horizontalLineTo(5f)
|
||||||
|
curveTo(3.9f, 21f, 3f, 20.1f, 3f, 19f)
|
||||||
|
verticalLineTo(15f)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _IconsaxFullscreen!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _IconsaxFullscreen: ImageVector? = null
|
||||||
@ -0,0 +1,90 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.resources.iconsax
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.SolidColor
|
||||||
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
|
import androidx.compose.ui.graphics.StrokeJoin
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.graphics.vector.path
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
val Iconsax.IconsaxFullscreenExit: ImageVector
|
||||||
|
get() {
|
||||||
|
if (_IconsaxFullscreenExit != null) {
|
||||||
|
return _IconsaxFullscreenExit!!
|
||||||
|
}
|
||||||
|
_IconsaxFullscreenExit = ImageVector.Builder(
|
||||||
|
name = "IconsaxFullscreenExit",
|
||||||
|
defaultWidth = 24.dp,
|
||||||
|
defaultHeight = 24.dp,
|
||||||
|
viewportWidth = 24f,
|
||||||
|
viewportHeight = 24f
|
||||||
|
).apply {
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(9f, 3f)
|
||||||
|
verticalLineTo(7f)
|
||||||
|
curveTo(9f, 8.1f, 8.1f, 9f, 7f, 9f)
|
||||||
|
horizontalLineTo(3f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(21f, 9f)
|
||||||
|
horizontalLineTo(17f)
|
||||||
|
curveTo(15.9f, 9f, 15f, 8.1f, 15f, 7f)
|
||||||
|
verticalLineTo(3f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(15f, 21f)
|
||||||
|
verticalLineTo(17f)
|
||||||
|
curveTo(15f, 15.9f, 15.9f, 15f, 17f, 15f)
|
||||||
|
horizontalLineTo(21f)
|
||||||
|
}
|
||||||
|
path(
|
||||||
|
stroke = SolidColor(Color.White),
|
||||||
|
strokeLineWidth = 1.5f,
|
||||||
|
strokeLineCap = StrokeCap.Round,
|
||||||
|
strokeLineJoin = StrokeJoin.Round
|
||||||
|
) {
|
||||||
|
moveTo(3f, 15f)
|
||||||
|
horizontalLineTo(7f)
|
||||||
|
curveTo(8.1f, 15f, 9f, 15.9f, 9f, 17f)
|
||||||
|
verticalLineTo(21f)
|
||||||
|
}
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
return _IconsaxFullscreenExit!!
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("ObjectPropertyName")
|
||||||
|
private var _IconsaxFullscreenExit: ImageVector? = null
|
||||||
@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.window.WindowPlacement
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxFullscreen
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxFullscreenExit
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.app_window_enter_fullscreen
|
||||||
|
import spotube.composeapp.generated.resources.app_window_exit_fullscreen
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun WindowFullscreenButton() {
|
||||||
|
val windowState = LocalWindowState.current
|
||||||
|
// Where to go back to on exit; only set while this button put the window in full screen
|
||||||
|
var placementBeforeFullscreen by remember { mutableStateOf<WindowPlacement?>(null) }
|
||||||
|
val isFullscreen = windowState.placement == WindowPlacement.Fullscreen
|
||||||
|
|
||||||
|
// Full screen belongs to the screen that turned it on, so leaving that screen leaves it too
|
||||||
|
DisposableEffect(windowState) {
|
||||||
|
onDispose {
|
||||||
|
val previous = placementBeforeFullscreen
|
||||||
|
if (previous != null && windowState.placement == WindowPlacement.Fullscreen) {
|
||||||
|
windowState.placement = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = {
|
||||||
|
if (isFullscreen) {
|
||||||
|
windowState.placement = placementBeforeFullscreen ?: WindowPlacement.Floating
|
||||||
|
placementBeforeFullscreen = null
|
||||||
|
} else {
|
||||||
|
placementBeforeFullscreen = windowState.placement
|
||||||
|
windowState.placement = WindowPlacement.Fullscreen
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
|
) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = if (isFullscreen) Iconsax.IconsaxFullscreenExit else Iconsax.IconsaxFullscreen,
|
||||||
|
contentDescription = stringResource(
|
||||||
|
if (isFullscreen) Res.string.app_window_exit_fullscreen else Res.string.app_window_enter_fullscreen
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
actual fun WindowFullscreenButton() {
|
||||||
|
// No-op on mobile targets.
|
||||||
|
}
|
||||||
BIN
metadata/en-US/images/featureGraphic.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
metadata/en-US/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
metadata/en-US/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
metadata/en-US/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
metadata/en-US/images/phoneScreenshots/4.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
metadata/en-US/images/phoneScreenshots/5.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
BIN
metadata/tr/images/featureGraphic.png
Normal file
|
After Width: | Height: | Size: 262 KiB |
BIN
metadata/tr/images/phoneScreenshots/1.png
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
metadata/tr/images/phoneScreenshots/2.png
Normal file
|
After Width: | Height: | Size: 124 KiB |
BIN
metadata/tr/images/phoneScreenshots/3.png
Normal file
|
After Width: | Height: | Size: 167 KiB |
BIN
metadata/tr/images/phoneScreenshots/4.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
metadata/tr/images/phoneScreenshots/5.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
|
Before Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 34 KiB |
@ -8,8 +8,9 @@ command: spotube
|
|||||||
finish-args:
|
finish-args:
|
||||||
- --share=ipc
|
- --share=ipc
|
||||||
- --share=network
|
- --share=network
|
||||||
# Compose renders through AWT/Skiko, which needs X11 (XWayland on Wayland sessions)
|
# The window is a Tao (Wayland/X11) one, so Wayland is used natively and X11 only as a fallback
|
||||||
- --socket=x11
|
- --socket=wayland
|
||||||
|
- --socket=fallback-x11
|
||||||
- --socket=pulseaudio
|
- --socket=pulseaudio
|
||||||
- --device=dri
|
- --device=dri
|
||||||
# Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads
|
# Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads
|
||||||
|
|||||||
@ -55,8 +55,28 @@
|
|||||||
|
|
||||||
<screenshots>
|
<screenshots>
|
||||||
<screenshot type="default">
|
<screenshot type="default">
|
||||||
<caption>Spotube on the desktop</caption>
|
<caption>Browse music from the service your plugin connects to</caption>
|
||||||
<image>https://raw.githubusercontent.com/team-spotube/spotube/master/assets/branding/spotube-screenshot.png</image>
|
<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>
|
||||||
</screenshot>
|
</screenshot>
|
||||||
</screenshots>
|
</screenshots>
|
||||||
|
|
||||||
|
|||||||