Compare commits

...

33 Commits

Author SHA1 Message Date
sibylla canton
42b416fcd1 Fix iOS stream repository injection
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 09:07:13 +01:00
sibylla canton
1f228a2aa0 Fix Kotlin Native iOS media session APIs
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 08:46:28 +01:00
sibylla canton
d7b00d6230 Remove redundant iOS validation workflow
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 08:46:28 +01:00
sibylla canton
38a1a8ee83 Add iOS background playback support
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
2026-11-03 08:46:28 +01:00
Kingkor Roy Tirtho
784d9260d1
Merge pull request #3132 from wuqqers/fix/desugar-jdk-libs-nio
fix(android): use desugar_jdk_libs_nio so NewPipeExtractor works on minSdk 28
2026-11-03 13:43:44 +06:00
Kingkor Roy Tirtho
9a47a1af5f
Merge branch 'dev' into fix/desugar-jdk-libs-nio 2026-11-03 13:42:50 +06:00
Kingkor Roy Tirtho
431fd2353f
Merge pull request #3204 from team-spotube/fix/large-list-memory
fix: memory blow-ups and crashes with large lists and local media
2026-11-03 13:15:19 +06:00
Kingkor Roy Tirtho
0bf0e1816c
Merge pull request #3203 from team-spotube/fix/linux-string-lookup-segfault
fix(desktop): stop non-composable string lookups from querying the system theme
2026-11-02 15:26:26 +06:00
Kingkor Roy Tirtho
f23c66c0c5 build: raise the desktop heap ceiling to 1 GB
384 MB left little headroom for large libraries and long sessions. It is
a ceiling, not a reservation: the serial GC only grows the heap to what
is live.
2026-11-02 14:20:10 +06:00
Kingkor Roy Tirtho
8537718225 fix: stop memory blow-ups and crashes with large lists and local media
TrackList rendered every loaded track inside one LazyColumn item, so all
rows stayed composed and recomposed together; scrolling ~1350 saved
tracks exhausted the desktop heap (#3200). Rows are now separate lazy
items and the card is drawn once behind them. Only the current row
depends on the playing state, the saved tracks screen no longer reloads
from page 1 whenever a page arrives, cached tracks are streamed in 64 KB
chunks instead of read whole, and queue loading/restoring maps entries
in one pass instead of quadratically.

Desktop kept full-size embedded covers for local files and serialized
them as JSON number arrays into the local media index and the queue,
which is persisted on every change (#3193). Covers are now shrunk to
160 px like on Android, the queue no longer persists them (restore fills
them in from the index), and store files already bloated past 8 MB are
dropped instead of loaded.

Lists keyed on plugin ids crashed on blank or repeated ids (#3196); they
now go through uniqueLazyKeys. Queue persistence failures are logged
instead of crashing the app, a likely cause of #3198.

Fixes #3200
Fixes #3193
Fixes #3196
2026-11-02 13:37:05 +06:00
Kingkor Roy Tirtho
1b089a5fec fix(desktop): stop non-composable string lookups from querying the system theme
Compose Resources' getString/getPluralString/getStringArray call
getSystemResourceEnvironment() on every lookup, which on desktop asks
Skiko for the system theme. On Linux that is a blocking D-Bus call to the
desktop portal on the calling thread, and from a background coroutine it
segfaulted the GraalVM AppImage on Fedora GNOME when installing a plugin
from the Welcome screen.

Strings never depend on the theme, so app code now uses wrappers in
core/i18n that reuse the resource environment the UI already computed
(published by AppLocaleProvider), falling back to the library lookup
before anything is composed. A test guards against importing the library
getters again.

Fixes #3183
2026-11-02 10:17:35 +06:00
Kingkor Roy Tirtho
0541fd9d23
Merge pull request #3197 from team-spotube/feat/jam-room-server
feat(jam): synced Group Jam on a Durable Object room server
2026-11-02 09:31:20 +06:00
Kingkor Roy Tirtho
ac5971846b fix(jam): keep guests on the host's recording across song changes
- The host sends its source for the current song and the next two with
  every anchor, so guests switch to the same recording before the song
  starts. Guests drop the cached audio and stream URL for a re-pinned
  song and reload it.
- Pins are retried once the room's queue arrives, instead of being lost
  when a guest joins before the queue.
- A guest that finishes a song a little early waits for the host instead
  of jumping back to the old song, and guests don't start endless
  playback on their own.
- Position tracking restarts on every song change, ignoring stale
  reports from the previous song.
- The device queue keeps its queue and current-entry flows live all the
  time. With WhileSubscribed they went stale when nothing on screen
  watched them, and the stream proxy answered 404 for queued tracks.
- Simulation tests run on the real device queue, cover recording
  switches, early song ends, idle CPU use and a long soak session.
2026-10-31 23:03:10 +06:00
Kingkor Roy Tirtho
aaa4d66db1 refactor(jam): move the room server to its own repository
The jam room server now lives in https://github.com/team-spotube/jam-server.
Remove jam-server/ and its deploy workflow, and point code comments, test
instructions, AGENTS.md and the self-hosting note (all locales) at the new
repository.
2026-10-31 19:01:44 +06:00
Kingkor Roy Tirtho
45a7758eb0 feat(jam): harden the room server, add device bans and friendly names
Security:
- Participants connect with a per-room key derived from a random per-install
  secret; others only see an id derived from it, so nobody can reconnect as
  someone else (including the host).
- "create" only works on a fresh code or for the room's own host, so empty
  rooms can't be taken over.
- Per-socket message and queue budgets disconnect clients that flood; the
  host throttles itself well under them.
- Bans also cover the device: a hashed digest of device traits combined with
  the network, stored only as an opaque per-room value. Earlier members are
  never caught by someone else's ban.
- Peer queue items and suggestions are sanitized (streaming tracks only, no
  URLs, https covers); suggestions are credited to the server-attested sender;
  local file paths go out as opaque ids. Names are cleaned server-side.
- Room codes and keys use a secure random source; the deploy workflow is
  read-only.

Names:
- Participants get a stable generated name ("Mellow Otter") per install, and
  can pick their own on the Jam screen or in Jam settings. The participant
  list marks "(you)".

Also translate the Jam strings into all locales and add tests for the above.
2026-10-31 18:14:45 +06:00
Kingkor Roy Tirtho
0a0b0c9374 feat(jam): replace MQTT with a Durable Object room server and synced playback
- Add jam-server/: a Cloudflare Worker with one Durable Object per room code.
  It tracks membership, host handoff and kicks/bans, keeps the queue for late
  joiners, routes guest commands to the host and serves the room clock.
- Sync playback: devices estimate the server clock, the host sends position
  anchors in server time, guests follow by nudging playback rate (seeking only
  when far off) and stream the host's audio source when they share a plugin.
- Seek, play/pause, skip and jump apply to the whole room.
- Recover from network loss instead of crashing: socket failures reconnect
  with backoff, silent sockets are detected, and an expired room is recreated
  by its host.
- Keep cost and battery low: anchors only on change (plus host drift and a
  60 s heartbeat), kept in memory rather than storage, free runtime keep-alives
  once a minute, rare clock re-syncs, and no polling while paused or buffering.
- Remove the MQTT broker settings and libraries.
- Add sync unit tests, client/session integration tests (JAM_TEST_URL) and a
  network-loss test, plus a deploy workflow for the server.
2026-10-31 16:26:41 +06:00
Kingkor Roy Tirtho
b12e98a8c5 fix(flatpak): grant the Wayland socket so the webview doesn't render black 2026-10-30 22:43:39 +06:00
Kingkor Roy Tirtho
35700920b4 docs: improve formatting and clarity in the documentation 2026-10-30 16:10:00 +06:00
Kingkor Roy Tirtho
e04b3414e0 chore(assets): refresh screenshots, store graphics and Steam hero
- Add new desktop and iOS screenshots under assets/screenshots, named by screen, theme and form factor, and optimized with pngquant + oxipng
- Add feature graphics under assets/feature-graphics: Play Store feature graphic, README hero and mobile strip, and phone/desktop store shots
- Use them in the README, Flathub metainfo (plain screenshots) and F-Droid metadata (phone screenshots + featureGraphic)
- Replace the Steam library hero with one made from the new UI
- Remove the old desktop and mobile screenshots
2026-10-30 14:43:47 +06:00
Kingkor Roy Tirtho
9f4ce11509 fix(player): smooth the expanded player swipe
Read sheet progress in draw/derived scopes instead of composition so a
drag no longer recomposes the player every frame, keep the expanded
player, floating player and bottom bar composed once shown instead of
building them mid-drag, and settle with a spring that carries fling
velocity. Add a drag test for CompactPlayerOverlay.
2026-10-30 14:16:51 +06:00
Kingkor Roy Tirtho
bba725a139 fix(android): fully close the app on swipe-away and avoid duplicate cookie DataStore
Initialize NewPipeDownloader once per process so recreating the Activity
no longer opens a second DataStore on spotube_cookies.preferences_pb.
Kill the process after PlaybackService shuts down on task removal so the
proxy server, MQTT client and singletons don't linger in the background.
2026-10-30 13:35:39 +06:00
Kingkor Roy Tirtho
c058074cff fix(plugin): update default selected plugins to use built-in plugins with distinct abilities 2026-10-30 13:08:34 +06:00
Kingkor Roy Tirtho
2aaa0db81e fix(broker): update default broker settings and improve connection error handling 2026-10-30 11:50:39 +06:00
Kingkor Roy Tirtho
14bc279653 fix(ios): crashing on search screen 2026-10-30 11:10:25 +06:00
Kingkor Roy Tirtho
2c1148a126 chore: remove devil's name 2026-10-30 10:33:11 +06:00
Kingkor Roy Tirtho
fa9b42cf10 cd(build): add support for building WebView native libraries on Linux and Windows 2026-10-29 16:03:55 +06:00
Kingkor Roy Tirtho
8d4ea82a7e fix(build): add headless mode for Linux and update CI to prepare dependencies 2026-10-29 15:01:43 +06:00
Kingkor Roy Tirtho
eb972c8633 fix(dependencies): update Android target SDK and various library versions 2026-10-29 11:25:22 +06:00
Kingkor Roy Tirtho
aca3aeb592 fix(theme): follow the OS dark mode on desktop
Compose's isSystemInDarkTheme never updates under the Tao window, so Theme.SYSTEM
stayed light. Use Nucleus' darkmode-detector on JVM and keep Compose's check on mobile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:53:47 +06:00
Kingkor Roy Tirtho
5b36edd259 fix(windows): make VLC playback work through the stream proxy
The proxy copied every upstream header, duplicating Content-Length, which VLC
rejects. Forward only the range/validator headers. Bump vlcj-bundler to 0.2.1,
which bundles the Matroska demuxer needed for WebM. Make StreamingUrlRepository's
platform injectable and add VLC log switch via DebugConfig.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
ea01dec6ea fix(settings): palette swatches follow the active light/dark appearance
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 18:49:20 +06:00
Kingkor Roy Tirtho
79266fd483 fix(windows): window not spawning due to early COM initialization 2026-10-27 16:35:27 +06:00
Omer Kaya Ekici
ed3743bbea fix(android): use desugar_jdk_libs_nio so URLEncoder.encode(String, Charset) is backported on API < 33
The default desugar_jdk_libs artifact does not include the java.nio/charset backing APIs, so on API < 33 (minSdk 28) NewPipeExtractor's URLEncoder.encode(String, Charset) call throws NoSuchMethodError and the app crashes on launch. The _nio variant backports those APIs (2.1.5 is available for it and matches the AGP 8.13.2 requirement).

Refs #3130
2026-09-20 10:43:52 +03:00
183 changed files with 5688 additions and 1902 deletions

View File

@ -12,6 +12,19 @@ on:
- stable - stable
- beta - beta
- nightly - nightly
platform:
description: 'Platform to build (nightly only; stable and beta always build everything)'
required: true
type: choice
default: 'all'
options:
- all
- android
- linux-x86_64
- linux-aarch64
- windows
- macos
- ios
dry_run: dry_run:
description: 'Dry run (build & upload artifacts only, skip creating release)' description: 'Dry run (build & upload artifacts only, skip creating release)'
required: false required: false
@ -29,16 +42,94 @@ permissions:
# Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side # Never cancel a running release when another is dispatched, and let stable, beta and nightly run side by side
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }} group: ${{ github.workflow }}-${{ github.ref }}-${{ inputs.release_type }}-${{ inputs.platform }}
cancel-in-progress: false cancel-in-progress: false
env: env:
COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview COMPOSE_WEBVIEW_REPO: KRTirtho/ComposeNativeWebview
COMPOSE_WEBVIEW_COMMIT: cbd36f88927298a28a2d64c6acf7ec3b2def6f9d # fix/linux-wayland-input
COMPOSE_WEBVIEW_COMMIT: b11ac748de1219f9432338c067a2c5dafef3072a
jobs: jobs:
validate-inputs:
name: Validate Inputs
runs-on: ubuntu-latest
steps:
- name: Single platform builds are nightly only
if: ${{ inputs.platform != 'all' && inputs.release_type != 'nightly' }}
run: |
echo "::error::Building a single platform is only supported for nightly releases"
exit 1
# ComposeNativeWebview's JNI backends are gitignored and only built for the host OS, so a
# publish from macOS alone ships a jar without the Linux / Windows WebView libraries (the
# WebView then renders nothing). Build them on their own OS and hand them to prepare-deps.
build-webview-natives:
name: WebView Natives (${{ matrix.name }})
needs: validate-inputs
runs-on: ${{ matrix.os }}
strategy:
fail-fast: true
matrix:
include:
- name: linux-x64
os: ubuntu-latest
- name: linux-aarch64
os: ubuntu-24.04-arm
- name: windows-x64
os: windows-latest
steps:
- name: Checkout ComposeNativeWebview
uses: actions/checkout@v7
with:
repository: ${{ env.COMPOSE_WEBVIEW_REPO }}
ref: ${{ env.COMPOSE_WEBVIEW_COMMIT }}
token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview
- name: Set up JDK 21
uses: actions/setup-java@v6
with:
distribution: temurin
java-version: 21
- name: Build Linux WebView native library
if: runner.os == 'Linux'
working-directory: compose-webview
run: |
sudo apt-get update
sudo apt-get install -y build-essential pkg-config libgtk-3-dev libcairo2-dev \
libsoup-3.0-dev libwebkit2gtk-4.1-dev
bash webview-compose/src/jvmMain/native/linux/build.sh
- name: Build Windows WebView native library
if: runner.os == 'Windows'
working-directory: compose-webview
shell: cmd
run: webview-compose\src\jvmMain\native\windows\build.bat
- name: Verify WebView native library
working-directory: compose-webview
shell: bash
run: |
case "${{ matrix.name }}" in
linux-*) files="${{ matrix.name }}/libcompose_webview_linux.so" ;;
windows-x64) files="win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll" ;;
esac
for f in $files; do
test -f "webview-compose/src/jvmMain/resources/nucleus/native/$f" || { echo "::error::Missing $f"; exit 1; }
done
- name: Upload WebView native library
uses: actions/upload-artifact@v7
with:
name: webview-natives-${{ matrix.name }}
path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native/
retention-days: 1
prepare-deps: prepare-deps:
name: Prepare Dependencies name: Prepare Dependencies
needs: [validate-inputs, build-webview-natives]
runs-on: macos-latest runs-on: macos-latest
steps: steps:
- name: Checkout ComposeNativeWebview - name: Checkout ComposeNativeWebview
@ -49,6 +140,15 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
path: compose-webview path: compose-webview
# Downloaded after checkout, so they are newer than the native sources and
# the build keeps them instead of trying to rebuild on the wrong OS.
- name: Download WebView native libraries
uses: actions/download-artifact@v8
with:
pattern: webview-natives-*
merge-multiple: true
path: compose-webview/webview-compose/src/jvmMain/resources/nucleus/native
- name: Set up JDK 21 - name: Set up JDK 21
uses: actions/setup-java@v6 uses: actions/setup-java@v6
with: with:
@ -70,6 +170,16 @@ jobs:
chmod +x gradlew chmod +x gradlew
./gradlew publishToMavenLocal ./gradlew publishToMavenLocal
- name: Verify published JVM jar carries every WebView backend
run: |
jar=$(ls ~/.m2/repository/dev/nucleusframework/composewebview-jvm/*/composewebview-jvm-*.jar | grep -v -e sources -e javadoc | head -1)
contents=$(unzip -l "$jar")
for f in linux-x64/libcompose_webview_linux.so linux-aarch64/libcompose_webview_linux.so \
win32-x64/compose_webview_windows.dll win32-x64/WebView2Loader.dll \
darwin-aarch64/libcompose_webview_macos.dylib darwin-x64/libcompose_webview_macos.dylib; do
grep -q "nucleus/native/$f" <<< "$contents" || { echo "::error::$jar is missing $f"; exit 1; }
done
- name: Upload maven local - name: Upload maven local
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@ -78,7 +188,8 @@ jobs:
build-android: build-android:
name: Android name: Android
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'android' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -152,7 +263,8 @@ jobs:
build-linux: build-linux:
name: Linux Desktop name: Linux Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-x86_64' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
@ -161,17 +273,17 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: maven-local name: maven-local
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
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
with: with:
@ -265,7 +377,8 @@ jobs:
build-linux-arm64: build-linux-arm64:
name: Linux Desktop (aarch64) name: Linux Desktop (aarch64)
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'linux-aarch64' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: ubuntu-24.04-arm runs-on: ubuntu-24.04-arm
steps: steps:
@ -281,17 +394,17 @@ jobs:
with: with:
packages: tools platform-tools ndk;28.2.13676358 packages: tools platform-tools ndk;28.2.13676358
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y rpm fuse build-essential zlib1g-dev patchelf zstd
- name: Download maven local - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
name: maven-local name: maven-local
path: ~/.m2/repository/ path: ~/.m2/repository/
- name: Install packaging tools
run: |
sudo apt-get update
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
with: with:
@ -385,7 +498,8 @@ jobs:
build-windows: build-windows:
name: Windows Desktop name: Windows Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'windows' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: windows-latest runs-on: windows-latest
steps: steps:
@ -448,7 +562,8 @@ jobs:
build-macos: build-macos:
name: macOS Desktop name: macOS Desktop
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'macos' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: macos-latest runs-on: macos-latest
steps: steps:
@ -516,7 +631,8 @@ jobs:
build-ios: build-ios:
name: iOS name: iOS
needs: prepare-deps needs: [validate-inputs, prepare-deps]
if: ${{ inputs.platform == 'all' || inputs.platform == 'ios' }}
timeout-minutes: 120 timeout-minutes: 120
runs-on: macos-latest runs-on: macos-latest
steps: steps:
@ -585,8 +701,11 @@ jobs:
create-release: create-release:
name: Create GitHub Release name: Create GitHub Release
if: ${{ !inputs.dry_run }} # Skipped platform jobs count as skipped, not failed, so a single platform build still releases
if: ${{ !inputs.dry_run && !cancelled() && !contains(needs.*.result, 'failure') }}
needs: needs:
- validate-inputs
- prepare-deps
- build-android - build-android
- build-linux - build-linux
- build-linux-arm64 - build-linux-arm64
@ -599,7 +718,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
# The build resolves its plugins from mavenLocal, so Gradle needs the same setup as the build jobs
- name: Download maven local - name: Download maven local
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
@ -639,6 +757,7 @@ jobs:
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
- name: Generate release notes - name: Generate release notes
if: ${{ inputs.platform == 'all' }}
run: | run: |
echo "## Downloads" > release-notes.md echo "## Downloads" > release-notes.md
echo "" >> release-notes.md echo "" >> release-notes.md
@ -658,9 +777,12 @@ jobs:
tag_name: ${{ steps.vars.outputs.tag }} tag_name: ${{ steps.vars.outputs.tag }}
# A new tag (stable) would otherwise point at the default branch instead of the built commit # A new tag (stable) would otherwise point at the default branch instead of the built commit
target_commitish: ${{ github.sha }} target_commitish: ${{ github.sha }}
fail_on_unmatched_files: true # A single platform build only has its own artifacts
name: ${{ steps.vars.outputs.release_name }} fail_on_unmatched_files: ${{ inputs.platform == 'all' }}
body_path: release-notes.md # A single platform build only replaces that platform's assets (same file names are overwritten),
# so leave the existing release's name and notes alone
name: ${{ inputs.platform == 'all' && steps.vars.outputs.release_name || '' }}
body_path: ${{ inputs.platform == 'all' && 'release-notes.md' || '' }}
draft: ${{ steps.vars.outputs.is_draft }} draft: ${{ steps.vars.outputs.is_draft }}
prerelease: ${{ steps.vars.outputs.is_prerelease }} prerelease: ${{ steps.vars.outputs.is_prerelease }}
files: | files: |

View File

@ -26,6 +26,15 @@
- Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). - Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop).
- Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. - Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails.
## Group Jam
- Room server: separate repository, https://github.com/team-spotube/jam-server (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in its `src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes, and land both changes together).
- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off).
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See the jam-server README.
- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in the jam-server README.
- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`.
- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`).
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (run `npm run dev` in a jam-server checkout).
## GraalVM native image (desktop) ## GraalVM native image (desktop)
- Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`. - Enabled via `graalvm { }` inside `nucleus.application` in `composeApp/build.gradle.kts`. Nucleus downloads GraalVM CE itself (or uses `$GRAALVM_HOME`). Linux needs `gcc` + `patchelf`.
- Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM. - Build: `:composeApp:createGraalvmNativeDistributable` (output `composeApp/build/compose/binaries/main/graalvm-app/`), installers via `packageGraalvm{Deb,Rpm,AppImage,Dmg,Msi,Exe}`. A build takes ~10 min and uses all cores and ~15 GB RAM.
@ -45,6 +54,7 @@
## UI component patterns ## UI component patterns
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen. - **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
- Outside composables (ViewModels, services, snackbars), import `getString` / `getPluralString` / `getStringArray` from `org.spotube.spotube.core.i18n`, never from `org.jetbrains.compose.resources`. The library versions ask Skiko for the system theme on every call, which on Linux is a blocking D-Bus call that segfaulted the desktop app from background threads (#3183). `NonComposableStringLookupTest` enforces this.
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors. - **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain. - **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required. - **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.

View File

@ -290,8 +290,8 @@
- **desktop**: double titlebar in local library folders and massive space in - **desktop**: double titlebar in local library folders and massive space in
overlay player overlay player
- lastfm form broken in other locales #2447 - lastfm form broken in other locales #2447
- spotify login broken due to new totp requirement #2494 - Greedy Green Corp login broken due to new totp requirement #2494
- spotify authentication 429 errors - Greedy Green Corp authentication 429 errors
### Features ### Features
@ -326,7 +326,7 @@
- **android**: home widget support (#2148) - **android**: home widget support (#2148)
- rewrite entire app in shadcn-ui replacing material eww - rewrite entire app in shadcn-ui replacing material eww
- grid/list customizable playbutton view - grid/list customizable playbutton view
- flag to hide spotify generated images with patterns - flag to hide Greedy Green Corp generated images with patterns
- show placeholder images where there is no item or on empty page - show placeholder images where there is no item or on empty page
- pause playback when no internet connection - pause playback when no internet connection
- implement yt-dlp for desktop and NewPipeExtractor for Android (#2316) - implement yt-dlp for desktop and NewPipeExtractor for Android (#2316)
@ -552,7 +552,7 @@
[#1463](https://github.com/team-spotube/spotube/issues/1463) [#1463](https://github.com/team-spotube/spotube/issues/1463)
[#1505](https://github.com/team-spotube/spotube/issues/1505) [#1505](https://github.com/team-spotube/spotube/issues/1505)
([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211)) ([d2683c5](https://github.com/team-spotube/spotube/commit/d2683c52d81d807be6ff72f15b8e9eb18181e211))
- spotify friends and user profile icon (mobile) showing when not authenticated - Greedy Green Corp friends and user profile icon (mobile) showing when not authenticated
[#1410](https://github.com/team-spotube/spotube/issues/1410) [#1410](https://github.com/team-spotube/spotube/issues/1410)
([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33)) ([9bccbc9](https://github.com/team-spotube/spotube/commit/9bccbc93c63dd34f6e15ff68c276976ecd1d9a33))
- **updater:** dead link - **updater:** dead link
@ -576,7 +576,7 @@
### Features ### Features
- add Spotify homepage personalized recommendations - add Greedy Green Corp homepage personalized recommendations
([#1402](https://github.com/team-spotube/spotube/issues/1402)) ([#1402](https://github.com/team-spotube/spotube/issues/1402))
([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11)) ([9e25c74](https://github.com/team-spotube/spotube/commit/9e25c742d4e43e4e10d2b48afb8e6d90288ffa11))
- add user profile page - add user profile page
@ -684,7 +684,7 @@
- add create playlist button in add playlist dialog - add create playlist button in add playlist dialog
([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7)) ([2168a64](https://personal.github.com/team-spotube/spotube/commit/2168a640af3104a43139c303d78e2c2326a1bda7))
- add spotify friends activity - add Greedy Green Corp friends activity
([#1130](https://personal.github.com/team-spotube/spotube/issues/1130)) ([#1130](https://personal.github.com/team-spotube/spotube/issues/1130))
([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557)) ([7983932](https://personal.github.com/team-spotube/spotube/commit/79839329b0970acccb0c566a31eee508adbc8557))
- **deep-link:** add track opening page - **deep-link:** add track opening page
@ -1436,7 +1436,7 @@
([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d)) ([448c9b3](https://github.com/team-spotube/spotube/commit/448c9b39f407668ad92a695afe3c9741baeca20d))
- **macos:** crashing on startup - **macos:** crashing on startup
([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b)) ([c46b428](https://github.com/team-spotube/spotube/commit/c46b4284b1d46a614cbcebc8c2f2e52714921b9b))
- spotify query hooks overriding default query params - Greedy Green Corp query hooks overriding default query params
([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873)) ([ec9a02e](https://github.com/team-spotube/spotube/commit/ec9a02e8b8d988e15ed58027054d2a9090d98873))
## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07) ## [2.7.0](https://github.com/team-spotube/spotube/compare/v2.6.0...v2.7.0) (2023-03-07)
@ -1469,7 +1469,7 @@
([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95)) ([9080441](https://github.com/team-spotube/spotube/commit/9080441b875ceb91260bbad79291365a98d5be95))
- individual shuffle and repeat/loop button of player - individual shuffle and repeat/loop button of player
([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8)) ([f79223c](https://github.com/team-spotube/spotube/commit/f79223cd41c61d9836d25e7bc2811c6515ba00c8))
- **lyrics:** use official spotify API for fetching lyrics and add zoom controls - **lyrics:** use official Greedy Green Corp API for fetching lyrics and add zoom controls
([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b)) ([10d0660](https://github.com/team-spotube/spotube/commit/10d0660972f008df0d11c280b681ce3b78f05d0b))
- **mobile:** pull to refresh support in all refreshable list views - **mobile:** pull to refresh support in all refreshable list views
([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6)) ([9f959ce](https://github.com/team-spotube/spotube/commit/9f959ce77cd95cfc34d01af1f5cf53dd4206b6a6))
@ -1921,7 +1921,7 @@
- private playlists of current user aren't shown fix - private playlists of current user aren't shown fix
(https://github.com/team-spotube/spotube/issues/92) (https://github.com/team-spotube/spotube/issues/92)
- refresh token error causing re-login (culprit: internal lib spotify-dart) - refresh token error causing re-login (culprit: internal lib Greedy Green Corp-dart)
- Typo in Login instructions URL - Typo in Login instructions URL
# v2.1.0 # v2.1.0
@ -1937,7 +1937,7 @@
### Improved ### Improved
- Spotify track to youtube track algorithm - Greedy Green Corp track to youtube track algorithm
- Genius lyrics matching algorithm - Genius lyrics matching algorithm
- Download track. Checks if already exists & replaces on user command - Download track. Checks if already exists & replaces on user command
- Wide screen responsiveness & adaptation - Wide screen responsiveness & adaptation
@ -1950,7 +1950,7 @@
(https://github.com/team-spotube/spotube/issues/46) (https://github.com/team-spotube/spotube/issues/46)
- Theme modification state doesn't persist - Theme modification state doesn't persist
(https://github.com/team-spotube/spotube/issues/54) (https://github.com/team-spotube/spotube/issues/54)
- Wrong URI path for "Login with Spotify" tutorial - Wrong URI path for "Login with Greedy Green Corp" tutorial
(https://github.com/team-spotube/spotube/issues/69) (https://github.com/team-spotube/spotube/issues/69)
- Card shadow showing in the background of TitleBar & Searchbar - Card shadow showing in the background of TitleBar & Searchbar
@ -2124,17 +2124,17 @@
### Bug Fixes ### Bug Fixes
- `access_token not found` Error after OAuth Login with Spotify credentials - `access_token not found` Error after OAuth Login with Greedy Green Corp credentials
(used to need a restart of the app to load the access_token) (used to need a restart of the app to load the access_token)
- `access_token not found` Error after OAuth Login with Spotify credentials - `access_token not found` Error after OAuth Login with Greedy Green Corp credentials
(used to need a restart of the app to load the access_token) (used to need a restart of the app to load the access_token)
- Volume level wasn't cached even after changing volume - Volume level wasn't cached even after changing volume
# v0.0.1 # v0.0.1
Spotube v0.0.1 - initial release of the open source software for playing Spotify Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp
music using Youtube public API music using Youtube public API
Spotube v0.0.1 - initial release of the open source software for playing Spotify Spotube v0.0.1 - initial release of the open source software for playing Greedy Green Corp
music using Youtube public API music using Youtube public API
### New ### New
@ -2143,7 +2143,7 @@ music using Youtube public API
- Playback Queue - Playback Queue
- Save to Liked Tracks/Playlists - Save to Liked Tracks/Playlists
- Bypass API rate limitation on basic usage using personal developer Apps for - Bypass API rate limitation on basic usage using personal developer Apps for
spotify API Greedy Green Corp API
- Bypass API rate limitation on basic usage using personal developer Apps for - Bypass API rate limitation on basic usage using personal developer Apps for
spotify API Greedy Green Corp API
- Youtube search & get handled using scrape-yt - Youtube search & get handled using scrape-yt

211
README.md
View File

@ -2,7 +2,8 @@
<img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo"> <img width="600" src="assets/branding/spotube_banner.png" alt="Spotube Logo">
A cross-platform extensible open-source music streaming platform.<br> A cross-platform extensible open-source music streaming platform.<br>
Bring your own music metadata/playlist/audio-source with plugins created by community or by yourself. A small step towards the decentralized music streaming era! Bring your own music metadata/playlist/audio-source with plugins created by community or by
yourself. A small step towards the decentralized music streaming era!
<a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a> <a href="https://spotube.cc"><img alt="Visit the website" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/documentation/website_vector.svg"></a>
<a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a> <a href="https://discord.gg/uJ94vxB6vg"><img alt="Discord Server" height="56" src="https://cdn.jsdelivr.net/npm/@intergrav/devins-badges@3/assets/cozy/social/discord-plural_vector.svg"></a>
@ -16,17 +17,16 @@ Bring your own music metadata/playlist/audio-source with plugins created by comm
--- ---
![Spotube Desktop](assets/branding/spotube-screenshot.png) ![Spotube Desktop](assets/feature-graphics/readme-hero.png)
![Spotube Mobile](assets/feature-graphics/readme-mobile.png)
![Spotube Mobile](assets/branding/mobile-screenshots/combined.jpg) ****</div>
</div>
## 🌃 Features ## 🌃 Features
- 🧩 Plugin powered, supports any platform or custom music service through plugins. - 🧩 Plugin powered, supports any platform or custom music service through plugins.
- 🗺️ Community driven plugins for popular platforms or create your own. - 🗺️ Community driven plugins for popular platforms or create your own.
- ⬇️ Freely downloadable tracks with tagged metadata. - ⬇️ Freely downloadable tracks with tagged **metadata**.
- 🖥️ 📱 Cross-platform support. - 🖥️ 📱 Cross-platform support.
- 🪶 Small size & less data usage. - 🪶 Small size & less data usage.
- 🕒 Time synced lyrics regardless of the plugin support. - 🕒 Time synced lyrics regardless of the plugin support.
@ -41,132 +41,183 @@ Download Spotube from the Official Website: https://spotube.cc
### 🔄 Nightly Builds ### 🔄 Nightly Builds
Grab the latest nightly builds of Spotube [from the Offical Website](https://spotube.cc/downloads/nightly). Grab the latest nightly builds of
Spotube [from the Offical Website](https://spotube.cc/downloads/nightly).
## 🕳️ Building from source
<details>
<summary>
<h3 style="display: inline;">🕳️ Building from source [Click to expand]</h3>
</summary>
<hr/>
<a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a> <a href="https://github.com/team-spotube/spotube/actions"><img alt="GitHub Workflow Status" src="https://img.shields.io/github/actions/workflow/status/KRTirtho/spotube/spotube-release-binary.yml?+label=Build%20Status"></a>
Spotube is a Kotlin Multiplatform + Compose Multiplatform app. The desktop app is built with the [Nucleus](https://nucleusframework.dev) Gradle plugin and released as a **GraalVM native image**: an ahead-of-time compiled binary that needs no JVM installed. Spotube is a Compose Multiplatform app.
### Prerequisites ### 1. Prerequisites
- **JDK 21** to run Gradle. The Gradle daemon JDK and the GraalVM toolchain are provisioned automatically. - **JDK 21** to run Gradle (the Gradle daemon JDK and GraalVM are provisioned automatically).
- **Rust** (`rustup` + `cargo`). The `composeApp` Rust library is built through [Gobley](https://gobley.dev). - **Rust** (`rustup` + `cargo`), for the `composeApp` library built
through [Gobley](https://gobley.dev).
- **Android SDK**, only for Android builds. - **Android SDK**, only for Android builds.
- **Unpublished dependency.** One dependency isn't on Maven Central yet. Clone it and run `./gradlew publishToMavenLocal` in it: - **[ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview)**, which isn't on
- [kdroidFilter/ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview), at the commit pinned in [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) (`COMPOSE_WEBVIEW_COMMIT`) Maven Central yet. Clone it at the commit pinned as `COMPOSE_WEBVIEW_COMMIT` in [
- **Linux runtime libraries** (needed to run the app, including `:composeApp:run`). The `.deb`/`.rpm` install these as dependencies from your distro. For a source build, install them yourself, **including VLC**. Only the `.AppImage` is self-contained (it bundles VLC, GTK 3 and WebKitGTK, and uses just the host's glibc, graphics, X11 and sound libraries): `spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) and run
`./gradlew publishToMavenLocal` in it.
- **Linux only: runtime libraries, including VLC.** Needed to run the app, including
`:composeApp:run`:
| Distro | Command | | Distro | Command |
|---|---| | ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` | | Debian/Ubuntu | `sudo apt-get install libvlc5 vlc-plugin-base libwebkit2gtk-4.1-0 libgtk-3-0 libasound2 libgl1 libxtst6 libxi6 libxrender1` |
| Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender`. `vlc-libs` is only in [RPM Fusion](https://rpmfusion.org/Configuration), so enable it first. | | Fedora | `sudo dnf install vlc-libs webkit2gtk4.1 gtk3 alsa-lib mesa-libGL libXtst libXi libXrender` (`vlc-libs` needs [RPM Fusion](https://rpmfusion.org/Configuration)) |
| Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` | | Arch / CachyOS / Manjaro | `sudo pacman -S vlc webkit2gtk-4.1 gtk3 alsa-lib libglvnd libxtst libxi libxrender` |
| openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` (for wide codec support, switch to the [Packman](https://en.opensuse.org/Additional_package_repositories#Packman) repo's VLC) | | openSUSE | `sudo zypper install libvlc5 libwebkit2gtk-4_1-0 libgtk-3-0 libasound2 Mesa-libGL1 libXtst6 libXi6 libXrender1` |
Arch-based distros and openSUSE have no native package here. Use the `.AppImage`. The `.rpm` is built with Fedora package names, so it won't resolve on openSUSE. On Ubuntu 24.04+/Debian 13, `libgtk-3-0` and `libasound2` are `libgtk-3-0t64` and `libasound2t64`;
apt resolves this itself.
On Ubuntu 24.04+/Debian 13 `libgtk-3-0` and `libasound2` are named `libgtk-3-0t64` and `libasound2t64`; apt resolves this automatically. ### 2. Run in development
Always use the Gradle wrapper (`./gradlew`, or `gradlew.bat` on Windows).
### Development
Desktop (JVM), fastest edit/run loop
```bash ```bash
./gradlew :composeApp:run # desktop (JVM), fastest edit/run loop ./gradlew :composeApp:run
./gradlew :composeApp:assembleStableDebug # Android debug APK (or assembleNightlyDebug)
``` ```
Development builds stay fast. The release-only optimizations (the Rust release profile with LTO and symbol stripping, the native image, native-library pruning) are only applied when you build something to ship. See [Production vs development builds](#production-vs-development-builds). Android debug APK
```bash
./gradlew :composeApp:assembleStableDebug
```
> Use `./gradlew :composeApp:assembleNightlyDebug` for the nightly channel.
### Building the desktop app (GraalVM native image) Development builds stay fast: the release-only optimizations below are skipped.
#### Native toolchain ### 3. Build the desktop app
[Nucleus downloads and caches GraalVM CE](https://nucleusframework.dev/en/docs/performance/graalvm/) under `~/.gradle/nucleus/graalvm` on the first native build. Set `GRAALVM_HOME` to use your own GraalVM instead. Each OS also needs a native C toolchain: A native build takes **about 10 minutes**, uses **every CPU core** and peaks at **~15 GB of RAM**.
It only happens for the tasks below, never for `run`.
**Native toolchain** (Nucleus downloads GraalVM CE on the first build; set `GRAALVM_HOME` to use
your own):
| OS | Requirements | | OS | Requirements |
|---|---| | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Linux | `gcc`, `zlib` headers, `patchelf`; plus `rpm` for `.rpm` packages, `fuse` for `.AppImage`, and, for the AppImage's bundled libraries, `zstd` (plus `ar`/`tar` and `readelf`, from binutils). Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` (also install the [Linux runtime libraries](#prerequisites) to run what you build) | | Linux | `gcc`, `zlib` headers, `patchelf`; `rpm` for `.rpm`, `fuse` + `zstd` for `.AppImage`. <br/>Debian/Ubuntu: `sudo apt-get install build-essential zlib1g-dev patchelf rpm fuse zstd`. <br/>Fedora: `sudo dnf install gcc zlib-devel patchelf rpm-build fuse zstd` |
| macOS | Xcode Command Line Tools (`xcode-select --install`) | | macOS | Xcode Command Line Tools (`xcode-select --install`) |
| Windows | Visual Studio Build Tools with the "Desktop development with C++" workload. Build from an "x64 Native Tools" prompt if `native-image` can't find MSVC. | | Windows | Visual Studio Build Tools with "Desktop development with C++" (use an "x64 Native Tools" prompt if `native-image` can't find MSVC) |
A native build is a whole-program compile. It takes **about 10 minutes**, uses **every CPU core**, and peaks at roughly **15 GB of RAM** (it gets slower, not broken, with less). That only happens for the native tasks below, never for `run`. **Build and run the app folder:**
#### Build and run
```bash ```bash
# App folder: binary + its native libs, in composeApp/build/compose/binaries/main/graalvm-app/
./gradlew :composeApp:createGraalvmNativeDistributable ./gradlew :composeApp:createGraalvmNativeDistributable
```
> Output folder: composeApp/build/compose/binaries/main/graalvm-app/
# Run that folder's binary directly Build and launch in one step
./composeApp/build/compose/binaries/main/graalvm-app/org.spotube.spotube/spotube ```bash
# Or build and launch in one step
./gradlew :composeApp:runGraalvmNativeDistributable ./gradlew :composeApp:runGraalvmNativeDistributable
``` ```
Keep the `spotube` binary next to the `.so`/`.dll`/`lib/` files it was built with; it loads them from its own folder. Keep the `spotube` binary next to the libraries it was built with; it loads them from its own
folder.
#### Installers **Installers** (each OS builds only its own):
Each OS can only build its own installers: | OS | Command |
| ---------------- | ----------------------------------------------------------------------- |
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm` |
| Linux (AppImage) | `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` |
| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) |
| macOS | `./gradlew :composeApp:packageGraalvmDmg` |
> Output folder: `composeApp/build/compose/binaries/main/graalvm-<format>`
| OS | Command | Output | > [!Tip]
|---|---|---| > Add `-Pnightly=true` for the nightly channel (separate icons and name).
| Linux | `./gradlew :composeApp:packageGraalvmDeb :composeApp:packageGraalvmRpm`, then `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true` | `composeApp/build/compose/binaries/main/graalvm-{deb,rpm,appimage}/` |
| Windows | `./gradlew :composeApp:packageGraalvmExe` (or `packageGraalvmMsi`) | `composeApp/build/compose/binaries/main/graalvm-exe/` |
| macOS | `./gradlew :composeApp:packageGraalvmDmg` | `composeApp/build/compose/binaries/main/graalvm-dmg/` |
Add `-Pnightly=true` for the nightly channel (separate icons and name). <details>
<summary>How the Linux AppImage bundle works</summary>
The `.deb` and `.rpm` use your distro's shared libraries and declare them as dependencies, so the package manager installs them (on Fedora, `vlc-libs` needs RPM Fusion enabled). The `.AppImage` is the self-contained one: it bundles VLC, GTK 3 and WebKitGTK (see [What the AppImage bundles](#what-the-appimage-bundles)). - [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt)
downloads the Ubuntu 24.04 packages (audio-only VLC via
[vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin), GTK 3, WebKitGTK 4.1,
glib-networking), keeps only what GTK/WebKit reach and relocates the libraries with `patchelf`.
Needs `ar`, `tar`, `zstd`, `readelf`, `patchelf`.
- WebKitGTK has its helper-process path compiled in, so
[`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)
patches it to a relative path and moves to that directory at startup. It also sets
`GIO_EXTRA_MODULES` and `WEBKIT_DISABLE_DMABUF_RENDERER=1` (the bundled WebKitGTK 2.44 never
paints against a newer host Mesa).
- `main()` must call `GraalVmInitializer.initialize()` first: the VLC loader and the environment
setup read `compose.application.resources.dir`.
#### What the AppImage bundles </details>
Build it with `./gradlew :composeApp:packageGraalvmAppImage -PappImage=true`. Without `-PappImage=true` nothing is bundled and the app uses the system libraries, which is what the `.deb`/`.rpm` and `:composeApp:run` do. ### 4. Native build breaks, JVM works
The bundle follows the [AppImage exclude list](https://github.com/AppImageCommunity/pkg2appimage/blob/master/excludelist) and what Tauri apps ship. It carries VLC (audio-only, from the [vlcj-bundler](https://github.com/team-spotube/vlcj-bundler-gradle-plugin) plugin's `bundleVlc` option), GTK 3, WebKitGTK 4.1, GLib/GIO, pango/cairo and their dependencies, plus glib-networking for HTTPS in the webview. It leaves the host's glibc, `libstdc++`, `libz`, GL/EGL/DRM, X11/xcb, ALSA, fontconfig/freetype/harfbuzz and the Wayland libraries alone, so the host's graphics, display and sound stack is used. Everything is taken from the Ubuntu 24.04 archive, so the AppImage needs glibc 2.38 or newer. A native image is a closed world: everything reached through reflection, JNI or JNA (vlcj, Zipline,
NewPipe, ktor, datastore, ...) must be declared at build time. Nucleus generates most of it; the
rest lives in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json).
How it works, in short: Symptoms: `ClassNotFoundException`, `NoSuchMethodException` or `MissingReflectionRegistrationError`
- [`DownloadLinuxGtkBundleTask`](buildSrc/src/main/kotlin/org/spotube/gradle/DownloadLinuxGtkBundleTask.kt) downloads the packages, keeps the libraries reachable from GTK/WebKit and relocates them with `patchelf`. Needs `ar`, `tar`, `zstd`, `readelf` and `patchelf`. in the native binary, while `:composeApp:run` works.
- WebKitGTK has its helper-process path compiled in, so the library is patched to look relative to the working directory, and at startup the app moves there ([`AppImageEnvironment`](composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/appimage/AppImageEnvironment.kt)). It also points `GIO_EXTRA_MODULES` at the bundled TLS module and sets `WEBKIT_DISABLE_DMABUF_RENDERER=1`, because the bundled WebKitGTK 2.44's dmabuf renderer never paints (or aborts) against a newer host Mesa/compositor. The display backend is left alone: forcing X11 doesn't help, since the webview doesn't paint on XWayland either.
- Startup order matters: `main()` calls `GraalVmInitializer.initialize()` first, since the VLC loader and the environment setup read `compose.application.resources.dir`.
#### Reflection metadata (when a feature breaks only in the native build) 1. `./gradlew :composeApp:runWithNativeAgent` starts the app on the JVM with GraalVM's tracing agent.
2. Use the broken feature, then **close the window normally**. Ctrl+C discards the recording.
3. Commit the updated JSON in `composeApp/graalvm/` and rebuild.
A native image is a closed world: anything the app reaches through reflection, JNI or JNA (vlcj, the Zipline/QuickJS plugin engine, NewPipe, ktor, datastore, …) must be declared at build time. Nucleus generates most of this automatically. The rest is in [`composeApp/graalvm/reachability-metadata.json`](composeApp/graalvm/reachability-metadata.json). > [!Tip]
> `./gradlew :composeApp:runGraalvmNative` is a faster debug loop: a quick `-Ob` build that names the
> missing registration.
If something works in `:composeApp:run` but throws `ClassNotFoundException`, `NoSuchMethodException`, `MissingReflectionRegistrationError` or similar in the native binary, record what's missing: > [!Warning]
> Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry, or playback fails to initialize.
```bash ### 5. Production vs development builds
./gradlew :composeApp:runWithNativeAgent
```
The app starts on the JVM with GraalVM's tracing agent attached. Use the broken feature (and anything near it), then **close the window normally**: the recorded entries are merged into `composeApp/graalvm/` only on a clean exit, not with Ctrl+C. Commit the updated JSON and rebuild the native image. Production mode turns on automatically for `package*`, `create*`, `notarize*` and
`nativeImageCompile` tasks. Force it with `-Pproduction=true|false`.
`./gradlew :composeApp:runGraalvmNative` gives a faster debugging loop: a quick-build (`-Ob`) native image that reports exactly which registration is missing. | Area | Development | Production |
| ----------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------------ |
| Rust lib | fast `debug` profile | `release` with LTO and stripping (31 MB → 1.8 MB) |
| Desktop app | hot-reloadable JVM app | size-optimized (`-Os`) GraalVM native image |
| Native libs | all | host OS/CPU only; Skia loaded from `lib/`; on Linux ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's 28 MB one |
Keep the hand-written `uk.co.caprica.vlcj.binding.lib.LibVlc` entry. JNA's direct mapping looks up its `native` methods through reflection, and without the entry, playback fails to initialize. Android picks the Rust profile from its build type.
### Production vs development builds ### 6. Releases
Production mode is switched on automatically when you ask Gradle for a shippable artifact: any `package*`, `create*`, `notarize*` or `nativeImageCompile` task. You can force it either way with `-Pproduction=true` or `-Pproduction=false`. Run [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml) manually
(`workflow_dispatch`) as `stable`, `beta` or `nightly`.
| | Development (`run`, debug builds) | Production (installers, native image) | - **Beta:** the stable build as a draft, tagged `v<version>-beta.N`. Set `betaNumber` in
|---|---|---| `composeApp/build.gradle.kts` to N, and back to `null` for stable.
| Rust library (desktop) | `debug` profile, fast incremental compile | `release` profile with LTO, single codegen unit, stripped (31 MB → 1.8 MB) | - **Desktop:** each platform (Linux x86_64 and aarch64, Windows, macOS) builds its installers on its
| Desktop app | JVM, hot-reloadable | GraalVM native image, `-Os` (size-optimized) | own runner.
| Native libraries | everything on the classpath | only the host OS/CPU's natives; other OSes' and CPUs' libraries are pruned from the uber JAR the image is compiled from | - **Android:** a signed APK.
| Skia (Skiko) | extracted from the JAR | loaded from `lib/` next to the binary, not embedded a second time |
| JDK runtime libs (Linux) | n/a | native-image's ~10 KB `libjvm.so`/`libjava.so` shims instead of the JDK's (28 MB `libjvm.so`) |
Android picks the Rust profile from its build type (`debug` or `release`) on its own. </details>
<hr/>
### Releases ## 📚 Resources
Releases are built by [`spotube-release-binary.yml`](.github/workflows/spotube-release-binary.yml), run manually (`workflow_dispatch`) as `stable`, `beta` or `nightly`. A beta is the stable build released like a stable release (draft, not a pre-release), tagged `v<version>-beta.N` (set `betaNumber` in `composeApp/build.gradle.kts` to N, and back to `null` for stable). Each desktop platform (Linux x86_64 and aarch64, Windows, macOS) builds its native installers on its own runner. Android builds a signed APK. **Core stack**
- [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html)
- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/)
- [Compose Hot Reload](https://github.com/JetBrains/compose-hot-reload)
- [Koin for KMP](https://insert-koin.io/docs/reference/koin-mp/kmp)
- [Ktor client](https://ktor.io/docs/client-create-multiplatform-application.html)
- [DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore)
**Desktop and native**
- [Nucleus](https://github.com/kdroidFilter/Nucleus): desktop packaging and GraalVM integration
- [ComposeNativeWebview](https://github.com/kdroidFilter/ComposeNativeWebview)
- [GraalVM Native Image](https://www.graalvm.org/latest/reference-manual/native-image/) and its
[reachability metadata](https://www.graalvm.org/latest/reference-manual/native-image/metadata/)
- [Gobley](https://gobley.dev): Rust in Kotlin Multiplatform
- [vlcj](https://github.com/caprica/vlcj): VLC bindings for audio playback
**Plugins**
- [Zipline](https://github.com/cashapp/zipline): runs the JS plugins
## 👥 The Spotube team ## 👥 The Spotube team

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

After

Width:  |  Height:  |  Size: 828 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 771 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 611 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 210 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 960 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 429 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 431 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 432 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 564 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 897 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 538 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 533 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

View File

@ -289,12 +289,6 @@ kotlin {
// DLNA // DLNA
implementation(libs.dns.sd.kt) implementation(libs.dns.sd.kt)
// mqtt client for jam-session
implementation(libs.mqtt.client)
implementation(libs.mqtt.x.models)
implementation(libs.mqtt.buffer)
implementation(libs.mqtt.buffer.codec)
// Markdown renderer // Markdown renderer
implementation(libs.multiplatform.markdown.renderer) implementation(libs.multiplatform.markdown.renderer)
implementation(libs.multiplatform.markdown.renderer.m3) implementation(libs.multiplatform.markdown.renderer.m3)
@ -366,6 +360,7 @@ kotlin {
implementation(libs.nucleus.decorated.window.tao) implementation(libs.nucleus.decorated.window.tao)
implementation(libs.compose.native.tray) implementation(libs.compose.native.tray)
implementation(libs.nucleus.media.control) implementation(libs.nucleus.media.control)
implementation(libs.nucleus.darkmode.detector)
} }
} }
} }
@ -442,14 +437,6 @@ android {
} }
// ditchoom's socket-quic-quiche ships libquiche_jni.so with 4 KB LOAD segment alignment
// (not 16 KB compatible), even in the latest release. It is only an optional QUIC transport
// pulled in transitively by mqtt-client, so drop it on Android until upstream fixes it.
configurations.matching { it.name.startsWith("debug") || it.name.startsWith("release") }.configureEach {
exclude(group = "com.ditchoom", module = "socket-quic-quiche")
exclude(group = "com.ditchoom", module = "socket-quic-quiche-android")
}
dependencies { dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs) coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling) debugImplementation(libs.compose.uiTooling)
@ -462,7 +449,7 @@ nucleus.application {
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work "--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
// --- ADD THESE FOR SPEED & LOW RAM --- // --- ADD THESE FOR SPEED & LOW RAM ---
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront) "-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room) "-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup! "-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB "-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
) )
@ -569,7 +556,7 @@ nucleus.application {
// ProGuard is not used for native images; -Os is the size equivalent // ProGuard is not used for native images; -Os is the size equivalent
optimization = NativeImageOptimization.SIZE optimization = NativeImageOptimization.SIZE
// Same ceiling as the JVM distribution's -Xmx // Same ceiling as the JVM distribution's -Xmx
maxHeapSize = "384m" maxHeapSize = "1g"
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed // Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
// a second ~30 MB copy as a resource // a second ~30 MB copy as a resource
buildArgs.add("-H:+UnlockExperimentalVMOptions") buildArgs.add("-H:+UnlockExperimentalVMOptions")
@ -645,6 +632,12 @@ tasks.withType<ComposeHotRun>().configureEach {
jvmArgs( jvmArgs(
"--enable-native-access=ALL-UNNAMED" "--enable-native-access=ALL-UNNAMED"
) )
// Linux: the hot-reload agent starts AWT before main(), and JBR's X11 toolkit pins GTK to
// XWayland, where the embedded WebKit WebView renders black. Tao owns the windows, so the
// reloadable JVM stays headless (DevTools runs in its own JVM and is unaffected).
if (System.getProperty("os.name").lowercase().contains("linux")) {
jvmArgs("-Djava.awt.headless=true")
}
systemProperty( systemProperty(
"compose.application.resources.dir", "compose.application.resources.dir",
project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get() project.layout.buildDirectory.dir("compose/tmp/prepareAppResources").get()

View File

@ -82,10 +82,16 @@ class NewPipeDownloader(private val cookieJar: PersistentCookieJar) : Downloader
companion object { companion object {
private var instance: NewPipeDownloader? = null private var instance: NewPipeDownloader? = null
/**
* Safe to call on every Activity creation: the process (and so this singleton) outlives
* the Activity, and a second [PersistentCookieJar] would open a second DataStore on the
* same cookies file, which DataStore rejects.
*/
@Synchronized
fun init(paths: Paths) { fun init(paths: Paths) {
if (instance != null) return
val cookieJar = PersistentCookieJar(paths) val cookieJar = PersistentCookieJar(paths)
instance = NewPipeDownloader(cookieJar) instance = NewPipeDownloader(cookieJar).also { NewPipe.init(it) }
NewPipe.init(instance)
} }
} }
} }

View File

@ -0,0 +1,37 @@
/*
* 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.jam
import android.content.res.Resources
import android.os.Build
import java.util.Locale
import java.util.TimeZone
internal actual fun jamDeviceTraits(): List<String> {
val display = Resources.getSystem().displayMetrics
return listOf(
"android",
Build.MANUFACTURER,
Build.MODEL,
Build.DEVICE,
Build.VERSION.RELEASE,
Build.VERSION.SDK_INT.toString(),
"${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}",
Locale.getDefault().toLanguageTag(),
TimeZone.getDefault().id)
}

View File

@ -41,7 +41,7 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository
import org.spotube.spotube.modules.search.SearchRepository import org.spotube.spotube.modules.search.SearchRepository
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_tab_albums import spotube.composeapp.generated.resources.library_tab_albums
import spotube.composeapp.generated.resources.library_tab_artists import spotube.composeapp.generated.resources.library_tab_artists

View File

@ -23,6 +23,7 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.Process
import android.util.Log import android.util.Log
import androidx.annotation.OptIn import androidx.annotation.OptIn
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
@ -54,13 +55,15 @@ import kotlinx.coroutines.launch
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
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.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
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class PlaybackService : MediaLibraryService(), KoinComponent { class PlaybackService : MediaLibraryService(), KoinComponent {
private var taskRemoved = false
private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer private val audioPlayer = inject<AudioPlayerInterface>().value as AudioPlayer
private val audioPlayerQueue: AudioPlayerQueue by inject() private val audioPlayerQueue: AudioPlayerQueue by inject()
@ -161,9 +164,13 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
removeSession(librarySession) removeSession(librarySession)
librarySession.release() librarySession.release()
super.onDestroy() super.onDestroy()
// Swiping the app out of recents should fully close it: Android otherwise keeps this
// process cached with the proxy server, jam client and every singleton still alive.
if (taskRemoved) Process.killProcess(Process.myPid())
} }
override fun onTaskRemoved(rootIntent: Intent?) { override fun onTaskRemoved(rootIntent: Intent?) {
taskRemoved = true
pauseAllPlayersAndStopSelf() pauseAllPlayersAndStopSelf()
} }

View File

@ -308,25 +308,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">عرض محتوى الويب داخل التطبيق</string> <string name="plugin_permissions_capability_webview_desc">عرض محتوى الويب داخل التطبيق</string>
<string name="settings_section_jam">الجام الجماعي</string> <string name="settings_section_jam">الجام الجماعي</string>
<string name="settings_jam_broker_title">وسيط MQTT</string> <string name="settings_jam_server_test">اختبار الاتصال</string>
<string name="settings_jam_broker_host">مضيف الوسيط</string> <string name="settings_jam_server_testing">جارٍ الاختبار…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">تم الاتصال خلال %1$d ms</string>
<string name="settings_jam_broker_port">منفذ الوسيط</string> <string name="settings_jam_server_test_fail">فشل: %1$s</string>
<string name="settings_jam_broker_tls">استخدام TLS</string>
<string name="settings_jam_broker_username">اسم المستخدم (اختياري)</string>
<string name="settings_jam_broker_password">كلمة المرور (اختياري)</string>
<string name="settings_jam_broker_client_id">بادئة معرّف العميل</string>
<string name="settings_jam_broker_client_id_subtitle">تُستخدم لتعريف نسخة Spotube هذه</string>
<string name="settings_jam_broker_tls_subtitle">تشفير الاتصال بالوسيط</string>
<string name="settings_jam_broker_diagnostics_subtitle">تحقق من إعدادات الوسيط الحالية قبل بدء غرفة.</string>
<string name="settings_jam_broker_test">اختبار الاتصال</string>
<string name="settings_jam_broker_testing">جارٍ الاختبار…</string>
<string name="settings_jam_broker_test_ok">تم الاتصال خلال %1$d ms</string>
<string name="settings_jam_broker_test_fail">فشل: %1$s</string>
<string name="settings_jam_broker_placeholder_note">وسيط مؤقت — اضبط وسيطك الخاص للاستضافة الذاتية</string>
<string name="settings_jam_section_connection">الاتصال</string> <string name="settings_jam_section_connection">الاتصال</string>
<string name="settings_jam_section_credentials">بيانات الاعتماد</string>
<string name="settings_jam_section_identity">الهوية</string>
<string name="settings_jam_section_diagnostics">التشخيص</string> <string name="settings_jam_section_diagnostics">التشخيص</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -339,9 +325,6 @@
<string name="jam_setup_working">جارٍ الاتصال…</string> <string name="jam_setup_working">جارٍ الاتصال…</string>
<string name="jam_setup_code_label">رمز الغرفة</string> <string name="jam_setup_code_label">رمز الغرفة</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">الوسيط: %1$s</string>
<string name="jam_connection_missing">اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها.</string>
<string name="jam_connection_settings">إعدادات الوسيط</string>
<string name="jam_session_connected">متصل</string> <string name="jam_session_connected">متصل</string>
<string name="jam_session_connecting">جارٍ إعادة الاتصال…</string> <string name="jam_session_connecting">جارٍ إعادة الاتصال…</string>
<string name="jam_invite_copy">نسخ الرمز</string> <string name="jam_invite_copy">نسخ الرمز</string>
@ -361,6 +344,28 @@
<string name="jam_error_join">تعذّر الانضمام إلى جلسة الجام</string> <string name="jam_error_join">تعذّر الانضمام إلى جلسة الجام</string>
<string name="jam_error_invalid_code">أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.</string> <string name="jam_error_invalid_code">أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.</string>
<string name="jam_share_message">انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s</string> <string name="jam_share_message">انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s</string>
<string name="settings_jam_server_title">خادم الغرف</string>
<string name="settings_jam_server_url">رابط الخادم</string>
<string name="settings_jam_server_url_invalid">أدخل رابطًا يبدأ بـ https://</string>
<string name="settings_jam_server_diagnostics_subtitle">تحقّق من خادم الغرف قبل بدء غرفة.</string>
<string name="settings_jam_server_note">يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر الخادم من github.com/team-spotube/jam-server وأدخل رابطه.</string>
<string name="settings_jam_section_you">أنت</string>
<string name="jam_connection_host">الخادم: %1$s</string>
<string name="jam_connection_missing">حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها.</string>
<string name="jam_connection_settings">إعدادات الخادم</string>
<string name="jam_participants_you">%1$s (أنت)</string>
<string name="jam_name_title">اسمك</string>
<string name="jam_name_subtitle">يراك الآخرون في الجام باسم %1$s</string>
<string name="jam_name_description">يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها.</string>
<string name="jam_name_too_long">استخدم %1$d حرفًا كحد أقصى.</string>
<string name="jam_error_no_server">لم يتم إعداد خادم للجام</string>
<string name="jam_error_server_unreachable">تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي.</string>
<string name="jam_error_server_incompatible">يعمل خادم الجام بإصدار غير متوافق.</string>
<string name="jam_error_room_not_found">لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا.</string>
<string name="jam_error_room_full">هذه الغرفة ممتلئة.</string>
<string name="jam_error_banned">حظرك المضيف من هذه الغرفة.</string>
<string name="jam_error_kicked">أزالك المضيف من الغرفة.</string>
<string name="jam_error_replaced">فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال.</string>
<string name="player_go_to_album">الانتقال إلى الألبوم</string> <string name="player_go_to_album">الانتقال إلى الألبوم</string>
<string name="player_sleep_timer">مؤقت النوم</string> <string name="player_sleep_timer">مؤقت النوم</string>
@ -402,7 +407,7 @@
<string name="welcome_guide_title">كيفية استخدام Spotube</string> <string name="welcome_guide_title">كيفية استخدام Spotube</string>
<string name="welcome_guide_subtitle">أربع خطوات سريعة وتبدأ الاستماع.</string> <string name="welcome_guide_subtitle">أربع خطوات سريعة وتبدأ الاستماع.</string>
<string name="welcome_guide_signin_title">تسجيل الدخول</string> <string name="welcome_guide_signin_title">تسجيل الدخول</string>
<string name="welcome_guide_signin_desc">اربط حسابك على Spotify، أو اختر إضافة الكتالوج الخاصة بك.</string> <string name="welcome_guide_signin_desc">اربط حسابك، أو اختر إضافة الكتالوج الخاصة بك.</string>
<string name="welcome_guide_search_title">ابحث وشغّل</string> <string name="welcome_guide_search_title">ابحث وشغّل</string>
<string name="welcome_guide_search_desc">اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل.</string> <string name="welcome_guide_search_desc">اعثر على أي أغنية أو ألبوم أو فنان، ثم اضغط تشغيل.</string>
<string name="welcome_guide_offline_title">استمع بلا إنترنت</string> <string name="welcome_guide_offline_title">استمع بلا إنترنت</string>
@ -533,14 +538,9 @@
<string name="jam_destination_suggest">اقتراح للجام</string> <string name="jam_destination_suggest">اقتراح للجام</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">لم يتم ضبط وسيط للجام</string>
<string name="jam_error_broker_host_empty">مضيف الوسيط فارغ</string>
<string name="jam_error_connection_failed">فشل الاتصال</string> <string name="jam_error_connection_failed">فشل الاتصال</string>
<string name="jam_error_connection_lost">انقطع الاتصال</string> <string name="jam_error_connection_lost">انقطع الاتصال</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">الوسيط</string>
<string name="settings_jam_broker_username_placeholder">مجهول</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">تمت الإضافة إلى قائمة انتظار الجام</string> <string name="jam_queue_added_track">تمت الإضافة إلى قائمة انتظار الجام</string>

View File

@ -292,25 +292,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো</string> <string name="plugin_permissions_capability_webview_desc">অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো</string>
<string name="settings_section_jam">গ্রুপ জ্যাম</string> <string name="settings_section_jam">গ্রুপ জ্যাম</string>
<string name="settings_jam_broker_title">MQTT ব্রোকার</string> <string name="settings_jam_server_test">কানেকশন পরীক্ষা করুন</string>
<string name="settings_jam_broker_host">ব্রোকার হোস্ট</string> <string name="settings_jam_server_testing">পরীক্ষা চলছে…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">%1$d ms-এ কানেক্ট হয়েছে</string>
<string name="settings_jam_broker_port">ব্রোকার পোর্ট</string> <string name="settings_jam_server_test_fail">ব্যর্থ: %1$s</string>
<string name="settings_jam_broker_tls">TLS ব্যবহার করুন</string>
<string name="settings_jam_broker_username">ইউজারনেম (ঐচ্ছিক)</string>
<string name="settings_jam_broker_password">পাসওয়ার্ড (ঐচ্ছিক)</string>
<string name="settings_jam_broker_client_id">ক্লায়েন্ট ID প্রিফিক্স</string>
<string name="settings_jam_broker_client_id_subtitle">এই Spotube শনাক্ত করতে ব্যবহৃত হয়</string>
<string name="settings_jam_broker_tls_subtitle">ব্রোকার কানেকশন এনক্রিপ্ট করুন</string>
<string name="settings_jam_broker_diagnostics_subtitle">রুম শুরু করার আগে বর্তমান ব্রোকার সেটিংস যাচাই করুন।</string>
<string name="settings_jam_broker_test">কানেকশন পরীক্ষা করুন</string>
<string name="settings_jam_broker_testing">পরীক্ষা চলছে…</string>
<string name="settings_jam_broker_test_ok">%1$d ms-এ কানেক্ট হয়েছে</string>
<string name="settings_jam_broker_test_fail">ব্যর্থ: %1$s</string>
<string name="settings_jam_broker_placeholder_note">প্লেসহোল্ডার ব্রোকার — সেলফ-হোস্ট করতে নিজেরটি কনফিগার করুন</string>
<string name="settings_jam_section_connection">কানেকশন</string> <string name="settings_jam_section_connection">কানেকশন</string>
<string name="settings_jam_section_credentials">ক্রেডেনশিয়াল</string>
<string name="settings_jam_section_identity">পরিচিতি</string>
<string name="settings_jam_section_diagnostics">ডায়াগনস্টিকস</string> <string name="settings_jam_section_diagnostics">ডায়াগনস্টিকস</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">কানেক্ট হচ্ছে…</string> <string name="jam_setup_working">কানেক্ট হচ্ছে…</string>
<string name="jam_setup_code_label">রুম কোড</string> <string name="jam_setup_code_label">রুম কোড</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">ব্রোকার: %1$s</string>
<string name="jam_connection_missing">রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন।</string>
<string name="jam_connection_settings">ব্রোকার সেটিংস</string>
<string name="jam_session_connected">কানেক্টেড</string> <string name="jam_session_connected">কানেক্টেড</string>
<string name="jam_session_connecting">আবার কানেক্ট হচ্ছে…</string> <string name="jam_session_connecting">আবার কানেক্ট হচ্ছে…</string>
<string name="jam_invite_copy">কোড কপি করুন</string> <string name="jam_invite_copy">কোড কপি করুন</string>
@ -345,6 +328,28 @@
<string name="jam_error_join">জ্যাম সেশনে যোগ দেওয়া যায়নি</string> <string name="jam_error_join">জ্যাম সেশনে যোগ দেওয়া যায়নি</string>
<string name="jam_error_invalid_code">অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।</string> <string name="jam_error_invalid_code">অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।</string>
<string name="jam_share_message">এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s</string> <string name="jam_share_message">এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s</string>
<string name="settings_jam_server_title">রুম সার্ভার</string>
<string name="settings_jam_server_url">সার্ভার URL</string>
<string name="settings_jam_server_url_invalid">https:// দিয়ে শুরু হওয়া একটি URL দিন</string>
<string name="settings_jam_server_diagnostics_subtitle">রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন।</string>
<string name="settings_jam_server_note">জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে github.com/team-spotube/jam-server থেকে সার্ভার ডিপ্লয় করে তার URL দিন।</string>
<string name="settings_jam_section_you">আপনি</string>
<string name="jam_connection_host">সার্ভার: %1$s</string>
<string name="jam_connection_missing">রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন।</string>
<string name="jam_connection_settings">সার্ভার সেটিংস</string>
<string name="jam_participants_you">%1$s (আপনি)</string>
<string name="jam_name_title">আপনার নাম</string>
<string name="jam_name_subtitle">জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে</string>
<string name="jam_name_description">জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে।</string>
<string name="jam_name_too_long">সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন।</string>
<string name="jam_error_no_server">কোনো জ্যাম সার্ভার সেট করা নেই</string>
<string name="jam_error_server_unreachable">জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন।</string>
<string name="jam_error_server_incompatible">জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়।</string>
<string name="jam_error_room_not_found">এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন।</string>
<string name="jam_error_room_full">এই রুমটি পূর্ণ।</string>
<string name="jam_error_banned">হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন।</string>
<string name="jam_error_kicked">হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন।</string>
<string name="jam_error_replaced">এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে।</string>
<string name="player_go_to_album">অ্যালবামে যান</string> <string name="player_go_to_album">অ্যালবামে যান</string>
<string name="player_sleep_timer">স্লিপ টাইমার</string> <string name="player_sleep_timer">স্লিপ টাইমার</string>
@ -386,7 +391,7 @@
<string name="welcome_guide_title">Spotube যেভাবে ব্যবহার করবেন</string> <string name="welcome_guide_title">Spotube যেভাবে ব্যবহার করবেন</string>
<string name="welcome_guide_subtitle">চারটি সহজ ধাপ, তারপরই গান শুনুন।</string> <string name="welcome_guide_subtitle">চারটি সহজ ধাপ, তারপরই গান শুনুন।</string>
<string name="welcome_guide_signin_title">সাইন ইন</string> <string name="welcome_guide_signin_title">সাইন ইন</string>
<string name="welcome_guide_signin_desc">আপনার Spotify অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন।</string> <string name="welcome_guide_signin_desc">আপনার অ্যাকাউন্ট কানেক্ট করুন, অথবা নিজের পছন্দের ক্যাটালগ প্লাগইন বেছে নিন।</string>
<string name="welcome_guide_search_title">খুঁজুন ও প্লে করুন</string> <string name="welcome_guide_search_title">খুঁজুন ও প্লে করুন</string>
<string name="welcome_guide_search_desc">যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন।</string> <string name="welcome_guide_search_desc">যেকোনো গান, অ্যালবাম বা শিল্পী খুঁজুন, তারপর প্লে চাপুন।</string>
<string name="welcome_guide_offline_title">অফলাইনে শুনুন</string> <string name="welcome_guide_offline_title">অফলাইনে শুনুন</string>
@ -517,14 +522,9 @@
<string name="jam_destination_suggest">জ্যামে সাজেস্ট করুন</string> <string name="jam_destination_suggest">জ্যামে সাজেস্ট করুন</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">কোনো জ্যাম ব্রোকার কনফিগার করা নেই</string>
<string name="jam_error_broker_host_empty">ব্রোকার হোস্ট ফাঁকা</string>
<string name="jam_error_connection_failed">কানেকশন ব্যর্থ হয়েছে</string> <string name="jam_error_connection_failed">কানেকশন ব্যর্থ হয়েছে</string>
<string name="jam_error_connection_lost">কানেকশন বিচ্ছিন্ন হয়েছে</string> <string name="jam_error_connection_lost">কানেকশন বিচ্ছিন্ন হয়েছে</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">ব্রোকার</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">জ্যাম কিউতে যোগ করা হয়েছে</string> <string name="jam_queue_added_track">জ্যাম কিউতে যোগ করা হয়েছে</string>

View File

@ -292,25 +292,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Webinhalte in der App anzeigen</string> <string name="plugin_permissions_capability_webview_desc">Webinhalte in der App anzeigen</string>
<string name="settings_section_jam">Gruppen-Jam</string> <string name="settings_section_jam">Gruppen-Jam</string>
<string name="settings_jam_broker_title">MQTT-Broker</string> <string name="settings_jam_server_test">Verbindung testen</string>
<string name="settings_jam_broker_host">Broker-Host</string> <string name="settings_jam_server_testing">Wird getestet…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Verbunden in %1$d ms</string>
<string name="settings_jam_broker_port">Broker-Port</string> <string name="settings_jam_server_test_fail">Fehlgeschlagen: %1$s</string>
<string name="settings_jam_broker_tls">TLS verwenden</string>
<string name="settings_jam_broker_username">Benutzername (optional)</string>
<string name="settings_jam_broker_password">Passwort (optional)</string>
<string name="settings_jam_broker_client_id">Client-ID-Präfix</string>
<string name="settings_jam_broker_client_id_subtitle">Dient zur Identifizierung dieser Spotube-Instanz</string>
<string name="settings_jam_broker_tls_subtitle">Verbindung zum Broker verschlüsseln</string>
<string name="settings_jam_broker_diagnostics_subtitle">Prüfe die aktuellen Broker-Einstellungen, bevor du einen Raum startest.</string>
<string name="settings_jam_broker_test">Verbindung testen</string>
<string name="settings_jam_broker_testing">Wird getestet…</string>
<string name="settings_jam_broker_test_ok">Verbunden in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Fehlgeschlagen: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Platzhalter-Broker – richte deinen eigenen ein, um selbst zu hosten</string>
<string name="settings_jam_section_connection">Verbindung</string> <string name="settings_jam_section_connection">Verbindung</string>
<string name="settings_jam_section_credentials">Zugangsdaten</string>
<string name="settings_jam_section_identity">Identität</string>
<string name="settings_jam_section_diagnostics">Diagnose</string> <string name="settings_jam_section_diagnostics">Diagnose</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Verbinden…</string> <string name="jam_setup_working">Verbinden…</string>
<string name="jam_setup_code_label">Raumcode</string> <string name="jam_setup_code_label">Raumcode</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Richte in den Einstellungen einen Broker ein, um einen Raum zu hosten oder beizutreten.</string>
<string name="jam_connection_settings">Broker-Einstellungen</string>
<string name="jam_session_connected">Verbunden</string> <string name="jam_session_connected">Verbunden</string>
<string name="jam_session_connecting">Verbindung wird wiederhergestellt…</string> <string name="jam_session_connecting">Verbindung wird wiederhergestellt…</string>
<string name="jam_invite_copy">Code kopieren</string> <string name="jam_invite_copy">Code kopieren</string>
@ -345,6 +328,28 @@
<string name="jam_error_join">Beitritt zur Jam-Session fehlgeschlagen</string> <string name="jam_error_join">Beitritt zur Jam-Session fehlgeschlagen</string>
<string name="jam_error_invalid_code">Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein.</string> <string name="jam_error_invalid_code">Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein.</string>
<string name="jam_share_message">Tritt meiner Spotube-Jam bei, Code: %1$s</string> <string name="jam_share_message">Tritt meiner Spotube-Jam bei, Code: %1$s</string>
<string name="settings_jam_server_title">Raumserver</string>
<string name="settings_jam_server_url">Server-URL</string>
<string name="settings_jam_server_url_invalid">Gib eine URL ein, die mit https:// beginnt.</string>
<string name="settings_jam_server_diagnostics_subtitle">Prüfe den Raumserver, bevor du einen Raum startest.</string>
<string name="settings_jam_server_note">Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle den Server von github.com/team-spotube/jam-server bereit und gib seine URL ein.</string>
<string name="settings_jam_section_you">Du</string>
<string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten.</string>
<string name="jam_connection_settings">Servereinstellungen</string>
<string name="jam_participants_you">%1$s (du)</string>
<string name="jam_name_title">Dein Name</string>
<string name="jam_name_subtitle">Andere im Jam sehen dich als %1$s</string>
<string name="jam_name_description">Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst.</string>
<string name="jam_name_too_long">Verwende höchstens %1$d Zeichen.</string>
<string name="jam_error_no_server">Kein Jam-Server eingerichtet</string>
<string name="jam_error_server_unreachable">Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam.</string>
<string name="jam_error_server_incompatible">Der Jam-Server verwendet eine inkompatible Version.</string>
<string name="jam_error_room_not_found">Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut.</string>
<string name="jam_error_room_full">Dieser Raum ist voll.</string>
<string name="jam_error_banned">Der Host hat dich aus diesem Raum gesperrt.</string>
<string name="jam_error_kicked">Der Host hat dich aus dem Raum entfernt.</string>
<string name="jam_error_replaced">Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt.</string>
<string name="player_go_to_album">Zum Album</string> <string name="player_go_to_album">Zum Album</string>
<string name="player_sleep_timer">Sleep-Timer</string> <string name="player_sleep_timer">Sleep-Timer</string>
@ -386,7 +391,7 @@
<string name="welcome_guide_title">So nutzt du Spotube</string> <string name="welcome_guide_title">So nutzt du Spotube</string>
<string name="welcome_guide_subtitle">Vier schnelle Schritte und du hörst Musik.</string> <string name="welcome_guide_subtitle">Vier schnelle Schritte und du hörst Musik.</string>
<string name="welcome_guide_signin_title">Anmelden</string> <string name="welcome_guide_signin_title">Anmelden</string>
<string name="welcome_guide_signin_desc">Verbinde dein Spotify-Konto oder wähle ein eigenes Katalog-Plugin.</string> <string name="welcome_guide_signin_desc">Verbinde dein Konto oder wähle ein eigenes Katalog-Plugin.</string>
<string name="welcome_guide_search_title">Suchen und abspielen</string> <string name="welcome_guide_search_title">Suchen und abspielen</string>
<string name="welcome_guide_search_desc">Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen.</string> <string name="welcome_guide_search_desc">Finde einen Song, ein Album oder einen Künstler und tippe auf Abspielen.</string>
<string name="welcome_guide_offline_title">Offline hören</string> <string name="welcome_guide_offline_title">Offline hören</string>
@ -517,14 +522,9 @@
<string name="jam_destination_suggest">Für Jam vorschlagen</string> <string name="jam_destination_suggest">Für Jam vorschlagen</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Kein Jam-Broker konfiguriert</string>
<string name="jam_error_broker_host_empty">Broker-Host ist leer</string>
<string name="jam_error_connection_failed">Verbindung fehlgeschlagen</string> <string name="jam_error_connection_failed">Verbindung fehlgeschlagen</string>
<string name="jam_error_connection_lost">Verbindung unterbrochen</string> <string name="jam_error_connection_lost">Verbindung unterbrochen</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonym</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Zur Jam-Warteschlange hinzugefügt</string> <string name="jam_queue_added_track">Zur Jam-Warteschlange hinzugefügt</string>

View File

@ -296,25 +296,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Mostrar contenido web dentro de la app</string> <string name="plugin_permissions_capability_webview_desc">Mostrar contenido web dentro de la app</string>
<string name="settings_section_jam">Jam en grupo</string> <string name="settings_section_jam">Jam en grupo</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Probar conexión</string>
<string name="settings_jam_broker_host">Host del broker</string> <string name="settings_jam_server_testing">Probando…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Conectado en %1$d ms</string>
<string name="settings_jam_broker_port">Puerto del broker</string> <string name="settings_jam_server_test_fail">Error: %1$s</string>
<string name="settings_jam_broker_tls">Usar TLS</string>
<string name="settings_jam_broker_username">Usuario (opcional)</string>
<string name="settings_jam_broker_password">Contraseña (opcional)</string>
<string name="settings_jam_broker_client_id">Prefijo de ID de cliente</string>
<string name="settings_jam_broker_client_id_subtitle">Se usa para identificar esta instancia de Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Cifra la conexión con el broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Comprueba los ajustes actuales del broker antes de crear una sala.</string>
<string name="settings_jam_broker_test">Probar conexión</string>
<string name="settings_jam_broker_testing">Probando…</string>
<string name="settings_jam_broker_test_ok">Conectado en %1$d ms</string>
<string name="settings_jam_broker_test_fail">Error: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker de ejemplo: configura el tuyo para alojarlo tú mismo</string>
<string name="settings_jam_section_connection">Conexión</string> <string name="settings_jam_section_connection">Conexión</string>
<string name="settings_jam_section_credentials">Credenciales</string>
<string name="settings_jam_section_identity">Identidad</string>
<string name="settings_jam_section_diagnostics">Diagnóstico</string> <string name="settings_jam_section_diagnostics">Diagnóstico</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Conectando…</string> <string name="jam_setup_working">Conectando…</string>
<string name="jam_setup_code_label">Código de la sala</string> <string name="jam_setup_code_label">Código de la sala</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Configura un broker en Ajustes para crear una sala o unirte a una.</string>
<string name="jam_connection_settings">Ajustes del broker</string>
<string name="jam_session_connected">Conectado</string> <string name="jam_session_connected">Conectado</string>
<string name="jam_session_connecting">Reconectando…</string> <string name="jam_session_connecting">Reconectando…</string>
<string name="jam_invite_copy">Copiar código</string> <string name="jam_invite_copy">Copiar código</string>
@ -349,6 +332,28 @@
<string name="jam_error_join">No se pudo unir a la sesión de Jam</string> <string name="jam_error_join">No se pudo unir a la sesión de Jam</string>
<string name="jam_error_invalid_code">Introduce un código de sala de seis caracteres con letras y números.</string> <string name="jam_error_invalid_code">Introduce un código de sala de seis caracteres con letras y números.</string>
<string name="jam_share_message">Únete a mi Jam de Spotube con el código: %1$s</string> <string name="jam_share_message">Únete a mi Jam de Spotube con el código: %1$s</string>
<string name="settings_jam_server_title">Servidor de salas</string>
<string name="settings_jam_server_url">URL del servidor</string>
<string name="settings_jam_server_url_invalid">Introduce una URL que empiece por https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Comprueba el servidor de salas antes de crear una sala.</string>
<string name="settings_jam_server_note">Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega el servidor de github.com/team-spotube/jam-server e introduce su URL.</string>
<string name="settings_jam_section_you">Tú</string>
<string name="jam_connection_host">Servidor: %1$s</string>
<string name="jam_connection_missing">Configura un servidor de salas en Ajustes para crear una sala o unirte a una.</string>
<string name="jam_connection_settings">Ajustes del servidor</string>
<string name="jam_participants_you">%1$s (tú)</string>
<string name="jam_name_title">Tu nombre</string>
<string name="jam_name_subtitle">Los demás en la jam te ven como %1$s</string>
<string name="jam_name_description">Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una.</string>
<string name="jam_name_too_long">Usa como máximo %1$d caracteres.</string>
<string name="jam_error_no_server">No hay ningún servidor de jam configurado</string>
<string name="jam_error_server_unreachable">No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo.</string>
<string name="jam_error_server_incompatible">El servidor de jam usa una versión incompatible.</string>
<string name="jam_error_room_not_found">No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo.</string>
<string name="jam_error_room_full">Esta sala está llena.</string>
<string name="jam_error_banned">El anfitrión te ha bloqueado en esta sala.</string>
<string name="jam_error_kicked">El anfitrión te ha quitado de la sala.</string>
<string name="jam_error_replaced">Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión.</string>
<string name="player_go_to_album">Ir al álbum</string> <string name="player_go_to_album">Ir al álbum</string>
<string name="player_sleep_timer">Temporizador</string> <string name="player_sleep_timer">Temporizador</string>
@ -390,7 +395,7 @@
<string name="welcome_guide_title">Cómo usar Spotube</string> <string name="welcome_guide_title">Cómo usar Spotube</string>
<string name="welcome_guide_subtitle">Cuatro pasos rápidos y a escuchar.</string> <string name="welcome_guide_subtitle">Cuatro pasos rápidos y a escuchar.</string>
<string name="welcome_guide_signin_title">Inicia sesión</string> <string name="welcome_guide_signin_title">Inicia sesión</string>
<string name="welcome_guide_signin_desc">Conecta tu cuenta de Spotify o elige tu propio plugin de catálogo.</string> <string name="welcome_guide_signin_desc">Conecta tu cuenta o elige tu propio plugin de catálogo.</string>
<string name="welcome_guide_search_title">Busca y reproduce</string> <string name="welcome_guide_search_title">Busca y reproduce</string>
<string name="welcome_guide_search_desc">Encuentra cualquier canción, álbum o artista y dale al play.</string> <string name="welcome_guide_search_desc">Encuentra cualquier canción, álbum o artista y dale al play.</string>
<string name="welcome_guide_offline_title">Escucha sin conexión</string> <string name="welcome_guide_offline_title">Escucha sin conexión</string>
@ -521,14 +526,9 @@
<string name="jam_destination_suggest">Sugerir al Jam</string> <string name="jam_destination_suggest">Sugerir al Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">No hay ningún broker de Jam configurado</string>
<string name="jam_error_broker_host_empty">El host del broker está vacío</string>
<string name="jam_error_connection_failed">Error de conexión</string> <string name="jam_error_connection_failed">Error de conexión</string>
<string name="jam_error_connection_lost">Se perdió la conexión</string> <string name="jam_error_connection_lost">Se perdió la conexión</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anónimo</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Añadida a la cola del Jam</string> <string name="jam_queue_added_track">Añadida a la cola del Jam</string>

View File

@ -292,25 +292,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">نمایش محتوای وب درون برنامه</string> <string name="plugin_permissions_capability_webview_desc">نمایش محتوای وب درون برنامه</string>
<string name="settings_section_jam">جم گروهی</string> <string name="settings_section_jam">جم گروهی</string>
<string name="settings_jam_broker_title">بروکر MQTT</string> <string name="settings_jam_server_test">آزمایش اتصال</string>
<string name="settings_jam_broker_host">میزبان بروکر</string> <string name="settings_jam_server_testing">در حال آزمایش…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">در %1$d میلی‌ثانیه متصل شد</string>
<string name="settings_jam_broker_port">درگاه بروکر</string> <string name="settings_jam_server_test_fail">ناموفق: %1$s</string>
<string name="settings_jam_broker_tls">استفاده از TLS</string>
<string name="settings_jam_broker_username">نام کاربری (اختیاری)</string>
<string name="settings_jam_broker_password">گذرواژه (اختیاری)</string>
<string name="settings_jam_broker_client_id">پیشوند شناسهٔ کلاینت</string>
<string name="settings_jam_broker_client_id_subtitle">برای شناسایی این نسخه از Spotube استفاده می‌شود</string>
<string name="settings_jam_broker_tls_subtitle">رمزگذاری اتصال به بروکر</string>
<string name="settings_jam_broker_diagnostics_subtitle">پیش از شروع اتاق، تنظیمات فعلی بروکر را بررسی کنید.</string>
<string name="settings_jam_broker_test">آزمایش اتصال</string>
<string name="settings_jam_broker_testing">در حال آزمایش…</string>
<string name="settings_jam_broker_test_ok">در %1$d میلی‌ثانیه متصل شد</string>
<string name="settings_jam_broker_test_fail">ناموفق: %1$s</string>
<string name="settings_jam_broker_placeholder_note">بروکر نمونه — برای میزبانی شخصی، بروکر خودتان را پیکربندی کنید</string>
<string name="settings_jam_section_connection">اتصال</string> <string name="settings_jam_section_connection">اتصال</string>
<string name="settings_jam_section_credentials">اطلاعات ورود</string>
<string name="settings_jam_section_identity">هویت</string>
<string name="settings_jam_section_diagnostics">عیب‌یابی</string> <string name="settings_jam_section_diagnostics">عیب‌یابی</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">در حال اتصال…</string> <string name="jam_setup_working">در حال اتصال…</string>
<string name="jam_setup_code_label">کد اتاق</string> <string name="jam_setup_code_label">کد اتاق</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">بروکر: %1$s</string>
<string name="jam_connection_missing">برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راه‌اندازی کنید.</string>
<string name="jam_connection_settings">تنظیمات بروکر</string>
<string name="jam_session_connected">متصل</string> <string name="jam_session_connected">متصل</string>
<string name="jam_session_connecting">در حال اتصال دوباره…</string> <string name="jam_session_connecting">در حال اتصال دوباره…</string>
<string name="jam_invite_copy">کپی کد</string> <string name="jam_invite_copy">کپی کد</string>
@ -345,6 +328,28 @@
<string name="jam_error_join">پیوستن به جلسهٔ جم ناموفق بود</string> <string name="jam_error_join">پیوستن به جلسهٔ جم ناموفق بود</string>
<string name="jam_error_invalid_code">یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید.</string> <string name="jam_error_invalid_code">یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید.</string>
<string name="jam_share_message">با این کد به جم Spotube من بپیوندید: %1$s</string> <string name="jam_share_message">با این کد به جم Spotube من بپیوندید: %1$s</string>
<string name="settings_jam_server_title">سرور اتاق‌ها</string>
<string name="settings_jam_server_url">نشانی سرور</string>
<string name="settings_jam_server_url_invalid">نشانی‌ای وارد کنید که با https:// شروع شود</string>
<string name="settings_jam_server_diagnostics_subtitle">پیش از شروع اتاق، سرور اتاق‌ها را بررسی کنید.</string>
<string name="settings_jam_server_note">جم از سرور رایگان اتاق‌های Spotube استفاده می‌کند. برای میزبانی شخصی، سرور را از github.com/team-spotube/jam-server راه‌اندازی کنید و نشانی آن را وارد کنید.</string>
<string name="settings_jam_section_you">شما</string>
<string name="jam_connection_host">سرور: %1$s</string>
<string name="jam_connection_missing">برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید.</string>
<string name="jam_connection_settings">تنظیمات سرور</string>
<string name="jam_participants_you">%1$s (شما)</string>
<string name="jam_name_title">نام شما</string>
<string name="jam_name_subtitle">دیگران در جم شما را با نام %1$s می‌بینند</string>
<string name="jam_name_description">به همه در جم نشان داده می‌شود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی می‌سازید یا به آن می‌پیوندید اعمال می‌شود.</string>
<string name="jam_name_too_long">حداکثر %1$d نویسه وارد کنید.</string>
<string name="jam_error_no_server">هیچ سرور جمی تنظیم نشده است</string>
<string name="jam_error_server_unreachable">دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید.</string>
<string name="jam_error_server_incompatible">سرور جم نسخهٔ ناسازگاری اجرا می‌کند.</string>
<string name="jam_error_room_not_found">اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید.</string>
<string name="jam_error_room_full">این اتاق پر است.</string>
<string name="jam_error_banned">میزبان شما را از این اتاق مسدود کرد.</string>
<string name="jam_error_kicked">میزبان شما را از اتاق بیرون کرد.</string>
<string name="jam_error_replaced">این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد.</string>
<string name="player_go_to_album">رفتن به آلبوم</string> <string name="player_go_to_album">رفتن به آلبوم</string>
<string name="player_sleep_timer">زمان‌سنج خواب</string> <string name="player_sleep_timer">زمان‌سنج خواب</string>
@ -386,7 +391,7 @@
<string name="welcome_guide_title">نحوهٔ استفاده از Spotube</string> <string name="welcome_guide_title">نحوهٔ استفاده از Spotube</string>
<string name="welcome_guide_subtitle">چهار گام سریع و مشغول شنیدن هستید.</string> <string name="welcome_guide_subtitle">چهار گام سریع و مشغول شنیدن هستید.</string>
<string name="welcome_guide_signin_title">ورود</string> <string name="welcome_guide_signin_title">ورود</string>
<string name="welcome_guide_signin_desc">حساب Spotify خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید.</string> <string name="welcome_guide_signin_desc">حساب خود را متصل کنید یا افزونهٔ کاتالوگ دلخواهتان را انتخاب کنید.</string>
<string name="welcome_guide_search_title">جستجو و پخش</string> <string name="welcome_guide_search_title">جستجو و پخش</string>
<string name="welcome_guide_search_desc">هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید.</string> <string name="welcome_guide_search_desc">هر آهنگ، آلبوم یا هنرمندی را پیدا کنید و پخش را بزنید.</string>
<string name="welcome_guide_offline_title">گوش دادن آفلاین</string> <string name="welcome_guide_offline_title">گوش دادن آفلاین</string>
@ -517,14 +522,9 @@
<string name="jam_destination_suggest">پیشنهاد به جم</string> <string name="jam_destination_suggest">پیشنهاد به جم</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">هیچ بروکر جمی پیکربندی نشده است</string>
<string name="jam_error_broker_host_empty">میزبان بروکر خالی است</string>
<string name="jam_error_connection_failed">اتصال ناموفق بود</string> <string name="jam_error_connection_failed">اتصال ناموفق بود</string>
<string name="jam_error_connection_lost">اتصال قطع شد</string> <string name="jam_error_connection_lost">اتصال قطع شد</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">بروکر</string>
<string name="settings_jam_broker_username_placeholder">ناشناس</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">به صف جم اضافه شد</string> <string name="jam_queue_added_track">به صف جم اضافه شد</string>

View File

@ -296,25 +296,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Afficher du contenu web dans l\'application</string> <string name="plugin_permissions_capability_webview_desc">Afficher du contenu web dans l\'application</string>
<string name="settings_section_jam">Jam de groupe</string> <string name="settings_section_jam">Jam de groupe</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Tester la connexion</string>
<string name="settings_jam_broker_host">Hôte du broker</string> <string name="settings_jam_server_testing">Test en cours…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Connecté en %1$d ms</string>
<string name="settings_jam_broker_port">Port du broker</string> <string name="settings_jam_server_test_fail">Échec : %1$s</string>
<string name="settings_jam_broker_tls">Utiliser TLS</string>
<string name="settings_jam_broker_username">Nom d\'utilisateur (facultatif)</string>
<string name="settings_jam_broker_password">Mot de passe (facultatif)</string>
<string name="settings_jam_broker_client_id">Préfixe de l\'ID client</string>
<string name="settings_jam_broker_client_id_subtitle">Sert à identifier cette instance de Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Chiffrer la connexion au broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Vérifiez les paramètres actuels du broker avant de créer une salle.</string>
<string name="settings_jam_broker_test">Tester la connexion</string>
<string name="settings_jam_broker_testing">Test en cours…</string>
<string name="settings_jam_broker_test_ok">Connecté en %1$d ms</string>
<string name="settings_jam_broker_test_fail">Échec : %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker temporaire : configurez le vôtre pour l\'auto-héberger</string>
<string name="settings_jam_section_connection">Connexion</string> <string name="settings_jam_section_connection">Connexion</string>
<string name="settings_jam_section_credentials">Identifiants</string>
<string name="settings_jam_section_identity">Identité</string>
<string name="settings_jam_section_diagnostics">Diagnostic</string> <string name="settings_jam_section_diagnostics">Diagnostic</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Connexion…</string> <string name="jam_setup_working">Connexion…</string>
<string name="jam_setup_code_label">Code de la salle</string> <string name="jam_setup_code_label">Code de la salle</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker : %1$s</string>
<string name="jam_connection_missing">Configurez un broker dans les Paramètres pour créer ou rejoindre une salle.</string>
<string name="jam_connection_settings">Paramètres du broker</string>
<string name="jam_session_connected">Connecté</string> <string name="jam_session_connected">Connecté</string>
<string name="jam_session_connecting">Reconnexion…</string> <string name="jam_session_connecting">Reconnexion…</string>
<string name="jam_invite_copy">Copier le code</string> <string name="jam_invite_copy">Copier le code</string>
@ -349,6 +332,28 @@
<string name="jam_error_join">Impossible de rejoindre la session Jam</string> <string name="jam_error_join">Impossible de rejoindre la session Jam</string>
<string name="jam_error_invalid_code">Saisissez un code de salle de six caractères (lettres et chiffres).</string> <string name="jam_error_invalid_code">Saisissez un code de salle de six caractères (lettres et chiffres).</string>
<string name="jam_share_message">Rejoignez ma Jam Spotube avec le code : %1$s</string> <string name="jam_share_message">Rejoignez ma Jam Spotube avec le code : %1$s</string>
<string name="settings_jam_server_title">Serveur de salles</string>
<string name="settings_jam_server_url">URL du serveur</string>
<string name="settings_jam_server_url_invalid">Saisissez une URL commençant par https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Vérifiez le serveur de salles avant de lancer une salle.</string>
<string name="settings_jam_server_note">Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez le serveur depuis github.com/team-spotube/jam-server et saisissez son URL.</string>
<string name="settings_jam_section_you">Vous</string>
<string name="jam_connection_host">Serveur : %1$s</string>
<string name="jam_connection_missing">Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle.</string>
<string name="jam_connection_settings">Paramètres du serveur</string>
<string name="jam_participants_you">%1$s (vous)</string>
<string name="jam_name_title">Votre nom</string>
<string name="jam_name_subtitle">Les autres participants vous voient comme %1$s</string>
<string name="jam_name_description">Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle.</string>
<string name="jam_name_too_long">Utilisez au maximum %1$d caractères.</string>
<string name="jam_error_no_server">Aucun serveur de jam configuré</string>
<string name="jam_error_server_unreachable">Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe.</string>
<string name="jam_error_server_incompatible">Le serveur de jam utilise une version incompatible.</string>
<string name="jam_error_room_not_found">Aucune salle avec ce code. Vérifiez-le et réessayez.</string>
<string name="jam_error_room_full">Cette salle est pleine.</string>
<string name="jam_error_banned">L’hôte vous a banni de cette salle.</string>
<string name="jam_error_kicked">L’hôte vous a retiré de la salle.</string>
<string name="jam_error_replaced">Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée.</string>
<string name="player_go_to_album">Accéder à l\'album</string> <string name="player_go_to_album">Accéder à l\'album</string>
<string name="player_sleep_timer">Minuteur de veille</string> <string name="player_sleep_timer">Minuteur de veille</string>
@ -390,7 +395,7 @@
<string name="welcome_guide_title">Comment utiliser Spotube</string> <string name="welcome_guide_title">Comment utiliser Spotube</string>
<string name="welcome_guide_subtitle">Quatre étapes rapides et c\'est parti.</string> <string name="welcome_guide_subtitle">Quatre étapes rapides et c\'est parti.</string>
<string name="welcome_guide_signin_title">Se connecter</string> <string name="welcome_guide_signin_title">Se connecter</string>
<string name="welcome_guide_signin_desc">Connectez votre compte Spotify ou choisissez votre propre plugin de catalogue.</string> <string name="welcome_guide_signin_desc">Connectez votre compte ou choisissez votre propre plugin de catalogue.</string>
<string name="welcome_guide_search_title">Rechercher et écouter</string> <string name="welcome_guide_search_title">Rechercher et écouter</string>
<string name="welcome_guide_search_desc">Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture.</string> <string name="welcome_guide_search_desc">Trouvez n\'importe quel titre, album ou artiste, puis lancez la lecture.</string>
<string name="welcome_guide_offline_title">Écouter hors connexion</string> <string name="welcome_guide_offline_title">Écouter hors connexion</string>
@ -521,14 +526,9 @@
<string name="jam_destination_suggest">Proposer à la Jam</string> <string name="jam_destination_suggest">Proposer à la Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Aucun broker Jam configuré</string>
<string name="jam_error_broker_host_empty">L\'hôte du broker est vide</string>
<string name="jam_error_connection_failed">Échec de la connexion</string> <string name="jam_error_connection_failed">Échec de la connexion</string>
<string name="jam_error_connection_lost">Connexion perdue</string> <string name="jam_error_connection_lost">Connexion perdue</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonyme</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Ajouté à la file d\'attente de la Jam</string> <string name="jam_queue_added_track">Ajouté à la file d\'attente de la Jam</string>

View File

@ -291,25 +291,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">ऐप के अंदर वेब कंटेंट दिखाएं</string> <string name="plugin_permissions_capability_webview_desc">ऐप के अंदर वेब कंटेंट दिखाएं</string>
<string name="settings_section_jam">ग्रुप जैम</string> <string name="settings_section_jam">ग्रुप जैम</string>
<string name="settings_jam_broker_title">MQTT ब्रोकर</string> <string name="settings_jam_server_test">कनेक्शन टेस्ट करें</string>
<string name="settings_jam_broker_host">ब्रोकर होस्ट</string> <string name="settings_jam_server_testing">टेस्ट हो रहा है…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">%1$d ms में कनेक्ट हुआ</string>
<string name="settings_jam_broker_port">ब्रोकर पोर्ट</string> <string name="settings_jam_server_test_fail">विफल: %1$s</string>
<string name="settings_jam_broker_tls">TLS इस्तेमाल करें</string>
<string name="settings_jam_broker_username">यूज़रनेम (वैकल्पिक)</string>
<string name="settings_jam_broker_password">पासवर्ड (वैकल्पिक)</string>
<string name="settings_jam_broker_client_id">क्लाइंट ID प्रीफ़िक्स</string>
<string name="settings_jam_broker_client_id_subtitle">इस Spotube की पहचान के लिए इस्तेमाल होता है</string>
<string name="settings_jam_broker_tls_subtitle">ब्रोकर कनेक्शन एन्क्रिप्ट करें</string>
<string name="settings_jam_broker_diagnostics_subtitle">रूम शुरू करने से पहले मौजूदा ब्रोकर सेटिंग जांचें।</string>
<string name="settings_jam_broker_test">कनेक्शन टेस्ट करें</string>
<string name="settings_jam_broker_testing">टेस्ट हो रहा है…</string>
<string name="settings_jam_broker_test_ok">%1$d ms में कनेक्ट हुआ</string>
<string name="settings_jam_broker_test_fail">विफल: %1$s</string>
<string name="settings_jam_broker_placeholder_note">प्लेसहोल्डर ब्रोकर — खुद होस्ट करने के लिए अपना ब्रोकर सेट करें</string>
<string name="settings_jam_section_connection">कनेक्शन</string> <string name="settings_jam_section_connection">कनेक्शन</string>
<string name="settings_jam_section_credentials">क्रेडेंशियल</string>
<string name="settings_jam_section_identity">पहचान</string>
<string name="settings_jam_section_diagnostics">डायग्नोस्टिक्स</string> <string name="settings_jam_section_diagnostics">डायग्नोस्टिक्स</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -322,9 +308,6 @@
<string name="jam_setup_working">कनेक्ट हो रहा है…</string> <string name="jam_setup_working">कनेक्ट हो रहा है…</string>
<string name="jam_setup_code_label">रूम कोड</string> <string name="jam_setup_code_label">रूम कोड</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">ब्रोकर: %1$s</string>
<string name="jam_connection_missing">रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें।</string>
<string name="jam_connection_settings">ब्रोकर सेटिंग</string>
<string name="jam_session_connected">कनेक्ट है</string> <string name="jam_session_connected">कनेक्ट है</string>
<string name="jam_session_connecting">फिर से कनेक्ट हो रहा है…</string> <string name="jam_session_connecting">फिर से कनेक्ट हो रहा है…</string>
<string name="jam_invite_copy">कोड कॉपी करें</string> <string name="jam_invite_copy">कोड कॉपी करें</string>
@ -344,6 +327,28 @@
<string name="jam_error_join">जैम सेशन से नहीं जुड़ सके</string> <string name="jam_error_join">जैम सेशन से नहीं जुड़ सके</string>
<string name="jam_error_invalid_code">अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।</string> <string name="jam_error_invalid_code">अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।</string>
<string name="jam_share_message">मेरे Spotube जैम से जुड़ें, कोड: %1$s</string> <string name="jam_share_message">मेरे Spotube जैम से जुड़ें, कोड: %1$s</string>
<string name="settings_jam_server_title">रूम सर्वर</string>
<string name="settings_jam_server_url">सर्वर URL</string>
<string name="settings_jam_server_url_invalid">https:// से शुरू होने वाला URL डालें</string>
<string name="settings_jam_server_diagnostics_subtitle">रूम शुरू करने से पहले रूम सर्वर जांच लें।</string>
<string name="settings_jam_server_note">जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए github.com/team-spotube/jam-server से सर्वर डिप्लॉय करें और उसका URL डालें।</string>
<string name="settings_jam_section_you">आप</string>
<string name="jam_connection_host">सर्वर: %1$s</string>
<string name="jam_connection_missing">रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें।</string>
<string name="jam_connection_settings">सर्वर सेटिंग</string>
<string name="jam_participants_you">%1$s (आप)</string>
<string name="jam_name_title">आपका नाम</string>
<string name="jam_name_subtitle">जैम में बाकी लोग आपको %1$s के रूप में देखते हैं</string>
<string name="jam_name_description">जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा।</string>
<string name="jam_name_too_long">ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें।</string>
<string name="jam_error_no_server">कोई जैम सर्वर सेट नहीं है</string>
<string name="jam_error_server_unreachable">जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें।</string>
<string name="jam_error_server_incompatible">जैम सर्वर का वर्शन संगत नहीं है।</string>
<string name="jam_error_room_not_found">इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें।</string>
<string name="jam_error_room_full">यह रूम भर गया है।</string>
<string name="jam_error_banned">होस्ट ने आपको इस रूम से बैन कर दिया है।</string>
<string name="jam_error_kicked">होस्ट ने आपको रूम से हटा दिया है।</string>
<string name="jam_error_replaced">यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया।</string>
<string name="player_go_to_album">एल्बम पर जाएं</string> <string name="player_go_to_album">एल्बम पर जाएं</string>
<string name="player_sleep_timer">स्लीप टाइमर</string> <string name="player_sleep_timer">स्लीप टाइमर</string>
@ -385,7 +390,7 @@
<string name="welcome_guide_title">Spotube कैसे इस्तेमाल करें</string> <string name="welcome_guide_title">Spotube कैसे इस्तेमाल करें</string>
<string name="welcome_guide_subtitle">चार आसान स्टेप और आप सुनना शुरू कर सकते हैं।</string> <string name="welcome_guide_subtitle">चार आसान स्टेप और आप सुनना शुरू कर सकते हैं।</string>
<string name="welcome_guide_signin_title">साइन इन करें</string> <string name="welcome_guide_signin_title">साइन इन करें</string>
<string name="welcome_guide_signin_desc">अपना Spotify अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें।</string> <string name="welcome_guide_signin_desc">अपना अकाउंट कनेक्ट करें, या अपना कैटलॉग प्लगइन चुनें।</string>
<string name="welcome_guide_search_title">खोजें और चलाएं</string> <string name="welcome_guide_search_title">खोजें और चलाएं</string>
<string name="welcome_guide_search_desc">कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं।</string> <string name="welcome_guide_search_desc">कोई भी गाना, एल्बम या कलाकार खोजें, फिर प्ले दबाएं।</string>
<string name="welcome_guide_offline_title">ऑफ़लाइन सुनें</string> <string name="welcome_guide_offline_title">ऑफ़लाइन सुनें</string>
@ -516,14 +521,9 @@
<string name="jam_destination_suggest">जैम में सुझाएं</string> <string name="jam_destination_suggest">जैम में सुझाएं</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">कोई जैम ब्रोकर सेट नहीं है</string>
<string name="jam_error_broker_host_empty">ब्रोकर होस्ट खाली है</string>
<string name="jam_error_connection_failed">कनेक्शन विफल</string> <string name="jam_error_connection_failed">कनेक्शन विफल</string>
<string name="jam_error_connection_lost">कनेक्शन टूट गया</string> <string name="jam_error_connection_lost">कनेक्शन टूट गया</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">ब्रोकर</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">जैम कतार में जोड़ा गया</string> <string name="jam_queue_added_track">जैम कतार में जोड़ा गया</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Menampilkan konten web di dalam aplikasi</string> <string name="plugin_permissions_capability_webview_desc">Menampilkan konten web di dalam aplikasi</string>
<string name="settings_section_jam">Jam Grup</string> <string name="settings_section_jam">Jam Grup</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Uji koneksi</string>
<string name="settings_jam_broker_host">Host broker</string> <string name="settings_jam_server_testing">Menguji…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Terhubung dalam %1$d ms</string>
<string name="settings_jam_broker_port">Port broker</string> <string name="settings_jam_server_test_fail">Gagal: %1$s</string>
<string name="settings_jam_broker_tls">Gunakan TLS</string>
<string name="settings_jam_broker_username">Nama pengguna (opsional)</string>
<string name="settings_jam_broker_password">Kata sandi (opsional)</string>
<string name="settings_jam_broker_client_id">Prefiks ID klien</string>
<string name="settings_jam_broker_client_id_subtitle">Dipakai untuk mengidentifikasi Spotube ini</string>
<string name="settings_jam_broker_tls_subtitle">Enkripsi koneksi broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Periksa pengaturan broker saat ini sebelum memulai ruang.</string>
<string name="settings_jam_broker_test">Uji koneksi</string>
<string name="settings_jam_broker_testing">Menguji…</string>
<string name="settings_jam_broker_test_ok">Terhubung dalam %1$d ms</string>
<string name="settings_jam_broker_test_fail">Gagal: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker sementara — konfigurasikan milikmu sendiri untuk self-host</string>
<string name="settings_jam_section_connection">Koneksi</string> <string name="settings_jam_section_connection">Koneksi</string>
<string name="settings_jam_section_credentials">Kredensial</string>
<string name="settings_jam_section_identity">Identitas</string>
<string name="settings_jam_section_diagnostics">Diagnostik</string> <string name="settings_jam_section_diagnostics">Diagnostik</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">Menghubungkan…</string> <string name="jam_setup_working">Menghubungkan…</string>
<string name="jam_setup_code_label">Kode ruang</string> <string name="jam_setup_code_label">Kode ruang</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Atur broker di Pengaturan untuk membuat atau bergabung ke ruang.</string>
<string name="jam_connection_settings">Pengaturan broker</string>
<string name="jam_session_connected">Terhubung</string> <string name="jam_session_connected">Terhubung</string>
<string name="jam_session_connecting">Menghubungkan ulang…</string> <string name="jam_session_connecting">Menghubungkan ulang…</string>
<string name="jam_invite_copy">Salin kode</string> <string name="jam_invite_copy">Salin kode</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">Gagal bergabung ke sesi Jam</string> <string name="jam_error_join">Gagal bergabung ke sesi Jam</string>
<string name="jam_error_invalid_code">Masukkan kode ruang enam karakter berisi huruf dan angka.</string> <string name="jam_error_invalid_code">Masukkan kode ruang enam karakter berisi huruf dan angka.</string>
<string name="jam_share_message">Gabung ke Jam Spotube-ku dengan kode: %1$s</string> <string name="jam_share_message">Gabung ke Jam Spotube-ku dengan kode: %1$s</string>
<string name="settings_jam_server_title">Server ruang</string>
<string name="settings_jam_server_url">URL server</string>
<string name="settings_jam_server_url_invalid">Masukkan URL yang diawali https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Periksa server ruang sebelum memulai ruang.</string>
<string name="settings_jam_server_note">Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy server dari github.com/team-spotube/jam-server lalu masukkan URL-nya.</string>
<string name="settings_jam_section_you">Kamu</string>
<string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang.</string>
<string name="jam_connection_settings">Pengaturan server</string>
<string name="jam_participants_you">%1$s (kamu)</string>
<string name="jam_name_title">Namamu</string>
<string name="jam_name_subtitle">Orang lain di jam melihatmu sebagai %1$s</string>
<string name="jam_name_description">Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang.</string>
<string name="jam_name_too_long">Gunakan paling banyak %1$d karakter.</string>
<string name="jam_error_no_server">Belum ada server jam yang diatur</string>
<string name="jam_error_server_unreachable">Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup.</string>
<string name="jam_error_server_incompatible">Server jam menjalankan versi yang tidak kompatibel.</string>
<string name="jam_error_room_not_found">Tidak ada ruang dengan kode itu. Periksa lalu coba lagi.</string>
<string name="jam_error_room_full">Ruang ini sudah penuh.</string>
<string name="jam_error_banned">Host memblokirmu dari ruang ini.</string>
<string name="jam_error_kicked">Host mengeluarkanmu dari ruang.</string>
<string name="jam_error_replaced">Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup.</string>
<string name="player_go_to_album">Buka album</string> <string name="player_go_to_album">Buka album</string>
<string name="player_sleep_timer">Timer tidur</string> <string name="player_sleep_timer">Timer tidur</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">Cara menggunakan Spotube</string> <string name="welcome_guide_title">Cara menggunakan Spotube</string>
<string name="welcome_guide_subtitle">Empat langkah singkat dan kamu siap mendengarkan.</string> <string name="welcome_guide_subtitle">Empat langkah singkat dan kamu siap mendengarkan.</string>
<string name="welcome_guide_signin_title">Masuk</string> <string name="welcome_guide_signin_title">Masuk</string>
<string name="welcome_guide_signin_desc">Hubungkan akun Spotify-mu, atau pilih plugin katalogmu sendiri.</string> <string name="welcome_guide_signin_desc">Hubungkan akunmu, atau pilih plugin katalogmu sendiri.</string>
<string name="welcome_guide_search_title">Cari dan putar</string> <string name="welcome_guide_search_title">Cari dan putar</string>
<string name="welcome_guide_search_desc">Temukan lagu, album, atau artis apa pun, lalu tekan putar.</string> <string name="welcome_guide_search_desc">Temukan lagu, album, atau artis apa pun, lalu tekan putar.</string>
<string name="welcome_guide_offline_title">Dengarkan offline</string> <string name="welcome_guide_offline_title">Dengarkan offline</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">Sarankan ke Jam</string> <string name="jam_destination_suggest">Sarankan ke Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Belum ada broker Jam yang dikonfigurasi</string>
<string name="jam_error_broker_host_empty">Host broker kosong</string>
<string name="jam_error_connection_failed">Koneksi gagal</string> <string name="jam_error_connection_failed">Koneksi gagal</string>
<string name="jam_error_connection_lost">Koneksi terputus</string> <string name="jam_error_connection_lost">Koneksi terputus</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonim</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Ditambahkan ke antrean Jam</string> <string name="jam_queue_added_track">Ditambahkan ke antrean Jam</string>

View File

@ -296,25 +296,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Mostrare contenuti web all\'interno dell\'app</string> <string name="plugin_permissions_capability_webview_desc">Mostrare contenuti web all\'interno dell\'app</string>
<string name="settings_section_jam">Jam di gruppo</string> <string name="settings_section_jam">Jam di gruppo</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Verifica connessione</string>
<string name="settings_jam_broker_host">Host del broker</string> <string name="settings_jam_server_testing">Verifica in corso…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Connesso in %1$d ms</string>
<string name="settings_jam_broker_port">Porta del broker</string> <string name="settings_jam_server_test_fail">Non riuscito: %1$s</string>
<string name="settings_jam_broker_tls">Usa TLS</string>
<string name="settings_jam_broker_username">Nome utente (facoltativo)</string>
<string name="settings_jam_broker_password">Password (facoltativa)</string>
<string name="settings_jam_broker_client_id">Prefisso ID client</string>
<string name="settings_jam_broker_client_id_subtitle">Usato per identificare questa istanza di Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Crittografa la connessione al broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Verifica le impostazioni attuali del broker prima di avviare una stanza.</string>
<string name="settings_jam_broker_test">Verifica connessione</string>
<string name="settings_jam_broker_testing">Verifica in corso…</string>
<string name="settings_jam_broker_test_ok">Connesso in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Non riuscito: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker di esempio: configurane uno tuo per l\'hosting autonomo</string>
<string name="settings_jam_section_connection">Connessione</string> <string name="settings_jam_section_connection">Connessione</string>
<string name="settings_jam_section_credentials">Credenziali</string>
<string name="settings_jam_section_identity">Identità</string>
<string name="settings_jam_section_diagnostics">Diagnostica</string> <string name="settings_jam_section_diagnostics">Diagnostica</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Connessione…</string> <string name="jam_setup_working">Connessione…</string>
<string name="jam_setup_code_label">Codice stanza</string> <string name="jam_setup_code_label">Codice stanza</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Configura un broker nelle Impostazioni per ospitare una stanza o parteciparvi.</string>
<string name="jam_connection_settings">Impostazioni broker</string>
<string name="jam_session_connected">Connesso</string> <string name="jam_session_connected">Connesso</string>
<string name="jam_session_connecting">Riconnessione…</string> <string name="jam_session_connecting">Riconnessione…</string>
<string name="jam_invite_copy">Copia codice</string> <string name="jam_invite_copy">Copia codice</string>
@ -349,6 +332,28 @@
<string name="jam_error_join">Impossibile partecipare alla sessione Jam</string> <string name="jam_error_join">Impossibile partecipare alla sessione Jam</string>
<string name="jam_error_invalid_code">Inserisci un codice stanza di sei caratteri composto da lettere e cifre.</string> <string name="jam_error_invalid_code">Inserisci un codice stanza di sei caratteri composto da lettere e cifre.</string>
<string name="jam_share_message">Unisciti alla mia Jam su Spotube con il codice: %1$s</string> <string name="jam_share_message">Unisciti alla mia Jam su Spotube con il codice: %1$s</string>
<string name="settings_jam_server_title">Server delle stanze</string>
<string name="settings_jam_server_url">URL del server</string>
<string name="settings_jam_server_url_invalid">Inserisci un URL che inizi con https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Controlla il server delle stanze prima di avviare una stanza.</string>
<string name="settings_jam_server_note">Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci il server da github.com/team-spotube/jam-server e inseriscine l’URL.</string>
<string name="settings_jam_section_you">Tu</string>
<string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci.</string>
<string name="jam_connection_settings">Impostazioni del server</string>
<string name="jam_participants_you">%1$s (tu)</string>
<string name="jam_name_title">Il tuo nome</string>
<string name="jam_name_subtitle">Gli altri nella jam ti vedono come %1$s</string>
<string name="jam_name_description">Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri.</string>
<string name="jam_name_too_long">Usa al massimo %1$d caratteri.</string>
<string name="jam_error_no_server">Nessun server jam configurato</string>
<string name="jam_error_server_unreachable">Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo.</string>
<string name="jam_error_server_incompatible">Il server jam usa una versione non compatibile.</string>
<string name="jam_error_room_not_found">Nessuna stanza con questo codice. Controllalo e riprova.</string>
<string name="jam_error_room_full">Questa stanza è piena.</string>
<string name="jam_error_banned">L’host ti ha bannato da questa stanza.</string>
<string name="jam_error_kicked">L’host ti ha rimosso dalla stanza.</string>
<string name="jam_error_replaced">Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa.</string>
<string name="player_go_to_album">Vai all\'album</string> <string name="player_go_to_album">Vai all\'album</string>
<string name="player_sleep_timer">Timer di spegnimento</string> <string name="player_sleep_timer">Timer di spegnimento</string>
@ -390,7 +395,7 @@
<string name="welcome_guide_title">Come usare Spotube</string> <string name="welcome_guide_title">Come usare Spotube</string>
<string name="welcome_guide_subtitle">Quattro rapidi passaggi e sei pronto ad ascoltare.</string> <string name="welcome_guide_subtitle">Quattro rapidi passaggi e sei pronto ad ascoltare.</string>
<string name="welcome_guide_signin_title">Accedi</string> <string name="welcome_guide_signin_title">Accedi</string>
<string name="welcome_guide_signin_desc">Collega il tuo account Spotify o scegli il tuo plugin di catalogo.</string> <string name="welcome_guide_signin_desc">Collega il tuo account o scegli il tuo plugin di catalogo.</string>
<string name="welcome_guide_search_title">Cerca e riproduci</string> <string name="welcome_guide_search_title">Cerca e riproduci</string>
<string name="welcome_guide_search_desc">Trova qualsiasi brano, album o artista, poi premi play.</string> <string name="welcome_guide_search_desc">Trova qualsiasi brano, album o artista, poi premi play.</string>
<string name="welcome_guide_offline_title">Ascolta offline</string> <string name="welcome_guide_offline_title">Ascolta offline</string>
@ -521,14 +526,9 @@
<string name="jam_destination_suggest">Suggerisci alla Jam</string> <string name="jam_destination_suggest">Suggerisci alla Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Nessun broker Jam configurato</string>
<string name="jam_error_broker_host_empty">L\'host del broker è vuoto</string>
<string name="jam_error_connection_failed">Connessione non riuscita</string> <string name="jam_error_connection_failed">Connessione non riuscita</string>
<string name="jam_error_connection_lost">Connessione persa</string> <string name="jam_error_connection_lost">Connessione persa</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonimo</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Aggiunto alla coda della Jam</string> <string name="jam_queue_added_track">Aggiunto alla coda della Jam</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">アプリ内でのWebコンテンツの表示</string> <string name="plugin_permissions_capability_webview_desc">アプリ内でのWebコンテンツの表示</string>
<string name="settings_section_jam">グループジャム</string> <string name="settings_section_jam">グループジャム</string>
<string name="settings_jam_broker_title">MQTTブローカー</string> <string name="settings_jam_server_test">接続をテスト</string>
<string name="settings_jam_broker_host">ブローカーのホスト</string> <string name="settings_jam_server_testing">テスト中…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">%1$d msで接続しました</string>
<string name="settings_jam_broker_port">ブローカーのポート</string> <string name="settings_jam_server_test_fail">失敗: %1$s</string>
<string name="settings_jam_broker_tls">TLSを使用</string>
<string name="settings_jam_broker_username">ユーザー名 (任意)</string>
<string name="settings_jam_broker_password">パスワード (任意)</string>
<string name="settings_jam_broker_client_id">クライアントIDのプレフィックス</string>
<string name="settings_jam_broker_client_id_subtitle">このSpotubeを識別するために使用されます</string>
<string name="settings_jam_broker_tls_subtitle">ブローカーとの接続を暗号化します</string>
<string name="settings_jam_broker_diagnostics_subtitle">ルームを開始する前に、現在のブローカー設定を確認します。</string>
<string name="settings_jam_broker_test">接続をテスト</string>
<string name="settings_jam_broker_testing">テスト中…</string>
<string name="settings_jam_broker_test_ok">%1$d msで接続しました</string>
<string name="settings_jam_broker_test_fail">失敗: %1$s</string>
<string name="settings_jam_broker_placeholder_note">仮のブローカーです。セルフホストするには独自のブローカーを設定してください</string>
<string name="settings_jam_section_connection">接続</string> <string name="settings_jam_section_connection">接続</string>
<string name="settings_jam_section_credentials">認証情報</string>
<string name="settings_jam_section_identity">識別情報</string>
<string name="settings_jam_section_diagnostics">診断</string> <string name="settings_jam_section_diagnostics">診断</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">接続中…</string> <string name="jam_setup_working">接続中…</string>
<string name="jam_setup_code_label">ルームコード</string> <string name="jam_setup_code_label">ルームコード</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">ブローカー: %1$s</string>
<string name="jam_connection_missing">ルームを主催または参加するには、設定でブローカーを設定してください。</string>
<string name="jam_connection_settings">ブローカー設定</string>
<string name="jam_session_connected">接続済み</string> <string name="jam_session_connected">接続済み</string>
<string name="jam_session_connecting">再接続中…</string> <string name="jam_session_connecting">再接続中…</string>
<string name="jam_invite_copy">コードをコピー</string> <string name="jam_invite_copy">コードをコピー</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">ジャムセッションに参加できませんでした</string> <string name="jam_error_join">ジャムセッションに参加できませんでした</string>
<string name="jam_error_invalid_code">英数字6文字のルームコードを入力してください。</string> <string name="jam_error_invalid_code">英数字6文字のルームコードを入力してください。</string>
<string name="jam_share_message">コード %1$s で私のSpotubeジャムに参加しよう</string> <string name="jam_share_message">コード %1$s で私のSpotubeジャムに参加しよう</string>
<string name="settings_jam_server_title">ルームサーバー</string>
<string name="settings_jam_server_url">サーバーURL</string>
<string name="settings_jam_server_url_invalid">https:// で始まるURLを入力してください。</string>
<string name="settings_jam_server_diagnostics_subtitle">ルームを始める前にルームサーバーを確認します。</string>
<string name="settings_jam_server_note">ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、github.com/team-spotube/jam-server のサーバーをデプロイし、そのURLを入力してください。</string>
<string name="settings_jam_section_you">あなた</string>
<string name="jam_connection_host">サーバー: %1$s</string>
<string name="jam_connection_missing">ルームをホストまたは参加するには、設定でルームサーバーを指定してください。</string>
<string name="jam_connection_settings">サーバー設定</string>
<string name="jam_participants_you">%1$s(あなた)</string>
<string name="jam_name_title">あなたの名前</string>
<string name="jam_name_subtitle">ジャムの他の参加者には %1$s と表示されます</string>
<string name="jam_name_description">ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。</string>
<string name="jam_name_too_long">%1$d 文字以内で入力してください。</string>
<string name="jam_error_no_server">ジャムサーバーが設定されていません</string>
<string name="jam_error_server_unreachable">ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。</string>
<string name="jam_error_server_incompatible">ジャムサーバーのバージョンに互換性がありません。</string>
<string name="jam_error_room_not_found">そのコードのルームはありません。コードを確認してもう一度お試しください。</string>
<string name="jam_error_room_full">このルームは満員です。</string>
<string name="jam_error_banned">ホストによってこのルームからBANされました。</string>
<string name="jam_error_kicked">ホストによってルームから削除されました。</string>
<string name="jam_error_replaced">このデバイスでこのルームが再度開かれたため、この接続は閉じられました。</string>
<string name="player_go_to_album">アルバムへ移動</string> <string name="player_go_to_album">アルバムへ移動</string>
<string name="player_sleep_timer">スリープタイマー</string> <string name="player_sleep_timer">スリープタイマー</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">Spotubeの使い方</string> <string name="welcome_guide_title">Spotubeの使い方</string>
<string name="welcome_guide_subtitle">簡単な4ステップで、すぐに聴けます。</string> <string name="welcome_guide_subtitle">簡単な4ステップで、すぐに聴けます。</string>
<string name="welcome_guide_signin_title">サインイン</string> <string name="welcome_guide_signin_title">サインイン</string>
<string name="welcome_guide_signin_desc">Spotifyアカウントを連携するか、お好みのカタログプラグインを選んでください。</string> <string name="welcome_guide_signin_desc">アカウントを連携するか、お好みのカタログプラグインを選んでください。</string>
<string name="welcome_guide_search_title">検索して再生</string> <string name="welcome_guide_search_title">検索して再生</string>
<string name="welcome_guide_search_desc">曲、アルバム、アーティストを探して再生しましょう。</string> <string name="welcome_guide_search_desc">曲、アルバム、アーティストを探して再生しましょう。</string>
<string name="welcome_guide_offline_title">オフラインで聴く</string> <string name="welcome_guide_offline_title">オフラインで聴く</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">ジャムに提案</string> <string name="jam_destination_suggest">ジャムに提案</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">ジャム用ブローカーが設定されていません</string>
<string name="jam_error_broker_host_empty">ブローカーのホストが空です</string>
<string name="jam_error_connection_failed">接続に失敗しました</string> <string name="jam_error_connection_failed">接続に失敗しました</string>
<string name="jam_error_connection_lost">接続が切断されました</string> <string name="jam_error_connection_lost">接続が切断されました</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">ブローカー</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">ジャムのキューに追加しました</string> <string name="jam_queue_added_track">ジャムのキューに追加しました</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">앱 안에 웹 콘텐츠 표시</string> <string name="plugin_permissions_capability_webview_desc">앱 안에 웹 콘텐츠 표시</string>
<string name="settings_section_jam">그룹 잼</string> <string name="settings_section_jam">그룹 잼</string>
<string name="settings_jam_broker_title">MQTT 브로커</string> <string name="settings_jam_server_test">연결 테스트</string>
<string name="settings_jam_broker_host">브로커 호스트</string> <string name="settings_jam_server_testing">테스트 중…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">%1$d ms 만에 연결됨</string>
<string name="settings_jam_broker_port">브로커 포트</string> <string name="settings_jam_server_test_fail">실패: %1$s</string>
<string name="settings_jam_broker_tls">TLS 사용</string>
<string name="settings_jam_broker_username">사용자 이름(선택 사항)</string>
<string name="settings_jam_broker_password">비밀번호(선택 사항)</string>
<string name="settings_jam_broker_client_id">클라이언트 ID 접두사</string>
<string name="settings_jam_broker_client_id_subtitle">이 Spotube 인스턴스를 식별하는 데 사용돼요</string>
<string name="settings_jam_broker_tls_subtitle">브로커 연결 암호화</string>
<string name="settings_jam_broker_diagnostics_subtitle">방을 시작하기 전에 현재 브로커 설정을 확인하세요.</string>
<string name="settings_jam_broker_test">연결 테스트</string>
<string name="settings_jam_broker_testing">테스트 중…</string>
<string name="settings_jam_broker_test_ok">%1$d ms 만에 연결됨</string>
<string name="settings_jam_broker_test_fail">실패: %1$s</string>
<string name="settings_jam_broker_placeholder_note">임시 브로커입니다 — 직접 호스팅하려면 브로커를 설정하세요</string>
<string name="settings_jam_section_connection">연결</string> <string name="settings_jam_section_connection">연결</string>
<string name="settings_jam_section_credentials">인증 정보</string>
<string name="settings_jam_section_identity">식별 정보</string>
<string name="settings_jam_section_diagnostics">진단</string> <string name="settings_jam_section_diagnostics">진단</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">연결 중…</string> <string name="jam_setup_working">연결 중…</string>
<string name="jam_setup_code_label">방 코드</string> <string name="jam_setup_code_label">방 코드</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">브로커: %1$s</string>
<string name="jam_connection_missing">방을 만들거나 참여하려면 설정에서 브로커를 설정하세요.</string>
<string name="jam_connection_settings">브로커 설정</string>
<string name="jam_session_connected">연결됨</string> <string name="jam_session_connected">연결됨</string>
<string name="jam_session_connecting">다시 연결 중…</string> <string name="jam_session_connecting">다시 연결 중…</string>
<string name="jam_invite_copy">코드 복사</string> <string name="jam_invite_copy">코드 복사</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">잼 세션에 참여하지 못했어요</string> <string name="jam_error_join">잼 세션에 참여하지 못했어요</string>
<string name="jam_error_invalid_code">영문자와 숫자로 된 6자리 방 코드를 입력하세요.</string> <string name="jam_error_invalid_code">영문자와 숫자로 된 6자리 방 코드를 입력하세요.</string>
<string name="jam_share_message">내 Spotube 잼에 참여하세요. 코드: %1$s</string> <string name="jam_share_message">내 Spotube 잼에 참여하세요. 코드: %1$s</string>
<string name="settings_jam_server_title">방 서버</string>
<string name="settings_jam_server_url">서버 URL</string>
<string name="settings_jam_server_url_invalid">https://로 시작하는 URL을 입력하세요.</string>
<string name="settings_jam_server_diagnostics_subtitle">방을 시작하기 전에 방 서버를 확인하세요.</string>
<string name="settings_jam_server_note">잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 github.com/team-spotube/jam-server의 서버를 배포하고 URL을 입력하세요.</string>
<string name="settings_jam_section_you">나</string>
<string name="jam_connection_host">서버: %1$s</string>
<string name="jam_connection_missing">방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요.</string>
<string name="jam_connection_settings">서버 설정</string>
<string name="jam_participants_you">%1$s (나)</string>
<string name="jam_name_title">내 이름</string>
<string name="jam_name_subtitle">잼의 다른 사람들에게 %1$s(으)로 표시됩니다</string>
<string name="jam_name_description">잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다.</string>
<string name="jam_name_too_long">최대 %1$d자까지 입력하세요.</string>
<string name="jam_error_no_server">설정된 잼 서버가 없습니다</string>
<string name="jam_error_server_unreachable">잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요.</string>
<string name="jam_error_server_incompatible">잼 서버의 버전이 호환되지 않습니다.</string>
<string name="jam_error_room_not_found">해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요.</string>
<string name="jam_error_room_full">이 방은 가득 찼습니다.</string>
<string name="jam_error_banned">호스트가 이 방에서 나를 차단했습니다.</string>
<string name="jam_error_kicked">호스트가 나를 방에서 내보냈습니다.</string>
<string name="jam_error_replaced">이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다.</string>
<string name="player_go_to_album">앨범으로 이동</string> <string name="player_go_to_album">앨범으로 이동</string>
<string name="player_sleep_timer">취침 타이머</string> <string name="player_sleep_timer">취침 타이머</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">Spotube 사용 방법</string> <string name="welcome_guide_title">Spotube 사용 방법</string>
<string name="welcome_guide_subtitle">간단한 네 단계면 바로 감상할 수 있어요.</string> <string name="welcome_guide_subtitle">간단한 네 단계면 바로 감상할 수 있어요.</string>
<string name="welcome_guide_signin_title">로그인</string> <string name="welcome_guide_signin_title">로그인</string>
<string name="welcome_guide_signin_desc">Spotify 계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요.</string> <string name="welcome_guide_signin_desc">계정을 연결하거나 원하는 카탈로그 플러그인을 선택하세요.</string>
<string name="welcome_guide_search_title">검색하고 재생하기</string> <string name="welcome_guide_search_title">검색하고 재생하기</string>
<string name="welcome_guide_search_desc">노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요.</string> <string name="welcome_guide_search_desc">노래, 앨범, 아티스트를 찾은 다음 재생을 누르세요.</string>
<string name="welcome_guide_offline_title">오프라인으로 감상하기</string> <string name="welcome_guide_offline_title">오프라인으로 감상하기</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">잼에 추천하기</string> <string name="jam_destination_suggest">잼에 추천하기</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">구성된 잼 브로커가 없어요</string>
<string name="jam_error_broker_host_empty">브로커 호스트가 비어 있어요</string>
<string name="jam_error_connection_failed">연결 실패</string> <string name="jam_error_connection_failed">연결 실패</string>
<string name="jam_error_connection_lost">연결 끊김</string> <string name="jam_error_connection_lost">연결 끊김</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">브로커</string>
<string name="settings_jam_broker_username_placeholder">익명</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">잼 대기열에 추가했어요</string> <string name="jam_queue_added_track">잼 대기열에 추가했어요</string>

View File

@ -292,25 +292,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Webcontent weergeven in de app</string> <string name="plugin_permissions_capability_webview_desc">Webcontent weergeven in de app</string>
<string name="settings_section_jam">Group Jam</string> <string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT-broker</string> <string name="settings_jam_server_test">Verbinding testen</string>
<string name="settings_jam_broker_host">Broker-host</string> <string name="settings_jam_server_testing">Testen…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Verbonden in %1$d ms</string>
<string name="settings_jam_broker_port">Broker-poort</string> <string name="settings_jam_server_test_fail">Mislukt: %1$s</string>
<string name="settings_jam_broker_tls">TLS gebruiken</string>
<string name="settings_jam_broker_username">Gebruikersnaam (optioneel)</string>
<string name="settings_jam_broker_password">Wachtwoord (optioneel)</string>
<string name="settings_jam_broker_client_id">Voorvoegsel client-ID</string>
<string name="settings_jam_broker_client_id_subtitle">Wordt gebruikt om deze Spotube-installatie te herkennen</string>
<string name="settings_jam_broker_tls_subtitle">De verbinding met de broker versleutelen</string>
<string name="settings_jam_broker_diagnostics_subtitle">Controleer de huidige broker-instellingen voordat je een Jam start.</string>
<string name="settings_jam_broker_test">Verbinding testen</string>
<string name="settings_jam_broker_testing">Testen…</string>
<string name="settings_jam_broker_test_ok">Verbonden in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Mislukt: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Tijdelijke broker — stel je eigen broker in om zelf te hosten</string>
<string name="settings_jam_section_connection">Verbinding</string> <string name="settings_jam_section_connection">Verbinding</string>
<string name="settings_jam_section_credentials">Inloggegevens</string>
<string name="settings_jam_section_identity">Identiteit</string>
<string name="settings_jam_section_diagnostics">Diagnose</string> <string name="settings_jam_section_diagnostics">Diagnose</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Verbinden…</string> <string name="jam_setup_working">Verbinden…</string>
<string name="jam_setup_code_label">Sessiecode</string> <string name="jam_setup_code_label">Sessiecode</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Stel een broker in bij Instellingen om een sessie te hosten of eraan deel te nemen.</string>
<string name="jam_connection_settings">Broker-instellingen</string>
<string name="jam_session_connected">Verbonden</string> <string name="jam_session_connected">Verbonden</string>
<string name="jam_session_connecting">Opnieuw verbinden…</string> <string name="jam_session_connecting">Opnieuw verbinden…</string>
<string name="jam_invite_copy">Code kopiëren</string> <string name="jam_invite_copy">Code kopiëren</string>
@ -345,6 +328,28 @@
<string name="jam_error_join">Deelnemen aan Jam-sessie mislukt</string> <string name="jam_error_join">Deelnemen aan Jam-sessie mislukt</string>
<string name="jam_error_invalid_code">Voer een sessiecode van zes tekens in met letters en cijfers.</string> <string name="jam_error_invalid_code">Voer een sessiecode van zes tekens in met letters en cijfers.</string>
<string name="jam_share_message">Doe mee met mijn Spotube Jam met code: %1$s</string> <string name="jam_share_message">Doe mee met mijn Spotube Jam met code: %1$s</string>
<string name="settings_jam_server_title">Sessieserver</string>
<string name="settings_jam_server_url">Server-URL</string>
<string name="settings_jam_server_url_invalid">Voer een URL in die begint met https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Controleer de sessieserver voordat je een sessie start.</string>
<string name="settings_jam_server_note">Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan de server van github.com/team-spotube/jam-server en voer de URL in.</string>
<string name="settings_jam_section_you">Jij</string>
<string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen.</string>
<string name="jam_connection_settings">Serverinstellingen</string>
<string name="jam_participants_you">%1$s (jij)</string>
<string name="jam_name_title">Je naam</string>
<string name="jam_name_subtitle">Anderen in de jam zien je als %1$s</string>
<string name="jam_name_description">Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt.</string>
<string name="jam_name_too_long">Gebruik maximaal %1$d tekens.</string>
<string name="jam_error_no_server">Geen jamserver ingesteld</string>
<string name="jam_error_server_unreachable">Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam.</string>
<string name="jam_error_server_incompatible">De jamserver draait een incompatibele versie.</string>
<string name="jam_error_room_not_found">Geen sessie met die code. Controleer de code en probeer het opnieuw.</string>
<string name="jam_error_room_full">Deze sessie is vol.</string>
<string name="jam_error_banned">De host heeft je uit deze sessie verbannen.</string>
<string name="jam_error_kicked">De host heeft je uit de sessie verwijderd.</string>
<string name="jam_error_replaced">Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten.</string>
<string name="player_go_to_album">Naar album</string> <string name="player_go_to_album">Naar album</string>
<string name="player_sleep_timer">Slaaptimer</string> <string name="player_sleep_timer">Slaaptimer</string>
@ -386,7 +391,7 @@
<string name="welcome_guide_title">Zo gebruik je Spotube</string> <string name="welcome_guide_title">Zo gebruik je Spotube</string>
<string name="welcome_guide_subtitle">Vier snelle stappen en je luistert al.</string> <string name="welcome_guide_subtitle">Vier snelle stappen en je luistert al.</string>
<string name="welcome_guide_signin_title">Inloggen</string> <string name="welcome_guide_signin_title">Inloggen</string>
<string name="welcome_guide_signin_desc">Koppel je Spotify-account of kies je eigen catalogusplug-in.</string> <string name="welcome_guide_signin_desc">Koppel je account of kies je eigen catalogusplug-in.</string>
<string name="welcome_guide_search_title">Zoeken en afspelen</string> <string name="welcome_guide_search_title">Zoeken en afspelen</string>
<string name="welcome_guide_search_desc">Vind elk nummer, album of elke artiest en druk op afspelen.</string> <string name="welcome_guide_search_desc">Vind elk nummer, album of elke artiest en druk op afspelen.</string>
<string name="welcome_guide_offline_title">Offline luisteren</string> <string name="welcome_guide_offline_title">Offline luisteren</string>
@ -517,14 +522,9 @@
<string name="jam_destination_suggest">Voorstellen aan Jam</string> <string name="jam_destination_suggest">Voorstellen aan Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Geen Jam-broker ingesteld</string>
<string name="jam_error_broker_host_empty">Broker-host is leeg</string>
<string name="jam_error_connection_failed">Verbinding mislukt</string> <string name="jam_error_connection_failed">Verbinding mislukt</string>
<string name="jam_error_connection_lost">Verbinding verbroken</string> <string name="jam_error_connection_lost">Verbinding verbroken</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anoniem</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Toegevoegd aan de Jam-wachtrij</string> <string name="jam_queue_added_track">Toegevoegd aan de Jam-wachtrij</string>

View File

@ -300,25 +300,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Wyświetlanie treści internetowych w aplikacji</string> <string name="plugin_permissions_capability_webview_desc">Wyświetlanie treści internetowych w aplikacji</string>
<string name="settings_section_jam">Grupowy Jam</string> <string name="settings_section_jam">Grupowy Jam</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Testuj połączenie</string>
<string name="settings_jam_broker_host">Host brokera</string> <string name="settings_jam_server_testing">Testowanie…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Połączono w %1$d ms</string>
<string name="settings_jam_broker_port">Port brokera</string> <string name="settings_jam_server_test_fail">Niepowodzenie: %1$s</string>
<string name="settings_jam_broker_tls">Użyj TLS</string>
<string name="settings_jam_broker_username">Nazwa użytkownika (opcjonalnie)</string>
<string name="settings_jam_broker_password">Hasło (opcjonalnie)</string>
<string name="settings_jam_broker_client_id">Prefiks ID klienta</string>
<string name="settings_jam_broker_client_id_subtitle">Służy do identyfikacji tej instancji Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Szyfruj połączenie z brokerem</string>
<string name="settings_jam_broker_diagnostics_subtitle">Sprawdź bieżące ustawienia brokera przed utworzeniem pokoju.</string>
<string name="settings_jam_broker_test">Testuj połączenie</string>
<string name="settings_jam_broker_testing">Testowanie…</string>
<string name="settings_jam_broker_test_ok">Połączono w %1$d ms</string>
<string name="settings_jam_broker_test_fail">Niepowodzenie: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker zastępczy – skonfiguruj własny, aby hostować samodzielnie</string>
<string name="settings_jam_section_connection">Połączenie</string> <string name="settings_jam_section_connection">Połączenie</string>
<string name="settings_jam_section_credentials">Dane logowania</string>
<string name="settings_jam_section_identity">Tożsamość</string>
<string name="settings_jam_section_diagnostics">Diagnostyka</string> <string name="settings_jam_section_diagnostics">Diagnostyka</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Łączenie…</string> <string name="jam_setup_working">Łączenie…</string>
<string name="jam_setup_code_label">Kod pokoju</string> <string name="jam_setup_code_label">Kod pokoju</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Skonfiguruj brokera w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.</string>
<string name="jam_connection_settings">Ustawienia brokera</string>
<string name="jam_session_connected">Połączono</string> <string name="jam_session_connected">Połączono</string>
<string name="jam_session_connecting">Ponowne łączenie…</string> <string name="jam_session_connecting">Ponowne łączenie…</string>
<string name="jam_invite_copy">Kopiuj kod</string> <string name="jam_invite_copy">Kopiuj kod</string>
@ -353,6 +336,28 @@
<string name="jam_error_join">Nie udało się dołączyć do sesji Jam</string> <string name="jam_error_join">Nie udało się dołączyć do sesji Jam</string>
<string name="jam_error_invalid_code">Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr.</string> <string name="jam_error_invalid_code">Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr.</string>
<string name="jam_share_message">Dołącz do mojego Jamu w Spotube, kod: %1$s</string> <string name="jam_share_message">Dołącz do mojego Jamu w Spotube, kod: %1$s</string>
<string name="settings_jam_server_title">Serwer pokojów</string>
<string name="settings_jam_server_url">Adres URL serwera</string>
<string name="settings_jam_server_url_invalid">Wpisz adres URL zaczynający się od https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Sprawdź serwer pokojów przed utworzeniem pokoju.</string>
<string name="settings_jam_server_note">Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż serwer z github.com/team-spotube/jam-server i wpisz jego adres URL.</string>
<string name="settings_jam_section_you">Ty</string>
<string name="jam_connection_host">Serwer: %1$s</string>
<string name="jam_connection_missing">Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.</string>
<string name="jam_connection_settings">Ustawienia serwera</string>
<string name="jam_participants_you">%1$s (ty)</string>
<string name="jam_name_title">Twoja nazwa</string>
<string name="jam_name_subtitle">Inni w jamie widzą cię jako %1$s</string>
<string name="jam_name_description">Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego.</string>
<string name="jam_name_too_long">Użyj maksymalnie %1$d znaków.</string>
<string name="jam_error_no_server">Nie skonfigurowano serwera jamu</string>
<string name="jam_error_server_unreachable">Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam.</string>
<string name="jam_error_server_incompatible">Serwer jamu używa niezgodnej wersji.</string>
<string name="jam_error_room_not_found">Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie.</string>
<string name="jam_error_room_full">Ten pokój jest pełny.</string>
<string name="jam_error_banned">Gospodarz zablokował cię w tym pokoju.</string>
<string name="jam_error_kicked">Gospodarz usunął cię z pokoju.</string>
<string name="jam_error_replaced">Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte.</string>
<string name="player_go_to_album">Przejdź do albumu</string> <string name="player_go_to_album">Przejdź do albumu</string>
<string name="player_sleep_timer">Wyłącznik czasowy</string> <string name="player_sleep_timer">Wyłącznik czasowy</string>
@ -394,7 +399,7 @@
<string name="welcome_guide_title">Jak korzystać ze Spotube</string> <string name="welcome_guide_title">Jak korzystać ze Spotube</string>
<string name="welcome_guide_subtitle">Cztery szybkie kroki i już słuchasz.</string> <string name="welcome_guide_subtitle">Cztery szybkie kroki i już słuchasz.</string>
<string name="welcome_guide_signin_title">Zaloguj się</string> <string name="welcome_guide_signin_title">Zaloguj się</string>
<string name="welcome_guide_signin_desc">Połącz konto Spotify lub wybierz własną wtyczkę katalogu.</string> <string name="welcome_guide_signin_desc">Połącz konto lub wybierz własną wtyczkę katalogu.</string>
<string name="welcome_guide_search_title">Szukaj i odtwarzaj</string> <string name="welcome_guide_search_title">Szukaj i odtwarzaj</string>
<string name="welcome_guide_search_desc">Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz.</string> <string name="welcome_guide_search_desc">Znajdź dowolny utwór, album lub wykonawcę i naciśnij Odtwórz.</string>
<string name="welcome_guide_offline_title">Słuchaj offline</string> <string name="welcome_guide_offline_title">Słuchaj offline</string>
@ -525,14 +530,9 @@
<string name="jam_destination_suggest">Zaproponuj w Jamie</string> <string name="jam_destination_suggest">Zaproponuj w Jamie</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Nie skonfigurowano brokera Jam</string>
<string name="jam_error_broker_host_empty">Host brokera jest pusty</string>
<string name="jam_error_connection_failed">Połączenie nieudane</string> <string name="jam_error_connection_failed">Połączenie nieudane</string>
<string name="jam_error_connection_lost">Utracono połączenie</string> <string name="jam_error_connection_lost">Utracono połączenie</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonim</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Dodano do kolejki Jamu</string> <string name="jam_queue_added_track">Dodano do kolejki Jamu</string>

View File

@ -296,25 +296,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Exibir conteúdo da web dentro do app</string> <string name="plugin_permissions_capability_webview_desc">Exibir conteúdo da web dentro do app</string>
<string name="settings_section_jam">Jam em grupo</string> <string name="settings_section_jam">Jam em grupo</string>
<string name="settings_jam_broker_title">Broker MQTT</string> <string name="settings_jam_server_test">Testar conexão</string>
<string name="settings_jam_broker_host">Host do broker</string> <string name="settings_jam_server_testing">Testando…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Conectado em %1$d ms</string>
<string name="settings_jam_broker_port">Porta do broker</string> <string name="settings_jam_server_test_fail">Falhou: %1$s</string>
<string name="settings_jam_broker_tls">Usar TLS</string>
<string name="settings_jam_broker_username">Nome de usuário (opcional)</string>
<string name="settings_jam_broker_password">Senha (opcional)</string>
<string name="settings_jam_broker_client_id">Prefixo do ID do cliente</string>
<string name="settings_jam_broker_client_id_subtitle">Usado para identificar esta instância do Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Criptografa a conexão com o broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Verifique as configurações atuais do broker antes de criar uma sala.</string>
<string name="settings_jam_broker_test">Testar conexão</string>
<string name="settings_jam_broker_testing">Testando…</string>
<string name="settings_jam_broker_test_ok">Conectado em %1$d ms</string>
<string name="settings_jam_broker_test_fail">Falhou: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker provisório — configure o seu próprio para hospedar por conta própria</string>
<string name="settings_jam_section_connection">Conexão</string> <string name="settings_jam_section_connection">Conexão</string>
<string name="settings_jam_section_credentials">Credenciais</string>
<string name="settings_jam_section_identity">Identidade</string>
<string name="settings_jam_section_diagnostics">Diagnóstico</string> <string name="settings_jam_section_diagnostics">Diagnóstico</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Conectando…</string> <string name="jam_setup_working">Conectando…</string>
<string name="jam_setup_code_label">Código da sala</string> <string name="jam_setup_code_label">Código da sala</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Configure um broker nas Configurações para criar ou entrar em uma sala.</string>
<string name="jam_connection_settings">Configurações do broker</string>
<string name="jam_session_connected">Conectado</string> <string name="jam_session_connected">Conectado</string>
<string name="jam_session_connecting">Reconectando…</string> <string name="jam_session_connecting">Reconectando…</string>
<string name="jam_invite_copy">Copiar código</string> <string name="jam_invite_copy">Copiar código</string>
@ -349,6 +332,28 @@
<string name="jam_error_join">Falha ao entrar na Jam</string> <string name="jam_error_join">Falha ao entrar na Jam</string>
<string name="jam_error_invalid_code">Digite um código de sala com seis caracteres, usando letras e números.</string> <string name="jam_error_invalid_code">Digite um código de sala com seis caracteres, usando letras e números.</string>
<string name="jam_share_message">Entre na minha Jam do Spotube com o código: %1$s</string> <string name="jam_share_message">Entre na minha Jam do Spotube com o código: %1$s</string>
<string name="settings_jam_server_title">Servidor de salas</string>
<string name="settings_jam_server_url">URL do servidor</string>
<string name="settings_jam_server_url_invalid">Digite uma URL que comece com https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Verifique o servidor de salas antes de criar uma sala.</string>
<string name="settings_jam_server_note">O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o servidor de github.com/team-spotube/jam-server e informe a URL dele.</string>
<string name="settings_jam_section_you">Você</string>
<string name="jam_connection_host">Servidor: %1$s</string>
<string name="jam_connection_missing">Defina um servidor de salas nas Configurações para criar ou entrar em uma sala.</string>
<string name="jam_connection_settings">Configurações do servidor</string>
<string name="jam_participants_you">%1$s (você)</string>
<string name="jam_name_title">Seu nome</string>
<string name="jam_name_subtitle">Os outros no jam veem você como %1$s</string>
<string name="jam_name_description">Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala.</string>
<string name="jam_name_too_long">Use no máximo %1$d caracteres.</string>
<string name="jam_error_no_server">Nenhum servidor de jam configurado</string>
<string name="jam_error_server_unreachable">Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo.</string>
<string name="jam_error_server_incompatible">O servidor de jam usa uma versão incompatível.</string>
<string name="jam_error_room_not_found">Nenhuma sala com esse código. Confira e tente de novo.</string>
<string name="jam_error_room_full">Esta sala está cheia.</string>
<string name="jam_error_banned">O anfitrião baniu você desta sala.</string>
<string name="jam_error_kicked">O anfitrião removeu você da sala.</string>
<string name="jam_error_replaced">Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada.</string>
<string name="player_go_to_album">Ir para o álbum</string> <string name="player_go_to_album">Ir para o álbum</string>
<string name="player_sleep_timer">Timer</string> <string name="player_sleep_timer">Timer</string>
@ -390,7 +395,7 @@
<string name="welcome_guide_title">Como usar o Spotube</string> <string name="welcome_guide_title">Como usar o Spotube</string>
<string name="welcome_guide_subtitle">Quatro passos rápidos e você já está ouvindo.</string> <string name="welcome_guide_subtitle">Quatro passos rápidos e você já está ouvindo.</string>
<string name="welcome_guide_signin_title">Entrar</string> <string name="welcome_guide_signin_title">Entrar</string>
<string name="welcome_guide_signin_desc">Conecte sua conta do Spotify ou escolha seu próprio plugin de catálogo.</string> <string name="welcome_guide_signin_desc">Conecte sua conta ou escolha seu próprio plugin de catálogo.</string>
<string name="welcome_guide_search_title">Busque e toque</string> <string name="welcome_guide_search_title">Busque e toque</string>
<string name="welcome_guide_search_desc">Encontre qualquer música, álbum ou artista e aperte o play.</string> <string name="welcome_guide_search_desc">Encontre qualquer música, álbum ou artista e aperte o play.</string>
<string name="welcome_guide_offline_title">Ouça offline</string> <string name="welcome_guide_offline_title">Ouça offline</string>
@ -521,14 +526,9 @@
<string name="jam_destination_suggest">Sugerir na Jam</string> <string name="jam_destination_suggest">Sugerir na Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Nenhum broker de Jam configurado</string>
<string name="jam_error_broker_host_empty">O host do broker está vazio</string>
<string name="jam_error_connection_failed">Falha na conexão</string> <string name="jam_error_connection_failed">Falha na conexão</string>
<string name="jam_error_connection_lost">Conexão perdida</string> <string name="jam_error_connection_lost">Conexão perdida</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anônimo</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Adicionada à fila da Jam</string> <string name="jam_queue_added_track">Adicionada à fila da Jam</string>

View File

@ -300,25 +300,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Показ веб-контента внутри приложения</string> <string name="plugin_permissions_capability_webview_desc">Показ веб-контента внутри приложения</string>
<string name="settings_section_jam">Групповой джем</string> <string name="settings_section_jam">Групповой джем</string>
<string name="settings_jam_broker_title">MQTT-брокер</string> <string name="settings_jam_server_test">Проверить подключение</string>
<string name="settings_jam_broker_host">Хост брокера</string> <string name="settings_jam_server_testing">Проверка…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Подключено за %1$d мс</string>
<string name="settings_jam_broker_port">Порт брокера</string> <string name="settings_jam_server_test_fail">Ошибка: %1$s</string>
<string name="settings_jam_broker_tls">Использовать TLS</string>
<string name="settings_jam_broker_username">Имя пользователя (необязательно)</string>
<string name="settings_jam_broker_password">Пароль (необязательно)</string>
<string name="settings_jam_broker_client_id">Префикс ID клиента</string>
<string name="settings_jam_broker_client_id_subtitle">Используется для идентификации этого экземпляра Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Шифровать подключение к брокеру</string>
<string name="settings_jam_broker_diagnostics_subtitle">Проверьте текущие настройки брокера перед созданием комнаты.</string>
<string name="settings_jam_broker_test">Проверить подключение</string>
<string name="settings_jam_broker_testing">Проверка…</string>
<string name="settings_jam_broker_test_ok">Подключено за %1$d мс</string>
<string name="settings_jam_broker_test_fail">Ошибка: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Временный брокер — настройте свой, чтобы размещать джемы самостоятельно</string>
<string name="settings_jam_section_connection">Подключение</string> <string name="settings_jam_section_connection">Подключение</string>
<string name="settings_jam_section_credentials">Учётные данные</string>
<string name="settings_jam_section_identity">Идентификация</string>
<string name="settings_jam_section_diagnostics">Диагностика</string> <string name="settings_jam_section_diagnostics">Диагностика</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Подключение…</string> <string name="jam_setup_working">Подключение…</string>
<string name="jam_setup_code_label">Код комнаты</string> <string name="jam_setup_code_label">Код комнаты</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Брокер: %1$s</string>
<string name="jam_connection_missing">Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней.</string>
<string name="jam_connection_settings">Настройки брокера</string>
<string name="jam_session_connected">Подключено</string> <string name="jam_session_connected">Подключено</string>
<string name="jam_session_connecting">Переподключение…</string> <string name="jam_session_connecting">Переподключение…</string>
<string name="jam_invite_copy">Копировать код</string> <string name="jam_invite_copy">Копировать код</string>
@ -353,6 +336,28 @@
<string name="jam_error_join">Не удалось присоединиться к джему</string> <string name="jam_error_join">Не удалось присоединиться к джему</string>
<string name="jam_error_invalid_code">Введите код комнаты из шести букв и цифр.</string> <string name="jam_error_invalid_code">Введите код комнаты из шести букв и цифр.</string>
<string name="jam_share_message">Присоединяйся к моему джему в Spotube по коду: %1$s</string> <string name="jam_share_message">Присоединяйся к моему джему в Spotube по коду: %1$s</string>
<string name="settings_jam_server_title">Сервер комнат</string>
<string name="settings_jam_server_url">URL сервера</string>
<string name="settings_jam_server_url_invalid">Введите URL, начинающийся с https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Проверьте сервер комнат перед созданием комнаты.</string>
<string name="settings_jam_server_note">Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите сервер из github.com/team-spotube/jam-server и укажите его URL.</string>
<string name="settings_jam_section_you">Вы</string>
<string name="jam_connection_host">Сервер: %1$s</string>
<string name="jam_connection_missing">Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней.</string>
<string name="jam_connection_settings">Настройки сервера</string>
<string name="jam_participants_you">%1$s (вы)</string>
<string name="jam_name_title">Ваше имя</string>
<string name="jam_name_subtitle">Другие участники джема видят вас как %1$s</string>
<string name="jam_name_description">Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней.</string>
<string name="jam_name_too_long">Не больше %1$d символов.</string>
<string name="jam_error_no_server">Сервер джема не настроен</string>
<string name="jam_error_server_unreachable">Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем.</string>
<string name="jam_error_server_incompatible">Сервер джема использует несовместимую версию.</string>
<string name="jam_error_room_not_found">Комнаты с таким кодом нет. Проверьте код и попробуйте снова.</string>
<string name="jam_error_room_full">Эта комната заполнена.</string>
<string name="jam_error_banned">Организатор заблокировал вас в этой комнате.</string>
<string name="jam_error_kicked">Организатор удалил вас из комнаты.</string>
<string name="jam_error_replaced">Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто.</string>
<string name="player_go_to_album">Перейти к альбому</string> <string name="player_go_to_album">Перейти к альбому</string>
<string name="player_sleep_timer">Таймер сна</string> <string name="player_sleep_timer">Таймер сна</string>
@ -394,7 +399,7 @@
<string name="welcome_guide_title">Как пользоваться Spotube</string> <string name="welcome_guide_title">Как пользоваться Spotube</string>
<string name="welcome_guide_subtitle">Четыре простых шага — и музыка уже играет.</string> <string name="welcome_guide_subtitle">Четыре простых шага — и музыка уже играет.</string>
<string name="welcome_guide_signin_title">Вход</string> <string name="welcome_guide_signin_title">Вход</string>
<string name="welcome_guide_signin_desc">Подключите аккаунт Spotify или выберите свой плагин каталога.</string> <string name="welcome_guide_signin_desc">Подключите аккаунт или выберите свой плагин каталога.</string>
<string name="welcome_guide_search_title">Ищите и слушайте</string> <string name="welcome_guide_search_title">Ищите и слушайте</string>
<string name="welcome_guide_search_desc">Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести».</string> <string name="welcome_guide_search_desc">Найдите любой трек, альбом или исполнителя и нажмите «Воспроизвести».</string>
<string name="welcome_guide_offline_title">Слушайте офлайн</string> <string name="welcome_guide_offline_title">Слушайте офлайн</string>
@ -525,14 +530,9 @@
<string name="jam_destination_suggest">Предложить в джем</string> <string name="jam_destination_suggest">Предложить в джем</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Брокер для джема не настроен</string>
<string name="jam_error_broker_host_empty">Не указан хост брокера</string>
<string name="jam_error_connection_failed">Не удалось подключиться</string> <string name="jam_error_connection_failed">Не удалось подключиться</string>
<string name="jam_error_connection_lost">Соединение потеряно</string> <string name="jam_error_connection_lost">Соединение потеряно</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Брокер</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Добавлено в очередь джема</string> <string name="jam_queue_added_track">Добавлено в очередь джема</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">แสดงเนื้อหาเว็บภายในแอป</string> <string name="plugin_permissions_capability_webview_desc">แสดงเนื้อหาเว็บภายในแอป</string>
<string name="settings_section_jam">แจมกลุ่ม</string> <string name="settings_section_jam">แจมกลุ่ม</string>
<string name="settings_jam_broker_title">MQTT Broker</string> <string name="settings_jam_server_test">ทดสอบการเชื่อมต่อ</string>
<string name="settings_jam_broker_host">โฮสต์ของ Broker</string> <string name="settings_jam_server_testing">กำลังทดสอบ…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">เชื่อมต่อแล้วใน %1$d ms</string>
<string name="settings_jam_broker_port">พอร์ตของ Broker</string> <string name="settings_jam_server_test_fail">ล้มเหลว: %1$s</string>
<string name="settings_jam_broker_tls">ใช้ TLS</string>
<string name="settings_jam_broker_username">ชื่อผู้ใช้ (ไม่บังคับ)</string>
<string name="settings_jam_broker_password">รหัสผ่าน (ไม่บังคับ)</string>
<string name="settings_jam_broker_client_id">คำนำหน้า Client ID</string>
<string name="settings_jam_broker_client_id_subtitle">ใช้เพื่อระบุ Spotube เครื่องนี้</string>
<string name="settings_jam_broker_tls_subtitle">เข้ารหัสการเชื่อมต่อกับ Broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">ตรวจสอบการตั้งค่า Broker ปัจจุบันก่อนเริ่มห้อง</string>
<string name="settings_jam_broker_test">ทดสอบการเชื่อมต่อ</string>
<string name="settings_jam_broker_testing">กำลังทดสอบ…</string>
<string name="settings_jam_broker_test_ok">เชื่อมต่อแล้วใน %1$d ms</string>
<string name="settings_jam_broker_test_fail">ล้มเหลว: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker ตัวอย่าง — กำหนดค่าของคุณเองเพื่อโฮสต์เอง</string>
<string name="settings_jam_section_connection">การเชื่อมต่อ</string> <string name="settings_jam_section_connection">การเชื่อมต่อ</string>
<string name="settings_jam_section_credentials">ข้อมูลเข้าสู่ระบบ</string>
<string name="settings_jam_section_identity">ตัวตน</string>
<string name="settings_jam_section_diagnostics">การวินิจฉัย</string> <string name="settings_jam_section_diagnostics">การวินิจฉัย</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">กำลังเชื่อมต่อ…</string> <string name="jam_setup_working">กำลังเชื่อมต่อ…</string>
<string name="jam_setup_code_label">รหัสห้อง</string> <string name="jam_setup_code_label">รหัสห้อง</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">ตั้งค่า Broker ในการตั้งค่าเพื่อสร้างหรือเข้าร่วมห้อง</string>
<string name="jam_connection_settings">การตั้งค่า Broker</string>
<string name="jam_session_connected">เชื่อมต่อแล้ว</string> <string name="jam_session_connected">เชื่อมต่อแล้ว</string>
<string name="jam_session_connecting">กำลังเชื่อมต่อใหม่…</string> <string name="jam_session_connecting">กำลังเชื่อมต่อใหม่…</string>
<string name="jam_invite_copy">คัดลอกรหัส</string> <string name="jam_invite_copy">คัดลอกรหัส</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">เข้าร่วมเซสชันแจมไม่สำเร็จ</string> <string name="jam_error_join">เข้าร่วมเซสชันแจมไม่สำเร็จ</string>
<string name="jam_error_invalid_code">ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข</string> <string name="jam_error_invalid_code">ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข</string>
<string name="jam_share_message">มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s</string> <string name="jam_share_message">มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s</string>
<string name="settings_jam_server_title">เซิร์ฟเวอร์ห้อง</string>
<string name="settings_jam_server_url">URL ของเซิร์ฟเวอร์</string>
<string name="settings_jam_server_url_invalid">ใส่ URL ที่ขึ้นต้นด้วย https://</string>
<string name="settings_jam_server_diagnostics_subtitle">ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง</string>
<string name="settings_jam_server_note">แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้งเซิร์ฟเวอร์จาก github.com/team-spotube/jam-server แล้วใส่ URL ของเซิร์ฟเวอร์นั้น</string>
<string name="settings_jam_section_you">คุณ</string>
<string name="jam_connection_host">เซิร์ฟเวอร์: %1$s</string>
<string name="jam_connection_missing">ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง</string>
<string name="jam_connection_settings">การตั้งค่าเซิร์ฟเวอร์</string>
<string name="jam_participants_you">%1$s (คุณ)</string>
<string name="jam_name_title">ชื่อของคุณ</string>
<string name="jam_name_subtitle">คนอื่นในแจมจะเห็นคุณเป็น %1$s</string>
<string name="jam_name_description">ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป</string>
<string name="jam_name_too_long">ใช้ได้ไม่เกิน %1$d ตัวอักษร</string>
<string name="jam_error_no_server">ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม</string>
<string name="jam_error_server_unreachable">เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม</string>
<string name="jam_error_server_incompatible">เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้</string>
<string name="jam_error_room_not_found">ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง</string>
<string name="jam_error_room_full">ห้องนี้เต็มแล้ว</string>
<string name="jam_error_banned">โฮสต์แบนคุณออกจากห้องนี้</string>
<string name="jam_error_kicked">โฮสต์นำคุณออกจากห้อง</string>
<string name="jam_error_replaced">ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้</string>
<string name="player_go_to_album">ไปที่อัลบั้ม</string> <string name="player_go_to_album">ไปที่อัลบั้ม</string>
<string name="player_sleep_timer">ตั้งเวลาปิด</string> <string name="player_sleep_timer">ตั้งเวลาปิด</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">วิธีใช้ Spotube</string> <string name="welcome_guide_title">วิธีใช้ Spotube</string>
<string name="welcome_guide_subtitle">แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย</string> <string name="welcome_guide_subtitle">แค่ 4 ขั้นตอนง่าย ๆ ก็เริ่มฟังได้เลย</string>
<string name="welcome_guide_signin_title">เข้าสู่ระบบ</string> <string name="welcome_guide_signin_title">เข้าสู่ระบบ</string>
<string name="welcome_guide_signin_desc">เชื่อมต่อบัญชี Spotify ของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง</string> <string name="welcome_guide_signin_desc">เชื่อมต่อบัญชีของคุณ หรือเลือกปลั๊กอินแคตตาล็อกของคุณเอง</string>
<string name="welcome_guide_search_title">ค้นหาและเล่น</string> <string name="welcome_guide_search_title">ค้นหาและเล่น</string>
<string name="welcome_guide_search_desc">ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น</string> <string name="welcome_guide_search_desc">ค้นหาเพลง อัลบั้ม หรือศิลปิน แล้วกดเล่น</string>
<string name="welcome_guide_offline_title">ฟังแบบออฟไลน์</string> <string name="welcome_guide_offline_title">ฟังแบบออฟไลน์</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">เสนอให้แจม</string> <string name="jam_destination_suggest">เสนอให้แจม</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">ยังไม่ได้กำหนดค่า Broker สำหรับแจม</string>
<string name="jam_error_broker_host_empty">โฮสต์ของ Broker ว่างอยู่</string>
<string name="jam_error_connection_failed">การเชื่อมต่อล้มเหลว</string> <string name="jam_error_connection_failed">การเชื่อมต่อล้มเหลว</string>
<string name="jam_error_connection_lost">การเชื่อมต่อขาดหาย</string> <string name="jam_error_connection_lost">การเชื่อมต่อขาดหาย</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">เพิ่มลงในคิวแจมแล้ว</string> <string name="jam_queue_added_track">เพิ่มลงในคิวแจมแล้ว</string>

View File

@ -292,25 +292,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Uygulama içinde web içeriği görüntüleme</string> <string name="plugin_permissions_capability_webview_desc">Uygulama içinde web içeriği görüntüleme</string>
<string name="settings_section_jam">Grup Jam</string> <string name="settings_section_jam">Grup Jam</string>
<string name="settings_jam_broker_title">MQTT Aracısı</string> <string name="settings_jam_server_test">Bağlantıyı test et</string>
<string name="settings_jam_broker_host">Aracı ana bilgisayarı</string> <string name="settings_jam_server_testing">Test ediliyor…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">%1$d ms\'de bağlandı</string>
<string name="settings_jam_broker_port">Aracı portu</string> <string name="settings_jam_server_test_fail">Başarısız: %1$s</string>
<string name="settings_jam_broker_tls">TLS kullan</string>
<string name="settings_jam_broker_username">Kullanıcı adı (isteğe bağlı)</string>
<string name="settings_jam_broker_password">Şifre (isteğe bağlı)</string>
<string name="settings_jam_broker_client_id">İstemci kimliği ön eki</string>
<string name="settings_jam_broker_client_id_subtitle">Bu Spotube örneğini tanımlamak için kullanılır</string>
<string name="settings_jam_broker_tls_subtitle">Aracı bağlantısını şifrele</string>
<string name="settings_jam_broker_diagnostics_subtitle">Oda başlatmadan önce geçerli aracı ayarlarını kontrol et.</string>
<string name="settings_jam_broker_test">Bağlantıyı test et</string>
<string name="settings_jam_broker_testing">Test ediliyor…</string>
<string name="settings_jam_broker_test_ok">%1$d ms\'de bağlandı</string>
<string name="settings_jam_broker_test_fail">Başarısız: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Geçici aracı — kendin barındırmak için kendi aracını yapılandır</string>
<string name="settings_jam_section_connection">Bağlantı</string> <string name="settings_jam_section_connection">Bağlantı</string>
<string name="settings_jam_section_credentials">Kimlik bilgileri</string>
<string name="settings_jam_section_identity">Kimlik</string>
<string name="settings_jam_section_diagnostics">Tanılama</string> <string name="settings_jam_section_diagnostics">Tanılama</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Bağlanıyor…</string> <string name="jam_setup_working">Bağlanıyor…</string>
<string name="jam_setup_code_label">Oda kodu</string> <string name="jam_setup_code_label">Oda kodu</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Aracı: %1$s</string>
<string name="jam_connection_missing">Oda açmak veya bir odaya katılmak için Ayarlar\'da bir aracı ayarla.</string>
<string name="jam_connection_settings">Aracı ayarları</string>
<string name="jam_session_connected">Bağlandı</string> <string name="jam_session_connected">Bağlandı</string>
<string name="jam_session_connecting">Yeniden bağlanıyor…</string> <string name="jam_session_connecting">Yeniden bağlanıyor…</string>
<string name="jam_invite_copy">Kodu kopyala</string> <string name="jam_invite_copy">Kodu kopyala</string>
@ -345,6 +328,28 @@
<string name="jam_error_join">Jam oturumuna katılınamadı</string> <string name="jam_error_join">Jam oturumuna katılınamadı</string>
<string name="jam_error_invalid_code">Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir.</string> <string name="jam_error_invalid_code">Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir.</string>
<string name="jam_share_message">Spotube Jam\'ime şu kodla katıl: %1$s</string> <string name="jam_share_message">Spotube Jam\'ime şu kodla katıl: %1$s</string>
<string name="settings_jam_server_title">Oda sunucusu</string>
<string name="settings_jam_server_url">Sunucu URL’si</string>
<string name="settings_jam_server_url_invalid">https:// ile başlayan bir URL gir.</string>
<string name="settings_jam_server_diagnostics_subtitle">Oda başlatmadan önce oda sunucusunu kontrol et.</string>
<string name="settings_jam_server_note">Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için github.com/team-spotube/jam-server adresindeki sunucuyu kur ve URL’sini gir.</string>
<string name="settings_jam_section_you">Sen</string>
<string name="jam_connection_host">Sunucu: %1$s</string>
<string name="jam_connection_missing">Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle.</string>
<string name="jam_connection_settings">Sunucu ayarları</string>
<string name="jam_participants_you">%1$s (sen)</string>
<string name="jam_name_title">Adın</string>
<string name="jam_name_subtitle">Jam’deki diğer kişiler seni %1$s olarak görür</string>
<string name="jam_name_description">Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur.</string>
<string name="jam_name_too_long">En fazla %1$d karakter kullan.</string>
<string name="jam_error_no_server">Jam sunucusu ayarlanmamış</string>
<string name="jam_error_server_unreachable">Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et.</string>
<string name="jam_error_server_incompatible">Jam sunucusu uyumsuz bir sürüm çalıştırıyor.</string>
<string name="jam_error_room_not_found">Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene.</string>
<string name="jam_error_room_full">Bu oda dolu.</string>
<string name="jam_error_banned">Oda sahibi seni bu odadan engelledi.</string>
<string name="jam_error_kicked">Oda sahibi seni odadan çıkardı.</string>
<string name="jam_error_replaced">Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı.</string>
<string name="player_go_to_album">Albüme git</string> <string name="player_go_to_album">Albüme git</string>
<string name="player_sleep_timer">Uyku zamanlayıcısı</string> <string name="player_sleep_timer">Uyku zamanlayıcısı</string>
@ -386,7 +391,7 @@
<string name="welcome_guide_title">Spotube nasıl kullanılır</string> <string name="welcome_guide_title">Spotube nasıl kullanılır</string>
<string name="welcome_guide_subtitle">Dört hızlı adım ve dinlemeye başlıyorsun.</string> <string name="welcome_guide_subtitle">Dört hızlı adım ve dinlemeye başlıyorsun.</string>
<string name="welcome_guide_signin_title">Giriş yap</string> <string name="welcome_guide_signin_title">Giriş yap</string>
<string name="welcome_guide_signin_desc">Spotify hesabını bağla veya kendi katalog eklentini seç.</string> <string name="welcome_guide_signin_desc">hesabını bağla veya kendi katalog eklentini seç.</string>
<string name="welcome_guide_search_title">Ara ve çal</string> <string name="welcome_guide_search_title">Ara ve çal</string>
<string name="welcome_guide_search_desc">Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas.</string> <string name="welcome_guide_search_desc">Herhangi bir şarkıyı, albümü veya sanatçıyı bul, sonra çal\'a bas.</string>
<string name="welcome_guide_offline_title">Çevrimdışı dinle</string> <string name="welcome_guide_offline_title">Çevrimdışı dinle</string>
@ -517,14 +522,9 @@
<string name="jam_destination_suggest">Jam\'e öner</string> <string name="jam_destination_suggest">Jam\'e öner</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Jam aracısı yapılandırılmadı</string>
<string name="jam_error_broker_host_empty">Aracı ana bilgisayarı boş</string>
<string name="jam_error_connection_failed">Bağlantı başarısız</string> <string name="jam_error_connection_failed">Bağlantı başarısız</string>
<string name="jam_error_connection_lost">Bağlantı kesildi</string> <string name="jam_error_connection_lost">Bağlantı kesildi</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Aracı</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Jam sırasına eklendi</string> <string name="jam_queue_added_track">Jam sırasına eklendi</string>

View File

@ -300,25 +300,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Показ вебвмісту всередині застосунку</string> <string name="plugin_permissions_capability_webview_desc">Показ вебвмісту всередині застосунку</string>
<string name="settings_section_jam">Груповий джем</string> <string name="settings_section_jam">Груповий джем</string>
<string name="settings_jam_broker_title">MQTT-брокер</string> <string name="settings_jam_server_test">Перевірити підключення</string>
<string name="settings_jam_broker_host">Хост брокера</string> <string name="settings_jam_server_testing">Перевірка…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Підключено за %1$d мс</string>
<string name="settings_jam_broker_port">Порт брокера</string> <string name="settings_jam_server_test_fail">Помилка: %1$s</string>
<string name="settings_jam_broker_tls">Використовувати TLS</string>
<string name="settings_jam_broker_username">Ім’я користувача (необов’язково)</string>
<string name="settings_jam_broker_password">Пароль (необов’язково)</string>
<string name="settings_jam_broker_client_id">Префікс ID клієнта</string>
<string name="settings_jam_broker_client_id_subtitle">Використовується для ідентифікації цього екземпляра Spotube</string>
<string name="settings_jam_broker_tls_subtitle">Шифрувати підключення до брокера</string>
<string name="settings_jam_broker_diagnostics_subtitle">Перевірте поточні налаштування брокера перед створенням кімнати.</string>
<string name="settings_jam_broker_test">Перевірити підключення</string>
<string name="settings_jam_broker_testing">Перевірка…</string>
<string name="settings_jam_broker_test_ok">Підключено за %1$d мс</string>
<string name="settings_jam_broker_test_fail">Помилка: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Тимчасовий брокер — налаштуйте власний, щоб розміщувати джеми самостійно</string>
<string name="settings_jam_section_connection">Підключення</string> <string name="settings_jam_section_connection">Підключення</string>
<string name="settings_jam_section_credentials">Облікові дані</string>
<string name="settings_jam_section_identity">Ідентифікація</string>
<string name="settings_jam_section_diagnostics">Діагностика</string> <string name="settings_jam_section_diagnostics">Діагностика</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Підключення…</string> <string name="jam_setup_working">Підключення…</string>
<string name="jam_setup_code_label">Код кімнати</string> <string name="jam_setup_code_label">Код кімнати</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Брокер: %1$s</string>
<string name="jam_connection_missing">Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї.</string>
<string name="jam_connection_settings">Налаштування брокера</string>
<string name="jam_session_connected">Підключено</string> <string name="jam_session_connected">Підключено</string>
<string name="jam_session_connecting">Повторне підключення…</string> <string name="jam_session_connecting">Повторне підключення…</string>
<string name="jam_invite_copy">Копіювати код</string> <string name="jam_invite_copy">Копіювати код</string>
@ -353,6 +336,28 @@
<string name="jam_error_join">Не вдалося приєднатися до джему</string> <string name="jam_error_join">Не вдалося приєднатися до джему</string>
<string name="jam_error_invalid_code">Введіть код кімнати з шести літер і цифр.</string> <string name="jam_error_invalid_code">Введіть код кімнати з шести літер і цифр.</string>
<string name="jam_share_message">Приєднуйся до мого джему в Spotube за кодом: %1$s</string> <string name="jam_share_message">Приєднуйся до мого джему в Spotube за кодом: %1$s</string>
<string name="settings_jam_server_title">Сервер кімнат</string>
<string name="settings_jam_server_url">URL сервера</string>
<string name="settings_jam_server_url_invalid">Введіть URL, що починається з https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Перевірте сервер кімнат перед створенням кімнати.</string>
<string name="settings_jam_server_note">Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть сервер із github.com/team-spotube/jam-server і вкажіть його URL.</string>
<string name="settings_jam_section_you">Ви</string>
<string name="jam_connection_host">Сервер: %1$s</string>
<string name="jam_connection_missing">Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї.</string>
<string name="jam_connection_settings">Налаштування сервера</string>
<string name="jam_participants_you">%1$s (ви)</string>
<string name="jam_name_title">Ваше ім’я</string>
<string name="jam_name_subtitle">Інші учасники джему бачать вас як %1$s</string>
<string name="jam_name_description">Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї.</string>
<string name="jam_name_too_long">Не більше %1$d символів.</string>
<string name="jam_error_no_server">Сервер джему не налаштовано</string>
<string name="jam_error_server_unreachable">Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем.</string>
<string name="jam_error_server_incompatible">Сервер джему використовує несумісну версію.</string>
<string name="jam_error_room_not_found">Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз.</string>
<string name="jam_error_room_full">Ця кімната заповнена.</string>
<string name="jam_error_banned">Організатор заблокував вас у цій кімнаті.</string>
<string name="jam_error_kicked">Організатор видалив вас із кімнати.</string>
<string name="jam_error_replaced">Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито.</string>
<string name="player_go_to_album">Перейти до альбому</string> <string name="player_go_to_album">Перейти до альбому</string>
<string name="player_sleep_timer">Таймер сну</string> <string name="player_sleep_timer">Таймер сну</string>
@ -394,7 +399,7 @@
<string name="welcome_guide_title">Як користуватися Spotube</string> <string name="welcome_guide_title">Як користуватися Spotube</string>
<string name="welcome_guide_subtitle">Чотири прості кроки — і музика вже грає.</string> <string name="welcome_guide_subtitle">Чотири прості кроки — і музика вже грає.</string>
<string name="welcome_guide_signin_title">Вхід</string> <string name="welcome_guide_signin_title">Вхід</string>
<string name="welcome_guide_signin_desc">Підключіть акаунт Spotify або виберіть власний плагін каталогу.</string> <string name="welcome_guide_signin_desc">Підключіть акаунт або виберіть власний плагін каталогу.</string>
<string name="welcome_guide_search_title">Шукайте й слухайте</string> <string name="welcome_guide_search_title">Шукайте й слухайте</string>
<string name="welcome_guide_search_desc">Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити».</string> <string name="welcome_guide_search_desc">Знайдіть будь-який трек, альбом чи виконавця й натисніть «Відтворити».</string>
<string name="welcome_guide_offline_title">Слухайте офлайн</string> <string name="welcome_guide_offline_title">Слухайте офлайн</string>
@ -525,14 +530,9 @@
<string name="jam_destination_suggest">Запропонувати в джем</string> <string name="jam_destination_suggest">Запропонувати в джем</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Брокер для джему не налаштовано</string>
<string name="jam_error_broker_host_empty">Не вказано хост брокера</string>
<string name="jam_error_connection_failed">Не вдалося підключитися</string> <string name="jam_error_connection_failed">Не вдалося підключитися</string>
<string name="jam_error_connection_lost">З’єднання втрачено</string> <string name="jam_error_connection_lost">З’єднання втрачено</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Брокер</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Додано до черги джему</string> <string name="jam_queue_added_track">Додано до черги джему</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Hiển thị nội dung web trong ứng dụng</string> <string name="plugin_permissions_capability_webview_desc">Hiển thị nội dung web trong ứng dụng</string>
<string name="settings_section_jam">Jam nhóm</string> <string name="settings_section_jam">Jam nhóm</string>
<string name="settings_jam_broker_title">MQTT Broker</string> <string name="settings_jam_server_test">Kiểm tra kết nối</string>
<string name="settings_jam_broker_host">Máy chủ broker</string> <string name="settings_jam_server_testing">Đang kiểm tra…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">Đã kết nối trong %1$d ms</string>
<string name="settings_jam_broker_port">Cổng broker</string> <string name="settings_jam_server_test_fail">Thất bại: %1$s</string>
<string name="settings_jam_broker_tls">Dùng TLS</string>
<string name="settings_jam_broker_username">Tên người dùng (không bắt buộc)</string>
<string name="settings_jam_broker_password">Mật khẩu (không bắt buộc)</string>
<string name="settings_jam_broker_client_id">Tiền tố Client ID</string>
<string name="settings_jam_broker_client_id_subtitle">Dùng để nhận diện Spotube này</string>
<string name="settings_jam_broker_tls_subtitle">Mã hóa kết nối tới broker</string>
<string name="settings_jam_broker_diagnostics_subtitle">Kiểm tra cài đặt broker hiện tại trước khi tạo phòng.</string>
<string name="settings_jam_broker_test">Kiểm tra kết nối</string>
<string name="settings_jam_broker_testing">Đang kiểm tra…</string>
<string name="settings_jam_broker_test_ok">Đã kết nối trong %1$d ms</string>
<string name="settings_jam_broker_test_fail">Thất bại: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Broker tạm thời — hãy cấu hình broker riêng để tự lưu trữ</string>
<string name="settings_jam_section_connection">Kết nối</string> <string name="settings_jam_section_connection">Kết nối</string>
<string name="settings_jam_section_credentials">Thông tin đăng nhập</string>
<string name="settings_jam_section_identity">Danh tính</string>
<string name="settings_jam_section_diagnostics">Chẩn đoán</string> <string name="settings_jam_section_diagnostics">Chẩn đoán</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">Đang kết nối…</string> <string name="jam_setup_working">Đang kết nối…</string>
<string name="jam_setup_code_label">Mã phòng</string> <string name="jam_setup_code_label">Mã phòng</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string>
<string name="jam_connection_missing">Hãy thiết lập broker trong Cài đặt để tạo hoặc tham gia phòng.</string>
<string name="jam_connection_settings">Cài đặt broker</string>
<string name="jam_session_connected">Đã kết nối</string> <string name="jam_session_connected">Đã kết nối</string>
<string name="jam_session_connecting">Đang kết nối lại…</string> <string name="jam_session_connecting">Đang kết nối lại…</string>
<string name="jam_invite_copy">Sao chép mã</string> <string name="jam_invite_copy">Sao chép mã</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">Không thể tham gia phiên Jam</string> <string name="jam_error_join">Không thể tham gia phiên Jam</string>
<string name="jam_error_invalid_code">Nhập mã phòng gồm sáu ký tự chữ và số.</string> <string name="jam_error_invalid_code">Nhập mã phòng gồm sáu ký tự chữ và số.</string>
<string name="jam_share_message">Tham gia Jam Spotube của mình với mã: %1$s</string> <string name="jam_share_message">Tham gia Jam Spotube của mình với mã: %1$s</string>
<string name="settings_jam_server_title">Máy chủ phòng</string>
<string name="settings_jam_server_url">URL máy chủ</string>
<string name="settings_jam_server_url_invalid">Nhập URL bắt đầu bằng https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Kiểm tra máy chủ phòng trước khi tạo phòng.</string>
<string name="settings_jam_server_note">Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai máy chủ từ github.com/team-spotube/jam-server rồi nhập URL của nó.</string>
<string name="settings_jam_section_you">Bạn</string>
<string name="jam_connection_host">Máy chủ: %1$s</string>
<string name="jam_connection_missing">Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng.</string>
<string name="jam_connection_settings">Cài đặt máy chủ</string>
<string name="jam_participants_you">%1$s (bạn)</string>
<string name="jam_name_title">Tên của bạn</string>
<string name="jam_name_subtitle">Mọi người trong jam thấy bạn là %1$s</string>
<string name="jam_name_description">Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng.</string>
<string name="jam_name_too_long">Dùng tối đa %1$d ký tự.</string>
<string name="jam_error_no_server">Chưa cấu hình máy chủ jam</string>
<string name="jam_error_server_unreachable">Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm.</string>
<string name="jam_error_server_incompatible">Máy chủ jam đang chạy phiên bản không tương thích.</string>
<string name="jam_error_room_not_found">Không có phòng nào với mã này. Hãy kiểm tra và thử lại.</string>
<string name="jam_error_room_full">Phòng này đã đầy.</string>
<string name="jam_error_banned">Chủ phòng đã cấm bạn khỏi phòng này.</string>
<string name="jam_error_kicked">Chủ phòng đã xóa bạn khỏi phòng.</string>
<string name="jam_error_replaced">Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng.</string>
<string name="player_go_to_album">Đi tới album</string> <string name="player_go_to_album">Đi tới album</string>
<string name="player_sleep_timer">Hẹn giờ tắt</string> <string name="player_sleep_timer">Hẹn giờ tắt</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">Cách dùng Spotube</string> <string name="welcome_guide_title">Cách dùng Spotube</string>
<string name="welcome_guide_subtitle">Bốn bước nhanh là bạn đã có thể nghe nhạc.</string> <string name="welcome_guide_subtitle">Bốn bước nhanh là bạn đã có thể nghe nhạc.</string>
<string name="welcome_guide_signin_title">Đăng nhập</string> <string name="welcome_guide_signin_title">Đăng nhập</string>
<string name="welcome_guide_signin_desc">Kết nối tài khoản Spotify hoặc chọn plugin danh mục của riêng bạn.</string> <string name="welcome_guide_signin_desc">Kết nối tài khoản hoặc chọn plugin danh mục của riêng bạn.</string>
<string name="welcome_guide_search_title">Tìm kiếm và phát</string> <string name="welcome_guide_search_title">Tìm kiếm và phát</string>
<string name="welcome_guide_search_desc">Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát.</string> <string name="welcome_guide_search_desc">Tìm bất kỳ bài hát, album hay nghệ sĩ nào rồi nhấn phát.</string>
<string name="welcome_guide_offline_title">Nghe ngoại tuyến</string> <string name="welcome_guide_offline_title">Nghe ngoại tuyến</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">Đề xuất cho Jam</string> <string name="jam_destination_suggest">Đề xuất cho Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">Chưa cấu hình broker cho Jam</string>
<string name="jam_error_broker_host_empty">Máy chủ broker đang trống</string>
<string name="jam_error_connection_failed">Kết nối thất bại</string> <string name="jam_error_connection_failed">Kết nối thất bại</string>
<string name="jam_error_connection_lost">Mất kết nối</string> <string name="jam_error_connection_lost">Mất kết nối</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">ẩn danh</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Đã thêm vào danh sách chờ của Jam</string> <string name="jam_queue_added_track">Đã thêm vào danh sách chờ của Jam</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">在應用程式內顯示網頁內容</string> <string name="plugin_permissions_capability_webview_desc">在應用程式內顯示網頁內容</string>
<string name="settings_section_jam">群組 Jam</string> <string name="settings_section_jam">群組 Jam</string>
<string name="settings_jam_broker_title">MQTT 代理伺服器</string> <string name="settings_jam_server_test">測試連線</string>
<string name="settings_jam_broker_host">代理伺服器主機</string> <string name="settings_jam_server_testing">測試中…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">已連線,耗時 %1$d 毫秒</string>
<string name="settings_jam_broker_port">代理伺服器連接埠</string> <string name="settings_jam_server_test_fail">失敗:%1$s</string>
<string name="settings_jam_broker_tls">使用 TLS</string>
<string name="settings_jam_broker_username">使用者名稱(選填)</string>
<string name="settings_jam_broker_password">密碼(選填)</string>
<string name="settings_jam_broker_client_id">用戶端 ID 前綴</string>
<string name="settings_jam_broker_client_id_subtitle">用於識別這個 Spotube</string>
<string name="settings_jam_broker_tls_subtitle">加密與代理伺服器的連線</string>
<string name="settings_jam_broker_diagnostics_subtitle">在建立房間前檢查目前的代理伺服器設定。</string>
<string name="settings_jam_broker_test">測試連線</string>
<string name="settings_jam_broker_testing">測試中…</string>
<string name="settings_jam_broker_test_ok">已連線,耗時 %1$d 毫秒</string>
<string name="settings_jam_broker_test_fail">失敗:%1$s</string>
<string name="settings_jam_broker_placeholder_note">預留的代理伺服器 — 請設定你自己的伺服器以自行架設</string>
<string name="settings_jam_section_connection">連線</string> <string name="settings_jam_section_connection">連線</string>
<string name="settings_jam_section_credentials">憑證</string>
<string name="settings_jam_section_identity">身分</string>
<string name="settings_jam_section_diagnostics">診斷</string> <string name="settings_jam_section_diagnostics">診斷</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">連線中…</string> <string name="jam_setup_working">連線中…</string>
<string name="jam_setup_code_label">房間代碼</string> <string name="jam_setup_code_label">房間代碼</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">代理伺服器:%1$s</string>
<string name="jam_connection_missing">請先在「設定」中設定代理伺服器,才能建立或加入房間。</string>
<string name="jam_connection_settings">代理伺服器設定</string>
<string name="jam_session_connected">已連線</string> <string name="jam_session_connected">已連線</string>
<string name="jam_session_connecting">重新連線中…</string> <string name="jam_session_connecting">重新連線中…</string>
<string name="jam_invite_copy">複製代碼</string> <string name="jam_invite_copy">複製代碼</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">無法加入 Jam</string> <string name="jam_error_join">無法加入 Jam</string>
<string name="jam_error_invalid_code">請輸入由英文字母和數字組成的六位房間代碼。</string> <string name="jam_error_invalid_code">請輸入由英文字母和數字組成的六位房間代碼。</string>
<string name="jam_share_message">用代碼 %1$s 加入我的 Spotube Jam</string> <string name="jam_share_message">用代碼 %1$s 加入我的 Spotube Jam</string>
<string name="settings_jam_server_title">房間伺服器</string>
<string name="settings_jam_server_url">伺服器網址</string>
<string name="settings_jam_server_url_invalid">請輸入以 https:// 開頭的網址</string>
<string name="settings_jam_server_diagnostics_subtitle">建立房間前先檢查房間伺服器。</string>
<string name="settings_jam_server_note">Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 github.com/team-spotube/jam-server 中的伺服器並輸入其網址。</string>
<string name="settings_jam_section_you">你</string>
<string name="jam_connection_host">伺服器:%1$s</string>
<string name="jam_connection_missing">請在設定中指定房間伺服器,以建立或加入房間。</string>
<string name="jam_connection_settings">伺服器設定</string>
<string name="jam_participants_you">%1$s(你)</string>
<string name="jam_name_title">你的名稱</string>
<string name="jam_name_subtitle">Jam 裡的其他人會看到你是 %1$s</string>
<string name="jam_name_description">Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。</string>
<string name="jam_name_too_long">最多 %1$d 個字元。</string>
<string name="jam_error_no_server">尚未設定 Jam 伺服器</string>
<string name="jam_error_server_unreachable">無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。</string>
<string name="jam_error_server_incompatible">Jam 伺服器的版本不相容。</string>
<string name="jam_error_room_not_found">沒有使用此代碼的房間。請檢查後再試一次。</string>
<string name="jam_error_room_full">此房間已滿。</string>
<string name="jam_error_banned">主持人已將你從此房間封鎖。</string>
<string name="jam_error_kicked">主持人已將你移出房間。</string>
<string name="jam_error_replaced">此房間已在這部裝置上再次開啟,因此這個連線已關閉。</string>
<string name="player_go_to_album">前往專輯</string> <string name="player_go_to_album">前往專輯</string>
<string name="player_sleep_timer">睡眠定時器</string> <string name="player_sleep_timer">睡眠定時器</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">如何使用 Spotube</string> <string name="welcome_guide_title">如何使用 Spotube</string>
<string name="welcome_guide_subtitle">只要四個簡單步驟,就能開始聆聽。</string> <string name="welcome_guide_subtitle">只要四個簡單步驟,就能開始聆聽。</string>
<string name="welcome_guide_signin_title">登入</string> <string name="welcome_guide_signin_title">登入</string>
<string name="welcome_guide_signin_desc">連結你的 Spotify 帳戶,或選擇你自己的目錄外掛程式。</string> <string name="welcome_guide_signin_desc">連結你的帳戶,或選擇你自己的目錄外掛程式。</string>
<string name="welcome_guide_search_title">搜尋並播放</string> <string name="welcome_guide_search_title">搜尋並播放</string>
<string name="welcome_guide_search_desc">找到任何歌曲、專輯或藝人,然後按下播放。</string> <string name="welcome_guide_search_desc">找到任何歌曲、專輯或藝人,然後按下播放。</string>
<string name="welcome_guide_offline_title">離線聆聽</string> <string name="welcome_guide_offline_title">離線聆聽</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">推薦到 Jam</string> <string name="jam_destination_suggest">推薦到 Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">尚未設定 Jam 代理伺服器</string>
<string name="jam_error_broker_host_empty">代理伺服器主機為空白</string>
<string name="jam_error_connection_failed">連線失敗</string> <string name="jam_error_connection_failed">連線失敗</string>
<string name="jam_error_connection_lost">連線中斷</string> <string name="jam_error_connection_lost">連線中斷</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">代理伺服器</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">已加入 Jam 待播清單</string> <string name="jam_queue_added_track">已加入 Jam 待播清單</string>

View File

@ -288,25 +288,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">在应用内显示网页内容</string> <string name="plugin_permissions_capability_webview_desc">在应用内显示网页内容</string>
<string name="settings_section_jam">一起听</string> <string name="settings_section_jam">一起听</string>
<string name="settings_jam_broker_title">MQTT 代理服务器</string> <string name="settings_jam_server_test">测试连接</string>
<string name="settings_jam_broker_host">代理服务器主机</string> <string name="settings_jam_server_testing">正在测试…</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_test_ok">已连接,耗时 %1$d 毫秒</string>
<string name="settings_jam_broker_port">代理服务器端口</string> <string name="settings_jam_server_test_fail">失败:%1$s</string>
<string name="settings_jam_broker_tls">使用 TLS</string>
<string name="settings_jam_broker_username">用户名(可选)</string>
<string name="settings_jam_broker_password">密码(可选)</string>
<string name="settings_jam_broker_client_id">客户端 ID 前缀</string>
<string name="settings_jam_broker_client_id_subtitle">用于识别此 Spotube 实例</string>
<string name="settings_jam_broker_tls_subtitle">加密与代理服务器的连接</string>
<string name="settings_jam_broker_diagnostics_subtitle">创建房间前,检查当前的代理服务器设置。</string>
<string name="settings_jam_broker_test">测试连接</string>
<string name="settings_jam_broker_testing">正在测试…</string>
<string name="settings_jam_broker_test_ok">已连接,耗时 %1$d 毫秒</string>
<string name="settings_jam_broker_test_fail">失败:%1$s</string>
<string name="settings_jam_broker_placeholder_note">占位代理服务器 — 如需自托管,请配置你自己的服务器</string>
<string name="settings_jam_section_connection">连接</string> <string name="settings_jam_section_connection">连接</string>
<string name="settings_jam_section_credentials">凭据</string>
<string name="settings_jam_section_identity">身份</string>
<string name="settings_jam_section_diagnostics">诊断</string> <string name="settings_jam_section_diagnostics">诊断</string>
<!-- Group Jam --> <!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">正在连接…</string> <string name="jam_setup_working">正在连接…</string>
<string name="jam_setup_code_label">房间码</string> <string name="jam_setup_code_label">房间码</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">代理服务器:%1$s</string>
<string name="jam_connection_missing">请先在设置中配置代理服务器,才能创建或加入房间。</string>
<string name="jam_connection_settings">代理服务器设置</string>
<string name="jam_session_connected">已连接</string> <string name="jam_session_connected">已连接</string>
<string name="jam_session_connecting">正在重新连接…</string> <string name="jam_session_connecting">正在重新连接…</string>
<string name="jam_invite_copy">复制房间码</string> <string name="jam_invite_copy">复制房间码</string>
@ -341,6 +324,28 @@
<string name="jam_error_join">加入一起听会话失败</string> <string name="jam_error_join">加入一起听会话失败</string>
<string name="jam_error_invalid_code">请输入由字母和数字组成的六位房间码。</string> <string name="jam_error_invalid_code">请输入由字母和数字组成的六位房间码。</string>
<string name="jam_share_message">用房间码加入我的 Spotube 一起听:%1$s</string> <string name="jam_share_message">用房间码加入我的 Spotube 一起听:%1$s</string>
<string name="settings_jam_server_title">房间服务器</string>
<string name="settings_jam_server_url">服务器网址</string>
<string name="settings_jam_server_url_invalid">请输入以 https:// 开头的网址</string>
<string name="settings_jam_server_diagnostics_subtitle">创建房间前先检查房间服务器。</string>
<string name="settings_jam_server_note">一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 github.com/team-spotube/jam-server 中的服务器并填写其网址。</string>
<string name="settings_jam_section_you">你</string>
<string name="jam_connection_host">服务器:%1$s</string>
<string name="jam_connection_missing">请在设置中指定房间服务器,以创建或加入房间。</string>
<string name="jam_connection_settings">服务器设置</string>
<string name="jam_participants_you">%1$s(你)</string>
<string name="jam_name_title">你的名字</string>
<string name="jam_name_subtitle">房间里的其他人看到你的名字是 %1$s</string>
<string name="jam_name_description">房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。</string>
<string name="jam_name_too_long">最多 %1$d 个字符。</string>
<string name="jam_error_no_server">未配置一起听服务器</string>
<string name="jam_error_server_unreachable">无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。</string>
<string name="jam_error_server_incompatible">一起听服务器的版本不兼容。</string>
<string name="jam_error_room_not_found">没有使用该代码的房间。请检查后重试。</string>
<string name="jam_error_room_full">该房间已满。</string>
<string name="jam_error_banned">房主已将你从此房间封禁。</string>
<string name="jam_error_kicked">房主已将你移出房间。</string>
<string name="jam_error_replaced">此房间已在本设备上再次打开,因此此连接已关闭。</string>
<string name="player_go_to_album">转到专辑</string> <string name="player_go_to_album">转到专辑</string>
<string name="player_sleep_timer">睡眠定时器</string> <string name="player_sleep_timer">睡眠定时器</string>
@ -382,7 +387,7 @@
<string name="welcome_guide_title">如何使用 Spotube</string> <string name="welcome_guide_title">如何使用 Spotube</string>
<string name="welcome_guide_subtitle">只需四个简单步骤,即可开始聆听。</string> <string name="welcome_guide_subtitle">只需四个简单步骤,即可开始聆听。</string>
<string name="welcome_guide_signin_title">登录</string> <string name="welcome_guide_signin_title">登录</string>
<string name="welcome_guide_signin_desc">连接你的 Spotify 帐号,或选择你自己的曲库插件。</string> <string name="welcome_guide_signin_desc">连接你的帐号,或选择你自己的曲库插件。</string>
<string name="welcome_guide_search_title">搜索并播放</string> <string name="welcome_guide_search_title">搜索并播放</string>
<string name="welcome_guide_search_desc">查找任意歌曲、专辑或艺人,然后点击播放。</string> <string name="welcome_guide_search_desc">查找任意歌曲、专辑或艺人,然后点击播放。</string>
<string name="welcome_guide_offline_title">离线收听</string> <string name="welcome_guide_offline_title">离线收听</string>
@ -513,14 +518,9 @@
<string name="jam_destination_suggest">推荐到一起听</string> <string name="jam_destination_suggest">推荐到一起听</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">未配置一起听代理服务器</string>
<string name="jam_error_broker_host_empty">代理服务器主机为空</string>
<string name="jam_error_connection_failed">连接失败</string> <string name="jam_error_connection_failed">连接失败</string>
<string name="jam_error_connection_lost">连接已断开</string> <string name="jam_error_connection_lost">连接已断开</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">代理服务器</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">已添加到一起听队列</string> <string name="jam_queue_added_track">已添加到一起听队列</string>

View File

@ -310,26 +310,18 @@
<string name="plugin_permissions_capability_webview_title">WebView</string> <string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string> <string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
<string name="settings_section_jam">Group Jam</string> <string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT Broker</string> <string name="settings_jam_server_title">Room server</string>
<string name="settings_jam_broker_host">Broker host</string> <string name="settings_jam_server_url">Server URL</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string> <string name="settings_jam_server_url_invalid">Enter a URL starting with https://</string>
<string name="settings_jam_broker_port">Broker port</string> <string name="settings_jam_server_diagnostics_subtitle">Check the room server before starting a room.</string>
<string name="settings_jam_broker_tls">Use TLS</string> <string name="settings_jam_server_note">Jam uses Spotube’s free room server. To self-host, deploy the server from github.com/team-spotube/jam-server and enter its URL.</string>
<string name="settings_jam_broker_username">Username (optional)</string> <string name="settings_jam_server_test">Test connection</string>
<string name="settings_jam_broker_password">Password (optional)</string> <string name="settings_jam_server_testing">Testing…</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string> <string name="settings_jam_server_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_client_id_subtitle">Used to identify this Spotube instance</string> <string name="settings_jam_server_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_tls_subtitle">Encrypt the broker connection</string>
<string name="settings_jam_broker_diagnostics_subtitle">Check the current broker settings before starting a room.</string>
<string name="settings_jam_broker_test">Test connection</string>
<string name="settings_jam_broker_testing">Testing…</string>
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
<string name="settings_jam_section_connection">Connection</string> <string name="settings_jam_section_connection">Connection</string>
<string name="settings_jam_section_credentials">Credentials</string>
<string name="settings_jam_section_identity">Identity</string>
<string name="settings_jam_section_diagnostics">Diagnostics</string> <string name="settings_jam_section_diagnostics">Diagnostics</string>
<string name="settings_jam_section_you">You</string>
<!-- Group Jam --> <!-- Group Jam -->
<string name="jam_screen_title">Group Jam</string> <string name="jam_screen_title">Group Jam</string>
@ -341,9 +333,9 @@
<string name="jam_setup_working">Connecting…</string> <string name="jam_setup_working">Connecting…</string>
<string name="jam_setup_code_label">Room code</string> <string name="jam_setup_code_label">Room code</string>
<string name="jam_setup_code_placeholder">ABC123</string> <string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %1$s</string> <string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string> <string name="jam_connection_missing">Set a room server in Settings to host or join a room.</string>
<string name="jam_connection_settings">Broker settings</string> <string name="jam_connection_settings">Server settings</string>
<string name="jam_session_connected">Connected</string> <string name="jam_session_connected">Connected</string>
<string name="jam_session_connecting">Reconnecting…</string> <string name="jam_session_connecting">Reconnecting…</string>
<string name="jam_invite_copy">Copy code</string> <string name="jam_invite_copy">Copy code</string>
@ -352,6 +344,7 @@
<string name="jam_participants_heading">Participants (%1$d)</string> <string name="jam_participants_heading">Participants (%1$d)</string>
<string name="jam_participants_host">Host</string> <string name="jam_participants_host">Host</string>
<string name="jam_participants_guest">Listener</string> <string name="jam_participants_guest">Listener</string>
<string name="jam_participants_you">%1$s (you)</string>
<string name="jam_participants_waiting">Waiting for listeners…</string> <string name="jam_participants_waiting">Waiting for listeners…</string>
<string name="jam_participants_remove">Remove</string> <string name="jam_participants_remove">Remove</string>
<string name="jam_participants_ban">Ban</string> <string name="jam_participants_ban">Ban</string>
@ -363,6 +356,145 @@
<string name="jam_error_join">Failed to join jam session</string> <string name="jam_error_join">Failed to join jam session</string>
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string> <string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string> <string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
<string name="jam_name_title">Your name</string>
<string name="jam_name_subtitle">Others in the jam see you as %1$s</string>
<string name="jam_name_description">Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room.</string>
<string name="jam_name_too_long">Use at most %1$d characters.</string>
<!-- A generated name: adjective, then animal (e.g. "Mellow Otter"). Reorder for your language if needed. -->
<string name="jam_name_format">%1$s %2$s</string>
<!-- Word lists for generated jam names. Translations may use their own words and any number of them. -->
<string-array name="jam_name_adjectives">
<item>Brave</item>
<item>Bright</item>
<item>Calm</item>
<item>Cheerful</item>
<item>Chill</item>
<item>Clever</item>
<item>Cosmic</item>
<item>Cozy</item>
<item>Curious</item>
<item>Dapper</item>
<item>Dreamy</item>
<item>Eager</item>
<item>Electric</item>
<item>Fancy</item>
<item>Fearless</item>
<item>Fizzy</item>
<item>Fluffy</item>
<item>Funky</item>
<item>Gentle</item>
<item>Giddy</item>
<item>Glowing</item>
<item>Golden</item>
<item>Groovy</item>
<item>Happy</item>
<item>Humble</item>
<item>Jazzy</item>
<item>Jolly</item>
<item>Jumpy</item>
<item>Kind</item>
<item>Lively</item>
<item>Lucky</item>
<item>Lunar</item>
<item>Mellow</item>
<item>Merry</item>
<item>Mighty</item>
<item>Misty</item>
<item>Nimble</item>
<item>Peppy</item>
<item>Plucky</item>
<item>Polite</item>
<item>Quick</item>
<item>Quiet</item>
<item>Rapid</item>
<item>Rhythmic</item>
<item>Rosy</item>
<item>Silly</item>
<item>Sleepy</item>
<item>Smooth</item>
<item>Snappy</item>
<item>Soulful</item>
<item>Sparkly</item>
<item>Speedy</item>
<item>Spicy</item>
<item>Sunny</item>
<item>Swift</item>
<item>Tidy</item>
<item>Tiny</item>
<item>Vivid</item>
<item>Wavy</item>
<item>Whistling</item>
<item>Witty</item>
<item>Zany</item>
<item>Zesty</item>
<item>Zippy</item>
</string-array>
<string-array name="jam_name_animals">
<item>Alpaca</item>
<item>Badger</item>
<item>Beaver</item>
<item>Bison</item>
<item>Bunny</item>
<item>Capybara</item>
<item>Cheetah</item>
<item>Chipmunk</item>
<item>Cobra</item>
<item>Corgi</item>
<item>Coyote</item>
<item>Crane</item>
<item>Dingo</item>
<item>Dolphin</item>
<item>Duckling</item>
<item>Eagle</item>
<item>Falcon</item>
<item>Ferret</item>
<item>Finch</item>
<item>Flamingo</item>
<item>Fox</item>
<item>Gazelle</item>
<item>Gecko</item>
<item>Giraffe</item>
<item>Hedgehog</item>
<item>Heron</item>
<item>Hippo</item>
<item>Ibis</item>
<item>Jaguar</item>
<item>Koala</item>
<item>Lemur</item>
<item>Llama</item>
<item>Lynx</item>
<item>Magpie</item>
<item>Manatee</item>
<item>Marmot</item>
<item>Meerkat</item>
<item>Mongoose</item>
<item>Moose</item>
<item>Narwhal</item>
<item>Ocelot</item>
<item>Octopus</item>
<item>Orca</item>
<item>Otter</item>
<item>Owl</item>
<item>Panda</item>
<item>Pangolin</item>
<item>Parrot</item>
<item>Pelican</item>
<item>Penguin</item>
<item>Puffin</item>
<item>Quokka</item>
<item>Raccoon</item>
<item>Raven</item>
<item>Robin</item>
<item>Seal</item>
<item>Sloth</item>
<item>Sparrow</item>
<item>Squirrel</item>
<item>Tapir</item>
<item>Tiger</item>
<item>Toucan</item>
<item>Walrus</item>
<item>Wombat</item>
</string-array>
<string name="player_go_to_album">Go to album</string> <string name="player_go_to_album">Go to album</string>
<string name="player_sleep_timer">Sleep timer</string> <string name="player_sleep_timer">Sleep timer</string>
@ -404,7 +536,7 @@
<string name="welcome_guide_title">How to use Spotube</string> <string name="welcome_guide_title">How to use Spotube</string>
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string> <string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
<string name="welcome_guide_signin_title">Sign in</string> <string name="welcome_guide_signin_title">Sign in</string>
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string> <string name="welcome_guide_signin_desc">Connect your account, or pick your own catalog plugin.</string>
<string name="welcome_guide_search_title">Search and play</string> <string name="welcome_guide_search_title">Search and play</string>
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string> <string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
<string name="welcome_guide_offline_title">Listen offline</string> <string name="welcome_guide_offline_title">Listen offline</string>
@ -549,14 +681,17 @@
<string name="jam_destination_suggest">Suggest to Jam</string> <string name="jam_destination_suggest">Suggest to Jam</string>
<!-- Group Jam: connection errors --> <!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">No jam broker configured</string> <string name="jam_error_no_server">No jam server configured</string>
<string name="jam_error_broker_host_empty">Broker host is empty</string> <string name="jam_error_server_unreachable">Couldn’t reach the jam server. Check your network or the server URL in Settings → Jam.</string>
<string name="jam_error_server_incompatible">The jam server runs an incompatible version.</string>
<string name="jam_error_room_not_found">No room with that code. Check it and try again.</string>
<string name="jam_error_room_full">This room is full.</string>
<string name="jam_error_banned">The host banned you from this room.</string>
<string name="jam_error_kicked">The host removed you from the room.</string>
<string name="jam_error_replaced">This room was opened again on this device, so this connection was closed.</string>
<string name="jam_error_connection_failed">Connection failed</string> <string name="jam_error_connection_failed">Connection failed</string>
<string name="jam_error_connection_lost">Connection lost</string> <string name="jam_error_connection_lost">Connection lost</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar --> <!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Added to the jam queue</string> <string name="jam_queue_added_track">Added to the jam queue</string>

View File

@ -39,4 +39,6 @@ object DebugConfig {
* Keep it `false` when committing. * Keep it `false` when committing.
*/ */
const val FORCE_SUPPORT_REMINDER: Boolean = false const val FORCE_SUPPORT_REMINDER: Boolean = false
const val FORCE_VLC_LOGS: Boolean = false
} }

View File

@ -21,6 +21,7 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlin.time.Duration import kotlin.time.Duration
data class MediaItem( data class MediaItem(
@ -84,7 +85,9 @@ sealed interface QueueEntry {
val artists: List<String>, val artists: List<String>,
val duration: Long, val duration: Long,
val album: String?, val album: String?,
val coverBytes: ByteArray?, // Not persisted: the queue is saved on every change and covers made it hundreds of MB
// of JSON (#3193). Restore fills them back in from the local media index.
@Transient val coverBytes: ByteArray? = null,
override val url: String, override val url: String,
override val addedBy: String = "") : QueueEntry { override val addedBy: String = "") : QueueEntry {
override val artworkUrl: String? get() = null override val artworkUrl: String? get() = null

View File

@ -44,6 +44,12 @@ interface AudioPlayerQueue {
suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? suspend fun getCurrentCollectionEntry(): QueueCollectionEntry?
suspend fun getCollectionHistory(): List<QueueCollectionEntry> suspend fun getCollectionHistory(): List<QueueCollectionEntry>
/**
* Stops endless playback from adding recommendations when the queue runs out. A jam
* guest's queue belongs to the host; recommendations of its own would split them.
*/
fun setEndlessPlaybackSuppressed(suppressed: Boolean) = Unit
fun isPlaylistPlaying(playlistId: String? = null): Boolean { fun isPlaylistPlaying(playlistId: String? = null): Boolean {
val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false
return playlistId == null || entry.id == playlistId return playlistId == null || entry.id == playlistId

View File

@ -18,10 +18,15 @@
package org.spotube.spotube.core.audioplayer package org.spotube.spotube.core.audioplayer
import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
import org.spotube.spotube.PlatformType
import org.spotube.spotube.getPlatform
import org.spotube.spotube.core.di.injectLogger import org.spotube.spotube.core.di.injectLogger
import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.modules.blacklist.BlacklistRepository import org.spotube.spotube.modules.blacklist.BlacklistRepository
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
import org.spotube.spotube.modules.plugin.PluginProvider import org.spotube.spotube.modules.plugin.PluginProvider
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO import kotlinx.coroutines.IO
@ -37,6 +42,7 @@ 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
import org.koin.core.component.get
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
@ -48,6 +54,7 @@ class DeviceAudioPlayerQueue(
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent { private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
private val streamingUrlRepository = get<StreamingUrlRepository>()
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -57,19 +64,22 @@ class DeviceAudioPlayerQueue(
private var isRestoringPersistedState = true private var isRestoringPersistedState = true
// Eagerly: callers read `.value` (the stream proxy looks up the track it's asked to play,
// Jam follows the room). With WhileSubscribed, `.value` went stale whenever nothing on
// screen was collecting, so the proxy answered "not in the queue" (404) for new tracks.
override val queueFlow: StateFlow<List<QueueEntry>> = combine( override val queueFlow: StateFlow<List<QueueEntry>> = combine(
audioPlayer.playlistFlow, audioPlayer.playlistFlow,
entryByMediaUrl) { playlist, knownEntries -> entryByMediaUrl) { playlist, knownEntries ->
playlist.map { mediaItem -> playlist.map { mediaItem ->
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
} }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) }.stateIn(scope, SharingStarted.Eagerly, emptyList())
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine( override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
entryByMediaUrl) { mediaItem, knownEntries -> entryByMediaUrl) { mediaItem, knownEntries ->
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.Eagerly, null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
currentCollectionEntryState.asStateFlow() currentCollectionEntryState.asStateFlow()
@ -127,14 +137,15 @@ class DeviceAudioPlayerQueue(
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?) { collectionEntry: QueueCollectionEntry?) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val resolved = resolveMediaItems(entries)
val mediaItems = resolved.map { it.first }
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
logger.w { "load: no valid media items after filtering blanks" } logger.w { "load: no valid media items after filtering blanks" }
} }
val normalizedStartPosition = val normalizedStartPosition =
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex) if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" } logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } entryByMediaUrl.value = entriesByUrl(resolved)
updateCollectionContext(collectionEntry) updateCollectionContext(collectionEntry)
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
persistQueueStateIfReady() persistQueueStateIfReady()
@ -306,9 +317,14 @@ class DeviceAudioPlayerQueue(
} }
private var isFetchingRecommendations = false private var isFetchingRecommendations = false
private var endlessPlaybackSuppressed = false
override fun setEndlessPlaybackSuppressed(suppressed: Boolean) {
endlessPlaybackSuppressed = suppressed
}
private suspend fun handleQueueCompletion() { private suspend fun handleQueueCompletion() {
if (isFetchingRecommendations) return if (isFetchingRecommendations || endlessPlaybackSuppressed) return
val settings = settingsProvider.settingsState.value ?: return val settings = settingsProvider.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return if (!settings.enableEndlessPlayback) return
@ -393,19 +409,66 @@ class DeviceAudioPlayerQueue(
return return
} }
val mediaItems = state.entries.mapNotNull { it.toMediaItem() } val restored = resolveMediaItems(withLocalCovers(state.entries))
if (mediaItems.isEmpty()) { if (restored.isEmpty()) {
logger.w { "restorePersistedState: all entries had blank urls" } logger.w { "restorePersistedState: all entries had blank urls" }
return return
} }
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! } val mediaItems = restored.map { it.first }
entryByMediaUrl.value = entriesByUrl(restored)
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex) val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" } logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition) audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
logger.i { "Persisted state restored successfully" } logger.i { "Persisted state restored successfully" }
} }
/**
* Converts each entry once. Looking every item's entry up again by converting the whole list
* per item was quadratic: ~900k suspending conversions to start a 1353-track playlist.
*/
private suspend fun resolveMediaItems(entries: List<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
/** First entry wins for a repeated url, as before. */
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
}
/** Local covers aren't persisted with the queue; look them up in the local media index. */
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
if (entries.none { it is QueueEntry.LocalTrack }) return entries
val covers = runCatching {
get<LocalMediaCacheRepository>().cacheFlow.first().folders
.flatMap { it.tracks }
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
.toMap()
}.getOrElse {
logger.w(it) { "Couldn't read local media index for queue covers" }
emptyMap()
}
if (covers.isEmpty()) return entries
return entries.map { entry ->
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
} else entry
}
}
/**
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
*/
private suspend fun persistQueueStateIfReady() { private suspend fun persistQueueStateIfReady() {
try {
persistQueueState()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
logger.e(e) { "Failed to persist queue state" }
}
}
private suspend fun persistQueueState() {
if (isRestoringPersistedState) { if (isRestoringPersistedState) {
logger.v { "Skipping persist: still restoring persisted state" } logger.v { "Skipping persist: still restoring persisted state" }
return return
@ -456,7 +519,25 @@ class DeviceAudioPlayerQueue(
private suspend fun QueueEntry.toMediaItem(): MediaItem? { private suspend fun QueueEntry.toMediaItem(): MediaItem? {
return when (this) { return when (this) {
is QueueEntry.StreamingTrack -> { is QueueEntry.StreamingTrack -> {
val resolvedUrl = url.ifBlank { buildStreamingUrl(track.id, protocol) } val isIos = getPlatform().type == PlatformType.IOS
val requiresIosResolution = isIos && (
url.isBlank() ||
url.startsWith("http://127.0.0.1") ||
url.startsWith("https://127.0.0.1") ||
url.startsWith("http://[::1]") ||
url.startsWith("https://[::1]") ||
url.startsWith("http://localhost") ||
url.startsWith("https://localhost"))
val resolvedStream = if (requiresIosResolution) {
streamingUrlRepository.resolveStreamInfo(track)
} else {
null
}
val resolvedUrl = if (requiresIosResolution) {
resolvedStream?.url ?: buildStreamingUrl(track.id, protocol)
} else {
url.ifBlank { buildStreamingUrl(track.id, protocol) }
}
if (resolvedUrl.isBlank()) return null if (resolvedUrl.isBlank()) return null
MediaItem( MediaItem(
title = track.title, title = track.title,
@ -465,7 +546,7 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = artworkUrl.orEmpty(), coverURL = artworkUrl.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol) protocol = resolvedStream?.protocol ?: protocol)
} }
is QueueEntry.LocalTrack -> { is QueueEntry.LocalTrack -> {

View File

@ -61,13 +61,13 @@ class Database(val paths: Paths) {
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy { val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) }
) )
} }
val localMediaDataStore: DataStore<Preferences> by lazy { val localMediaDataStore: DataStore<Preferences> by lazy {
PreferenceDataStoreFactory.createWithPath( PreferenceDataStoreFactory.createWithPath(
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() } produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) }
) )
} }
@ -83,7 +83,23 @@ class Database(val paths: Paths) {
) )
} }
/**
* DataStore reads a whole preferences file into the heap. Earlier builds stored full-size
* embedded covers as JSON number arrays in the queue and local media stores, growing them to
* hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so
* drop one that is far past any sane size instead of loading it.
*/
private fun dropIfOversized(path: okio.Path): okio.Path {
runCatching {
val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path
if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path)
}
return path
}
private companion object { private companion object {
const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024
/** Preference files written by previous launches; their presence means an upgrade. */ /** Preference files written by previous launches; their presence means an upgrade. */
val EXISTING_INSTALL_PROBE_FILES = listOf( val EXISTING_INSTALL_PROBE_FILES = listOf(
"spotube.preferences_pb", "spotube.preferences_pb",

View File

@ -86,6 +86,7 @@ import org.spotube.spotube.modules.update.AppUpdateService
import org.spotube.spotube.modules.update.PluginUpdateService import org.spotube.spotube.modules.update.PluginUpdateService
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import org.spotube.spotube.modules.settings.SettingsRepository import org.spotube.spotube.modules.settings.SettingsRepository
import org.spotube.spotube.modules.settings.UserSettingsStore
import org.spotube.spotube.modules.settings.UserSettingsSource import org.spotube.spotube.modules.settings.UserSettingsSource
import org.spotube.spotube.modules.settings.SettingsViewModel import org.spotube.spotube.modules.settings.SettingsViewModel
import org.spotube.spotube.modules.settings.JamSettingsViewModel import org.spotube.spotube.modules.settings.JamSettingsViewModel
@ -151,7 +152,10 @@ val sharedModules = module {
viewModelOf(::MetadataPluginGuardViewModel) viewModelOf(::MetadataPluginGuardViewModel)
// Settings // Settings
singleOf(::SettingsRepository) { bind<UserSettingsSource>() } singleOf(::SettingsRepository) {
bind<UserSettingsSource>()
bind<UserSettingsStore>()
}
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() } viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel) viewModelOf(::JamSettingsViewModel)
viewModel { viewModel {
@ -283,7 +287,11 @@ val sharedModules = module {
} }
single { RemotePlaybackController(get(), get(), get(), get(), get()) } single { RemotePlaybackController(get(), get(), get(), get(), get()) }
singleOf(::JamRoomClient) singleOf(::JamRoomClient)
single { JamRoomService(get(), get(), get(), get()) } single {
JamRoomService(
get(), get(), get(), get(), get(), get(), get(),
evictCachedAudio = get<CacheManager>()::invalidateCacheEntry)
}
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() } singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> { single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get()) DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -54,6 +54,7 @@ fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> U
layoutDirection?.let { LocalLayoutDirection provides it }) layoutDirection?.let { LocalLayoutDirection provides it })
CompositionLocalProvider(*locales.toTypedArray()) { CompositionLocalProvider(*locales.toTypedArray()) {
key(languageTag) { key(languageTag) {
PublishResourceEnvironment()
content() content()
} }
} }

View File

@ -0,0 +1,71 @@
/*
* 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.i18n
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.ResourceEnvironment
import org.jetbrains.compose.resources.StringArrayResource
import org.jetbrains.compose.resources.StringResource
import org.jetbrains.compose.resources.getSystemResourceEnvironment
import org.jetbrains.compose.resources.rememberResourceEnvironment
import kotlin.concurrent.Volatile
import org.jetbrains.compose.resources.getPluralString as composeGetPluralString
import org.jetbrains.compose.resources.getString as composeGetString
import org.jetbrains.compose.resources.getStringArray as composeGetStringArray
/*
* Non-composable string lookups (ViewModels, services, snackbars). Use these instead of
* org.jetbrains.compose.resources.getString/getPluralString/getStringArray.
*
* The library versions call getSystemResourceEnvironment() on every lookup. On desktop that
* also asks Skiko for the system theme, and on Linux Skiko answers with a blocking D-Bus call to
* the desktop portal on whatever thread is asking. Run from a background coroutine, that call
* segfaulted the GraalVM build on GNOME (#3183). Strings never depend on the theme, so these
* reuse the environment the UI already computed (on the UI thread, for stringResource) and only
* fall back to the library's lookup before anything has been composed.
*/
@Volatile
private var composedEnvironment: ResourceEnvironment? = null
/** Publishes the UI's resource environment for [getString] and friends. Called by [AppLocaleProvider]. */
@Composable
internal fun PublishResourceEnvironment() {
val environment = rememberResourceEnvironment()
SideEffect { composedEnvironment = environment }
}
private fun resourceEnvironment(): ResourceEnvironment =
composedEnvironment ?: getSystemResourceEnvironment()
suspend fun getString(resource: StringResource): String =
composeGetString(resourceEnvironment(), resource)
suspend fun getString(resource: StringResource, vararg formatArgs: Any): String =
composeGetString(resourceEnvironment(), resource, *formatArgs)
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
composeGetPluralString(resourceEnvironment(), resource, quantity)
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
composeGetPluralString(resourceEnvironment(), resource, quantity, *formatArgs)
suspend fun getStringArray(resource: StringArrayResource): List<String> =
composeGetStringArray(resourceEnvironment(), resource)

View File

@ -0,0 +1,118 @@
/*
* 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.jam
/**
* Estimates the offset between this device's clock and the room server's (NTP-style).
*
* Each ping is sent at local time `c` and answered with server time `s`; it arrives back
* at local time `r`. Assuming symmetric latency, the server read its clock halfway
* through, so `offset = s - (c + r) / 2`. Samples with the lowest round trip have the
* least room for asymmetry, so the estimate uses the fastest of the recent samples.
* Samples older than [maxAgeMs] are dropped, so the estimate follows slow clock drift.
*/
class JamClock(private val window: Int = 16, private val maxAgeMs: Long = 15 * 60_000L) {
private data class Sample(val rttMs: Long, val offsetMs: Long, val atMs: Long)
private val samples = ArrayDeque<Sample>()
/** Local-to-server offset in ms, or null before the first sample. */
var offsetMs: Long? = null
private set
/** Round trip of the sample the offset came from. */
var rttMs: Long? = null
private set
fun addSample(clientSentMs: Long, serverMs: Long, clientReceivedMs: Long) {
val rtt = clientReceivedMs - clientSentMs
if (rtt < 0) return
samples.addLast(Sample(rtt, serverMs - (clientSentMs + clientReceivedMs) / 2, clientReceivedMs))
while (samples.size > window || samples.first().atMs < clientReceivedMs - maxAgeMs) samples.removeFirst()
val best = samples.minBy { it.rttMs }
offsetMs = best.offsetMs
rttMs = best.rttMs
}
fun serverTime(localMs: Long): Long = localMs + (offsetMs ?: 0L)
fun reset() {
samples.clear()
offsetMs = null
rttMs = null
}
}
/** What a guest does about the gap between where the room is and where it is. */
sealed class JamCorrection {
/** In sync: play at the room's rate. */
data object None : JamCorrection()
/** Slightly off: speed up or slow down until caught up (inaudible, no gap). */
data class Nudge(val rate: Float) : JamCorrection()
/** Too far off to nudge: jump to [positionMs]. */
data class Seek(val positionMs: Long) : JamCorrection()
}
/**
* Turns drift into a [JamCorrection]. Players report their position coarsely (VLC in
* ~250 ms steps), so decisions use the median of the last few drift readings, and
* small drift inside [deadBandMs] is left alone.
*/
class JamDriftCorrector(
private val deadBandMs: Long = 120,
private val seekThresholdMs: Long = 1_500,
/** Drift is closed over roughly this long while nudging. */
private val catchUpMs: Long = 3_000,
private val maxNudge: Float = 0.05f,
private val window: Int = 5,
/** Readings needed before a seek, so one noisy report right after a change can't cause one. */
private val minReadingsToSeek: Int = 3) {
private val drifts = ArrayDeque<Long>()
/**
* [expectedMs] is the room's position, [actualMs] ours, [roomRate] the host's rate.
* [seekLeadMs] is how long a seek takes to resume here; a seek aims that far ahead.
*/
fun correct(expectedMs: Long, actualMs: Long, roomRate: Float, seekLeadMs: Long): JamCorrection {
val drift = expectedMs - actualMs
// A huge jump is acted on at once; waiting for the median would replay it for seconds.
if (kotlin.math.abs(drift) > seekThresholdMs * 2) {
reset()
return JamCorrection.Seek(expectedMs + seekLeadMs)
}
drifts.addLast(drift)
while (drifts.size > window) drifts.removeFirst()
val smoothed = drifts.sorted()[drifts.size / 2]
return when {
kotlin.math.abs(smoothed) > seekThresholdMs && drifts.size >= minReadingsToSeek -> {
reset()
JamCorrection.Seek(expectedMs + seekLeadMs)
}
kotlin.math.abs(smoothed) <= deadBandMs -> JamCorrection.None
else -> {
val nudge = (smoothed.toFloat() / catchUpMs).coerceIn(-maxNudge, maxNudge)
JamCorrection.Nudge(roomRate * (1f + nudge))
}
}
}
fun reset() = drifts.clear()
}

View File

@ -0,0 +1,42 @@
/*
* 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.jam
import okio.ByteString.Companion.encodeUtf8
/**
* Stable traits of this device (model, OS version, locale, …) for jam bans. They never
* leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the
* server hashes that again with the network, so a room only ever holds an opaque value.
*/
internal expect fun jamDeviceTraits(): List<String>
/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */
internal fun jamDeviceDigest(roomCode: String): String? = runCatching {
val traits = jamDeviceTraits().filter { it.isNotBlank() }
if (traits.isEmpty()) return null
"spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex()
}.getOrNull()
/**
* This install's participant secret for room [roomCode], derived from the install's
* [deviceKey] (random, kept in settings). The same install gets the same identity in a
* room every time, so a ban sticks across rejoins; different rooms get unrelated keys.
*/
internal fun jamParticipantKey(deviceKey: String, roomCode: String): String =
"spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex()

View File

@ -0,0 +1,52 @@
/*
* 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.jam
import okio.ByteString.Companion.encodeUtf8
import org.spotube.spotube.core.i18n.getString
import org.spotube.spotube.core.i18n.getStringArray
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_name_adjectives
import spotube.composeapp.generated.resources.jam_name_animals
import spotube.composeapp.generated.resources.jam_name_format
/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */
object JamNames {
/** The server's limit (`MAX_NAME_LENGTH` in the jam-server repository's `src/protocol.ts`). */
const val MAX_LENGTH = 40
/**
* The generated name for [seed]. The same seed always gets the same name, so with this
* install's key a device keeps its name across sessions until the user picks their own.
*/
suspend fun generate(seed: String): String {
val adjectives = getStringArray(Res.array.jam_name_adjectives)
val animals = getStringArray(Res.array.jam_name_animals)
val hash = "spotube-jam-name:$seed".encodeUtf8().sha256()
val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)]
val animal = animals[pick(hash[2], hash[3], animals.size)]
return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH)
}
/** Cleans a name the user typed; blank means "use the generated one". */
fun normalize(input: String): String =
input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim()
private fun pick(high: Byte, low: Byte, size: Int): Int =
(((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size
}

View File

@ -20,91 +20,82 @@ package org.spotube.spotube.core.jam
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.audioplayer.MediaItem import org.spotube.spotube.core.audioplayer.MediaItem
import org.spotube.spotube.core.audioplayer.QueueEntry import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.server.PluginTaggedAudioSource
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /**
* Jam messages exchanged over MQTT. * Room payloads. The server only routes and retains them; their shape is owned here.
* *
* - `state` topic: [QueueState] (retained, host -> everyone) * - [JamQueue] (retained, host -> everyone): the shared queue.
* - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist] * - [JamAnchor] (retained, host -> everyone): where playback is, in server time.
* (anyone -> host, except Kick which is host -> guest) * - [JamCommand] (guest -> host): playback control and suggestions.
* - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
*/ */
@Serializable @Serializable
sealed class JamMessage { data class JamQueue(
@Serializable
@SerialName("queueState")
data class QueueState(
val items: List<JamMediaItem>, val items: List<JamMediaItem>,
val currentIndex: Int, val shuffleEnabled: Boolean = false)
val shuffleEnabled: Boolean = false,
/** /**
* Whether guests should follow the host's current index. True when the * A playback anchor: track [trackKey] (at queue [index]) was at [positionMs] at server
* host manually skipped/jumped or loaded a queue; false when the host * time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at
* merely auto-advanced because its song ended (guests stay put). * [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead.
*/ */
val follow: Boolean = false, /** The audio source the host plays [trackKey] from. */
/** Room-wide play/pause; guests follow every change of it. */ @Serializable
val isPlaying: Boolean = false) : JamMessage() data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource)
@Serializable @Serializable
@SerialName("playbackCommand") data class JamAnchor(
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage() val trackKey: String,
val index: Int,
@Serializable val positionMs: Long,
@SerialName("suggestTrack") val serverTimeMs: Long,
data class SuggestTrack( /** Room-wide play/pause intent; guests follow every change of it. */
val mediaItem: JamMediaItem, val playing: Boolean,
val addedBy: String = "") : JamMessage() val advancing: Boolean = playing,
val rate: Float = 1f,
@Serializable /** The audio source the host plays [trackKey] from, so guests stream the same file. */
@SerialName("suggestPlaylist") val source: PluginTaggedAudioSource? = null,
data class SuggestPlaylist( /**
val tracks: List<JamMediaItem>, * The host's sources for the next songs. A player loads the next song long before it
val addedBy: String = "") : JamMessage() * plays, so guests need the host's recording of it well before then.
*/
@Serializable val upcoming: List<JamTrackSource> = emptyList()) {
@SerialName("kick") /** The room's position at server time [serverNowMs]. */
data class Kick( fun positionAt(serverNowMs: Long): Long =
val participantId: String, if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs
val reason: String = "kicked") : JamMessage()
@Serializable
@SerialName("leave")
data class Leave(val reason: String = "user_left") : JamMessage()
} }
/** /** Playback control and suggestions. Volume, loop and the local queue view stay local. */
* Playback commands. Queue navigation and play/pause are global — seek,
* volume, shuffle and loop are local to each participant.
*/
@Serializable @Serializable
sealed class PlaybackCmd { sealed class JamCommand {
@Serializable @Serializable
@SerialName("setPlaying") @SerialName("setPlaying")
data class SetPlaying(val playing: Boolean) : PlaybackCmd() data class SetPlaying(val playing: Boolean) : JamCommand()
@Serializable @Serializable
@SerialName("skipNext") @SerialName("skipNext")
data object SkipNext : PlaybackCmd() data object SkipNext : JamCommand()
@Serializable @Serializable
@SerialName("skipPrevious") @SerialName("skipPrevious")
data object SkipPrevious : PlaybackCmd() data object SkipPrevious : JamCommand()
@Serializable @Serializable
@SerialName("jumpTo") @SerialName("jumpTo")
data class JumpTo(val index: Int) : PlaybackCmd() data class JumpTo(val index: Int) : JamCommand()
}
/** Retained per-participant presence entry (with an MQTT Last Will for leave). */
@Serializable @Serializable
data class JamPresence( @SerialName("seekTo")
val clientId: String, data class SeekTo(val positionMs: Long) : JamCommand()
val displayName: String,
val isHost: Boolean, @Serializable
val left: Boolean = false) @SerialName("suggest")
data class Suggest(
val items: List<JamMediaItem>,
val addedBy: String = "") : JamCommand()
}
@Serializable @Serializable
data class JamMediaItem( data class JamMediaItem(
@ -117,7 +108,35 @@ data class JamMediaItem(
val coverUrl: String, val coverUrl: String,
val protocol: String, val protocol: String,
val addedBy: String = "") { val addedBy: String = "") {
/** Identifies the track across devices: the metadata id, or an opaque id for local files. */
val key: String get() = trackId.ifBlank { url }
/**
* This item as received from another participant, made safe to load: streaming tracks
* only (a peer's local file can't play here, and a URL would make this device fetch
* whatever a peer chose), https covers only, and bounded text. Null if unusable.
*/
fun sanitized(): JamMediaItem? {
if (trackId.isBlank() || trackId.length > MAX_ID_LENGTH) return null
return copy(
url = "",
title = title.take(MAX_TEXT_LENGTH),
artist = artist.take(MAX_TEXT_LENGTH),
album = album.take(MAX_TEXT_LENGTH),
durationMs = durationMs.coerceAtLeast(0),
coverUrl = coverUrl.takeIf { it.startsWith("https://") && it.length <= MAX_URL_LENGTH }.orEmpty(),
protocol = protocol.take(MAX_ID_LENGTH),
addedBy = addedBy.take(MAX_ID_LENGTH))
}
companion object { companion object {
private const val MAX_ID_LENGTH = 200
private const val MAX_TEXT_LENGTH = 300
private const val MAX_URL_LENGTH = 2048
/** The cross-device key of a local file at [path], which doesn't reveal the path. */
fun localTrackKey(path: String): String = "local:" + path.hashCode().toUInt().toString(16)
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem( is QueueEntry.StreamingTrack -> JamMediaItem(
url = "", url = "",
@ -132,8 +151,9 @@ data class JamMediaItem(
protocol = entry.protocol.name, protocol = entry.protocol.name,
addedBy = entry.addedBy) addedBy = entry.addedBy)
// Only an opaque id: the path would show the host's folders (and user name) to everyone.
is QueueEntry.LocalTrack -> JamMediaItem( is QueueEntry.LocalTrack -> JamMediaItem(
url = entry.url, url = localTrackKey(entry.url),
trackId = "", trackId = "",
title = entry.name, title = entry.name,
artist = entry.artists.joinToString(", "), artist = entry.artists.joinToString(", "),

View File

@ -18,67 +18,113 @@
package org.spotube.spotube.core.jam package org.spotube.spotube.core.jam
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.ditchoom.buffer.Charset import io.ktor.client.plugins.HttpTimeout
import com.ditchoom.buffer.codec.asReadBuffer import io.ktor.client.plugins.websocket.WebSockets
import com.ditchoom.buffer.toReadBuffer import io.ktor.client.plugins.websocket.webSocketSession
import com.ditchoom.mqtt.client.ConnectionState import io.ktor.client.request.get
import com.ditchoom.mqtt.client.MqttClient import io.ktor.client.statement.bodyAsText
import com.ditchoom.mqtt.connection.MqttConnectionOptions import io.ktor.http.URLBuilder
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec import io.ktor.http.URLProtocol
import com.ditchoom.mqtt.controlpacket.QualityOfService import io.ktor.http.appendPathSegments
import com.ditchoom.mqtt.controlpacket.TopicName import io.ktor.http.isSuccess
import com.ditchoom.mqtt.controlpacket.WillConfig import io.ktor.websocket.DefaultWebSocketSession
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest import io.ktor.websocket.Frame
import org.spotube.spotube.modules.settings.JamBroker import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.delay
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.launchIn import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.getString import kotlinx.serialization.json.JsonClassDiscriminator
import org.spotube.spotube.core.i18n.getString
import org.spotube.spotube.core.network.HttpClientFactory
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_broker_host_empty import spotube.composeapp.generated.resources.jam_error_banned
import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.jam_error_connection_failed
import spotube.composeapp.generated.resources.jam_error_connection_lost import spotube.composeapp.generated.resources.jam_error_connection_lost
import spotube.composeapp.generated.resources.jam_error_no_broker import spotube.composeapp.generated.resources.jam_error_kicked
import spotube.composeapp.generated.resources.settings_jam_broker_test_ok import spotube.composeapp.generated.resources.jam_error_replaced
import org.koin.core.component.KoinComponent import spotube.composeapp.generated.resources.jam_error_room_full
import kotlin.time.Duration.Companion.seconds import spotube.composeapp.generated.resources.jam_error_room_not_found
import spotube.composeapp.generated.resources.jam_error_server_incompatible
import spotube.composeapp.generated.resources.jam_error_server_unreachable
import spotube.composeapp.generated.resources.settings_jam_server_test_ok
import kotlin.time.Clock
/** /**
* Thin facade over the Ditchoom MQTT 5 client for one jam room. * WebSocket client for one jam room on the room server (https://github.com/team-spotube/jam-server, a Cloudflare
* Durable Object per room code).
* *
* Topics (room code `C`): * The server keeps the host's latest queue and anchor for late joiners, routes guest
* - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone) * commands to the host and is the clock every participant syncs playback against
* - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest) * ([clock]). A dropped connection is retried with the same client id, which the
* - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will * server treats as the same participant (a host keeps the room for a grace period).
* (`left = true`) so a dropped client disappears from the room automatically.
* *
* The library keeps the connection alive (auto-reconnect + backoff); this class only * The room is billed per incoming message, and on phones every transmission wakes the
* re-establishes the room subscription and re-publishes presence after a reconnect. * cellular radio for seconds, so the steady state sends almost nothing: one keep-alive
* a minute (answered by the server runtime for free) and a clock re-sync every
* [CLOCK_RESYNC_INTERVAL_MS]. Reconnect attempts back off to [RECONNECT_BACKOFF_MS]'s
* last step while the network stays away.
*/ */
class JamRoomClient : KoinComponent { class JamRoomClient(httpClientFactory: HttpClientFactory) {
private val log = Logger.withTag("JamRoomClient") private val log = Logger.withTag("JamRoomClient")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) // Nothing here may take the app down: a network failure is a reconnect, not a crash.
private val scope = CoroutineScope(
SupervisorJob() + Dispatchers.IO +
CoroutineExceptionHandler { _, e -> log.w(e) { "Unexpected jam connection failure" } })
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
classDiscriminator = "type" classDiscriminator = "type"
encodeDefaults = true encodeDefaults = true
} }
private val http = httpClientFactory.create {
install(WebSockets)
install(HttpTimeout) { connectTimeoutMillis = CONNECT_TIMEOUT_MS }
}
private val plainHttp = httpClientFactory.create {
install(HttpTimeout) { requestTimeoutMillis = CONNECT_TIMEOUT_MS }
}
private var client: MqttClient? = null val clock = JamClock()
private var roomCode: String? = null private var session: DefaultWebSocketSession? = null
private var localPresence: JamPresence? = null private var connectionJob: Job? = null
private var reader: Deferred<String?>? = null
private var pingId = 0
/** When the current socket last received anything; a silent socket is a dead one. */
private var lastFrameAtMs = 0L
/** The room's host as last reported by the server. */
private var hostId: String? = null
/**
* Our public participant id in the current room, as the server derived it from our
* secret key. Null until the first welcome.
*/
var selfId: String? = null
private set
private val pongs = MutableStateFlow(0)
private val _isConnected = MutableStateFlow(false) private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow() val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -86,237 +132,429 @@ class JamRoomClient : KoinComponent {
private val _connectionError = MutableStateFlow<String?>(null) private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow() val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8) private val _events = MutableSharedFlow<JamRoomEvent>(extraBufferCapacity = 64)
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow() val events: SharedFlow<JamRoomEvent> = _events.asSharedFlow()
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
// ---------- Public API ---------- // ---------- Public API ----------
/** One-off connection check used by the settings screen. Returns latency description. */ /** Checks that [serverUrl] runs a compatible room server. Returns a latency description. */
suspend fun testConnection(broker: JamBroker): Result<String> { suspend fun testConnection(serverUrl: String): Result<String> = runCatching {
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_broker_host_empty)))
val started = kotlin.time.TimeSource.Monotonic.markNow() val started = kotlin.time.TimeSource.Monotonic.markNow()
return runCatching { val response = plainHttp.get(healthUrl(serverUrl))
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test") if (!response.status.isSuccess()) error(getString(Res.string.jam_error_server_unreachable))
try { val health = json.decodeFromString(Health.serializer(), response.bodyAsText())
withTimeout(broker.connectionTimeoutSeconds.seconds) { if (!health.ok || health.v != PROTOCOL_VERSION) error(getString(Res.string.jam_error_server_incompatible))
client.awaitConnectivity() getString(Res.string.settings_jam_server_test_ok, started.elapsedNow().inWholeMilliseconds)
}
getString(Res.string.settings_jam_broker_test_ok, started.elapsedNow().inWholeMilliseconds)
} finally {
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
}
}
} }
/** Connects to [code] on [broker] and starts routing room messages. */ /**
* Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException]
* when the server refuses (code taken, room not found, banned, …). Once connected,
* drops are retried in the background until [disconnect] or a refusal.
*
* [clientKey] is this participant's secret ([jamParticipantKey]): reconnecting with
* it makes us the same participant, so it never leaves this device except to the
* server. [deviceDigest] ([jamDeviceDigest]) lets a ban cover this device too.
*/
suspend fun connect( suspend fun connect(
broker: JamBroker, serverUrl: String,
code: String, code: String,
clientId: String, clientKey: String,
displayName: String, displayName: String,
isHost: Boolean): Result<Unit> { create: Boolean,
deviceDigest: String? = null): Result<JamRoomEvent.Welcome> {
disconnect() disconnect()
if (broker.host.isBlank()) { clock.reset()
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_no_broker))) val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest)
} val welcome = runCatching { open(opener, create) }
return runCatching { .onFailure { e ->
roomCode = code log.w(e) { "Failed to open jam room $code" }
localPresence = JamPresence(
clientId = clientId,
displayName = displayName,
isHost = isHost,
left = false)
val mqtt = startClient(broker, clientId)
client = mqtt
withTimeout(broker.connectionTimeoutSeconds.seconds) {
mqtt.awaitConnectivity()
}
_connectionError.value = null
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
mqtt.connectionState
.onEach { onConnectionStateChanged(it) }
.launchIn(scope)
Unit
}.onFailure { e ->
log.w(e) { "Failed to connect to jam broker" }
_connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed)
}
.getOrElse { return Result.failure(it) }
connectionJob = scope.launch {
var current = welcome.second
var attempt = 0
while (isActive) {
val refusal = runCatching { current.await() }.getOrNull()
ensureActive()
_isConnected.value = false _isConnected.value = false
runCatching { client?.shutdown(sendDisconnect = true, drain = false) } if (refusal != null) {
client = null fail(refusal)
return@launch
} }
_connectionError.value = getString(Res.string.jam_error_connection_lost)
// Rejoin: the room exists now, so even a former host comes back with "join".
while (isActive) {
delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)])
attempt++
var reopened = runCatching { open(opener, create = false) }
// Everyone was gone long enough for the room to expire: its host brings it back.
if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" &&
hostId != null && hostId == selfId
) {
log.i { "Jam room $code expired while offline; recreating it" }
reopened = runCatching { open(opener, create = true) }
}
val error = reopened.exceptionOrNull()
if (error is JamRoomException) {
fail(error.code)
return@launch
}
val (event, socket) = reopened.getOrNull() ?: continue
attempt = 0
current = socket
_events.emit(event)
break
}
}
}
return Result.success(welcome.first)
} }
suspend fun publishState(state: JamMessage.QueueState) { fun sendQueue(queue: JamQueue) = send(ClientFrame.Queue(queue))
val code = roomCode ?: return
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
}
suspend fun publishCommand(message: JamMessage) { fun sendAnchor(anchor: JamAnchor) = send(ClientFrame.Anchor(anchor))
val code = roomCode ?: return
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
}
/** Publishes our own (retained) presence. Re-published after every reconnect. */ fun sendCommand(command: JamCommand) = send(ClientFrame.Cmd(command))
suspend fun publishPresence() {
val code = roomCode ?: return
val presence = localPresence ?: return
publishJson(
presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence),
retain = true)
}
/** Marks us as the host in presence (host takeover). */ fun kick(participantId: String, ban: Boolean) = send(ClientFrame.Kick(participantId, ban))
suspend fun claimHost() {
val presence = localPresence ?: return
localPresence = presence.copy(isHost = true)
publishPresence()
}
/** Graceful leave: publish `left = true` before disconnecting. */
suspend fun leavePresence() {
val presence = localPresence ?: return
val code = roomCode ?: return
runCatching {
publishJson(
presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
retain = true)
}
}
suspend fun disconnect() { suspend fun disconnect() {
roomCode = null connectionJob?.cancel()
localPresence = null connectionJob = null
hostId = null
selfId = null
reader?.cancel()
reader = null
val current = session
session = null
_isConnected.value = false _isConnected.value = false
_presence.value = emptyMap() _connectionError.value = null
val current = client runCatching { current?.close() }
client = null
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
} }
// ---------- Internals ---------- // ---------- Internals ----------
private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient { /**
val connection = MqttConnectionOptions.SocketConnection( * Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome
host = broker.host, * and the socket's reader, which completes with the server's refusal (if any) on close.
port = broker.port, */
tlsEnabled = broker.useTls, /** Everything needed to (re)open one room connection. */
connectionTimeout = broker.connectionTimeoutSeconds.seconds) private data class Opener(
val code = roomCode ?: "unset" val serverUrl: String,
val will = WillConfig.Enabled( val code: String,
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), val clientKey: String,
payload = json.encodeToString( val displayName: String,
JamPresence.serializer(), val deviceDigest: String?)
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
qos = QualityOfService.AT_LEAST_ONCE, private suspend fun open(opener: Opener, create: Boolean): Pair<JamRoomEvent.Welcome, Deferred<String?>> {
retain = true) val code = opener.code
val request = ConnectionRequest( val socket = runCatching { http.webSocketSession(roomUrl(opener, create)) }
clientId = clientId, .getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) }
keepAliveSeconds = broker.keepAliveSeconds, try {
cleanStart = true, val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) }
userName = broker.username, ?: throw IllegalStateException(getString(Res.string.jam_error_server_unreachable))
password = broker.password, if (welcome.v != PROTOCOL_VERSION) {
will = will) throw IllegalStateException(getString(Res.string.jam_error_server_incompatible))
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
val brokerRef = persistence.addBroker(connection, request)
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
} }
private fun onConnectionStateChanged(state: ConnectionState) { session = socket
when (state) { selfId = welcome.you
is ConnectionState.Connected -> { hostId = welcome.host
lastFrameAtMs = now()
_isConnected.value = true _isConnected.value = true
_connectionError.value = null _connectionError.value = null
// Read from here on: a pong's arrival time is part of the clock measurement.
val socketReader = scope.async { pump(socket) }
reader = socketReader
syncClock(socket)
log.i { "Joined jam room $code as ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" }
return welcome.toEvent() to socketReader
} catch (e: Throwable) {
runCatching { socket.close() }
throw e
}
}
private suspend fun awaitWelcome(socket: DefaultWebSocketSession): ServerFrame.Welcome {
while (true) {
val frame = socket.incoming.receiveCatching().getOrNull()
?: throw IllegalStateException(getString(Res.string.jam_error_connection_failed))
when (val decoded = decode(frame)) {
is ServerFrame.Welcome -> return decoded
is ServerFrame.Error -> throw JamRoomException(decoded.code, refusalMessage(decoded.code))
else -> Unit
}
}
}
/**
* A burst of clock pings (playback can't sync before the clock is known); waits for
* their pongs, so the first sync is done when this returns.
*/
private suspend fun syncClock(socket: DefaultWebSocketSession, pings: Int = INITIAL_PINGS) {
val target = pongs.value + pings
repeat(pings) {
ping(socket)
delay(PING_SPACING_MS)
}
withTimeoutOrNull(CLOCK_SYNC_TIMEOUT_MS) { pongs.first { it >= target } }
}
/**
* Reads frames until the socket closes or fails (a dropped network surfaces here as
* an exception). Returns the server's refusal, if any.
*/
private suspend fun pump(socket: DefaultWebSocketSession): String? = withKeepAlive(socket) {
var refusal: String? = null
try {
for (frame in socket.incoming) {
lastFrameAtMs = now()
refusal = handle(frame) ?: refusal
}
} catch (e: Exception) {
currentCoroutineContext().ensureActive()
log.i { "Jam connection dropped: ${e.message}" }
}
refusal
}
/**
* Keeps the socket alive (and NAT mappings open) with free keep-alives, gives up on a
* socket that has gone silent (a half-open connection never errors by itself), and
* re-syncs the clock now and then.
*/
private suspend fun <T> withKeepAlive(
socket: DefaultWebSocketSession,
block: suspend () -> T): T {
val keeper = scope.launch {
var lastClockSyncMs = now()
while (isActive) {
delay(KEEPALIVE_INTERVAL_MS)
if (now() - lastFrameAtMs > DEAD_AFTER_MS) {
log.i { "Jam connection went silent; reconnecting" }
runCatching { socket.close() }
socket.cancel()
return@launch
}
runCatching { socket.send(Frame.Text(KEEPALIVE_PING)) }
if (now() - lastClockSyncMs >= CLOCK_RESYNC_INTERVAL_MS) {
lastClockSyncMs = now()
runCatching { syncClock(socket, RESYNC_PINGS) }
}
}
}
return try {
block()
} finally {
keeper.cancel()
}
}
/** Routes one frame; returns an error code when the server refused us. */
private suspend fun handle(frame: Frame): String? {
if (frame is Frame.Text && frame.readText() == KEEPALIVE_PONG) return null
when (val decoded = decode(frame)) {
is ServerFrame.Pong -> {
clock.addSample(decoded.c, decoded.s, now())
pongs.value++
}
is ServerFrame.Welcome -> {
hostId = decoded.host
_events.emit(decoded.toEvent())
}
is ServerFrame.Presence -> {
hostId = decoded.host
_events.emit(JamRoomEvent.Presence(decoded.host, decoded.participants.toParticipants(decoded.host)))
}
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
// Sent too much too fast (a burst of skips, say): not a refusal, just reconnect.
is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" }
null -> Unit
}
return null
}
private suspend fun fail(code: String) {
val message = refusalMessage(code)
log.i { "Jam room closed by the server: $code" }
session = null
_connectionError.value = message
_events.emit(JamRoomEvent.Closed(code, message))
}
private suspend fun ping(socket: DefaultWebSocketSession) {
socket.send(Frame.Text(json.encodeToString(ClientFrame.serializer(), ClientFrame.Ping(++pingId, now()))))
}
private fun send(frame: ClientFrame) {
val socket = session ?: return
val text = json.encodeToString(ClientFrame.serializer(), frame)
scope.launch { scope.launch {
// Every connection (initial + reconnects) must re-establish the runCatching { socket.send(Frame.Text(text)) }
// broker-side subscription (clean session) and re-publish our .onFailure { log.w(it) { "Failed to send jam frame" } }
// retained presence to clear any Last Will. Re-subscribing with
// the same filter replaces the previous dispatcher handler.
subscribeRoom()
publishPresence()
} }
} }
ConnectionState.Disconnected, ConnectionState.Handshaking -> { private fun decode(frame: Frame): ServerFrame? {
_isConnected.value = false if (frame !is Frame.Text) return null
val text = frame.readText()
return runCatching { json.decodeFromString(ServerFrame.serializer(), text) }
.onFailure { log.w(it) { "Failed to decode jam frame: ${text.take(200)}" } }
.getOrNull()
} }
else -> { private suspend fun refusalMessage(code: String): String = when (code) {
_isConnected.value = false "room_not_found" -> getString(Res.string.jam_error_room_not_found)
scope.launch { "room_full" -> getString(Res.string.jam_error_room_full)
val message = getString(Res.string.jam_error_connection_lost) "banned" -> getString(Res.string.jam_error_banned)
if (!_isConnected.value) _connectionError.value = message "kicked" -> getString(Res.string.jam_error_kicked)
} "replaced" -> getString(Res.string.jam_error_replaced)
else -> getString(Res.string.jam_error_connection_failed)
} }
private fun roomUrl(opener: Opener, create: Boolean) =
URLBuilder(opener.serverUrl.trim()).apply {
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
appendPathSegments("v1", "rooms", opener.code)
parameters.append("key", opener.clientKey)
parameters.append("name", opener.displayName)
parameters.append("mode", if (create) "create" else "join")
opener.deviceDigest?.let { parameters.append("dev", it) }
}.buildString()
private fun healthUrl(serverUrl: String) =
URLBuilder(serverUrl.trim()).apply {
if (protocol == URLProtocol.WS) protocol = URLProtocol.HTTP
if (protocol == URLProtocol.WSS) protocol = URLProtocol.HTTPS
appendPathSegments("v1", "health")
}.buildString()
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
companion object {
const val PROTOCOL_VERSION = 1
private const val CONNECT_TIMEOUT_MS = 10_000L
private const val INITIAL_PINGS = 6
private const val RESYNC_PINGS = 3
private const val PING_SPACING_MS = 100L
private const val CLOCK_SYNC_TIMEOUT_MS = 3_000L
/** Clocks drift by a few ms per minute at most; this keeps sync error well under 50 ms. */
private const val CLOCK_RESYNC_INTERVAL_MS = 10 * 60_000L
/**
* As rare as possible (each one wakes a phone's radio) while staying under the
* 100 s idle timeout of Cloudflare's proxy. A dropped idle socket would only cost
* a reconnect, so there's no need for margin beyond that.
*/
private const val KEEPALIVE_INTERVAL_MS = 60_000L
/** No pong to two keep-alives in a row: the connection is gone. */
private const val DEAD_AFTER_MS = 2 * KEEPALIVE_INTERVAL_MS + 10_000L
private const val KEEPALIVE_PING = "ping"
private const val KEEPALIVE_PONG = "pong"
/** Quick at first (most drops are a network switch), then slower to spare the battery. */
private val RECONNECT_BACKOFF_MS = longArrayOf(500, 1_000, 2_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 60_000)
} }
} }
private suspend fun subscribeRoom() { /** Something that happened in the room, for [JamRoomService]. */
val mqtt = client ?: return sealed interface JamRoomEvent {
val code = roomCode ?: return /** (Re)joined the room; carries the retained queue and anchor. */
val operation = mqtt.subscribe( data class Welcome(
roomFilter(code), val you: String,
OpaquePublishPayloadCodec, val host: String?,
QualityOfService.AT_LEAST_ONCE) { publish, payload -> val participants: List<JamParticipant>,
route(publish.topic.toString(), payload) val queue: JamQueue?,
} val anchor: JamAnchor?) : JamRoomEvent
runCatching { operation.subAck.await() }
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } } data class Presence(val host: String?, val participants: List<JamParticipant>) : JamRoomEvent
data class Queue(val queue: JamQueue) : JamRoomEvent
data class Anchor(val anchor: JamAnchor) : JamRoomEvent
data class Command(val from: String, val command: JamCommand) : JamRoomEvent
/** The server ended our membership ([code]: kicked, banned, …); no retry follows. */
data class Closed(val code: String, val message: String) : JamRoomEvent
} }
private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) { /** The server refused to let us into the room. */
val text = runCatching { class JamRoomException(val code: String, message: String) : Exception(message)
val buffer = payload.handle.asReadBuffer()
buffer.readString(buffer.remaining(), Charset.UTF8) // ---------- Wire frames (see src/protocol.ts in https://github.com/team-spotube/jam-server) ----------
}.getOrElse { e ->
log.w(e) { "Failed to read jam payload on $topic" } @Serializable
return private data class Health(val ok: Boolean = false, val v: Int = 0)
@Serializable
private data class WireParticipant(val id: String, val name: String)
private fun List<WireParticipant>.toParticipants(host: String?) =
map { JamParticipant(it.id, it.name, isHost = it.id == host) }
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("t")
private sealed class ClientFrame {
@Serializable
@SerialName("ping")
data class Ping(val id: Int, val c: Long) : ClientFrame()
@Serializable
@SerialName("queue")
data class Queue(val body: JamQueue) : ClientFrame()
@Serializable
@SerialName("anchor")
data class Anchor(val body: JamAnchor) : ClientFrame()
@Serializable
@SerialName("cmd")
data class Cmd(val body: JamCommand) : ClientFrame()
@Serializable
@SerialName("kick")
data class Kick(val target: String, val ban: Boolean) : ClientFrame()
} }
runCatching { @OptIn(ExperimentalSerializationApi::class)
when { @Serializable
topic.endsWith("/state") -> { @JsonClassDiscriminator("t")
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) private sealed class ServerFrame {
@Serializable
@SerialName("welcome")
data class Welcome(
val v: Int,
val you: String,
val host: String? = null,
val participants: List<WireParticipant> = emptyList(),
val queue: JamQueue? = null,
val anchor: JamAnchor? = null) : ServerFrame() {
fun toEvent() = JamRoomEvent.Welcome(you, host, participants.toParticipants(host), queue, anchor)
} }
topic.endsWith("/cmd") -> { @Serializable
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) @SerialName("pong")
} data class Pong(val id: Int, val c: Long, val s: Long) : ServerFrame()
topic.contains("/presence/") -> { @Serializable
val presence = json.decodeFromString(JamPresence.serializer(), text) @SerialName("presence")
_presence.value = _presence.value + (presence.clientId to presence) data class Presence(val host: String? = null, val participants: List<WireParticipant> = emptyList()) : ServerFrame()
}
}
}.onFailure { e ->
log.w(e) { "Failed to decode jam message on $topic: $text" }
}
}
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) { @Serializable
val mqtt = client ?: return @SerialName("queue")
runCatching { data class Queue(val body: JamQueue) : ServerFrame()
mqtt.publish(
topicName = topic,
qos = QualityOfService.AT_LEAST_ONCE,
payload = payload.toReadBuffer(Charset.UTF8),
retain = retain)
}.onFailure { e ->
log.w(e) { "Failed to publish to $topic" }
}
}
private fun stateTopic(code: String) = "spotube/jam/$code/state" @Serializable
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd" @SerialName("anchor")
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId" data class Anchor(val body: JamAnchor) : ServerFrame()
private fun roomFilter(code: String) = "spotube/jam/$code/#"
@Serializable
@SerialName("cmd")
data class Cmd(val from: String, val body: JamCommand) : ServerFrame()
@Serializable
@SerialName("error")
data class Error(val code: String) : ServerFrame()
} }

View File

@ -17,11 +17,12 @@
package org.spotube.spotube.core.jam package org.spotube.spotube.core.jam
import kotlin.random.Random import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
/** /**
* Six-character room codes shared verbally / by text. Codes are opaque keys used * Six-character room codes shared verbally / by text. Codes are opaque keys used
* to namespace the MQTT topics of a jam room — they carry no connection details. * to name a jam room on the room server — they carry no connection details.
* *
* The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to * The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to
* read aloud and retype. * read aloud and retype.
@ -30,12 +31,23 @@ object JamRoomCode {
const val LENGTH = 6 const val LENGTH = 6
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
fun generate(): String = buildString(LENGTH) { /** From a cryptographically secure source: a predictable code would let others in. */
repeat(LENGTH) { @OptIn(ExperimentalUuidApi::class)
append(ALPHABET[Random.nextInt(ALPHABET.length)]) fun generate(): String {
val bytes = Uuid.random().toByteArray()
// 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform.
return buildString(LENGTH) {
repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) }
} }
} }
/**
* A participant's secret for one session, from a cryptographically secure source.
* The server derives the public participant id from it; only this device knows it.
*/
@OptIn(ExperimentalUuidApi::class)
fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString()
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
fun normalize(input: String): String = input fun normalize(input: String): String = input
.uppercase() .uppercase()

View File

@ -40,8 +40,8 @@ import kotlinx.coroutines.flow.map
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
import org.jetbrains.compose.resources.getPluralString import org.spotube.spotube.core.i18n.getPluralString
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_queue_added_track import spotube.composeapp.generated.resources.jam_queue_added_track
import spotube.composeapp.generated.resources.jam_queue_added_tracks import spotube.composeapp.generated.resources.jam_queue_added_tracks
@ -202,7 +202,7 @@ class RemotePlaybackController(
/** /**
* Adds a single track to the active jam queue. The host applies it to the * Adds a single track to the active jam queue. The host applies it to the
* local (shared) queue directly; a guest suggests it to the host over MQTT. * local (shared) queue directly; a guest suggests it to the host through the room server.
*/ */
fun addTrackToJam(track: MetadataTrack) { fun addTrackToJam(track: MetadataTrack) {
if (jamRoomService.role.value == null) return if (jamRoomService.role.value == null) return

View File

@ -196,22 +196,18 @@ class CacheManager(
val (start, end) = range val (start, end) = range
val length = end - start + 1 val length = end - start + 1
val contentRange = "bytes $start-$end/$fileSize" val contentRange = "bytes $start-$end/$fileSize"
fileSystem.source(filePath).buffer().use { source ->
source.skip(start)
val bytes = source.readByteArray(length)
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.PartialContent override val status: HttpStatusCode = HttpStatusCode.PartialContent
override val contentLength: Long = bytes.size.toLong() override val contentLength: Long = length
override val contentType: ContentType = entry.toContentType() override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply { override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.ContentRange, contentRange) append(HttpHeaders.ContentRange, contentRange)
append(HttpHeaders.AcceptRanges, "bytes") append(HttpHeaders.AcceptRanges, "bytes")
}.build() }.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(bytes) streamFileRange(fileSystem, filePath, start, length, channel)
} }
}) })
}
return return
} }
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
@ -223,20 +219,43 @@ class CacheManager(
return return
} }
fileSystem.source(filePath).buffer().use { source ->
val bytes = source.readByteArray()
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = HttpStatusCode.OK override val status: HttpStatusCode = HttpStatusCode.OK
override val contentLength: Long = bytes.size.toLong() override val contentLength: Long = fileSize
override val contentType: ContentType = entry.toContentType() override val contentType: ContentType = entry.toContentType()
override val headers: Headers = HeadersBuilder().apply { override val headers: Headers = HeadersBuilder().apply {
append(HttpHeaders.AcceptRanges, "bytes") append(HttpHeaders.AcceptRanges, "bytes")
}.build() }.build()
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) { override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
channel.writeFully(bytes) streamFileRange(fileSystem, filePath, 0, fileSize, channel)
} }
}) })
} }
}
private const val STREAM_CHUNK_BYTES = 64 * 1024
/**
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
* player re-requests them on every seek, so they must never be read into the heap whole.
*/
internal suspend fun streamFileRange(
fileSystem: FileSystem,
filePath: Path,
start: Long,
length: Long,
channel: io.ktor.utils.io.ByteWriteChannel) {
fileSystem.source(filePath).buffer().use { source ->
source.skip(start)
val chunk = ByteArray(STREAM_CHUNK_BYTES)
var remaining = length
while (remaining > 0) {
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
if (read == -1) break
channel.writeFully(chunk, 0, read)
remaining -= read
}
} }
} }

View File

@ -425,7 +425,7 @@ internal class StreamProxy(
if (requestMethod == HttpMethod.Head) { if (requestMethod == HttpMethod.Head) {
call.respond(object : OutgoingContent.NoContent() { call.respond(object : OutgoingContent.NoContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
}) })
return return
@ -446,7 +446,7 @@ internal class StreamProxy(
call.respond(object : OutgoingContent.WriteChannelContent() { call.respond(object : OutgoingContent.WriteChannelContent() {
override val status: HttpStatusCode = upstream.status override val status: HttpStatusCode = upstream.status
override val headers: Headers = downstreamHeaders override val headers: Headers = downstreamHeaders.passthroughForPlayer()
override val contentLength: Long? = downstreamContentLength override val contentLength: Long? = downstreamContentLength
override val contentType: ContentType? = override val contentType: ContentType? =
downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse) downstreamHeaders[HttpHeaders.ContentType]?.let(ContentType.Companion::parse)
@ -594,4 +594,28 @@ internal class StreamProxy(
} }
}.build() }.build()
} }
}
private val PLAYER_PASSTHROUGH_HEADERS = listOf(
HttpHeaders.ContentRange,
HttpHeaders.AcceptRanges,
HttpHeaders.ETag,
HttpHeaders.LastModified,
)
/**
* The upstream headers a player actually needs. Copying them all makes Ktor emit duplicate
* Content-Length/Content-Type (also set through the OutgoingContent) and forwards hop-by-hop
* headers like `Connection: keep-alive`, which VLC rejects by closing the socket right after
* the response headers. Content-Range and Accept-Ranges must stay: without them the player
* treats a 206 as invalid and the stream as unseekable, and e.g. WebM can't be demuxed.
*/
internal fun Headers.passthroughForPlayer(): Headers {
val upstream = this
return HeadersBuilder().apply {
PLAYER_PASSTHROUGH_HEADERS.forEach { name ->
upstream.getAll(name)?.forEach { value -> append(name, value) }
}
}.build()
} }

View File

@ -80,7 +80,8 @@ class StreamingUrlRepository(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent { private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
private val platform: PlatformType = currentPlatform) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -145,7 +146,7 @@ class StreamingUrlRepository(
} else { } else {
stream.streams stream.streams
} }
return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality) return selectPreferredAudioStream(candidates, preferredFormat, preferredQuality, platform)
.also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) } .also { logSelection(trackId, origin, candidates, preferredFormat, preferredQuality, it) }
} }
@ -259,7 +260,7 @@ class StreamingUrlRepository(
"lowest wins):\n" + "lowest wins):\n" +
streams.joinToString("\n") { stream -> streams.joinToString("\n") { stream ->
val marker = if (stream === selected) " -> " else " " val marker = if (stream === selected) " -> " else " "
"$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(currentPlatform)) 0 else 1}, " + "$marker${stream.describeForLog()} key=(${if (stream.isPlayableOn(platform)) 0 else 1}, " +
"${formatMismatchCount(stream, preferredFormat)}, " + "${formatMismatchCount(stream, preferredFormat)}, " +
"${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " + "${if (stream.matchesQualityType(preferredQuality)) 0 else 1}, " +
"${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " + "${stream.qualityDistance(preferredQuality).let { if (it == Long.MAX_VALUE) "inf" else "$it" }}, " +
@ -268,8 +269,8 @@ class StreamingUrlRepository(
} }
when { when {
selected == null -> logger.w { "[Resolve $trackId] no stream selected" } selected == null -> logger.w { "[Resolve $trackId] no stream selected" }
!selected.isPlayableOn(currentPlatform) -> logger.w { !selected.isPlayableOn(platform) -> logger.w {
"[Resolve $trackId] NO PLAYABLE STREAM on $currentPlatform; falling back to " + "[Resolve $trackId] NO PLAYABLE STREAM on $platform; falling back to " +
"${selected.describeForLog()}, which the player will likely reject" "${selected.describeForLog()}, which the player will likely reject"
} }
formatMismatchCount(selected, preferredFormat) > 0 -> logger.w { formatMismatchCount(selected, preferredFormat) > 0 -> logger.w {
@ -424,12 +425,16 @@ internal fun selectPreferredAudioStream(
} }
) )
// AVPlayer has no WebM/Ogg/Matroska demuxer, so e.g. YouTube's opus/webm streams fail to load. private val MATROSKA_CONTAINERS = setOf("webm", "mkv", "matroska")
private val IOS_UNSUPPORTED_CONTAINERS = setOf("webm", "ogg", "oga", "mkv", "matroska")
// Containers a platform's player can't open, so e.g. YouTube's opus/webm streams fail to load there.
private val UNSUPPORTED_CONTAINERS: Map<PlatformType, Set<String>> = mapOf(
// AVPlayer has no WebM/Ogg/Matroska demuxer.
PlatformType.IOS to MATROSKA_CONTAINERS + setOf("ogg", "oga"))
/** /**
* Whether [platform]'s player can play this stream. VLC (desktop) and ExoPlayer (Android) * Whether [platform]'s player can play this stream. ExoPlayer (Android) and VLC elsewhere handle
* handle everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH. * everything plugins return; AVPlayer (iOS) can't demux WebM/Ogg and can't play DASH.
*/ */
internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean = internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
isContainerPlayableOn(container, platform) && isContainerPlayableOn(container, platform) &&
@ -437,7 +442,7 @@ internal fun AudioStream.isPlayableOn(platform: PlatformType): Boolean =
/** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */ /** Whether [platform]'s player can open [container] at all; see [AudioStream.isPlayableOn]. */
internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean = internal fun isContainerPlayableOn(container: String, platform: PlatformType): Boolean =
platform != PlatformType.IOS || container.lowercase() !in IOS_UNSUPPORTED_CONTAINERS container.lowercase() !in UNSUPPORTED_CONTAINERS[platform].orEmpty()
private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int = private fun formatMismatchCount(stream: AudioStream, preferredFormat: AudioFormat): Int =
(if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) + (if (stream.codec.equals(preferredFormat.codec, ignoreCase = true)) 0 else 1) +

View File

@ -46,6 +46,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -80,6 +81,7 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.luminance import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.layout
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
@ -96,6 +98,7 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
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.ui.misc.SkeletonTree import org.spotube.spotube.core.ui.misc.SkeletonTree
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
import org.spotube.spotube.core.ui.misc.TextWithShimmer import org.spotube.spotube.core.ui.misc.TextWithShimmer
import org.spotube.spotube.core.ui.misc.shimmerApply import org.spotube.spotube.core.ui.misc.shimmerApply
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
@ -119,6 +122,42 @@ private val CompactTrackListBreakpoint = 600.dp
private val LargeTrackListBreakpoint = 840.dp private val LargeTrackListBreakpoint = 840.dp
private const val ShimmerRowCount = 6 private const val ShimmerRowCount = 6
private val TrackCardOuterPadding = 8.dp
private val TrackCardInnerPadding = 12.dp
private const val TrackCardTopKey = "track-card-top"
private const val TrackCardRowKeyPrefix = "track-card-row:"
private const val TrackCardFooterKey = "track-card-footer"
private const val TrackCardBottomKey = "track-card-bottom"
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
/**
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
*/
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
layout { measurable, constraints ->
val info = listState.layoutInfo
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
// Item offsets are relative to the viewport start, which is negative by the top padding.
val toLocal = -info.viewportStartOffset
val viewport = info.viewportEndOffset - info.viewportStartOffset
val overscan = viewport * 3
val first = cardItems.first()
val last = cardItems.last()
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
val bottom = if (last.key == TrackCardBottomKey) {
last.offset + last.size + toLocal - edgeInsetPx
} else viewport + overscan
val height = (bottom - top).coerceAtLeast(0)
val placeable = measurable.measure(
constraints.copy(minHeight = height, maxHeight = height))
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
}
enum class TrackSortOption(val label: StringResource) { enum class TrackSortOption(val label: StringResource) {
None(Res.string.track_list_sort_original), None(Res.string.track_list_sort_original),
Title(Res.string.track_list_sort_title), Title(Res.string.track_list_sort_title),
@ -198,7 +237,7 @@ fun TrackList(
val shouldLoadMore = remember(density) { val shouldLoadMore = remember(density) {
derivedStateOf { derivedStateOf {
val loadMoreThreshold = with(density) { 200.dp.toPx() } val loadMoreThreshold = with(density) { 200.dp.toPx() }
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" } val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
// Trigger when the bottom of the track card is close to/inside the viewport, // Trigger when the bottom of the track card is close to/inside the viewport,
// which means the user has scrolled through (or is about to scroll through) // which means the user has scrolled through (or is about to scroll through)
// all loaded tracks. // all loaded tracks.
@ -219,21 +258,7 @@ fun TrackList(
val showAlbum = !isCompact val showAlbum = !isCompact
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = { val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
MillenniumCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
contentPadding = PaddingValues(0.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 12.dp, bottom = 12.dp)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
MillenniumSeparator()
}
TrackListRow( TrackListRow(
index = displayedIndex + 1, index = displayedIndex + 1,
track = track, track = track,
@ -241,7 +266,8 @@ fun TrackList(
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions, useDropdownForOptions = useDropdownForOptions,
isCurrentTrack = track.id == currentTrackId, isCurrentTrack = track.id == currentTrackId,
isCurrentTrackPlaying = isCurrentTrackPlaying, // Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
isSelectionMode = isSelectionMode, isSelectionMode = isSelectionMode,
isSelected = selectedTrackIds.contains(track.id), isSelected = selectedTrackIds.contains(track.id),
onTrackClick = { onTrackClick = {
@ -289,6 +315,8 @@ fun TrackList(
onArtistsOverflowClick = { onArtistsOverflowClick(track) }) onArtistsOverflowClick = { onArtistsOverflowClick(track) })
} }
// Shimmer, error, empty and next-page rows at the end of the card.
val trackCardFooter: @Composable () -> Unit = {
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex -> repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
@ -329,6 +357,26 @@ fun TrackList(
useDropdownForOptions = useDropdownForOptions) useDropdownForOptions = useDropdownForOptions)
} }
} }
val trackCardContent: @Composable () -> Unit = {
MillenniumCard(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = TrackCardOuterPadding),
contentPadding = PaddingValues(0.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
) {
visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, track)
}
trackCardFooter()
}
} }
} }
@ -454,26 +502,54 @@ fun TrackList(
if (scrollable) { if (scrollable) {
Box(modifier = modifier.fillMaxWidth()) { Box(modifier = modifier.fillMaxWidth()) {
LazyColumn( Box(
state = listState,
modifier = Modifier modifier = Modifier
.widthIn(max = 1280.dp) .widthIn(max = 1280.dp)
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
contentPadding = contentPadding, // Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
verticalArrangement = Arrangement.spacedBy(2.dp)) { // they sit on is drawn once behind the list, spanning the card's laid-out items.
MillenniumCard(
modifier = Modifier
.matchParentSize()
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
contentPadding = PaddingValues(0.dp)) {}
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
// gaps around the card are applied to the items before and after it instead.
LazyColumn(
state = listState,
contentPadding = contentPadding) {
if (headerContent != null) { if (headerContent != null) {
item { item {
headerContent() Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
} }
} }
if (!simplified) { if (!simplified) {
item { filterSortRow() } item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
}
item(key = TrackCardTopKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
}
// Plugin ids can repeat (the same track twice in a playlist).
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
Column(Modifier.fillMaxWidth()) {
if (displayedIndex > 0) {
MillenniumSeparator()
}
trackRow(displayedIndex, visibleTracks[displayedIndex])
}
}
item(key = TrackCardFooterKey) {
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
}
item(key = TrackCardBottomKey) {
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
} }
item(key = "track-card") { trackCardContent() }
if (footerContent != null) { if (footerContent != null) {
item { item {
footerContent() Box(Modifier.padding(top = 2.dp)) { footerContent() }
}
} }
} }
} }

View File

@ -0,0 +1,67 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.misc
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
/*
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
*/
private const val DUPLICATE_SEPARATOR = '\u0000'
/**
* One key per item: an item keeps its id the first time it appears, so scroll position and
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
* a real id.
*/
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
val occurrences = HashMap<String, Int>(items.size)
return items.map { item ->
val base = id(item)
val occurrence = occurrences[base] ?: 0
occurrences[base] = occurrence + 1
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
}
}
fun <T> LazyListScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}
fun <T> LazyGridScope.itemsWithUniqueKeys(
items: List<T>,
id: (T) -> String,
keyPrefix: String = "",
itemContent: @Composable LazyGridItemScope.(T) -> Unit
) {
val keys = uniqueLazyKeys(items, id)
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
}

View File

@ -17,7 +17,6 @@
package org.spotube.spotube.core.ui.theming package org.spotube.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@ -205,7 +204,7 @@ fun SpotubeTheme(
val isDarkTheme = when (settings.theme) { val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false Theme.LIGHT -> false
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemDarkTheme()
} }
val palette: MillenniumPalette = settings.palette val palette: MillenniumPalette = settings.palette

View File

@ -0,0 +1,24 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.ui.theming
import androidx.compose.runtime.Composable
/** Whether the OS is in dark mode, observed live; desktop can't rely on Compose's own check under Tao. */
@Composable
expect fun isSystemDarkTheme(): Boolean

View File

@ -51,7 +51,7 @@ 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
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.common_unknown_error import spotube.composeapp.generated.resources.common_unknown_error

View File

@ -18,6 +18,7 @@
package org.spotube.spotube.modules.artist package org.spotube.spotube.modules.artist
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
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
@ -638,7 +639,7 @@ private fun AlbumsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(albums, key = { it.id }) { album -> itemsWithUniqueKeys(albums, id = { it.id }) { album ->
AlbumCard(album = album) AlbumCard(album = album)
} }
} }
@ -676,7 +677,7 @@ private fun RelatedArtistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(artists, key = { it.id }) { artist -> itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
ArtistCard(artist = artist) ArtistCard(artist = artist)
} }
} }
@ -714,7 +715,7 @@ private fun FeaturedPlaylistsSection(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
items(playlists, key = { it.id }) { playlist -> itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
PlaylistCard(playlist = playlist) PlaylistCard(playlist = playlist)
} }
} }

View File

@ -48,7 +48,7 @@ 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
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
sealed interface ArtistScreenState { sealed interface ArtistScreenState {

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