Compare commits

...

17 Commits

Author SHA1 Message Date
dependabot[bot]
c5542ad930
Merge eb30f98056 into 79d47c1d87 2026-10-21 09:10:15 +00:00
Kingkor Roy Tirtho
79d47c1d87 feat: enhance loading states for various UI components and update dependencies 2026-10-20 20:32:47 +06:00
Kingkor Roy Tirtho
08892c2aa0 fix(downloads): respect download format/quality and never save manifests or video
- Downloads resolve streams with the user's download format/quality via a
  new StreamRequest instead of the streaming settings, and bypass the
  playback stream-URL cache in both directions
- Downloads only consider progressive streams; DASH/HLS manifests were
  previously saved as-is instead of audio
- NewPipe: map AAC "mp4a.40.x"/"m4a" to the advertised "aac"/"mp4" so real
  AAC streams can match (previously the hard-coded aac/mp4 manifests always
  won), report each stream's real delivery method, skip streams without a
  format or non-URL content
- Fail the download on non-2xx responses, on a range request answered with
  a full 200, and on video/* content, with localized reasons, instead of
  writing the response body to disk
- Save AAC as .m4a
- Tests for request overrides, manifest exclusion and cache isolation
2026-10-20 16:03:48 +06:00
Kingkor Roy Tirtho
8bbefdfb7d fix(android): app stuck on splash screen from main-thread string loading
PlaybackService.onCreate runs on the main thread and called
runBlocking { getString(...) } for its notification strings. Compose
resources share one in-flight load per strings file (AsyncCache creates the
Deferred in the caller's scope); when App()'s stringResource started that
load on the main dispatcher first, blocking main to await it deadlocked and
the first frame never rendered. Timing-dependent, so mostly seen under the
debugger.

Create the notification channel immediately under the app name and update
its localized name/description from serviceScope. The short-lived
placeholder notification no longer carries localized text.
2026-10-20 16:03:48 +06:00
Kingkor Roy Tirtho
d4961bb5ed feat: download diagnostics, live connect status, remote settings on devices screen
Downloads
- Show the real download folder: "Current folder: <path> (default)" in
  settings instead of the misleading "default music folder", and a
  "Saved to <path>" line on the Downloads screen
- Register finished downloads with Android's MediaStore (MediaScanner) so
  music players and the Files app see them immediately
- Add tagged logs across the download path to diagnose format/quality
  selection: raw NewPipe streams ([Streams]), every selection candidate
  with its rank key and fallback warnings ([Resolve]), and per-download
  enqueue prefs, resolved stream, HEAD/GET status + content type, segment
  responses and written vs expected bytes ([Download])

Connect status
- Mobile Home keeps its Devices / Group Jam / Support icons (Jam now uses
  the sound icon) and shows a slim banner only while remote-controlling a
  device or in a Jam ("Playing on krtx", "Hosting · 3 listeners")
- Sidebar Devices / Group Jam rows get a live dot and status line
- New ConnectStatusViewModel feeds both

Remote control settings
- Extract "Allow remote control" and "Device name" into shared
  remoteControlSettingCards, used by Playback settings and a new
  "This device" section on the Devices screen
2026-10-20 15:54:23 +06:00
Kingkor Roy Tirtho
b38a4f6963 feat(remote): redesign remote control and devices screens, fix connection issues
- Remove the unused "Enable Connect" setting and its strings
- Redesign RemoteControlScreen to match the expanded player: artwork sized
  to fit, side-by-side queue panel on wide screens, proper connecting /
  disconnected / error states, "Playing on <device>" subtitle
- Seek and volume sliders keep local drag state and only commit on release
  (volume throttled while dragging) instead of sending a command per frame
- Redesign DevicesScreen with the settings card components, a connected
  device card, error banner and empty state; ViewModels expose one UiState
- Fix tapping an already-connected device doing nothing; reopen its remote
  screen instead, and switch devices by disconnecting first
- Fix the controller getting stuck in Connecting after the host denies a
  request: Ktor never delivers Close frames to `incoming`, so detect the
  close reason after the receive loop ends
- Fix a UI freeze when leaving the devices screen on desktop: run discovery
  (and JmDNS's blocking close/resolve) on Dispatchers.IO
- Advertise the LAN address of the default-route interface on desktop;
  dns-sd-kt otherwise picks any site-local address, e.g. a Docker bridge
2026-10-20 15:11:10 +06:00
Kingkor Roy Tirtho
facc8ed0b9 fix(android): update playback state management to ensure guests follow host's play state 2026-10-20 14:28:18 +06:00
Kingkor Roy Tirtho
0b21eff4c7 fix(android): simplify service start logic for PlaybackService to avoid crashes on background playback 2026-10-20 13:44:30 +06:00
Kingkor Roy Tirtho
9d5854023b fix(android): update sharing functionality to use chooser intent with new task flag 2026-10-20 13:19:04 +06:00
Kingkor Roy Tirtho
38e9bbc1d8 fix(android): create intermediate directories for Rust targets to prevent runtime errors 2026-10-20 13:00:29 +06:00
Kingkor Roy Tirtho
64fb23e62b
Merge pull request #3152 from team-spotube/macos-dmg-fixes
fix(macos): add GraalVM runtime dependency and initialize GraalVm in …
2026-10-19 19:19:00 +06:00
Kingkor Roy Tirtho
64a78727ce
Update gradle/libs.versions.toml 2026-10-19 19:18:25 +06:00
Kingkor Roy Tirtho
046f724c9b
Merge branch 'dev' into macos-dmg-fixes 2026-10-19 19:15:56 +06:00
Kingkor Roy Tirtho
e92e62613e chore: implement Linux AppImage bundling for GTK and WebKitGTK, update dependencies and documentation 2026-10-19 19:15:13 +06:00
Kingkor Roy Tirtho
7ee3c70c96 fix(macos): add GraalVM runtime dependency and initialize GraalVm in main function 2026-10-19 18:50:27 +06:00
Kingkor Roy Tirtho
069b586d44 chore: update release workflow and documentation for dependency management 2026-10-19 11:50:53 +06:00
dependabot[bot]
eb30f98056
chore(deps): bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-10 05:08:47 +00:00
114 changed files with 3784 additions and 1611 deletions

View File

@ -34,8 +34,6 @@ concurrency:
env: env:
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d
GRADLE_PLUGIN_REPO: team-spotube/gradle-plugin
VLCJ_BUNDLER_REPO: team-spotube/vlcj-bundler-gradle-plugin
jobs: jobs:
prepare-deps: prepare-deps:
@ -50,20 +48,6 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview path: compose-webview
- name: Checkout gradle-plugin
uses: actions/checkout@v7
with:
repository: ${{ env.GRADLE_PLUGIN_REPO }}
token: ${{ secrets.GITHUB_TOKEN }}
path: gradle-plugin
- name: Checkout vlcj-bundler
uses: actions/checkout@v7
with:
repository: ${{ env.VLCJ_BUNDLER_REPO }}
token: ${{ secrets.GITHUB_TOKEN }}
path: vlcj-bundler
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
with: with:
@ -85,18 +69,6 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew publishToMavenLocal ./gradlew publishToMavenLocal
- name: Publish gradle-plugin to mavenLocal
working-directory: gradle-plugin
run: |
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Publish vlcj-bundler to mavenLocal
working-directory: vlcj-bundler
run: |
chmod +x gradlew
./gradlew publishToMavenLocal
- name: Upload maven local - name: Upload maven local
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@ -194,7 +166,7 @@ jobs:
- name: Install packaging tools - name: Install packaging tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
@ -224,12 +196,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
- name: Package Linux AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
- name: Rename Linux artifacts - name: Rename Linux artifacts
run: | run: |
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-x86_64.deb cp "$DEB_FILE" Spotube-linux-x86_64.deb
cp "$RPM_FILE" Spotube-linux-x86_64.rpm cp "$RPM_FILE" Spotube-linux-x86_64.rpm
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
cp "$APPIMAGE_FILE" Spotube-linux-x86_64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -243,6 +223,12 @@ jobs:
name: linux-rpm name: linux-rpm
path: Spotube-linux-x86_64.rpm path: Spotube-linux-x86_64.rpm
- name: Upload AppImage
uses: actions/upload-artifact@v7
with:
name: linux-appimage
path: Spotube-linux-x86_64.AppImage
- name: Setup tmate session on failure - name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }} if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
@ -273,7 +259,7 @@ jobs:
- name: Install packaging tools - name: Install packaging tools
run: | run: |
sudo apt-get update sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
@ -303,12 +289,20 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }} run: ./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
# Only the AppImage bundles VLC, GTK and WebKitGTK; deb/rpm depend on the distro packages
- name: Package Linux AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: ./gradlew :composeApp:packageGraalvmAppImage -PappImage=true -Pnightly=${{ inputs.release_type == 'nightly' }} -PbuildNumber=${{ github.run_number }}
- name: Rename Linux artifacts - name: Rename Linux artifacts
run: | run: |
DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1) DEB_FILE=$(find composeApp/build/compose/binaries/main/graalvm-deb -maxdepth 1 -name "*.deb" -type f | head -1)
RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1) RPM_FILE=$(find composeApp/build/compose/binaries/main/graalvm-rpm -maxdepth 1 -name "*.rpm" -type f | head -1)
cp "$DEB_FILE" Spotube-linux-aarch64.deb cp "$DEB_FILE" Spotube-linux-aarch64.deb
cp "$RPM_FILE" Spotube-linux-aarch64.rpm cp "$RPM_FILE" Spotube-linux-aarch64.rpm
APPIMAGE_FILE=$(find composeApp/build/compose/binaries/main -name "*.AppImage" -type f | head -1)
cp "$APPIMAGE_FILE" Spotube-linux-aarch64.AppImage
- name: Upload DEB - name: Upload DEB
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@ -322,6 +316,12 @@ jobs:
name: linux-rpm-arm64 name: linux-rpm-arm64
path: Spotube-linux-aarch64.rpm path: Spotube-linux-aarch64.rpm
- name: Upload AppImage
uses: actions/upload-artifact@v7
with:
name: linux-appimage-arm64
path: Spotube-linux-aarch64.AppImage
- name: Setup tmate session on failure - name: Setup tmate session on failure
if: ${{ inputs.debug_ssh && failure() }} if: ${{ inputs.debug_ssh && failure() }}
uses: mxschmitt/action-tmate@v3 uses: mxschmitt/action-tmate@v3
@ -507,7 +507,7 @@ jobs:
run: | run: |
echo "## Downloads" > release-notes.md echo "## Downloads" > release-notes.md
echo "" >> release-notes.md echo "" >> release-notes.md
for dir in android-apk linux-deb linux-rpm linux-deb-arm64 linux-rpm-arm64 windows-exe macos-dmg; do for dir in android-apk linux-deb linux-rpm linux-appimage linux-deb-arm64 linux-rpm-arm64 linux-appimage-arm64 windows-exe macos-dmg; do
if [ -d "$dir" ]; then if [ -d "$dir" ]; then
echo "### $dir" >> release-notes.md echo "### $dir" >> release-notes.md
for f in "$dir"/*; do for f in "$dir"/*; do
@ -539,7 +539,9 @@ jobs:
android-apk/*.apk android-apk/*.apk
linux-deb/*.deb linux-deb/*.deb
linux-rpm/*.rpm linux-rpm/*.rpm
linux-appimage/*.AppImage
linux-deb-arm64/*.deb linux-deb-arm64/*.deb
linux-rpm-arm64/*.rpm linux-rpm-arm64/*.rpm
linux-appimage-arm64/*.AppImage
windows-exe/*.exe windows-exe/*.exe
macos-dmg/*.dmg macos-dmg/*.dmg

View File

@ -59,9 +59,7 @@
## Release workflow (`.github/workflows/release.yml`) ## Release workflow (`.github/workflows/release.yml`)
- Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`). - Triggered by `workflow_dispatch` with a `release_type` choice input (`stable` | `nightly`).
- `prepare-deps` job checks out & publishes to `mavenLocal`: - `prepare-deps` job checks out & publishes `kdroidFilter/ComposeNativeWebview` (compose-webview) to `mavenLocal`. The spotube gradle plugin and vlcj-bundler now come from Maven Central.
- `kdroidFilter/ComposeNativeWebview` (compose-webview)
- `team-spotube/gradle-plugin` (spotubeGradle + vlcjBundler plugins)
- Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-<format>/`), not jpackage/JVM ones. - Build jobs per platform (Android, Linux, Windows, macOS), all `needs: prepare-deps`. Desktop jobs ship GraalVM native installers (`packageGraalvm{Deb,Rpm,Exe,Dmg}`, output in `composeApp/build/compose/binaries/main/graalvm-<format>/`), not jpackage/JVM ones.
- **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release. - **Stable**: reads `versionName` from `composeApp/build.gradle.kts`, tag = `v{version}`, draft release.
- **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease. - **Nightly**: builds with `-PversionName=nightly`, tag = `nightly` (updates existing), prerelease.

View File

@ -54,11 +54,20 @@ Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app i
- **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 the GraalVM toolchain are provisioned automatically.
- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). - **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev).
- **Android SDK**, only for Android builds. - **Android SDK**, only for Android builds.
- **Unpublished dependencies.** Three dependencies aren't on Maven Central yet. Clone each one and run `./gradlew publishToMavenLocal` in it: - **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it:
- [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) - [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`)
- [team-spotube/gradle-plugin](https://github.com/team-spotube/gradle-plugin) - **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):
- [team-spotube/vlcj-bundler-gradle-plugin](https://github.com/team-spotube/vlcj-bundler-gradle-plugin)
- **Linux runtime:** `webkit2gtk-4.1` for the embedded webview. | Distro | Command |
|---|---|
| Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` |
| Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. |
| Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` |
| openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) |
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 named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically.
Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows). Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows).
@ -79,7 +88,7 @@ Development builds stay fast. The release-only optimizations (the Rust release p
| OS | Requirements | | OS | Requirements |
|---|---| |---|---|
| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages. Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build` | | Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) |
| macOS | Xcode Command Line Tools (`xcode-select --install`) | | 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 the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. |
@ -106,12 +115,25 @@ Each OS can only build its own installers:
| OS | Command | Output | | OS | Command | Output |
|---|---|---| |---|---|---|
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` (also `packageGraalvmAppImage`) | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm}/` | | 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/` | | Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` |
| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` | | macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` |
Add `-Pnightly=true` for the nightly channel (separate icons and name). Add `-Pnightly=true` for the nightly channel (separate icons and name).
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)).
#### What the AppImage bundles
Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do.
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.
How it works, in short:
- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`.
- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either.
- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`.
#### Reflection metadata (when a feature breaks only in the native build) #### Reflection metadata (when a feature breaks only in the native build)
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). 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).

View File

@ -0,0 +1,9 @@
// Build logic for the Linux AppImage bundle (see DownloadLinuxGtkBundleTask). Lives in buildSrc so the IDE
// resolves it; an `apply(from = ...)` script plugin gets no Kotlin classpath in Android Studio.
plugins {
`kotlin-dsl`
}
repositories {
mavenCentral()
}

View File

@ -0,0 +1,260 @@
/*
* 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/>.
*/
// Bundles GTK 3 and WebKitGTK 4.1 into the Linux AppImage, the way Tauri apps do, so it runs on a
// system that has neither. Used only with -PappImage=true; deb/rpm depend on the distro's packages.
//
// The libraries come from the Ubuntu archive. Like the AppImage exclude list prescribes, nothing that
// has to match the host is bundled: glibc, libstdc++, libz, GL/EGL/DRM, X11/xcb, ALSA, fontconfig,
// freetype, harfbuzz, expat, gmp and the wayland libraries. What is left is pruned to the libraries
// reachable from GTK/WebKit through their ELF NEEDED entries.
//
// Layout (copied into the AppDir root by Nucleus; AppRun puts $APPDIR/usr/lib on LD_LIBRARY_PATH):
// usr/lib/*.so.N shared libraries, RUNPATH=$ORIGIN
// usr/lib/<triplet>/webkit2gtk-4.1/WebKit*Process helper processes
// usr/lib/<triplet>/gio/modules/libgiognutls.so TLS backend for WebKit/libsoup
// libwebkit2gtk has the helper path compiled in as /usr/lib/<triplet>/webkit2gtk-4.1; "/usr" is
// patched to "././" (same length), so the helpers resolve relative to the working directory, which the
// app sets to $APPDIR/usr at startup (AppImageEnvironment). Needs `ar`, `tar` (+ `zstd`), `readelf`
// and `patchelf` on the build machine.
package org.spotube.gradle
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.TaskAction
import org.gradle.work.DisableCachingByDefault
import java.io.File
import java.net.URI
import java.util.zip.GZIPInputStream
@DisableCachingByDefault(because = "Downloads from the network")
abstract class DownloadLinuxGtkBundleTask : DefaultTask() {
/** Root passed on to Nucleus' `appResourcesRootDir`; the files land in `<root>/linux-<arch>/usr`. */
@get:OutputDirectory
abstract val outputDir: DirectoryProperty
/** Ubuntu release the binaries come from. Its glibc is the minimum the bundle runs on. */
@get:Input
abstract val ubuntuRelease: Property<String>
init {
group = "dependencies"
description = "Downloads and relocates GTK 3 and WebKitGTK 4.1 for the Linux AppImage."
}
private val packages = listOf(
"libgtk-3-0t64", "libwebkit2gtk-4.1-0", "libjavascriptcoregtk-4.1-0", "glib-networking",
"libatk-bridge2.0-0t64", "libatk1.0-0t64", "libatomic1", "libatspi2.0-0t64", "libbrotli1",
"libbz2-1.0", "libcairo-gobject2", "libcairo2", "libcap2", "libdatrie1", "libdbus-1-3",
"libdw1t64", "libelf1t64", "libenchant-2-2", "libepoxy0", "libevdev2", "libffi8", "libgcrypt20",
"libgdk-pixbuf-2.0-0", "libglib2.0-0t64", "libgnutls30t64", "libgssapi-krb5-2",
"libgstreamer-gl1.0-0", "libgstreamer-plugins-base1.0-0", "libgstreamer1.0-0", "libgudev-1.0-0",
"libharfbuzz-icu0", "libhogweed6t64", "libhyphen0", "libicu74", "libidn2-0", "libjpeg-turbo8",
"libjpeg8", "libk5crypto3", "libkeyutils1", "libkrb5-3", "libkrb5support0", "liblcms2-2",
"liblz4-1", "liblzma5", "libmanette-0.2-0", "libnettle8t64", "libnghttp2-14", "liborc-0.4-0t64",
"libp11-kit0", "libpango-1.0-0", "libpangocairo-1.0-0", "libpangoft2-1.0-0", "libpixman-1-0",
"libpng16-16t64", "libpsl5t64", "libseccomp2", "libsecret-1-0", "libsharpyuv0", "libsoup-3.0-0",
"libsqlite3-0", "libsystemd0", "libtasn1-6", "libthai0", "libudev1", "libunistring5", "libunwind8",
"libwebp7", "libwebpdemux2", "libwebpmux3", "libxcb-render0", "libxcb-shm0", "libxcomposite1",
"libxcursor1", "libxdamage1", "libxext6", "libxfixes3", "libxi6", "libxinerama1", "libxkbcommon0",
"libxml2", "libxrandr2", "libxrender1", "libxslt1.1", "libzstd1",
)
// Provided by the host, per the AppImage exclude list (which includes the wayland libraries). Never
// copied even if a bundled package carries them.
private val hostLibraries = setOf(
"libc.so.6", "libm.so.6", "libdl.so.2", "libpthread.so.0", "librt.so.1", "libresolv.so.2",
"libutil.so.1", "libnsl.so.1", "libstdc++.so.6", "libgcc_s.so.1", "libz.so.1", "libGL.so.1",
"libEGL.so.1", "libGLX.so.0", "libGLdispatch.so.0", "libOpenGL.so.0", "libdrm.so.2",
"libgbm.so.1", "libX11.so.6", "libX11-xcb.so.1", "libxcb.so.1", "libasound.so.2",
"libfontconfig.so.1", "libfreetype.so.6", "libharfbuzz.so.0", "libexpat.so.1", "libgmp.so.10",
"libwayland-client.so.0", "libwayland-server.so.0", "libwayland-cursor.so.0",
"libwayland-egl.so.1", "libuuid.so.1", "libgpg-error.so.0", "libfribidi.so.0", "libXau.so.6",
"libXdmcp.so.6", "libpcre2-8.so.0", "libselinux.so.1", "libmount.so.1", "libblkid.so.1",
)
private val rootLibraries = listOf(
"libgtk-3.so.0", "libgdk-3.so.0", "libwebkit2gtk-4.1.so.0", "libjavascriptcoregtk-4.1.so.0",
)
@TaskAction
fun run() {
val arch = System.getProperty("os.arch").lowercase()
val isArm = arch.contains("aarch64") || arch.contains("arm64")
val resourceDir = if (isArm) "linux-arm64" else "linux-x64"
val debArch = if (isArm) "arm64" else "amd64"
val triplet = if (isArm) "aarch64-linux-gnu" else "x86_64-linux-gnu"
val mirror = if (isArm) "http://ports.ubuntu.com/ubuntu-ports" else "http://archive.ubuntu.com/ubuntu"
val target = outputDir.dir(resourceDir).get().asFile
val marker = File(target, ".complete")
if (marker.exists()) {
println("Linux GTK/WebKit bundle already present. Skipping download.")
return
}
for (tool in listOf("ar", "tar", "readelf", "patchelf")) {
val found = try {
ProcessBuilder(tool, "--version").redirectErrorStream(true).start()
.also { it.inputStream.readBytes() }.waitFor() == 0
} catch (_: Exception) {
false
}
if (!found) throw GradleException("`$tool` is required to bundle GTK/WebKit on Linux but was not found on PATH.")
}
target.deleteRecursively()
val work = File(temporaryDir, "work").apply { deleteRecursively(); mkdirs() }
val root = File(work, "root").apply { mkdirs() }
val index = fetchPackageIndex(mirror, debArch)
for (pkg in packages) {
val filename = index[pkg] ?: throw GradleException("Package $pkg not found in the ${ubuntuRelease.get()} index")
val deb = File(work, "$pkg.deb")
println("Downloading $pkg")
URI("$mirror/$filename").toURL().openStream().use { input -> deb.outputStream().use { input.copyTo(it) } }
extractDeb(deb, root, File(work, "ar-$pkg"))
}
val srcLib = File(root, "usr/lib/$triplet")
val libOut = File(target, "usr/lib").apply { mkdirs() }
// Libraries by SONAME (libbz2.so.1.0 and friends don't end in a single number)
val available = HashMap<String, File>()
srcLib.listFiles { f -> f.isFile && ".so" in f.name }.orEmpty().forEach { file ->
soname(file)?.let { available.putIfAbsent(it, file) }
}
val helperSrc = File(srcLib, "webkit2gtk-4.1")
val helperOut = File(libOut, "$triplet/webkit2gtk-4.1")
val helpers = listOf("WebKitWebProcess", "WebKitNetworkProcess", "WebKitGPUProcess")
for (name in helpers) {
// copyTo drops the mode bits; WebKit spawns these, so they must stay executable
File(helperSrc, name).copyTo(File(helperOut, name), overwrite = true).setExecutable(true, false)
}
val injected = File(helperSrc, "injected-bundle/libwebkit2gtkinjectedbundle.so")
injected.copyTo(File(helperOut, "injected-bundle/${injected.name}"), overwrite = true)
val gioTls = File(srcLib, "gio/modules/libgiognutls.so")
gioTls.copyTo(File(libOut, "$triplet/gio/modules/${gioTls.name}"), overwrite = true)
// Keep only what GTK, WebKit, their helpers and the TLS module pull in via NEEDED
val pending = ArrayDeque(rootLibraries)
listOf(*helpers.map { File(helperOut, it) }.toTypedArray(), File(helperOut, "injected-bundle/${injected.name}"),
File(libOut, "$triplet/gio/modules/${gioTls.name}")).forEach { pending += needed(it) }
val keep = LinkedHashSet<String>()
while (pending.isNotEmpty()) {
val name = pending.removeFirst()
if (name in hostLibraries || name in keep) continue
val file = available[name] ?: continue
keep += name
pending += needed(file)
}
for (name in keep) available.getValue(name).copyTo(File(libOut, name), overwrite = true)
patchWebKitPaths(File(libOut, "libwebkit2gtk-4.1.so.0"), triplet)
// Libraries find each other next to themselves; the helpers and modules sit two/three levels down
libOut.listFiles { f -> f.isFile }.orEmpty().forEach { patchRpath(it, "\$ORIGIN") }
helpers.forEach { patchRpath(File(helperOut, it), "\$ORIGIN/../..") }
patchRpath(File(helperOut, "injected-bundle/${injected.name}"), "\$ORIGIN/../../..")
patchRpath(File(libOut, "$triplet/gio/modules/${gioTls.name}"), "\$ORIGIN/../../..")
marker.writeText("")
work.deleteRecursively()
println("GTK/WebKit bundled into $target (${keep.size} libraries)")
}
/** "/usr/lib/<triplet>/webkit2gtk-4.1[/injected-bundle]" -> "././lib/<triplet>/webkit2gtk-4.1[/...]". */
private fun patchWebKitPaths(lib: File, triplet: String) {
val bytes = lib.readBytes()
val needle = "/usr/lib/$triplet/webkit2gtk-4.1".toByteArray()
val replacement = "././".toByteArray()
var count = 0
var i = 0
while (i <= bytes.size - needle.size) {
var match = true
for (j in needle.indices) {
if (bytes[i + j] != needle[j]) { match = false; break }
}
if (match) {
replacement.copyInto(bytes, i)
count++
i += needle.size
} else {
i++
}
}
if (count == 0) throw GradleException("WebKit helper path not found in ${lib.name}; the bundling layout needs updating")
lib.writeBytes(bytes)
}
private fun soname(file: File): String? =
exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence()
.firstOrNull { "(SONAME)" in it }?.substringAfter('[')?.substringBefore(']')
private fun needed(file: File): List<String> =
exec(file.parentFile, "readelf", "-d", file.absolutePath).lineSequence()
.filter { "(NEEDED)" in it }.map { it.substringAfter('[').substringBefore(']') }.toList()
/** name -> pool filename; `-updates` is read first so security/bugfix builds win. */
private fun fetchPackageIndex(mirror: String, debArch: String): Map<String, String> {
val release = ubuntuRelease.get()
val index = HashMap<String, String>()
for (suite in listOf("$release-updates", release)) {
for (component in listOf("main", "universe")) {
val url = URI("$mirror/dists/$suite/$component/binary-$debArch/Packages.gz").toURL()
GZIPInputStream(url.openStream()).bufferedReader().useLines { lines ->
var name: String? = null
for (line in lines) {
when {
line.startsWith("Package: ") -> name = line.removePrefix("Package: ")
line.startsWith("Filename: ") -> name?.let { index.putIfAbsent(it, line.removePrefix("Filename: ")) }
line.isEmpty() -> name = null
}
}
}
}
}
return index
}
private fun extractDeb(deb: File, into: File, scratch: File) {
scratch.mkdirs()
exec(scratch, "ar", "x", deb.absolutePath)
val data = scratch.listFiles { f -> f.name.startsWith("data.tar") }?.singleOrNull()
?: throw GradleException("No data archive in ${deb.name}")
// GNU tar picks the decompressor from the content (zstd needs the zstd binary installed)
exec(scratch, "tar", "-xf", data.absolutePath, "-C", into.absolutePath)
}
private fun patchRpath(file: File, rpath: String) {
exec(file.parentFile, "patchelf", "--set-rpath", rpath, file.absolutePath)
}
private fun exec(dir: File, vararg command: String): String {
val process = ProcessBuilder(*command).directory(dir).redirectErrorStream(true).start()
val output = process.inputStream.bufferedReader().readText()
if (process.waitFor() != 0) {
throw GradleException("`${command.joinToString(" ")}` failed:\n$output")
}
return output
}
}

View File

@ -18,11 +18,13 @@
import gobley.gradle.GobleyHost import gobley.gradle.GobleyHost
import gobley.gradle.Variant import gobley.gradle.Variant
import gobley.gradle.cargo.dsl.jvm import gobley.gradle.cargo.dsl.jvm
import gobley.gradle.rust.targets.RustAndroidTarget
import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization import dev.nucleusframework.desktop.application.dsl.NativeImageOptimization
import dev.nucleusframework.desktop.application.dsl.TargetFormat import dev.nucleusframework.desktop.application.dsl.TargetFormat
import org.jetbrains.compose.reload.gradle.ComposeHotRun import org.jetbrains.compose.reload.gradle.ComposeHotRun
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Copy
import org.spotube.gradle.DownloadLinuxGtkBundleTask
import java.io.FileInputStream import java.io.FileInputStream
import java.util.Properties import java.util.Properties
@ -106,9 +108,30 @@ tasks.configureEach {
} }
} }
// Windows/macOS always bundle VLC. On Linux only the self-contained AppImage (-PappImage=true) bundles
// VLC, GTK and WebKitGTK; deb/rpm use the distro's packages.
val isLinuxHost = System.getProperty("os.name").lowercase().contains("linux")
val bundleForAppImage = isLinuxHost && providers.gradleProperty("appImage").map { it.toBoolean() }.getOrElse(false)
vlcjBundler { vlcjBundler {
packageName = "$bundleId.core.generated" // choose a different package packageName = "$bundleId.core.generated" // choose a different package
objectName = "VLCBundleLoaderGenerated" // or rename the object objectName = "VLCBundleLoaderGenerated" // or rename the object
bundleVlc = !isLinuxHost || bundleForAppImage
}
// GTK 3 + WebKitGTK for the AppImage (see DownloadLinuxGtkBundleTask in buildSrc)
val downloadLinuxGtkBundle = tasks.register<DownloadLinuxGtkBundleTask>("downloadLinuxGtkBundle") {
outputDir.set(layout.buildDirectory.dir("linux-gtk-bundle"))
ubuntuRelease.set("noble")
}
val linuxGtkBundleDir: Provider<Directory> = downloadLinuxGtkBundle.flatMap { it.outputDir }
// One resources root holding VLC and GTK/WebKit, laid out for the AppImage
val assembleAppImageResources = tasks.register<Sync>("assembleAppImageResources") {
from(vlcjBundler.vlcNativesDirectory)
from(linuxGtkBundleDir)
exclude("**/.complete")
into(layout.buildDirectory.dir("appimage-resources"))
} }
@ -302,6 +325,7 @@ kotlin {
implementation(libs.jna) implementation(libs.jna)
implementation(libs.nucleus.core.runtime) implementation(libs.nucleus.core.runtime)
implementation(libs.nucleus.graalvm.runtime)
implementation(libs.nucleus.nucleus.application) implementation(libs.nucleus.nucleus.application)
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
@ -365,6 +389,14 @@ android {
isMinifyEnabled = false isMinifyEnabled = false
signingConfig = signingConfigs.getByName("release") signingConfig = signingConfigs.getByName("release")
} }
getByName("debug") {
// Debug and release share the application id, so sign both with the release key
// when it's configured; otherwise one can't be installed over the other.
// Without local.properties signing (contributors) keep the default debug keystore.
if (signingConfigs.getByName("release").storeFile != null) {
signingConfig = signingConfigs.getByName("release")
}
}
} }
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_11
@ -401,10 +433,15 @@ nucleus.application {
nativeDistributions { nativeDistributions {
packageName = bundleId packageName = bundleId
// User-facing name (.app bundle, DMG productName, Launchpad/Dock); packageName above is the reverse-DNS id.
// The GraalVM DMG task reads only appName ?: packageName, not macOS.packageName
appName = if (isNightly) "Spotube Nightly" else "Spotube"
packageVersion = version packageVersion = version
licenseFile = project.file("../LICENSE") licenseFile = project.file("../LICENSE")
appResourcesRootDir.set(vlcjBundler.vlcNativesDirectory) appResourcesRootDir.set(
if (bundleForAppImage) assembleAppImageResources.map { layout.buildDirectory.dir("appimage-resources").get() } else vlcjBundler.vlcNativesDirectory
)
targetFormats( targetFormats(
TargetFormat.Dmg, TargetFormat.Dmg,
@ -424,6 +461,44 @@ nucleus.application {
modules("jdk.security.auth") modules("jdk.security.auth")
debMaintainer = "Team Spotube <team.spotube@proton.me>" debMaintainer = "Team Spotube <team.spotube@proton.me>"
rpmLicenseType = "AGPL-3.0-or-later" rpmLicenseType = "AGPL-3.0-or-later"
// deb/rpm use the distro's shared libraries (only the AppImage bundles VLC). VLC is not
// bundled on Linux otherwise, the webview needs WebKitGTK 4.1, and the window/tray/MPRIS
// natives link GTK 3, GLib/GIO and D-Bus. Setting these replaces electron-builder's
// Electron-specific defaults (libnss3, libnotify, ...).
debDepends = listOf(
"libvlc5",
"vlc-plugin-base",
"libwebkit2gtk-4.1-0",
"libgtk-3-0 | libgtk-3-0t64",
"libglib2.0-0 | libglib2.0-0t64",
"libdbus-1-3",
"libasound2 | libasound2t64",
"libgl1",
"libfontconfig1",
"libfreetype6",
"libx11-6",
"libxext6",
"libxi6",
"libxrender1",
"libxtst6",
)
// vlc-libs comes from RPM Fusion (Fedora ships no VLC); the README tells users to enable it
rpmRequires = listOf(
"vlc-libs",
"webkit2gtk4.1",
"gtk3",
"glib2",
"dbus-libs",
"alsa-lib",
"mesa-libGL",
"fontconfig",
"freetype",
"libX11",
"libXext",
"libXi",
"libXrender",
"libXtst",
)
} }
windows { windows {
iconFile.set(project.file("$desktopIconDir/icon.ico")) iconFile.set(project.file("$desktopIconDir/icon.ico"))
@ -435,6 +510,8 @@ nucleus.application {
macOS { macOS {
iconFile.set(project.file("$desktopIconDir/icon.icns")) iconFile.set(project.file("$desktopIconDir/icon.icns"))
bundleID = if (isNightly) "$bundleId.nightly" else bundleId
dockName = appName
notarization { notarization {
} }
@ -527,6 +604,8 @@ tasks.withType<Copy>().matching { it.name == "copyGraalvmAwtSoLibs" }.configureE
// Issue with compose plugin // Issue with compose plugin
// https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks // https://github.com/JetBrains/compose-hot-reload/blob/master/docs/Known_limitations.md#property-composeapplicationresourcesdir-is-null-when-running-hot-reload-tasks
tasks.withType<ComposeHotRun>().configureEach { tasks.withType<ComposeHotRun>().configureEach {
// Populates the resources dir below with the VLC natives (appResourcesRootDir); hot run doesn't depend on it by default
dependsOn("prepareAppResources")
jvmArgs( jvmArgs(
"--enable-native-access=ALL-UNNAMED" "--enable-native-access=ALL-UNNAMED"
) )
@ -544,6 +623,15 @@ cargo {
} }
} }
// Gobley adds build/intermediates/rust/<triple>/<variant> as a jniLibs source dir, but AGP leaves source dirs
// that don't exist at configuration time out of the merge inputs. On a clean build (CI) libcompose_app.so would
// then silently miss the APK and crash at runtime with UnsatisfiedLinkError, so create the dirs up front.
RustAndroidTarget.entries.forEach { target ->
Variant.entries.forEach { variant ->
layout.buildDirectory.dir("intermediates/rust/${target.rustTriple}/$variant").get().asFile.mkdirs()
}
}
// Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never // Google only ships x86_64 Linux NDK toolchains, so on a Linux arm64 host the Android Rust targets can never
// link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners. // link (clang: "Exec format error"). Desktop packaging doesn't need them; Android is built on x86_64 runners.
run { run {
@ -560,3 +648,12 @@ run {
.configureEach { enabled = false } .configureEach { enabled = false }
} }
} }
// Nucleus' resource copies (prepareAppResources, copyGraalvmAppResources) drop the execute bit, but WebKitGTK
// spawns its helper processes from the AppImage bundle (DownloadLinuxGtkBundleTask in buildSrc) and fails with
// "Permission denied" otherwise.
tasks.matching { it.name == "prepareAppResources" || it.name == "copyGraalvmAppResources" }.configureEach {
(this as? org.gradle.api.tasks.AbstractCopyTask)?.filesMatching("**/webkit2gtk-4.1/WebKit*Process") {
permissions { unix("rwxr-xr-x") }
}
}

View File

@ -19,17 +19,22 @@ package org.spotube.spotube.core.newpipe
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream import org.spotube.plugin_interfaces.plugin_apis.audio.AudioStream
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import co.touchlab.kermit.Logger
import org.spotube.spotube.core.server.describeForLog
import io.ktor.http.Url import io.ktor.http.Url
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.schabi.newpipe.extractor.ServiceList.YouTube import org.schabi.newpipe.extractor.ServiceList.YouTube
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.DeliveryMethod
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
/** NewPipe reports durations in seconds (-1 when unknown). */ /** NewPipe reports durations in seconds (-1 when unknown). */
private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0 private fun secondsToMs(seconds: Long): Long = if (seconds > 0) seconds * 1000 else 0
actual class NewPipeService { actual class NewPipeService {
private val logger = Logger.withTag("NewPipeService")
actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> { actual suspend fun searchVideos(query: String, kind: SearchKind): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extractor = YouTube.getSearchExtractor( val extractor = YouTube.getSearchExtractor(
@ -66,13 +71,33 @@ actual class NewPipeService {
val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id") val extractor = YouTube.getStreamExtractor("https://www.youtube.com/watch?v=$id")
extractor.fetchPage() extractor.fetchPage()
// Raw extractor output, before any mapping/filtering, so stream-selection issues can be
// traced back to what YouTube actually offered for this video.
logger.i {
"[Streams $id] '${extractor.name}' raw audioStreams=${extractor.audioStreams.size}:\n" +
extractor.audioStreams.joinToString("\n") { stream ->
" itag=${stream.itag} format=${stream.format?.name} mime=${stream.format?.mimeType} " +
"suffix=${stream.format?.suffix} codec=${stream.codec} bitrate=${stream.bitrate} " +
"avgBitrate=${stream.averageBitrate} delivery=${stream.deliveryMethod} isUrl=${stream.isUrl} " +
"trackType=${stream.audioTrackType}"
}
}
logger.i {
"[Streams $id] dashMpdUrl=${extractor.dashMpdUrl.isNotBlank()} hlsUrl=${extractor.hlsUrl.isNotBlank()} " +
"raw videoStreams=${extractor.videoStreams.size} videoOnlyStreams=${extractor.videoOnlyStreams.size}"
}
val audioStreams = extractor.audioStreams val audioStreams = extractor.audioStreams
.mapNotNull { stream -> .mapNotNull { stream ->
val format = stream.format ?: return@mapNotNull null
// Non-URL content is an inline manifest, not something a player or downloader can fetch.
if (!stream.isUrl) return@mapNotNull null
AudioStream.Lossy( AudioStream.Lossy(
url = stream.content, url = stream.content,
codec = stream.codec, codec = normalizeCodec(stream.codec),
container = stream.format!!.suffix, container = normalizeContainer(format.suffix),
bitrate = stream.bitrate) bitrate = stream.bitrate,
protocol = stream.deliveryMethod.toProtocol())
} + buildList { } + buildList {
if (extractor.dashMpdUrl.isNotBlank()) add( if (extractor.dashMpdUrl.isNotBlank()) add(
AudioStream.Lossy( AudioStream.Lossy(
@ -103,6 +128,10 @@ actual class NewPipeService {
bitrate = stream.bitrate, bitrate = stream.bitrate,
container = stream.format!!.suffix) container = stream.format!!.suffix)
} }
logger.i {
"[Streams $id] mapped audio candidates (${audioStreams.size}):\n" +
audioStreams.joinToString("\n") { " ${it.describeForLog()}" }
}
VideoInfo( VideoInfo(
id = id, id = id,
title = extractor.name, title = extractor.name,
@ -112,10 +141,37 @@ actual class NewPipeService {
thumbnailUrl = extractor.thumbnails.first().url, thumbnailUrl = extractor.thumbnails.first().url,
// fallback to video streams if no audio streams are available // fallback to video streams if no audio streams are available
audioStreams = audioStreams.ifEmpty { audioStreams = audioStreams.ifEmpty {
logger.w {
"[Streams $id] no audio streams; FALLING BACK TO ${videoStreams.size} VIDEO streams " +
"(muxed video+audio, downloads will be video files):\n" +
extractor.videoStreams.joinToString("\n") {
" itag=${it.itag} format=${it.format?.name} res=${it.resolution} " +
"codec=${it.codec} videoOnly=${it.isVideoOnly} isUrl=${it.isUrl}"
}
}
videoStreams videoStreams
}, },
videoStreams = videoStreams videoStreams = videoStreams
) )
} }
} }
} }
/**
* NewPipe names AAC "mp4a.40.x" in an "m4a" container, but the plugin advertises (and users pick)
* "aac"/"mp4". Without this a real AAC stream never matched the preference, and the hard-coded
* aac/mp4 DASH/HLS manifests always won instead.
*/
private fun normalizeCodec(codec: String?): String {
val value = codec.orEmpty()
return if (value.startsWith("mp4a", ignoreCase = true)) "aac" else value
}
private fun normalizeContainer(suffix: String): String =
if (suffix.equals("m4a", ignoreCase = true)) "mp4" else suffix
private fun DeliveryMethod.toProtocol(): StreamProtocol = when (this) {
DeliveryMethod.DASH -> StreamProtocol.DASH
DeliveryMethod.HLS -> StreamProtocol.HLS
else -> StreamProtocol.PROGRESSIVE
}

View File

@ -18,7 +18,6 @@
package org.spotube.spotube package org.spotube.spotube
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
@ -37,13 +36,8 @@ class MainActivity : ComponentActivity() {
FileKit.init(this) FileKit.init(this)
NewPipeDownloader.init(Paths()) NewPipeDownloader.init(Paths())
// Start PlaybackService from Activity context (allowed on Android 12+) // Plain start from the foreground; Media3 promotes PlaybackService itself once playback starts
val serviceIntent = Intent(this, PlaybackService::class.java) startService(Intent(this, PlaybackService::class.java))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(serviceIntent)
} else {
startService(serviceIntent)
}
intent?.dataString?.let(ExternalUriHandler::onNewUri) intent?.dataString?.let(ExternalUriHandler::onNewUri)
setContent { setContent {

View File

@ -19,7 +19,6 @@ package org.spotube.spotube.core.audioplayer
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import android.util.Log import android.util.Log
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
import androidx.media3.common.C import androidx.media3.common.C
@ -52,17 +51,14 @@ actual class AudioPlayer actual constructor(context: Any) : AudioPlayerInterface
private val appContext: Context = (context as Context).applicationContext private val appContext: Context = (context as Context).applicationContext
private fun ensureServiceStarted() { private fun ensureServiceStarted() {
// On Android 12+ starting a foreground service from the background throws // A plain start, never startForegroundService: that obliges the service to call
// (ForegroundServiceStartNotAllowedException) — e.g. when a jam session or // startForeground() within seconds of *every* call, and once Media3 has demoted it
// remote control applies playback while the app is backgrounded. Never let // on pause nothing does, so the app is killed (ForegroundServiceDidNotStartInTime).
// that crash the app; playback itself runs in-process without the service. // Media3's MediaSessionService promotes itself to the foreground when playback starts.
// Starting from the background (jam sync, remote control) throws; playback still runs
// in-process without the service, so never let that crash the app.
try { try {
val intent = Intent(appContext, PlaybackService::class.java) appContext.startService(Intent(appContext, PlaybackService::class.java))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
appContext.startForegroundService(intent)
} else {
appContext.startService(intent)
}
} catch (e: Exception) { } catch (e: Exception) {
Log.w("AudioPlayer", "Failed to start playback service", e) Log.w("AudioPlayer", "Failed to start playback service", e)
} }

View File

@ -0,0 +1,21 @@
/*
* 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.discovery
// The OS's own service-discovery stack advertises on the right network.
internal actual fun preferredAdvertiseAddress(): String? = null

View File

@ -18,15 +18,19 @@
package org.spotube.spotube.core.share package org.spotube.spotube.core.share
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.app.ShareCompat import androidx.core.app.ShareCompat
import org.spotube.spotube.core.share.ShareService import org.spotube.spotube.core.share.ShareService
class AndroidShareService(private val context: Context) : ShareService { class AndroidShareService(private val context: Context) : ShareService {
override fun share(url: String, title: String) { override fun share(url: String, title: String) {
ShareCompat.IntentBuilder(context) // context is the application context (Koin), which can only start activities in a new task
val chooser = ShareCompat.IntentBuilder(context)
.setType("text/plain") .setType("text/plain")
.setSubject(title) .setSubject(title)
.setText(url) .setText(url)
.startChooser() .createChooserIntent()
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(chooser)
} }
} }

View File

@ -51,7 +51,6 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
@ -59,7 +58,6 @@ import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.notification_playback_channel_description import spotube.composeapp.generated.resources.notification_playback_channel_description
import spotube.composeapp.generated.resources.notification_playback_channel_name import spotube.composeapp.generated.resources.notification_playback_channel_name
import spotube.composeapp.generated.resources.notification_playback_service_text
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService(), KoinComponent { class PlaybackService : MediaLibraryService(), KoinComponent {
@ -96,9 +94,10 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )
// Short-lived placeholder until Media3 posts the playback notification. No localized text:
// loading it here would block the main thread (see createNotificationChannel).
val notification = NotificationCompat.Builder(this, CHANNEL_ID) val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle("Spotube") .setContentTitle("Spotube")
.setContentText(runBlocking { getString(Res.string.notification_playback_service_text) })
.setSmallIcon(R.drawable.ic_stat_spotube) .setSmallIcon(R.drawable.ic_stat_spotube)
.setContentIntent(sessionActivity) .setContentIntent(sessionActivity)
.setOngoing(true) .setOngoing(true)
@ -125,21 +124,32 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
Log.i(TAG, "Library session created") Log.i(TAG, "Library session created")
} }
/**
* The channel must exist before startForeground, so it's created right away under the app name
* and renamed once the localized strings load.
*
* Never `runBlocking { getString(...) }` here: onCreate runs on the main thread, and Compose
* resources share one in-flight load per strings file. If App()'s stringResource started that
* load on the main dispatcher first, blocking main to await it deadlocks the app on its
* splash screen.
*/
@SuppressLint("ObsoleteSdkInt") @SuppressLint("ObsoleteSdkInt")
private fun createNotificationChannel() { private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val channel = NotificationChannel( val manager = getSystemService(NotificationManager::class.java)
CHANNEL_ID, fun channel(name: String, description: String? = null) =
runBlocking { getString(Res.string.notification_playback_channel_name) }, NotificationChannel(CHANNEL_ID, name, NotificationManager.IMPORTANCE_LOW).apply {
NotificationManager.IMPORTANCE_LOW this.description = description
).apply {
description = runBlocking {
getString(Res.string.notification_playback_channel_description)
}
setShowBadge(false) setShowBadge(false)
} }
val manager = getSystemService(NotificationManager::class.java)
manager.createNotificationChannel(channel) manager.createNotificationChannel(channel("Spotube"))
serviceScope.launch {
// Re-creating a channel with the same ID updates its name and description.
manager.createNotificationChannel(
channel(
name = getString(Res.string.notification_playback_channel_name),
description = getString(Res.string.notification_playback_channel_description)))
} }
} }

View File

@ -0,0 +1,27 @@
/*
* 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.downloads
import android.content.Context
import android.media.MediaScannerConnection
import org.koin.core.context.GlobalContext
internal actual fun addToMediaLibrary(path: String) {
val context = GlobalContext.get().get<Context>()
MediaScannerConnection.scanFile(context, arrayOf(path), null, null)
}

View File

@ -179,7 +179,6 @@
<string name="settings_download_folder_title">تخصيص مجلد التنزيل</string> <string name="settings_download_folder_title">تخصيص مجلد التنزيل</string>
<string name="settings_download_folder_current">المجلد الحالي: %1$s</string> <string name="settings_download_folder_current">المجلد الحالي: %1$s</string>
<string name="settings_download_folder_default">المجلد الحالي: مجلد الموسيقى الافتراضي</string>
<string name="settings_download_folder_description">اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي.</string> <string name="settings_download_folder_description">اتركه فارغًا لاستخدام مجلد الموسيقى الافتراضي.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">مجلدات الوسائط المحلية</string> <string name="settings_local_media_folders_title">مجلدات الوسائط المحلية</string>
@ -225,8 +224,6 @@
<string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string> <string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string>
<string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string> <string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string>
<string name="settings_enable_endless_playback_subtitle">واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.</string> <string name="settings_enable_endless_playback_subtitle">واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.</string>
<string name="settings_enable_connect_title">تفعيل Connect</string>
<string name="settings_enable_connect_subtitle">إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect.</string>
<string name="settings_playback_port_title">منفذ خادم وكيل التشغيل</string> <string name="settings_playback_port_title">منفذ خادم وكيل التشغيل</string>
<string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string> <string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string>
<string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string> <string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string>
@ -511,7 +508,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">الأجهزة</string> <string name="devices_title">الأجهزة</string>
<string name="devices_refresh">تحديث</string> <string name="devices_refresh">تحديث</string>
<string name="devices_connected_to">متصل بـ %1$s:%2$d</string>
<string name="devices_disconnect">قطع الاتصال</string> <string name="devices_disconnect">قطع الاتصال</string>
<string name="devices_connecting">جارٍ الاتصال...</string> <string name="devices_connecting">جارٍ الاتصال...</string>
<string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string> <string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string>
@ -519,20 +515,13 @@
<string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string> <string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string>
<string name="devices_unknown_device">جهاز غير معروف</string> <string name="devices_unknown_device">جهاز غير معروف</string>
<string name="devices_resolving">جارٍ التحديد...</string> <string name="devices_resolving">جارٍ التحديد...</string>
<string name="devices_device_id">المعرّف: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">التحكم عن بُعد</string> <string name="devices_remote_title">التحكم عن بُعد</string>
<string name="devices_remote_disconnected">غير متصل</string> <string name="devices_remote_disconnected">غير متصل</string>
<string name="devices_remote_connection_error">خطأ في الاتصال: %1$s</string> <string name="devices_remote_connection_error">خطأ في الاتصال: %1$s</string>
<string name="devices_remote_album_cover">غلاف الألبوم</string>
<string name="devices_remote_unknown_track">مقطع غير معروف</string> <string name="devices_remote_unknown_track">مقطع غير معروف</string>
<string name="devices_remote_unknown_artist">فنان غير معروف</string> <string name="devices_remote_unknown_artist">فنان غير معروف</string>
<string name="devices_remote_seek">التقديم والترجيع</string>
<string name="devices_remote_shuffle">ترتيب عشوائي</string>
<string name="devices_remote_previous">السابق</string>
<string name="devices_remote_next">التالي</string>
<string name="devices_remote_loop_mode">وضع التكرار</string>
<string name="devices_remote_volume">مستوى الصوت</string> <string name="devices_remote_volume">مستوى الصوت</string>
<string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string> <string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string>
<string name="devices_remote_more_options">خيارات إضافية</string> <string name="devices_remote_more_options">خيارات إضافية</string>
@ -949,7 +938,6 @@
<string name="app_window_close">إغلاق</string> <string name="app_window_close">إغلاق</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">خدمة التشغيل</string>
<string name="notification_playback_channel_name">التشغيل</string> <string name="notification_playback_channel_name">التشغيل</string>
<string name="notification_playback_channel_description">عناصر التحكم في تشغيل الوسائط</string> <string name="notification_playback_channel_description">عناصر التحكم في تشغيل الوسائط</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">ডাউনলোড ফোল্ডার পরিবর্তন</string> <string name="settings_download_folder_title">ডাউনলোড ফোল্ডার পরিবর্তন</string>
<string name="settings_download_folder_current">বর্তমান ফোল্ডার: %1$s</string> <string name="settings_download_folder_current">বর্তমান ফোল্ডার: %1$s</string>
<string name="settings_download_folder_default">বর্তমান ফোল্ডার: ডিফল্ট মিউজিক ফোল্ডার</string>
<string name="settings_download_folder_description">ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন।</string> <string name="settings_download_folder_description">ডিফল্ট মিউজিক ফোল্ডার ব্যবহার করতে এটি ফাঁকা রাখুন।</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">লোকাল মিডিয়া ফোল্ডার</string> <string name="settings_local_media_folders_title">লোকাল মিডিয়া ফোল্ডার</string>
@ -213,8 +212,6 @@
<string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string> <string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string>
<string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string> <string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string>
<string name="settings_enable_endless_playback_subtitle">কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।</string> <string name="settings_enable_endless_playback_subtitle">কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।</string>
<string name="settings_enable_connect_title">Connect চালু করুন</string>
<string name="settings_enable_connect_subtitle">Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন।</string>
<string name="settings_playback_port_title">প্লেব্যাক প্রক্সি সার্ভার পোর্ট</string> <string name="settings_playback_port_title">প্লেব্যাক প্রক্সি সার্ভার পোর্ট</string>
<string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string> <string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string>
<string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string> <string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string>
@ -495,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">ডিভাইস</string> <string name="devices_title">ডিভাইস</string>
<string name="devices_refresh">রিফ্রেশ</string> <string name="devices_refresh">রিফ্রেশ</string>
<string name="devices_connected_to">%1$s:%2$d-এ কানেক্টেড</string>
<string name="devices_disconnect">ডিসকানেক্ট</string> <string name="devices_disconnect">ডিসকানেক্ট</string>
<string name="devices_connecting">কানেক্ট হচ্ছে...</string> <string name="devices_connecting">কানেক্ট হচ্ছে...</string>
<string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string> <string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string>
@ -503,20 +499,13 @@
<string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string> <string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string>
<string name="devices_unknown_device">অজানা ডিভাইস</string> <string name="devices_unknown_device">অজানা ডিভাইস</string>
<string name="devices_resolving">খোঁজা হচ্ছে...</string> <string name="devices_resolving">খোঁজা হচ্ছে...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">রিমোট কন্ট্রোল</string> <string name="devices_remote_title">রিমোট কন্ট্রোল</string>
<string name="devices_remote_disconnected">ডিসকানেক্টেড</string> <string name="devices_remote_disconnected">ডিসকানেক্টেড</string>
<string name="devices_remote_connection_error">কানেকশন ত্রুটি: %1$s</string> <string name="devices_remote_connection_error">কানেকশন ত্রুটি: %1$s</string>
<string name="devices_remote_album_cover">অ্যালবাম কভার</string>
<string name="devices_remote_unknown_track">অজানা ট্র্যাক</string> <string name="devices_remote_unknown_track">অজানা ট্র্যাক</string>
<string name="devices_remote_unknown_artist">অজানা শিল্পী</string> <string name="devices_remote_unknown_artist">অজানা শিল্পী</string>
<string name="devices_remote_seek">সিক</string>
<string name="devices_remote_shuffle">শাফল</string>
<string name="devices_remote_previous">আগের</string>
<string name="devices_remote_next">পরের</string>
<string name="devices_remote_loop_mode">লুপ মোড</string>
<string name="devices_remote_volume">ভলিউম</string> <string name="devices_remote_volume">ভলিউম</string>
<string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string> <string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string>
<string name="devices_remote_more_options">আরও অপশন</string> <string name="devices_remote_more_options">আরও অপশন</string>
@ -925,7 +914,6 @@
<string name="app_window_close">বন্ধ করুন</string> <string name="app_window_close">বন্ধ করুন</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">প্লেব্যাক সার্ভিস</string>
<string name="notification_playback_channel_name">প্লেব্যাক</string> <string name="notification_playback_channel_name">প্লেব্যাক</string>
<string name="notification_playback_channel_description">মিডিয়া প্লেব্যাক কন্ট্রোল</string> <string name="notification_playback_channel_description">মিডিয়া প্লেব্যাক কন্ট্রোল</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Eigener Download-Ordner</string> <string name="settings_download_folder_title">Eigener Download-Ordner</string>
<string name="settings_download_folder_current">Aktueller Ordner: %1$s</string> <string name="settings_download_folder_current">Aktueller Ordner: %1$s</string>
<string name="settings_download_folder_default">Aktueller Ordner: Standard-Musikordner</string>
<string name="settings_download_folder_description">Leer lassen, um den Standard-Musikordner zu verwenden.</string> <string name="settings_download_folder_description">Leer lassen, um den Standard-Musikordner zu verwenden.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokale Medienordner</string> <string name="settings_local_media_folders_title">Lokale Medienordner</string>
@ -213,8 +212,6 @@
<string name="settings_error_cache_size_range">Die Größe muss 0 oder eine positive Zahl sein.</string> <string name="settings_error_cache_size_range">Die Größe muss 0 oder eine positive Zahl sein.</string>
<string name="settings_enable_endless_playback_title">Endlose Wiedergabe aktivieren</string> <string name="settings_enable_endless_playback_title">Endlose Wiedergabe aktivieren</string>
<string name="settings_enable_endless_playback_subtitle">Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter.</string> <string name="settings_enable_endless_playback_subtitle">Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter.</string>
<string name="settings_enable_connect_title">Connect aktivieren</string>
<string name="settings_enable_connect_subtitle">Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen.</string>
<string name="settings_playback_port_title">Port des Wiedergabe-Proxyservers</string> <string name="settings_playback_port_title">Port des Wiedergabe-Proxyservers</string>
<string name="settings_allow_remote_control_title">Fernsteuerung erlauben</string> <string name="settings_allow_remote_control_title">Fernsteuerung erlauben</string>
<string name="settings_allow_remote_control_subtitle">Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden.</string> <string name="settings_allow_remote_control_subtitle">Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden.</string>
@ -495,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Geräte</string> <string name="devices_title">Geräte</string>
<string name="devices_refresh">Aktualisieren</string> <string name="devices_refresh">Aktualisieren</string>
<string name="devices_connected_to">Verbunden mit %1$s:%2$d</string>
<string name="devices_disconnect">Trennen</string> <string name="devices_disconnect">Trennen</string>
<string name="devices_connecting">Verbinden...</string> <string name="devices_connecting">Verbinden...</string>
<string name="devices_searching">Suche nach Geräten im Netzwerk...</string> <string name="devices_searching">Suche nach Geräten im Netzwerk...</string>
@ -503,20 +499,13 @@
<string name="devices_empty_hint">Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist.</string> <string name="devices_empty_hint">Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist.</string>
<string name="devices_unknown_device">Unbekanntes Gerät</string> <string name="devices_unknown_device">Unbekanntes Gerät</string>
<string name="devices_resolving">Wird aufgelöst...</string> <string name="devices_resolving">Wird aufgelöst...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Fernsteuerung</string> <string name="devices_remote_title">Fernsteuerung</string>
<string name="devices_remote_disconnected">Getrennt</string> <string name="devices_remote_disconnected">Getrennt</string>
<string name="devices_remote_connection_error">Verbindungsfehler: %1$s</string> <string name="devices_remote_connection_error">Verbindungsfehler: %1$s</string>
<string name="devices_remote_album_cover">Albumcover</string>
<string name="devices_remote_unknown_track">Unbekannter Titel</string> <string name="devices_remote_unknown_track">Unbekannter Titel</string>
<string name="devices_remote_unknown_artist">Unbekannter Künstler</string> <string name="devices_remote_unknown_artist">Unbekannter Künstler</string>
<string name="devices_remote_seek">Spulen</string>
<string name="devices_remote_shuffle">Zufallswiedergabe</string>
<string name="devices_remote_previous">Zurück</string>
<string name="devices_remote_next">Weiter</string>
<string name="devices_remote_loop_mode">Wiederholungsmodus</string>
<string name="devices_remote_volume">Lautstärke</string> <string name="devices_remote_volume">Lautstärke</string>
<string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string> <string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string>
<string name="devices_remote_more_options">Weitere Optionen</string> <string name="devices_remote_more_options">Weitere Optionen</string>
@ -925,7 +914,6 @@
<string name="app_window_close">Schließen</string> <string name="app_window_close">Schließen</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Wiedergabedienst</string>
<string name="notification_playback_channel_name">Wiedergabe</string> <string name="notification_playback_channel_name">Wiedergabe</string>
<string name="notification_playback_channel_description">Steuerung der Medienwiedergabe</string> <string name="notification_playback_channel_description">Steuerung der Medienwiedergabe</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Carpeta de descargas personalizada</string> <string name="settings_download_folder_title">Carpeta de descargas personalizada</string>
<string name="settings_download_folder_current">Carpeta actual: %1$s</string> <string name="settings_download_folder_current">Carpeta actual: %1$s</string>
<string name="settings_download_folder_default">Carpeta actual: carpeta de música predeterminada</string>
<string name="settings_download_folder_description">Déjalo vacío para usar la carpeta de música predeterminada.</string> <string name="settings_download_folder_description">Déjalo vacío para usar la carpeta de música predeterminada.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Carpetas de contenido local</string> <string name="settings_local_media_folders_title">Carpetas de contenido local</string>
@ -216,8 +215,6 @@
<string name="settings_error_cache_size_range">El tamaño debe ser 0 o un número positivo.</string> <string name="settings_error_cache_size_range">El tamaño debe ser 0 o un número positivo.</string>
<string name="settings_enable_endless_playback_title">Activar reproducción infinita</string> <string name="settings_enable_endless_playback_title">Activar reproducción infinita</string>
<string name="settings_enable_endless_playback_subtitle">Sigue reproduciendo canciones recomendadas cuando termine la cola.</string> <string name="settings_enable_endless_playback_subtitle">Sigue reproduciendo canciones recomendadas cuando termine la cola.</string>
<string name="settings_enable_connect_title">Activar Connect</string>
<string name="settings_enable_connect_subtitle">Ofrece controles de reproducción remota mediante la función Spotube Connect.</string>
<string name="settings_playback_port_title">Puerto del servidor proxy de reproducción</string> <string name="settings_playback_port_title">Puerto del servidor proxy de reproducción</string>
<string name="settings_allow_remote_control_title">Permitir control remoto</string> <string name="settings_allow_remote_control_title">Permitir control remoto</string>
<string name="settings_allow_remote_control_subtitle">Permite que otros dispositivos de la misma red controlen la reproducción al conectarse.</string> <string name="settings_allow_remote_control_subtitle">Permite que otros dispositivos de la misma red controlen la reproducción al conectarse.</string>
@ -499,7 +496,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivos</string> <string name="devices_title">Dispositivos</string>
<string name="devices_refresh">Actualizar</string> <string name="devices_refresh">Actualizar</string>
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
<string name="devices_disconnect">Desconectar</string> <string name="devices_disconnect">Desconectar</string>
<string name="devices_connecting">Conectando...</string> <string name="devices_connecting">Conectando...</string>
<string name="devices_searching">Buscando dispositivos en la red...</string> <string name="devices_searching">Buscando dispositivos en la red...</string>
@ -507,20 +503,13 @@
<string name="devices_empty_hint">Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes.</string> <string name="devices_empty_hint">Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes.</string>
<string name="devices_unknown_device">Dispositivo desconocido</string> <string name="devices_unknown_device">Dispositivo desconocido</string>
<string name="devices_resolving">Resolviendo...</string> <string name="devices_resolving">Resolviendo...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Control remoto</string> <string name="devices_remote_title">Control remoto</string>
<string name="devices_remote_disconnected">Desconectado</string> <string name="devices_remote_disconnected">Desconectado</string>
<string name="devices_remote_connection_error">Error de conexión: %1$s</string> <string name="devices_remote_connection_error">Error de conexión: %1$s</string>
<string name="devices_remote_album_cover">Portada del álbum</string>
<string name="devices_remote_unknown_track">Canción desconocida</string> <string name="devices_remote_unknown_track">Canción desconocida</string>
<string name="devices_remote_unknown_artist">Artista desconocido</string> <string name="devices_remote_unknown_artist">Artista desconocido</string>
<string name="devices_remote_seek">Avanzar</string>
<string name="devices_remote_shuffle">Aleatorio</string>
<string name="devices_remote_previous">Anterior</string>
<string name="devices_remote_next">Siguiente</string>
<string name="devices_remote_loop_mode">Modo de repetición</string>
<string name="devices_remote_volume">Volumen</string> <string name="devices_remote_volume">Volumen</string>
<string name="devices_remote_remove_from_queue">Quitar de la cola</string> <string name="devices_remote_remove_from_queue">Quitar de la cola</string>
<string name="devices_remote_more_options">Más opciones</string> <string name="devices_remote_more_options">Más opciones</string>
@ -931,7 +920,6 @@
<string name="app_window_close">Cerrar</string> <string name="app_window_close">Cerrar</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Servicio de reproducción</string>
<string name="notification_playback_channel_name">Reproducción</string> <string name="notification_playback_channel_name">Reproducción</string>
<string name="notification_playback_channel_description">Controles de reproducción multimedia</string> <string name="notification_playback_channel_description">Controles de reproducción multimedia</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">پوشهٔ دانلود سفارشی</string> <string name="settings_download_folder_title">پوشهٔ دانلود سفارشی</string>
<string name="settings_download_folder_current">پوشهٔ فعلی: %1$s</string> <string name="settings_download_folder_current">پوشهٔ فعلی: %1$s</string>
<string name="settings_download_folder_default">پوشهٔ فعلی: پوشهٔ پیش‌فرض موسیقی</string>
<string name="settings_download_folder_description">برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید.</string> <string name="settings_download_folder_description">برای استفاده از پوشهٔ پیش‌فرض موسیقی، این قسمت را خالی بگذارید.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">پوشه‌های رسانهٔ محلی</string> <string name="settings_local_media_folders_title">پوشه‌های رسانهٔ محلی</string>
@ -213,8 +212,6 @@
<string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string> <string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string>
<string name="settings_enable_endless_playback_title">فعال‌سازی پخش بی‌پایان</string> <string name="settings_enable_endless_playback_title">فعال‌سازی پخش بی‌پایان</string>
<string name="settings_enable_endless_playback_subtitle">وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند.</string> <string name="settings_enable_endless_playback_subtitle">وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند.</string>
<string name="settings_enable_connect_title">فعال‌سازی Connect</string>
<string name="settings_enable_connect_subtitle">کنترل‌های پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید.</string>
<string name="settings_playback_port_title">درگاه سرور پراکسی پخش</string> <string name="settings_playback_port_title">درگاه سرور پراکسی پخش</string>
<string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string> <string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string>
<string name="settings_allow_remote_control_subtitle">به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string> <string name="settings_allow_remote_control_subtitle">به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string>
@ -495,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">دستگاه‌ها</string> <string name="devices_title">دستگاه‌ها</string>
<string name="devices_refresh">به‌روزرسانی</string> <string name="devices_refresh">به‌روزرسانی</string>
<string name="devices_connected_to">متصل به %1$s:%2$d</string>
<string name="devices_disconnect">قطع اتصال</string> <string name="devices_disconnect">قطع اتصال</string>
<string name="devices_connecting">در حال اتصال...</string> <string name="devices_connecting">در حال اتصال...</string>
<string name="devices_searching">در حال جستجوی دستگاه‌ها در شبکه...</string> <string name="devices_searching">در حال جستجوی دستگاه‌ها در شبکه...</string>
@ -503,20 +499,13 @@
<string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string> <string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string>
<string name="devices_unknown_device">دستگاه ناشناس</string> <string name="devices_unknown_device">دستگاه ناشناس</string>
<string name="devices_resolving">در حال شناسایی...</string> <string name="devices_resolving">در حال شناسایی...</string>
<string name="devices_device_id">شناسه: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">کنترل از راه دور</string> <string name="devices_remote_title">کنترل از راه دور</string>
<string name="devices_remote_disconnected">قطع شد</string> <string name="devices_remote_disconnected">قطع شد</string>
<string name="devices_remote_connection_error">خطای اتصال: %1$s</string> <string name="devices_remote_connection_error">خطای اتصال: %1$s</string>
<string name="devices_remote_album_cover">کاور آلبوم</string>
<string name="devices_remote_unknown_track">آهنگ ناشناس</string> <string name="devices_remote_unknown_track">آهنگ ناشناس</string>
<string name="devices_remote_unknown_artist">هنرمند ناشناس</string> <string name="devices_remote_unknown_artist">هنرمند ناشناس</string>
<string name="devices_remote_seek">جابه‌جایی</string>
<string name="devices_remote_shuffle">پخش تصادفی</string>
<string name="devices_remote_previous">قبلی</string>
<string name="devices_remote_next">بعدی</string>
<string name="devices_remote_loop_mode">حالت تکرار</string>
<string name="devices_remote_volume">صدا</string> <string name="devices_remote_volume">صدا</string>
<string name="devices_remote_remove_from_queue">حذف از صف</string> <string name="devices_remote_remove_from_queue">حذف از صف</string>
<string name="devices_remote_more_options">گزینه‌های بیشتر</string> <string name="devices_remote_more_options">گزینه‌های بیشتر</string>
@ -925,7 +914,6 @@
<string name="app_window_close">بستن</string> <string name="app_window_close">بستن</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">سرویس پخش</string>
<string name="notification_playback_channel_name">پخش</string> <string name="notification_playback_channel_name">پخش</string>
<string name="notification_playback_channel_description">کنترل‌های پخش رسانه</string> <string name="notification_playback_channel_description">کنترل‌های پخش رسانه</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Dossier de téléchargement personnalisé</string> <string name="settings_download_folder_title">Dossier de téléchargement personnalisé</string>
<string name="settings_download_folder_current">Dossier actuel : %1$s</string> <string name="settings_download_folder_current">Dossier actuel : %1$s</string>
<string name="settings_download_folder_default">Dossier actuel : dossier Musique par défaut</string>
<string name="settings_download_folder_description">Laissez vide pour utiliser le dossier Musique par défaut.</string> <string name="settings_download_folder_description">Laissez vide pour utiliser le dossier Musique par défaut.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Dossiers de médias locaux</string> <string name="settings_local_media_folders_title">Dossiers de médias locaux</string>
@ -216,8 +215,6 @@
<string name="settings_error_cache_size_range">La taille doit être 0 ou un nombre positif.</string> <string name="settings_error_cache_size_range">La taille doit être 0 ou un nombre positif.</string>
<string name="settings_enable_endless_playback_title">Activer la lecture continue</string> <string name="settings_enable_endless_playback_title">Activer la lecture continue</string>
<string name="settings_enable_endless_playback_subtitle">Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente.</string> <string name="settings_enable_endless_playback_subtitle">Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente.</string>
<string name="settings_enable_connect_title">Activer Connect</string>
<string name="settings_enable_connect_subtitle">Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect.</string>
<string name="settings_playback_port_title">Port du serveur proxy de lecture</string> <string name="settings_playback_port_title">Port du serveur proxy de lecture</string>
<string name="settings_allow_remote_control_title">Autoriser le contrôle à distance</string> <string name="settings_allow_remote_control_title">Autoriser le contrôle à distance</string>
<string name="settings_allow_remote_control_subtitle">Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent.</string> <string name="settings_allow_remote_control_subtitle">Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent.</string>
@ -499,7 +496,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Appareils</string> <string name="devices_title">Appareils</string>
<string name="devices_refresh">Actualiser</string> <string name="devices_refresh">Actualiser</string>
<string name="devices_connected_to">Connecté à %1$s:%2$d</string>
<string name="devices_disconnect">Se déconnecter</string> <string name="devices_disconnect">Se déconnecter</string>
<string name="devices_connecting">Connexion...</string> <string name="devices_connecting">Connexion...</string>
<string name="devices_searching">Recherche d\'appareils sur le réseau...</string> <string name="devices_searching">Recherche d\'appareils sur le réseau...</string>
@ -507,20 +503,13 @@
<string name="devices_empty_hint">Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil.</string> <string name="devices_empty_hint">Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil.</string>
<string name="devices_unknown_device">Appareil inconnu</string> <string name="devices_unknown_device">Appareil inconnu</string>
<string name="devices_resolving">Résolution...</string> <string name="devices_resolving">Résolution...</string>
<string name="devices_device_id">ID : %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Contrôle à distance</string> <string name="devices_remote_title">Contrôle à distance</string>
<string name="devices_remote_disconnected">Déconnecté</string> <string name="devices_remote_disconnected">Déconnecté</string>
<string name="devices_remote_connection_error">Erreur de connexion : %1$s</string> <string name="devices_remote_connection_error">Erreur de connexion : %1$s</string>
<string name="devices_remote_album_cover">Pochette d\'album</string>
<string name="devices_remote_unknown_track">Titre inconnu</string> <string name="devices_remote_unknown_track">Titre inconnu</string>
<string name="devices_remote_unknown_artist">Artiste inconnu</string> <string name="devices_remote_unknown_artist">Artiste inconnu</string>
<string name="devices_remote_seek">Avancer/reculer</string>
<string name="devices_remote_shuffle">Lecture aléatoire</string>
<string name="devices_remote_previous">Précédent</string>
<string name="devices_remote_next">Suivant</string>
<string name="devices_remote_loop_mode">Mode de répétition</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Retirer de la file d\'attente</string> <string name="devices_remote_remove_from_queue">Retirer de la file d\'attente</string>
<string name="devices_remote_more_options">Plus d\'options</string> <string name="devices_remote_more_options">Plus d\'options</string>
@ -931,7 +920,6 @@
<string name="app_window_close">Fermer</string> <string name="app_window_close">Fermer</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Service de lecture</string>
<string name="notification_playback_channel_name">Lecture</string> <string name="notification_playback_channel_name">Lecture</string>
<string name="notification_playback_channel_description">Commandes de lecture multimédia</string> <string name="notification_playback_channel_description">Commandes de lecture multimédia</string>

View File

@ -170,7 +170,6 @@
<string name="settings_download_folder_title">डाउनलोड फ़ोल्डर बदलें</string> <string name="settings_download_folder_title">डाउनलोड फ़ोल्डर बदलें</string>
<string name="settings_download_folder_current">मौजूदा फ़ोल्डर: %1$s</string> <string name="settings_download_folder_current">मौजूदा फ़ोल्डर: %1$s</string>
<string name="settings_download_folder_default">मौजूदा फ़ोल्डर: डिफ़ॉल्ट म्यूज़िक फ़ोल्डर</string>
<string name="settings_download_folder_description">डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें।</string> <string name="settings_download_folder_description">डिफ़ॉल्ट म्यूज़िक फ़ोल्डर इस्तेमाल करने के लिए इसे खाली छोड़ें।</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">लोकल मीडिया फ़ोल्डर</string> <string name="settings_local_media_folders_title">लोकल मीडिया फ़ोल्डर</string>
@ -212,8 +211,6 @@
<string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string> <string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string>
<string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string> <string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string>
<string name="settings_enable_endless_playback_subtitle">कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।</string> <string name="settings_enable_endless_playback_subtitle">कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।</string>
<string name="settings_enable_connect_title">Connect चालू करें</string>
<string name="settings_enable_connect_subtitle">Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं।</string>
<string name="settings_playback_port_title">प्लेबैक प्रॉक्सी सर्वर पोर्ट</string> <string name="settings_playback_port_title">प्लेबैक प्रॉक्सी सर्वर पोर्ट</string>
<string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string> <string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string>
<string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string> <string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string>
@ -494,7 +491,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">डिवाइस</string> <string name="devices_title">डिवाइस</string>
<string name="devices_refresh">रीफ़्रेश करें</string> <string name="devices_refresh">रीफ़्रेश करें</string>
<string name="devices_connected_to">%1$s:%2$d से कनेक्ट है</string>
<string name="devices_disconnect">डिस्कनेक्ट करें</string> <string name="devices_disconnect">डिस्कनेक्ट करें</string>
<string name="devices_connecting">कनेक्ट हो रहा है...</string> <string name="devices_connecting">कनेक्ट हो रहा है...</string>
<string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string> <string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string>
@ -502,20 +498,13 @@
<string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string> <string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string>
<string name="devices_unknown_device">अज्ञात डिवाइस</string> <string name="devices_unknown_device">अज्ञात डिवाइस</string>
<string name="devices_resolving">पता लगाया जा रहा है...</string> <string name="devices_resolving">पता लगाया जा रहा है...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">रिमोट कंट्रोल</string> <string name="devices_remote_title">रिमोट कंट्रोल</string>
<string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string> <string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string>
<string name="devices_remote_connection_error">कनेक्शन में गड़बड़ी: %1$s</string> <string name="devices_remote_connection_error">कनेक्शन में गड़बड़ी: %1$s</string>
<string name="devices_remote_album_cover">एल्बम कवर</string>
<string name="devices_remote_unknown_track">अज्ञात ट्रैक</string> <string name="devices_remote_unknown_track">अज्ञात ट्रैक</string>
<string name="devices_remote_unknown_artist">अज्ञात कलाकार</string> <string name="devices_remote_unknown_artist">अज्ञात कलाकार</string>
<string name="devices_remote_seek">आगे-पीछे करें</string>
<string name="devices_remote_shuffle">शफ़ल</string>
<string name="devices_remote_previous">पिछला</string>
<string name="devices_remote_next">अगला</string>
<string name="devices_remote_loop_mode">रिपीट मोड</string>
<string name="devices_remote_volume">वॉल्यूम</string> <string name="devices_remote_volume">वॉल्यूम</string>
<string name="devices_remote_remove_from_queue">कतार से हटाएं</string> <string name="devices_remote_remove_from_queue">कतार से हटाएं</string>
<string name="devices_remote_more_options">और विकल्प</string> <string name="devices_remote_more_options">और विकल्प</string>
@ -924,7 +913,6 @@
<string name="app_window_close">बंद करें</string> <string name="app_window_close">बंद करें</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">प्लेबैक सेवा</string>
<string name="notification_playback_channel_name">प्लेबैक</string> <string name="notification_playback_channel_name">प्लेबैक</string>
<string name="notification_playback_channel_description">मीडिया प्लेबैक कंट्रोल</string> <string name="notification_playback_channel_description">मीडिया प्लेबैक कंट्रोल</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">Ganti folder unduhan</string> <string name="settings_download_folder_title">Ganti folder unduhan</string>
<string name="settings_download_folder_current">Folder saat ini: %1$s</string> <string name="settings_download_folder_current">Folder saat ini: %1$s</string>
<string name="settings_download_folder_default">Folder saat ini: folder musik bawaan</string>
<string name="settings_download_folder_description">Biarkan kosong untuk memakai folder musik bawaan.</string> <string name="settings_download_folder_description">Biarkan kosong untuk memakai folder musik bawaan.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Folder media lokal</string> <string name="settings_local_media_folders_title">Folder media lokal</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">Ukuran harus 0 atau bilangan positif.</string> <string name="settings_error_cache_size_range">Ukuran harus 0 atau bilangan positif.</string>
<string name="settings_enable_endless_playback_title">Aktifkan pemutaran tanpa henti</string> <string name="settings_enable_endless_playback_title">Aktifkan pemutaran tanpa henti</string>
<string name="settings_enable_endless_playback_subtitle">Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis.</string> <string name="settings_enable_endless_playback_subtitle">Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis.</string>
<string name="settings_enable_connect_title">Aktifkan Connect</string>
<string name="settings_enable_connect_subtitle">Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect.</string>
<string name="settings_playback_port_title">Port server proxy pemutaran</string> <string name="settings_playback_port_title">Port server proxy pemutaran</string>
<string name="settings_allow_remote_control_title">Izinkan kontrol jarak jauh</string> <string name="settings_allow_remote_control_title">Izinkan kontrol jarak jauh</string>
<string name="settings_allow_remote_control_subtitle">Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung.</string> <string name="settings_allow_remote_control_subtitle">Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung.</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Perangkat</string> <string name="devices_title">Perangkat</string>
<string name="devices_refresh">Muat ulang</string> <string name="devices_refresh">Muat ulang</string>
<string name="devices_connected_to">Terhubung ke %1$s:%2$d</string>
<string name="devices_disconnect">Putuskan</string> <string name="devices_disconnect">Putuskan</string>
<string name="devices_connecting">Menghubungkan...</string> <string name="devices_connecting">Menghubungkan...</string>
<string name="devices_searching">Mencari perangkat di jaringan...</string> <string name="devices_searching">Mencari perangkat di jaringan...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan.</string> <string name="devices_empty_hint">Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan.</string>
<string name="devices_unknown_device">Perangkat Tidak Dikenal</string> <string name="devices_unknown_device">Perangkat Tidak Dikenal</string>
<string name="devices_resolving">Menyelesaikan...</string> <string name="devices_resolving">Menyelesaikan...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Kontrol Jarak Jauh</string> <string name="devices_remote_title">Kontrol Jarak Jauh</string>
<string name="devices_remote_disconnected">Terputus</string> <string name="devices_remote_disconnected">Terputus</string>
<string name="devices_remote_connection_error">Kesalahan koneksi: %1$s</string> <string name="devices_remote_connection_error">Kesalahan koneksi: %1$s</string>
<string name="devices_remote_album_cover">Sampul album</string>
<string name="devices_remote_unknown_track">Lagu Tidak Dikenal</string> <string name="devices_remote_unknown_track">Lagu Tidak Dikenal</string>
<string name="devices_remote_unknown_artist">Artis Tidak Dikenal</string> <string name="devices_remote_unknown_artist">Artis Tidak Dikenal</string>
<string name="devices_remote_seek">Cari posisi</string>
<string name="devices_remote_shuffle">Acak</string>
<string name="devices_remote_previous">Sebelumnya</string>
<string name="devices_remote_next">Berikutnya</string>
<string name="devices_remote_loop_mode">Mode ulang</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Hapus dari antrean</string> <string name="devices_remote_remove_from_queue">Hapus dari antrean</string>
<string name="devices_remote_more_options">Opsi lainnya</string> <string name="devices_remote_more_options">Opsi lainnya</string>
@ -919,7 +908,6 @@
<string name="app_window_close">Tutup</string> <string name="app_window_close">Tutup</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Layanan pemutaran</string>
<string name="notification_playback_channel_name">Pemutaran</string> <string name="notification_playback_channel_name">Pemutaran</string>
<string name="notification_playback_channel_description">Kontrol pemutaran media</string> <string name="notification_playback_channel_description">Kontrol pemutaran media</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Cartella di download personalizzata</string> <string name="settings_download_folder_title">Cartella di download personalizzata</string>
<string name="settings_download_folder_current">Cartella attuale: %1$s</string> <string name="settings_download_folder_current">Cartella attuale: %1$s</string>
<string name="settings_download_folder_default">Cartella attuale: cartella Musica predefinita</string>
<string name="settings_download_folder_description">Lascia vuoto per usare la cartella Musica predefinita.</string> <string name="settings_download_folder_description">Lascia vuoto per usare la cartella Musica predefinita.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Cartelle multimediali locali</string> <string name="settings_local_media_folders_title">Cartelle multimediali locali</string>
@ -216,8 +215,6 @@
<string name="settings_error_cache_size_range">La dimensione deve essere 0 o un numero positivo.</string> <string name="settings_error_cache_size_range">La dimensione deve essere 0 o un numero positivo.</string>
<string name="settings_enable_endless_playback_title">Attiva riproduzione continua</string> <string name="settings_enable_endless_playback_title">Attiva riproduzione continua</string>
<string name="settings_enable_endless_playback_subtitle">Continua la riproduzione con brani consigliati quando la coda finisce.</string> <string name="settings_enable_endless_playback_subtitle">Continua la riproduzione con brani consigliati quando la coda finisce.</string>
<string name="settings_enable_connect_title">Attiva Connect</string>
<string name="settings_enable_connect_subtitle">Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect.</string>
<string name="settings_playback_port_title">Porta del server proxy di riproduzione</string> <string name="settings_playback_port_title">Porta del server proxy di riproduzione</string>
<string name="settings_allow_remote_control_title">Consenti controllo remoto</string> <string name="settings_allow_remote_control_title">Consenti controllo remoto</string>
<string name="settings_allow_remote_control_subtitle">Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono.</string> <string name="settings_allow_remote_control_subtitle">Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono.</string>
@ -499,7 +496,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivi</string> <string name="devices_title">Dispositivi</string>
<string name="devices_refresh">Aggiorna</string> <string name="devices_refresh">Aggiorna</string>
<string name="devices_connected_to">Connesso a %1$s:%2$d</string>
<string name="devices_disconnect">Disconnetti</string> <string name="devices_disconnect">Disconnetti</string>
<string name="devices_connecting">Connessione...</string> <string name="devices_connecting">Connessione...</string>
<string name="devices_searching">Ricerca di dispositivi sulla rete...</string> <string name="devices_searching">Ricerca di dispositivi sulla rete...</string>
@ -507,20 +503,13 @@
<string name="devices_empty_hint">Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni.</string> <string name="devices_empty_hint">Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni.</string>
<string name="devices_unknown_device">Dispositivo sconosciuto</string> <string name="devices_unknown_device">Dispositivo sconosciuto</string>
<string name="devices_resolving">Risoluzione...</string> <string name="devices_resolving">Risoluzione...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Controllo remoto</string> <string name="devices_remote_title">Controllo remoto</string>
<string name="devices_remote_disconnected">Disconnesso</string> <string name="devices_remote_disconnected">Disconnesso</string>
<string name="devices_remote_connection_error">Errore di connessione: %1$s</string> <string name="devices_remote_connection_error">Errore di connessione: %1$s</string>
<string name="devices_remote_album_cover">Copertina dell\'album</string>
<string name="devices_remote_unknown_track">Brano sconosciuto</string> <string name="devices_remote_unknown_track">Brano sconosciuto</string>
<string name="devices_remote_unknown_artist">Artista sconosciuto</string> <string name="devices_remote_unknown_artist">Artista sconosciuto</string>
<string name="devices_remote_seek">Posizione</string>
<string name="devices_remote_shuffle">Riproduzione casuale</string>
<string name="devices_remote_previous">Precedente</string>
<string name="devices_remote_next">Successivo</string>
<string name="devices_remote_loop_mode">Modalità ripetizione</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string> <string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string>
<string name="devices_remote_more_options">Altre opzioni</string> <string name="devices_remote_more_options">Altre opzioni</string>
@ -931,7 +920,6 @@
<string name="app_window_close">Chiudi</string> <string name="app_window_close">Chiudi</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Servizio di riproduzione</string>
<string name="notification_playback_channel_name">Riproduzione</string> <string name="notification_playback_channel_name">Riproduzione</string>
<string name="notification_playback_channel_description">Controlli di riproduzione multimediale</string> <string name="notification_playback_channel_description">Controlli di riproduzione multimediale</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">ダウンロードフォルダの変更</string> <string name="settings_download_folder_title">ダウンロードフォルダの変更</string>
<string name="settings_download_folder_current">現在のフォルダ: %1$s</string> <string name="settings_download_folder_current">現在のフォルダ: %1$s</string>
<string name="settings_download_folder_default">現在のフォルダ: デフォルトの音楽フォルダ</string>
<string name="settings_download_folder_description">空欄にするとデフォルトの音楽フォルダを使用します。</string> <string name="settings_download_folder_description">空欄にするとデフォルトの音楽フォルダを使用します。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">ローカルメディアフォルダ</string> <string name="settings_local_media_folders_title">ローカルメディアフォルダ</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string> <string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string>
<string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string> <string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string>
<string name="settings_enable_endless_playback_subtitle">キューが終わったら、おすすめの曲で再生を続けます。</string> <string name="settings_enable_endless_playback_subtitle">キューが終わったら、おすすめの曲で再生を続けます。</string>
<string name="settings_enable_connect_title">Connectを有効化</string>
<string name="settings_enable_connect_subtitle">Spotube Connect機能でリモート再生コントロールを公開します。</string>
<string name="settings_playback_port_title">再生プロキシサーバーのポート</string> <string name="settings_playback_port_title">再生プロキシサーバーのポート</string>
<string name="settings_allow_remote_control_title">リモート操作を許可</string> <string name="settings_allow_remote_control_title">リモート操作を許可</string>
<string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string> <string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">デバイス</string> <string name="devices_title">デバイス</string>
<string name="devices_refresh">更新</string> <string name="devices_refresh">更新</string>
<string name="devices_connected_to">%1$s:%2$d に接続済み</string>
<string name="devices_disconnect">切断</string> <string name="devices_disconnect">切断</string>
<string name="devices_connecting">接続中...</string> <string name="devices_connecting">接続中...</string>
<string name="devices_searching">ネットワーク上のデバイスを検索中...</string> <string name="devices_searching">ネットワーク上のデバイスを検索中...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string> <string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string>
<string name="devices_unknown_device">不明なデバイス</string> <string name="devices_unknown_device">不明なデバイス</string>
<string name="devices_resolving">解決中...</string> <string name="devices_resolving">解決中...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">リモート操作</string> <string name="devices_remote_title">リモート操作</string>
<string name="devices_remote_disconnected">切断されました</string> <string name="devices_remote_disconnected">切断されました</string>
<string name="devices_remote_connection_error">接続エラー: %1$s</string> <string name="devices_remote_connection_error">接続エラー: %1$s</string>
<string name="devices_remote_album_cover">アルバムカバー</string>
<string name="devices_remote_unknown_track">不明な曲</string> <string name="devices_remote_unknown_track">不明な曲</string>
<string name="devices_remote_unknown_artist">不明なアーティスト</string> <string name="devices_remote_unknown_artist">不明なアーティスト</string>
<string name="devices_remote_seek">シーク</string>
<string name="devices_remote_shuffle">シャッフル</string>
<string name="devices_remote_previous">前へ</string>
<string name="devices_remote_next">次へ</string>
<string name="devices_remote_loop_mode">リピートモード</string>
<string name="devices_remote_volume">音量</string> <string name="devices_remote_volume">音量</string>
<string name="devices_remote_remove_from_queue">キューから削除</string> <string name="devices_remote_remove_from_queue">キューから削除</string>
<string name="devices_remote_more_options">その他のオプション</string> <string name="devices_remote_more_options">その他のオプション</string>
@ -919,7 +908,6 @@
<string name="app_window_close">閉じる</string> <string name="app_window_close">閉じる</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">再生サービス</string>
<string name="notification_playback_channel_name">再生</string> <string name="notification_playback_channel_name">再生</string>
<string name="notification_playback_channel_description">メディア再生コントロール</string> <string name="notification_playback_channel_description">メディア再生コントロール</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">다운로드 폴더 지정</string> <string name="settings_download_folder_title">다운로드 폴더 지정</string>
<string name="settings_download_folder_current">현재 폴더: %1$s</string> <string name="settings_download_folder_current">현재 폴더: %1$s</string>
<string name="settings_download_folder_default">현재 폴더: 기본 음악 폴더</string>
<string name="settings_download_folder_description">기본 음악 폴더를 사용하려면 비워 두세요.</string> <string name="settings_download_folder_description">기본 음악 폴더를 사용하려면 비워 두세요.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">로컬 미디어 폴더</string> <string name="settings_local_media_folders_title">로컬 미디어 폴더</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string> <string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string>
<string name="settings_enable_endless_playback_title">끝없는 재생 사용</string> <string name="settings_enable_endless_playback_title">끝없는 재생 사용</string>
<string name="settings_enable_endless_playback_subtitle">대기열이 끝나면 추천 트랙으로 계속 재생해요.</string> <string name="settings_enable_endless_playback_subtitle">대기열이 끝나면 추천 트랙으로 계속 재생해요.</string>
<string name="settings_enable_connect_title">Connect 사용</string>
<string name="settings_enable_connect_subtitle">Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요.</string>
<string name="settings_playback_port_title">재생 프록시 서버 포트</string> <string name="settings_playback_port_title">재생 프록시 서버 포트</string>
<string name="settings_allow_remote_control_title">원격 제어 허용</string> <string name="settings_allow_remote_control_title">원격 제어 허용</string>
<string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string> <string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">기기</string> <string name="devices_title">기기</string>
<string name="devices_refresh">새로고침</string> <string name="devices_refresh">새로고침</string>
<string name="devices_connected_to">%1$s:%2$d에 연결됨</string>
<string name="devices_disconnect">연결 해제</string> <string name="devices_disconnect">연결 해제</string>
<string name="devices_connecting">연결 중...</string> <string name="devices_connecting">연결 중...</string>
<string name="devices_searching">네트워크에서 기기를 찾는 중...</string> <string name="devices_searching">네트워크에서 기기를 찾는 중...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string> <string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string>
<string name="devices_unknown_device">알 수 없는 기기</string> <string name="devices_unknown_device">알 수 없는 기기</string>
<string name="devices_resolving">확인 중...</string> <string name="devices_resolving">확인 중...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">원격 제어</string> <string name="devices_remote_title">원격 제어</string>
<string name="devices_remote_disconnected">연결 끊김</string> <string name="devices_remote_disconnected">연결 끊김</string>
<string name="devices_remote_connection_error">연결 오류: %1$s</string> <string name="devices_remote_connection_error">연결 오류: %1$s</string>
<string name="devices_remote_album_cover">앨범 커버</string>
<string name="devices_remote_unknown_track">알 수 없는 트랙</string> <string name="devices_remote_unknown_track">알 수 없는 트랙</string>
<string name="devices_remote_unknown_artist">알 수 없는 아티스트</string> <string name="devices_remote_unknown_artist">알 수 없는 아티스트</string>
<string name="devices_remote_seek">탐색</string>
<string name="devices_remote_shuffle">셔플</string>
<string name="devices_remote_previous">이전</string>
<string name="devices_remote_next">다음</string>
<string name="devices_remote_loop_mode">반복 모드</string>
<string name="devices_remote_volume">볼륨</string> <string name="devices_remote_volume">볼륨</string>
<string name="devices_remote_remove_from_queue">대기열에서 삭제</string> <string name="devices_remote_remove_from_queue">대기열에서 삭제</string>
<string name="devices_remote_more_options">옵션 더보기</string> <string name="devices_remote_more_options">옵션 더보기</string>
@ -919,7 +908,6 @@
<string name="app_window_close">닫기</string> <string name="app_window_close">닫기</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">재생 서비스</string>
<string name="notification_playback_channel_name">재생</string> <string name="notification_playback_channel_name">재생</string>
<string name="notification_playback_channel_description">미디어 재생 컨트롤</string> <string name="notification_playback_channel_description">미디어 재생 컨트롤</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Andere downloadmap</string> <string name="settings_download_folder_title">Andere downloadmap</string>
<string name="settings_download_folder_current">Huidige map: %1$s</string> <string name="settings_download_folder_current">Huidige map: %1$s</string>
<string name="settings_download_folder_default">Huidige map: standaard muziekmap</string>
<string name="settings_download_folder_description">Laat dit leeg om de standaard muziekmap te gebruiken.</string> <string name="settings_download_folder_description">Laat dit leeg om de standaard muziekmap te gebruiken.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokale mediamappen</string> <string name="settings_local_media_folders_title">Lokale mediamappen</string>
@ -213,8 +212,6 @@
<string name="settings_error_cache_size_range">Grootte moet 0 of een positief getal zijn.</string> <string name="settings_error_cache_size_range">Grootte moet 0 of een positief getal zijn.</string>
<string name="settings_enable_endless_playback_title">Eindeloos afspelen inschakelen</string> <string name="settings_enable_endless_playback_title">Eindeloos afspelen inschakelen</string>
<string name="settings_enable_endless_playback_subtitle">Blijf afspelen met aanbevolen nummers als je wachtrij leeg is.</string> <string name="settings_enable_endless_playback_subtitle">Blijf afspelen met aanbevolen nummers als je wachtrij leeg is.</string>
<string name="settings_enable_connect_title">Connect inschakelen</string>
<string name="settings_enable_connect_subtitle">Maak bediening op afstand beschikbaar via Spotube Connect.</string>
<string name="settings_playback_port_title">Poort van afspeelproxyserver</string> <string name="settings_playback_port_title">Poort van afspeelproxyserver</string>
<string name="settings_allow_remote_control_title">Bediening op afstand toestaan</string> <string name="settings_allow_remote_control_title">Bediening op afstand toestaan</string>
<string name="settings_allow_remote_control_subtitle">Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken.</string> <string name="settings_allow_remote_control_subtitle">Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken.</string>
@ -495,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Apparaten</string> <string name="devices_title">Apparaten</string>
<string name="devices_refresh">Vernieuwen</string> <string name="devices_refresh">Vernieuwen</string>
<string name="devices_connected_to">Verbonden met %1$s:%2$d</string>
<string name="devices_disconnect">Verbinding verbreken</string> <string name="devices_disconnect">Verbinding verbreken</string>
<string name="devices_connecting">Verbinden...</string> <string name="devices_connecting">Verbinden...</string>
<string name="devices_searching">Zoeken naar apparaten op het netwerk...</string> <string name="devices_searching">Zoeken naar apparaten op het netwerk...</string>
@ -503,20 +499,13 @@
<string name="devices_empty_hint">Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat.</string> <string name="devices_empty_hint">Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat.</string>
<string name="devices_unknown_device">Onbekend apparaat</string> <string name="devices_unknown_device">Onbekend apparaat</string>
<string name="devices_resolving">Bezig met ophalen...</string> <string name="devices_resolving">Bezig met ophalen...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Bediening op afstand</string> <string name="devices_remote_title">Bediening op afstand</string>
<string name="devices_remote_disconnected">Niet verbonden</string> <string name="devices_remote_disconnected">Niet verbonden</string>
<string name="devices_remote_connection_error">Verbindingsfout: %1$s</string> <string name="devices_remote_connection_error">Verbindingsfout: %1$s</string>
<string name="devices_remote_album_cover">Albumhoes</string>
<string name="devices_remote_unknown_track">Onbekend nummer</string> <string name="devices_remote_unknown_track">Onbekend nummer</string>
<string name="devices_remote_unknown_artist">Onbekende artiest</string> <string name="devices_remote_unknown_artist">Onbekende artiest</string>
<string name="devices_remote_seek">Zoeken in nummer</string>
<string name="devices_remote_shuffle">Shuffle</string>
<string name="devices_remote_previous">Vorige</string>
<string name="devices_remote_next">Volgende</string>
<string name="devices_remote_loop_mode">Herhaalmodus</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string> <string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string>
<string name="devices_remote_more_options">Meer opties</string> <string name="devices_remote_more_options">Meer opties</string>
@ -925,7 +914,6 @@
<string name="app_window_close">Sluiten</string> <string name="app_window_close">Sluiten</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Afspeelservice</string>
<string name="notification_playback_channel_name">Afspelen</string> <string name="notification_playback_channel_name">Afspelen</string>
<string name="notification_playback_channel_description">Bediening voor media-afspelen</string> <string name="notification_playback_channel_description">Bediening voor media-afspelen</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Własny folder pobierania</string> <string name="settings_download_folder_title">Własny folder pobierania</string>
<string name="settings_download_folder_current">Obecny folder: %1$s</string> <string name="settings_download_folder_current">Obecny folder: %1$s</string>
<string name="settings_download_folder_default">Obecny folder: domyślny folder muzyki</string>
<string name="settings_download_folder_description">Pozostaw puste, aby używać domyślnego folderu muzyki.</string> <string name="settings_download_folder_description">Pozostaw puste, aby używać domyślnego folderu muzyki.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Foldery lokalnych multimediów</string> <string name="settings_local_media_folders_title">Foldery lokalnych multimediów</string>
@ -219,8 +218,6 @@
<string name="settings_error_cache_size_range">Rozmiar musi wynosić 0 lub być liczbą dodatnią.</string> <string name="settings_error_cache_size_range">Rozmiar musi wynosić 0 lub być liczbą dodatnią.</string>
<string name="settings_enable_endless_playback_title">Włącz nieskończone odtwarzanie</string> <string name="settings_enable_endless_playback_title">Włącz nieskończone odtwarzanie</string>
<string name="settings_enable_endless_playback_subtitle">Gdy kolejka się skończy, odtwarzaj dalej polecane utwory.</string> <string name="settings_enable_endless_playback_subtitle">Gdy kolejka się skończy, odtwarzaj dalej polecane utwory.</string>
<string name="settings_enable_connect_title">Włącz Connect</string>
<string name="settings_enable_connect_subtitle">Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect.</string>
<string name="settings_playback_port_title">Port serwera proxy odtwarzania</string> <string name="settings_playback_port_title">Port serwera proxy odtwarzania</string>
<string name="settings_allow_remote_control_title">Zezwól na zdalne sterowanie</string> <string name="settings_allow_remote_control_title">Zezwól na zdalne sterowanie</string>
<string name="settings_allow_remote_control_subtitle">Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu.</string> <string name="settings_allow_remote_control_subtitle">Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu.</string>
@ -503,7 +500,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Urządzenia</string> <string name="devices_title">Urządzenia</string>
<string name="devices_refresh">Odśwież</string> <string name="devices_refresh">Odśwież</string>
<string name="devices_connected_to">Połączono z %1$s:%2$d</string>
<string name="devices_disconnect">Rozłącz</string> <string name="devices_disconnect">Rozłącz</string>
<string name="devices_connecting">Łączenie...</string> <string name="devices_connecting">Łączenie...</string>
<string name="devices_searching">Wyszukiwanie urządzeń w sieci...</string> <string name="devices_searching">Wyszukiwanie urządzeń w sieci...</string>
@ -511,20 +507,13 @@
<string name="devices_empty_hint">Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”.</string> <string name="devices_empty_hint">Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”.</string>
<string name="devices_unknown_device">Nieznane urządzenie</string> <string name="devices_unknown_device">Nieznane urządzenie</string>
<string name="devices_resolving">Ustalanie adresu...</string> <string name="devices_resolving">Ustalanie adresu...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Zdalne sterowanie</string> <string name="devices_remote_title">Zdalne sterowanie</string>
<string name="devices_remote_disconnected">Rozłączono</string> <string name="devices_remote_disconnected">Rozłączono</string>
<string name="devices_remote_connection_error">Błąd połączenia: %1$s</string> <string name="devices_remote_connection_error">Błąd połączenia: %1$s</string>
<string name="devices_remote_album_cover">Okładka albumu</string>
<string name="devices_remote_unknown_track">Nieznany utwór</string> <string name="devices_remote_unknown_track">Nieznany utwór</string>
<string name="devices_remote_unknown_artist">Nieznany wykonawca</string> <string name="devices_remote_unknown_artist">Nieznany wykonawca</string>
<string name="devices_remote_seek">Przewiń</string>
<string name="devices_remote_shuffle">Odtwarzanie losowe</string>
<string name="devices_remote_previous">Poprzedni</string>
<string name="devices_remote_next">Następny</string>
<string name="devices_remote_loop_mode">Tryb powtarzania</string>
<string name="devices_remote_volume">Głośność</string> <string name="devices_remote_volume">Głośność</string>
<string name="devices_remote_remove_from_queue">Usuń z kolejki</string> <string name="devices_remote_remove_from_queue">Usuń z kolejki</string>
<string name="devices_remote_more_options">Więcej opcji</string> <string name="devices_remote_more_options">Więcej opcji</string>
@ -937,7 +926,6 @@
<string name="app_window_close">Zamknij</string> <string name="app_window_close">Zamknij</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Usługa odtwarzania</string>
<string name="notification_playback_channel_name">Odtwarzanie</string> <string name="notification_playback_channel_name">Odtwarzanie</string>
<string name="notification_playback_channel_description">Sterowanie odtwarzaniem multimediów</string> <string name="notification_playback_channel_description">Sterowanie odtwarzaniem multimediów</string>

View File

@ -173,7 +173,6 @@
<string name="settings_download_folder_title">Pasta de download personalizada</string> <string name="settings_download_folder_title">Pasta de download personalizada</string>
<string name="settings_download_folder_current">Pasta atual: %1$s</string> <string name="settings_download_folder_current">Pasta atual: %1$s</string>
<string name="settings_download_folder_default">Pasta atual: pasta de músicas padrão</string>
<string name="settings_download_folder_description">Deixe em branco para usar a pasta de músicas padrão.</string> <string name="settings_download_folder_description">Deixe em branco para usar a pasta de músicas padrão.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Pastas de mídia local</string> <string name="settings_local_media_folders_title">Pastas de mídia local</string>
@ -216,8 +215,6 @@
<string name="settings_error_cache_size_range">O tamanho deve ser 0 ou um número positivo.</string> <string name="settings_error_cache_size_range">O tamanho deve ser 0 ou um número positivo.</string>
<string name="settings_enable_endless_playback_title">Ativar reprodução contínua</string> <string name="settings_enable_endless_playback_title">Ativar reprodução contínua</string>
<string name="settings_enable_endless_playback_subtitle">Continua tocando faixas recomendadas quando sua fila acabar.</string> <string name="settings_enable_endless_playback_subtitle">Continua tocando faixas recomendadas quando sua fila acabar.</string>
<string name="settings_enable_connect_title">Ativar Connect</string>
<string name="settings_enable_connect_subtitle">Disponibiliza controles de reprodução remota pelo recurso Spotube Connect.</string>
<string name="settings_playback_port_title">Porta do servidor proxy de reprodução</string> <string name="settings_playback_port_title">Porta do servidor proxy de reprodução</string>
<string name="settings_allow_remote_control_title">Permitir controle remoto</string> <string name="settings_allow_remote_control_title">Permitir controle remoto</string>
<string name="settings_allow_remote_control_subtitle">Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem.</string> <string name="settings_allow_remote_control_subtitle">Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem.</string>
@ -499,7 +496,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivos</string> <string name="devices_title">Dispositivos</string>
<string name="devices_refresh">Atualizar</string> <string name="devices_refresh">Atualizar</string>
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
<string name="devices_disconnect">Desconectar</string> <string name="devices_disconnect">Desconectar</string>
<string name="devices_connecting">Conectando...</string> <string name="devices_connecting">Conectando...</string>
<string name="devices_searching">Procurando dispositivos na rede...</string> <string name="devices_searching">Procurando dispositivos na rede...</string>
@ -507,20 +503,13 @@
<string name="devices_empty_hint">Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações.</string> <string name="devices_empty_hint">Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações.</string>
<string name="devices_unknown_device">Dispositivo desconhecido</string> <string name="devices_unknown_device">Dispositivo desconhecido</string>
<string name="devices_resolving">Resolvendo...</string> <string name="devices_resolving">Resolvendo...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Controle remoto</string> <string name="devices_remote_title">Controle remoto</string>
<string name="devices_remote_disconnected">Desconectado</string> <string name="devices_remote_disconnected">Desconectado</string>
<string name="devices_remote_connection_error">Erro de conexão: %1$s</string> <string name="devices_remote_connection_error">Erro de conexão: %1$s</string>
<string name="devices_remote_album_cover">Capa do álbum</string>
<string name="devices_remote_unknown_track">Faixa desconhecida</string> <string name="devices_remote_unknown_track">Faixa desconhecida</string>
<string name="devices_remote_unknown_artist">Artista desconhecido</string> <string name="devices_remote_unknown_artist">Artista desconhecido</string>
<string name="devices_remote_seek">Avançar/retroceder</string>
<string name="devices_remote_shuffle">Ordem aleatória</string>
<string name="devices_remote_previous">Anterior</string>
<string name="devices_remote_next">Próxima</string>
<string name="devices_remote_loop_mode">Modo de repetição</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Remover da fila</string> <string name="devices_remote_remove_from_queue">Remover da fila</string>
<string name="devices_remote_more_options">Mais opções</string> <string name="devices_remote_more_options">Mais opções</string>
@ -931,7 +920,6 @@
<string name="app_window_close">Fechar</string> <string name="app_window_close">Fechar</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Serviço de reprodução</string>
<string name="notification_playback_channel_name">Reprodução</string> <string name="notification_playback_channel_name">Reprodução</string>
<string name="notification_playback_channel_description">Controles de reprodução de mídia</string> <string name="notification_playback_channel_description">Controles de reprodução de mídia</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Своя папка для загрузок</string> <string name="settings_download_folder_title">Своя папка для загрузок</string>
<string name="settings_download_folder_current">Текущая папка: %1$s</string> <string name="settings_download_folder_current">Текущая папка: %1$s</string>
<string name="settings_download_folder_default">Текущая папка: стандартная папка «Музыка»</string>
<string name="settings_download_folder_description">Оставьте пустым, чтобы использовать стандартную папку «Музыка».</string> <string name="settings_download_folder_description">Оставьте пустым, чтобы использовать стандартную папку «Музыка».</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Папки с локальной музыкой</string> <string name="settings_local_media_folders_title">Папки с локальной музыкой</string>
@ -219,8 +218,6 @@
<string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string> <string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string>
<string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string> <string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string>
<string name="settings_enable_endless_playback_subtitle">Продолжать воспроизведение рекомендованными треками, когда очередь закончится.</string> <string name="settings_enable_endless_playback_subtitle">Продолжать воспроизведение рекомендованными треками, когда очередь закончится.</string>
<string name="settings_enable_connect_title">Включить Connect</string>
<string name="settings_enable_connect_subtitle">Предоставлять удалённое управление воспроизведением через Spotube Connect.</string>
<string name="settings_playback_port_title">Порт прокси-сервера воспроизведения</string> <string name="settings_playback_port_title">Порт прокси-сервера воспроизведения</string>
<string name="settings_allow_remote_control_title">Разрешить удалённое управление</string> <string name="settings_allow_remote_control_title">Разрешить удалённое управление</string>
<string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string> <string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string>
@ -503,7 +500,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Устройства</string> <string name="devices_title">Устройства</string>
<string name="devices_refresh">Обновить</string> <string name="devices_refresh">Обновить</string>
<string name="devices_connected_to">Подключено к %1$s:%2$d</string>
<string name="devices_disconnect">Отключиться</string> <string name="devices_disconnect">Отключиться</string>
<string name="devices_connecting">Подключение...</string> <string name="devices_connecting">Подключение...</string>
<string name="devices_searching">Поиск устройств в сети...</string> <string name="devices_searching">Поиск устройств в сети...</string>
@ -511,20 +507,13 @@
<string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string> <string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string>
<string name="devices_unknown_device">Неизвестное устройство</string> <string name="devices_unknown_device">Неизвестное устройство</string>
<string name="devices_resolving">Определение адреса...</string> <string name="devices_resolving">Определение адреса...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Удалённое управление</string> <string name="devices_remote_title">Удалённое управление</string>
<string name="devices_remote_disconnected">Отключено</string> <string name="devices_remote_disconnected">Отключено</string>
<string name="devices_remote_connection_error">Ошибка подключения: %1$s</string> <string name="devices_remote_connection_error">Ошибка подключения: %1$s</string>
<string name="devices_remote_album_cover">Обложка альбома</string>
<string name="devices_remote_unknown_track">Неизвестный трек</string> <string name="devices_remote_unknown_track">Неизвестный трек</string>
<string name="devices_remote_unknown_artist">Неизвестный исполнитель</string> <string name="devices_remote_unknown_artist">Неизвестный исполнитель</string>
<string name="devices_remote_seek">Перемотка</string>
<string name="devices_remote_shuffle">Перемешать</string>
<string name="devices_remote_previous">Предыдущий</string>
<string name="devices_remote_next">Следующий</string>
<string name="devices_remote_loop_mode">Режим повтора</string>
<string name="devices_remote_volume">Громкость</string> <string name="devices_remote_volume">Громкость</string>
<string name="devices_remote_remove_from_queue">Удалить из очереди</string> <string name="devices_remote_remove_from_queue">Удалить из очереди</string>
<string name="devices_remote_more_options">Другие действия</string> <string name="devices_remote_more_options">Другие действия</string>
@ -937,7 +926,6 @@
<string name="app_window_close">Закрыть</string> <string name="app_window_close">Закрыть</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Служба воспроизведения</string>
<string name="notification_playback_channel_name">Воспроизведение</string> <string name="notification_playback_channel_name">Воспроизведение</string>
<string name="notification_playback_channel_description">Управление воспроизведением</string> <string name="notification_playback_channel_description">Управление воспроизведением</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">กำหนดโฟลเดอร์ดาวน์โหลดเอง</string> <string name="settings_download_folder_title">กำหนดโฟลเดอร์ดาวน์โหลดเอง</string>
<string name="settings_download_folder_current">โฟลเดอร์ปัจจุบัน: %1$s</string> <string name="settings_download_folder_current">โฟลเดอร์ปัจจุบัน: %1$s</string>
<string name="settings_download_folder_default">โฟลเดอร์ปัจจุบัน: โฟลเดอร์เพลงเริ่มต้น</string>
<string name="settings_download_folder_description">เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น</string> <string name="settings_download_folder_description">เว้นว่างไว้เพื่อใช้โฟลเดอร์เพลงเริ่มต้น</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">โฟลเดอร์สื่อในเครื่อง</string> <string name="settings_local_media_folders_title">โฟลเดอร์สื่อในเครื่อง</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string> <string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string>
<string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string> <string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string>
<string name="settings_enable_endless_playback_subtitle">เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด</string> <string name="settings_enable_endless_playback_subtitle">เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด</string>
<string name="settings_enable_connect_title">เปิดใช้ Connect</string>
<string name="settings_enable_connect_subtitle">เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect</string>
<string name="settings_playback_port_title">พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น</string> <string name="settings_playback_port_title">พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น</string>
<string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string> <string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string>
<string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string> <string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">อุปกรณ์</string> <string name="devices_title">อุปกรณ์</string>
<string name="devices_refresh">รีเฟรช</string> <string name="devices_refresh">รีเฟรช</string>
<string name="devices_connected_to">เชื่อมต่อกับ %1$s:%2$d แล้ว</string>
<string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string> <string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string>
<string name="devices_connecting">กำลังเชื่อมต่อ...</string> <string name="devices_connecting">กำลังเชื่อมต่อ...</string>
<string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string> <string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string> <string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string>
<string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string> <string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string>
<string name="devices_resolving">กำลังระบุ...</string> <string name="devices_resolving">กำลังระบุ...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">รีโมตคอนโทรล</string> <string name="devices_remote_title">รีโมตคอนโทรล</string>
<string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string> <string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string>
<string name="devices_remote_connection_error">ข้อผิดพลาดในการเชื่อมต่อ: %1$s</string> <string name="devices_remote_connection_error">ข้อผิดพลาดในการเชื่อมต่อ: %1$s</string>
<string name="devices_remote_album_cover">ปกอัลบั้ม</string>
<string name="devices_remote_unknown_track">ไม่ทราบชื่อเพลง</string> <string name="devices_remote_unknown_track">ไม่ทราบชื่อเพลง</string>
<string name="devices_remote_unknown_artist">ไม่ทราบศิลปิน</string> <string name="devices_remote_unknown_artist">ไม่ทราบศิลปิน</string>
<string name="devices_remote_seek">เลื่อนตำแหน่ง</string>
<string name="devices_remote_shuffle">สุ่มเพลง</string>
<string name="devices_remote_previous">ก่อนหน้า</string>
<string name="devices_remote_next">ถัดไป</string>
<string name="devices_remote_loop_mode">โหมดเล่นซ้ำ</string>
<string name="devices_remote_volume">ระดับเสียง</string> <string name="devices_remote_volume">ระดับเสียง</string>
<string name="devices_remote_remove_from_queue">นำออกจากคิว</string> <string name="devices_remote_remove_from_queue">นำออกจากคิว</string>
<string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string> <string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string>
@ -919,7 +908,6 @@
<string name="app_window_close">ปิด</string> <string name="app_window_close">ปิด</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">บริการเล่นเพลง</string>
<string name="notification_playback_channel_name">การเล่น</string> <string name="notification_playback_channel_name">การเล่น</string>
<string name="notification_playback_channel_description">ตัวควบคุมการเล่นสื่อ</string> <string name="notification_playback_channel_description">ตัวควบคุมการเล่นสื่อ</string>

View File

@ -171,7 +171,6 @@
<string name="settings_download_folder_title">Özel indirme klasörü</string> <string name="settings_download_folder_title">Özel indirme klasörü</string>
<string name="settings_download_folder_current">Geçerli klasör: %1$s</string> <string name="settings_download_folder_current">Geçerli klasör: %1$s</string>
<string name="settings_download_folder_default">Geçerli klasör: varsayılan müzik klasörü</string>
<string name="settings_download_folder_description">Varsayılan müzik klasörünü kullanmak için boş bırak.</string> <string name="settings_download_folder_description">Varsayılan müzik klasörünü kullanmak için boş bırak.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Yerel medya klasörleri</string> <string name="settings_local_media_folders_title">Yerel medya klasörleri</string>
@ -213,8 +212,6 @@
<string name="settings_error_cache_size_range">Boyut 0 veya pozitif bir sayı olmalı.</string> <string name="settings_error_cache_size_range">Boyut 0 veya pozitif bir sayı olmalı.</string>
<string name="settings_enable_endless_playback_title">Sonsuz çalmayı etkinleştir</string> <string name="settings_enable_endless_playback_title">Sonsuz çalmayı etkinleştir</string>
<string name="settings_enable_endless_playback_subtitle">Sıran bittiğinde önerilen parçalarla çalmaya devam et.</string> <string name="settings_enable_endless_playback_subtitle">Sıran bittiğinde önerilen parçalarla çalmaya devam et.</string>
<string name="settings_enable_connect_title">Connect\'i etkinleştir</string>
<string name="settings_enable_connect_subtitle">Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun.</string>
<string name="settings_playback_port_title">Çalma proxy sunucusu portu</string> <string name="settings_playback_port_title">Çalma proxy sunucusu portu</string>
<string name="settings_allow_remote_control_title">Uzaktan kontrole izin ver</string> <string name="settings_allow_remote_control_title">Uzaktan kontrole izin ver</string>
<string name="settings_allow_remote_control_subtitle">Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin.</string> <string name="settings_allow_remote_control_subtitle">Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin.</string>
@ -495,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Cihazlar</string> <string name="devices_title">Cihazlar</string>
<string name="devices_refresh">Yenile</string> <string name="devices_refresh">Yenile</string>
<string name="devices_connected_to">%1$s:%2$d adresine bağlı</string>
<string name="devices_disconnect">Bağlantıyı kes</string> <string name="devices_disconnect">Bağlantıyı kes</string>
<string name="devices_connecting">Bağlanıyor...</string> <string name="devices_connecting">Bağlanıyor...</string>
<string name="devices_searching">Ağda cihaz aranıyor...</string> <string name="devices_searching">Ağda cihaz aranıyor...</string>
@ -503,20 +499,13 @@
<string name="devices_empty_hint">Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol.</string> <string name="devices_empty_hint">Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol.</string>
<string name="devices_unknown_device">Bilinmeyen Cihaz</string> <string name="devices_unknown_device">Bilinmeyen Cihaz</string>
<string name="devices_resolving">Çözümleniyor...</string> <string name="devices_resolving">Çözümleniyor...</string>
<string name="devices_device_id">Kimlik: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Uzaktan Kontrol</string> <string name="devices_remote_title">Uzaktan Kontrol</string>
<string name="devices_remote_disconnected">Bağlantı kesildi</string> <string name="devices_remote_disconnected">Bağlantı kesildi</string>
<string name="devices_remote_connection_error">Bağlantı hatası: %1$s</string> <string name="devices_remote_connection_error">Bağlantı hatası: %1$s</string>
<string name="devices_remote_album_cover">Albüm kapağı</string>
<string name="devices_remote_unknown_track">Bilinmeyen Parça</string> <string name="devices_remote_unknown_track">Bilinmeyen Parça</string>
<string name="devices_remote_unknown_artist">Bilinmeyen Sanatçı</string> <string name="devices_remote_unknown_artist">Bilinmeyen Sanatçı</string>
<string name="devices_remote_seek">Konum</string>
<string name="devices_remote_shuffle">Karıştır</string>
<string name="devices_remote_previous">Önceki</string>
<string name="devices_remote_next">Sonraki</string>
<string name="devices_remote_loop_mode">Tekrar modu</string>
<string name="devices_remote_volume">Ses düzeyi</string> <string name="devices_remote_volume">Ses düzeyi</string>
<string name="devices_remote_remove_from_queue">Sıradan kaldır</string> <string name="devices_remote_remove_from_queue">Sıradan kaldır</string>
<string name="devices_remote_more_options">Diğer seçenekler</string> <string name="devices_remote_more_options">Diğer seçenekler</string>
@ -925,7 +914,6 @@
<string name="app_window_close">Kapat</string> <string name="app_window_close">Kapat</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Çalma hizmeti</string>
<string name="notification_playback_channel_name">Çalma</string> <string name="notification_playback_channel_name">Çalma</string>
<string name="notification_playback_channel_description">Medya çalma kontrolleri</string> <string name="notification_playback_channel_description">Medya çalma kontrolleri</string>

View File

@ -175,7 +175,6 @@
<string name="settings_download_folder_title">Власна папка для завантажень</string> <string name="settings_download_folder_title">Власна папка для завантажень</string>
<string name="settings_download_folder_current">Поточна папка: %1$s</string> <string name="settings_download_folder_current">Поточна папка: %1$s</string>
<string name="settings_download_folder_default">Поточна папка: стандартна папка «Музика»</string>
<string name="settings_download_folder_description">Залиште порожнім, щоб використовувати стандартну папку «Музика».</string> <string name="settings_download_folder_description">Залиште порожнім, щоб використовувати стандартну папку «Музика».</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Папки з локальною музикою</string> <string name="settings_local_media_folders_title">Папки з локальною музикою</string>
@ -219,8 +218,6 @@
<string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string> <string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string>
<string name="settings_enable_endless_playback_title">Безкінечне відтворення</string> <string name="settings_enable_endless_playback_title">Безкінечне відтворення</string>
<string name="settings_enable_endless_playback_subtitle">Продовжувати відтворення рекомендованими треками, коли черга закінчиться.</string> <string name="settings_enable_endless_playback_subtitle">Продовжувати відтворення рекомендованими треками, коли черга закінчиться.</string>
<string name="settings_enable_connect_title">Увімкнути Connect</string>
<string name="settings_enable_connect_subtitle">Надавати віддалене керування відтворенням через Spotube Connect.</string>
<string name="settings_playback_port_title">Порт проксі-сервера відтворення</string> <string name="settings_playback_port_title">Порт проксі-сервера відтворення</string>
<string name="settings_allow_remote_control_title">Дозволити віддалене керування</string> <string name="settings_allow_remote_control_title">Дозволити віддалене керування</string>
<string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string> <string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string>
@ -503,7 +500,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Пристрої</string> <string name="devices_title">Пристрої</string>
<string name="devices_refresh">Оновити</string> <string name="devices_refresh">Оновити</string>
<string name="devices_connected_to">Підключено до %1$s:%2$d</string>
<string name="devices_disconnect">Від’єднатися</string> <string name="devices_disconnect">Від’єднатися</string>
<string name="devices_connecting">Підключення...</string> <string name="devices_connecting">Підключення...</string>
<string name="devices_searching">Пошук пристроїв у мережі...</string> <string name="devices_searching">Пошук пристроїв у мережі...</string>
@ -511,20 +507,13 @@
<string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string> <string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string>
<string name="devices_unknown_device">Невідомий пристрій</string> <string name="devices_unknown_device">Невідомий пристрій</string>
<string name="devices_resolving">Визначення адреси...</string> <string name="devices_resolving">Визначення адреси...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Віддалене керування</string> <string name="devices_remote_title">Віддалене керування</string>
<string name="devices_remote_disconnected">Від’єднано</string> <string name="devices_remote_disconnected">Від’єднано</string>
<string name="devices_remote_connection_error">Помилка підключення: %1$s</string> <string name="devices_remote_connection_error">Помилка підключення: %1$s</string>
<string name="devices_remote_album_cover">Обкладинка альбому</string>
<string name="devices_remote_unknown_track">Невідомий трек</string> <string name="devices_remote_unknown_track">Невідомий трек</string>
<string name="devices_remote_unknown_artist">Невідомий виконавець</string> <string name="devices_remote_unknown_artist">Невідомий виконавець</string>
<string name="devices_remote_seek">Перемотування</string>
<string name="devices_remote_shuffle">Перемішати</string>
<string name="devices_remote_previous">Попередній</string>
<string name="devices_remote_next">Наступний</string>
<string name="devices_remote_loop_mode">Режим повтору</string>
<string name="devices_remote_volume">Гучність</string> <string name="devices_remote_volume">Гучність</string>
<string name="devices_remote_remove_from_queue">Видалити з черги</string> <string name="devices_remote_remove_from_queue">Видалити з черги</string>
<string name="devices_remote_more_options">Інші дії</string> <string name="devices_remote_more_options">Інші дії</string>
@ -937,7 +926,6 @@
<string name="app_window_close">Закрити</string> <string name="app_window_close">Закрити</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Служба відтворення</string>
<string name="notification_playback_channel_name">Відтворення</string> <string name="notification_playback_channel_name">Відтворення</string>
<string name="notification_playback_channel_description">Керування відтворенням</string> <string name="notification_playback_channel_description">Керування відтворенням</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">Thư mục tải xuống tùy chỉnh</string> <string name="settings_download_folder_title">Thư mục tải xuống tùy chỉnh</string>
<string name="settings_download_folder_current">Thư mục hiện tại: %1$s</string> <string name="settings_download_folder_current">Thư mục hiện tại: %1$s</string>
<string name="settings_download_folder_default">Thư mục hiện tại: thư mục nhạc mặc định</string>
<string name="settings_download_folder_description">Để trống để dùng thư mục nhạc mặc định.</string> <string name="settings_download_folder_description">Để trống để dùng thư mục nhạc mặc định.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Thư mục nhạc trên máy</string> <string name="settings_local_media_folders_title">Thư mục nhạc trên máy</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">Dung lượng phải là 0 hoặc một số dương.</string> <string name="settings_error_cache_size_range">Dung lượng phải là 0 hoặc một số dương.</string>
<string name="settings_enable_endless_playback_title">Bật phát liên tục</string> <string name="settings_enable_endless_playback_title">Bật phát liên tục</string>
<string name="settings_enable_endless_playback_subtitle">Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc.</string> <string name="settings_enable_endless_playback_subtitle">Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc.</string>
<string name="settings_enable_connect_title">Bật Connect</string>
<string name="settings_enable_connect_subtitle">Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect.</string>
<string name="settings_playback_port_title">Cổng máy chủ proxy phát lại</string> <string name="settings_playback_port_title">Cổng máy chủ proxy phát lại</string>
<string name="settings_allow_remote_control_title">Cho phép điều khiển từ xa</string> <string name="settings_allow_remote_control_title">Cho phép điều khiển từ xa</string>
<string name="settings_allow_remote_control_subtitle">Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối.</string> <string name="settings_allow_remote_control_subtitle">Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối.</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Thiết bị</string> <string name="devices_title">Thiết bị</string>
<string name="devices_refresh">Làm mới</string> <string name="devices_refresh">Làm mới</string>
<string name="devices_connected_to">Đã kết nối với %1$s:%2$d</string>
<string name="devices_disconnect">Ngắt kết nối</string> <string name="devices_disconnect">Ngắt kết nối</string>
<string name="devices_connecting">Đang kết nối...</string> <string name="devices_connecting">Đang kết nối...</string>
<string name="devices_searching">Đang tìm thiết bị trong mạng...</string> <string name="devices_searching">Đang tìm thiết bị trong mạng...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt.</string> <string name="devices_empty_hint">Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt.</string>
<string name="devices_unknown_device">Thiết bị không xác định</string> <string name="devices_unknown_device">Thiết bị không xác định</string>
<string name="devices_resolving">Đang phân giải...</string> <string name="devices_resolving">Đang phân giải...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Điều khiển từ xa</string> <string name="devices_remote_title">Điều khiển từ xa</string>
<string name="devices_remote_disconnected">Đã ngắt kết nối</string> <string name="devices_remote_disconnected">Đã ngắt kết nối</string>
<string name="devices_remote_connection_error">Lỗi kết nối: %1$s</string> <string name="devices_remote_connection_error">Lỗi kết nối: %1$s</string>
<string name="devices_remote_album_cover">Ảnh bìa album</string>
<string name="devices_remote_unknown_track">Bài hát không xác định</string> <string name="devices_remote_unknown_track">Bài hát không xác định</string>
<string name="devices_remote_unknown_artist">Nghệ sĩ không xác định</string> <string name="devices_remote_unknown_artist">Nghệ sĩ không xác định</string>
<string name="devices_remote_seek">Tua</string>
<string name="devices_remote_shuffle">Phát ngẫu nhiên</string>
<string name="devices_remote_previous">Trước</string>
<string name="devices_remote_next">Tiếp</string>
<string name="devices_remote_loop_mode">Chế độ lặp</string>
<string name="devices_remote_volume">Âm lượng</string> <string name="devices_remote_volume">Âm lượng</string>
<string name="devices_remote_remove_from_queue">Xóa khỏi danh sách chờ</string> <string name="devices_remote_remove_from_queue">Xóa khỏi danh sách chờ</string>
<string name="devices_remote_more_options">Tùy chọn khác</string> <string name="devices_remote_more_options">Tùy chọn khác</string>
@ -919,7 +908,6 @@
<string name="app_window_close">Đóng</string> <string name="app_window_close">Đóng</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Dịch vụ phát nhạc</string>
<string name="notification_playback_channel_name">Phát nhạc</string> <string name="notification_playback_channel_name">Phát nhạc</string>
<string name="notification_playback_channel_description">Điều khiển phát nội dung</string> <string name="notification_playback_channel_description">Điều khiển phát nội dung</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">自訂下載資料夾</string> <string name="settings_download_folder_title">自訂下載資料夾</string>
<string name="settings_download_folder_current">目前資料夾:%1$s</string> <string name="settings_download_folder_current">目前資料夾:%1$s</string>
<string name="settings_download_folder_default">目前資料夾:預設音樂資料夾</string>
<string name="settings_download_folder_description">留空即使用預設音樂資料夾。</string> <string name="settings_download_folder_description">留空即使用預設音樂資料夾。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">本機媒體資料夾</string> <string name="settings_local_media_folders_title">本機媒體資料夾</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">大小必須為 0 或正數。</string> <string name="settings_error_cache_size_range">大小必須為 0 或正數。</string>
<string name="settings_enable_endless_playback_title">啟用無限播放</string> <string name="settings_enable_endless_playback_title">啟用無限播放</string>
<string name="settings_enable_endless_playback_subtitle">待播清單播完後,以推薦曲目繼續播放。</string> <string name="settings_enable_endless_playback_subtitle">待播清單播完後,以推薦曲目繼續播放。</string>
<string name="settings_enable_connect_title">啟用 Connect</string>
<string name="settings_enable_connect_subtitle">透過 Spotube Connect 功能提供遠端播放控制。</string>
<string name="settings_playback_port_title">播放代理伺服器連接埠</string> <string name="settings_playback_port_title">播放代理伺服器連接埠</string>
<string name="settings_allow_remote_control_title">允許遠端控制</string> <string name="settings_allow_remote_control_title">允許遠端控制</string>
<string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string> <string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">裝置</string> <string name="devices_title">裝置</string>
<string name="devices_refresh">重新整理</string> <string name="devices_refresh">重新整理</string>
<string name="devices_connected_to">已連線至 %1$s:%2$d</string>
<string name="devices_disconnect">中斷連線</string> <string name="devices_disconnect">中斷連線</string>
<string name="devices_connecting">連線中...</string> <string name="devices_connecting">連線中...</string>
<string name="devices_searching">正在搜尋網路上的裝置...</string> <string name="devices_searching">正在搜尋網路上的裝置...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string> <string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string>
<string name="devices_unknown_device">未知裝置</string> <string name="devices_unknown_device">未知裝置</string>
<string name="devices_resolving">解析中...</string> <string name="devices_resolving">解析中...</string>
<string name="devices_device_id">ID:%1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">遠端控制</string> <string name="devices_remote_title">遠端控制</string>
<string name="devices_remote_disconnected">已中斷連線</string> <string name="devices_remote_disconnected">已中斷連線</string>
<string name="devices_remote_connection_error">連線錯誤:%1$s</string> <string name="devices_remote_connection_error">連線錯誤:%1$s</string>
<string name="devices_remote_album_cover">專輯封面</string>
<string name="devices_remote_unknown_track">未知曲目</string> <string name="devices_remote_unknown_track">未知曲目</string>
<string name="devices_remote_unknown_artist">未知藝人</string> <string name="devices_remote_unknown_artist">未知藝人</string>
<string name="devices_remote_seek">播放進度</string>
<string name="devices_remote_shuffle">隨機播放</string>
<string name="devices_remote_previous">上一首</string>
<string name="devices_remote_next">下一首</string>
<string name="devices_remote_loop_mode">重複播放模式</string>
<string name="devices_remote_volume">音量</string> <string name="devices_remote_volume">音量</string>
<string name="devices_remote_remove_from_queue">從待播清單移除</string> <string name="devices_remote_remove_from_queue">從待播清單移除</string>
<string name="devices_remote_more_options">更多選項</string> <string name="devices_remote_more_options">更多選項</string>
@ -919,7 +908,6 @@
<string name="app_window_close">關閉</string> <string name="app_window_close">關閉</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">播放服務</string>
<string name="notification_playback_channel_name">播放</string> <string name="notification_playback_channel_name">播放</string>
<string name="notification_playback_channel_description">媒體播放控制項</string> <string name="notification_playback_channel_description">媒體播放控制項</string>

View File

@ -169,7 +169,6 @@
<string name="settings_download_folder_title">自定义下载文件夹</string> <string name="settings_download_folder_title">自定义下载文件夹</string>
<string name="settings_download_folder_current">当前文件夹:%1$s</string> <string name="settings_download_folder_current">当前文件夹:%1$s</string>
<string name="settings_download_folder_default">当前文件夹:默认音乐文件夹</string>
<string name="settings_download_folder_description">留空则使用默认音乐文件夹。</string> <string name="settings_download_folder_description">留空则使用默认音乐文件夹。</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">本地媒体文件夹</string> <string name="settings_local_media_folders_title">本地媒体文件夹</string>
@ -210,8 +209,6 @@
<string name="settings_error_cache_size_range">大小必须为 0 或正数。</string> <string name="settings_error_cache_size_range">大小必须为 0 或正数。</string>
<string name="settings_enable_endless_playback_title">启用无尽播放</string> <string name="settings_enable_endless_playback_title">启用无尽播放</string>
<string name="settings_enable_endless_playback_subtitle">队列播放完毕后,继续播放推荐的歌曲。</string> <string name="settings_enable_endless_playback_subtitle">队列播放完毕后,继续播放推荐的歌曲。</string>
<string name="settings_enable_connect_title">启用 Connect</string>
<string name="settings_enable_connect_subtitle">通过 Spotube Connect 功能开放远程播放控制。</string>
<string name="settings_playback_port_title">播放代理服务器端口</string> <string name="settings_playback_port_title">播放代理服务器端口</string>
<string name="settings_allow_remote_control_title">允许远程控制</string> <string name="settings_allow_remote_control_title">允许远程控制</string>
<string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string> <string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string>
@ -491,7 +488,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">设备</string> <string name="devices_title">设备</string>
<string name="devices_refresh">刷新</string> <string name="devices_refresh">刷新</string>
<string name="devices_connected_to">已连接到 %1$s:%2$d</string>
<string name="devices_disconnect">断开连接</string> <string name="devices_disconnect">断开连接</string>
<string name="devices_connecting">正在连接...</string> <string name="devices_connecting">正在连接...</string>
<string name="devices_searching">正在搜索网络中的设备...</string> <string name="devices_searching">正在搜索网络中的设备...</string>
@ -499,20 +495,13 @@
<string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string> <string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string>
<string name="devices_unknown_device">未知设备</string> <string name="devices_unknown_device">未知设备</string>
<string name="devices_resolving">正在解析...</string> <string name="devices_resolving">正在解析...</string>
<string name="devices_device_id">ID:%1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">远程控制</string> <string name="devices_remote_title">远程控制</string>
<string name="devices_remote_disconnected">已断开连接</string> <string name="devices_remote_disconnected">已断开连接</string>
<string name="devices_remote_connection_error">连接错误:%1$s</string> <string name="devices_remote_connection_error">连接错误:%1$s</string>
<string name="devices_remote_album_cover">专辑封面</string>
<string name="devices_remote_unknown_track">未知歌曲</string> <string name="devices_remote_unknown_track">未知歌曲</string>
<string name="devices_remote_unknown_artist">未知艺人</string> <string name="devices_remote_unknown_artist">未知艺人</string>
<string name="devices_remote_seek">进度</string>
<string name="devices_remote_shuffle">随机播放</string>
<string name="devices_remote_previous">上一首</string>
<string name="devices_remote_next">下一首</string>
<string name="devices_remote_loop_mode">循环模式</string>
<string name="devices_remote_volume">音量</string> <string name="devices_remote_volume">音量</string>
<string name="devices_remote_remove_from_queue">从队列中移除</string> <string name="devices_remote_remove_from_queue">从队列中移除</string>
<string name="devices_remote_more_options">更多选项</string> <string name="devices_remote_more_options">更多选项</string>
@ -919,7 +908,6 @@
<string name="app_window_close">关闭</string> <string name="app_window_close">关闭</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">播放服务</string>
<string name="notification_playback_channel_name">播放</string> <string name="notification_playback_channel_name">播放</string>
<string name="notification_playback_channel_description">媒体播放控制</string> <string name="notification_playback_channel_description">媒体播放控制</string>

View File

@ -76,6 +76,7 @@
<!-- Downloads --> <!-- Downloads -->
<string name="downloads_summary">%1$d active • %2$d completed</string> <string name="downloads_summary">%1$d active • %2$d completed</string>
<string name="downloads_saved_to">Saved to %1$s</string>
<string name="downloads_clear_completed">Clear completed</string> <string name="downloads_clear_completed">Clear completed</string>
<string name="downloads_empty">No downloads yet</string> <string name="downloads_empty">No downloads yet</string>
<string name="downloads_status_downloading">Downloading %1$d%</string> <string name="downloads_status_downloading">Downloading %1$d%</string>
@ -173,7 +174,7 @@
<string name="settings_download_folder_title">Download folder override</string> <string name="settings_download_folder_title">Download folder override</string>
<string name="settings_download_folder_current">Current folder: %1$s</string> <string name="settings_download_folder_current">Current folder: %1$s</string>
<string name="settings_download_folder_default">Current folder: default music folder</string> <string name="settings_download_folder_default_path">Current folder: %1$s (default)</string>
<string name="settings_download_folder_description">Leave this empty to use the default music folder.</string> <string name="settings_download_folder_description">Leave this empty to use the default music folder.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string> <string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Local media folders</string> <string name="settings_local_media_folders_title">Local media folders</string>
@ -215,8 +216,6 @@
<string name="settings_error_cache_size_range">Size must be 0 or a positive number.</string> <string name="settings_error_cache_size_range">Size must be 0 or a positive number.</string>
<string name="settings_enable_endless_playback_title">Enable endless playback</string> <string name="settings_enable_endless_playback_title">Enable endless playback</string>
<string name="settings_enable_endless_playback_subtitle">Keep playback going with recommended tracks when your queue ends.</string> <string name="settings_enable_endless_playback_subtitle">Keep playback going with recommended tracks when your queue ends.</string>
<string name="settings_enable_connect_title">Enable Connect</string>
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
<string name="settings_playback_port_title">Playback proxy server port</string> <string name="settings_playback_port_title">Playback proxy server port</string>
<string name="settings_allow_remote_control_title">Allow remote control</string> <string name="settings_allow_remote_control_title">Allow remote control</string>
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string> <string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
@ -504,6 +503,7 @@
<!-- Devices: connection errors --> <!-- Devices: connection errors -->
<string name="devices_error_connection_failed">Connection failed</string> <string name="devices_error_connection_failed">Connection failed</string>
<string name="devices_error_connection_lost">Connection lost</string> <string name="devices_error_connection_lost">Connection lost</string>
<string name="devices_error_connection_denied">The other device declined the request, or has remote control turned off.</string>
<string name="devices_error_send_failed">Send failed</string> <string name="devices_error_send_failed">Send failed</string>
<string name="devices_error_discovery_failed">Discovery failed: %1$s</string> <string name="devices_error_discovery_failed">Discovery failed: %1$s</string>
<string name="devices_error_connect_failed">Failed to connect: %1$s</string> <string name="devices_error_connect_failed">Failed to connect: %1$s</string>
@ -511,7 +511,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Devices</string> <string name="devices_title">Devices</string>
<string name="devices_refresh">Refresh</string> <string name="devices_refresh">Refresh</string>
<string name="devices_connected_to">Connected to %1$s:%2$d</string>
<string name="devices_disconnect">Disconnect</string> <string name="devices_disconnect">Disconnect</string>
<string name="devices_connecting">Connecting...</string> <string name="devices_connecting">Connecting...</string>
<string name="devices_searching">Searching for devices on the network...</string> <string name="devices_searching">Searching for devices on the network...</string>
@ -519,23 +518,29 @@
<string name="devices_empty_hint">Make sure the other device has \"Allow remote control\" enabled in settings.</string> <string name="devices_empty_hint">Make sure the other device has \"Allow remote control\" enabled in settings.</string>
<string name="devices_unknown_device">Unknown Device</string> <string name="devices_unknown_device">Unknown Device</string>
<string name="devices_resolving">Resolving...</string> <string name="devices_resolving">Resolving...</string>
<string name="devices_device_id">ID: %1$s...</string> <string name="devices_section_connected">Connected</string>
<string name="devices_section_available">Available devices</string>
<string name="devices_section_this_device">This device</string>
<string name="devices_status_connected">Connected</string>
<string name="devices_open_remote">Open remote</string>
<string name="devices_search_again">Search again</string>
<string name="devices_searching_more">Still looking for more devices...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Remote Control</string> <string name="devices_remote_title">Remote Control</string>
<string name="devices_remote_disconnected">Disconnected</string> <string name="devices_remote_disconnected">Disconnected</string>
<string name="devices_remote_connection_error">Connection error: %1$s</string> <string name="devices_remote_connection_error">Connection error: %1$s</string>
<string name="devices_remote_album_cover">Album cover</string>
<string name="devices_remote_unknown_track">Unknown Track</string> <string name="devices_remote_unknown_track">Unknown Track</string>
<string name="devices_remote_unknown_artist">Unknown Artist</string> <string name="devices_remote_unknown_artist">Unknown Artist</string>
<string name="devices_remote_seek">Seek</string>
<string name="devices_remote_shuffle">Shuffle</string>
<string name="devices_remote_previous">Previous</string>
<string name="devices_remote_next">Next</string>
<string name="devices_remote_loop_mode">Loop mode</string>
<string name="devices_remote_volume">Volume</string> <string name="devices_remote_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Remove from queue</string> <string name="devices_remote_remove_from_queue">Remove from queue</string>
<string name="devices_remote_more_options">More options</string> <string name="devices_remote_more_options">More options</string>
<string name="devices_remote_playing_on">Playing on %1$s</string>
<string name="devices_remote_connecting_hint">If asked, allow this device on the other Spotube first.</string>
<string name="devices_remote_disconnected_hint">You\'re no longer controlling the other device. Pick it again to reconnect.</string>
<string name="devices_remote_back_to_devices">Back to devices</string>
<string name="devices_remote_nothing_playing">Nothing playing</string>
<string name="devices_remote_nothing_playing_hint">Start something on the other device, or pick a track from its queue.</string>
<!-- Group Jam: play destination picker --> <!-- Group Jam: play destination picker -->
<string name="jam_destination_title">Play Where?</string> <string name="jam_destination_title">Play Where?</string>
@ -705,6 +710,9 @@
<string name="downloads_error_no_metadata">No track metadata</string> <string name="downloads_error_no_metadata">No track metadata</string>
<string name="downloads_error_stream_unresolved_short">Could not resolve stream URL</string> <string name="downloads_error_stream_unresolved_short">Could not resolve stream URL</string>
<string name="downloads_error_stream_unresolved">Could not resolve stream URL. Try playing the track first.</string> <string name="downloads_error_stream_unresolved">Could not resolve stream URL. Try playing the track first.</string>
<string name="downloads_error_http">The server refused the download (HTTP %1$d)</string>
<string name="downloads_error_range_ignored">The server sent an incomplete download. Try again.</string>
<string name="downloads_error_video_only">Only a video is available for this track, so it wasn\'t downloaded</string>
<!-- Saved tracks --> <!-- Saved tracks -->
<string name="saved_tracks_title">Saved Tracks</string> <string name="saved_tracks_title">Saved Tracks</string>
@ -922,6 +930,14 @@
<string name="nav_group_on_device">On Device</string> <string name="nav_group_on_device">On Device</string>
<string name="nav_group_connect">Connect</string> <string name="nav_group_connect">Connect</string>
<string name="nav_devices">Devices</string> <string name="nav_devices">Devices</string>
<plurals name="connect_jam_hosting">
<item quantity="one">Hosting · %1$d listener</item>
<item quantity="other">Hosting · %1$d listeners</item>
</plurals>
<plurals name="connect_jam_joined">
<item quantity="one">In a Jam · %1$d listener</item>
<item quantity="other">In a Jam · %1$d listeners</item>
</plurals>
<!-- System tray (desktop) --> <!-- System tray (desktop) -->
<string name="tray_hide_window">Hide Window</string> <string name="tray_hide_window">Hide Window</string>
@ -949,7 +965,6 @@
<string name="app_window_close">Close</string> <string name="app_window_close">Close</string>
<!-- Android playback notification --> <!-- Android playback notification -->
<string name="notification_playback_service_text">Playback service</string>
<string name="notification_playback_channel_name">Playback</string> <string name="notification_playback_channel_name">Playback</string>
<string name="notification_playback_channel_description">Media playback controls</string> <string name="notification_playback_channel_description">Media playback controls</string>

View File

@ -49,6 +49,7 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.blacklist.BlacklistViewModel import org.spotube.spotube.modules.blacklist.BlacklistViewModel
import org.spotube.spotube.modules.devices.DevicesViewModel import org.spotube.spotube.modules.devices.DevicesViewModel
import org.spotube.spotube.modules.devices.RemoteControlViewModel import org.spotube.spotube.modules.devices.RemoteControlViewModel
import org.spotube.spotube.modules.shell.ConnectStatusViewModel
import org.spotube.spotube.modules.jam.JamViewModel import org.spotube.spotube.modules.jam.JamViewModel
import org.spotube.spotube.modules.downloads.DownloadManager import org.spotube.spotube.modules.downloads.DownloadManager
import org.spotube.spotube.modules.downloads.DownloadsViewModel import org.spotube.spotube.modules.downloads.DownloadsViewModel
@ -66,6 +67,7 @@ import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaFoldersC
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaLibraryCoordinator
import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel import org.spotube.spotube.modules.library.playlist.LibraryPlaylistsViewModel
import org.spotube.spotube.modules.lyrics.LyricsViewModel import org.spotube.spotube.modules.lyrics.LyricsViewModel
import org.spotube.spotube.modules.playlist.AddTracksToPlaylistViewModel
import org.spotube.spotube.modules.playlist.PlaylistRepository import org.spotube.spotube.modules.playlist.PlaylistRepository
import org.spotube.spotube.modules.playlist.PlaylistViewModel import org.spotube.spotube.modules.playlist.PlaylistViewModel
import org.spotube.spotube.modules.plugin.AudioPluginSource import org.spotube.spotube.modules.plugin.AudioPluginSource
@ -184,6 +186,7 @@ val sharedModules = module {
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get()) remotePlaybackController = get())
} }
viewModel { (playlistId: String) -> AddTracksToPlaylistViewModel(playlistId, get(), get()) }
// Saved Tracks // Saved Tracks
singleOf(::SavedTracksRepository) singleOf(::SavedTracksRepository)
@ -219,6 +222,7 @@ val sharedModules = module {
viewModelOf(::BlacklistViewModel) viewModelOf(::BlacklistViewModel)
viewModel { DevicesViewModel(get()) } viewModel { DevicesViewModel(get()) }
viewModelOf(::RemoteControlViewModel) viewModelOf(::RemoteControlViewModel)
viewModelOf(::ConnectStatusViewModel)
viewModel { viewModel {
JamViewModel( JamViewModel(
jamRoomService = get(), jamRoomService = get(),

View File

@ -0,0 +1,27 @@
/*
* 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.discovery
/**
* The LAN address to publish in our mDNS advertisement, or null to let the platform decide.
*
* Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local
* address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's
* 192.168.49.1) that other devices can't reach.
*/
internal expect fun preferredAdvertiseAddress(): String?

View File

@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent
import com.appstractive.dnssd.NetService import com.appstractive.dnssd.NetService
import com.appstractive.dnssd.createNetService import com.appstractive.dnssd.createNetService
import com.appstractive.dnssd.discoverServices import com.appstractive.dnssd.discoverServices
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlin.text.decodeToString import kotlin.text.decodeToString
data class DiscoveredDevice( data class DiscoveredDevice(
@ -35,7 +39,8 @@ data class DiscoveredDevice(
} }
sealed interface DiscoveryState { sealed interface DiscoveryState {
data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState /** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */
data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState
data class Resolved(val device: DiscoveredDevice) : DiscoveryState data class Resolved(val device: DiscoveredDevice) : DiscoveryState
data class Removed(val device: DiscoveredDevice) : DiscoveryState data class Removed(val device: DiscoveredDevice) : DiscoveryState
} }
@ -46,6 +51,11 @@ class DeviceDiscoveryService {
const val TXT_DEVICE_ID = "deviceId" const val TXT_DEVICE_ID = "deviceId"
} }
/**
* Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking
* `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the
* collector was cancelled on the main thread.
*/
fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event -> fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event ->
when (event) { when (event) {
is DiscoveryEvent.Discovered -> { is DiscoveryEvent.Discovered -> {
@ -55,7 +65,9 @@ class DeviceDiscoveryService {
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
DiscoveryState.Discovered(device = device, resolve = event.resolve) DiscoveryState.Discovered(
device = device,
resolve = { withContext(Dispatchers.IO) { event.resolve() } })
} }
is DiscoveryEvent.Resolved -> { is DiscoveryEvent.Resolved -> {
@ -78,7 +90,7 @@ class DeviceDiscoveryService {
DiscoveryState.Removed(device = device) DiscoveryState.Removed(device = device)
} }
} }
} }.flowOn(Dispatchers.IO)
suspend fun advertise( suspend fun advertise(
name: String, name: String,
@ -97,5 +109,6 @@ class DeviceDiscoveryService {
type = SERVICE_TYPE, type = SERVICE_TYPE,
name = name, name = name,
port = port, port = port,
addresses = preferredAdvertiseAddress()?.let { listOf(it) },
txt = mapOf(TXT_DEVICE_ID to deviceId)) txt = mapOf(TXT_DEVICE_ID to deviceId))
} }

View File

@ -45,7 +45,7 @@ sealed class JamMessage {
* merely auto-advanced because its song ended (guests stay put). * merely auto-advanced because its song ended (guests stay put).
*/ */
val follow: Boolean = false, val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */ /** Room-wide play/pause; guests follow every change of it. */
val isPlaying: Boolean = false) : JamMessage() val isPlaying: Boolean = false) : JamMessage()
@Serializable @Serializable
@ -76,11 +76,15 @@ sealed class JamMessage {
} }
/** /**
* Playback commands. Only queue navigation is global — play/pause, seek, * Playback commands. Queue navigation and play/pause are global — seek,
* volume, shuffle and loop are local to each participant. * volume, shuffle and loop are local to each participant.
*/ */
@Serializable @Serializable
sealed class PlaybackCmd { sealed class PlaybackCmd {
@Serializable
@SerialName("setPlaying")
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
@Serializable @Serializable
@SerialName("skipNext") @SerialName("skipNext")
data object SkipNext : PlaybackCmd() data object SkipNext : PlaybackCmd()

View File

@ -33,11 +33,14 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.random.Random import kotlin.random.Random
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -52,9 +55,9 @@ import kotlin.time.Clock
* Sync rules: * Sync rules:
* - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo]
* from anyone are applied by the host, then broadcast via the retained state topic. * from anyone are applied by the host, then broadcast via the retained state topic.
* - Play/pause, seek, volume and loop are local to each device — never broadcast. * - Play/pause is global: anyone's play/pause is sent to the host, which applies it
* When the queue moves on, a paused participant stays paused; a playing one * and broadcasts it; guests follow every change of the host's play state.
* keeps playing the new current item. * - Seek, volume and loop are local to each device — never broadcast.
* - Shuffle is host-only; guests mirror the host's shuffle setting. * - Shuffle is host-only; guests mirror the host's shuffle setting.
* - Guests can only add to the queue (suggest); the host applies suggestions. * - Guests can only add to the queue (suggest); the host applies suggestions.
* - If the host leaves, the participant with the lowest client id takes over. * - If the host leaves, the participant with the lowest client id takes over.
@ -63,7 +66,7 @@ private data class PlaybackBroadcast(
val queue: List<QueueEntry>, val queue: List<QueueEntry>,
val current: QueueEntry?, val current: QueueEntry?,
val shuffle: Boolean, val shuffle: Boolean,
val playerState: PlayerState) val isPlaying: Boolean)
class JamRoomService( class JamRoomService(
private val jamClient: JamRoomClient, private val jamClient: JamRoomClient,
@ -73,6 +76,20 @@ class JamRoomService(
private val log = Logger.withTag("JamRoomService") private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
/**
* Play/pause intent. Transient states (buffering between tracks, loading, completion)
* keep the last value, so they never pause or resume the room.
*/
private val playIntent: StateFlow<Boolean> = audioPlayer.playerStateFlow
.mapNotNull { state ->
when (state) {
PlayerState.PLAYING -> true
PlayerState.PAUSED -> false
else -> null
}
}
.stateIn(scope, SharingStarted.Eagerly, audioPlayer.playerStateFlow.value == PlayerState.PLAYING)
private val _role = MutableStateFlow<JamRole?>(null) private val _role = MutableStateFlow<JamRole?>(null)
val role: StateFlow<JamRole?> = _role.asStateFlow() val role: StateFlow<JamRole?> = _role.asStateFlow()
@ -116,8 +133,8 @@ class JamRoomService(
*/ */
private var autoAdvanceDeadlineMs = 0L private var autoAdvanceDeadlineMs = 0L
/** Guest side: playback has started at least once (local control is the guest's own). */ /** Guest side: the room's play state from the last host broadcast (null until the first one). */
private var hasStartedPlayback = false private var lastHostIsPlaying: Boolean? = null
/** Host side: client ids banned for this session. */ /** Host side: client ids banned for this session. */
private val bannedClientIds = mutableSetOf<String>() private val bannedClientIds = mutableSetOf<String>()
@ -145,12 +162,14 @@ class JamRoomService(
audioPlayer.completionFlow audioPlayer.completionFlow
.onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS }
.launchIn(scope) .launchIn(scope)
// Once a guest has played on its own (or was started by the host), its // A guest's own play/pause (UI, notification, headset) goes to the host, which
// play/pause is its own — the host's play state only starts fresh joiners. // applies it for the whole room. Intent that matches the room's state came from
audioPlayer.playerStateFlow // following the host, so it isn't echoed back.
.onEach { state -> playIntent
if (_role.value == JamRole.Guest && state == PlayerState.PLAYING) { .onEach { playing ->
hasStartedPlayback = true val roomPlaying = lastHostIsPlaying
if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) {
publishCommand(PlaybackCmd.SetPlaying(playing))
} }
} }
.launchIn(scope) .launchIn(scope)
@ -182,7 +201,7 @@ class JamRoomService(
bannedClientIds.clear() bannedClientIds.clear()
leaving = false leaving = false
autoAdvanceDeadlineMs = 0L autoAdvanceDeadlineMs = 0L
hasStartedPlayback = false lastHostIsPlaying = null
startHostBroadcast() startHostBroadcast()
persistLastCode(code) persistLastCode(code)
code code
@ -214,7 +233,7 @@ class JamRoomService(
lastAppliedItems = emptyList() lastAppliedItems = emptyList()
lastAppliedIndex = -1 lastAppliedIndex = -1
leaving = false leaving = false
hasStartedPlayback = false lastHostIsPlaying = null
persistLastCode(normalized) persistLastCode(normalized)
} }
} }
@ -232,7 +251,7 @@ class JamRoomService(
lastAppliedItems = emptyList() lastAppliedItems = emptyList()
lastAppliedIndex = -1 lastAppliedIndex = -1
bannedClientIds.clear() bannedClientIds.clear()
hasStartedPlayback = false lastHostIsPlaying = null
} }
// ---------- Controls (called from the UI) ---------- // ---------- Controls (called from the UI) ----------
@ -294,8 +313,8 @@ class JamRoomService(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow, audioPlayer.shuffleModeFlow,
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState -> playIntent) { queue, current, shuffle, isPlaying ->
PlaybackBroadcast(queue, current, shuffle, playerState) PlaybackBroadcast(queue, current, shuffle, isPlaying)
} }
.onEach { broadcast -> .onEach { broadcast ->
if (_role.value != JamRole.Host) return@onEach if (_role.value != JamRole.Host) return@onEach
@ -311,7 +330,7 @@ class JamRoomService(
currentIndex = index.coerceAtLeast(0), currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle, shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs, follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING) isPlaying = broadcast.isPlaying)
) )
} }
.launchIn(this) .launchIn(this)
@ -332,15 +351,17 @@ class JamRoomService(
_shuffleEnabled.value = state.shuffleEnabled _shuffleEnabled.value = state.shuffleEnabled
runCatching { audioPlayer.shuffle(state.shuffleEnabled) } runCatching { audioPlayer.shuffle(state.shuffleEnabled) }
// Late joiner: the host is already playing, so start immediately. Once // Play/pause is room-wide: follow every change of the host's play state. The room
// this guest has played on its own, the host's play state is ignored. // state is recorded first, so the resulting local intent isn't sent back as a command.
if (state.isPlaying && !hasStartedPlayback) { if (state.isPlaying != lastHostIsPlaying) {
runCatching { audioPlayer.play() } lastHostIsPlaying = state.isPlaying
.onFailure { log.w(it) { "Failed to start playback on host play state" } } if (state.isPlaying != playIntent.value) {
runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() }
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
}
} }
val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
val wasPlaying = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
if (items != lastAppliedItems) { if (items != lastAppliedItems) {
val previous = lastAppliedItems val previous = lastAppliedItems
@ -369,7 +390,7 @@ class JamRoomService(
runCatching { runCatching {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() }, entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying, autoPlay = state.isPlaying,
startPosition = startIndex) startPosition = startIndex)
}.onFailure { log.w(it) { "Failed to apply jam queue" } } }.onFailure { log.w(it) { "Failed to apply jam queue" } }
return return
@ -419,6 +440,10 @@ class JamRoomService(
private suspend fun applyCommand(command: PlaybackCmd) { private suspend fun applyCommand(command: PlaybackCmd) {
when (command) { when (command) {
// Applied locally; the host broadcast carries the new play state to every guest
is PlaybackCmd.SetPlaying -> runCatching {
if (command.playing) audioPlayer.play() else audioPlayer.pause()
}
PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() }
PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() }
is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) }

View File

@ -192,7 +192,7 @@ val navigationModule = module {
BlacklistScreen() BlacklistScreen()
} }
navigation<Routes.Devices> { navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get()) DevicesScreen()
} }
navigation<Routes.RemoteControl> { navigation<Routes.RemoteControl> {
RemoteControlScreen( RemoteControlScreen(

View File

@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.websocket.CloseReason import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.close import io.ktor.websocket.close
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.devices_error_connection_denied
import spotube.composeapp.generated.resources.devices_error_connection_failed import spotube.composeapp.generated.resources.devices_error_connection_failed
import spotube.composeapp.generated.resources.devices_error_connection_lost import spotube.composeapp.generated.resources.devices_error_connection_lost
import spotube.composeapp.generated.resources.devices_error_send_failed import spotube.composeapp.generated.resources.devices_error_send_failed
@ -66,7 +67,7 @@ class RemoteControlClient {
} }
} }
private var session: WebSocketSession? = null private var session: DefaultWebSocketSession? = null
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected) private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@ -77,12 +78,23 @@ class RemoteControlClient {
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null) private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow() val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) { /**
* [deviceName] identifies this controller to the host; [remoteDeviceName] is the
* host's advertised name, kept only so the UI can show what it is controlling.
*/
suspend fun connect(
host: String,
port: Int,
deviceId: String,
deviceName: String,
remoteDeviceName: String = "") {
if (_connectionState.value is ConnectionState.Connected) { if (_connectionState.value is ConnectionState.Connected) {
logger.w { "Already connected" } logger.w { "Already connected" }
return return
} }
// Drop a stale attempt (e.g. one still waiting on the host's permission prompt).
closeSession()
_connectionState.value = ConnectionState.Connecting _connectionState.value = ConnectionState.Connecting
try { try {
session = httpClient.webSocketSession { session = httpClient.webSocketSession {
@ -95,16 +107,16 @@ class RemoteControlClient {
// Start receiving messages in a separate coroutine // Start receiving messages in a separate coroutine
scope.launch { scope.launch {
receiveLoop(host, port) receiveLoop(host, port, remoteDeviceName)
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Failed to connect to $host:$port" } logger.e(e) { "Failed to connect to $host:$port" }
closeSession()
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed)
disconnect()
} }
} }
private suspend fun receiveLoop(host: String, port: Int) { private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) {
val currentSession = session ?: return val currentSession = session ?: return
logger.d { "Starting receive loop for $host:$port" } logger.d { "Starting receive loop for $host:$port" }
try { try {
@ -119,7 +131,7 @@ class RemoteControlClient {
when (event) { when (event) {
is RemoteControlEvent.Connected -> { is RemoteControlEvent.Connected -> {
logger.i { "Connection authorized by server" } logger.i { "Connection authorized by server" }
_connectionState.value = ConnectionState.Connected(host, port) _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName)
} }
is RemoteControlEvent.WaitingForPermission -> { is RemoteControlEvent.WaitingForPermission -> {
logger.i { "Waiting for permission: ${event.message}" } logger.i { "Waiting for permission: ${event.message}" }
@ -144,18 +156,37 @@ class RemoteControlClient {
logger.w(e) { "Failed to parse message: $text" } logger.w(e) { "Failed to parse message: $text" }
} }
} }
is Frame.Close -> {
logger.i { "WebSocket closed by server" }
_connectionState.value = ConnectionState.Disconnected
break
}
else -> {} else -> {}
} }
} }
logger.d { "Receive loop exited normally" } // Ktor consumes Close frames itself and just ends `incoming`, so the server
// closing the socket (denied request, remote control turned off, host quit)
// only shows up here. Without this the state stayed Connecting/Connected forever
// and every later connect attempt was swallowed.
val reason = currentSession.closeReason.await()
logger.i { "WebSocket closed by server: $reason" }
onServerClosed(currentSession, reason)
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Error in receive loop" } logger.e(e) { "Error in receive loop" }
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) if (session === currentSession) {
session = null
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
}
}
}
private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) {
// disconnect() (or a newer connect()) already replaced this session; nothing to report.
if (session !== closedSession) return
session = null
val wasAuthorized = _connectionState.value is ConnectionState.Connected
_connectionState.value = when {
// The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny
// (or dismissed the prompt), or remote control is turned off over there.
!wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY ->
ConnectionState.Error(null, Res.string.devices_error_connection_denied)
wasAuthorized -> ConnectionState.Disconnected
else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed)
} }
} }
@ -180,12 +211,18 @@ class RemoteControlClient {
} }
suspend fun disconnect() { suspend fun disconnect() {
session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) closeSession()
session = null
_connectionState.value = ConnectionState.Disconnected _connectionState.value = ConnectionState.Disconnected
logger.i { "Disconnected" } logger.i { "Disconnected" }
} }
/** Detaches before closing so the receive loop doesn't report this as a server close. */
private suspend fun closeSession() {
val current = session ?: return
session = null
runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) }
}
private fun randomShortId(): String { private fun randomShortId(): String {
val chars = "0123456789abcdef" val chars = "0123456789abcdef"
return buildString(8) { return buildString(8) {
@ -199,7 +236,7 @@ class RemoteControlClient {
sealed interface ConnectionState { sealed interface ConnectionState {
data object Disconnected : ConnectionState data object Disconnected : ConnectionState
data object Connecting : ConnectionState data object Connecting : ConnectionState
data class Connected(val host: String, val port: Int) : ConnectionState data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState
/** /**
* [message] is the raw (untranslated) cause when available; [fallback] is the * [message] is the raw (untranslated) cause when available; [fallback] is the
* localized description to show when it isn't. * localized description to show when it isn't.

View File

@ -28,12 +28,16 @@ import org.spotube.spotube.modules.blacklist.BlacklistRepository
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.flow.Flow
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.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getPluralString import org.jetbrains.compose.resources.getPluralString
@ -82,6 +86,22 @@ sealed interface PlaybackDestinationRequest {
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
} }
/**
* Identifies a whole-collection action (e.g. "shuffle album X") while its tracks are being
* fetched and queued locally, so the button that started it can show a loading state.
*/
data class CollectionPlaybackKey(
val type: RemoteCollectionType,
val id: String,
val action: PlaybackDestinationAction,
val shuffle: Boolean = false)
/** Loading flags for a collection screen's play / shuffle / add-to-queue buttons. */
data class CollectionPlaybackUiState(
val isPlayLoading: Boolean = false,
val isShuffleLoading: Boolean = false,
val isAddToQueueLoading: Boolean = false)
/** /**
* Routes playback actions (play / add to queue / play next) to either the local * Routes playback actions (play / add to queue / play next) to either the local
* device or a connected remote device. When a remote device is connected the * device or a connected remote device. When a remote device is connected the
@ -103,6 +123,18 @@ class RemotePlaybackController(
private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8) private val _events = MutableSharedFlow<String>(extraBufferCapacity = 8)
val events: SharedFlow<String> = _events.asSharedFlow() val events: SharedFlow<String> = _events.asSharedFlow()
/** Collection actions currently running locally; see [CollectionPlaybackKey]. */
private val _busyCollections = MutableStateFlow<Set<CollectionPlaybackKey>>(emptySet())
val busyCollections: StateFlow<Set<CollectionPlaybackKey>> = _busyCollections.asStateFlow()
fun collectionPlaybackUiState(type: RemoteCollectionType, id: String): Flow<CollectionPlaybackUiState> =
busyCollections.map { busy ->
CollectionPlaybackUiState(
isPlayLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play) in busy,
isShuffleLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.Play, shuffle = true) in busy,
isAddToQueueLoading = CollectionPlaybackKey(type, id, PlaybackDestinationAction.AddToQueue) in busy)
}.distinctUntilChanged()
fun isRemoteConnected(): Boolean { fun isRemoteConnected(): Boolean {
return remoteControlClient.connectionState.value is ConnectionState.Connected return remoteControlClient.connectionState.value is ConnectionState.Connected
} }
@ -230,103 +262,119 @@ class RemotePlaybackController(
} }
private fun executeLocally(request: PlaybackDestinationRequest) { private fun executeLocally(request: PlaybackDestinationRequest) {
// Starting from a specific track (a tap in a track list) isn't tied to a collection button.
val busyKey = (request as? PlaybackDestinationRequest.Collection)
?.takeIf { it.startTrack == null }
?.let { CollectionPlaybackKey(it.type, it.id, it.action, it.shuffle) }
if (busyKey != null && busyKey in _busyCollections.value) return
busyKey?.let { key -> _busyCollections.update { it + key } }
scope.launch { scope.launch {
when (request) { try {
is PlaybackDestinationRequest.Collection -> { executeLocallyNow(request)
val startTrack = request.startTrack } catch (e: Exception) {
when (request.type) { logger.e(e) { "Failed to play ${request.title} locally" }
RemoteCollectionType.Playlist -> when (request.action) { } finally {
PlaybackDestinationAction.Play -> { busyKey?.let { key -> _busyCollections.update { it - key } }
if (startTrack != null) { }
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack) }
} else { }
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
} private suspend fun executeLocallyNow(request: PlaybackDestinationRequest) {
when (request) {
is PlaybackDestinationRequest.Collection -> {
val startTrack = request.startTrack
when (request.type) {
RemoteCollectionType.Playlist -> when (request.action) {
PlaybackDestinationAction.Play -> {
if (startTrack != null) {
collectionPlaybackHelper.playPlaylistFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playPlaylist(request.id, request.shuffle)
} }
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id)
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id)
} }
RemoteCollectionType.Album -> when (request.action) { PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addPlaylistToQueue(request.id)
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playPlaylistNext(request.id)
if (startTrack != null) { }
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else { RemoteCollectionType.Album -> when (request.action) {
collectionPlaybackHelper.playAlbum(request.id, request.shuffle) PlaybackDestinationAction.Play -> {
} if (startTrack != null) {
collectionPlaybackHelper.playAlbumFromTrack(request.id, startTrack)
} else {
collectionPlaybackHelper.playAlbum(request.id, request.shuffle)
} }
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id)
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id)
} }
RemoteCollectionType.ArtistTopTracks -> when (request.action) { PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addAlbumToQueue(request.id)
PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id) PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playAlbumNext(request.id)
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id) }
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
}
RemoteCollectionType.SavedTracks -> when (request.action) { RemoteCollectionType.ArtistTopTracks -> when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> collectionPlaybackHelper.playArtistTopTracks(request.id)
if (startTrack != null) { PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addArtistTopTracksToQueue(request.id)
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack) PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.playArtistTopTracksNext(request.id)
} else { }
collectionPlaybackHelper.playSavedTracks(request.shuffle)
} RemoteCollectionType.SavedTracks -> when (request.action) {
PlaybackDestinationAction.Play -> {
if (startTrack != null) {
collectionPlaybackHelper.playSavedTracksFromTrack(startTrack)
} else {
collectionPlaybackHelper.playSavedTracks(request.shuffle)
} }
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue()
} }
PlaybackDestinationAction.AddToQueue -> collectionPlaybackHelper.addSavedTracksToQueue()
PlaybackDestinationAction.PlayNext -> collectionPlaybackHelper.addSavedTracksToQueue()
} }
} }
}
is PlaybackDestinationRequest.Track -> { is PlaybackDestinationRequest.Track -> {
val entry = QueueEntry.StreamingTrack(track = request.track, url = "") val entry = QueueEntry.StreamingTrack(track = request.track, url = "")
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = listOf(entry), entries = listOf(entry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addToQueue(entry) audioPlayerQueue.addToQueue(entry)
} }
PlaybackDestinationAction.PlayNext -> { PlaybackDestinationAction.PlayNext -> {
val queue = audioPlayerQueue.getQueue() val queue = audioPlayerQueue.getQueue()
queue.find { candidate -> queue.find { candidate ->
(candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true (candidate as? QueueEntry.StreamingTrack)?.track?.matchesTrack(request.track) == true
}?.let { audioPlayerQueue.removeFromQueue(it) } }?.let { audioPlayerQueue.removeFromQueue(it) }
audioPlayerQueue.addAllAfterCurrent(listOf(entry)) audioPlayerQueue.addAllAfterCurrent(listOf(entry))
}
} }
} }
}
is PlaybackDestinationRequest.Tracks -> { is PlaybackDestinationRequest.Tracks -> {
val entries = request.tracks val entries = request.tracks
.filter { track -> !isTrackBlacklisted(track) } .filter { track -> !isTrackBlacklisted(track) }
.map { track -> QueueEntry.StreamingTrack(track = track, url = "") } .map { track -> QueueEntry.StreamingTrack(track = track, url = "") }
when (request.action) { when (request.action) {
PlaybackDestinationAction.Play -> { PlaybackDestinationAction.Play -> {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null) collectionEntry = null)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
audioPlayerQueue.addAllToQueue(entries) audioPlayerQueue.addAllToQueue(entries)
} }
PlaybackDestinationAction.PlayNext -> { PlaybackDestinationAction.PlayNext -> {
audioPlayerQueue.addAllAfterCurrent(entries) audioPlayerQueue.addAllAfterCurrent(entries)
}
} }
} }
} }

View File

@ -54,6 +54,16 @@ private data class CachedAlternatives(
val pluginId: String, val pluginId: String,
val sources: List<AudioSource>) val sources: List<AudioSource>)
/**
* Stream preferences for callers other than playback. The downloader passes the user's *download*
* format/quality and needs a file it can fetch with plain HTTP, so DASH/HLS manifests are excluded.
* Such requests bypass the playback stream-URL cache in both directions.
*/
data class StreamRequest(
val format: AudioFormat,
val quality: AudioQuality,
val progressiveOnly: Boolean)
data class StreamInfo( data class StreamInfo(
val url: String, val url: String,
val protocol: StreamProtocol, val protocol: StreamProtocol,
@ -90,22 +100,49 @@ class StreamingUrlRepository(
suspend fun resolveStreamInfo( suspend fun resolveStreamInfo(
track: MetadataTrack, track: MetadataTrack,
forceRefresh: Boolean = false forceRefresh: Boolean = false,
request: StreamRequest? = null
): StreamInfo? { ): StreamInfo? {
val trackId = track.id val trackId = track.id
val settings = settingsRepository.userSettings.value val settings = settingsRepository.userSettings.value
val preferredFormat = settings.streamingMusicFormat val preferredFormat = request?.format ?: settings.streamingMusicFormat
val preferredQuality = settings.streamingMusicQuality val preferredQuality = request?.quality ?: settings.streamingMusicQuality
// The URL cache holds what *playback* resolved; a download with other prefs must not
// read it, nor replace it with a stream playback didn't ask for.
val useUrlCache = request == null
val audioPlugin = pluginManager.selectedAudioPlugin.value val audioPlugin = pluginManager.selectedAudioPlugin.value
if (audioPlugin == null) { if (audioPlugin == null) {
logger.w { "No audio plugin selected while resolving stream for track $trackId" } logger.w { "No audio plugin selected while resolving stream for track $trackId" }
return null return null
} }
logger.i {
"[Resolve $trackId] '${track.title}' plugin=${audioPlugin.pluginId} forceRefresh=$forceRefresh " +
"prefs: format=${preferredFormat.codec}/${preferredFormat.container} quality=$preferredQuality " +
if (request == null) "(streaming settings)" else "(caller request, progressiveOnly=${request.progressiveOnly})"
}
if (!forceRefresh) { fun select(stream: AudioSource.Streamed, origin: String): AudioStream? {
val candidates = if (request?.progressiveOnly == true) {
stream.streams.filter { it.protocol == StreamProtocol.PROGRESSIVE }.also { kept ->
if (kept.size != stream.streams.size) logger.i {
"[Resolve $trackId] dropped ${stream.streams.size - kept.size} manifest (DASH/HLS) candidates; " +
"caller needs a directly downloadable file"
}
}
} else {
stream.streams
}
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
}
if (useUrlCache && !forceRefresh) {
getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached -> getCachedStreamUrl(trackId, preferredFormat, preferredQuality, audioPlugin.pluginId)?.let { cached ->
logger.v { "Using cached stream URL for track $trackId" } logger.i {
"[Resolve $trackId] cache hit: codec=${cached.codec} container=${cached.container} " +
"protocol=${cached.protocol} ${urlSummary(cached.url)}"
}
return cached return cached
} }
} }
@ -126,13 +163,12 @@ class StreamingUrlRepository(
if (stream != null) { if (stream != null) {
logger.d { "Resolved stream for track $trackId using cached source" } logger.d { "Resolved stream for track $trackId using cached source" }
val resolvedStream = selectPreferredAudioStream( val resolvedStream = select(stream, "matched source ${source.id}")
stream.streams,
preferredFormat,
preferredQuality)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) if (useUrlCache) {
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
}
return info return info
} }
} }
@ -185,18 +221,49 @@ class StreamingUrlRepository(
} }
logger.d { "Resolved stream for track $trackId via track lookup" } logger.d { "Resolved stream for track $trackId via track lookup" }
val resolvedStream = selectPreferredAudioStream( val resolvedStream = select(stream, "track lookup -> ${stream.id}")
stream.streams,
preferredFormat,
preferredQuality)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) if (useUrlCache) {
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
}
return info return info
} }
return null return null
} }
/** Logs every candidate with its ranking key so it's obvious *why* a stream won. */
private fun logSelection(
trackId: String,
origin: String,
streams: List<AudioStream>,
preferredFormat: AudioFormat,
preferredQuality: AudioQuality,
selected: AudioStream?) {
logger.i {
"[Resolve $trackId] selecting from ${streams.size} candidates ($origin), " +
"want ${preferredFormat.codec}/${preferredFormat.container} $preferredQuality " +
"(rank key = formatMismatch, qualityTypeMismatch, qualityDistance, channelDistance; lowest wins):\n" +
streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
"${stream.channelDistance(preferredQuality)}) ${urlSummary(stream.url)}"
}
}
when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
"[Resolve $trackId] FORMAT FALLBACK: wanted ${preferredFormat.codec}/${preferredFormat.container}, " +
"got ${selected.codec}/${selected.container} (${selected.protocol})"
}
selected.qualityDistance(preferredQuality) != 0L -> logger.w {
"[Resolve $trackId] QUALITY FALLBACK: wanted $preferredQuality, got ${selected.describeForLog()}"
}
}
}
private fun AudioStream.toStreamInfo() = StreamInfo( private fun AudioStream.toStreamInfo() = StreamInfo(
url = normalizeManifestUrl(url, protocol), url = normalizeManifestUrl(url, protocol),
protocol = protocol, protocol = protocol,
@ -353,3 +420,20 @@ private fun AudioStream.channelDistance(preferredQuality: AudioQuality): Int = w
else -> 0 else -> 0
} }
internal fun AudioStream.describeForLog(): String = when (this) {
is AudioStream.Lossy -> "lossy codec=$codec container=$container bitrate=$bitrate protocol=$protocol"
is AudioStream.Lossless ->
"lossless codec=$codec container=$container sampleRate=$sampleRate channels=$channels protocol=$protocol"
}
/**
* Host plus the query params that identify a YouTube stream (itag, mime, clen = byte size, dur),
* instead of the full signed URL.
*/
internal fun urlSummary(url: String): String = runCatching {
val parsed = io.ktor.http.Url(url)
val keys = listOf("itag", "mime", "clen", "dur")
val params = keys.mapNotNull { key -> parsed.parameters[key]?.let { "$key=$it" } }
"url=${parsed.host}${parsed.encodedPath.take(40)}" + if (params.isEmpty()) "" else " ${params.joinToString(" ")}"
}.getOrDefault("url=<unparseable ${url.take(60)}>")

View File

@ -28,6 +28,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
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.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.core.remote.CollectionPlaybackKey
import org.spotube.spotube.core.remote.PlaybackDestinationAction
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.cards.PlayableCard
@ -44,6 +46,7 @@ fun AlbumCard(
listMode: Boolean = false listMode: Boolean = false
) { ) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
title = album.title, title = album.title,
@ -71,6 +74,8 @@ fun AlbumCard(
}, },
modifier = if (listMode) modifier else modifier.width(160.dp), modifier = if (listMode) modifier else modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}", sharedElementKey = "album_art_${album.id}",
listMode = listMode listMode = listMode,
playLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.Play) in busyCollections,
addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Album, album.id, PlaybackDestinationAction.AddToQueue) in busyCollections
) )
} }

View File

@ -88,6 +88,10 @@ fun CollectionDetails(
isJamGuest: Boolean = false, isJamGuest: Boolean = false,
onAddToJam: (() -> Unit)? = null, onAddToJam: (() -> Unit)? = null,
isFollowing: Boolean = false, isFollowing: Boolean = false,
isFollowLoading: Boolean = false,
isPlayLoading: Boolean = false,
isShuffleLoading: Boolean = false,
isAddToQueueLoading: Boolean = false,
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
@ -126,7 +130,8 @@ fun CollectionDetails(
MillenniumButton( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onPlay) { onClick = onPlay,
loading = isPlayLoading) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description)) contentDescription = if (isPlaying) stringResource(Res.string.player_pause_content_description) else stringResource(Res.string.player_play_content_description))
@ -135,6 +140,7 @@ fun CollectionDetails(
MillenniumButton( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onShufflePlay, onClick = onShufflePlay,
loading = isShuffleLoading,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play)) MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = stringResource(Res.string.collection_details_shuffle_play))
@ -148,6 +154,7 @@ fun CollectionDetails(
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = isAddToQueueLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
@ -159,6 +166,7 @@ fun CollectionDetails(
if (isFollowing) { if (isFollowing) {
MillenniumIconButton( MillenniumIconButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isFollowLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxHeart2, imageVector = Iconsax.IconsaxHeart2,
@ -167,6 +175,7 @@ fun CollectionDetails(
} else { } else {
MillenniumIconButton( MillenniumIconButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isFollowLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxHeart, imageVector = Iconsax.IconsaxHeart,

View File

@ -69,6 +69,10 @@ fun CollectionView(
onAddToQueue: () -> Unit, onAddToQueue: () -> Unit,
isPlaying: Boolean = false, isPlaying: Boolean = false,
isFollowing: Boolean = false, isFollowing: Boolean = false,
isFollowLoading: Boolean = false,
isPlayLoading: Boolean = false,
isShuffleLoading: Boolean = false,
isAddToQueueLoading: Boolean = false,
onFollowClick: () -> Unit = {}, onFollowClick: () -> Unit = {},
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
@ -123,6 +127,7 @@ fun CollectionView(
{ {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = isPlayLoading,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
@ -159,6 +164,10 @@ fun CollectionView(
onAddToQueue = onAddToQueue, onAddToQueue = onAddToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = isFollowing, isFollowing = isFollowing,
isFollowLoading = isFollowLoading,
isPlayLoading = isPlayLoading,
isShuffleLoading = isShuffleLoading,
isAddToQueueLoading = isAddToQueueLoading,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,
@ -180,6 +189,10 @@ fun CollectionView(
onAddToQueue = onAddToQueue, onAddToQueue = onAddToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = isFollowing, isFollowing = isFollowing,
isFollowLoading = isFollowLoading,
isPlayLoading = isPlayLoading,
isShuffleLoading = isShuffleLoading,
isAddToQueueLoading = isAddToQueueLoading,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
showFollowButton = showFollowButton, showFollowButton = showFollowButton,
onEdit = onEdit, onEdit = onEdit,

View File

@ -87,6 +87,7 @@ fun ErrorDisplay(
LoginRequiredDisplay( LoginRequiredDisplay(
pluginName = authState.pluginName, pluginName = authState.pluginName,
onLogin = authViewModel::login, onLogin = authViewModel::login,
isLoggingIn = authState.isLoggingIn,
modifier = modifier) modifier = modifier)
return return
} }

View File

@ -72,7 +72,10 @@ import org.spotube.spotube.core.audioplayer.QueueCollectionEntry
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
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.playback.CollectionPlaybackHelper import org.spotube.spotube.core.remote.CollectionPlaybackKey
import org.spotube.spotube.core.remote.PlaybackDestinationAction
import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.ui.coverflow.Coverflow import org.spotube.spotube.core.ui.coverflow.Coverflow
import org.spotube.spotube.core.ui.coverflow.CoverflowParams import org.spotube.spotube.core.ui.coverflow.CoverflowParams
import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState import org.spotube.spotube.core.ui.coverflow.rememberCoverflowState
@ -288,11 +291,12 @@ private fun FeaturedPlayButton(
item: MetadataBrowseItem, item: MetadataBrowseItem,
audioPlayerQueue: AudioPlayerQueue, audioPlayerQueue: AudioPlayerQueue,
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
playbackHelper: CollectionPlaybackHelper = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject(),
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
when (item) { when (item) {
is MetadataBrowseItem.Album -> { is MetadataBrowseItem.Album -> {
@ -309,10 +313,18 @@ private fun FeaturedPlayButton(
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Album(item.data.id)) navigationCommands.navigateTo(Routes.Album(item.data.id))
} else { } else {
scope.launch { playbackHelper.playAlbum(item.data.id) } remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album,
item.data.id,
item.data.title
)
} }
}, },
enabled = enabled enabled = enabled,
loading = CollectionPlaybackKey(
RemoteCollectionType.Album, item.data.id, PlaybackDestinationAction.Play
) in busyCollections,
foreground = Color.White
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
@ -337,10 +349,18 @@ private fun FeaturedPlayButton(
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Playlist(item.data.id)) navigationCommands.navigateTo(Routes.Playlist(item.data.id))
} else { } else {
scope.launch { playbackHelper.playPlaylist(item.data.id) } remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist,
item.data.id,
item.data.title
)
} }
}, },
enabled = enabled enabled = enabled,
loading = CollectionPlaybackKey(
RemoteCollectionType.Playlist, item.data.id, PlaybackDestinationAction.Play
) in busyCollections,
foreground = Color.White
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,

View File

@ -38,7 +38,6 @@ import com.composeunstyled.Text
import com.composeunstyled.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
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
@ -50,14 +49,17 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.QueueCollectionEntry import org.spotube.spotube.core.audioplayer.QueueCollectionEntry
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.playback.CollectionPlaybackHelper
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxAddSquare import org.spotube.spotube.resources.iconsax.IconsaxAddSquare
import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle import org.spotube.spotube.resources.iconsax.IconsaxPauseCircle
import org.spotube.spotube.resources.iconsax.Play import org.spotube.spotube.resources.iconsax.Play
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.spotube.spotube.core.remote.CollectionPlaybackKey
import org.spotube.spotube.core.remote.PlaybackDestinationAction
import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.modules.saved_tracks.SAVED_TRACKS_COLLECTION_ID
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.spotube.spotube.core.ui.component.cards.PlayableListTile import org.spotube.spotube.core.ui.component.cards.PlayableListTile
@ -72,14 +74,26 @@ import androidx.compose.ui.platform.LocalInputModeManager
fun LikedTracksCard( fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
listMode: Boolean = false listMode: Boolean = false
) { ) {
val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks val isPlaying = currentCollectionEntry is QueueCollectionEntry.SavedTracks
val title = stringResource(Res.string.media_liked_tracks) val title = stringResource(Res.string.media_liked_tracks)
val playLoading = CollectionPlaybackKey(
RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.Play
) in busyCollections
val addToQueueLoading = CollectionPlaybackKey(
RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, PlaybackDestinationAction.AddToQueue
) in busyCollections
val onPlay = {
remotePlaybackController.requestCollectionPlay(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title)
}
val onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID, title)
}
val subtitle = stringResource(Res.string.media_saved_songs) val subtitle = stringResource(Res.string.media_saved_songs)
if (listMode) { if (listMode) {
PlayableListTile( PlayableListTile(
@ -87,10 +101,12 @@ fun LikedTracksCard(
subtitle = subtitle, subtitle = subtitle,
isPlaying = isPlaying, isPlaying = isPlaying,
onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }, onClick = { navigationCommands.navigateTo(Routes.SavedTracks) },
onPlay = { scope.launch { playbackHelper.playSavedTracks() } }, onPlay = onPlay,
onAddToQueue = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onAddToQueue = onAddToQueue,
modifier = modifier, modifier = modifier,
sharedElementKey = "saved_tracks_art", sharedElementKey = "saved_tracks_art",
playLoading = playLoading,
addToQueueLoading = addToQueueLoading,
artwork = { artwork = {
Image( Image(
painterResource(Res.drawable.liked_tracks), painterResource(Res.drawable.liked_tracks),
@ -132,13 +148,14 @@ fun LikedTracksCard(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
if (isHovered || touchInput || isPlaying) { if (isHovered || touchInput || isPlaying || playLoading || addToQueueLoading) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
MillenniumIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -150,7 +167,8 @@ fun LikedTracksCard(
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
MillenniumIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.playSavedTracks() } }, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(30.dp), modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {

View File

@ -49,7 +49,8 @@ import spotube.composeapp.generated.resources.*
fun LoginRequiredDisplay( fun LoginRequiredDisplay(
pluginName: String?, pluginName: String?,
onLogin: () -> Unit, onLogin: () -> Unit,
modifier: Modifier = Modifier) { modifier: Modifier = Modifier,
isLoggingIn: Boolean = false) {
Column( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@ -88,6 +89,7 @@ fun LoginRequiredDisplay(
MillenniumButton( MillenniumButton(
onClick = onLogin, onClick = onLogin,
loading = isLoggingIn,
modifier = Modifier.height(44.dp)) { modifier = Modifier.height(44.dp)) {
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
MillenniumIcon( MillenniumIcon(

View File

@ -27,6 +27,8 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
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.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.core.remote.CollectionPlaybackKey
import org.spotube.spotube.core.remote.PlaybackDestinationAction
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.ui.component.cards.PlayableCard import org.spotube.spotube.core.ui.component.cards.PlayableCard
@ -45,6 +47,7 @@ fun PlaylistCard(
) { ) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
val busyCollections by remotePlaybackController.busyCollections.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
title = playlist.title, title = playlist.title,
@ -71,6 +74,8 @@ fun PlaylistCard(
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}", sharedElementKey = "playlist_art_${playlist.id}",
listMode = listMode listMode = listMode,
playLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.Play) in busyCollections,
addToQueueLoading = CollectionPlaybackKey(RemoteCollectionType.Playlist, playlist.id, PlaybackDestinationAction.AddToQueue) in busyCollections
) )
} }

View File

@ -76,7 +76,9 @@ fun PlayableCard(
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null, sharedElementKey: String? = null,
listMode: Boolean = false listMode: Boolean = false,
playLoading: Boolean = false,
addToQueueLoading: Boolean = false
) { ) {
if (listMode) { if (listMode) {
PlayableListTile( PlayableListTile(
@ -88,7 +90,9 @@ fun PlayableCard(
onPlay, onPlay,
onAddToQueue, onAddToQueue,
modifier, modifier,
sharedElementKey sharedElementKey,
playLoading = playLoading,
addToQueueLoading = addToQueueLoading
) )
return return
} }
@ -124,7 +128,8 @@ fun PlayableCard(
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = highlighted || touchInput || (isPlaying && onPlay != null), visible = highlighted || touchInput || (isPlaying && onPlay != null) ||
playLoading || addToQueueLoading,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut() exit = fadeOut()
) { ) {
@ -137,6 +142,7 @@ fun PlayableCard(
onAddToQueue?.takeIf { !isPlaying }?.let { onAddToQueue?.takeIf { !isPlaying }?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -152,6 +158,7 @@ fun PlayableCard(
onPlay?.let { onPlay?.let {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(34.dp), modifier = Modifier.size(34.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary

View File

@ -56,6 +56,8 @@ fun PlayableListTile(
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null, sharedElementKey: String? = null,
playLoading: Boolean = false,
addToQueueLoading: Boolean = false,
imageModel: Any? = imageURL, imageModel: Any? = imageURL,
imageShape: Shape = RoundedCornerShape(6.dp), imageShape: Shape = RoundedCornerShape(6.dp),
placeholderIcon: ImageVector = Iconsax.IconsaxMusic, placeholderIcon: ImageVector = Iconsax.IconsaxMusic,
@ -115,6 +117,7 @@ fun PlayableListTile(
if (onAddToQueue != null && !isPlaying) { if (onAddToQueue != null && !isPlaying) {
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = addToQueueLoading,
modifier = Modifier.size(if (compact) 32.dp else 40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
@ -127,6 +130,7 @@ fun PlayableListTile(
if (onPlay != null) { if (onPlay != null) {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = playLoading,
modifier = Modifier.size(if (compact) 32.dp else 40.dp), modifier = Modifier.size(if (compact) 32.dp else 40.dp),
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {

View File

@ -45,6 +45,7 @@ fun AlbumScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -71,6 +72,9 @@ fun AlbumScreen(
val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is AlbumScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = album?.title ?: stringResource(Res.string.album_loading_title), title = album?.title ?: stringResource(Res.string.album_loading_title),
description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}", description = album?.description ?: "${album?.albumType?.name.orEmpty()} • ${album?.releaseDate.orEmpty()}",
imageURL = artworkUrl, imageURL = artworkUrl,
@ -86,6 +90,7 @@ fun AlbumScreen(
onAddToQueue = viewModel::addAlbumToQueue, onAddToQueue = viewModel::addAlbumToQueue,
isPlaying = isAlbumPlaying, isPlaying = isAlbumPlaying,
isFollowing = savedAlbumIds.contains(albumId), isFollowing = savedAlbumIds.contains(albumId),
isFollowLoading = dataState?.isSaving == true,
onFollowClick = viewModel::toggleSavedAlbum, onFollowClick = viewModel::toggleSavedAlbum,
isLoading = isLoading, isLoading = isLoading,
error = errorMessage, error = errorMessage,

View File

@ -26,6 +26,7 @@ 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.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.core.remote.CollectionPlaybackUiState
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService import org.spotube.spotube.core.share.ShareService
@ -47,6 +48,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -104,6 +106,11 @@ class AlbumViewModel(
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading) private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
val uiState: StateFlow<AlbumScreenState> = _state.asStateFlow() val uiState: StateFlow<AlbumScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.Album, albumId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedAlbumIds val savedAlbumIds
get() = libraryRepository.savedAlbumIdsFlow get() = libraryRepository.savedAlbumIdsFlow
@ -203,13 +210,30 @@ class AlbumViewModel(
} }
fun toggleSavedAlbum() { fun toggleSavedAlbum() {
if ((_state.value as? AlbumScreenState.Data)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
val isLiked = runCatching {
libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false val isLiked =
if (isLiked) { libraryRepository.isSavedAlbums(listOf(albumId)).firstOrNull() ?: false
libraryRepository.removeSavedAlbums(listOf(albumId)) if (isLiked) {
} else { libraryRepository.removeSavedAlbums(listOf(albumId))
libraryRepository.saveAlbums(listOf(albumId)) } else {
libraryRepository.saveAlbums(listOf(albumId))
}
}.onFailure { e ->
logger.e(e) { "Failed to toggle saved album" }
}
setSaving(false)
}
}
private fun setSaving(isSaving: Boolean) {
_state.update { state ->
when (state) {
is AlbumScreenState.Data.Loaded -> state.copy(isSaving = isSaving)
is AlbumScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving)
else -> state
} }
} }
} }

View File

@ -104,6 +104,7 @@ fun ArtistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.state.collectAsStateWithLifecycle() val state by viewModel.state.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -149,6 +150,7 @@ fun ArtistScreen(
ArtistHeaderCard( ArtistHeaderCard(
artist = currentState.artist, artist = currentState.artist,
isSaved = savedArtistIds.contains(currentState.artist.id), isSaved = savedArtistIds.contains(currentState.artist.id),
isSaving = currentState.isSaving,
onFollowClick = viewModel::toggleSavedArtist, onFollowClick = viewModel::toggleSavedArtist,
isBlacklisted = currentState.artist.id in blacklistedArtistIds, isBlacklisted = currentState.artist.id in blacklistedArtistIds,
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }) onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) })
@ -157,7 +159,9 @@ fun ArtistScreen(
item { item {
TopTracksHeader( TopTracksHeader(
onPlay = viewModel::playTopTracks, onPlay = viewModel::playTopTracks,
onAddToQueue = viewModel::addTopTracksToQueue onAddToQueue = viewModel::addTopTracksToQueue,
isPlayLoading = playbackUiState.isPlayLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading
) )
} }
@ -345,6 +349,7 @@ private fun ArtistErrorContent(
private fun ArtistHeaderCard( private fun ArtistHeaderCard(
artist: MetadataArtist.Detailed, artist: MetadataArtist.Detailed,
isSaved: Boolean, isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit onBlacklistClick: () -> Unit
@ -377,6 +382,7 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick onBlacklistClick = onBlacklistClick
@ -406,6 +412,7 @@ private fun ArtistHeaderCard(
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
isSaving = isSaving,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick onBlacklistClick = onBlacklistClick
@ -496,6 +503,7 @@ private fun ArtistMeta(
@Composable @Composable
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
isSaving: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit onBlacklistClick: () -> Unit
@ -507,6 +515,7 @@ private fun ArtistHeaderActions(
if (isSaved) { if (isSaved) {
MillenniumButton( MillenniumButton(
onClick = onFollowClick, onClick = onFollowClick,
loading = isSaving,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
Text( Text(
@ -517,7 +526,7 @@ private fun ArtistHeaderActions(
) )
} }
} else { } else {
MillenniumButton(onClick = onFollowClick) { MillenniumButton(onClick = onFollowClick, loading = isSaving) {
Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text(stringResource(Res.string.collection_details_follow), modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} }
@ -554,7 +563,9 @@ private fun ArtistHeaderActions(
@Composable @Composable
private fun TopTracksHeader( private fun TopTracksHeader(
onPlay: () -> Unit, onPlay: () -> Unit,
onAddToQueue: () -> Unit onAddToQueue: () -> Unit,
isPlayLoading: Boolean = false,
isAddToQueueLoading: Boolean = false
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
@ -572,6 +583,7 @@ private fun TopTracksHeader(
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
MillenniumIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
loading = isPlayLoading,
style = MillenniumIconButtonStyle.Primary style = MillenniumIconButtonStyle.Primary
) { ) {
MillenniumIcon( MillenniumIcon(
@ -582,6 +594,7 @@ private fun TopTracksHeader(
} }
MillenniumIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
loading = isAddToQueueLoading,
style = MillenniumIconButtonStyle.Secondary style = MillenniumIconButtonStyle.Secondary
) { ) {
MillenniumIcon( MillenniumIcon(

View File

@ -27,6 +27,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
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.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.remote.CollectionPlaybackUiState
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService import org.spotube.spotube.core.share.ShareService
@ -44,6 +45,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -61,7 +63,8 @@ sealed interface ArtistScreenState {
val relatedArtists: List<MetadataArtist.Basic>, val relatedArtists: List<MetadataArtist.Basic>,
val relatedArtistsNextPagination: PaginationStrategy?, val relatedArtistsNextPagination: PaginationStrategy?,
val featuredPlaylists: List<MetadataPlaylist>, val featuredPlaylists: List<MetadataPlaylist>,
val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState val featuredPlaylistsNextPagination: PaginationStrategy?,
val isSaving: Boolean = false) : ArtistScreenState
data class Error(val message: String) : ArtistScreenState data class Error(val message: String) : ArtistScreenState
} }
@ -82,6 +85,11 @@ class ArtistViewModel(
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading) private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
val state: StateFlow<ArtistScreenState> = _state.asStateFlow() val state: StateFlow<ArtistScreenState> = _state.asStateFlow()
/** Loading flags for the top-tracks play / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.ArtistTopTracks, artistId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedArtistIds val savedArtistIds
get() = libraryRepository.savedArtistIdsFlow get() = libraryRepository.savedArtistIdsFlow
@ -152,13 +160,26 @@ class ArtistViewModel(
} }
fun toggleSavedArtist() { fun toggleSavedArtist() {
if ((_state.value as? ArtistScreenState.Loaded)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false runCatching {
if (isLiked) { val isLiked = libraryRepository.isSavedArtists(listOf(artistId)).firstOrNull() ?: false
libraryRepository.removeSavedArtists(listOf(artistId)) if (isLiked) {
} else { libraryRepository.removeSavedArtists(listOf(artistId))
libraryRepository.saveArtists(listOf(artistId)) } else {
libraryRepository.saveArtists(listOf(artistId))
}
}.onFailure { e ->
logger.e(e) { "Failed to toggle saved artist" }
} }
setSaving(false)
}
}
private fun setSaving(isSaving: Boolean) {
_state.update { state ->
if (state is ArtistScreenState.Loaded) state.copy(isSaving = isSaving) else state
} }
} }

View File

@ -18,266 +18,396 @@
package org.spotube.spotube.modules.devices package org.spotube.spotube.modules.devices
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.theme.MillenniumTheme import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.shape.CircleShape
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
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.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow 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 org.spotube.spotube.core.discovery.DiscoveredDevice
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester
import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.components.SettingCardItem
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
import org.spotube.spotube.modules.settings.sections.remoteControlSettingCards
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare
import org.spotube.spotube.resources.iconsax.IconsaxInformation
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight
import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.devices_connected_to
import spotube.composeapp.generated.resources.devices_connecting
import spotube.composeapp.generated.resources.devices_device_id
import spotube.composeapp.generated.resources.devices_disconnect import spotube.composeapp.generated.resources.devices_disconnect
import spotube.composeapp.generated.resources.devices_empty import spotube.composeapp.generated.resources.devices_empty
import spotube.composeapp.generated.resources.devices_empty_hint import spotube.composeapp.generated.resources.devices_empty_hint
import spotube.composeapp.generated.resources.devices_open_remote
import spotube.composeapp.generated.resources.devices_refresh import spotube.composeapp.generated.resources.devices_refresh
import spotube.composeapp.generated.resources.devices_resolving import spotube.composeapp.generated.resources.devices_resolving
import spotube.composeapp.generated.resources.devices_search_again
import spotube.composeapp.generated.resources.devices_searching import spotube.composeapp.generated.resources.devices_searching
import spotube.composeapp.generated.resources.devices_searching_more
import spotube.composeapp.generated.resources.devices_section_available
import spotube.composeapp.generated.resources.devices_section_connected
import spotube.composeapp.generated.resources.devices_section_this_device
import spotube.composeapp.generated.resources.devices_status_connected
import spotube.composeapp.generated.resources.devices_title import spotube.composeapp.generated.resources.devices_title
import spotube.composeapp.generated.resources.devices_unknown_device import spotube.composeapp.generated.resources.devices_unknown_device
import spotube.composeapp.generated.resources.jam_error_dismiss import spotube.composeapp.generated.resources.jam_error_dismiss
private val ContentMaxWidth = 720.dp
@Composable @Composable
fun DevicesScreen( fun DevicesScreen(
navigationCommands: NavigationCommands) { viewModel: DevicesViewModel = koinViewModel(),
val viewModel = koinViewModel<DevicesViewModel>() settingsViewModel: SettingsViewModel = koinViewModel()
val devices by viewModel.devices.collectAsStateWithLifecycle() ) {
val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle() val settings by settingsViewModel.settingsState.collectAsStateWithLifecycle()
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
val error by viewModel.error.collectAsStateWithLifecycle()
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val shellBottomInset = LocalAppShellBottomInset.current
DisposableEffect(Unit) { DisposableEffect(Unit) {
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
requestLocalNetworkPermission() requestLocalNetworkPermission()
viewModel.startDiscovery() viewModel.startDiscovery()
onDispose { onDispose {
// The remote connection itself outlives this screen; only discovery stops.
viewModel.stopDiscovery() viewModel.stopDiscovery()
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
// The RemoteControlViewModel will manage the connection lifecycle
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background)) { .background(MillenniumTheme.colors.background)
) {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,
title = { Text(stringResource(Res.string.devices_title)) }, title = { Text(stringResource(Res.string.devices_title)) },
actions = { actions = {
if (isDiscovering && connectingToDevice == null) { MillenniumIconButton(
MillenniumSpokeSpinner( onClick = viewModel::startDiscovery,
size = 24.dp, enabled = !state.isConnecting,
modifier = Modifier.padding(end = 8.dp)) loading = state.isDiscovering,
} else { style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxRefreshRight, imageVector = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.devices_refresh), contentDescription = stringResource(Res.string.devices_refresh)
modifier = Modifier )
.size(24.dp)
.clickable(enabled = connectingToDevice == null) {
viewModel.startDiscovery()
})
}
})
val shellBottomInset = LocalAppShellBottomInset.current
Column(
modifier = Modifier
.fillMaxSize()
.weight(1f)) {
// Error banner
error?.let { devicesError ->
val errorMessage = devicesError.rawMessage
?: stringResource(devicesError.message, *devicesError.args.toTypedArray())
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)) {
Column {
Text(
text = errorMessage,
color = AppTheme.colors.error,
style = AppTheme.typography.bodyMedium)
MillenniumButton(
onClick = { viewModel.clearError() },
modifier = Modifier.padding(top = 8.dp),
style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.jam_error_dismiss))
}
}
} }
} }
)
// Connection status Box(
when (val state = connectionState) { modifier = Modifier
is ConnectionState.Connected -> { .weight(1f)
Box( .fillMaxWidth(),
modifier = Modifier contentAlignment = Alignment.TopCenter
.fillMaxWidth() ) {
.padding(16.dp)) { LazyColumn(
Column { modifier = Modifier
Text( .widthIn(max = ContentMaxWidth)
text = stringResource(Res.string.devices_connected_to, state.host, state.port), .fillMaxSize(),
style = AppTheme.typography.bodyMedium, contentPadding = PaddingValues(
color = AppTheme.colors.primary) start = 8.dp,
MillenniumButton( end = 8.dp,
onClick = { viewModel.disconnect() }, top = 8.dp,
modifier = Modifier.padding(top = 8.dp), bottom = shellBottomInset + 16.dp
style = MillenniumButtonStyle.Secondary) { )
Text(stringResource(Res.string.devices_disconnect)) ) {
state.error?.let { error ->
item {
ErrorBanner(
message = error.rawMessage
?: stringResource(error.message, *error.args.toTypedArray()),
onDismiss = viewModel::clearError
)
}
}
state.connectedDevice?.let { device ->
settingsSectionHeader(Res.string.devices_section_connected)
item {
ConnectedDeviceCard(
device = device,
onOpenRemote = viewModel::openRemote,
onDisconnect = viewModel::disconnect
)
}
}
if (state.devices.isNotEmpty()) {
settingsSectionHeader(Res.string.devices_section_available)
settingsSectionCard(
items = state.devices.map { device ->
{
DeviceTile(
device = device,
enabled = !state.isConnecting || device.isConnecting,
onClick = { viewModel.onDeviceClick(device.key) }
)
} }
} }
} )
} if (state.isDiscovering) {
is ConnectionState.Connecting -> { item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
contentAlignment = Alignment.CenterStart) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically) {
MillenniumSpokeSpinner(size = 24.dp)
Text( Text(
text = stringResource(Res.string.devices_connecting), text = stringResource(Res.string.devices_searching_more),
style = AppTheme.typography.bodyMedium)
}
}
}
else -> {}
}
// Device list
if (devices.isEmpty() && connectingToDevice == null) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = shellBottomInset),
contentAlignment = Alignment.Center) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = if (isDiscovering) {
stringResource(Res.string.devices_searching)
} else {
stringResource(Res.string.devices_empty)
},
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant)
if (!isDiscovering) {
Text(
text = stringResource(Res.string.devices_empty_hint),
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = MillenniumTheme.colors.muted,
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp)) textAlign = TextAlign.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 12.dp)
)
} }
} }
} } else if (state.showEmptyState) {
} else {
LazyColumn(
modifier = Modifier
.fillMaxSize()
.weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = androidx.compose.foundation.layout.PaddingValues(
horizontal = 16.dp,
vertical = 8.dp)) {
items(devices.values.toList(), key = { it.key }) { device ->
DeviceRow(
device = device,
isConnecting = connectingToDevice?.key == device.key,
onClick = { viewModel.connectToDevice(device) })
}
item { item {
Box(modifier = Modifier.padding(bottom = shellBottomInset)) EmptyState(
isDiscovering = state.isDiscovering,
onSearchAgain = viewModel::startDiscovery
)
} }
} }
// Same cards as Settings > Playback, so making this device controllable (or renaming
// it) doesn't require a detour through Settings.
settings?.let { current ->
settingsSectionHeader(Res.string.devices_section_this_device)
settingsSectionCard(
items = remoteControlSettingCards(current, settingsViewModel, requestLocalNetworkPermission)
)
}
} }
} }
} }
} }
@Composable @Composable
private fun DeviceRow( private fun DeviceTile(
device: DiscoveredDevice, device: DeviceItemUi,
isConnecting: Boolean, enabled: Boolean,
onClick: () -> Unit) { onClick: () -> Unit
Row( ) {
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
SettingCardItem(
enabled = enabled,
title = name,
subtitle = device.address ?: stringResource(Res.string.devices_resolving),
icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) },
trailingContent = {
when {
device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp)
device.isConnected -> Text(
text = stringResource(Res.string.devices_status_connected),
style = AppTheme.typography.labelMedium,
color = AppTheme.colors.primary
)
else -> MillenniumIcon(
imageVector = FeatherIcons.ChevronRight,
contentDescription = null,
tint = AppTheme.colors.onSurfaceVariant
)
}
},
onClick = onClick
)
}
@Composable
private fun LazyItemScope.ConnectedDeviceCard(
device: ConnectedDeviceUi,
onOpenRemote: () -> Unit,
onDisconnect: () -> Unit
) {
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillParentMaxWidth()
.clickable(onClick = onClick, enabled = !isConnecting) .padding(horizontal = 8.dp, vertical = 4.dp),
.padding(vertical = 12.dp, horizontal = 8.dp), maxWidth = ContentMaxWidth,
verticalAlignment = Alignment.CenterVertically, contentPadding = PaddingValues(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp)) { contentSpacing = 16.dp,
if (isConnecting) { shadowElevation = 6.dp
MillenniumSpokeSpinner(size = 24.dp) ) {
} else { Row(
MillenniumIcon( verticalAlignment = Alignment.CenterVertically,
imageVector = Iconsax.IconsaxMirroringScreen, horizontalArrangement = Arrangement.spacedBy(12.dp)
contentDescription = null, ) {
tint = AppTheme.colors.primary) SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name)
} Column(modifier = Modifier.weight(1f)) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) },
style = AppTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
Text(
text = if (device.host.isNotBlank() && device.port > 0) {
"${device.host}:${device.port}"
} else {
stringResource(Res.string.devices_resolving)
},
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis)
if (device.deviceId.isNotBlank()) {
Text( Text(
text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)), text = name,
style = AppTheme.typography.labelSmall, style = AppTheme.typography.titleMedium,
color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Text(
text = device.address,
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
) {
MillenniumButton(
onClick = onDisconnect,
loading = device.isDisconnecting,
style = MillenniumButtonStyle.Secondary
) {
Text(stringResource(Res.string.devices_disconnect))
}
MillenniumButton(
onClick = onOpenRemote,
style = MillenniumButtonStyle.Primary
) {
Text(stringResource(Res.string.devices_open_remote))
}
}
}
}
@Composable
private fun ErrorBanner(
message: String,
onDismiss: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp)
.clip(AppTheme.shapes.medium)
.background(AppTheme.colors.errorContainer)
.padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxInformation,
contentDescription = null,
tint = AppTheme.colors.onErrorContainer,
modifier = Modifier.size(20.dp)
)
Text(
text = message,
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onErrorContainer,
modifier = Modifier.weight(1f)
)
MillenniumIconButton(
onClick = onDismiss,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon(
imageVector = Iconsax.IconsaxCloseSquare,
contentDescription = stringResource(Res.string.jam_error_dismiss),
tint = AppTheme.colors.onErrorContainer
)
}
}
}
@Composable
private fun LazyItemScope.EmptyState(
isDiscovering: Boolean,
onSearchAgain: () -> Unit
) {
// Fixed padding rather than filling the viewport, so the "This device" section below stays visible.
Box(
modifier = Modifier
.fillParentMaxWidth()
.padding(horizontal = 24.dp, vertical = 48.dp),
contentAlignment = Alignment.Center
) {
Column(
modifier = Modifier.widthIn(max = 360.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
Box(
modifier = Modifier
.size(72.dp)
.clip(CircleShape)
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
contentAlignment = Alignment.Center
) {
if (isDiscovering) {
MillenniumSpokeSpinner(size = 32.dp)
} else {
MillenniumIcon(
imageVector = Iconsax.IconsaxWifiSquare,
contentDescription = null,
tint = AppTheme.colors.primary,
modifier = Modifier.size(32.dp)
)
}
}
Text(
text = stringResource(
if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty
),
style = AppTheme.typography.titleLarge,
textAlign = TextAlign.Center,
modifier = Modifier.padding(top = 8.dp)
)
Text(
text = stringResource(Res.string.devices_empty_hint),
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center
)
if (!isDiscovering) {
MillenniumButton(
onClick = onSearchAgain,
style = MillenniumButtonStyle.Secondary,
modifier = Modifier.padding(top = 8.dp)
) {
Text(stringResource(Res.string.devices_search_again))
}
} }
} }
} }

View File

@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlClient
import org.spotube.spotube.core.remote.RemoteControlService import org.spotube.spotube.core.remote.RemoteControlService
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -52,6 +55,29 @@ data class DevicesError(
val args: List<String> = emptyList(), val args: List<String> = emptyList(),
val rawMessage: String? = null) val rawMessage: String? = null)
data class DeviceItemUi(
val key: String,
/** Null when the device advertised a blank name. */
val name: String?,
/** `host:port`, or null while the address is still being resolved. */
val address: String?,
val isConnecting: Boolean,
val isConnected: Boolean)
data class ConnectedDeviceUi(
val name: String?,
val address: String,
val isDisconnecting: Boolean = false)
data class DevicesUiState(
val devices: List<DeviceItemUi> = emptyList(),
val isDiscovering: Boolean = false,
val isConnecting: Boolean = false,
val connectedDevice: ConnectedDeviceUi? = null,
val error: DevicesError? = null) {
val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting
}
class DevicesViewModel( class DevicesViewModel(
private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent {
private val logger = Logger.withTag("DevicesViewModel") private val logger = Logger.withTag("DevicesViewModel")
@ -61,36 +87,65 @@ class DevicesViewModel(
private val settingsProvider: SettingsProvider by inject() private val settingsProvider: SettingsProvider by inject()
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap()) private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
private val _isDiscovering = MutableStateFlow(false) private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null) private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
private val _error = MutableStateFlow<DevicesError?>(null) private val _error = MutableStateFlow<DevicesError?>(null)
val error: StateFlow<DevicesError?> = _error.asStateFlow() private val _isDisconnecting = MutableStateFlow(false)
val uiState: StateFlow<DevicesUiState> = combine(
_devices,
_isDiscovering,
_connectingToDevice,
remoteControlClient.connectionState,
combine(_error, _isDisconnecting, ::Pair)
) { devices, isDiscovering, connectingTo, connection, (error, isDisconnecting) ->
val connected = connection as? ConnectionState.Connected
DevicesUiState(
devices = devices.values.map { device ->
DeviceItemUi(
key = device.key,
name = device.name.ifBlank { null },
address = device.address(),
isConnecting = connectingTo?.key == device.key,
isConnected = connected != null && device.matches(connected))
},
isDiscovering = isDiscovering,
isConnecting = connectingTo != null,
connectedDevice = connected?.let {
ConnectedDeviceUi(
name = it.deviceName.ifBlank { null },
address = "${it.host}:${it.port}",
isDisconnecting = isDisconnecting)
},
error = error)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
private var discoveryJob: Job? = null private var discoveryJob: Job? = null
init { init {
// Observe connection state from the client
viewModelScope.launch { viewModelScope.launch {
var previous: ConnectionState = remoteControlClient.connectionState.value
remoteControlClient.connectionState.collect { state -> remoteControlClient.connectionState.collect { state ->
_connectionState.value = state val wasConnecting = previous is ConnectionState.Connecting
if (state is ConnectionState.Error) { previous = state
_error.value = DevicesError(message = state.fallback, rawMessage = state.message) when (state) {
_connectingToDevice.value = null is ConnectionState.Error -> {
} else if (state is ConnectionState.Disconnected) { _error.value = DevicesError(message = state.fallback, rawMessage = state.message)
_connectingToDevice.value = null _connectingToDevice.value = null
} else if (state is ConnectionState.Connected) { }
// Navigate to remote control screen after successful connection // Only a drop *during* an attempt means it failed. Switching devices
_connectingToDevice.value = null // disconnects the old one first, and that must not cancel the new attempt.
navigationCommands.navigateTo(Routes.RemoteControl) is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null
is ConnectionState.Connected -> {
// Only navigate for a connection this screen started. A connection
// that already existed when the screen opened must not bounce the
// user straight to the remote screen.
if (_connectingToDevice.value != null) {
_connectingToDevice.value = null
navigationCommands.navigateTo(Routes.RemoteControl)
}
}
is ConnectionState.Connecting -> {}
} }
} }
} }
@ -119,7 +174,8 @@ class DevicesViewModel(
logger.d { "Discovery event: $event" } logger.d { "Discovery event: $event" }
when (event) { when (event) {
is DiscoveryState.Discovered -> { is DiscoveryState.Discovered -> {
event.resolve() // Don't hold up the rest of the events while this device resolves.
launch { event.resolve() }
if (!isSelf(event.device)) { if (!isSelf(event.device)) {
_devices.update { it + (event.device.key to event.device.copy()) } _devices.update { it + (event.device.key to event.device.copy()) }
} }
@ -137,6 +193,9 @@ class DevicesViewModel(
} }
} }
} }
} catch (e: CancellationException) {
// stopDiscovery() (e.g. leaving the screen) is not a failure.
throw e
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Discovery failed" } logger.e(e) { "Discovery failed" }
_error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString()))
@ -175,7 +234,25 @@ class DevicesViewModel(
_isDiscovering.value = false _isDiscovering.value = false
} }
fun connectToDevice(device: DiscoveredDevice) { fun onDeviceClick(key: String) {
val device = _devices.value[key] ?: return
val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected
when {
// Already controlling it (e.g. came back from the remote screen): just reopen it.
connected != null && device.matches(connected) -> openRemote()
connected != null -> viewModelScope.launch {
remoteControlClient.disconnect()
connectToDevice(device)
}
else -> connectToDevice(device)
}
}
fun openRemote() {
navigationCommands.navigateTo(Routes.RemoteControl)
}
private fun connectToDevice(device: DiscoveredDevice) {
if (_connectingToDevice.value != null) { if (_connectingToDevice.value != null) {
logger.w { "Already connecting to a device" } logger.w { "Already connecting to a device" }
return return
@ -200,11 +277,11 @@ class DevicesViewModel(
host = device.host, host = device.host,
port = device.port, port = device.port,
deviceId = deviceId, deviceId = deviceId,
deviceName = deviceName) deviceName = deviceName,
remoteDeviceName = device.name)
// Clear connecting state after connection attempt // connect() returns once the socket is open, but the host may still be
// The connectionState flow will show the actual connection status // asking its user for permission. The connection-state collector clears
_connectingToDevice.value = null // the connecting flag once the outcome is known.
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Failed to connect to device" } logger.e(e) { "Failed to connect to device" }
_error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString()))
@ -214,8 +291,14 @@ class DevicesViewModel(
} }
fun disconnect() { fun disconnect() {
if (_isDisconnecting.value) return
_isDisconnecting.value = true
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.disconnect() try {
remoteControlClient.disconnect()
} finally {
_isDisconnecting.value = false
}
} }
} }
@ -223,3 +306,9 @@ class DevicesViewModel(
_error.value = null _error.value = null
} }
} }
private fun DiscoveredDevice.address(): String? =
if (host.isNotBlank() && port > 0) "$host:$port" else null
private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean =
host == connected.host && port == connected.port

View File

@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlClient
import org.spotube.spotube.core.remote.RemoteControlCommand import org.spotube.spotube.core.remote.RemoteControlCommand
import org.spotube.spotube.core.remote.RemoteControlEvent import org.spotube.spotube.core.remote.RemoteControlEvent
import org.spotube.spotube.core.remote.RemoteQueueEntry import org.spotube.spotube.modules.shell.formatPlayerTime
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.time.Duration.Companion.milliseconds
data class RemotePlayerState( enum class RemoteLoopMode(val wireValue: String) {
NONE("none"),
ONE("one"),
ALL("all");
fun next(): RemoteLoopMode = when (this) {
NONE -> ONE
ONE -> ALL
ALL -> NONE
}
companion object {
fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE
}
}
enum class RemoteVolumeLevel { MUTED, LOW, HIGH }
data class RemoteQueueItemUi(
val key: String,
val index: Int,
val mediaUrl: String,
val title: String,
val artists: String,
val coverUrl: String?,
val durationLabel: String,
val isCurrent: Boolean)
data class RemoteControlUiState(
val connection: ConnectionState = ConnectionState.Disconnected,
val deviceName: String = "",
val hasTrack: Boolean = false,
val title: String? = null,
val artists: String? = null,
val album: String? = null,
val coverUrl: String? = null,
val isPlaying: Boolean = false, val isPlaying: Boolean = false,
val positionMs: Long = 0, val isShuffling: Boolean = false,
val durationMs: Long = 0, val loopMode: RemoteLoopMode = RemoteLoopMode.NONE,
val volume: Float = 1.0f, val canSeek: Boolean = false,
val shuffleEnabled: Boolean = false, val seekProgress: Float = 0f,
val loopMode: String = "none", val positionLabel: String = formatPlayerTime(0.milliseconds),
val currentTrackId: String? = null, val durationLabel: String = formatPlayerTime(0.milliseconds),
val currentTrackTitle: String? = null, val volume: Float = 1f,
val currentTrackArtists: String? = null, val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH,
val currentTrackAlbum: String? = null, val queue: List<RemoteQueueItemUi> = emptyList(),
val currentTrackCoverUrl: String? = null) val currentQueueIndex: Int = -1,
val isQueueVisible: Boolean = false)
data class RemoteQueueState( private data class LocalOverrides(
val entries: List<RemoteQueueEntry> = emptyList(), val seekProgress: Float?,
val currentIndex: Int = -1) val volume: Float?,
val isQueueVisible: Boolean)
class RemoteControlViewModel : ViewModel(), KoinComponent { class RemoteControlViewModel : ViewModel(), KoinComponent {
private val logger = Logger.withTag("RemoteControlViewModel")
private val remoteControlClient: RemoteControlClient by inject() private val remoteControlClient: RemoteControlClient by inject()
private val _playerState = MutableStateFlow(RemotePlayerState()) // While the user drags a slider these hold the local value, so the host's
val playerState: StateFlow<RemotePlayerState> = _playerState.asStateFlow() // periodic state pushes don't yank the thumb back mid-gesture.
private val seekOverride = MutableStateFlow<Float?>(null)
private val volumeOverride = MutableStateFlow<Float?>(null)
private val isQueueVisible = MutableStateFlow(false)
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected) private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible ->
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() LocalOverrides(seek, volume, queueVisible)
}
private val _queueState = MutableStateFlow(RemoteQueueState()) val uiState: StateFlow<RemoteControlUiState> = combine(
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow() remoteControlClient.connectionState,
remoteControlClient.latestPlayerState,
private val _isQueueVisible = MutableStateFlow(false) remoteControlClient.latestQueue,
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow() overrides
) { connection, player, queue, local ->
buildUiState(connection, player, queue, local)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState())
init { init {
// Stream volume changes while dragging, throttled so a fast drag doesn't
// flood the socket with one command per frame.
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.connectionState.collect { state -> volumeOverride.filterNotNull().collect { volume ->
_connectionState.value = state remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
} delay(VOLUME_THROTTLE_MS)
}
viewModelScope.launch {
remoteControlClient.latestPlayerState.collect { event ->
if (event != null) {
handlePlayerState(event)
}
}
}
viewModelScope.launch {
remoteControlClient.latestQueue.collect { event ->
if (event != null) {
handleQueueUpdated(event)
}
} }
} }
} }
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { private fun buildUiState(
_playerState.update { connection: ConnectionState,
it.copy( player: RemoteControlEvent.PlayerState?,
isPlaying = event.isPlaying, queue: RemoteControlEvent.QueueUpdated?,
positionMs = event.positionMs, local: LocalOverrides
durationMs = event.durationMs, ): RemoteControlUiState {
volume = event.volume, val durationMs = player?.durationMs ?: 0L
shuffleEnabled = event.shuffleEnabled, val canSeek = durationMs > 0L
loopMode = event.loopMode, val liveProgress = if (canSeek) {
currentTrackId = event.currentTrackId, ((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f)
currentTrackTitle = event.currentTrackTitle, } else {
currentTrackArtists = event.currentTrackArtists, 0f
currentTrackAlbum = event.currentTrackAlbum,
currentTrackCoverUrl = event.currentTrackCoverUrl)
} }
val seekProgress = local.seekProgress ?: liveProgress
val currentIndex = queue?.currentIndex ?: -1
val volume = local.volume ?: player?.volume ?: 1f
return RemoteControlUiState(
connection = connection,
deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(),
hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null,
title = player?.currentTrackTitle,
artists = player?.currentTrackArtists,
album = player?.currentTrackAlbum,
coverUrl = player?.currentTrackCoverUrl,
isPlaying = player?.isPlaying ?: false,
isShuffling = player?.shuffleEnabled ?: false,
loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue),
canSeek = canSeek,
seekProgress = seekProgress,
positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds),
durationLabel = formatPlayerTime(durationMs.milliseconds),
volume = volume,
volumeLevel = when {
volume <= 0f -> RemoteVolumeLevel.MUTED
volume < 0.5f -> RemoteVolumeLevel.LOW
else -> RemoteVolumeLevel.HIGH
},
queue = queue?.entries.orEmpty().mapIndexed { index, entry ->
RemoteQueueItemUi(
key = "${entry.mediaUrl}@$index",
index = index,
mediaUrl = entry.mediaUrl,
title = entry.title,
artists = entry.artists,
coverUrl = entry.coverUrl,
durationLabel = formatPlayerTime(entry.durationMs.milliseconds),
isCurrent = index == currentIndex)
},
currentQueueIndex = currentIndex,
isQueueVisible = local.isQueueVisible)
} }
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { private fun send(command: RemoteControlCommand) {
_queueState.value = RemoteQueueState(
entries = event.entries,
currentIndex = event.currentIndex)
}
fun togglePlayPause() {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) remoteControlClient.sendCommand(command)
} }
} }
fun skipNext() { /**
* Keeps [override] at [value] until the host reports back (or a timeout), so
* the slider doesn't flash the stale value in between. Skips the reset if the
* user already started a new drag.
*/
private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow<Float?>, value: Float) {
withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) {
remoteControlClient.latestPlayerState.drop(1).first()
}
override.compareAndSet(value, null)
}
fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause)
fun skipNext() = send(RemoteControlCommand.SkipNext)
fun skipPrevious() = send(RemoteControlCommand.SkipPrevious)
fun onSeekChange(progress: Float) {
seekOverride.value = progress.coerceIn(0f, 1f)
}
fun onSeekFinished() {
val progress = seekOverride.value ?: return
val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L
if (durationMs <= 0L) {
seekOverride.value = null
return
}
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong()))
releaseOverrideAfterEcho(seekOverride, progress)
} }
} }
fun skipPrevious() { fun onVolumeChange(volume: Float) {
viewModelScope.launch { volumeOverride.value = volume.coerceIn(0f, 1f)
remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious)
}
} }
fun seek(positionMs: Long) { fun onVolumeChangeFinished() {
viewModelScope.launch { val volume = volumeOverride.value ?: return
remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs))
}
}
fun setVolume(volume: Float) {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
releaseOverrideAfterEcho(volumeOverride, volume)
} }
} }
fun toggleShuffle() { fun toggleShuffle() {
viewModelScope.launch { val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false
val newState = !_playerState.value.shuffleEnabled send(RemoteControlCommand.SetShuffle(!shuffling))
remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState))
}
} }
fun cycleLoopMode() { fun cycleLoopMode() {
viewModelScope.launch { val current = RemoteLoopMode.fromWire(
val currentMode = _playerState.value.loopMode remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue)
val newMode = when (currentMode) { send(RemoteControlCommand.SetLoopMode(current.next().wireValue))
"none" -> "one"
"one" -> "all"
else -> "none"
}
remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode))
}
} }
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0) return
viewModelScope.launch { send(RemoteControlCommand.PlayIndex(index))
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
}
} }
fun removeQueueItem(mediaUrl: String) { fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl))
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
}
}
fun toggleQueueVisibility() { fun toggleQueueVisibility() {
_isQueueVisible.update { !it } isQueueVisible.update { !it }
}
fun dismissQueue() {
isQueueVisible.value = false
} }
fun disconnect() { fun disconnect() {
@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
remoteControlClient.disconnect() remoteControlClient.disconnect()
} }
} }
private companion object {
const val VOLUME_THROTTLE_MS = 100L
const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L
}
} }

View File

@ -21,7 +21,10 @@ import co.touchlab.kermit.Logger
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.paths.Paths import org.spotube.spotube.core.paths.Paths
import org.spotube.spotube.core.server.StreamInfo import org.spotube.spotube.core.server.StreamInfo
import org.spotube.spotube.core.server.StreamRequest
import org.spotube.spotube.core.server.StreamingUrlRepository import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.core.server.urlSummary
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.plugins.HttpTimeout import io.ktor.client.plugins.HttpTimeout
@ -30,7 +33,9 @@ import io.ktor.client.request.head
import io.ktor.client.request.headers import io.ktor.client.request.headers
import io.ktor.client.statement.bodyAsChannel import io.ktor.client.statement.bodyAsChannel
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.contentLength import io.ktor.http.contentLength
import io.ktor.http.isSuccess
import io.ktor.utils.io.readRemaining import io.ktor.utils.io.readRemaining
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -112,6 +117,11 @@ class DownloadManager(
private val cachedDownloadFolder = MutableStateFlow<String?>(null) private val cachedDownloadFolder = MutableStateFlow<String?>(null)
/** Where downloads are saved right now: the user's override, else the platform default. */
val downloadFolder: StateFlow<String> = cachedDownloadFolder
.map { it?.takeIf(String::isNotBlank) ?: paths.getUserDownloadsDirPath() }
.stateIn(scope, SharingStarted.Eagerly, paths.getUserDownloadsDirPath())
private val activeJobs = mutableMapOf<String, Job>() private val activeJobs = mutableMapOf<String, Job>()
init { init {
@ -135,6 +145,13 @@ class DownloadManager(
track = track) track = track)
downloadsMap.update { it + (id to item) } downloadsMap.update { it + (id to item) }
val settings = settingsRepository.userSettings.value
logger.i {
"[Download $id] enqueued '${track.title}' by $artistNames (track=${track.id}) " +
"downloadPrefs=${settings.downloadMusicFormat.codec}/${settings.downloadMusicFormat.container} " +
"${settings.downloadMusicQuality} streamingPrefs=${settings.streamingMusicFormat.codec}/" +
"${settings.streamingMusicFormat.container} ${settings.streamingMusicQuality} folder=${downloadFolder.value}"
}
scope.launch { processQueue() } scope.launch { processQueue() }
} }
@ -210,7 +227,15 @@ class DownloadManager(
} }
try { try {
val streamInfo = streamingUrlRepository.resolveStreamInfo(track) logger.i { "[Download ${item.id}] resolving stream for '${track.title}'" }
val settings = settingsRepository.userSettings.value
val streamInfo = streamingUrlRepository.resolveStreamInfo(
track,
request = StreamRequest(
format = settings.downloadMusicFormat,
quality = settings.downloadMusicQuality,
// A manifest URL would be saved as-is instead of the audio it points to.
progressiveOnly = true))
if (streamInfo == null) { if (streamInfo == null) {
logger.w { "Could not resolve stream URL for ${track.title}" } logger.w { "Could not resolve stream URL for ${track.title}" }
val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short) val unresolvedStatus = getString(Res.string.downloads_error_stream_unresolved_short)
@ -226,6 +251,7 @@ class DownloadManager(
} }
val url = streamInfo.url val url = streamInfo.url
logResolvedStream(item, streamInfo)
val filename = buildFilename(item, streamInfo) val filename = buildFilename(item, streamInfo)
@ -235,15 +261,28 @@ class DownloadManager(
val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath() val tempDir = FileSystem.SYSTEM_TEMPORARY_DIRECTORY / "spotube_downloads".toPath()
fileSystem.createDirectories(tempDir) fileSystem.createDirectories(tempDir)
val (totalSize, supportsRanges) = probeUrl(url) val (totalSize, supportsRanges) = probeUrl(item.id, url)
if (totalSize > 0 && supportsRanges) { if (totalSize > 0 && supportsRanges) {
logger.i { "[Download ${item.id}] multi-segment download ($SEGMENT_COUNT segments, $totalSize bytes) -> ${downloadDir / filename}" }
executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize) executeMultiSegmentDownload(url, filename, item, tempDir, downloadDir, totalSize)
} else { } else {
logger.i { "[Download ${item.id}] single-connection download (size=$totalSize ranges=$supportsRanges) -> ${downloadDir / filename}" }
executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir) executeSingleConnectionDownload(url, filename, item, tempDir, downloadDir)
} }
val finalPath = downloadDir / filename.toPath()
val writtenBytes = fileSystem.metadataOrNull(finalPath)?.size
logger.i { "[Download ${item.id}] wrote $finalPath ($writtenBytes bytes, expected $totalSize)" }
if (totalSize > 0 && writtenBytes != null && writtenBytes != totalSize) {
logger.w { "[Download ${item.id}] SIZE MISMATCH: wrote $writtenBytes bytes, server reported $totalSize" }
}
applyMetadata(item, filename, downloadDir) applyMetadata(item, filename, downloadDir)
// Without this Android music players and the Files app's Audio view don't see the
// file until the next full media scan.
addToMediaLibrary((downloadDir / filename.toPath()).toString())
logger.i { "[Download ${item.id}] completed '${item.title}'" }
downloadsMap.update { map -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
@ -251,6 +290,7 @@ class DownloadManager(
} ?: map } ?: map
} }
} catch (e: CancellationException) { } catch (e: CancellationException) {
logger.i { "[Download ${item.id}] cancelled" }
downloadsMap.update { map -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
map + (item.id to it.copy(status = DownloadStatus.Cancelled)) map + (item.id to it.copy(status = DownloadStatus.Cancelled))
@ -258,7 +298,7 @@ class DownloadManager(
} }
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Download failed: ${item.title}" } logger.e(e) { "[Download ${item.id}] FAILED '${item.title}': ${e.message}" }
val failureMessage = e.message ?: getString(Res.string.common_unknown_error) val failureMessage = e.message ?: getString(Res.string.common_unknown_error)
downloadsMap.update { map -> downloadsMap.update { map ->
map[item.id]?.let { map[item.id]?.let {
@ -322,6 +362,21 @@ class DownloadManager(
append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}") append(HttpHeaders.Range, "bytes=${segment.start}-${segment.end}")
} }
} }
logger.d {
"[Download ${item.id}] segment ${segment.index} bytes=${segment.start}-${segment.end} -> " +
"HTTP ${response.status.value} type=${response.headers[HttpHeaders.ContentType]} " +
"range=${response.headers[HttpHeaders.ContentRange]}"
}
// expectSuccess = false, so without these checks an error page, or the whole file once per
// segment (a 200 to a range request), would be written into the "audio" file.
if (!response.status.isSuccess()) {
logger.w { "[Download ${item.id}] segment ${segment.index} got HTTP ${response.status}; failing download" }
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
}
if (response.status != HttpStatusCode.PartialContent) {
logger.w { "[Download ${item.id}] segment ${segment.index} got ${response.status} instead of 206; range ignored" }
throw DownloadException(getString(Res.string.downloads_error_range_ignored))
}
val channel = response.bodyAsChannel() val channel = response.bodyAsChannel()
fileSystem.sink(outputPath).buffer().use { sink -> fileSystem.sink(outputPath).buffer().use { sink ->
@ -344,6 +399,15 @@ class DownloadManager(
val response = httpClient.get(url) val response = httpClient.get(url)
val totalSize = response.contentLength() ?: 0L val totalSize = response.contentLength() ?: 0L
logger.i {
"[Download ${item.id}] GET -> HTTP ${response.status.value} " +
"type=${response.headers[HttpHeaders.ContentType]} length=$totalSize"
}
if (!response.status.isSuccess()) {
logger.w { "[Download ${item.id}] GET got HTTP ${response.status}; failing download" }
throw DownloadException(getString(Res.string.downloads_error_http, response.status.value))
}
rejectVideo(item.id, response.headers[HttpHeaders.ContentType])
val channel = response.bodyAsChannel() val channel = response.bodyAsChannel()
fileSystem.sink(tempFile).buffer().use { sink -> fileSystem.sink(tempFile).buffer().use { sink ->
@ -375,22 +439,65 @@ class DownloadManager(
} }
} }
private suspend fun probeUrl(url: String): Pair<Long, Boolean> { private suspend fun probeUrl(id: String, url: String): Pair<Long, Boolean> {
return try { return try {
val response = httpClient.head(url) val response = httpClient.head(url)
val contentLength = response.contentLength() ?: 0L val contentLength = response.contentLength() ?: 0L
val acceptRanges = response.headers[HttpHeaders.AcceptRanges] val acceptRanges = response.headers[HttpHeaders.AcceptRanges]
val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true val supportsRanges = acceptRanges?.equals("bytes", ignoreCase = true) == true
val contentType = response.headers[HttpHeaders.ContentType]
logger.i {
"[Download $id] HEAD -> HTTP ${response.status.value} type=$contentType " +
"length=$contentLength acceptRanges=$acceptRanges"
}
rejectVideo(id, contentType)
Pair(contentLength, supportsRanges) Pair(contentLength, supportsRanges)
} catch (e: DownloadException) {
throw e
} catch (e: Exception) { } catch (e: Exception) {
logger.w(e) { "HEAD request failed, falling back to single connection" } logger.w(e) { "[Download $id] HEAD request failed, falling back to single connection" }
Pair(0L, false) Pair(0L, false)
} }
} }
/**
* Some sources only offer muxed video (e.g. NewPipe falls back to video streams when a video
* has no audio-only ones). Saving that as a "song" is worse than failing with a clear reason.
*/
private suspend fun rejectVideo(id: String, contentType: String?) {
if (contentType?.startsWith("video/", ignoreCase = true) == true) {
logger.w { "[Download $id] server says this is VIDEO ($contentType), not audio; failing download" }
throw DownloadException(getString(Res.string.downloads_error_video_only))
}
}
private fun logResolvedStream(item: DownloadItem, info: StreamInfo) {
val settings = settingsRepository.userSettings.value
val wanted = settings.downloadMusicFormat
logger.i {
"[Download ${item.id}] resolved stream codec=${info.codec} container=${info.container} " +
"protocol=${info.protocol} ${urlSummary(info.url)}"
}
if (info.protocol != StreamProtocol.PROGRESSIVE) {
logger.w {
"[Download ${item.id}] stream is a ${info.protocol} MANIFEST; a plain GET saves the manifest, not audio"
}
}
if (!info.codec.equals(wanted.codec, ignoreCase = true) || !info.container.equals(wanted.container, ignoreCase = true)) {
logger.w {
"[Download ${item.id}] FORMAT FALLBACK: download setting is ${wanted.codec}/${wanted.container} " +
"${settings.downloadMusicQuality}, but the closest downloadable stream offered is ${info.codec}/${info.container}"
}
}
}
private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) { private suspend fun applyMetadata(item: DownloadItem, filename: String, downloadDir: Path) {
val filePath = downloadDir / filename.toPath() val filePath = downloadDir / filename.toPath()
if (!fileSystem.exists(filePath)) return if (!fileSystem.exists(filePath)) {
logger.w { "[Download ${item.id}] skipping metadata: $filePath does not exist" }
return
}
logger.d { "[Download ${item.id}] writing metadata to $filePath" }
val track = item.track ?: return val track = item.track ?: return
@ -421,18 +528,11 @@ class DownloadManager(
discNumber = track.discNumber, discNumber = track.discNumber,
coverBytes = coverBytes) coverBytes = coverBytes)
} catch (e: Exception) { } catch (e: Exception) {
logger.w(e) { "Failed to write audio metadata for ${item.title}" } logger.w(e) { "[Download ${item.id}] failed to write audio metadata for ${item.title}" }
} }
} }
private fun resolveDownloadDir(): Path { private fun resolveDownloadDir(): Path = downloadFolder.value.toPath()
val folder = cachedDownloadFolder.value
return if (!folder.isNullOrBlank()) {
folder.toPath()
} else {
paths.getUserDownloadsDirPath().toPath()
}
}
private fun cleanupTempFiles(id: String) { private fun cleanupTempFiles(id: String) {
try { try {
@ -452,13 +552,21 @@ class DownloadManager(
private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String { private fun buildFilename(downloadItem: DownloadItem, streamInfo: StreamInfo): String {
val safeTitle = downloadItem.title.sanitizeFilename() val safeTitle = downloadItem.title.sanitizeFilename()
val safeArtist = downloadItem.artists.sanitizeFilename() val safeArtist = downloadItem.artists.sanitizeFilename()
val extension = streamInfo.container val extension = fileExtension(streamInfo)
return "$safeArtist - $safeTitle.$extension" return "$safeArtist - $safeTitle.$extension"
} }
/** AAC in an MP4 container is audio-only, so give it the audio extension players expect. */
private fun fileExtension(info: StreamInfo): String =
if (info.codec.equals("aac", ignoreCase = true) && info.container.equals("mp4", ignoreCase = true)) "m4a"
else info.container
private fun String.sanitizeFilename(): String { private fun String.sanitizeFilename(): String {
return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200) return this.replace(Regex("[^a-zA-Z0-9.\\-_' ]"), "_").take(200)
} }
private data class Segment(val index: Int, val start: Long, val end: Long) private data class Segment(val index: Int, val start: Long, val end: Long)
} }
/** A download failure whose [message] is already localized and safe to show the user. */
private class DownloadException(message: String) : Exception(message)

View File

@ -100,11 +100,18 @@ internal fun DownloadsToolbarTitle() {
@Composable @Composable
internal fun DownloadsToolbarSummary(state: DownloadsUiState) { internal fun DownloadsToolbarSummary(state: DownloadsUiState) {
if (state.items.isNotEmpty()) Text( Column(Modifier.padding(start = 28.dp)) {
stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount), if (state.items.isNotEmpty()) Text(
modifier = Modifier.padding(start = 28.dp), stringResource(Res.string.downloads_summary, state.activeCount, state.completedCount),
style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant
) )
// Always visible (empty list included), so users know where their files end up.
if (state.folder.isNotBlank()) Text(
stringResource(Res.string.downloads_saved_to, state.folder),
style = AppTheme.typography.bodySmall, color = MillenniumTheme.colors.muted,
maxLines = 2, overflow = TextOverflow.Ellipsis
)
}
} }
@Composable @Composable

View File

@ -23,7 +23,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.combine
enum class DownloadRowAction { Cancel, Retry, Remove } enum class DownloadRowAction { Cancel, Retry, Remove }
@ -43,10 +43,12 @@ data class DownloadsUiState(
val items: List<DownloadRowUiState> = emptyList(), val items: List<DownloadRowUiState> = emptyList(),
val activeCount: Int = 0, val activeCount: Int = 0,
val completedCount: Int = 0, val completedCount: Int = 0,
val canClearCompleted: Boolean = false val canClearCompleted: Boolean = false,
/** Folder new downloads are saved to, shown so users can find their files. */
val folder: String = ""
) )
internal fun downloadsUiState(items: List<DownloadItem>): DownloadsUiState { internal fun downloadsUiState(items: List<DownloadItem>, folder: String = ""): DownloadsUiState {
val completedCount = items.count { it.status == DownloadStatus.Completed } val completedCount = items.count { it.status == DownloadStatus.Completed }
return DownloadsUiState( return DownloadsUiState(
items = items.map { item -> items = items.map { item ->
@ -66,7 +68,8 @@ internal fun downloadsUiState(items: List<DownloadItem>): DownloadsUiState {
}, },
activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued }, activeCount = items.count { it.status == DownloadStatus.Downloading || it.status == DownloadStatus.Queued },
completedCount = completedCount, completedCount = completedCount,
canClearCompleted = completedCount > 0 canClearCompleted = completedCount > 0,
folder = folder
) )
} }
@ -79,7 +82,7 @@ class DownloadsViewModel(
emptyList() emptyList()
) )
val uiState = downloadManager.downloadsFlow.map(::downloadsUiState) val uiState = combine(downloadManager.downloadsFlow, downloadManager.downloadFolder, ::downloadsUiState)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState()) .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DownloadsUiState())
fun downloadTrack(track: MetadataTrack) { fun downloadTrack(track: MetadataTrack) {

View File

@ -0,0 +1,21 @@
/*
* 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.downloads
/** Tells the OS media index about a finished download so other apps can see it right away. */
internal expect fun addToMediaLibrary(path: String)

View File

@ -75,13 +75,14 @@ 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.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.modules.shell.ConnectStatusBanner
import org.spotube.spotube.resources.iconsax.IconsaxSoundTwotone
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.modules.support.LocalSupportPrompt import org.spotube.spotube.modules.support.LocalSupportPrompt
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxHeart2 import org.spotube.spotube.resources.iconsax.IconsaxHeart2
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.koinInject import org.koin.compose.koinInject
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -93,6 +94,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.jetbrains.compose.resources.Font import org.jetbrains.compose.resources.Font
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import org.spotube.spotube.resources.iconsax.IconsaxMirrorScreenRegular
import spotube.composeapp.generated.resources.cookie_regular import spotube.composeapp.generated.resources.cookie_regular
@Composable @Composable
@ -161,7 +163,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxMirroringScreen, imageVector = Iconsax.IconsaxMirrorScreenRegular,
contentDescription = stringResource(Res.string.devices_title) contentDescription = stringResource(Res.string.devices_title)
) )
} }
@ -170,7 +172,7 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) { ) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.User, imageVector = Iconsax.IconsaxSoundTwotone,
contentDescription = stringResource(Res.string.jam_screen_title) contentDescription = stringResource(Res.string.jam_screen_title)
) )
} }
@ -186,6 +188,12 @@ fun HomeScreen(viewModel: HomeScreenViewModel) {
} }
} }
}) })
if (isSmallScreen) {
ConnectStatusBanner(
onOpenRemote = { navigationCommands.navigateTo(Routes.RemoteControl) },
onOpenJam = { navigationCommands.navigateTo(Routes.Jam) }
)
}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()

View File

@ -203,12 +203,12 @@ internal fun SetupPanel(
MillenniumButton( MillenniumButton(
onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin, onClick = if (state.setupMode == JamSetupMode.Create) onCreate else onJoin,
enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin, enabled = if (state.setupMode == JamSetupMode.Create) state.canCreate else state.canJoin,
loading = state.isBusy,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text( Text(
stringResource( stringResource(
when { when {
state.isBusy -> Res.string.jam_setup_working
state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button state.setupMode == JamSetupMode.Create -> Res.string.jam_setup_create_button
else -> Res.string.jam_setup_join_button else -> Res.string.jam_setup_join_button
} }
@ -451,6 +451,7 @@ internal fun SessionPanel(
} }
MillenniumButton( MillenniumButton(
onClick = onLeave, onClick = onLeave,
loading = state.isLeaving,
style = MillenniumButtonStyle.Secondary, style = MillenniumButtonStyle.Secondary,
modifier = Modifier.align(Alignment.End) modifier = Modifier.align(Alignment.End)
) { ) {

View File

@ -65,6 +65,7 @@ data class JamUiState(
val canCreate: Boolean = false, val canCreate: Boolean = false,
val canJoin: Boolean = false, val canJoin: Boolean = false,
val isBusy: Boolean = false, val isBusy: Boolean = false,
val isLeaving: Boolean = false,
val isHost: Boolean = false, val isHost: Boolean = false,
val supportsNativeShare: Boolean = false, val supportsNativeShare: Boolean = false,
val codeCopied: Boolean = false val codeCopied: Boolean = false
@ -79,6 +80,7 @@ class JamViewModel(
private val _localError = MutableStateFlow<String?>(null) private val _localError = MutableStateFlow<String?>(null)
private val setupMode = MutableStateFlow(JamSetupMode.Create) private val setupMode = MutableStateFlow(JamSetupMode.Create)
private val isBusy = MutableStateFlow(false) private val isBusy = MutableStateFlow(false)
private val isLeaving = MutableStateFlow(false)
private val codeCopied = MutableStateFlow(false) private val codeCopied = MutableStateFlow(false)
private val codeInput = TextFieldState() private val codeInput = TextFieldState()
private var copyFeedbackJob: Job? = null private var copyFeedbackJob: Job? = null
@ -99,7 +101,8 @@ class JamViewModel(
isBusy, isBusy,
codeCopied, codeCopied,
snapshotFlow { codeInput.text.toString() }, snapshotFlow { codeInput.text.toString() },
participantMenu participantMenu,
isLeaving
) { values -> ) { values ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole? val role = values[0] as JamRole?
@ -134,10 +137,11 @@ class JamViewModel(
error = localError ?: connectionError, error = localError ?: connectionError,
setupMode = values[7] as JamSetupMode, setupMode = values[7] as JamSetupMode,
isBusy = busy, isBusy = busy,
isLeaving = values[12] as Boolean,
codeCopied = values[9] as Boolean, codeCopied = values[9] as Boolean,
codeInput = codeInput, codeInput = codeInput,
canCreate = brokerConfigured && !busy, canCreate = brokerConfigured,
canJoin = brokerConfigured && !busy && canJoin = brokerConfigured &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host, isHost = role == JamRole.Host,
supportsNativeShare = supportsNativeShare supportsNativeShare = supportsNativeShare
@ -216,9 +220,15 @@ class JamViewModel(
} }
fun leave() { fun leave() {
if (isLeaving.value) return
isLeaving.value = true
viewModelScope.launch { viewModelScope.launch {
jamRoomService.leaveRoom() try {
_localError.value = null jamRoomService.leaveRoom()
_localError.value = null
} finally {
isLeaving.value = false
}
} }
} }

View File

@ -120,7 +120,6 @@ fun LibraryScreen(libraryState: LibraryState = koinInject()) {
LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) { LibraryTab.Playlists -> MetadataPluginGuard(showTopBar = false, requireLogin = true) {
LibraryPlaylistsScreen( LibraryPlaylistsScreen(
showCreatePlaylist = state.showCreatePlaylist, showCreatePlaylist = state.showCreatePlaylist,
onDismissCreatePlaylist = libraryState::dismissCreatePlaylist,
viewMode = viewMode viewMode = viewMode
) )
} }
@ -228,6 +227,7 @@ internal fun RowScope.LibraryToolbarActions(
icon = Iconsax.IconsaxRefreshRight, icon = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.library_action_refresh_local_tracks), contentDescription = stringResource(Res.string.library_action_refresh_local_tracks),
onClick = onRefresh, onClick = onRefresh,
loading = state.isRefreshing,
modifier = actionModifier, modifier = actionModifier,
style = MillenniumIconButtonStyle.Quiet style = MillenniumIconButtonStyle.Quiet
) )

View File

@ -42,6 +42,7 @@ data class LibraryToolbarUiState(
val canFilter: Boolean = true, val canFilter: Boolean = true,
val canCreatePlaylist: Boolean = true, val canCreatePlaylist: Boolean = true,
val canRefresh: Boolean = false, val canRefresh: Boolean = false,
val isRefreshing: Boolean = false,
val showCreatePlaylist: Boolean = false, val showCreatePlaylist: Boolean = false,
val canChangeView: Boolean = true, val canChangeView: Boolean = true,
val viewModeOverride: LibraryViewMode? = null val viewModeOverride: LibraryViewMode? = null
@ -57,13 +58,15 @@ class LibraryState : ViewModel() {
private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap()) private val viewModes = MutableStateFlow<Map<LibraryTab, LibraryViewMode>>(emptyMap())
private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1) private val refreshRequests = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
val localRefreshRequests = refreshRequests.asSharedFlow() val localRefreshRequests = refreshRequests.asSharedFlow()
private val localRefreshing = MutableStateFlow(false)
val uiState = combine( val uiState = combine(
currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } }, currentTab.flatMapLatest { tab -> queryFlow(tab).map { query -> tab to query } },
searchMode, searchMode,
createPlaylistVisible, createPlaylistVisible,
viewModes viewModes,
) { (tab, query), searching, creating, modes -> localRefreshing
) { (tab, query), searching, creating, modes, refreshing ->
LibraryToolbarUiState( LibraryToolbarUiState(
currentTab = tab, currentTab = tab,
searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()), searchMode = tab != LibraryTab.Downloads && (searching || query.isNotEmpty()),
@ -71,6 +74,7 @@ class LibraryState : ViewModel() {
canFilter = tab != LibraryTab.Downloads, canFilter = tab != LibraryTab.Downloads,
canCreatePlaylist = tab == LibraryTab.Playlists, canCreatePlaylist = tab == LibraryTab.Playlists,
canRefresh = tab == LibraryTab.LocalTracks, canRefresh = tab == LibraryTab.LocalTracks,
isRefreshing = tab == LibraryTab.LocalTracks && refreshing,
showCreatePlaylist = tab == LibraryTab.Playlists && creating, showCreatePlaylist = tab == LibraryTab.Playlists && creating,
canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists, canChangeView = tab == LibraryTab.Playlists || tab == LibraryTab.Albums || tab == LibraryTab.Artists,
viewModeOverride = modes[tab] viewModeOverride = modes[tab]
@ -113,6 +117,11 @@ class LibraryState : ViewModel() {
refreshRequests.tryEmit(Unit) refreshRequests.tryEmit(Unit)
} }
/** Reported by the local tracks ViewModel so the toolbar's refresh action can show progress. */
fun setLocalRefreshing(refreshing: Boolean) {
localRefreshing.value = refreshing
}
fun toggleViewMode(isCompact: Boolean) { fun toggleViewMode(isCompact: Boolean) {
val tab = currentTab.value val tab = currentTab.value
if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return if (tab != LibraryTab.Playlists && tab != LibraryTab.Albums && tab != LibraryTab.Artists) return

View File

@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -160,19 +161,25 @@ class LibraryLocalTracksViewModel(
} }
fun refresh() { fun refresh() {
if (!_state.value.canAccessMedia) return if (!_state.value.canAccessMedia || _state.value.isRefreshing) return
_state.update { it.copy(isRefreshing = true, error = null) }
libraryState.setLocalRefreshing(true)
viewModelScope.launch { viewModelScope.launch {
_state.update { it.copy(isRefreshing = true, error = null) } try {
runCatching { runCatching {
coordinator.refreshNow("manual") coordinator.refreshNow("manual")
}.onFailure { throwable -> }.onFailure { throwable ->
val message = if (throwable is CancellationException) throw throwable
throwable.message ?: getString(Res.string.library_local_refresh_failed) val message =
_state.update { throwable.message ?: getString(Res.string.library_local_refresh_failed)
it.copy(error = message) _state.update {
it.copy(error = message)
}
} }
} finally {
_state.update { it.copy(isRefreshing = false) }
libraryState.setLocalRefreshing(false)
} }
_state.update { it.copy(isRefreshing = false) }
} }
} }

View File

@ -28,11 +28,11 @@ import spotube.composeapp.generated.resources.*
fun LibraryPlaylistsScreen( fun LibraryPlaylistsScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
showCreatePlaylist: Boolean = false, showCreatePlaylist: Boolean = false,
onDismissCreatePlaylist: () -> Unit = {},
viewMode: LibraryViewMode = LibraryViewMode.Grid, viewMode: LibraryViewMode = LibraryViewMode.Grid,
viewModel: LibraryPlaylistsViewModel = koinViewModel() viewModel: LibraryPlaylistsViewModel = koinViewModel()
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playlistFormState by viewModel.playlistFormState.collectAsStateWithLifecycle()
Column(modifier) { Column(modifier) {
when (val current = state) { when (val current = state) {
is LibraryPlaylistsState.Error -> Box( is LibraryPlaylistsState.Error -> Box(
@ -87,8 +87,10 @@ fun LibraryPlaylistsScreen(
} }
PlaylistFormSheet( PlaylistFormSheet(
visible = showCreatePlaylist, visible = showCreatePlaylist,
onDismiss = onDismissCreatePlaylist, formState = playlistFormState,
onSubmit = viewModel::createPlaylist onDismiss = viewModel::dismissCreatePlaylist,
onSubmit = viewModel::createPlaylist,
onImagePicked = viewModel::onPlaylistFormImagePicked
) )
} }
} }

View File

@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.github.vinceglb.filekit.PlatformFile
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
@ -159,10 +160,31 @@ class LibraryPlaylistsViewModel(
} }
} }
fun createPlaylist(data: PlaylistFormData) = createPlaylist( private val playlistForm = PlaylistFormController(viewModelScope, logger)
data.name, data.description.ifBlank { null }, data.isPublic, val playlistFormState: StateFlow<PlaylistFormUiState> = playlistForm.uiState
data.isCollaborating, data.imageBase64, emptyList()
) fun onPlaylistFormImagePicked(file: PlatformFile) = playlistForm.onImagePicked(file)
fun dismissCreatePlaylist() {
if (playlistFormState.value.isSubmitting) return
playlistForm.dismiss()
libraryState.dismissCreatePlaylist()
}
fun createPlaylist(data: PlaylistFormData) {
playlistForm.submit(onSuccess = libraryState::dismissCreatePlaylist) {
repository.createPlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64,
trackIds = emptyList()
)
repository.invalidateCaches()
loadInitialData()
}
}
fun refresh() { fun refresh() {
viewModelScope.launch { viewModelScope.launch {
@ -170,31 +192,4 @@ class LibraryPlaylistsViewModel(
loadInitialData() loadInitialData()
} }
} }
fun createPlaylist(
name: String,
description: String?,
isPublic: Boolean,
isCollaborating: Boolean,
imageBase64: String,
trackIds: List<String>
) {
viewModelScope.launch {
runCatching {
repository.createPlaylist(
name = name,
description = description,
isPublic = isPublic,
isCollaborating = isCollaborating,
imageBase64 = imageBase64,
trackIds = trackIds
)
}.onFailure { e ->
logger.e(e) { "Failed to create playlist" }
}.onSuccess {
repository.invalidateCaches()
loadInitialData()
}
}
}
} }

View File

@ -0,0 +1,96 @@
/*
* 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.library.playlist
import co.touchlab.kermit.Logger
import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
data class PlaylistFormUiState(
val isVisible: Boolean = false,
val isSubmitting: Boolean = false,
val isProcessingImage: Boolean = false,
/** Base64 of an image picked in this session; `null` keeps the playlist's current cover. */
val pickedImageBase64: String? = null
)
/**
* Async state of [PlaylistFormSheet] (visibility, image encoding, submission), owned by the
* ViewModel that opens the sheet and driven on its [scope].
*/
class PlaylistFormController(
private val scope: CoroutineScope,
private val logger: Logger
) {
private val _uiState = MutableStateFlow(PlaylistFormUiState())
val uiState: StateFlow<PlaylistFormUiState> = _uiState.asStateFlow()
fun show() {
_uiState.value = PlaylistFormUiState(isVisible = true)
}
fun dismiss() {
if (_uiState.value.isSubmitting) return
_uiState.value = PlaylistFormUiState()
}
fun onImagePicked(file: PlatformFile) {
if (_uiState.value.isProcessingImage) return
_uiState.update { it.copy(isProcessingImage = true) }
scope.launch {
runCatching {
withContext(Dispatchers.Default) { file.readBytes().toByteString().base64() }
}.onSuccess { base64 ->
_uiState.update { it.copy(pickedImageBase64 = base64) }
}.onFailure { e ->
logger.e(e) { "Failed to read playlist cover image" }
}
_uiState.update { it.copy(isProcessingImage = false) }
}
}
/**
* Runs [action]; the sheet closes (and [onSuccess] runs) only if it succeeds, and stays open
* (re-enabled) on failure.
*/
fun submit(onSuccess: () -> Unit = {}, action: suspend () -> Unit) {
val current = _uiState.value
if (current.isSubmitting || current.isProcessingImage) return
_uiState.update { it.copy(isSubmitting = true) }
scope.launch {
runCatching { action() }
.onSuccess {
_uiState.value = PlaylistFormUiState()
onSuccess()
}
.onFailure { e ->
logger.e(e) { "Failed to submit playlist form" }
_uiState.update { it.copy(isSubmitting = false) }
}
}
}
}

View File

@ -34,9 +34,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.SheetDetent import com.composeunstyled.SheetDetent
@ -51,7 +49,6 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -62,12 +59,8 @@ import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import io.github.vinceglb.filekit.dialogs.FileKitType import io.github.vinceglb.filekit.dialogs.FileKitType
import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher import io.github.vinceglb.filekit.dialogs.compose.rememberFilePickerLauncher
import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.PlatformFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okio.ByteString.Companion.toByteString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_playlist_form_change_image import spotube.composeapp.generated.resources.library_playlist_form_change_image
@ -101,8 +94,10 @@ data class PlaylistFormData(
@Composable @Composable
fun PlaylistFormSheet( fun PlaylistFormSheet(
visible: Boolean, visible: Boolean,
formState: PlaylistFormUiState,
onDismiss: () -> Unit, onDismiss: () -> Unit,
onSubmit: (PlaylistFormData) -> Unit, onSubmit: (PlaylistFormData) -> Unit,
onImagePicked: (PlatformFile) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
initialData: PlaylistFormData? = null, initialData: PlaylistFormData? = null,
isEditing: Boolean = false isEditing: Boolean = false
@ -119,8 +114,8 @@ fun PlaylistFormSheet(
initialData?.isCollaborating ?: false initialData?.isCollaborating ?: false
) )
} }
var imageBase64 by remember(initialData) { mutableStateOf(initialData?.imageBase64 ?: "") } val imageBase64 = formState.pickedImageBase64 ?: initialData?.imageBase64.orEmpty()
var imagePreviewUrl by remember(initialData) { mutableStateOf(initialData?.imagePreviewUrl) } val imagePreviewUrl = if (formState.pickedImageBase64 != null) null else initialData?.imagePreviewUrl
val nameState = rememberTextFieldState(initialData?.name ?: "") val nameState = rememberTextFieldState(initialData?.name ?: "")
val descriptionState = rememberTextFieldState(initialData?.description ?: "") val descriptionState = rememberTextFieldState(initialData?.description ?: "")
@ -136,20 +131,10 @@ fun PlaylistFormSheet(
.collect { description = it } .collect { description = it }
} }
val scope = rememberCoroutineScope()
val imagePicker = rememberFilePickerLauncher( val imagePicker = rememberFilePickerLauncher(
type = FileKitType.Image type = FileKitType.Image
) { file -> ) { file ->
if (file != null) { if (file != null) onImagePicked(file)
scope.launch {
val bytes = file.readBytes()
val base64 = withContext(Dispatchers.Default) {
bytes.toByteString().base64()
}
imageBase64 = base64
imagePreviewUrl = null
}
}
} }
val currentData = PlaylistFormData( val currentData = PlaylistFormData(
@ -176,15 +161,14 @@ fun PlaylistFormSheet(
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) { horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)) {
MillenniumButton( MillenniumButton(
onClick = onDismiss, onClick = onDismiss,
enabled = !formState.isSubmitting,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.common_cancel)) Text(stringResource(Res.string.common_cancel))
} }
MillenniumButton( MillenniumButton(
onClick = { onClick = { onSubmit(currentData) },
onSubmit(currentData) enabled = name.isNotBlank() && !formState.isProcessingImage,
onDismiss() loading = formState.isSubmitting) {
},
enabled = name.isNotBlank()) {
Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create)) Text(stringResource(if (isEditing) Res.string.common_save else Res.string.library_playlist_form_create))
} }
} }
@ -198,6 +182,8 @@ fun PlaylistFormSheet(
onIsCollaboratingChange = { isCollaborating = it }, onIsCollaboratingChange = { isCollaborating = it },
imageBase64 = imageBase64, imageBase64 = imageBase64,
imagePreviewUrl = imagePreviewUrl, imagePreviewUrl = imagePreviewUrl,
isProcessingImage = formState.isProcessingImage,
isImagePickEnabled = !formState.isSubmitting,
onPickImage = handleImagePick) onPickImage = handleImagePick)
} }
} }
@ -212,12 +198,14 @@ private fun PlaylistFormFields(
onIsCollaboratingChange: (Boolean) -> Unit, onIsCollaboratingChange: (Boolean) -> Unit,
imageBase64: String, imageBase64: String,
imagePreviewUrl: String?, imagePreviewUrl: String?,
isProcessingImage: Boolean,
isImagePickEnabled: Boolean,
onPickImage: () -> Unit onPickImage: () -> Unit
) { ) {
// AdaptiveDialogBottomSheet already scrolls its content; a second scroll here nests
// scrollables and crashes with infinite height constraints.
Column( Column(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
MillenniumTextField( MillenniumTextField(
@ -312,7 +300,10 @@ private fun PlaylistFormFields(
} }
} }
} }
MillenniumButton(onClick = onPickImage) { MillenniumButton(
onClick = onPickImage,
enabled = isImagePickEnabled,
loading = isProcessingImage) {
Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image)) Text(stringResource(if (imageBase64.isNotBlank() || imagePreviewUrl != null) Res.string.library_playlist_form_change_image else Res.string.library_playlist_form_pick_image))
} }
} }

View File

@ -20,7 +20,6 @@ package org.spotube.spotube.modules.playlist
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
import org.spotube.millennium.composables.MillenniumCircularProgress import org.spotube.millennium.composables.MillenniumCircularProgress
import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle import org.spotube.millennium.composables.MillenniumButtonStyle
@ -48,15 +47,8 @@ import com.composeunstyled.Text
import com.composeunstyled.rememberModalBottomSheetState import com.composeunstyled.rememberModalBottomSheetState
import com.composeunstyled.currentWindowWidthBreakpoint import com.composeunstyled.currentWindowWidthBreakpoint
import org.spotube.millennium.adaptive.WindowBreakpoints import org.spotube.millennium.adaptive.WindowBreakpoints
import androidx.compose.foundation.text.input.rememberTextFieldState
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.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
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
@ -65,16 +57,14 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.modules.library.LibraryRepository
import org.spotube.spotube.modules.search.SearchRepository
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle import org.spotube.spotube.resources.iconsax.IconsaxCheckCircle
import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch import org.spotube.spotube.resources.iconsax.IconsaxFilterSearch
import org.spotube.spotube.resources.iconsax.IconsaxSearch import org.spotube.spotube.resources.iconsax.IconsaxSearch
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
import org.koin.compose.koinInject
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.playlist_add_tracks_add import spotube.composeapp.generated.resources.playlist_add_tracks_add
import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description import spotube.composeapp.generated.resources.playlist_add_tracks_added_content_description
@ -90,59 +80,17 @@ fun AddTracksToPlaylistDialog(
visible: Boolean, visible: Boolean,
playlistId: String, playlistId: String,
onDismiss: () -> Unit, onDismiss: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
searchRepository: SearchRepository = koinInject(),
libraryRepository: LibraryRepository = koinInject()) {
if (!visible) return if (!visible) return
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium val viewModel: AddTracksToPlaylistViewModel = koinViewModel(
key = "add-tracks-$playlistId",
var query by remember { mutableStateOf("") } parameters = { parametersOf(playlistId) }
var searchResults by remember { mutableStateOf<List<MetadataTrack>>(emptyList()) } )
var isSearching by remember { mutableStateOf(false) } val state by viewModel.uiState.collectAsStateWithLifecycle()
var hasSearched by remember { mutableStateOf(false) } val dismiss = {
var addingTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) } viewModel.reset()
var addedTrackIds by remember { mutableStateOf<Set<String>>(emptySet()) } onDismiss()
val scope = rememberCoroutineScope()
val queryState = rememberTextFieldState(query)
LaunchedEffect(queryState) {
snapshotFlow { queryState.text.toString() }
.drop(1)
.collect { query = it }
}
LaunchedEffect(visible) {
if (!visible) {
query = ""
searchResults = emptyList()
hasSearched = false
addingTrackIds = emptySet()
addedTrackIds = emptySet()
}
}
val handleSearch = {
if (query.isNotBlank() && !isSearching) {
isSearching = true
hasSearched = true
scope.launch {
val result = searchRepository.searchTracks(query)
searchResults = result.items.map { it.data }
isSearching = false
}
}
}
val handleAddTrack = { track: MetadataTrack ->
if (!addingTrackIds.contains(track.id) && !addedTrackIds.contains(track.id)) {
addingTrackIds = addingTrackIds + track.id
scope.launch {
libraryRepository.addTracksToPlaylist(playlistId, listOf(track.id))
addingTrackIds = addingTrackIds - track.id
addedTrackIds = addedTrackIds + track.id
}
}
} }
val content: @Composable () -> Unit = { val content: @Composable () -> Unit = {
@ -154,18 +102,19 @@ fun AddTracksToPlaylistDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically) { verticalAlignment = Alignment.CenterVertically) {
MillenniumTextField( MillenniumTextField(
state = queryState, state = state.queryState,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder)) placeholder = stringResource(Res.string.playlist_add_tracks_search_placeholder))
MillenniumIconButton( MillenniumIconButton(
icon = Iconsax.IconsaxSearch, icon = Iconsax.IconsaxSearch,
contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description), contentDescription = stringResource(Res.string.playlist_add_tracks_search_content_description),
onClick = handleSearch, onClick = viewModel::search,
loading = state.isSearching,
style = MillenniumIconButtonStyle.Secondary) style = MillenniumIconButtonStyle.Secondary)
} }
when { when {
isSearching -> { state.isSearching -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -174,7 +123,7 @@ fun AddTracksToPlaylistDialog(
MillenniumCircularProgress(size = 40.dp) MillenniumCircularProgress(size = 40.dp)
} }
} }
hasSearched && searchResults.isEmpty() -> { state.hasSearched && state.results.isEmpty() -> {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -183,18 +132,18 @@ fun AddTracksToPlaylistDialog(
Text(stringResource(Res.string.playlist_add_tracks_no_results)) Text(stringResource(Res.string.playlist_add_tracks_no_results))
} }
} }
searchResults.isNotEmpty() -> { state.results.isNotEmpty() -> {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.weight(1f), .weight(1f),
verticalArrangement = Arrangement.spacedBy(4.dp)) { verticalArrangement = Arrangement.spacedBy(4.dp)) {
items(searchResults, key = { it.id }) { track -> items(state.results, key = { it.track.id }) { result ->
TrackSearchResultRow( TrackSearchResultRow(
track = track, track = result.track,
isAdding = addingTrackIds.contains(track.id), isAdding = result.isAdding,
isAdded = addedTrackIds.contains(track.id), isAdded = result.isAdded,
onAdd = { handleAddTrack(track) }) onAdd = { viewModel.addTrack(result.track.id) })
} }
} }
} }
@ -213,7 +162,7 @@ fun AddTracksToPlaylistDialog(
val dialogTitle = stringResource(Res.string.playlist_add_tracks_title) val dialogTitle = stringResource(Res.string.playlist_add_tracks_title)
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = onDismiss, onDismiss = dismiss,
modifier = modifier, modifier = modifier,
dialogTitle = dialogTitle, dialogTitle = dialogTitle,
title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) }, title = { Text(dialogTitle, style = AppTheme.typography.headlineSmall) },
@ -221,7 +170,7 @@ fun AddTracksToPlaylistDialog(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) { horizontalArrangement = Arrangement.End) {
MillenniumButton(onClick = onDismiss) { MillenniumButton(onClick = dismiss) {
Text(stringResource(Res.string.playlist_add_tracks_done)) Text(stringResource(Res.string.playlist_add_tracks_done))
} }
} }
@ -288,12 +237,10 @@ private fun TrackSearchResultRow(
tint = AppTheme.colors.primary, tint = AppTheme.colors.primary,
modifier = Modifier.size(24.dp)) modifier = Modifier.size(24.dp))
} }
isAdding -> {
MillenniumSpokeSpinner(size = 24.dp)
}
else -> { else -> {
MillenniumButton( MillenniumButton(
onClick = onAdd, onClick = onAdd,
loading = isAdding,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text(stringResource(Res.string.playlist_add_tracks_add)) Text(stringResource(Res.string.playlist_add_tracks_add))
} }

View File

@ -0,0 +1,131 @@
/*
* 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.playlist
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.clearText
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.modules.library.LibraryRepository
import org.spotube.spotube.modules.search.SearchRepository
import kotlin.coroutines.cancellation.CancellationException
data class AddTrackResultUi(
val track: MetadataTrack,
val isAdding: Boolean,
val isAdded: Boolean
)
data class AddTracksToPlaylistUiState(
val queryState: TextFieldState = TextFieldState(),
val results: List<AddTrackResultUi> = emptyList(),
val isSearching: Boolean = false,
val hasSearched: Boolean = false
)
private data class AddTracksState(
val results: List<MetadataTrack> = emptyList(),
val isSearching: Boolean = false,
val hasSearched: Boolean = false,
val addingTrackIds: Set<String> = emptySet(),
val addedTrackIds: Set<String> = emptySet()
)
class AddTracksToPlaylistViewModel(
private val playlistId: String,
private val searchRepository: SearchRepository,
private val libraryRepository: LibraryRepository
) : ViewModel() {
private val logger = Logger.withTag("AddTracksToPlaylistViewModel")
private val queryState = TextFieldState()
private val state = MutableStateFlow(AddTracksState())
private var searchJob: Job? = null
val uiState: StateFlow<AddTracksToPlaylistUiState> = state.map { current ->
AddTracksToPlaylistUiState(
queryState = queryState,
results = current.results.map { track ->
AddTrackResultUi(
track = track,
isAdding = track.id in current.addingTrackIds,
isAdded = track.id in current.addedTrackIds
)
},
isSearching = current.isSearching,
hasSearched = current.hasSearched
)
}.stateIn(
viewModelScope,
SharingStarted.WhileSubscribed(5_000),
AddTracksToPlaylistUiState(queryState = queryState)
)
fun search() {
val query = queryState.text.toString()
if (query.isBlank() || state.value.isSearching) return
state.update { it.copy(isSearching = true, hasSearched = true) }
searchJob = viewModelScope.launch {
try {
val results = searchRepository.searchTracks(query).items.map { it.data }
state.update { it.copy(results = results) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
logger.e(e) { "Failed to search tracks for \"$query\"" }
state.update { it.copy(results = emptyList()) }
} finally {
state.update { it.copy(isSearching = false) }
}
}
}
fun addTrack(trackId: String) {
val current = state.value
if (trackId in current.addingTrackIds || trackId in current.addedTrackIds) return
state.update { it.copy(addingTrackIds = it.addingTrackIds + trackId) }
viewModelScope.launch {
try {
libraryRepository.addTracksToPlaylist(playlistId, listOf(trackId))
state.update { it.copy(addedTrackIds = it.addedTrackIds + trackId) }
} catch (cancelled: CancellationException) {
throw cancelled
} catch (e: Exception) {
logger.e(e) { "Failed to add track $trackId to playlist $playlistId" }
} finally {
state.update { it.copy(addingTrackIds = it.addingTrackIds - trackId) }
}
}
}
/** Clears the search so the dialog opens fresh next time; in-flight adds still complete. */
fun reset() {
searchJob?.cancel()
queryState.clearText()
state.update { AddTracksState() }
}
}

View File

@ -66,6 +66,7 @@ fun PlaylistScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -77,7 +78,7 @@ fun PlaylistScreen(
val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle() val currentUserId by viewModel.currentUserId.collectAsStateWithLifecycle()
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
var showEditPlaylist by remember { mutableStateOf(false) } val editPlaylistFormState by viewModel.editPlaylistFormState.collectAsStateWithLifecycle()
var showAddTracksDialog by remember { mutableStateOf(false) } var showAddTracksDialog by remember { mutableStateOf(false) }
val dataState = state as? PlaylistScreenState.Data val dataState = state as? PlaylistScreenState.Data
@ -117,6 +118,9 @@ fun PlaylistScreen(
} else null } else null
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = playlist?.title ?: stringResource(Res.string.playlist_loading_title), title = playlist?.title ?: stringResource(Res.string.playlist_loading_title),
description = playlist?.description.orEmpty(), description = playlist?.description.orEmpty(),
imageURL = artworkUrl, imageURL = artworkUrl,
@ -128,9 +132,10 @@ fun PlaylistScreen(
onAddToQueue = viewModel::addPlaylistToQueue, onAddToQueue = viewModel::addPlaylistToQueue,
isPlaying = isPlaying, isPlaying = isPlaying,
isFollowing = savedPlaylistIds.contains(playlistId), isFollowing = savedPlaylistIds.contains(playlistId),
isFollowLoading = dataState?.isSaving == true,
onFollowClick = viewModel::toggleSavedPlaylist, onFollowClick = viewModel::toggleSavedPlaylist,
showFollowButton = playlistId != "saved_tracks", showFollowButton = playlistId != "saved_tracks",
onEdit = if (isOwner) { { showEditPlaylist = true } } else null, onEdit = if (isOwner) viewModel::showEditPlaylist else null,
isLoading = isLoading, isLoading = isLoading,
error = errorMessage, error = errorMessage,
onRetry = { viewModel.refresh() }, onRetry = { viewModel.refresh() },
@ -157,8 +162,10 @@ fun PlaylistScreen(
trailingContent = { trailingContent = {
val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist val loadedPlaylist = (dataState as? PlaylistScreenState.Data.Loaded)?.playlist
PlaylistFormSheet( PlaylistFormSheet(
visible = showEditPlaylist, visible = editPlaylistFormState.isVisible,
onDismiss = { showEditPlaylist = false }, formState = editPlaylistFormState,
onDismiss = viewModel::dismissEditPlaylist,
onImagePicked = viewModel::onEditPlaylistImagePicked,
isEditing = true, isEditing = true,
initialData = loadedPlaylist?.let { initialData = loadedPlaylist?.let {
PlaylistFormData( PlaylistFormData(
@ -168,14 +175,7 @@ fun PlaylistScreen(
isCollaborating = false, isCollaborating = false,
imagePreviewUrl = it.thumbnails.firstOrNull()?.url) imagePreviewUrl = it.thumbnails.firstOrNull()?.url)
}, },
onSubmit = { data -> onSubmit = viewModel::updatePlaylist)
viewModel.updatePlaylist(
name = data.name,
description = data.description.ifBlank { null },
isPublic = data.isPublic,
isCollaborating = data.isCollaborating,
imageBase64 = data.imageBase64.ifBlank { null })
})
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,

View File

@ -26,6 +26,7 @@ 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.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.core.remote.CollectionPlaybackUiState
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService import org.spotube.spotube.core.share.ShareService
@ -48,6 +49,11 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import io.github.vinceglb.filekit.PlatformFile
import org.spotube.spotube.modules.library.playlist.PlaylistFormController
import org.spotube.spotube.modules.library.playlist.PlaylistFormData
import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.getString
@ -105,6 +111,11 @@ class PlaylistViewModel(
private val _state = MutableStateFlow<PlaylistScreenState>(PlaylistScreenState.Loading) private val _state = MutableStateFlow<PlaylistScreenState>(PlaylistScreenState.Loading)
val uiState: StateFlow<PlaylistScreenState> = _state.asStateFlow() val uiState: StateFlow<PlaylistScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.Playlist, playlistId)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedPlaylistIds val savedPlaylistIds
get() = libraryRepository.savedPlaylistIdsFlow get() = libraryRepository.savedPlaylistIdsFlow
@ -206,6 +217,8 @@ class PlaylistViewModel(
} }
fun toggleSavedPlaylist() { fun toggleSavedPlaylist() {
if ((_state.value as? PlaylistScreenState.Data)?.isSaving == true) return
setSaving(true)
viewModelScope.launch { viewModelScope.launch {
runCatching { runCatching {
val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false val isLiked = libraryRepository.isSavedPlaylists(listOf(playlistId)).firstOrNull() ?: false
@ -217,6 +230,17 @@ class PlaylistViewModel(
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to toggle saved playlist" } logger.e(e) { "Failed to toggle saved playlist" }
} }
setSaving(false)
}
}
private fun setSaving(isSaving: Boolean) {
_state.update { state ->
when (state) {
is PlaylistScreenState.Data.Loaded -> state.copy(isSaving = isSaving)
is PlaylistScreenState.Data.LoadingMore -> state.copy(isSaving = isSaving)
else -> state
}
} }
} }
@ -256,28 +280,27 @@ class PlaylistViewModel(
} }
} }
fun updatePlaylist( private val editPlaylistForm = PlaylistFormController(viewModelScope, logger)
name: String?, val editPlaylistFormState: StateFlow<PlaylistFormUiState> = editPlaylistForm.uiState
description: String?,
isPublic: Boolean?, fun showEditPlaylist() = editPlaylistForm.show()
isCollaborating: Boolean?,
imageBase64: String?) { fun dismissEditPlaylist() = editPlaylistForm.dismiss()
viewModelScope.launch {
runCatching { fun onEditPlaylistImagePicked(file: PlatformFile) = editPlaylistForm.onImagePicked(file)
libraryRepository.updatePlaylist(
id = playlistId, fun updatePlaylist(data: PlaylistFormData) {
name = name, editPlaylistForm.submit {
description = description, libraryRepository.updatePlaylist(
isPublic = isPublic, id = playlistId,
isCollaborating = isCollaborating, name = data.name,
imageBase64 = imageBase64, description = data.description.ifBlank { null },
trackIds = null) isPublic = data.isPublic,
}.onFailure { e -> isCollaborating = data.isCollaborating,
logger.e(e) { "Failed to update playlist" } imageBase64 = data.imageBase64.ifBlank { null },
}.onSuccess { trackIds = null)
repository.invalidateCaches() repository.invalidateCaches()
loadInitialData() loadInitialData()
}
} }
} }

View File

@ -23,6 +23,7 @@ import org.spotube.spotube.core.di.injectLogger
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
@ -30,12 +31,16 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
data class MetadataAuthUiState( data class MetadataAuthUiState(
/** True when the selected metadata plugin needs an account and the user isn't signed in. */ /** True when the selected metadata plugin needs an account and the user isn't signed in. */
val loginRequired: Boolean = false, val loginRequired: Boolean = false,
val pluginName: String? = null) val pluginName: String? = null,
/** True while a login started from the prompt is still running. */
val isLoggingIn: Boolean = false)
/** /**
* Tells screens whether the selected metadata plugin is waiting for a login, so they can show a * Tells screens whether the selected metadata plugin is waiting for a login, so they can show a
@ -45,15 +50,17 @@ data class MetadataAuthUiState(
class MetadataAuthViewModel( class MetadataAuthViewModel(
private val pluginManager: PluginManager) : ViewModel(), KoinComponent { private val pluginManager: PluginManager) : ViewModel(), KoinComponent {
private val logger by injectLogger<MetadataAuthViewModel>() private val logger by injectLogger<MetadataAuthViewModel>()
private val isLoggingIn = MutableStateFlow(false)
val uiState: StateFlow<MetadataAuthUiState> = combine( val uiState: StateFlow<MetadataAuthUiState> = combine(
pluginManager.selectedMetadataPlugin.flatMapLatest { service -> pluginManager.selectedMetadataPlugin.flatMapLatest { service ->
if (service == null) flowOf(false) else loginRequiredFlow(service) if (service == null) flowOf(false) else loginRequiredFlow(service)
}, },
pluginManager.state) { loginRequired, managerState -> pluginManager.state,
isLoggingIn) { loginRequired, managerState, loggingIn ->
val name = (managerState as? PluginManagerStates.Data) val name = (managerState as? PluginManagerStates.Data)
?.selectedPlugins?.get(PluginAbility.METADATA)?.name ?.selectedPlugins?.get(PluginAbility.METADATA)?.name
MetadataAuthUiState(loginRequired = loginRequired, pluginName = name) MetadataAuthUiState(loginRequired = loginRequired, pluginName = name, isLoggingIn = loggingIn)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), MetadataAuthUiState())
private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow<Boolean> = flow { private fun loginRequiredFlow(service: org.spotube.spotube.core.zipline.PluginService): Flow<Boolean> = flow {
@ -71,10 +78,20 @@ class MetadataAuthViewModel(
} }
fun login() { fun login() {
if (isLoggingIn.value) return
val service = pluginManager.selectedMetadataPlugin.value ?: return val service = pluginManager.selectedMetadataPlugin.value ?: return
pluginManager.launchTask { // The login runs in the plugin manager's scope so it survives this screen; we only wait on it.
val task = pluginManager.launchTask {
runCatching { service.use { coreAPI.login() } } runCatching { service.use { coreAPI.login() } }
.onFailure { logger.e(it) { "Metadata plugin login failed" } } .onFailure { logger.e(it) { "Metadata plugin login failed" } }
} }
isLoggingIn.value = true
viewModelScope.launch {
try {
task.join()
} finally {
isLoggingIn.update { false }
}
}
} }
} }

View File

@ -390,22 +390,30 @@ class PluginManager(
} }
} }
/** Called when the user confirms installation/update/replacement in the dialog. */ /**
fun confirmInstall() { * Called when the user confirms installation/update/replacement in the dialog. The prompt stays
val pending = pendingPlugin.value ?: return * up until the install finishes, so the dialog can show progress.
val bytes = pending.bytes ?: return dismissInstall() *
* @return The install job, or `null` when there was nothing to install.
*/
fun confirmInstall(): Job? {
val pending = pendingPlugin.value ?: return null
val bytes = pending.bytes ?: run { dismissInstall(); return null }
val allowReplacingInstalled = when (pending.kind) { val allowReplacingInstalled = when (pending.kind) {
InstallPromptKind.INSTALL -> false InstallPromptKind.INSTALL -> false
InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true InstallPromptKind.UPDATE, InstallPromptKind.REPLACE -> true
InstallPromptKind.INFO -> return dismissInstall() InstallPromptKind.INFO -> run { dismissInstall(); return null }
InstallPromptKind.REPLACE -> true
} }
pendingPlugin.value = null return scope.launch {
scope.launch { try {
addPluginFromByteArray( addPluginFromByteArray(
bytes = bytes, bytes = bytes,
allowReplacingInstalled = allowReplacingInstalled allowReplacingInstalled = allowReplacingInstalled
) )
} finally {
// Only close our own prompt; another plugin may have been queued meanwhile.
pendingPlugin.compareAndSet(pending, null)
}
} }
} }

View File

@ -235,6 +235,7 @@ internal fun PluginScreenContent(
} else { } else {
null null
}, },
isConfirming = state.isConfirmingInstall,
onDismiss = { viewModel.dismissInstall() } onDismiss = { viewModel.dismissInstall() }
) )
} }
@ -274,6 +275,7 @@ internal fun PluginScreenContent(
urlInput = state.urlInput, urlInput = state.urlInput,
urlError = state.urlError, urlError = state.urlError,
isLoadingUrl = state.isLoadingUrl, isLoadingUrl = state.isLoadingUrl,
isPreparingFile = state.isPreparingFile,
onUrlChange = { viewModel.onUrlInputChange(it) }, onUrlChange = { viewModel.onUrlInputChange(it) },
onSubmitUrl = { viewModel.submitUrl() }, onSubmitUrl = { viewModel.submitUrl() },
onPickFile = { launcher.launch() }, onPickFile = { launcher.launch() },
@ -459,6 +461,8 @@ internal fun PluginScreenContent(
onRemove = { viewModel.removePlugin(item.plugin) }, onRemove = { viewModel.removePlugin(item.plugin) },
isLoggedIn = item.authState.isLoggedIn, isLoggedIn = item.authState.isLoggedIn,
logoPath = item.logoPath, logoPath = item.logoPath,
isAuthInProgress = item.isAuthInProgress,
isRemoving = item.isRemoving,
onInfo = { viewModel.showPluginInfo(item.plugin) }, onInfo = { viewModel.showPluginInfo(item.plugin) },
onSupport = if (item.isSelected) { onSupport = if (item.isSelected) {
{ viewModel.loadSupport(item.plugin) } { viewModel.loadSupport(item.plugin) }
@ -620,6 +624,7 @@ private fun InstallSheet(
urlInput: String, urlInput: String,
urlError: UrlError?, urlError: UrlError?,
isLoadingUrl: Boolean, isLoadingUrl: Boolean,
isPreparingFile: Boolean,
onUrlChange: (String) -> Unit, onUrlChange: (String) -> Unit,
onSubmitUrl: () -> Unit, onSubmitUrl: () -> Unit,
onPickFile: () -> Unit, onPickFile: () -> Unit,
@ -671,20 +676,16 @@ private fun InstallSheet(
error = urlError?.text()) error = urlError?.text())
MillenniumButton( MillenniumButton(
onClick = onSubmitUrl, onClick = onSubmitUrl,
enabled = !isLoadingUrl, loading = isLoadingUrl,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
style = MillenniumButtonStyle.Primary) { style = MillenniumButtonStyle.Primary) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)) { horizontalArrangement = Arrangement.spacedBy(8.dp)) {
if (isLoadingUrl) { MillenniumIcon(
MillenniumSpokeSpinner(size = 16.dp, color = MillenniumTheme.colors.onAccent) Iconsax.IconsaxImportArrow2Bulk,
} else { contentDescription = null,
MillenniumIcon( modifier = Modifier.size(18.dp))
Iconsax.IconsaxImportArrow2Bulk,
contentDescription = null,
modifier = Modifier.size(18.dp))
}
Text(stringResource(Res.string.plugin_action_download)) Text(stringResource(Res.string.plugin_action_download))
} }
} }
@ -703,7 +704,7 @@ private fun InstallSheet(
MillenniumSeparator(modifier = Modifier.weight(1f)) MillenniumSeparator(modifier = Modifier.weight(1f))
} }
PluginPanel(modifier = Modifier.clickable(onClick = onPickFile)) { PluginPanel(modifier = Modifier.clickable(enabled = !isPreparingFile, onClick = onPickFile)) {
Row( Row(
modifier = Modifier.fillMaxWidth().padding(14.dp), modifier = Modifier.fillMaxWidth().padding(14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@ -721,11 +722,15 @@ private fun InstallSheet(
style = AppTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = AppTheme.colors.onSurfaceVariant) color = AppTheme.colors.onSurfaceVariant)
} }
MillenniumIcon( if (isPreparingFile) {
FeatherIcons.ChevronRight, MillenniumSpokeSpinner(size = 18.dp)
contentDescription = null, } else {
modifier = Modifier.size(18.dp), MillenniumIcon(
tint = AppTheme.colors.onSurfaceVariant) FeatherIcons.ChevronRight,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = AppTheme.colors.onSurfaceVariant)
}
} }
} }
} }
@ -1013,20 +1018,16 @@ private fun DiscoverRepoCard(
} }
MillenniumButton( MillenniumButton(
onClick = onInstall, onClick = onInstall,
enabled = !isInstalling, loading = isInstalling,
style = MillenniumButtonStyle.Secondary style = MillenniumButtonStyle.Secondary
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
if (isInstalling) { MillenniumIcon(
MillenniumSpokeSpinner(size = 16.dp) Iconsax.IconsaxAdd,
} else { contentDescription = null)
MillenniumIcon(
Iconsax.IconsaxAdd,
contentDescription = null)
}
Text(stringResource(Res.string.plugin_section_install)) Text(stringResource(Res.string.plugin_section_install))
} }
} }

View File

@ -26,6 +26,7 @@ import io.github.vinceglb.filekit.PlatformFile
import io.github.vinceglb.filekit.readBytes import io.github.vinceglb.filekit.readBytes
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
@ -55,7 +56,9 @@ data class PluginListItem(
val plugin: PluginEntry, val plugin: PluginEntry,
val isSelected: Boolean, val isSelected: Boolean,
val authState: PluginAuthState, val authState: PluginAuthState,
val logoPath: Path?) val logoPath: Path?,
val isAuthInProgress: Boolean = false,
val isRemoving: Boolean = false)
data class AbilitySelection( data class AbilitySelection(
val ability: PluginAbility, val ability: PluginAbility,
@ -92,6 +95,8 @@ sealed interface PluginUiState {
val urlInput: String = "", val urlInput: String = "",
val urlError: UrlError? = null, val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false, val isLoadingUrl: Boolean = false,
val isPreparingFile: Boolean = false,
val isConfirmingInstall: Boolean = false,
val showInstallSheet: Boolean = false, val showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null, val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null, val pluginInfoLogoPath: Path? = null,
@ -118,6 +123,10 @@ private data class PluginScreenState(
val urlInput: String = "", val urlInput: String = "",
val urlError: UrlError? = null, val urlError: UrlError? = null,
val isLoadingUrl: Boolean = false, val isLoadingUrl: Boolean = false,
val isPreparingFile: Boolean = false,
val isConfirmingInstall: Boolean = false,
val authInProgressIds: Set<String> = emptySet(),
val removingIds: Set<String> = emptySet(),
val showInstallSheet: Boolean = false, val showInstallSheet: Boolean = false,
val showPluginInfo: PluginEntry? = null, val showPluginInfo: PluginEntry? = null,
val pluginInfoLogoPath: Path? = null, val pluginInfoLogoPath: Path? = null,
@ -235,7 +244,9 @@ class PluginViewModel(
plugin = plugin, plugin = plugin,
isSelected = state.selectedPlugins.containsValue(plugin), isSelected = state.selectedPlugins.containsValue(plugin),
authState = authStates[plugin.id] ?: PluginAuthState(), authState = authStates[plugin.id] ?: PluginAuthState(),
logoPath = logoPaths[plugin.id]) logoPath = logoPaths[plugin.id],
isAuthInProgress = plugin.id in screen.authInProgressIds,
isRemoving = plugin.id in screen.removingIds)
}, },
abilitySelections = listOf( abilitySelections = listOf(
AbilitySelection( AbilitySelection(
@ -263,6 +274,8 @@ class PluginViewModel(
urlInput = screen.urlInput, urlInput = screen.urlInput,
urlError = screen.urlError, urlError = screen.urlError,
isLoadingUrl = screen.isLoadingUrl, isLoadingUrl = screen.isLoadingUrl,
isPreparingFile = screen.isPreparingFile,
isConfirmingInstall = screen.isConfirmingInstall,
showInstallSheet = screen.showInstallSheet, showInstallSheet = screen.showInstallSheet,
showPluginInfo = screen.showPluginInfo, showPluginInfo = screen.showPluginInfo,
pluginInfoLogoPath = screen.pluginInfoLogoPath, pluginInfoLogoPath = screen.pluginInfoLogoPath,
@ -360,12 +373,18 @@ class PluginViewModel(
} }
fun onFileSelected(file: PlatformFile?) { fun onFileSelected(file: PlatformFile?) {
if (file == null) return if (file == null || _screenState.value.isPreparingFile) return
_screenState.update { it.copy(isPreparingFile = true) }
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
pluginManager.preparePlugin(file.readBytes()) runCatching {
}.onFailure { e -> pluginManager.preparePlugin(file.readBytes())
logger.e(e) { "Failed to prepare plugin from file" } }.onFailure { e ->
if (e is CancellationException) throw e
logger.e(e) { "Failed to prepare plugin from file" }
}
} finally {
_screenState.update { it.copy(isPreparingFile = false) }
} }
} }
} }
@ -456,34 +475,68 @@ class PluginViewModel(
} }
fun removePlugin(plugin: PluginEntry) { fun removePlugin(plugin: PluginEntry) {
if (plugin.id in _screenState.value.removingIds) return
_screenState.update { it.copy(removingIds = it.removingIds + plugin.id) }
viewModelScope.launch { viewModelScope.launch {
runCatching { try {
pluginManager.removePlugin(plugin) runCatching {
}.onFailure { e -> pluginManager.removePlugin(plugin)
logger.e(e) { "Failed to remove plugin" } }.onFailure { e ->
if (e is CancellationException) throw e
logger.e(e) { "Failed to remove plugin" }
}
} finally {
_screenState.update { it.copy(removingIds = it.removingIds - plugin.id) }
} }
} }
} }
fun login(plugin: PluginEntry) { fun login(plugin: PluginEntry) {
if (plugin.id in _screenState.value.authInProgressIds) return
val service = findServiceForPlugin(plugin.id) ?: return val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask { trackAuthTask(plugin.id, pluginManager.launchTask {
service.use { coreAPI.login() } service.use { coreAPI.login() }
} })
} }
fun logout(plugin: PluginEntry) { fun logout(plugin: PluginEntry) {
if (plugin.id in _screenState.value.authInProgressIds) return
val service = findServiceForPlugin(plugin.id) ?: return val service = findServiceForPlugin(plugin.id) ?: return
pluginManager.launchTask { val task = pluginManager.launchTask {
service.use { coreAPI.logout() } service.use { coreAPI.logout() }
} }
viewModelScope.launch { trackAuthTask(plugin.id, task) {
webViewController.clearData(plugin.id) webViewController.clearData(plugin.id)
} }
} }
/**
* Marks [pluginId] busy until [task] (running in the plugin manager's scope, so it outlives this
* screen) and the optional [alongside] work finish, whatever their outcome.
*/
private fun trackAuthTask(pluginId: String, task: Job, alongside: suspend () -> Unit = {}) {
_screenState.update { it.copy(authInProgressIds = it.authInProgressIds + pluginId) }
viewModelScope.launch {
try {
alongside()
task.join()
} finally {
_screenState.update { it.copy(authInProgressIds = it.authInProgressIds - pluginId) }
}
}
}
fun confirmInstall() { fun confirmInstall() {
pluginManager.confirmInstall() if (_screenState.value.isConfirmingInstall) return
val task = pluginManager.confirmInstall() ?: return
_screenState.update { it.copy(isConfirmingInstall = true) }
viewModelScope.launch {
try {
task.join()
} finally {
_screenState.update { it.copy(isConfirmingInstall = false) }
}
}
} }
fun dismissInstall() { fun dismissInstall() {

View File

@ -77,7 +77,9 @@ internal fun PluginCard(
onLogout: (() -> Unit)? = null, onLogout: (() -> Unit)? = null,
onInfo: (() -> Unit)? = null, onInfo: (() -> Unit)? = null,
onSupport: (() -> Unit)? = null, onSupport: (() -> Unit)? = null,
logoPath: Path? = null) { logoPath: Path? = null,
isAuthInProgress: Boolean = false,
isRemoving: Boolean = false) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -272,6 +274,7 @@ internal fun PluginCard(
} else { } else {
MillenniumIconButton( MillenniumIconButton(
onClick = onRemove, onClick = onRemove,
loading = isRemoving,
style = MillenniumIconButtonStyle.Quiet) { style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon( MillenniumIcon(
Iconsax.IconsaxTrash, Iconsax.IconsaxTrash,
@ -289,6 +292,7 @@ internal fun PluginCard(
authAction?.let { (action, label) -> authAction?.let { (action, label) ->
MillenniumButton( MillenniumButton(
onClick = action, onClick = action,
loading = isAuthInProgress,
style = MillenniumButtonStyle.Secondary) { style = MillenniumButtonStyle.Secondary) {
Text( Text(
text = stringResource(label), text = stringResource(label),

View File

@ -226,7 +226,8 @@ internal fun PluginDialogActions(
onSecondary: () -> Unit, onSecondary: () -> Unit,
primaryLabel: String? = null, primaryLabel: String? = null,
onPrimary: (() -> Unit)? = null, onPrimary: (() -> Unit)? = null,
primaryEnabled: Boolean = true) { primaryEnabled: Boolean = true,
primaryLoading: Boolean = false) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp)) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
@ -240,6 +241,7 @@ internal fun PluginDialogActions(
MillenniumButton( MillenniumButton(
onClick = onPrimary, onClick = onPrimary,
enabled = primaryEnabled, enabled = primaryEnabled,
loading = primaryLoading,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
style = MillenniumButtonStyle.Primary) { style = MillenniumButtonStyle.Primary) {
Text(primaryLabel) Text(primaryLabel)

View File

@ -72,6 +72,7 @@ fun PluginPermissionDialog(
existingPlugin: PluginEntry? = null, existingPlugin: PluginEntry? = null,
logoPath: Path? = null, logoPath: Path? = null,
onConfirm: (() -> Unit)? = null, onConfirm: (() -> Unit)? = null,
isConfirming: Boolean = false,
onDismiss: () -> Unit) { onDismiss: () -> Unit) {
AdaptiveDialogBottomSheet( AdaptiveDialogBottomSheet(
onDismiss = onDismiss, onDismiss = onDismiss,
@ -83,7 +84,8 @@ fun PluginPermissionDialog(
if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel), if (confirmLabel == null) Res.string.common_close else Res.string.common_cancel),
onSecondary = onDismiss, onSecondary = onDismiss,
primaryLabel = confirmLabel, primaryLabel = confirmLabel,
onPrimary = onConfirm) onPrimary = onConfirm,
primaryLoading = isConfirming)
}) { }) {
PluginDialogBody { PluginDialogBody {
PluginPanel { PluginPanel {

View File

@ -108,6 +108,7 @@ fun MetadataPluginGuard(
LoginRequiredDisplay( LoginRequiredDisplay(
pluginName = authState.pluginName, pluginName = authState.pluginName,
onLogin = authViewModel::login, onLogin = authViewModel::login,
isLoggingIn = authState.isLoggingIn,
modifier = Modifier.widthIn(max = 420.dp)) modifier = Modifier.widthIn(max = 420.dp))
} }
} }

View File

@ -46,6 +46,7 @@ fun SavedTracksScreen(
navigationCommands: NavigationCommands navigationCommands: NavigationCommands
) { ) {
val state by viewModel.uiState.collectAsStateWithLifecycle() val state by viewModel.uiState.collectAsStateWithLifecycle()
val playbackUiState by viewModel.playbackUiState.collectAsStateWithLifecycle()
val jamRoomService: JamRoomService = koinInject() val jamRoomService: JamRoomService = koinInject()
val jamActive by jamRoomService.role.map { it != null } val jamActive by jamRoomService.role.map { it != null }
.collectAsStateWithLifecycle(initialValue = false) .collectAsStateWithLifecycle(initialValue = false)
@ -65,6 +66,9 @@ fun SavedTracksScreen(
val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty()) val isLoading = state is SavedTracksScreenState.Loading && (dataState == null || dataState.tracks.isEmpty())
CollectionView( CollectionView(
isPlayLoading = playbackUiState.isPlayLoading,
isShuffleLoading = playbackUiState.isShuffleLoading,
isAddToQueueLoading = playbackUiState.isAddToQueueLoading,
title = stringResource(Res.string.saved_tracks_title), title = stringResource(Res.string.saved_tracks_title),
description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "", description = dataState?.let { pluralStringResource(Res.plurals.library_local_tracks_count, it.totalCount, it.totalCount) } ?: "",
imageURL = "", imageURL = "",

View File

@ -34,6 +34,7 @@ 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.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.playback.CollectionPlaybackHelper import org.spotube.spotube.core.playback.CollectionPlaybackHelper
import org.spotube.spotube.core.remote.CollectionPlaybackUiState
import org.spotube.spotube.core.remote.RemoteCollectionType import org.spotube.spotube.core.remote.RemoteCollectionType
import org.spotube.spotube.core.remote.RemotePlaybackController import org.spotube.spotube.core.remote.RemotePlaybackController
import org.spotube.spotube.core.share.ShareService import org.spotube.spotube.core.share.ShareService
@ -54,6 +55,7 @@ import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -103,9 +105,18 @@ class SavedTracksViewModel(
private val _state = MutableStateFlow<SavedTracksScreenState>(SavedTracksScreenState.Loading) private val _state = MutableStateFlow<SavedTracksScreenState>(SavedTracksScreenState.Loading)
val uiState: StateFlow<SavedTracksScreenState> = _state.asStateFlow() val uiState: StateFlow<SavedTracksScreenState> = _state.asStateFlow()
/** Loading flags for this screen's play / shuffle / add-to-queue buttons. */
val playbackUiState: StateFlow<CollectionPlaybackUiState> = remotePlaybackController
.collectionPlaybackUiState(RemoteCollectionType.SavedTracks, SAVED_TRACKS_COLLECTION_ID)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), CollectionPlaybackUiState())
val savedTrackIdsFlow: StateFlow<Set<String>> val savedTrackIdsFlow: StateFlow<Set<String>>
get() = repository.savedTracksIdsFlow get() = repository.savedTracksIdsFlow
// Tracks whose save/remove request is still in flight.
private val _pendingSavedTrackIds = MutableStateFlow<Set<String>>(emptySet())
val pendingSavedTrackIds: StateFlow<Set<String>> = _pendingSavedTrackIds.asStateFlow()
private val _currentUserId = MutableStateFlow<String?>(null) private val _currentUserId = MutableStateFlow<String?>(null)
val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow() val currentUserId: StateFlow<String?> = _currentUserId.asStateFlow()
@ -340,6 +351,23 @@ class SavedTracksViewModel(
repository.removeSavedTracks(trackIds) repository.removeSavedTracks(trackIds)
} }
fun toggleSavedTrack(trackId: String, isSaved: Boolean) {
if (trackId in _pendingSavedTrackIds.value) return
_pendingSavedTrackIds.update { it + trackId }
viewModelScope.launch {
runCatching {
if (isSaved) {
repository.removeSavedTracks(listOf(trackId))
} else {
repository.saveTracks(listOf(trackId))
}
}.onFailure { e ->
logger.e(e) { "Failed to toggle saved track" }
}
_pendingSavedTrackIds.update { it - trackId }
}
}
private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean { private fun MetadataTrack.matchesTrack(other: MetadataTrack): Boolean {
if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id if (id.isNotBlank() && other.id.isNotBlank()) return id == other.id
return title == other.title && return title == other.title &&

View File

@ -244,13 +244,9 @@ internal fun JamSettingsContent(
trailingContent = { trailingContent = {
MillenniumButton( MillenniumButton(
onClick = viewModel::testConnection, onClick = viewModel::testConnection,
enabled = canTest) { enabled = broker.host.isNotBlank(),
Text( loading = uiState.isTesting) {
if (uiState.isTesting) { Text(stringResource(Res.string.settings_jam_broker_test))
stringResource(Res.string.settings_jam_broker_testing)
} else {
stringResource(Res.string.settings_jam_broker_test)
})
} }
}, },
onClick = viewModel::testConnection) onClick = viewModel::testConnection)

View File

@ -38,8 +38,12 @@ class JamSettingsViewModel(
if (_uiState.value.isTesting) return if (_uiState.value.isTesting) return
_uiState.update { it.copy(isTesting = true, testResult = null) } _uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch { viewModelScope.launch {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) try {
_uiState.update { it.copy(isTesting = false, testResult = result) } val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
_uiState.update { it.copy(testResult = result) }
} finally {
_uiState.update { it.copy(isTesting = false) }
}
} }
} }

View File

@ -103,7 +103,7 @@ class LocalMediaFoldersViewModel(
path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path }) path.substringAfterLast('/').substringAfterLast('\\').ifBlank { path })
}, },
isSaving = draft.isSaving, isSaving = draft.isSaving,
canSave = draft.isOpen && !draft.isSaving && draft.paths != draft.original, canSave = draft.isOpen && draft.paths != draft.original,
saveFailed = draft.saveFailed, saveFailed = draft.saveFailed,
automaticNote = when { automaticNote = when {
hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both hasDownloadFolder && settings.enableMusicCaching -> Res.string.settings_local_media_folders_automatic_both

View File

@ -56,7 +56,6 @@ data class UserSettings(
val cacheFolder: String? = null, val cacheFolder: String? = null,
val cacheSizeLimitMB: Long = -1L, val cacheSizeLimitMB: Long = -1L,
val enableEndlessPlayback: Boolean = true, val enableEndlessPlayback: Boolean = true,
val enableConnect: Boolean = false,
val playbackProxyServerPort: Int = 14769, val playbackProxyServerPort: Int = 14769,
// Remote Control (LAN) // Remote Control (LAN)

View File

@ -23,16 +23,21 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.spotube.spotube.core.paths.Paths
interface SettingsProvider { interface SettingsProvider {
val settingsState: StateFlow<UserSettings?> val settingsState: StateFlow<UserSettings?>
} }
class SettingsViewModel( class SettingsViewModel(
private val repository: SettingsRepository private val repository: SettingsRepository,
private val paths: Paths
) : ViewModel(), SettingsProvider { ) : ViewModel(), SettingsProvider {
override val settingsState = repository.userSettings override val settingsState = repository.userSettings
/** Where downloads go when no override folder is set. */
val defaultDownloadFolder: String = paths.getUserDownloadsDirPath()
fun updateSettings(transform: UserSettings.() -> UserSettings) { fun updateSettings(transform: UserSettings.() -> UserSettings) {
viewModelScope.launch { viewModelScope.launch {
// Get current state, apply transform, and save // Get current state, apply transform, and save

View File

@ -188,8 +188,8 @@ internal fun LocalMediaFoldersEditorContent(
) { ) {
Text(stringResource(Res.string.common_cancel)) Text(stringResource(Res.string.common_cancel))
} }
MillenniumButton(onClick = onSave, enabled = state.canSave) { MillenniumButton(onClick = onSave, enabled = state.canSave, loading = state.isSaving) {
Text(stringResource(if (state.isSaving) Res.string.settings_local_media_folders_saving else Res.string.common_save)) Text(stringResource(Res.string.common_save))
} }
} }
} }

View File

@ -46,7 +46,7 @@ import io.github.vinceglb.filekit.path
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_download_folder_current import spotube.composeapp.generated.resources.settings_download_folder_current
import spotube.composeapp.generated.resources.settings_download_folder_default import spotube.composeapp.generated.resources.settings_download_folder_default_path
import spotube.composeapp.generated.resources.settings_download_folder_title import spotube.composeapp.generated.resources.settings_download_folder_title
import spotube.composeapp.generated.resources.settings_download_format_subtitle_current import spotube.composeapp.generated.resources.settings_download_format_subtitle_current
import spotube.composeapp.generated.resources.settings_download_format_title import spotube.composeapp.generated.resources.settings_download_format_title
@ -77,6 +77,7 @@ internal fun LazyListScope.downloadsSection(
{ {
DownloadFolderSettingCard( DownloadFolderSettingCard(
folder = settings.overloadedDownloadFolder, folder = settings.overloadedDownloadFolder,
defaultFolder = settingsViewModel.defaultDownloadFolder,
onFolderSelected = { folder -> onFolderSelected = { folder ->
settingsViewModel.updateSettings { settingsViewModel.updateSettings {
copy(overloadedDownloadFolder = folder) copy(overloadedDownloadFolder = folder)
@ -142,6 +143,7 @@ internal fun LazyListScope.downloadsSection(
@Composable @Composable
private fun DownloadFolderSettingCard( private fun DownloadFolderSettingCard(
folder: String?, folder: String?,
defaultFolder: String,
onFolderSelected: (String?) -> Unit onFolderSelected: (String?) -> Unit
) { ) {
val pickerLauncher = rememberDirectoryPickerLauncher { directory -> val pickerLauncher = rememberDirectoryPickerLauncher { directory ->
@ -157,7 +159,7 @@ private fun DownloadFolderSettingCard(
title = stringResource(Res.string.settings_download_folder_title), title = stringResource(Res.string.settings_download_folder_title),
subtitle = folder?.let { subtitle = folder?.let {
stringResource(Res.string.settings_download_folder_current, it) stringResource(Res.string.settings_download_folder_current, it)
} ?: stringResource(Res.string.settings_download_folder_default), } ?: stringResource(Res.string.settings_download_folder_default_path, defaultFolder),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxDirectboxReceive, Iconsax.IconsaxDirectboxReceive,

View File

@ -41,9 +41,7 @@ import org.spotube.spotube.modules.settings.components.SwitchSettingCard
import org.spotube.spotube.modules.settings.components.TextInputSettingCard import org.spotube.spotube.modules.settings.components.TextInputSettingCard
import org.spotube.spotube.resources.iconsax.CustomServer import org.spotube.spotube.resources.iconsax.CustomServer
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxEdit
import org.spotube.spotube.resources.iconsax.IconsaxForbidden import org.spotube.spotube.resources.iconsax.IconsaxForbidden
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay import org.spotube.spotube.resources.iconsax.IconsaxMusicPlay
import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow import org.spotube.spotube.resources.iconsax.IconsaxRepeatArrow
import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting import org.spotube.spotube.resources.iconsax.IconsaxVerticalSetting
@ -122,66 +120,7 @@ internal fun LazyListScope.playbackSection(
} }
) )
} }
add { addAll(remoteControlSettingCards(settings, settingsViewModel, requestLocalNetworkPermission))
SwitchSettingCard(
title = stringResource(Res.string.settings_enable_connect_title),
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
icon = {
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
},
checked = settings.enableConnect,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(enableConnect = enabled)
}
}
)
}
add {
SwitchSettingCard(
title = stringResource(Res.string.settings_allow_remote_control_title),
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
icon = {
SettingsItemIcon(
Iconsax.IconsaxMirroringScreen,
stringResource(Res.string.settings_allow_remote_control_title)
)
},
checked = settings.allowRemoteControl,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(allowRemoteControl = enabled)
}
// Request the local network permission when enabling remote control
// so that DNS-SD registration can succeed on Android 16+
if (enabled) {
requestLocalNetworkPermission()
}
}
)
}
add {
TextInputSettingCard(
title = stringResource(Res.string.settings_remote_device_name_title),
subtitle = stringResource(
Res.string.settings_remote_device_name_subtitle,
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
),
icon = {
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
},
value = settings.remoteControlDeviceName,
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
normalize = { it.trim() },
validate = { _ -> null },
onValueSaved = { value ->
settingsViewModel.updateSettings {
copy(remoteControlDeviceName = value)
}
}
)
}
add { add {
val error_whole_number = stringResource(Res.string.settings_error_whole_number) val error_whole_number = stringResource(Res.string.settings_error_whole_number)
val error_port_range = stringResource(Res.string.settings_error_port_range) val error_port_range = stringResource(Res.string.settings_error_port_range)

View File

@ -0,0 +1,92 @@
/*
* 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.settings.sections
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.UserSettings
import org.spotube.spotube.modules.settings.components.SwitchSettingCard
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxEdit
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_allow_remote_control_subtitle
import spotube.composeapp.generated.resources.settings_allow_remote_control_title
import spotube.composeapp.generated.resources.settings_remote_device_name_default
import spotube.composeapp.generated.resources.settings_remote_device_name_description
import spotube.composeapp.generated.resources.settings_remote_device_name_placeholder
import spotube.composeapp.generated.resources.settings_remote_device_name_subtitle
import spotube.composeapp.generated.resources.settings_remote_device_name_title
/**
* "Allow remote control" and "Device name" cards. Shared by the Playback settings section and the
* Devices screen so both stay identical.
*/
internal fun remoteControlSettingCards(
settings: UserSettings,
settingsViewModel: SettingsViewModel,
requestLocalNetworkPermission: () -> Unit
): List<@Composable () -> Unit> = listOf(
{
SwitchSettingCard(
title = stringResource(Res.string.settings_allow_remote_control_title),
subtitle = stringResource(Res.string.settings_allow_remote_control_subtitle),
icon = {
SettingsItemIcon(
Iconsax.IconsaxMirroringScreen,
stringResource(Res.string.settings_allow_remote_control_title)
)
},
checked = settings.allowRemoteControl,
onCheckedChange = { enabled ->
settingsViewModel.updateSettings {
copy(allowRemoteControl = enabled)
}
// Request the local network permission when enabling remote control
// so that DNS-SD registration can succeed on Android 16+
if (enabled) {
requestLocalNetworkPermission()
}
}
)
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_remote_device_name_title),
subtitle = stringResource(
Res.string.settings_remote_device_name_subtitle,
settings.remoteControlDeviceName.ifBlank { stringResource(Res.string.settings_remote_device_name_default) }
),
icon = {
SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.settings_remote_device_name_title))
},
value = settings.remoteControlDeviceName,
dialogDescription = stringResource(Res.string.settings_remote_device_name_description),
placeholder = stringResource(Res.string.settings_remote_device_name_placeholder),
normalize = { it.trim() },
validate = { _ -> null },
onValueSaved = { value ->
settingsViewModel.updateSettings {
copy(remoteControlDeviceName = value)
}
}
)
}
)

View File

@ -431,6 +431,7 @@ fun AppExpandedPlayer(
val albumId = currentTrack?.album?.id val albumId = currentTrack?.album?.id
if (albumId != null) { if (albumId != null) {
navigationCommands.navigateTo(Routes.Album(albumId)) navigationCommands.navigateTo(Routes.Album(albumId))
onCollapse()
} }
onGoToAlbum() onGoToAlbum()
}) })
@ -535,6 +536,8 @@ fun AppExpandedPlayer(
artists = playerUiState.artistList, artists = playerUiState.artistList,
onArtistClick = { artistId -> onArtistClick = { artistId ->
navigationCommands.navigateTo(Routes.Artist(artistId)) navigationCommands.navigateTo(Routes.Artist(artistId))
// The page opens beneath the player, so get out of the way
onCollapse()
}, },
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,

Some files were not shown because too many files have changed in this diff Show More