Compare commits

...

9 Commits

Author SHA1 Message Date
Jonatan Nyberg
7560647647
Merge b0327fd224 into 0bf0e1816c 2026-11-03 01:00:07 +00: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
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
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
76 changed files with 4066 additions and 1449 deletions

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

@ -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)
@ -443,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)

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

@ -55,7 +55,7 @@ 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
@ -165,7 +165,7 @@ class PlaybackService : MediaLibraryService(), KoinComponent {
librarySession.release() librarySession.release()
super.onDestroy() super.onDestroy()
// Swiping the app out of recents should fully close it: Android otherwise keeps this // Swiping the app out of recents should fully close it: Android otherwise keeps this
// process cached with the proxy server, MQTT client and every singleton still alive. // process cached with the proxy server, jam client and every singleton still alive.
if (taskRemoved) Process.killProcess(Process.myPid()) if (taskRemoved) Process.killProcess(Process.myPid())
} }

View File

@ -0,0 +1,22 @@
<!--
~ 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/>.
-->
<resources>
<string name="app_name">Spotube</string>
<string name="now_playing">Spelas nu</string>
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
</resources>

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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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

@ -0,0 +1,169 @@
<!--
~ 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/>.
-->
<resources>
<string name="settings_screen_title">Inställningar</string>
<string name="settings_section_plugins">Tillägg</string>
<string name="settings_section_language_region">Språk och region</string>
<string name="settings_section_appearance">Utseende</string>
<string name="settings_section_playback">Uppspelning</string>
<string name="settings_section_downloads">Nedladdningar</string>
<string name="settings_section_caching">Cachelagring</string>
<string name="settings_section_desktop">Dator</string>
<string name="settings_section_updates">Uppdateringar</string>
<string name="settings_action_close">Stäng</string>
<string name="settings_action_save">Spara</string>
<string name="settings_action_cancel">Avbryt</string>
<string name="settings_language_title">Språk</string>
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
<string name="settings_country_title">Land</string>
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
<string name="settings_theme_title">Tema</string>
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
<string name="settings_theme_light">Ljust</string>
<string name="settings_theme_dark">Mörkt</string>
<string name="settings_theme_system">System</string>
<string name="settings_accent_title">Accentfärg</string>
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
<string name="settings_accent_dialog_title">Accentfärg</string>
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
<string name="settings_preview_light">Ljust</string>
<string name="settings_preview_dark">Mörkt</string>
<string name="settings_preview_label">Förhandsgranskning</string>
<string name="settings_preview_button">Knapp</string>
<string name="settings_accent_green_goblin">Gröna trollet</string>
<string name="settings_accent_electric_violet">Elektriskt violett</string>
<string name="settings_accent_oceanic_cyan">Havscyan</string>
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
<string name="settings_accent_rose_garden">Rosenträdgård</string>
<string name="settings_accent_midnight_blue">Midnattsblått</string>
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
<string name="settings_local_media_folders_manage">Hantera</string>
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
<string name="settings_download_format_title">Musikformat för nedladdning</string>
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
<string name="settings_streaming_format_title">Musikformat för strömning</string>
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
<string name="settings_cache_folder_title">Cachemapp</string>
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
<string name="settings_cache_size_limit_placeholder">500</string>
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
<string name="settings_enable_connect_title">Aktivera Connect</string>
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
<string name="settings_playback_port_placeholder">14769</string>
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
<string name="settings_blacklist_title">Spärrlista</string>
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
<string name="settings_plugins_manage_title">Hantera tillägg</string>
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
<string name="settings_plugins_ability_metadata">Metadata</string>
<string name="settings_plugins_ability_audio">Ljud</string>
<string name="settings_plugins_ability_lyrics">Låttexter</string>
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
<string name="settings_plugins_action_change">Ändra</string>
<string name="settings_plugins_action_select">Välj</string>
<string name="settings_plugins_selected">Valt</string>
<string name="settings_plugins_clear">Rensa val</string>
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
<string name="settings_plugins_clear_content_description">Rensa</string>
<string name="plugin_configure_title">Konfigurera</string>
<string name="plugin_screen_title">Tilläggshanterare</string>
<string name="plugin_error_enter_url">Ange en URL</string>
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
<string name="plugin_installed_singular">tillägg</string>
<string name="plugin_installed_plural">tillägg</string>
<string name="plugin_install_section_title">Installera ett tillägg</string>
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
<string name="plugin_action_download">Ladda ner</string>
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
<string name="plugin_state_active">Aktivt</string>
<string name="plugin_version_label">v%1$s</string>
<string name="plugin_state_builtin">Inbyggt</string>
<string name="plugin_action_remove">Ta bort tillägg</string>
<string name="plugin_action_login">Logga in</string>
<string name="plugin_action_logout">Logga ut</string>
<string name="plugin_section_url_title">Ladda ner från URL</string>
<string name="plugin_section_file_title">Installera från fil</string>
<string name="plugin_section_install">Installera</string>
<string name="plugin_permissions_author_version">• %1$s</string>
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
<string name="plugin_permissions_installed_label">Installerat</string>
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
</resources>

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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -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>
@ -549,15 +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>
<string name="jam_error_broker_unreachable">Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam.</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<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

@ -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

@ -57,19 +57,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()
@ -306,9 +309,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

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,22 +18,31 @@
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.TimeoutCancellationException 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
@ -41,47 +50,81 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.isActive
import kotlinx.coroutines.flow.onEach
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_broker_unreachable
import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.jam_error_connection_failed
import spotube.composeapp.generated.resources.jam_error_connection_lost import spotube.composeapp.generated.resources.jam_error_connection_lost
import spotube.composeapp.generated.resources.jam_error_no_broker import spotube.composeapp.generated.resources.jam_error_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()
@ -89,244 +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
try {
withTimeout(broker.connectionTimeoutSeconds.seconds) {
// connectionState replays its current value, unlike awaitConnectivity's
// broadcast flow, which loses a CONNACK that lands before it is collected.
mqtt.connectionState.first { it is ConnectionState.Connected }
}
} catch (e: TimeoutCancellationException) {
throw IllegalStateException(
getString(Res.string.jam_error_broker_unreachable, broker.host, broker.port), e)
}
_connectionError.value = null
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
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

@ -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

@ -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 {

View File

@ -62,7 +62,7 @@ import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import kotlin.time.Clock import kotlin.time.Clock
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
data class DownloadItem( data class DownloadItem(

View File

@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
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

@ -62,6 +62,7 @@ import org.spotube.spotube.core.navigation.NavigationCommands
import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.navigation.Routes
import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.core.ui.theming.AppTheme import org.spotube.spotube.core.ui.theming.AppTheme
import org.spotube.spotube.modules.settings.sections.JamNameSettingCard
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxSetting2 import org.spotube.spotube.resources.iconsax.IconsaxSetting2
@ -138,8 +139,17 @@ fun JamScreen(navigationCommands: NavigationCommands) {
viewModel::createSession, viewModel::createSession,
viewModel::joinSession viewModel::joinSession
) )
// Pick a name before starting or joining, without a detour through Settings.
JamPanel {
JamNameSettingCard(
customName = state.customName,
defaultName = state.defaultName,
onNameSaved = viewModel::setParticipantName,
enabled = !state.isBusy
)
} }
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } }
ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
} }
} }
} }
@ -412,7 +422,11 @@ internal fun SessionPanel(
} }
Column(Modifier.weight(1f)) { Column(Modifier.weight(1f)) {
Text( Text(
participant.displayName, if (participant.isYou) {
stringResource(Res.string.jam_participants_you, participant.displayName)
} else {
participant.displayName
},
style = AppTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
@ -461,16 +475,16 @@ internal fun SessionPanel(
} }
@Composable @Composable
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) { internal fun ServerStatus(state: JamUiState, onConfigure: () -> Unit) {
Row( Row(
Modifier.fillMaxWidth(), Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp) horizontalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
Text( Text(
if (state.brokerConfigured) stringResource( if (state.serverConfigured) stringResource(
Res.string.jam_connection_host, Res.string.jam_connection_host,
state.brokerHost state.serverHost
) else stringResource(Res.string.jam_connection_missing), ) else stringResource(Res.string.jam_connection_missing),
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,

View File

@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
enum class JamSetupMode { Create, Join } enum class JamSetupMode { Create, Join }
@ -48,6 +48,7 @@ data class JamParticipantUi(
val initial: String, val initial: String,
val isHost: Boolean, val isHost: Boolean,
val canModerate: Boolean, val canModerate: Boolean,
val isYou: Boolean = false,
val menuExpanded: Boolean = false val menuExpanded: Boolean = false
) )
@ -57,8 +58,8 @@ data class JamUiState(
val role: JamRole? = null, val role: JamRole? = null,
val participants: List<JamParticipantUi> = emptyList(), val participants: List<JamParticipantUi> = emptyList(),
val roomCode: String? = null, val roomCode: String? = null,
val brokerHost: String = "", val serverHost: String = "",
val brokerConfigured: Boolean = false, val serverConfigured: Boolean = false,
val error: String? = null, val error: String? = null,
val setupMode: JamSetupMode = JamSetupMode.Create, val setupMode: JamSetupMode = JamSetupMode.Create,
val codeInput: TextFieldState = TextFieldState(), val codeInput: TextFieldState = TextFieldState(),
@ -68,7 +69,11 @@ data class JamUiState(
val isLeaving: Boolean = false, val isLeaving: Boolean = false,
val isHost: Boolean = false, val isHost: Boolean = false,
val supportsNativeShare: Boolean = false, val supportsNativeShare: Boolean = false,
val codeCopied: Boolean = false val codeCopied: Boolean = false,
/** The name the user picked (blank when they haven't). */
val customName: String = "",
/** This device's generated name, used when [customName] is blank. */
val defaultName: String = ""
) )
class JamViewModel( class JamViewModel(
@ -85,6 +90,7 @@ class JamViewModel(
private val codeInput = TextFieldState() private val codeInput = TextFieldState()
private var copyFeedbackJob: Job? = null private var copyFeedbackJob: Job? = null
private val participantMenu = MutableStateFlow<String?>(null) private val participantMenu = MutableStateFlow<String?>(null)
private val defaultName = MutableStateFlow("")
val supportsNativeShare: Boolean = val supportsNativeShare: Boolean =
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
@ -102,7 +108,8 @@ class JamViewModel(
codeCopied, codeCopied,
snapshotFlow { codeInput.text.toString() }, snapshotFlow { codeInput.text.toString() },
participantMenu, participantMenu,
isLeaving isLeaving,
defaultName
) { values -> ) { values ->
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
val role = values[0] as JamRole? val role = values[0] as JamRole?
@ -115,7 +122,7 @@ class JamViewModel(
val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings
val localError = values[6] as String? val localError = values[6] as String?
val busy = values[8] as Boolean val busy = values[8] as Boolean
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank() val serverConfigured = !settings?.jamServerUrl.isNullOrBlank()
JamUiState( JamUiState(
isActive = role != null, isActive = role != null,
@ -128,23 +135,27 @@ class JamViewModel(
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
isHost = participant.isHost, isHost = participant.isHost,
canModerate = role == JamRole.Host && !participant.isHost, canModerate = role == JamRole.Host && !participant.isHost,
isYou = participant.id == jamRoomService.participantClientId,
menuExpanded = values[11] == participant.id menuExpanded = values[11] == participant.id
) )
}, },
roomCode = roomCode, roomCode = roomCode,
brokerHost = settings?.jamBroker?.host.orEmpty(), serverHost = settings?.jamServerUrl.orEmpty()
brokerConfigured = brokerConfigured, .substringAfter("://").substringBefore('/'),
serverConfigured = serverConfigured,
error = localError ?: connectionError, error = localError ?: connectionError,
setupMode = values[7] as JamSetupMode, setupMode = values[7] as JamSetupMode,
isBusy = busy, isBusy = busy,
isLeaving = values[12] as Boolean, isLeaving = values[12] as Boolean,
codeCopied = values[9] as Boolean, codeCopied = values[9] as Boolean,
codeInput = codeInput, codeInput = codeInput,
canCreate = brokerConfigured, canCreate = serverConfigured,
canJoin = brokerConfigured && canJoin = serverConfigured &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)), JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host, isHost = role == JamRole.Host,
supportsNativeShare = supportsNativeShare supportsNativeShare = supportsNativeShare,
customName = settings?.jamParticipantName.orEmpty(),
defaultName = values[13] as String
) )
}.stateIn( }.stateIn(
viewModelScope, viewModelScope,
@ -152,6 +163,14 @@ class JamViewModel(
JamUiState(codeInput = codeInput) JamUiState(codeInput = codeInput)
) )
init {
viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() }
}
fun setParticipantName(name: String) {
viewModelScope.launch { jamRoomService.setParticipantName(name) }
}
// ---------- Session lifecycle ---------- // ---------- Session lifecycle ----------
fun createSession() { fun createSession() {

View File

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
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

@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
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

@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException import kotlin.coroutines.cancellation.CancellationException
import org.spotube.spotube.PlatformType import org.spotube.spotube.PlatformType
import org.spotube.spotube.getPlatform import org.spotube.spotube.getPlatform
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.library_local_refresh_failed import spotube.composeapp.generated.resources.library_local_refresh_failed

View File

@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import io.github.vinceglb.filekit.PlatformFile import io.github.vinceglb.filekit.PlatformFile
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.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

@ -56,7 +56,7 @@ import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState
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

@ -61,7 +61,7 @@ import okio.Path.Companion.toPath
import okio.SYSTEM import okio.SYSTEM
import okio.buffer import okio.buffer
import okio.use import okio.use
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.plugin_action_install import spotube.composeapp.generated.resources.plugin_action_install

View File

@ -59,7 +59,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.koinInject import org.koin.compose.koinInject
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import spotube.composeapp.generated.resources.* import spotube.composeapp.generated.resources.*
const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks" const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks"

View File

@ -34,7 +34,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
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.search_error_no_metadata_plugin import spotube.composeapp.generated.resources.search_error_no_metadata_plugin

View File

@ -44,7 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.joinAll import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
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.search_error_capabilities import spotube.composeapp.generated.resources.search_error_capabilities
import spotube.composeapp.generated.resources.search_error_search import spotube.composeapp.generated.resources.search_error_search

View File

@ -18,46 +18,32 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.core.ui.component.ApplicationMainBar
import org.spotube.spotube.modules.settings.components.SwitchSettingCard
import org.spotube.spotube.modules.settings.components.TextInputSettingCard import org.spotube.spotube.modules.settings.components.TextInputSettingCard
import org.spotube.spotube.modules.settings.sections.JamNameSettingCard
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
import org.spotube.spotube.modules.settings.sections.settingsSectionCard import org.spotube.spotube.modules.settings.sections.settingsSectionCard
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
import org.spotube.spotube.resources.iconsax.CustomServer import org.spotube.spotube.resources.iconsax.CustomServer
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxEye
import org.spotube.spotube.resources.iconsax.IconsaxInformation import org.spotube.spotube.resources.iconsax.IconsaxInformation
import org.spotube.spotube.resources.iconsax.IconsaxTag
import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
import org.spotube.spotube.resources.iconsax.User
import org.spotube.spotube.modules.settings.components.SettingCardItem import org.spotube.spotube.modules.settings.components.SettingCardItem
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_error_port_range
import spotube.composeapp.generated.resources.settings_error_whole_number
import spotube.composeapp.generated.resources.settings_jam_broker_client_id
import spotube.composeapp.generated.resources.settings_jam_broker_client_id_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_diagnostics_subtitle
import spotube.composeapp.generated.resources.settings_jam_broker_host
import spotube.composeapp.generated.resources.settings_jam_broker_password
import spotube.composeapp.generated.resources.settings_jam_broker_placeholder_note
import spotube.composeapp.generated.resources.settings_jam_broker_port
import spotube.composeapp.generated.resources.settings_jam_broker_test
import spotube.composeapp.generated.resources.settings_jam_broker_testing
import spotube.composeapp.generated.resources.settings_jam_broker_tls
import spotube.composeapp.generated.resources.settings_jam_broker_tls_subtitle
import spotube.composeapp.generated.resources.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_credentials
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
import spotube.composeapp.generated.resources.settings_jam_section_identity
import spotube.composeapp.generated.resources.settings_jam_broker_username
import spotube.composeapp.generated.resources.settings_section_jam
import spotube.composeapp.generated.resources.settings_jam_broker_content_description
import spotube.composeapp.generated.resources.settings_jam_broker_username_placeholder
import spotube.composeapp.generated.resources.settings_jam_broker_test_fail
import spotube.composeapp.generated.resources.jam_error_connection_failed import spotube.composeapp.generated.resources.jam_error_connection_failed
import spotube.composeapp.generated.resources.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
import spotube.composeapp.generated.resources.settings_jam_section_you
import spotube.composeapp.generated.resources.settings_jam_server_diagnostics_subtitle
import spotube.composeapp.generated.resources.settings_jam_server_note
import spotube.composeapp.generated.resources.settings_jam_server_test
import spotube.composeapp.generated.resources.settings_jam_server_test_fail
import spotube.composeapp.generated.resources.settings_jam_server_testing
import spotube.composeapp.generated.resources.settings_jam_server_title
import spotube.composeapp.generated.resources.settings_jam_server_url
import spotube.composeapp.generated.resources.settings_jam_server_url_invalid
import spotube.composeapp.generated.resources.settings_section_jam
@Composable @Composable
fun JamSettingsScreen( fun JamSettingsScreen(
@ -80,7 +66,6 @@ internal fun JamSettingsContent(
viewModel: JamSettingsViewModel = koinViewModel(), viewModel: JamSettingsViewModel = koinViewModel(),
modifier: Modifier = Modifier) { modifier: Modifier = Modifier) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val broker = uiState.settings.jamBroker
val shellBottomInset = LocalAppShellBottomInset.current val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) { val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset) PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
@ -94,166 +79,71 @@ internal fun JamSettingsContent(
.widthIn(max = 1280.dp) .widthIn(max = 1280.dp)
.align(Alignment.TopCenter), .align(Alignment.TopCenter),
contentPadding = contentPadding) { contentPadding = contentPadding) {
settingsSectionHeader(Res.string.settings_jam_section_you)
settingsSectionCard(
items = listOf(
{
JamNameSettingCard(
customName = uiState.settings.jamParticipantName,
defaultName = uiState.defaultName,
onNameSaved = viewModel::setParticipantName)
}))
settingsSectionHeader(Res.string.settings_jam_section_connection) settingsSectionHeader(Res.string.settings_jam_section_connection)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
val invalidUrl = stringResource(Res.string.settings_jam_server_url_invalid)
TextInputSettingCard( TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_host), title = stringResource(Res.string.settings_jam_server_url),
value = broker.host, value = uiState.settings.jamServerUrl,
placeholder = "broker.example.com", placeholder = DEFAULT_JAM_SERVER_URL,
icon = { SettingsItemIcon(Iconsax.CustomServer, stringResource(Res.string.settings_jam_broker_content_description)) },
onValueSaved = { host ->
viewModel.update {
copy(
jamBroker = jamBroker.copy(
host = host
)
)
}
})
},
{
val errorWholeNumber =
stringResource(Res.string.settings_error_whole_number)
val errorPortRange =
stringResource(Res.string.settings_error_port_range)
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_port),
value = broker.port.toString(),
placeholder = "1883",
normalize = { it.filter(Char::isDigit).take(5) },
validate = { value -> validate = { value ->
val port = value.toIntOrNull() if (value.isBlank() || isJamServerUrl(value)) null else invalidUrl
when {
port == null -> errorWholeNumber
port !in 1..65535 -> errorPortRange
else -> null
}
},
onValueSaved = { port ->
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.CustomServer, Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_port) stringResource(Res.string.settings_jam_server_title))
)
})
}, },
{ onValueSaved = viewModel::setServerUrl)
SwitchSettingCard(
title = stringResource(Res.string.settings_jam_broker_tls),
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
checked = broker.useTls,
onCheckedChange = { tls ->
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxWifiSquare,
stringResource(Res.string.settings_jam_broker_tls)
)
})
}))
settingsSectionHeader(Res.string.settings_jam_section_credentials)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_username),
value = broker.username.orEmpty(),
placeholder = stringResource(Res.string.settings_jam_broker_username_placeholder),
onValueSaved = { username ->
viewModel.update {
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.User,
stringResource(Res.string.settings_jam_broker_username)
)
})
},
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_password),
value = broker.password.orEmpty(),
placeholder = "••••••••",
onValueSaved = { password ->
viewModel.update {
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxEye,
stringResource(Res.string.settings_jam_broker_password)
)
})
}))
settingsSectionHeader(Res.string.settings_jam_section_identity)
settingsSectionCard(
items = listOf(
{
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_client_id),
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
value = broker.clientIdPrefix,
placeholder = "spotube",
onValueSaved = { prefix ->
viewModel.update {
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
}
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxTag,
stringResource(Res.string.settings_jam_broker_client_id)
)
})
})) }))
settingsSectionHeader(Res.string.settings_jam_section_diagnostics) settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
settingsSectionCard( settingsSectionCard(
items = listOf( items = listOf(
{ {
val canTest = broker.host.isNotBlank() && !uiState.isTesting
val resultMessage = uiState.testResult?.fold( val resultMessage = uiState.testResult?.fold(
onSuccess = { it }, onSuccess = { it },
onFailure = { onFailure = {
stringResource( stringResource(
Res.string.settings_jam_broker_test_fail, Res.string.settings_jam_server_test_fail,
it.message ?: stringResource(Res.string.jam_error_connection_failed)) it.message ?: stringResource(Res.string.jam_error_connection_failed))
}) })
SettingCardItem( SettingCardItem(
enabled = canTest, enabled = !uiState.isTesting,
title = stringResource(Res.string.settings_jam_broker_test), title = stringResource(Res.string.settings_jam_server_test),
subtitle = when { subtitle = when {
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing) uiState.isTesting -> stringResource(Res.string.settings_jam_server_testing)
resultMessage != null -> resultMessage resultMessage != null -> resultMessage
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle) else -> stringResource(Res.string.settings_jam_server_diagnostics_subtitle)
}, },
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.IconsaxInformation, Iconsax.IconsaxInformation,
stringResource(Res.string.settings_jam_broker_test)) stringResource(Res.string.settings_jam_server_test))
}, },
trailingContent = { trailingContent = {
MillenniumButton( MillenniumButton(
onClick = viewModel::testConnection, onClick = viewModel::testConnection,
enabled = broker.host.isNotBlank(),
loading = uiState.isTesting) { loading = uiState.isTesting) {
Text(stringResource(Res.string.settings_jam_broker_test)) Text(stringResource(Res.string.settings_jam_server_test))
} }
}, },
onClick = viewModel::testConnection) onClick = viewModel::testConnection)
})) }))
item { item {
Text( Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note), text = stringResource(Res.string.settings_jam_server_note),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)) .padding(horizontal = 16.dp, vertical = 4.dp))

View File

@ -3,6 +3,7 @@ package org.spotube.spotube.modules.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import org.spotube.spotube.core.jam.JamRoomClient import org.spotube.spotube.core.jam.JamRoomClient
import org.spotube.spotube.core.jam.JamRoomService
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
@ -12,15 +13,22 @@ import kotlinx.coroutines.launch
data class JamSettingsUiState( data class JamSettingsUiState(
val settings: UserSettings = UserSettings(), val settings: UserSettings = UserSettings(),
val isTesting: Boolean = false, val isTesting: Boolean = false,
val testResult: Result<String>? = null) val testResult: Result<String>? = null,
/** This device's generated jam name, used while the user hasn't picked one. */
val defaultName: String = "")
class JamSettingsViewModel( class JamSettingsViewModel(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val jamRoomClient: JamRoomClient) : ViewModel() { private val jamRoomClient: JamRoomClient,
private val jamRoomService: JamRoomService) : ViewModel() {
private val _uiState = MutableStateFlow(JamSettingsUiState()) private val _uiState = MutableStateFlow(JamSettingsUiState())
val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow()
init { init {
viewModelScope.launch {
val name = jamRoomService.defaultParticipantName()
_uiState.update { it.copy(defaultName = name) }
}
viewModelScope.launch { viewModelScope.launch {
settingsRepository.userSettings.collect { settings -> settingsRepository.userSettings.collect { settings ->
_uiState.update { it.copy(settings = settings) } _uiState.update { it.copy(settings = settings) }
@ -28,9 +36,16 @@ class JamSettingsViewModel(
} }
} }
fun update(transform: UserSettings.() -> UserSettings) { fun setParticipantName(name: String) {
viewModelScope.launch { jamRoomService.setParticipantName(name) }
}
/** A blank URL goes back to the public server. */
fun setServerUrl(url: String) {
viewModelScope.launch { viewModelScope.launch {
settingsRepository.updateSettings(uiState.value.settings.transform()) val serverUrl = url.trim().trimEnd('/').ifBlank { DEFAULT_JAM_SERVER_URL }
settingsRepository.updateSettings(uiState.value.settings.copy(jamServerUrl = serverUrl))
_uiState.update { it.copy(testResult = null) }
} }
} }
@ -39,7 +54,7 @@ class JamSettingsViewModel(
_uiState.update { it.copy(isTesting = true, testResult = null) } _uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch { viewModelScope.launch {
try { try {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker) val result = jamRoomClient.testConnection(uiState.value.settings.jamServerUrl)
_uiState.update { it.copy(testResult = result) } _uiState.update { it.copy(testResult = result) }
} finally { } finally {
_uiState.update { it.copy(isTesting = false) } _uiState.update { it.copy(isTesting = false) }

View File

@ -59,10 +59,13 @@ data class UserSettings(
val remoteControlDeviceName: String = "", val remoteControlDeviceName: String = "",
val remoteControlDeviceId: String = "", val remoteControlDeviceId: String = "",
// Group Jam (MQTT) // Group Jam
val jamParticipantName: String = "", val jamParticipantName: String = "",
val jamBroker: JamBroker = JamBroker(), /** Room server (https://github.com/team-spotube/jam-server); self-hosters point this at their own deployment. */
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
val lastJamCode: String = "", val lastJamCode: String = "",
/** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */
val jamDeviceKey: String = "",
// Downloads // Downloads
val overloadedDownloadFolder: String? = null, // When null, uses default music folder val overloadedDownloadFolder: String? = null, // When null, uses default music folder
@ -91,21 +94,12 @@ data class UserSettings(
val supportRemindersEnabled: Boolean = true, val supportRemindersEnabled: Boolean = true,
val lastSupportReminderEpochMs: Long = 0L) val lastSupportReminderEpochMs: Long = 0L)
/** /** The public Group Jam room server. */
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
* until a real broker is configured; users can self-host and point the app at it.
*/ /** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
@Serializable internal fun isJamServerUrl(url: String): Boolean =
data class JamBroker( Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
val name: String = "",
val host: String = "broker.hivemq.com",
val port: Int = 8883,
val useTls: Boolean = true,
val username: String? = null,
val password: String? = null,
val clientIdPrefix: String = "spotube",
val keepAliveSeconds: Int = 30,
val connectionTimeoutSeconds: Int = 10)
/** /**
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC. * opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.

View File

@ -34,7 +34,12 @@ interface UserSettingsSource {
val userSettings: StateFlow<UserSettings> val userSettings: StateFlow<UserSettings>
} }
class SettingsRepository(private val database: Database) : UserSettingsSource { /** [UserSettingsSource] that can also save, for consumers that update a setting or two. */
interface UserSettingsStore : UserSettingsSource {
suspend fun updateSettings(newSettings: UserSettings)
}
class SettingsRepository(private val database: Database) : UserSettingsStore {
companion object { companion object {
private val SETTINGS_KEY = stringPreferencesKey("user_settings") private val SETTINGS_KEY = stringPreferencesKey("user_settings")
@ -64,7 +69,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
initialValue = UserSettings() // Default value initialValue = UserSettings() // Default value
) )
suspend fun updateSettings(newSettings: UserSettings) { override suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs -> database.settingsDataStore.edit { prefs ->
prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings) prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings)
} }

View File

@ -0,0 +1,58 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.modules.settings.sections
import androidx.compose.runtime.Composable
import org.jetbrains.compose.resources.stringResource
import org.spotube.spotube.core.jam.JamNames
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
import org.spotube.spotube.resources.iconsax.Iconsax
import org.spotube.spotube.resources.iconsax.IconsaxEdit
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_name_description
import spotube.composeapp.generated.resources.jam_name_subtitle
import spotube.composeapp.generated.resources.jam_name_title
import spotube.composeapp.generated.resources.jam_name_too_long
/**
* "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay
* identical, like the remote control device name card.
*
* [customName] is what the user saved (blank = none), [defaultName] the generated one.
*/
@Composable
internal fun JamNameSettingCard(
customName: String,
defaultName: String,
onNameSaved: (String) -> Unit,
enabled: Boolean = true
) {
val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH)
TextInputSettingCard(
title = stringResource(Res.string.jam_name_title),
subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }),
icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) },
value = customName,
dialogDescription = stringResource(Res.string.jam_name_description, defaultName),
placeholder = defaultName,
normalize = JamNames::normalize,
validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null },
onValueSaved = onNameSaved,
enabled = enabled
)
}

View File

@ -24,8 +24,7 @@ import org.spotube.spotube.resources.iconsax.CustomServer
import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.settings_jam_broker_host_subtitle import spotube.composeapp.generated.resources.settings_jam_server_title
import spotube.composeapp.generated.resources.settings_jam_broker_title
import spotube.composeapp.generated.resources.settings_section_jam import spotube.composeapp.generated.resources.settings_section_jam
internal fun LazyListScope.jamSection( internal fun LazyListScope.jamSection(
@ -36,20 +35,17 @@ internal fun LazyListScope.jamSection(
items = listOf( items = listOf(
{ {
SettingCardItem( SettingCardItem(
title = stringResource(Res.string.settings_jam_broker_title), title = stringResource(Res.string.settings_jam_server_title),
subtitle = stringResource( subtitle = settings.jamServerUrl,
Res.string.settings_jam_broker_host_subtitle,
settings.jamBroker.host.ifBlank { "—" },
settings.jamBroker.port),
icon = { icon = {
SettingsItemIcon( SettingsItemIcon(
Iconsax.CustomServer, Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_title)) stringResource(Res.string.settings_jam_server_title))
}, },
trailingContent = { trailingContent = {
MillenniumIcon( MillenniumIcon(
imageVector = FeatherIcons.ChevronRight, imageVector = FeatherIcons.ChevronRight,
contentDescription = stringResource(Res.string.settings_jam_broker_title), contentDescription = stringResource(Res.string.settings_jam_server_title),
tint = AppTheme.colors.onSurfaceVariant) tint = AppTheme.colors.onSurfaceVariant)
}, },
onClick = { navigationCommands.navigateTo(Routes.JamSettings) }) onClick = { navigationCommands.navigateTo(Routes.JamSettings) })

View File

@ -123,7 +123,7 @@ import org.koin.compose.koinInject
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.downloads_status_cancelled import spotube.composeapp.generated.resources.downloads_status_cancelled

View File

@ -0,0 +1,44 @@
/*
* 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 kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class JamNamesTest {
@Test
fun sameSeedSameName() = runTest {
assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a"))
}
@Test
fun namesAreTwoFriendlyWordsAndVaried() = runTest {
val names = List(60) { JamNames.generate("device-$it") }
assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString())
assertTrue(names.all { it.length <= JamNames.MAX_LENGTH })
assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names")
}
@Test
fun typedNamesAreCleaned() {
assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n"))
assertEquals("", JamNames.normalize(" "))
}
}

View File

@ -0,0 +1,93 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
class JamSecurityTest {
private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") =
JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B",
durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE")
@Test
fun peerItemsWithoutATrackIdAreDropped() {
assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized())
assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized())
}
@Test
fun peerItemsLoseUrlsAndNonHttpsCovers() {
val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!!
assertEquals("", clean.url)
assertEquals("", clean.coverUrl)
assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl)
}
@Test
fun peerTextIsBounded() {
val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!!
assertTrue(clean.title.length <= 300)
assertEquals(0, clean.durationMs)
}
@Test
fun localTrackKeysDontRevealPaths() {
val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")
assertFalse("alice" in key || "Music" in key)
assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3"))
assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3"))
}
@Test
fun clientKeysAreLongRandomAndAcceptedByTheServer() {
val keys = List(100) { JamRoomCode.newClientKey() }
assertEquals(keys.size, keys.toSet().size)
// Mirrors CLIENT_KEY in the jam-server repository's src/protocol.ts.
val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$")
assertTrue(keys.all { serverPattern.matches(it) })
}
@Test
fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() {
val device = JamRoomCode.newClientKey()
assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF"))
assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG"))
assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF"))
assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF")))
}
@Test
fun deviceDigestsRevealNothingAndDifferPerRoom() {
val digest = jamDeviceDigest("ABCDEF")!!
assertEquals(digest, jamDeviceDigest("ABCDEF"))
assertNotEquals(digest, jamDeviceDigest("ABCDEG"))
assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest })
// Mirrors DEVICE_DIGEST in the jam-server repository's src/protocol.ts.
assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest))
}
@Test
fun roomCodesAreValid() {
repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) }
}
}

View File

@ -0,0 +1,135 @@
/*
* 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 kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class JamSyncTest {
// --- JamClock ---
@Test
fun clockIsUnknownBeforeTheFirstSample() {
val clock = JamClock()
assertNull(clock.offsetMs)
assertEquals(1_000L, clock.serverTime(1_000L))
}
@Test
fun clockAssumesSymmetricLatency() {
val clock = JamClock()
// Server is 5 s ahead; 40 ms each way.
clock.addSample(clientSentMs = 1_000, serverMs = 6_040, clientReceivedMs = 1_080)
assertEquals(5_000L, clock.offsetMs)
assertEquals(80L, clock.rttMs)
}
@Test
fun clockTrustsTheFastestRoundTrip() {
val clock = JamClock()
clock.addSample(clientSentMs = 0, serverMs = 5_030, clientReceivedMs = 60) // offset 5000
// A slow sample with a lopsided path skews the estimate; it must not win.
clock.addSample(clientSentMs = 1_000, serverMs = 6_400, clientReceivedMs = 1_500) // offset 5150
assertEquals(5_000L, clock.offsetMs)
}
@Test
fun clockForgetsSamplesOutsideTheWindow() {
val clock = JamClock(window = 2)
clock.addSample(0, 5_010, 20) // fast, offset 5000
clock.addSample(100, 5_250, 300) // offset 5050
clock.addSample(400, 5_660, 600) // offset 5160 — the fast one is now gone
assertEquals(5_050L, clock.offsetMs)
}
@Test
fun clockIgnoresNegativeRoundTrips() {
val clock = JamClock()
clock.addSample(clientSentMs = 100, serverMs = 0, clientReceivedMs = 50)
assertNull(clock.offsetMs)
}
// --- JamAnchor ---
@Test
fun anchorExtrapolatesWhileAdvancing() {
val anchor = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = true, rate = 1.5f)
assertEquals(13_000L, anchor.positionAt(52_000))
}
@Test
fun anchorHoldsStillWhenNotAdvancing() {
val paused = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = false)
assertEquals(10_000L, paused.positionAt(60_000))
val buffering = JamAnchor("t", 0, 10_000, 50_000, playing = true, advancing = false)
assertEquals(10_000L, buffering.positionAt(60_000))
}
// --- JamDriftCorrector ---
@Test
fun smallDriftIsLeftAlone() {
val corrector = JamDriftCorrector()
assertEquals(JamCorrection.None, corrector.correct(10_080, 10_000, 1f, seekLeadMs = 300))
}
@Test
fun behindSpeedsUpAndAheadSlowsDown() {
val behind = JamDriftCorrector().correct(10_600, 10_000, 1f, seekLeadMs = 300)
assertIs<JamCorrection.Nudge>(behind)
assertTrue(behind.rate > 1f && behind.rate <= 1.05f)
val ahead = JamDriftCorrector().correct(10_000, 10_600, 1f, seekLeadMs = 300)
assertIs<JamCorrection.Nudge>(ahead)
assertTrue(ahead.rate < 1f && ahead.rate >= 0.95f)
}
@Test
fun nudgeFollowsTheRoomRate() {
val nudge = JamDriftCorrector().correct(10_300, 10_000, 1.25f, seekLeadMs = 0)
assertIs<JamCorrection.Nudge>(nudge)
assertEquals(1.25f * 1.05f, nudge.rate, 0.001f)
}
@Test
fun largeDriftSeeksAheadByTheLead() {
val seek = JamDriftCorrector().correct(20_000, 10_000, 1f, seekLeadMs = 400)
assertEquals(JamCorrection.Seek(20_400), seek)
}
@Test
fun aSingleNoisyReadingDoesNotSeek() {
val corrector = JamDriftCorrector(seekThresholdMs = 1_500)
repeat(4) { corrector.correct(10_000, 10_000, 1f, 0) }
// One coarse report 2 s off (under the immediate-seek bar) is outvoted by the median.
assertEquals(JamCorrection.None, corrector.correct(12_000, 10_000, 1f, 0))
}
@Test
fun persistentDriftSeeksOnceTheMedianAgrees() {
val corrector = JamDriftCorrector(seekThresholdMs = 1_500)
val results = List(3) { corrector.correct(12_000, 10_000, 1f, 0) }
assertIs<JamCorrection.Nudge>(results[0])
assertIs<JamCorrection.Nudge>(results[1])
assertEquals(JamCorrection.Seek(12_000), results[2])
}
}

View File

@ -0,0 +1,36 @@
/*
* 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 platform.Foundation.NSLocale
import platform.Foundation.NSTimeZone
import platform.Foundation.currentLocale
import platform.Foundation.localTimeZone
import platform.Foundation.localeIdentifier
import platform.UIKit.UIDevice
internal actual fun jamDeviceTraits(): List<String> {
val device = UIDevice.currentDevice
return listOf(
"ios",
device.model,
device.systemName(),
device.systemVersion,
NSLocale.currentLocale.localeIdentifier,
NSTimeZone.localTimeZone.name)
}

View File

@ -0,0 +1,30 @@
/*
* 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 java.util.Locale
import java.util.TimeZone
internal actual fun jamDeviceTraits(): List<String> = listOf(
"desktop",
System.getProperty("os.name").orEmpty(),
System.getProperty("os.version").orEmpty(),
System.getProperty("os.arch").orEmpty(),
Runtime.getRuntime().availableProcessors().toString(),
Locale.getDefault().toLanguageTag(),
TimeZone.getDefault().id)

View File

@ -28,7 +28,7 @@ import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.use import androidx.compose.ui.use
import org.spotube.spotube.modules.settings.SupportedLanguages import org.spotube.spotube.modules.settings.SupportedLanguages
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.compose.resources.getString import org.spotube.spotube.core.i18n.getString
import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.pluralStringResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.Res

View File

@ -0,0 +1,46 @@
/*
* 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 java.io.File
import kotlin.test.Test
import kotlin.test.assertTrue
/**
* The library's non-composable getters query the system theme over D-Bus on Linux on every
* call, which crashed the desktop app (#3183). App code must use the ones in Strings.kt.
*/
class NonComposableStringLookupTest {
@Test
fun appCodeUsesTheAppStringLookups() {
val forbidden = Regex("""^import org\.jetbrains\.compose\.resources\.get(String|PluralString|StringArray)\s*$""")
val offenders = File("src").walkTopDown()
.filter { it.isFile && it.extension == "kt" }
.flatMap { file ->
file.readLines().withIndex()
.filter { (_, line) -> forbidden.matches(line) }
.map { (index, _) -> "${file.path}:${index + 1}" }
}
.toList()
assertTrue(
offenders.isEmpty(),
"Import getString/getPluralString/getStringArray from org.spotube.spotube.core.i18n instead:\n" +
offenders.joinToString("\n"))
}
}

View File

@ -0,0 +1,88 @@
/*
* 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 kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.spotube.spotube.core.network.HttpClientFactory
import kotlin.math.abs
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Runs the real client against a room server. Skipped unless `JAM_TEST_URL` is set:
* run `npm run dev` in a checkout of https://github.com/team-spotube/jam-server, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`.
*/
class JamRoomClientIntegrationTest {
private val serverUrl: String? = System.getenv("JAM_TEST_URL")
@Test
fun hostAndGuestShareARoom() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val host = JamRoomClient(http)
val guest = JamRoomClient(http)
val code = JamRoomCode.generate()
try {
host.testConnection(url).getOrThrow()
val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow()
val hostId = hostWelcome.you
assertEquals(hostId, hostWelcome.host)
// Same machine: the clock offset is just measurement noise.
assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}")
val queue = JamQueue(listOf(JamMediaItem(url = "", trackId = "t1", title = "Song", artist = "A",
album = "", durationMs = 1_000, coverUrl = "", protocol = "PROGRESSIVE")))
host.sendQueue(queue)
val anchor = JamAnchor("t1", 0, positionMs = 1_234, serverTimeMs = host.clock.serverTime(0), playing = true)
host.sendAnchor(anchor)
Thread.sleep(300)
// Late joiners get the retained queue and anchor in the welcome.
val guestKey = JamRoomCode.newClientKey()
val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow()
val guestId = guestWelcome.you
assertEquals(queue, guestWelcome.queue)
assertEquals(anchor, guestWelcome.anchor)
assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id })
val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance<JamRoomEvent.Command>().first() }
guest.sendCommand(JamCommand.SeekTo(42_000))
val command = withTimeout(5_000) { received.await() }
assertEquals(guestId, command.from)
assertEquals(JamCommand.SeekTo(42_000), command.command)
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance<JamRoomEvent.Closed>().first() }
host.kick(guestId, ban = false)
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false)
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
} finally {
host.disconnect()
guest.disconnect()
http.close()
}
}
}

View File

@ -0,0 +1,705 @@
/*
* 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 co.touchlab.kermit.Logger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
import org.koin.dsl.module
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue
import org.spotube.spotube.core.audioplayer.FakeAudioPlayerQueueRepository
import org.spotube.spotube.core.audioplayer.FakeBlacklistRepository
import org.spotube.spotube.core.audioplayer.FakePluginProvider
import org.spotube.spotube.core.audioplayer.FakeSettingsProvider
import org.spotube.spotube.core.audioplayer.LoopState
import org.spotube.spotube.core.audioplayer.MediaItem
import org.spotube.spotube.core.audioplayer.PlayerState
import org.spotube.spotube.core.audioplayer.QueueCollectionEntry
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.network.HttpClientFactory
import org.spotube.spotube.core.server.FakeAudioPluginService
import org.spotube.spotube.core.server.FakeAudioPluginSource
import org.spotube.spotube.core.server.FakeStreamingAudioPlayerQueue
import org.spotube.spotube.core.server.FakeTrackSourceRepository
import org.spotube.spotube.core.server.FakeUserSettingsSource
import org.spotube.spotube.core.server.StreamingUrlRepository
import org.spotube.spotube.modules.settings.UserSettings
import org.spotube.spotube.modules.settings.UserSettingsStore
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.math.abs
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import kotlin.time.Clock
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
/**
* A host and a guest [JamRoomService], each driving a simulated player, in one room on a
* real room server. The guest's player runs 1% fast, the host's 0.1% slow (so it has to
* re-anchor), and both report positions coarsely, like real players. Skipped unless `JAM_TEST_URL` is set (see [JamRoomClientIntegrationTest]).
*/
class JamSessionSimulationTest {
private val serverUrl: String? = System.getenv("JAM_TEST_URL")
@BeforeTest
fun setup() {
startKoin { modules(module { factory { (tag: String?) -> Logger.withTag(tag ?: "test") } }) }
}
@AfterTest
fun teardown() {
stopKoin()
}
@Test
fun guestFollowsTheHost() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val tracks = List(3) { track("track-$it") }
val hostSource = source("host-upload")
val host = Participant(http, url, speedError = 0.999)
val guest = Participant(http, url, speedError = 1.01)
host.sources.seed(tracks[0], hostSource, PLUGIN)
guest.sources.seed(tracks[0], source("other-upload"), PLUGIN)
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(2_000)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
assertEquals(JamRole.Host, host.service.role.value)
assertEquals(JamRole.Guest, guest.service.role.value)
delay(7_000)
assertInSync(host, guest, "after joining")
assertEquals(hostSource, guest.sources.rawEntryFor(tracks[0].id)?.source, "guest uses the host's source")
host.player.seekTo(60_000.milliseconds)
delay(5_000)
assertInSync(host, guest, "after the host seeked")
// A guest's seek moves the whole room.
guest.player.seekTo(90_000.milliseconds)
// Two hops (guest -> host -> guest) and two seeks: allow longer to settle.
delay(8_000)
assertTrue(host.player.truePosition() in 96_000..100_000, "host followed the guest's seek: ${host.player.truePosition()}")
assertInSync(host, guest, "after the guest seeked")
guest.player.pause()
delay(1_500)
assertEquals(PlayerState.PAUSED, host.player.playerStateFlow.value, "guest paused the room")
host.player.play()
host.service.skipNext()
delay(6_000)
assertEquals(1, host.player.index.value)
assertEquals(1, guest.player.index.value, "guest followed the skip")
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value)
assertInSync(host, guest, "after the skip")
// Mashing skip stays within the server's flood limits (the host isn't cut off).
repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() }
delay(6_000)
assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip")
assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips")
assertInSync(host, guest, "after mashing skip")
// Steady playback costs (almost) nothing: count the anchors a listener receives.
val listener = JamRoomClient(http)
try {
listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow()
delay(1_000) // The host answers the join with an anchor.
var anchors = 0
val counting = launch {
listener.events.collect { if (it is JamRoomEvent.Anchor) anchors++ }
}
delay(STEADY_WINDOW_MS)
counting.cancel()
println("Jam simulation: $anchors anchor(s) in ${STEADY_WINDOW_MS / 1000}s of steady playback")
assertTrue(anchors <= 2, "steady playback sent $anchors anchors")
assertInSync(host, guest, "after steady playback")
} finally {
listener.disconnect()
}
} finally {
guest.close()
host.close()
http.close()
}
}
/**
* Songs that end on their own. The guest has its own uploads of every song (a shorter
* intro) and its player loads the next song halfway through the current one, so it
* must have the host's recording before then, or it plays a different one.
*/
@Test
fun usesTheHostsRecordingForEverySong() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
val host = Participant(http, url, speedError = 1.0, realQueue = true)
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
tracks.forEach {
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
}
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(500)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
delay(6_000)
assertEquals("host-song-0", guest.player.currentSource, "guest plays the host's recording of song 0")
assertInSync(host, guest, "during song 0")
for (song in 1..2) {
while (host.player.index.value < song) delay(100)
delay(5_000)
assertEquals(song, guest.player.index.value, "guest on song $song")
assertEquals("host-song-$song", guest.player.currentSource, "guest plays the host's recording of song $song")
assertInSync(host, guest, "5 s into song $song after the previous one ended")
}
} finally {
guest.close()
host.close()
http.close()
}
}
/**
* A guest that can't use the host's recordings (another audio plugin) finishes each
* song early. It must wait for the host, not jump back to the song that just ended.
*/
@Test
fun aGuestThatFinishesASongFirstWaitsForTheHost() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
val host = Participant(http, url, speedError = 1.0)
val guest = Participant(http, url, speedError = 1.0, pluginId = "other-plugin")
tracks.forEach { guest.sources.seed(it, source("own-${it.id}"), "other-plugin") }
val jumpedBack = CopyOnWriteArrayList<String>()
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(500)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
delay(4_000)
val watcher = launch {
var last = guest.player.index.value
guest.player.index.collect { now ->
if (now < last) jumpedBack += "guest went back from song $last to $now"
last = now
}
}
for (song in 1..2) {
while (host.player.index.value < song) delay(100)
delay(5_000)
assertEquals(song, guest.player.index.value, "guest on song $song")
}
watcher.cancel()
assertTrue(jumpedBack.isEmpty(), jumpedBack.toString())
} finally {
guest.close()
host.close()
http.close()
}
}
/**
* A jam must not keep the CPU busy (a spinning loop allocates nonstop and freezes the
* app in GC). Measures the process's CPU time while the room sits in each state.
*/
@Test
fun idleStatesDoNotBurnCpu() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val tracks = List(3) { track("cpu-$it") }
val host = Participant(http, url, speedError = 1.0, realQueue = true)
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
val os = java.lang.management.ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean
suspend fun cpuDuring(label: String, block: suspend () -> Unit) {
val before = os.processCpuTime
val start = System.nanoTime()
block()
val share = (os.processCpuTime - before).toDouble() / (System.nanoTime() - start)
println("Jam simulation: CPU ${"%.0f".format(share * 100)}% of a core while $label")
assertTrue(share < 0.5, "CPU ${"%.0f".format(share * 100)}% of a core while $label")
}
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(500)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
delay(5_000)
cpuDuring("playing in sync") { delay(8_000) }
host.player.pause()
delay(2_000)
cpuDuring("the room is paused") { delay(8_000) }
host.player.play()
delay(3_000)
host.player.stall()
delay(1_000)
cpuDuring("the host is buffering") { delay(8_000) }
host.player.unstall()
delay(3_000)
guest.player.stall()
cpuDuring("the guest is buffering") { delay(8_000) }
guest.player.unstall()
} finally {
guest.close()
host.close()
http.close()
}
}
/**
* Several minutes of jam (songs ending, recordings switching, seeks) must not grow the
* heap. Opt-in, since it takes minutes: set `JAM_SOAK=1`.
*/
@Test
fun longSessionDoesNotLeak() = runBlocking {
val url = serverUrl ?: return@runBlocking
if (System.getenv("JAM_SOAK") == null) return@runBlocking
val http = HttpClientFactory()
val tracks = List(8) { track("soak-$it", durationMs = SONG_MS) }
val host = Participant(http, url, speedError = 1.0, realQueue = true)
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
tracks.forEach {
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
}
val runtime = Runtime.getRuntime()
fun usedMb(): Long {
repeat(3) { System.gc(); Thread.sleep(200) }
return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
}
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(500)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
delay(20_000)
val baseline = usedMb()
val samples = mutableListOf(baseline)
repeat(6) { round ->
if (round % 2 == 0) host.player.seekTo(10_000.milliseconds) else guest.player.seekTo(20_000.milliseconds)
delay(30_000)
samples += usedMb()
}
println("Jam simulation: heap after GC over ${samples.size} samples (MB): $samples")
assertTrue(samples.last() - baseline < 30, "heap grew from $baseline MB to ${samples.last()} MB")
assertInSync(host, guest, "after a long session")
} finally {
guest.close()
host.close()
http.close()
}
}
/** Losing the network (guest, then host) must not crash, and the room picks up again. */
@Test
fun roomSurvivesNetworkLoss() = runBlocking {
val url = serverUrl ?: return@runBlocking
val http = HttpClientFactory()
val hostNet = TcpProxy(url)
val guestNet = TcpProxy(url)
val tracks = List(3) { track("track-$it") }
val host = Participant(http, hostNet.url, speedError = 1.0)
val guest = Participant(http, guestNet.url, speedError = 1.01)
val crashes = CopyOnWriteArrayList<Throwable>()
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { _, e -> crashes += e }
try {
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
delay(1_000)
val code = host.service.createRoom().getOrThrow()
guest.service.joinRoom(code).getOrThrow()
delay(5_000)
assertInSync(host, guest, "before the guest lost its network")
guestNet.goOffline()
delay(1_000)
assertEquals(false, guest.service.isConnected.value, "guest noticed the drop")
assertEquals(code, guest.service.roomCode.value, "guest stays in the room while offline")
// Meanwhile the room moves on without the guest.
host.player.seekTo(40_000.milliseconds)
delay(4_000)
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing offline")
guestNet.goOnline()
delay(10_000) // Up to one backoff step, then resync.
assertEquals(true, guest.service.isConnected.value, "guest reconnected")
assertEquals(JamRole.Guest, guest.service.role.value)
assertInSync(host, guest, "after the guest reconnected")
// A host that drops for less than the grace period keeps the room.
hostNet.goOffline()
delay(5_000)
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing while the host is away")
hostNet.goOnline()
delay(10_000)
assertEquals(true, host.service.isConnected.value, "host reconnected")
assertEquals(JamRole.Host, host.service.role.value, "host kept the room")
host.player.seekTo(100_000.milliseconds)
delay(5_000)
assertInSync(host, guest, "after the host reconnected and seeked")
assertTrue(crashes.isEmpty(), "uncaught exceptions: $crashes")
} finally {
Thread.setDefaultUncaughtExceptionHandler(previousHandler)
guest.close()
host.close()
hostNet.close()
guestNet.close()
http.close()
}
}
private fun assertInSync(host: Participant, guest: Participant, phase: String) {
assertEquals(host.player.index.value, guest.player.index.value, "same track $phase")
val gap = guest.player.songPosition() - host.player.songPosition()
println("Jam simulation: guest is ${gap}ms off $phase")
assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase")
}
private class Participant(
http: HttpClientFactory,
serverUrl: String,
speedError: Double,
pluginId: String = PLUGIN,
/** The app's own queue (stateIn flows and all) instead of [SimQueue]. */
realQueue: Boolean = false) {
val sources = FakeTrackSourceRepository()
val player = SimPlayer(speedError)
val queue: AudioPlayerQueue = if (realQueue) {
player.resolveSource = { i ->
val id = player.playlistFlow.value.getOrNull(i)?.url?.substringAfterLast('/')
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
}
DeviceAudioPlayerQueue(
player, FakeSettingsProvider(), FakeAudioPlayerQueueRepository(),
FakePluginProvider(), FakeBlacklistRepository())
} else {
SimQueue(player, sources)
}
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(pluginId))
val service = JamRoomService(
jamClient = JamRoomClient(http),
audioPlayer = player,
audioPlayerQueue = queue,
settingsRepository = FakeSettingsStore(UserSettings(jamServerUrl = serverUrl)),
audioPluginSource = plugins,
trackSources = sources,
streamingUrlRepository = StreamingUrlRepository(
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()),
evictCachedAudio = {})
suspend fun close() {
service.leaveRoom()
player.dispose()
}
}
private class FakeSettingsStore(initial: UserSettings) : UserSettingsStore {
override val userSettings = MutableStateFlow(initial)
override suspend fun updateSettings(newSettings: UserSettings) {
userSettings.value = newSettings
}
}
/**
* Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as
* a drifting device would), positions are reported every 250 ms rounded to 100 ms, and
* every seek or track change buffers briefly.
*
* Like ExoPlayer, it picks a song's recording (source) when it loads the song: the next
* song is loaded halfway through the current one, and jumping to the song already
* playing keeps its recording. Recordings differ in intro length ([introMs]).
*/
private class SimPlayer(private val speedError: Double) : AudioPlayerInterface {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var durations: List<Long> = emptyList()
/** The source the queue would resolve for a song now. */
var resolveSource: (Int) -> String = { "default" }
private val loadedSources = mutableMapOf<Int, String>()
val currentSource: String? get() = loadedSources[index.value]
private var basePosition = 0L
private var baseAt = now()
private var speed = 1f
private var playWhenReady = false
val index = MutableStateFlow(-1)
private val _state = MutableStateFlow(PlayerState.IDLE)
private val _position = MutableStateFlow(Duration.ZERO)
private val _duration = MutableStateFlow(Duration.ZERO)
private val _speed = MutableStateFlow(1f)
private val _playlist = MutableStateFlow<List<MediaItem>>(emptyList())
private val _currentItem = MutableStateFlow<MediaItem?>(null)
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
init {
scope.launch {
while (isActive) {
delay(250)
val position = truePosition()
val duration = currentDuration() ?: continue
val next = index.value + 1
if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) {
loadedSources[next] = resolveSource(next)
}
if (position >= duration) {
_completion.tryEmit(Unit)
if (index.value < durations.lastIndex) advanceGapless()
continue
}
_position.value = (position / 100 * 100).milliseconds
}
}
}
/** Where in the song (not the recording) playback is. */
fun songPosition(): Long = truePosition() - introMs(currentSource)
private fun currentDuration(): Long? =
durations.getOrNull(index.value)?.let { it + introMs(currentSource) }
@Synchronized
fun truePosition(): Long =
if (_state.value == PlayerState.PLAYING) {
basePosition + ((now() - baseAt) * speed * speedError).toLong()
} else {
basePosition
}
@Synchronized
private fun rebase(position: Long = truePosition()) {
basePosition = position
baseAt = now()
}
fun loadTracks(trackDurations: List<Long>, start: Int, autoPlay: Boolean) {
durations = trackDurations
playWhenReady = autoPlay
loadedSources.clear()
startTrack(start)
}
/**
* A song ending on its own, like ExoPlayer's gapless transition: no buffering, and
* the position keeps the old song's value until the next report.
*/
private fun advanceGapless() {
val next = index.value + 1
loadedSources.getOrPut(next) { resolveSource(next) }
index.value = next
_currentItem.value = _playlist.value.getOrNull(next)
_duration.value = currentDuration()!!.milliseconds
rebase(0)
}
fun startTrack(i: Int) {
// Jumping to the song already playing seeks within it (ExoPlayer's
// seekToDefaultPosition): the loaded recording stays.
if (i != index.value || i !in loadedSources) loadedSources[i] = loadedSources[i] ?: resolveSource(i)
index.value = i
_currentItem.value = _playlist.value.getOrNull(i)
_duration.value = currentDuration()!!.milliseconds
rebase(0)
_position.value = Duration.ZERO
buffer()
}
private fun buffer() {
rebase()
_state.value = PlayerState.BUFFERING
scope.launch {
delay(BUFFER_MS)
rebase()
_state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
}
}
/** Network stall: buffering until [unstall]. */
fun stall() {
rebase()
_state.value = PlayerState.BUFFERING
}
fun unstall() {
rebase()
_state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
}
override suspend fun play() {
playWhenReady = true
rebase()
if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PLAYING
}
override suspend fun pause() {
playWhenReady = false
rebase()
if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PAUSED
}
override suspend fun seekTo(position: Duration) {
rebase(position.inWholeMilliseconds)
_position.value = position
buffer()
}
override suspend fun setPlaybackSpeed(speed: Float) {
rebase()
this.speed = speed
_speed.value = speed
}
override suspend fun skipToNext() = startTrack((index.value + 1).coerceAtMost(durations.lastIndex))
override suspend fun skipToPrevious() = startTrack((index.value - 1).coerceAtLeast(0))
override suspend fun jumpTo(index: Int) = startTrack(index)
override val playerStateFlow: StateFlow<PlayerState> = _state
override val positionFlow: StateFlow<Duration> = _position
override val durationFlow: StateFlow<Duration> = _duration
override val playbackSpeedFlow: StateFlow<Float> = _speed
override val completionFlow: Flow<Unit> = _completion
override val shuffleModeFlow: StateFlow<Boolean> = MutableStateFlow(false)
override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentItem
override val playlistFlow: StateFlow<List<MediaItem>> = _playlist
override val bufferingPositionFlow: StateFlow<Duration> = MutableStateFlow(Duration.ZERO)
override val loopStateFlow: StateFlow<LoopState> = MutableStateFlow(LoopState.NONE)
override val volumeFlow: StateFlow<Float> = MutableStateFlow(1f)
override val errorFlow: Flow<Throwable> = MutableSharedFlow()
override suspend fun stop() = pause()
override suspend fun loop(state: LoopState) = Unit
override suspend fun shuffle(enabled: Boolean) = Unit
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
_playlist.value = playlist
loadTracks(playlist.map { it.duration.inWholeMilliseconds }, startPosition, autoPlay)
}
override suspend fun addMediaItem(mediaItem: MediaItem) {
_playlist.value += mediaItem
durations = durations + mediaItem.duration.inWholeMilliseconds
}
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit
override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit
override suspend fun setVolume(volume: Float) = Unit
override fun isDisposed(): Boolean = !scope.isActive
override fun dispose() = scope.cancel()
}
private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val entries = MutableStateFlow<List<QueueEntry>>(emptyList())
init {
player.resolveSource = { i ->
val id = (entries.value.getOrNull(i) as? QueueEntry.StreamingTrack)?.track?.id
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
}
}
override val queueFlow: StateFlow<List<QueueEntry>> = entries
override val currentQueueEntryFlow: StateFlow<QueueEntry?> =
combine(entries, player.index) { list, i -> list.getOrNull(i) }
.stateIn(scope, SharingStarted.Eagerly, null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = MutableStateFlow(null)
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
override suspend fun load(
entries: List<QueueEntry>,
autoPlay: Boolean,
startPosition: Int,
collectionEntry: QueueCollectionEntry?) {
this.entries.value = entries
player.loadTracks(entries.map { (it as QueueEntry.StreamingTrack).track.durationMs }, startPosition, autoPlay)
}
override suspend fun jumpTo(index: Int, autoPlay: Boolean) {
if (autoPlay) player.play() else player.pause()
player.startTrack(index)
}
override suspend fun reloadCurrent() = jumpTo(player.index.value, autoPlay = true)
override suspend fun addToQueue(entry: QueueEntry) = addAllToQueue(listOf(entry), null)
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) {
this.entries.value += entries
}
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = addAllToQueue(entries, null)
override suspend fun removeFromQueue(entry: QueueEntry) = Unit
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = Unit
override suspend fun move(fromIndex: Int, toIndex: Int) = Unit
override suspend fun clear() = Unit
override suspend fun getQueue(): List<QueueEntry> = entries.value
override suspend fun getCurrentQueueEntry(): QueueEntry? = currentQueueEntryFlow.value
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
}
companion object {
private const val PLUGIN = "test-audio-plugin"
private const val BUFFER_MS = 200L
private const val MAX_GAP_MS = 200L
private const val STEADY_WINDOW_MS = 40_000L
private const val SONG_MS = 30_000L
/** How much longer a recording's intro is than the original's ("own" uploads cut 2.5 s). */
private fun introMs(source: String?): Long = if (source?.startsWith("own-") == true) -2_500 else 0
private fun now() = Clock.System.now().toEpochMilliseconds()
private fun track(id: String, durationMs: Long = 180_000) = MetadataTrack(
id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null,
artists = emptyList(), album = null, thumbnails = null, explicit = null,
popularity = null, isrcCode = null, externalUri = null)
private fun source(id: String) = AudioSource.Basic(
id = id, title = id, artist = null, album = null, thumbnails = emptyList(),
externalUri = null, confidence = 1f)
}
}

View File

@ -0,0 +1,96 @@
/*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.spotube.spotube.core.jam
import java.net.InetSocketAddress
import java.net.ServerSocket
import java.net.Socket
import java.net.URI
import java.util.concurrent.CopyOnWriteArrayList
import kotlin.concurrent.thread
/**
* A TCP proxy in front of the room server that a test can cut, like a phone losing its
* network: [goOffline] resets every open connection and refuses new ones until [goOnline].
*/
class TcpProxy(target: String) : AutoCloseable {
private val targetHost: String
private val targetPort: Int
private val server = ServerSocket().apply {
reuseAddress = true
bind(InetSocketAddress("127.0.0.1", 0))
}
private val sockets = CopyOnWriteArrayList<Socket>()
@Volatile
private var online = true
init {
val uri = URI(target)
targetHost = uri.host
targetPort = if (uri.port > 0) uri.port else 80
thread(isDaemon = true, name = "tcp-proxy") {
while (!server.isClosed) {
val client = runCatching { server.accept() }.getOrNull() ?: break
if (!online) {
reset(client)
continue
}
val upstream = runCatching { Socket(targetHost, targetPort) }.getOrElse {
reset(client)
continue
}
sockets += client
sockets += upstream
pipe(client, upstream)
pipe(upstream, client)
}
}
}
val url: String get() = "http://127.0.0.1:${server.localPort}"
fun goOffline() {
online = false
sockets.forEach(::reset)
sockets.clear()
}
fun goOnline() {
online = true
}
override fun close() {
goOffline()
server.close()
}
private fun pipe(from: Socket, to: Socket) = thread(isDaemon = true, name = "tcp-proxy-pipe") {
runCatching { from.getInputStream().copyTo(to.getOutputStream()) }
reset(from)
reset(to)
}
/** Closes with a TCP reset, as an abrupt network loss looks to the other side. */
private fun reset(socket: Socket) {
runCatching {
socket.setSoLinger(true, 0)
socket.close()
}
}
}

View File

@ -65,20 +65,20 @@ class JamLayoutTest {
assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight) assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight)
} }
/** Actual Compose controls rendered without native-window capture or an MQTT connection. */ /** Actual Compose controls rendered without native-window capture or a room server connection. */
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Test @Test
fun renderRoomControlsAtNarrowAndWideSizes() { fun renderRoomControlsAtNarrowAndWideSizes() {
val output = File("build/reports/jam-ui").apply { mkdirs() } val output = File("build/reports/jam-ui").apply { mkdirs() }
val states = mapOf( val states = mapOf(
"create" to JamUiState( "create" to JamUiState(
brokerConfigured = true, serverConfigured = true,
brokerHost = "test.mosquitto.org", serverHost = "jam.example.com",
canCreate = true canCreate = true
), ),
"join" to JamUiState( "join" to JamUiState(
brokerConfigured = true, serverConfigured = true,
brokerHost = "test.mosquitto.org", serverHost = "jam.example.com",
setupMode = JamSetupMode.Join setupMode = JamSetupMode.Join
), ),
"host" to JamUiState( "host" to JamUiState(
@ -86,8 +86,8 @@ class JamLayoutTest {
isConnected = true, isConnected = true,
isHost = true, isHost = true,
roomCode = "RC5FJ8", roomCode = "RC5FJ8",
brokerConfigured = true, serverConfigured = true,
brokerHost = "test.mosquitto.org", serverHost = "jam.example.com",
participants = listOf( participants = listOf(
JamParticipantUi("host", "Host-8316", "H", true, false), JamParticipantUi("host", "Host-8316", "H", true, false),
JamParticipantUi("guest", "Guest-6684", "G", false, true) JamParticipantUi("guest", "Guest-6684", "G", false, true)
@ -97,8 +97,8 @@ class JamLayoutTest {
isActive = true, isActive = true,
isConnected = true, isConnected = true,
roomCode = "RC5FJ8", roomCode = "RC5FJ8",
brokerConfigured = true, serverConfigured = true,
brokerHost = "test.mosquitto.org", serverHost = "jam.example.com",
participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false)) participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false))
) )
) )
@ -161,7 +161,7 @@ class JamLayoutTest {
{}, {},
{}) {})
else SetupPanel(state, {}, {}, {}) else SetupPanel(state, {}, {}, {})
BrokerStatus(state, {}) ServerStatus(state, {})
} }
} }
} }

View File

@ -36,8 +36,6 @@ kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0" kotlinx-coroutines = "1.11.0"
kotlinx-serialization-json = "1.11.0" kotlinx-serialization-json = "1.11.0"
millennium = "0.1.1" millennium = "0.1.1"
mqttClient = "2.1.1"
mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0" multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2" murmurhash = "0.4.2"
newpipeextractor = "v0.26.5" newpipeextractor = "v0.26.5"
@ -105,10 +103,6 @@ ktor-server-core = { module = "io.ktor:ktor-server-core", version.ref = "ktor" }
ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" } millennium = { module = "org.spotube.millennium:millennium", version.ref = "millennium" }
mqtt-x-models = { module = "com.ditchoom:mqtt-5-models", version.ref = "mqttClient" }
mqtt-client = { module = "com.ditchoom:mqtt-client", version.ref = "mqttClient" }
mqtt-buffer = { module = "com.ditchoom:buffer", version.ref = "mqttBuffer" }
mqtt-buffer-codec = { module = "com.ditchoom:buffer-codec", version.ref = "mqttBuffer" }
multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer = { module = "com.mikepenz:multiplatform-markdown-renderer", version.ref = "multiplatformMarkdownRenderer" }
multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" } multiplatform-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" } murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }