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.
This commit is contained in:
Kingkor Roy Tirtho 2026-10-31 16:26:41 +06:00
parent b12e98a8c5
commit 0a0b0c9374
57 changed files with 20727 additions and 1399 deletions

35
.github/workflows/jam-server-deploy.yml vendored Normal file
View File

@ -0,0 +1,35 @@
name: Deploy Jam server
on:
push:
branches: [master]
paths: ["jam-server/**"]
workflow_dispatch:
concurrency:
group: jam-server-deploy
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
defaults:
run:
working-directory: jam-server
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: jam-server/package-lock.json
- run: npm ci
- run: npm run typecheck
- name: Deploy
run: npx wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
- name: Smoke test
run: JAM_URL="${{ vars.JAM_SERVER_URL }}" npm test
if: ${{ vars.JAM_SERVER_URL != '' }}

View File

@ -26,6 +26,14 @@
- 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.
## Group Jam
- Room server: `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 `jam-server/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).
- 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 `jam-server/README.md`.
- 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 (start `npm run dev` in `jam-server/`).
## 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`.
- 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.

View File

@ -289,12 +289,6 @@ kotlin {
// DLNA
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
implementation(libs.multiplatform.markdown.renderer)
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 {
coreLibraryDesugaring(libs.desugar.jdk.libs)
debugImplementation(libs.compose.uiTooling)

View File

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

View File

@ -308,25 +308,11 @@
<string name="plugin_permissions_capability_webview_title">WebView</string>
<string name="plugin_permissions_capability_webview_desc">عرض محتوى الويب داخل التطبيق</string>
<string name="settings_section_jam">الجام الجماعي</string>
<string name="settings_jam_broker_title">وسيط MQTT</string>
<string name="settings_jam_broker_host">مضيف الوسيط</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">منفذ الوسيط</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_server_test">اختبار الاتصال</string>
<string name="settings_jam_server_testing">جارٍ الاختبار…</string>
<string name="settings_jam_server_test_ok">تم الاتصال خلال %1$d ms</string>
<string name="settings_jam_server_test_fail">فشل: %1$s</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>
<!-- Group Jam -->
@ -339,9 +325,6 @@
<string name="jam_setup_working">جارٍ الاتصال…</string>
<string name="jam_setup_code_label">رمز الغرفة</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_connecting">جارٍ إعادة الاتصال…</string>
<string name="jam_invite_copy">نسخ الرمز</string>
@ -533,14 +516,9 @@
<string name="jam_destination_suggest">اقتراح للجام</string>
<!-- 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_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 -->
<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_desc">অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো</string>
<string name="settings_section_jam">গ্রুপ জ্যাম</string>
<string name="settings_jam_broker_title">MQTT ব্রোকার</string>
<string name="settings_jam_broker_host">ব্রোকার হোস্ট</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">ব্রোকার পোর্ট</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_server_test">কানেকশন পরীক্ষা করুন</string>
<string name="settings_jam_server_testing">পরীক্ষা চলছে…</string>
<string name="settings_jam_server_test_ok">%1$d ms-এ কানেক্ট হয়েছে</string>
<string name="settings_jam_server_test_fail">ব্যর্থ: %1$s</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>
<!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">কানেক্ট হচ্ছে…</string>
<string name="jam_setup_code_label">রুম কোড</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_connecting">আবার কানেক্ট হচ্ছে…</string>
<string name="jam_invite_copy">কোড কপি করুন</string>
@ -517,14 +500,9 @@
<string name="jam_destination_suggest">জ্যামে সাজেস্ট করুন</string>
<!-- 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_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 -->
<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_desc">Webinhalte in der App anzeigen</string>
<string name="settings_section_jam">Gruppen-Jam</string>
<string name="settings_jam_broker_title">MQTT-Broker</string>
<string name="settings_jam_broker_host">Broker-Host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker-Port</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_server_test">Verbindung testen</string>
<string name="settings_jam_server_testing">Wird getestet…</string>
<string name="settings_jam_server_test_ok">Verbunden in %1$d ms</string>
<string name="settings_jam_server_test_fail">Fehlgeschlagen: %1$s</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>
<!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Verbinden…</string>
<string name="jam_setup_code_label">Raumcode</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_connecting">Verbindung wird wiederhergestellt…</string>
<string name="jam_invite_copy">Code kopieren</string>
@ -517,14 +500,9 @@
<string name="jam_destination_suggest">Für Jam vorschlagen</string>
<!-- 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_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 -->
<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_desc">Mostrar contenido web dentro de la app</string>
<string name="settings_section_jam">Jam en grupo</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Host del broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Puerto del broker</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_server_test">Probar conexión</string>
<string name="settings_jam_server_testing">Probando…</string>
<string name="settings_jam_server_test_ok">Conectado en %1$d ms</string>
<string name="settings_jam_server_test_fail">Error: %1$s</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>
<!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Conectando…</string>
<string name="jam_setup_code_label">Código de la sala</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_connecting">Reconectando…</string>
<string name="jam_invite_copy">Copiar código</string>
@ -521,14 +504,9 @@
<string name="jam_destination_suggest">Sugerir al Jam</string>
<!-- 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_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 -->
<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_desc">نمایش محتوای وب درون برنامه</string>
<string name="settings_section_jam">جم گروهی</string>
<string name="settings_jam_broker_title">بروکر MQTT</string>
<string name="settings_jam_broker_host">میزبان بروکر</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">درگاه بروکر</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_server_test">آزمایش اتصال</string>
<string name="settings_jam_server_testing">در حال آزمایش…</string>
<string name="settings_jam_server_test_ok">در %1$d میلی‌ثانیه متصل شد</string>
<string name="settings_jam_server_test_fail">ناموفق: %1$s</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>
<!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">در حال اتصال…</string>
<string name="jam_setup_code_label">کد اتاق</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_connecting">در حال اتصال دوباره…</string>
<string name="jam_invite_copy">کپی کد</string>
@ -517,14 +500,9 @@
<string name="jam_destination_suggest">پیشنهاد به جم</string>
<!-- 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_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 -->
<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_desc">Afficher du contenu web dans l\'application</string>
<string name="settings_section_jam">Jam de groupe</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Hôte du broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Port du broker</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_server_test">Tester la connexion</string>
<string name="settings_jam_server_testing">Test en cours…</string>
<string name="settings_jam_server_test_ok">Connecté en %1$d ms</string>
<string name="settings_jam_server_test_fail">Échec : %1$s</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>
<!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Connexion…</string>
<string name="jam_setup_code_label">Code de la salle</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_connecting">Reconnexion…</string>
<string name="jam_invite_copy">Copier le code</string>
@ -521,14 +504,9 @@
<string name="jam_destination_suggest">Proposer à la Jam</string>
<!-- 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_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 -->
<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_desc">ऐप के अंदर वेब कंटेंट दिखाएं</string>
<string name="settings_section_jam">ग्रुप जैम</string>
<string name="settings_jam_broker_title">MQTT ब्रोकर</string>
<string name="settings_jam_broker_host">ब्रोकर होस्ट</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">ब्रोकर पोर्ट</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_server_test">कनेक्शन टेस्ट करें</string>
<string name="settings_jam_server_testing">टेस्ट हो रहा है…</string>
<string name="settings_jam_server_test_ok">%1$d ms में कनेक्ट हुआ</string>
<string name="settings_jam_server_test_fail">विफल: %1$s</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>
<!-- Group Jam -->
@ -322,9 +308,6 @@
<string name="jam_setup_working">कनेक्ट हो रहा है…</string>
<string name="jam_setup_code_label">रूम कोड</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_connecting">फिर से कनेक्ट हो रहा है…</string>
<string name="jam_invite_copy">कोड कॉपी करें</string>
@ -516,14 +499,9 @@
<string name="jam_destination_suggest">जैम में सुझाएं</string>
<!-- 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_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 -->
<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_desc">Menampilkan konten web di dalam aplikasi</string>
<string name="settings_section_jam">Jam Grup</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Host broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Port broker</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_server_test">Uji koneksi</string>
<string name="settings_jam_server_testing">Menguji…</string>
<string name="settings_jam_server_test_ok">Terhubung dalam %1$d ms</string>
<string name="settings_jam_server_test_fail">Gagal: %1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">Menghubungkan…</string>
<string name="jam_setup_code_label">Kode ruang</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_connecting">Menghubungkan ulang…</string>
<string name="jam_invite_copy">Salin kode</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">Sarankan ke Jam</string>
<!-- 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_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 -->
<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_desc">Mostrare contenuti web all\'interno dell\'app</string>
<string name="settings_section_jam">Jam di gruppo</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Host del broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Porta del broker</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_server_test">Verifica connessione</string>
<string name="settings_jam_server_testing">Verifica in corso…</string>
<string name="settings_jam_server_test_ok">Connesso in %1$d ms</string>
<string name="settings_jam_server_test_fail">Non riuscito: %1$s</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>
<!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Connessione…</string>
<string name="jam_setup_code_label">Codice stanza</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_connecting">Riconnessione…</string>
<string name="jam_invite_copy">Copia codice</string>
@ -521,14 +504,9 @@
<string name="jam_destination_suggest">Suggerisci alla Jam</string>
<!-- 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_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 -->
<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_desc">アプリ内でのWebコンテンツの表示</string>
<string name="settings_section_jam">グループジャム</string>
<string name="settings_jam_broker_title">MQTTブローカー</string>
<string name="settings_jam_broker_host">ブローカーのホスト</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">ブローカーのポート</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_server_test">接続をテスト</string>
<string name="settings_jam_server_testing">テスト中…</string>
<string name="settings_jam_server_test_ok">%1$d msで接続しました</string>
<string name="settings_jam_server_test_fail">失敗: %1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">接続中…</string>
<string name="jam_setup_code_label">ルームコード</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_connecting">再接続中…</string>
<string name="jam_invite_copy">コードをコピー</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">ジャムに提案</string>
<!-- 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_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 -->
<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_desc">앱 안에 웹 콘텐츠 표시</string>
<string name="settings_section_jam">그룹 잼</string>
<string name="settings_jam_broker_title">MQTT 브로커</string>
<string name="settings_jam_broker_host">브로커 호스트</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">브로커 포트</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_server_test">연결 테스트</string>
<string name="settings_jam_server_testing">테스트 중…</string>
<string name="settings_jam_server_test_ok">%1$d ms 만에 연결됨</string>
<string name="settings_jam_server_test_fail">실패: %1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">연결 중…</string>
<string name="jam_setup_code_label">방 코드</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_connecting">다시 연결 중…</string>
<string name="jam_invite_copy">코드 복사</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">잼에 추천하기</string>
<!-- 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_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 -->
<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_desc">Webcontent weergeven in de app</string>
<string name="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT-broker</string>
<string name="settings_jam_broker_host">Broker-host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker-poort</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_server_test">Verbinding testen</string>
<string name="settings_jam_server_testing">Testen…</string>
<string name="settings_jam_server_test_ok">Verbonden in %1$d ms</string>
<string name="settings_jam_server_test_fail">Mislukt: %1$s</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>
<!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Verbinden…</string>
<string name="jam_setup_code_label">Sessiecode</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_connecting">Opnieuw verbinden…</string>
<string name="jam_invite_copy">Code kopiëren</string>
@ -517,14 +500,9 @@
<string name="jam_destination_suggest">Voorstellen aan Jam</string>
<!-- 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_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 -->
<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_desc">Wyświetlanie treści internetowych w aplikacji</string>
<string name="settings_section_jam">Grupowy Jam</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Host brokera</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Port brokera</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_server_test">Testuj połączenie</string>
<string name="settings_jam_server_testing">Testowanie…</string>
<string name="settings_jam_server_test_ok">Połączono w %1$d ms</string>
<string name="settings_jam_server_test_fail">Niepowodzenie: %1$s</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>
<!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Łączenie…</string>
<string name="jam_setup_code_label">Kod pokoju</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_connecting">Ponowne łączenie…</string>
<string name="jam_invite_copy">Kopiuj kod</string>
@ -525,14 +508,9 @@
<string name="jam_destination_suggest">Zaproponuj w Jamie</string>
<!-- 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_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 -->
<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_desc">Exibir conteúdo da web dentro do app</string>
<string name="settings_section_jam">Jam em grupo</string>
<string name="settings_jam_broker_title">Broker MQTT</string>
<string name="settings_jam_broker_host">Host do broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Porta do broker</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_server_test">Testar conexão</string>
<string name="settings_jam_server_testing">Testando…</string>
<string name="settings_jam_server_test_ok">Conectado em %1$d ms</string>
<string name="settings_jam_server_test_fail">Falhou: %1$s</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>
<!-- Group Jam -->
@ -327,9 +313,6 @@
<string name="jam_setup_working">Conectando…</string>
<string name="jam_setup_code_label">Código da sala</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_connecting">Reconectando…</string>
<string name="jam_invite_copy">Copiar código</string>
@ -521,14 +504,9 @@
<string name="jam_destination_suggest">Sugerir na Jam</string>
<!-- 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_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 -->
<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_desc">Показ веб-контента внутри приложения</string>
<string name="settings_section_jam">Групповой джем</string>
<string name="settings_jam_broker_title">MQTT-брокер</string>
<string name="settings_jam_broker_host">Хост брокера</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Порт брокера</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_server_test">Проверить подключение</string>
<string name="settings_jam_server_testing">Проверка…</string>
<string name="settings_jam_server_test_ok">Подключено за %1$d мс</string>
<string name="settings_jam_server_test_fail">Ошибка: %1$s</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>
<!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Подключение…</string>
<string name="jam_setup_code_label">Код комнаты</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_connecting">Переподключение…</string>
<string name="jam_invite_copy">Копировать код</string>
@ -525,14 +508,9 @@
<string name="jam_destination_suggest">Предложить в джем</string>
<!-- 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_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 -->
<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_desc">แสดงเนื้อหาเว็บภายในแอป</string>
<string name="settings_section_jam">แจมกลุ่ม</string>
<string name="settings_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">โฮสต์ของ Broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">พอร์ตของ Broker</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_server_test">ทดสอบการเชื่อมต่อ</string>
<string name="settings_jam_server_testing">กำลังทดสอบ…</string>
<string name="settings_jam_server_test_ok">เชื่อมต่อแล้วใน %1$d ms</string>
<string name="settings_jam_server_test_fail">ล้มเหลว: %1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">กำลังเชื่อมต่อ…</string>
<string name="jam_setup_code_label">รหัสห้อง</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_connecting">กำลังเชื่อมต่อใหม่…</string>
<string name="jam_invite_copy">คัดลอกรหัส</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">เสนอให้แจม</string>
<!-- 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_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 -->
<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_desc">Uygulama içinde web içeriği görüntüleme</string>
<string name="settings_section_jam">Grup Jam</string>
<string name="settings_jam_broker_title">MQTT Aracısı</string>
<string name="settings_jam_broker_host">Aracı ana bilgisayarı</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Aracı portu</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_server_test">Bağlantıyı test et</string>
<string name="settings_jam_server_testing">Test ediliyor…</string>
<string name="settings_jam_server_test_ok">%1$d ms\'de bağlandı</string>
<string name="settings_jam_server_test_fail">Başarısız: %1$s</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>
<!-- Group Jam -->
@ -323,9 +309,6 @@
<string name="jam_setup_working">Bağlanıyor…</string>
<string name="jam_setup_code_label">Oda kodu</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_connecting">Yeniden bağlanıyor…</string>
<string name="jam_invite_copy">Kodu kopyala</string>
@ -517,14 +500,9 @@
<string name="jam_destination_suggest">Jam\'e öner</string>
<!-- 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_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 -->
<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_desc">Показ вебвмісту всередині застосунку</string>
<string name="settings_section_jam">Груповий джем</string>
<string name="settings_jam_broker_title">MQTT-брокер</string>
<string name="settings_jam_broker_host">Хост брокера</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Порт брокера</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_server_test">Перевірити підключення</string>
<string name="settings_jam_server_testing">Перевірка…</string>
<string name="settings_jam_server_test_ok">Підключено за %1$d мс</string>
<string name="settings_jam_server_test_fail">Помилка: %1$s</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>
<!-- Group Jam -->
@ -331,9 +317,6 @@
<string name="jam_setup_working">Підключення…</string>
<string name="jam_setup_code_label">Код кімнати</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_connecting">Повторне підключення…</string>
<string name="jam_invite_copy">Копіювати код</string>
@ -525,14 +508,9 @@
<string name="jam_destination_suggest">Запропонувати в джем</string>
<!-- 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_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 -->
<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_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_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">Máy chủ broker</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Cổng broker</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_server_test">Kiểm tra kết nối</string>
<string name="settings_jam_server_testing">Đang kiểm tra…</string>
<string name="settings_jam_server_test_ok">Đã kết nối trong %1$d ms</string>
<string name="settings_jam_server_test_fail">Thất bại: %1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<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_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_connecting">Đang kết nối lại…</string>
<string name="jam_invite_copy">Sao chép mã</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">Đề xuất cho Jam</string>
<!-- 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_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 -->
<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_desc">在應用程式內顯示網頁內容</string>
<string name="settings_section_jam">群組 Jam</string>
<string name="settings_jam_broker_title">MQTT 代理伺服器</string>
<string name="settings_jam_broker_host">代理伺服器主機</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">代理伺服器連接埠</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_server_test">測試連線</string>
<string name="settings_jam_server_testing">測試中…</string>
<string name="settings_jam_server_test_ok">已連線,耗時 %1$d 毫秒</string>
<string name="settings_jam_server_test_fail">失敗:%1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">連線中…</string>
<string name="jam_setup_code_label">房間代碼</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_connecting">重新連線中…</string>
<string name="jam_invite_copy">複製代碼</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">推薦到 Jam</string>
<!-- 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_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 -->
<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_desc">在应用内显示网页内容</string>
<string name="settings_section_jam">一起听</string>
<string name="settings_jam_broker_title">MQTT 代理服务器</string>
<string name="settings_jam_broker_host">代理服务器主机</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">代理服务器端口</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_server_test">测试连接</string>
<string name="settings_jam_server_testing">正在测试…</string>
<string name="settings_jam_server_test_ok">已连接,耗时 %1$d 毫秒</string>
<string name="settings_jam_server_test_fail">失败:%1$s</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>
<!-- Group Jam -->
@ -319,9 +305,6 @@
<string name="jam_setup_working">正在连接…</string>
<string name="jam_setup_code_label">房间码</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_connecting">正在重新连接…</string>
<string name="jam_invite_copy">复制房间码</string>
@ -513,14 +496,9 @@
<string name="jam_destination_suggest">推荐到一起听</string>
<!-- 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_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 -->
<string name="jam_queue_added_track">已添加到一起听队列</string>

View File

@ -310,25 +310,16 @@
<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="settings_section_jam">Group Jam</string>
<string name="settings_jam_broker_title">MQTT Broker</string>
<string name="settings_jam_broker_host">Broker host</string>
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
<string name="settings_jam_broker_port">Broker port</string>
<string name="settings_jam_broker_tls">Use TLS</string>
<string name="settings_jam_broker_username">Username (optional)</string>
<string name="settings_jam_broker_password">Password (optional)</string>
<string name="settings_jam_broker_client_id">Client ID prefix</string>
<string name="settings_jam_broker_client_id_subtitle">Used to identify this Spotube instance</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_server_title">Room server</string>
<string name="settings_jam_server_url">Server URL</string>
<string name="settings_jam_server_url_invalid">Enter a URL starting with https://</string>
<string name="settings_jam_server_diagnostics_subtitle">Check the room server before starting a room.</string>
<string name="settings_jam_server_note">Jam uses Spotube’s free room server. To self-host, deploy jam-server from the Spotube repository and enter its URL.</string>
<string name="settings_jam_server_test">Test connection</string>
<string name="settings_jam_server_testing">Testing…</string>
<string name="settings_jam_server_test_ok">Connected in %1$d ms</string>
<string name="settings_jam_server_test_fail">Failed: %1$s</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>
<!-- Group Jam -->
@ -341,9 +332,9 @@
<string name="jam_setup_working">Connecting…</string>
<string name="jam_setup_code_label">Room code</string>
<string name="jam_setup_code_placeholder">ABC123</string>
<string name="jam_connection_host">Broker: %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_settings">Broker settings</string>
<string name="jam_connection_host">Server: %1$s</string>
<string name="jam_connection_missing">Set a room server in Settings to host or join a room.</string>
<string name="jam_connection_settings">Server settings</string>
<string name="jam_session_connected">Connected</string>
<string name="jam_session_connecting">Reconnecting…</string>
<string name="jam_invite_copy">Copy code</string>
@ -549,15 +540,17 @@
<string name="jam_destination_suggest">Suggest to Jam</string>
<!-- Group Jam: connection errors -->
<string name="jam_error_no_broker">No jam broker configured</string>
<string name="jam_error_broker_host_empty">Broker host is empty</string>
<string name="jam_error_no_server">No jam server configured</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">You joined this room from another device.</string>
<string name="jam_error_connection_failed">Connection failed</string>
<string name="jam_error_connection_lost">Connection lost</string>
<string name="jam_error_broker_unreachable">Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam.</string>
<!-- Settings: Group Jam broker -->
<string name="settings_jam_broker_content_description">Broker</string>
<string name="settings_jam_broker_username_placeholder">anonymous</string>
<!-- Group Jam: queue snackbar -->
<string name="jam_queue_added_track">Added to the jam queue</string>

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.settings.SettingsProvider
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.SettingsViewModel
import org.spotube.spotube.modules.settings.JamSettingsViewModel
@ -151,7 +152,10 @@ val sharedModules = module {
viewModelOf(::MetadataPluginGuardViewModel)
// Settings
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
singleOf(::SettingsRepository) {
bind<UserSettingsSource>()
bind<UserSettingsStore>()
}
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
viewModelOf(::JamSettingsViewModel)
viewModel {
@ -283,7 +287,7 @@ val sharedModules = module {
}
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
singleOf(::JamRoomClient)
single { JamRoomService(get(), get(), get(), get()) }
single { JamRoomService(get(), get(), get(), get(), get(), get(), get()) }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

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

@ -20,91 +20,73 @@ package org.spotube.spotube.core.jam
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.MediaItem
import org.spotube.spotube.core.audioplayer.QueueEntry
import org.spotube.spotube.core.server.PluginTaggedAudioSource
import kotlinx.serialization.SerialName
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)
* - `cmd` topic: [PlaybackCommand], [Kick], [SuggestTrack], [SuggestPlaylist]
* (anyone -> host, except Kick which is host -> guest)
* - `presence/{clientId}` topic: [JamPresence] (retained, one per participant)
* - [JamQueue] (retained, host -> everyone): the shared queue.
* - [JamAnchor] (retained, host -> everyone): where playback is, in server time.
* - [JamCommand] (guest -> host): playback control and suggestions.
*/
@Serializable
sealed class JamMessage {
@Serializable
@SerialName("queueState")
data class QueueState(
data class JamQueue(
val items: List<JamMediaItem>,
val currentIndex: Int,
val shuffleEnabled: Boolean = false,
/**
* Whether guests should follow the host's current index. True when the
* host manually skipped/jumped or loaded a queue; false when the host
* merely auto-advanced because its song ended (guests stay put).
*/
val follow: Boolean = false,
/** Room-wide play/pause; guests follow every change of it. */
val isPlaying: Boolean = false) : JamMessage()
@Serializable
@SerialName("playbackCommand")
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
@Serializable
@SerialName("suggestTrack")
data class SuggestTrack(
val mediaItem: JamMediaItem,
val addedBy: String = "") : JamMessage()
@Serializable
@SerialName("suggestPlaylist")
data class SuggestPlaylist(
val tracks: List<JamMediaItem>,
val addedBy: String = "") : JamMessage()
@Serializable
@SerialName("kick")
data class Kick(
val participantId: String,
val reason: String = "kicked") : JamMessage()
@Serializable
@SerialName("leave")
data class Leave(val reason: String = "user_left") : JamMessage()
}
val shuffleEnabled: Boolean = false)
/**
* Playback commands. Queue navigation and play/pause are global — seek,
* volume, shuffle and loop are local to each participant.
* A playback anchor: track [trackKey] (at queue [index]) was at [positionMs] at server
* time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at
* [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead.
*/
@Serializable
sealed class PlaybackCmd {
data class JamAnchor(
val trackKey: String,
val index: Int,
val positionMs: Long,
val serverTimeMs: Long,
/** Room-wide play/pause intent; guests follow every change of it. */
val playing: Boolean,
val advancing: Boolean = playing,
val rate: Float = 1f,
/** The audio source the host plays [trackKey] from, so guests stream the same file. */
val source: PluginTaggedAudioSource? = null) {
/** The room's position at server time [serverNowMs]. */
fun positionAt(serverNowMs: Long): Long =
if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs
}
/** Playback control and suggestions. Volume, loop and the local queue view stay local. */
@Serializable
sealed class JamCommand {
@Serializable
@SerialName("setPlaying")
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
data class SetPlaying(val playing: Boolean) : JamCommand()
@Serializable
@SerialName("skipNext")
data object SkipNext : PlaybackCmd()
data object SkipNext : JamCommand()
@Serializable
@SerialName("skipPrevious")
data object SkipPrevious : PlaybackCmd()
data object SkipPrevious : JamCommand()
@Serializable
@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
data class JamPresence(
val clientId: String,
val displayName: String,
val isHost: Boolean,
val left: Boolean = false)
@Serializable
@SerialName("seekTo")
data class SeekTo(val positionMs: Long) : JamCommand()
@Serializable
@SerialName("suggest")
data class Suggest(
val items: List<JamMediaItem>,
val addedBy: String = "") : JamCommand()
}
@Serializable
data class JamMediaItem(
@ -117,6 +99,9 @@ data class JamMediaItem(
val coverUrl: String,
val protocol: String,
val addedBy: String = "") {
/** Identifies the track across devices: the metadata id, or the file for local tracks. */
val key: String get() = trackId.ifBlank { "local:$url" }
companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem(

View File

@ -18,22 +18,31 @@
package org.spotube.spotube.core.jam
import co.touchlab.kermit.Logger
import com.ditchoom.buffer.Charset
import com.ditchoom.buffer.codec.asReadBuffer
import com.ditchoom.buffer.toReadBuffer
import com.ditchoom.mqtt.client.ConnectionState
import com.ditchoom.mqtt.client.MqttClient
import com.ditchoom.mqtt.connection.MqttConnectionOptions
import com.ditchoom.mqtt.controlpacket.OpaquePublishPayloadCodec
import com.ditchoom.mqtt.controlpacket.QualityOfService
import com.ditchoom.mqtt.controlpacket.TopicName
import com.ditchoom.mqtt.controlpacket.WillConfig
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
import org.spotube.spotube.modules.settings.JamBroker
import io.ktor.client.plugins.HttpTimeout
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import io.ktor.http.URLBuilder
import io.ktor.http.URLProtocol
import io.ktor.http.appendPathSegments
import io.ktor.http.isSuccess
import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.Frame
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.Job
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.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
@ -41,47 +50,74 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive
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.JsonClassDiscriminator
import org.jetbrains.compose.resources.getString
import org.spotube.spotube.core.network.HttpClientFactory
import spotube.composeapp.generated.resources.Res
import spotube.composeapp.generated.resources.jam_error_broker_host_empty
import spotube.composeapp.generated.resources.jam_error_broker_unreachable
import spotube.composeapp.generated.resources.jam_error_banned
import spotube.composeapp.generated.resources.jam_error_connection_failed
import spotube.composeapp.generated.resources.jam_error_connection_lost
import spotube.composeapp.generated.resources.jam_error_no_broker
import spotube.composeapp.generated.resources.settings_jam_broker_test_ok
import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.seconds
import spotube.composeapp.generated.resources.jam_error_kicked
import spotube.composeapp.generated.resources.jam_error_replaced
import spotube.composeapp.generated.resources.jam_error_room_full
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 (`jam-server/`, a Cloudflare
* Durable Object per room code).
*
* Topics (room code `C`):
* - `spotube/jam/{C}/state` retained — [JamMessage.QueueState] (host -> everyone)
* - `spotube/jam/{C}/cmd` volatile — commands/suggestions (everyone -> host, host -> guest)
* - `spotube/jam/{C}/presence/{clientId}` retained — [JamPresence], with a Last Will
* (`left = true`) so a dropped client disappears from the room automatically.
* The server keeps the host's latest queue and anchor for late joiners, routes guest
* commands to the host and is the clock every participant syncs playback against
* ([clock]). A dropped connection is retried with the same client id, which the
* server treats as the same participant (a host keeps the room for a grace period).
*
* The library keeps the connection alive (auto-reconnect + backoff); this class only
* re-establishes the room subscription and re-publishes presence after a reconnect.
* The room is billed per incoming message, and on phones every transmission wakes the
* 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 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 {
ignoreUnknownKeys = true
classDiscriminator = "type"
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 localPresence: JamPresence? = null
private var session: DefaultWebSocketSession? = 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
private val pongs = MutableStateFlow(0)
private val _isConnected = MutableStateFlow(false)
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
@ -89,244 +125,413 @@ class JamRoomClient : KoinComponent {
private val _connectionError = MutableStateFlow<String?>(null)
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
val state: SharedFlow<JamMessage.QueueState> = _state.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()
private val _events = MutableSharedFlow<JamRoomEvent>(extraBufferCapacity = 64)
val events: SharedFlow<JamRoomEvent> = _events.asSharedFlow()
// ---------- Public API ----------
/** One-off connection check used by the settings screen. Returns latency description. */
suspend fun testConnection(broker: JamBroker): Result<String> {
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_broker_host_empty)))
/** Checks that [serverUrl] runs a compatible room server. Returns a latency description. */
suspend fun testConnection(serverUrl: String): Result<String> = runCatching {
val started = kotlin.time.TimeSource.Monotonic.markNow()
return runCatching {
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test")
try {
withTimeout(broker.connectionTimeoutSeconds.seconds) {
client.awaitConnectivity()
}
getString(Res.string.settings_jam_broker_test_ok, started.elapsedNow().inWholeMilliseconds)
} finally {
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
}
}
val response = plainHttp.get(healthUrl(serverUrl))
if (!response.status.isSuccess()) error(getString(Res.string.jam_error_server_unreachable))
val health = json.decodeFromString(Health.serializer(), response.bodyAsText())
if (!health.ok || health.v != PROTOCOL_VERSION) error(getString(Res.string.jam_error_server_incompatible))
getString(Res.string.settings_jam_server_test_ok, started.elapsedNow().inWholeMilliseconds)
}
/** 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.
*/
suspend fun connect(
broker: JamBroker,
serverUrl: String,
code: String,
clientId: String,
displayName: String,
isHost: Boolean): Result<Unit> {
create: Boolean): Result<JamRoomEvent.Welcome> {
disconnect()
if (broker.host.isBlank()) {
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_no_broker)))
}
return runCatching {
roomCode = 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" }
clock.reset()
val welcome = runCatching { open(serverUrl, code, clientId, displayName, create) }
.onFailure { e ->
log.w(e) { "Failed to open jam room $code" }
_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
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
client = null
if (refusal != 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(serverUrl, code, clientId, displayName, 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 == clientId) {
log.i { "Jam room $code expired while offline; recreating it" }
reopened = runCatching { open(serverUrl, code, clientId, displayName, 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) {
val code = roomCode ?: return
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
}
fun sendQueue(queue: JamQueue) = send(ClientFrame.Queue(queue))
suspend fun publishCommand(message: JamMessage) {
val code = roomCode ?: return
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
}
fun sendAnchor(anchor: JamAnchor) = send(ClientFrame.Anchor(anchor))
/** Publishes our own (retained) presence. Re-published after every reconnect. */
suspend fun publishPresence() {
val code = roomCode ?: return
val presence = localPresence ?: return
publishJson(
presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence),
retain = true)
}
fun sendCommand(command: JamCommand) = send(ClientFrame.Cmd(command))
/** Marks us as the host in presence (host takeover). */
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)
}
}
fun kick(participantId: String, ban: Boolean) = send(ClientFrame.Kick(participantId, ban))
suspend fun disconnect() {
roomCode = null
localPresence = null
connectionJob?.cancel()
connectionJob = null
hostId = null
reader?.cancel()
reader = null
val current = session
session = null
_isConnected.value = false
_presence.value = emptyMap()
val current = client
client = null
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
_connectionError.value = null
runCatching { current?.close() }
}
// ---------- Internals ----------
private suspend fun startClient(broker: JamBroker, clientId: String): MqttClient {
val connection = MqttConnectionOptions.SocketConnection(
host = broker.host,
port = broker.port,
tlsEnabled = broker.useTls,
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
val code = roomCode ?: "unset"
val will = WillConfig.Enabled(
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
payload = json.encodeToString(
JamPresence.serializer(),
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
qos = QualityOfService.AT_LEAST_ONCE,
retain = true)
val request = ConnectionRequest(
clientId = clientId,
keepAliveSeconds = broker.keepAliveSeconds,
cleanStart = true,
userName = broker.username,
password = broker.password,
will = will)
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
val brokerRef = persistence.addBroker(connection, request)
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
/**
* Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome
* and the socket's reader, which completes with the server's refusal (if any) on close.
*/
private suspend fun open(
serverUrl: String,
code: String,
clientId: String,
displayName: String,
create: Boolean): Pair<JamRoomEvent.Welcome, Deferred<String?>> {
val socket = runCatching { http.webSocketSession(roomUrl(serverUrl, code, clientId, displayName, create)) }
.getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) }
try {
val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) }
?: throw IllegalStateException(getString(Res.string.jam_error_server_unreachable))
if (welcome.v != PROTOCOL_VERSION) {
throw IllegalStateException(getString(Res.string.jam_error_server_incompatible))
}
private fun onConnectionStateChanged(state: ConnectionState) {
when (state) {
is ConnectionState.Connected -> {
session = socket
hostId = welcome.host
lastFrameAtMs = now()
_isConnected.value = true
_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 $clientId (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))
is ServerFrame.Error -> return decoded.code
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 {
// Every connection (initial + reconnects) must re-establish the
// broker-side subscription (clean session) and re-publish our
// retained presence to clear any Last Will. Re-subscribing with
// the same filter replaces the previous dispatcher handler.
subscribeRoom()
publishPresence()
runCatching { socket.send(Frame.Text(text)) }
.onFailure { log.w(it) { "Failed to send jam frame" } }
}
}
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
_isConnected.value = false
private fun decode(frame: Frame): ServerFrame? {
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 -> {
_isConnected.value = false
scope.launch {
val message = getString(Res.string.jam_error_connection_lost)
if (!_isConnected.value) _connectionError.value = message
}
}
}
private suspend fun refusalMessage(code: String): String = when (code) {
"room_not_found" -> getString(Res.string.jam_error_room_not_found)
"room_full" -> getString(Res.string.jam_error_room_full)
"banned" -> getString(Res.string.jam_error_banned)
"kicked" -> getString(Res.string.jam_error_kicked)
"replaced" -> getString(Res.string.jam_error_replaced)
else -> getString(Res.string.jam_error_connection_failed)
}
private suspend fun subscribeRoom() {
val mqtt = client ?: return
val code = roomCode ?: return
val operation = mqtt.subscribe(
roomFilter(code),
OpaquePublishPayloadCodec,
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
route(publish.topic.toString(), payload)
}
runCatching { operation.subAck.await() }
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } }
}
private fun roomUrl(serverUrl: String, code: String, clientId: String, displayName: String, create: Boolean) =
URLBuilder(serverUrl.trim()).apply {
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
appendPathSegments("v1", "rooms", code)
parameters.append("id", clientId)
parameters.append("name", displayName)
parameters.append("mode", if (create) "create" else "join")
}.buildString()
private fun route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) {
val text = runCatching {
val buffer = payload.handle.asReadBuffer()
buffer.readString(buffer.remaining(), Charset.UTF8)
}.getOrElse { e ->
log.w(e) { "Failed to read jam payload on $topic" }
return
}
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()
runCatching {
when {
topic.endsWith("/state") -> {
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
}
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
topic.endsWith("/cmd") -> {
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
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)
}
topic.contains("/presence/") -> {
val presence = json.decodeFromString(JamPresence.serializer(), text)
_presence.value = _presence.value + (presence.clientId to presence)
}
}
}.onFailure { e ->
log.w(e) { "Failed to decode jam message on $topic: $text" }
}
}
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) {
val mqtt = client ?: return
runCatching {
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"
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd"
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId"
private fun roomFilter(code: String) = "spotube/jam/$code/#"
}
/** Something that happened in the room, for [JamRoomService]. */
sealed interface JamRoomEvent {
/** (Re)joined the room; carries the retained queue and anchor. */
data class Welcome(
val you: String,
val host: String?,
val participants: List<JamParticipant>,
val queue: JamQueue?,
val anchor: JamAnchor?) : JamRoomEvent
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
}
/** The server refused to let us into the room. */
class JamRoomException(val code: String, message: String) : Exception(message)
// ---------- Wire frames (see jam-server/src/protocol.ts) ----------
@Serializable
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()
}
@OptIn(ExperimentalSerializationApi::class)
@Serializable
@JsonClassDiscriminator("t")
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)
}
@Serializable
@SerialName("pong")
data class Pong(val id: Int, val c: Long, val s: Long) : ServerFrame()
@Serializable
@SerialName("presence")
data class Presence(val host: String? = null, val participants: List<WireParticipant> = emptyList()) : ServerFrame()
@Serializable
@SerialName("queue")
data class Queue(val body: JamQueue) : ServerFrame()
@Serializable
@SerialName("anchor")
data class Anchor(val body: JamAnchor) : ServerFrame()
@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

@ -21,7 +21,7 @@ import kotlin.random.Random
/**
* 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
* read aloud and retype.

View File

@ -202,7 +202,7 @@ class RemotePlaybackController(
/**
* 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) {
if (jamRoomService.role.value == null) return

View File

@ -139,7 +139,7 @@ fun JamScreen(navigationCommands: NavigationCommands) {
viewModel::joinSession
)
}
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
}
}
}
@ -461,16 +461,16 @@ internal fun SessionPanel(
}
@Composable
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
internal fun ServerStatus(state: JamUiState, onConfigure: () -> Unit) {
Row(
Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Text(
if (state.brokerConfigured) stringResource(
if (state.serverConfigured) stringResource(
Res.string.jam_connection_host,
state.brokerHost
state.serverHost
) else stringResource(Res.string.jam_connection_missing),
style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant,

View File

@ -57,8 +57,8 @@ data class JamUiState(
val role: JamRole? = null,
val participants: List<JamParticipantUi> = emptyList(),
val roomCode: String? = null,
val brokerHost: String = "",
val brokerConfigured: Boolean = false,
val serverHost: String = "",
val serverConfigured: Boolean = false,
val error: String? = null,
val setupMode: JamSetupMode = JamSetupMode.Create,
val codeInput: TextFieldState = TextFieldState(),
@ -115,7 +115,7 @@ class JamViewModel(
val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings
val localError = values[6] as String?
val busy = values[8] as Boolean
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
val serverConfigured = !settings?.jamServerUrl.isNullOrBlank()
JamUiState(
isActive = role != null,
@ -132,16 +132,17 @@ class JamViewModel(
)
},
roomCode = roomCode,
brokerHost = settings?.jamBroker?.host.orEmpty(),
brokerConfigured = brokerConfigured,
serverHost = settings?.jamServerUrl.orEmpty()
.substringAfter("://").substringBefore('/'),
serverConfigured = serverConfigured,
error = localError ?: connectionError,
setupMode = values[7] as JamSetupMode,
isBusy = busy,
isLeaving = values[12] as Boolean,
codeCopied = values[9] as Boolean,
codeInput = codeInput,
canCreate = brokerConfigured,
canJoin = brokerConfigured &&
canCreate = serverConfigured,
canJoin = serverConfigured &&
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
isHost = role == JamRole.Host,
supportsNativeShare = supportsNativeShare

View File

@ -18,46 +18,30 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.sections.SettingsItemIcon
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
import org.spotube.spotube.resources.iconsax.CustomServer
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.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.shell.LocalAppShellBottomInset
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
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.settings_jam_section_connection
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
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
fun JamSettingsScreen(
@ -80,7 +64,6 @@ internal fun JamSettingsContent(
viewModel: JamSettingsViewModel = koinViewModel(),
modifier: Modifier = Modifier) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val broker = uiState.settings.jamBroker
val shellBottomInset = LocalAppShellBottomInset.current
val contentPadding = remember(shellBottomInset) {
PaddingValues(top = 16.dp, bottom = 16.dp + shellBottomInset)
@ -98,162 +81,58 @@ internal fun JamSettingsContent(
settingsSectionCard(
items = listOf(
{
val invalidUrl = stringResource(Res.string.settings_jam_server_url_invalid)
TextInputSettingCard(
title = stringResource(Res.string.settings_jam_broker_host),
value = broker.host,
placeholder = "broker.example.com",
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) },
title = stringResource(Res.string.settings_jam_server_url),
value = uiState.settings.jamServerUrl,
placeholder = DEFAULT_JAM_SERVER_URL,
validate = { value ->
val port = value.toIntOrNull()
when {
port == null -> errorWholeNumber
port !in 1..65535 -> errorPortRange
else -> null
}
},
onValueSaved = { port ->
viewModel.update { copy(jamBroker = jamBroker.copy(port = port.toInt())) }
if (value.isBlank() || isJamServerUrl(value)) null else invalidUrl
},
icon = {
SettingsItemIcon(
Iconsax.CustomServer,
stringResource(Res.string.settings_jam_broker_port)
)
})
stringResource(Res.string.settings_jam_server_title))
},
{
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)
)
})
onValueSaved = viewModel::setServerUrl)
}))
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
settingsSectionCard(
items = listOf(
{
val canTest = broker.host.isNotBlank() && !uiState.isTesting
val resultMessage = uiState.testResult?.fold(
onSuccess = { it },
onFailure = {
stringResource(
Res.string.settings_jam_broker_test_fail,
Res.string.settings_jam_server_test_fail,
it.message ?: stringResource(Res.string.jam_error_connection_failed))
})
SettingCardItem(
enabled = canTest,
title = stringResource(Res.string.settings_jam_broker_test),
enabled = !uiState.isTesting,
title = stringResource(Res.string.settings_jam_server_test),
subtitle = when {
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
uiState.isTesting -> stringResource(Res.string.settings_jam_server_testing)
resultMessage != null -> resultMessage
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
else -> stringResource(Res.string.settings_jam_server_diagnostics_subtitle)
},
icon = {
SettingsItemIcon(
Iconsax.IconsaxInformation,
stringResource(Res.string.settings_jam_broker_test))
stringResource(Res.string.settings_jam_server_test))
},
trailingContent = {
MillenniumButton(
onClick = viewModel::testConnection,
enabled = broker.host.isNotBlank(),
loading = uiState.isTesting) {
Text(stringResource(Res.string.settings_jam_broker_test))
Text(stringResource(Res.string.settings_jam_server_test))
}
},
onClick = viewModel::testConnection)
}))
item {
Text(
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
text = stringResource(Res.string.settings_jam_server_note),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp))

View File

@ -28,9 +28,12 @@ class JamSettingsViewModel(
}
}
fun update(transform: UserSettings.() -> UserSettings) {
/** A blank URL goes back to the public server. */
fun setServerUrl(url: String) {
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 +42,7 @@ class JamSettingsViewModel(
_uiState.update { it.copy(isTesting = true, testResult = null) }
viewModelScope.launch {
try {
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
val result = jamRoomClient.testConnection(uiState.value.settings.jamServerUrl)
_uiState.update { it.copy(testResult = result) }
} finally {
_uiState.update { it.copy(isTesting = false) }

View File

@ -59,9 +59,10 @@ data class UserSettings(
val remoteControlDeviceName: String = "",
val remoteControlDeviceId: String = "",
// Group Jam (MQTT)
// Group Jam
val jamParticipantName: String = "",
val jamBroker: JamBroker = JamBroker(),
/** Room server (`jam-server/`); self-hosters point this at their own deployment. */
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
val lastJamCode: String = "",
// Downloads
@ -91,21 +92,12 @@ data class UserSettings(
val supportRemindersEnabled: Boolean = true,
val lastSupportReminderEpochMs: Long = 0L)
/**
* Configuration for the MQTT broker used by Group Jam. The host is a placeholder
* until a real broker is configured; users can self-host and point the app at it.
*/
@Serializable
data class JamBroker(
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)
/** The public Group Jam room server. */
const val DEFAULT_JAM_SERVER_URL = "https://spotube-jam.workers.dev"
/** Whether [url] can be a room server address (http(s), or ws(s) for self-hosters). */
internal fun isJamServerUrl(url: String): Boolean =
Regex("^(https?|wss?)://[^\\s/?#]+(/\\S*)?$", RegexOption.IGNORE_CASE).matches(url.trim())
/**
* 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>
}
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 {
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
@ -64,7 +69,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
initialValue = UserSettings() // Default value
)
suspend fun updateSettings(newSettings: UserSettings) {
override suspend fun updateSettings(newSettings: UserSettings) {
database.settingsDataStore.edit { prefs ->
prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings)
}

View File

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

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,85 @@
/*
* 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:
* `cd jam-server && npm run dev`, 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, "it-host-1", "Host", create = true).getOrThrow()
assertEquals("it-host-1", 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 guestWelcome = guest.connect(url, code, "it-guest-1", "Guest", create = false).getOrThrow()
assertEquals(queue, guestWelcome.queue)
assertEquals(anchor, guestWelcome.anchor)
assertEquals(listOf("it-host-1", "it-guest-1"), 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("it-guest-1", command.from)
assertEquals(JamCommand.SeekTo(42_000), command.command)
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance<JamRoomEvent.Closed>().first() }
host.kick("it-guest-1", ban = false)
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
val wrongRoom = guest.connect(url, JamRoomCode.generate(), "it-guest-1", "Guest", create = false)
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
} finally {
host.disconnect()
guest.disconnect()
http.close()
}
}
}

View File

@ -0,0 +1,439 @@
/*
* 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.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")
// Steady playback costs (almost) nothing: count the anchors a listener receives.
val listener = JamRoomClient(http)
try {
listener.connect(url, code, "it-listener", "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()
}
}
/** 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.truePosition() - host.player.truePosition()
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) {
val player = SimPlayer(speedError)
val queue = SimQueue(player)
val sources = FakeTrackSourceRepository()
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(PLUGIN))
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()))
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.
*/
private class SimPlayer(private val speedError: Double) : AudioPlayerInterface {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private var durations: List<Long> = emptyList()
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 _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
init {
scope.launch {
while (isActive) {
delay(250)
val position = truePosition()
val duration = durations.getOrNull(index.value) ?: continue
if (position >= duration) {
_completion.tryEmit(Unit)
if (index.value < durations.lastIndex) startTrack(index.value + 1)
continue
}
_position.value = (position / 100 * 100).milliseconds
}
}
}
@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
startTrack(start)
}
fun startTrack(i: Int) {
index.value = i
_duration.value = durations[i].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
}
}
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?> = MutableStateFlow(null)
override val playlistFlow: StateFlow<List<MediaItem>> = MutableStateFlow(emptyList())
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) = error("use SimQueue")
override suspend fun addMediaItem(mediaItem: MediaItem) = Unit
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) : AudioPlayerQueue {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val entries = MutableStateFlow<List<QueueEntry>>(emptyList())
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 fun now() = Clock.System.now().toEpochMilliseconds()
private fun track(id: String) = MetadataTrack(
id = id, title = id, durationMs = 180_000, 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)
}
/** 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)
@Test
fun renderRoomControlsAtNarrowAndWideSizes() {
val output = File("build/reports/jam-ui").apply { mkdirs() }
val states = mapOf(
"create" to JamUiState(
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
serverConfigured = true,
serverHost = "jam.example.com",
canCreate = true
),
"join" to JamUiState(
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
serverConfigured = true,
serverHost = "jam.example.com",
setupMode = JamSetupMode.Join
),
"host" to JamUiState(
@ -86,8 +86,8 @@ class JamLayoutTest {
isConnected = true,
isHost = true,
roomCode = "RC5FJ8",
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
serverConfigured = true,
serverHost = "jam.example.com",
participants = listOf(
JamParticipantUi("host", "Host-8316", "H", true, false),
JamParticipantUi("guest", "Guest-6684", "G", false, true)
@ -97,8 +97,8 @@ class JamLayoutTest {
isActive = true,
isConnected = true,
roomCode = "RC5FJ8",
brokerConfigured = true,
brokerHost = "test.mosquitto.org",
serverConfigured = true,
serverHost = "jam.example.com",
participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false))
)
)
@ -161,7 +161,7 @@ class JamLayoutTest {
{},
{})
else SetupPanel(state, {}, {}, {})
BrokerStatus(state, {})
ServerStatus(state, {})
}
}
}

View File

@ -36,8 +36,6 @@ kotlin = "2.4.21"
kotlinx-coroutines = "1.11.0"
kotlinx-serialization-json = "1.11.0"
millennium = "0.1.1"
mqttClient = "2.1.1"
mqttBuffer = "6.30.8"
multiplatformMarkdownRenderer = "0.45.0"
murmurhash = "0.4.2"
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-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
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-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "multiplatformMarkdownRenderer" }
murmurhash = { module = "com.goncalossilva:murmurhash", version.ref = "murmurhash" }

3
jam-server/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
.wrangler/
.dev.vars

72
jam-server/README.md Normal file
View File

@ -0,0 +1,72 @@
# Spotube Jam server
The room server behind Group Jam: a Cloudflare Worker with one
[Durable Object](https://developers.cloudflare.com/durable-objects/) per room code.
Each room relays a few small JSON frames between the host and its guests. Nobody streams
audio through it (every device fetches its own), so a room costs a few KB per minute.
Rooms are billed per incoming message (20 count as one request) and per storage write,
so steady playback sends almost nothing:
- The host sends a playback anchor only when playback changes (play/pause, seek, skip,
speed), when its player drifts from its last anchor, and once a minute as a safety net.
Guests extrapolate anchors with the shared clock in between.
- Anchors are kept in memory, not storage. A room that hibernated wakes for a join, and
the host answers the join with a fresh anchor. Only queue changes and membership
bookkeeping are written.
- Keep-alives (`"ping"` → `"pong"`) are answered by the runtime without waking the room.
Clock pings go out on join and every 10 minutes.
A four-person room sends roughly 200 messages an hour (about 10 billed requests).
## What a room does
- **Membership**: tracks who is connected, who hosts, and who is banned. If the host
drops, it keeps hosting for 30 s (so it can reconnect, e.g. after switching networks), then the longest-connected guest
takes over.
- **Retained state**: keeps the host's latest queue (and, while awake, its playback
anchor) so late joiners start in sync. An empty room keeps them for 10 minutes, then deletes itself.
- **Routing**: guest commands (play/pause, skip, seek, suggestions) go to the host.
Kicks and bans are enforced by the server.
- **Clock**: answers pings with its own time. Every device syncs playback to this one
clock (see `JamClock` in the app).
The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is
`composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`.
## Develop
```sh
npm install
npm run dev # http://127.0.0.1:8787
npm test # smoke test against the dev server (another terminal)
npm run typecheck
```
The app's end-to-end tests run against it too:
```sh
JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest --tests 'org.spotube.spotube.core.jam.*'
```
## Deploy
```sh
CLOUDFLARE_API_TOKEN=... CLOUDFLARE_ACCOUNT_ID=... npx wrangler deploy
```
The token needs the "Edit Cloudflare Workers" permissions. Durable Objects on the
SQLite backend work on the free plan. `.github/workflows/jam-server-deploy.yml` deploys on
pushes to `master` that touch this directory (secrets `CLOUDFLARE_API_TOKEN` and
`CLOUDFLARE_ACCOUNT_ID`; optional variable `JAM_SERVER_URL` runs the smoke test after).
## Self-host
Deploy to your own Cloudflare account as above, then enter the Worker's URL in
Spotube → Settings → Jam → Server URL.
## Limits
- 32 participants per room, 512 KB per frame.
- 60 room connections per minute per IP (`CONNECT_LIMITER` in `wrangler.jsonc`).

1606
jam-server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
jam-server/package.json Normal file
View File

@ -0,0 +1,22 @@
{
"name": "spotube-jam-server",
"private": true,
"description": "Group Jam room server for Spotube (Cloudflare Worker + Durable Objects)",
"license": "AGPL-3.0-or-later",
"type": "module",
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy",
"types": "wrangler types",
"typecheck": "wrangler types && tsc --noEmit",
"test": "node test/smoke.mjs"
},
"devDependencies": {
"typescript": "^5.9.3",
"wrangler": "^4.156.0"
},
"allowScripts": {
"esbuild@0.28.2": true,
"workerd@1.20261023.1": true
}
}

44
jam-server/src/index.ts Normal file
View File

@ -0,0 +1,44 @@
import { CLIENT_ID, MAX_NAME_LENGTH, PROTOCOL_VERSION, ROOM_CODE } from "./protocol";
export { JamRoom } from "./room";
const JSON_HEADERS = { "content-type": "application/json" };
/**
* Routes:
* - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection")
* - `GET /v1/rooms/{CODE}?id=&name=&mode=create|join` (WebSocket) → the room's Durable Object
*/
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/v1/health") {
return Response.json({ ok: true, v: PROTOCOL_VERSION, now: Date.now() });
}
const match = url.pathname.match(/^\/v1\/rooms\/([^/]+)$/);
if (!match) return new Response("Not found", { status: 404 });
if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
return new Response("Expected a WebSocket upgrade", { status: 426 });
}
const code = match[1].toUpperCase();
const id = url.searchParams.get("id") ?? "";
const name = (url.searchParams.get("name") ?? "").trim().slice(0, MAX_NAME_LENGTH);
const mode = url.searchParams.get("mode");
if (!ROOM_CODE.test(code) || !CLIENT_ID.test(id) || !name || (mode !== "create" && mode !== "join")) {
return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS });
}
const ip = request.headers.get("CF-Connecting-IP") ?? "unknown";
const { success } = await env.CONNECT_LIMITER.limit({ key: ip });
if (!success) return new Response("Too many connections", { status: 429 });
const forwarded = new URL(request.url);
forwarded.search = new URLSearchParams({ id, name, mode }).toString();
const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code));
return room.fetch(new Request(forwarded, request));
},
} satisfies ExportedHandler<Env>;

View File

@ -0,0 +1,85 @@
/**
* Wire protocol between Spotube and the jam room server (version 1).
*
* Every frame is a JSON object with a `t` discriminator. The server never looks
* inside `body`: queue, anchor and command payloads are owned by the app and only
* routed (and, for queue/anchor, retained) here.
*/
export const PROTOCOL_VERSION = 1;
/** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */
export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
export const CLIENT_ID = /^[A-Za-z0-9_-]{4,64}$/;
export const MAX_NAME_LENGTH = 40;
export const MAX_PARTICIPANTS = 32;
/** A long queue with cover URLs is the largest frame, a few hundred KB at most. */
export const MAX_FRAME_BYTES = 512 * 1024;
/**
* A host that drops keeps the room for this long before someone else takes over (long
* enough for a phone to switch networks). Guests keep playing in the meantime.
*/
export const HOST_GRACE_MS = 30_000;
/** An empty room keeps its queue for this long, so everyone can rejoin after a blip. */
export const EMPTY_ROOM_TTL_MS = 10 * 60_000;
/**
* Keep-alive, sent as a bare text frame. The runtime answers it without waking the room,
* so it costs nothing; clock pings (`ping` frames) are only sent when the clock syncs.
*/
export const KEEPALIVE_PING = "ping";
export const KEEPALIVE_PONG = "pong";
export type JoinMode = "create" | "join";
export type ClientFrame =
| { t: "ping"; id: number; c: number }
| { t: "queue"; body: unknown }
| { t: "anchor"; body: unknown }
| { t: "cmd"; body: unknown }
| { t: "kick"; target: string; ban?: boolean };
export interface Participant {
id: string;
name: string;
}
export type ServerFrame =
| {
t: "welcome";
v: number;
you: string;
host: string | null;
participants: Participant[];
queue: unknown;
anchor: unknown;
}
| { t: "pong"; id: number; c: number; s: number }
| { t: "presence"; host: string | null; participants: Participant[] }
| { t: "queue"; body: unknown }
| { t: "anchor"; body: unknown }
| { t: "cmd"; from: string; body: unknown }
| { t: "error"; code: ErrorCode };
export type ErrorCode =
| "bad_request"
| "room_taken"
| "room_not_found"
| "room_full"
| "banned"
| "kicked"
| "replaced"
| "frame_too_large";
/** WebSocket close codes the app maps back to an [ErrorCode]. */
export const CLOSE_CODES: Record<ErrorCode, number> = {
bad_request: 4000,
room_taken: 4001,
room_not_found: 4004,
room_full: 4005,
banned: 4003,
kicked: 4008,
replaced: 4009,
frame_too_large: 4013,
};

311
jam-server/src/room.ts Normal file
View File

@ -0,0 +1,311 @@
import { DurableObject } from "cloudflare:workers";
import {
CLOSE_CODES,
KEEPALIVE_PING,
KEEPALIVE_PONG,
EMPTY_ROOM_TTL_MS,
HOST_GRACE_MS,
MAX_FRAME_BYTES,
MAX_PARTICIPANTS,
PROTOCOL_VERSION,
type ClientFrame,
type ErrorCode,
type JoinMode,
type Participant,
type ServerFrame,
} from "./protocol";
/** Kept on each socket so it survives hibernation. */
interface Attachment {
id: string;
name: string;
joined: number;
}
const enum Key {
Host = "host",
Queue = "queue",
Banned = "banned",
HostGoneAt = "hostGoneAt",
EmptySince = "emptySince",
}
/**
* One jam room, named by its six-letter code.
*
* The room is a relay with memory: it tracks who is connected and who hosts, keeps
* the host's latest queue and playback anchor for late joiners, routes guest
* commands to the host, and serves the clock everyone syncs playback against.
* Sockets use the hibernation API, so an idle room costs nothing between messages.
*
* Cost: rooms are billed per incoming message and per storage write, so the steady
* state is kept free of both. Keep-alives are answered by the runtime without waking
* the room, and the anchor (sent on every playback change) lives in memory only: when
* a hibernated room wakes for a join, the host answers the join's presence frame with
* a fresh anchor anyway.
*/
export class JamRoom extends DurableObject<Env> {
private anchor: unknown = null;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
ctx.setWebSocketAutoResponse(new WebSocketRequestResponsePair(KEEPALIVE_PING, KEEPALIVE_PONG));
}
async fetch(request: Request): Promise<Response> {
const url = new URL(request.url);
const id = url.searchParams.get("id")!;
const name = url.searchParams.get("name")!;
const mode = url.searchParams.get("mode") as JoinMode;
const { 0: client, 1: server } = new WebSocketPair();
const previous = this.ctx.getWebSockets(id);
this.ctx.acceptWebSocket(server, [id]);
server.serializeAttachment({ id, name, joined: Date.now() } satisfies Attachment);
// A reconnect with the same id (network change, app restart) takes over the old socket.
for (const ws of previous) this.reject(ws, "replaced");
const refusal = await this.admit(id, mode);
if (refusal) {
this.reject(server, refusal);
} else {
await this.ctx.storage.delete(Key.EmptySince);
const [host, queue] = await Promise.all([
this.ctx.storage.get<string>(Key.Host),
this.ctx.storage.get(Key.Queue),
]);
this.send(server, {
t: "welcome",
v: PROTOCOL_VERSION,
you: id,
host: host ?? null,
participants: this.participants(),
queue: queue ?? null,
anchor: this.anchor,
});
this.broadcast(await this.presence(), server);
}
return new Response(null, { status: 101, webSocket: client });
}
async webSocketMessage(ws: WebSocket, message: string | ArrayBuffer): Promise<void> {
const size = typeof message === "string" ? message.length : message.byteLength;
if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large");
if (typeof message !== "string") return;
let frame: ClientFrame;
try {
frame = JSON.parse(message);
} catch {
return;
}
const sender = this.attachment(ws);
switch (frame.t) {
case "ping":
this.send(ws, { t: "pong", id: frame.id, c: frame.c, s: Date.now() });
return;
case "queue":
if (!(await this.isHost(sender.id))) return;
await this.ctx.storage.put(Key.Queue, frame.body);
this.broadcast({ t: "queue", body: frame.body }, ws);
return;
case "anchor":
if (!(await this.isHost(sender.id))) return;
this.anchor = frame.body;
this.broadcast({ t: "anchor", body: frame.body }, ws);
return;
case "cmd": {
const host = await this.ctx.storage.get<string>(Key.Host);
const out: ServerFrame = { t: "cmd", from: sender.id, body: frame.body };
if (sender.id === host) {
this.broadcast(out, ws);
} else if (host) {
for (const target of this.ctx.getWebSockets(host)) this.send(target, out);
}
return;
}
case "kick": {
if (!(await this.isHost(sender.id)) || frame.target === sender.id) return;
if (frame.ban) {
const banned = (await this.ctx.storage.get<string[]>(Key.Banned)) ?? [];
if (!banned.includes(frame.target)) {
await this.ctx.storage.put(Key.Banned, [...banned, frame.target]);
}
}
for (const target of this.ctx.getWebSockets(frame.target)) {
this.reject(target, frame.ban ? "banned" : "kicked");
}
return;
}
}
}
async webSocketClose(ws: WebSocket, code: number): Promise<void> {
try {
ws.close(code === 1005 ? 1000 : code);
} catch {
// Already closed.
}
await this.onDeparture(ws);
}
async webSocketError(ws: WebSocket): Promise<void> {
await this.onDeparture(ws);
}
/** Fires after a host's grace period, or when an empty room expires. */
async alarm(): Promise<void> {
const live = this.live();
if (live.length === 0) {
const emptySince = await this.ctx.storage.get<number>(Key.EmptySince);
if (emptySince === undefined) return;
const expiresAt = emptySince + EMPTY_ROOM_TTL_MS;
if (Date.now() >= expiresAt) {
await this.ctx.storage.deleteAll();
this.anchor = null;
} else {
await this.ctx.storage.setAlarm(expiresAt);
}
return;
}
const host = await this.ctx.storage.get<string>(Key.Host);
if (host && live.some((ws) => this.attachment(ws).id === host)) return;
await this.promote();
}
// ---------- Membership ----------
private async admit(id: string, mode: JoinMode): Promise<ErrorCode | null> {
const others = this.live().filter((ws) => this.attachment(ws).id !== id);
if (mode === "create") {
if (others.length > 0) return "room_taken";
// Leftovers of an expired-but-not-yet-cleaned room must not leak into a new one.
await this.ctx.storage.deleteAll();
this.anchor = null;
await this.ctx.storage.put(Key.Host, id);
return null;
}
const [host, banned, queue] = await Promise.all([
this.ctx.storage.get<string>(Key.Host),
this.ctx.storage.get<string[]>(Key.Banned),
this.ctx.storage.get(Key.Queue),
]);
if (host === undefined && queue === undefined && others.length === 0) return "room_not_found";
if (banned?.includes(id)) return "banned";
if (others.length >= MAX_PARTICIPANTS) return "room_full";
// Joining a room whose host is gone (and whose grace period is over) promotes someone.
const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host);
if (!hostLive) {
const goneAt = await this.ctx.storage.get<number>(Key.HostGoneAt);
if (goneAt === undefined || Date.now() - goneAt >= HOST_GRACE_MS) {
await this.promote(id);
}
} else if (host === id) {
await this.ctx.storage.delete(Key.HostGoneAt);
}
return null;
}
private async onDeparture(ws: WebSocket): Promise<void> {
const { id } = this.attachment(ws);
const live = this.live().filter((other) => other !== ws);
// Replaced by a newer socket of the same participant: nothing changed.
if (live.some((other) => this.attachment(other).id === id)) return;
if (live.length === 0) {
await this.ctx.storage.put(Key.EmptySince, Date.now());
await this.ctx.storage.setAlarm(Date.now() + EMPTY_ROOM_TTL_MS);
return;
}
if (id === (await this.ctx.storage.get<string>(Key.Host))) {
await this.ctx.storage.put(Key.HostGoneAt, Date.now());
await this.ctx.storage.setAlarm(Date.now() + HOST_GRACE_MS);
}
this.broadcast(await this.presence(), ws);
}
/** The longest-connected participant hosts next (or [fallback] in an empty room). */
private async promote(fallback?: string): Promise<void> {
const next = this.participants()[0]?.id ?? fallback;
if (!next) return;
await this.ctx.storage.put(Key.Host, next);
await this.ctx.storage.delete(Key.HostGoneAt);
this.broadcast(await this.presence());
}
private async isHost(id: string): Promise<boolean> {
return (await this.ctx.storage.get<string>(Key.Host)) === id;
}
private async presence(): Promise<ServerFrame> {
return {
t: "presence",
host: (await this.ctx.storage.get<string>(Key.Host)) ?? null,
participants: this.participants(),
};
}
// ---------- Sockets ----------
private live(): WebSocket[] {
return this.ctx.getWebSockets().filter((ws) => ws.readyState === WebSocket.OPEN);
}
/** Connected participants, longest-connected first, one entry per id. */
private participants(): Participant[] {
const byId = new Map<string, Attachment>();
for (const ws of this.live()) {
const a = this.attachment(ws);
const existing = byId.get(a.id);
if (!existing || a.joined < existing.joined) byId.set(a.id, a);
}
return [...byId.values()]
.sort((a, b) => a.joined - b.joined || a.id.localeCompare(b.id))
.map(({ id, name }) => ({ id, name }));
}
private attachment(ws: WebSocket): Attachment {
return ws.deserializeAttachment() as Attachment;
}
private send(ws: WebSocket, frame: ServerFrame): void {
try {
ws.send(JSON.stringify(frame));
} catch {
// The socket closed under us; its close handler cleans up.
}
}
private broadcast(frame: ServerFrame, except?: WebSocket): void {
const text = JSON.stringify(frame);
for (const ws of this.live()) {
if (ws === except) continue;
try {
ws.send(text);
} catch {
// See send().
}
}
}
private reject(ws: WebSocket, code: ErrorCode): void {
this.send(ws, { t: "error", code });
try {
ws.close(CLOSE_CODES[code], code);
} catch {
// Already closed.
}
}
}

131
jam-server/test/smoke.mjs Normal file
View File

@ -0,0 +1,131 @@
// End-to-end smoke test against a running server: `npm run dev`, then `npm test`.
// Point it elsewhere with JAM_URL=https://spotube-jam.<account>.workers.dev npm test
import assert from "node:assert/strict";
const BASE = process.env.JAM_URL ?? "http://127.0.0.1:8787";
const WS_BASE = BASE.replace(/^http/, "ws");
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
function connect(room, id, mode, name = id) {
const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?id=${id}&name=${encodeURIComponent(name)}&mode=${mode}`);
const inbox = [];
const waiters = [];
ws.addEventListener("message", (e) => {
const frame = e.data === "pong" ? { t: "keepalive" } : JSON.parse(e.data);
const i = waiters.findIndex((w) => w.match(frame));
if (i >= 0) waiters.splice(i, 1)[0].resolve(frame);
else inbox.push(frame);
});
const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code)));
return {
ws,
closed,
send: (frame) => ws.send(JSON.stringify(frame)),
next(t, pred = () => true, timeoutMs = 5_000) {
const match = (f) => f.t === t && pred(f);
const i = inbox.findIndex(match);
if (i >= 0) return Promise.resolve(inbox.splice(i, 1)[0]);
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error(`${id}: timed out waiting for ${t}`)), timeoutMs);
waiters.push({ match, resolve: (f) => (clearTimeout(timer), resolve(f)) });
});
},
};
}
const step = async (name, fn) => {
await fn();
console.log(`ok - ${name}`);
};
await step("health", async () => {
const res = await (await fetch(`${BASE}/v1/health`)).json();
assert.equal(res.ok, true);
assert.equal(res.v, 1);
});
const room = code();
const host = connect(room, "host-0001", "create", "Host");
let guest;
await step("host creates the room", async () => {
const welcome = await host.next("welcome");
assert.equal(welcome.host, "host-0001");
host.send({ t: "queue", body: { items: [1, 2, 3] } });
});
await step("guest joins and gets the retained queue", async () => {
// The host's queue frame is processed before the guest's join is.
host.send({ t: "ping", id: 1, c: 0 });
await host.next("pong");
guest = connect(room, "guest-0001", "join", "Guest");
const welcome = await guest.next("welcome");
assert.equal(welcome.host, "host-0001");
assert.deepEqual(welcome.queue, { items: [1, 2, 3] });
assert.ok(welcome.participants.some((p) => p.id === "guest-0001"));
await host.next("presence", (f) => f.participants.length === 2);
});
await step("clock ping", async () => {
guest.send({ t: "ping", id: 7, c: Date.now() });
const pong = await guest.next("pong");
assert.equal(pong.id, 7);
assert.equal(typeof pong.s, "number");
});
await step("keep-alives are answered", async () => {
guest.ws.send("ping");
await guest.next("keepalive");
});
await step("anchor fans out to guests", async () => {
host.send({ t: "anchor", body: { positionMs: 1234 } });
const anchor = await guest.next("anchor");
assert.equal(anchor.body.positionMs, 1234);
});
await step("guest anchors are ignored", async () => {
guest.send({ t: "anchor", body: { positionMs: 1 } });
guest.send({ t: "ping", id: 8, c: 0 });
await guest.next("pong", (f) => f.id === 8);
await assert.rejects(host.next("anchor", () => true, 500));
});
await step("guest commands reach the host", async () => {
guest.send({ t: "cmd", body: { type: "skipNext" } });
const cmd = await host.next("cmd");
assert.equal(cmd.from, "guest-0001");
});
await step("a taken code can't be created again", async () => {
const intruder = connect(room, "intruder-1", "create");
const error = await intruder.next("error");
assert.equal(error.code, "room_taken");
assert.equal(await intruder.closed, 4001);
});
await step("unknown rooms are not found", async () => {
const lost = connect(code(), "lost-0001", "join");
assert.equal((await lost.next("error")).code, "room_not_found");
});
await step("ban closes the socket and blocks rejoining", async () => {
host.send({ t: "kick", target: "guest-0001", ban: true });
assert.equal((await guest.next("error")).code, "banned");
assert.equal(await guest.closed, 4003);
const again = connect(room, "guest-0001", "join");
assert.equal((await again.next("error")).code, "banned");
});
await step("host hands over after the grace period", async () => {
const heir = connect(room, "heir-0001", "join", "Heir");
await heir.next("welcome");
host.ws.close();
const presence = await heir.next("presence", (f) => f.host === "heir-0001", 45_000);
assert.deepEqual(presence.participants.map((p) => p.id), ["heir-0001"]);
heir.ws.close();
});
console.log("all smoke tests passed");
process.exit(0);

14
jam-server/tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es2022",
"module": "es2022",
"moduleResolution": "bundler",
"lib": ["es2022"],
"types": ["./worker-configuration.d.ts"],
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true
},
"include": ["src"]
}

16269
jam-server/worker-configuration.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

15
jam-server/wrangler.jsonc Normal file
View File

@ -0,0 +1,15 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "spotube-jam",
"main": "src/index.ts",
"compatibility_date": "2026-10-01",
"observability": { "enabled": true },
"durable_objects": {
"bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }]
},
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }],
// Room connects per client IP; stops code guessing from waking (and billing) rooms.
"ratelimits": [
{ "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
]
}