diff --git a/.github/workflows/jam-server-deploy.yml b/.github/workflows/jam-server-deploy.yml
new file mode 100644
index 00000000..8ffe31b0
--- /dev/null
+++ b/.github/workflows/jam-server-deploy.yml
@@ -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 != '' }}
diff --git a/AGENTS.md b/AGENTS.md
index 816d9590..9d48dcfa 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index f60ba709..38967b31 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -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)
diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt
index 74df39e7..26f5a071 100644
--- a/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt
+++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/media/PlaybackService.kt
@@ -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())
}
diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
index 9eb21d8c..7910c67d 100644
--- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
@@ -308,25 +308,11 @@
WebView
عرض محتوى الويب داخل التطبيق
الجام الجماعي
- وسيط MQTT
- مضيف الوسيط
- %1$s:%2$d
- منفذ الوسيط
- استخدام TLS
- اسم المستخدم (اختياري)
- كلمة المرور (اختياري)
- بادئة معرّف العميل
- تُستخدم لتعريف نسخة Spotube هذه
- تشفير الاتصال بالوسيط
- تحقق من إعدادات الوسيط الحالية قبل بدء غرفة.
- اختبار الاتصال
- جارٍ الاختبار…
- تم الاتصال خلال %1$d ms
- فشل: %1$s
- وسيط مؤقت — اضبط وسيطك الخاص للاستضافة الذاتية
+ اختبار الاتصال
+ جارٍ الاختبار…
+ تم الاتصال خلال %1$d ms
+ فشل: %1$s
الاتصال
- بيانات الاعتماد
- الهوية
التشخيص
@@ -339,9 +325,6 @@
جارٍ الاتصال…
رمز الغرفة
ABC123
- الوسيط: %1$s
- اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها.
- إعدادات الوسيط
متصل
جارٍ إعادة الاتصال…
نسخ الرمز
@@ -533,14 +516,9 @@
اقتراح للجام
- لم يتم ضبط وسيط للجام
- مضيف الوسيط فارغ
فشل الاتصال
انقطع الاتصال
-
- الوسيط
- مجهول
تمت الإضافة إلى قائمة انتظار الجام
diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
index 268cdf95..d5742676 100644
--- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
@@ -292,25 +292,11 @@
WebView
অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো
গ্রুপ জ্যাম
- MQTT ব্রোকার
- ব্রোকার হোস্ট
- %1$s:%2$d
- ব্রোকার পোর্ট
- TLS ব্যবহার করুন
- ইউজারনেম (ঐচ্ছিক)
- পাসওয়ার্ড (ঐচ্ছিক)
- ক্লায়েন্ট ID প্রিফিক্স
- এই Spotube শনাক্ত করতে ব্যবহৃত হয়
- ব্রোকার কানেকশন এনক্রিপ্ট করুন
- রুম শুরু করার আগে বর্তমান ব্রোকার সেটিংস যাচাই করুন।
- কানেকশন পরীক্ষা করুন
- পরীক্ষা চলছে…
- %1$d ms-এ কানেক্ট হয়েছে
- ব্যর্থ: %1$s
- প্লেসহোল্ডার ব্রোকার — সেলফ-হোস্ট করতে নিজেরটি কনফিগার করুন
+ কানেকশন পরীক্ষা করুন
+ পরীক্ষা চলছে…
+ %1$d ms-এ কানেক্ট হয়েছে
+ ব্যর্থ: %1$s
কানেকশন
- ক্রেডেনশিয়াল
- পরিচিতি
ডায়াগনস্টিকস
@@ -323,9 +309,6 @@
কানেক্ট হচ্ছে…
রুম কোড
ABC123
- ব্রোকার: %1$s
- রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন।
- ব্রোকার সেটিংস
কানেক্টেড
আবার কানেক্ট হচ্ছে…
কোড কপি করুন
@@ -517,14 +500,9 @@
জ্যামে সাজেস্ট করুন
- কোনো জ্যাম ব্রোকার কনফিগার করা নেই
- ব্রোকার হোস্ট ফাঁকা
কানেকশন ব্যর্থ হয়েছে
কানেকশন বিচ্ছিন্ন হয়েছে
-
- ব্রোকার
- anonymous
জ্যাম কিউতে যোগ করা হয়েছে
diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml
index 2f965807..0837991c 100644
--- a/composeApp/src/commonMain/composeResources/values-de/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml
@@ -292,25 +292,11 @@
WebView
Webinhalte in der App anzeigen
Gruppen-Jam
- MQTT-Broker
- Broker-Host
- %1$s:%2$d
- Broker-Port
- TLS verwenden
- Benutzername (optional)
- Passwort (optional)
- Client-ID-Präfix
- Dient zur Identifizierung dieser Spotube-Instanz
- Verbindung zum Broker verschlüsseln
- Prüfe die aktuellen Broker-Einstellungen, bevor du einen Raum startest.
- Verbindung testen
- Wird getestet…
- Verbunden in %1$d ms
- Fehlgeschlagen: %1$s
- Platzhalter-Broker – richte deinen eigenen ein, um selbst zu hosten
+ Verbindung testen
+ Wird getestet…
+ Verbunden in %1$d ms
+ Fehlgeschlagen: %1$s
Verbindung
- Zugangsdaten
- Identität
Diagnose
@@ -323,9 +309,6 @@
Verbinden…
Raumcode
ABC123
- Broker: %1$s
- Richte in den Einstellungen einen Broker ein, um einen Raum zu hosten oder beizutreten.
- Broker-Einstellungen
Verbunden
Verbindung wird wiederhergestellt…
Code kopieren
@@ -517,14 +500,9 @@
Für Jam vorschlagen
- Kein Jam-Broker konfiguriert
- Broker-Host ist leer
Verbindung fehlgeschlagen
Verbindung unterbrochen
-
- Broker
- anonym
Zur Jam-Warteschlange hinzugefügt
diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml
index b4ae5fa1..6d486799 100644
--- a/composeApp/src/commonMain/composeResources/values-es/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml
@@ -296,25 +296,11 @@
WebView
Mostrar contenido web dentro de la app
Jam en grupo
- Broker MQTT
- Host del broker
- %1$s:%2$d
- Puerto del broker
- Usar TLS
- Usuario (opcional)
- Contraseña (opcional)
- Prefijo de ID de cliente
- Se usa para identificar esta instancia de Spotube
- Cifra la conexión con el broker
- Comprueba los ajustes actuales del broker antes de crear una sala.
- Probar conexión
- Probando…
- Conectado en %1$d ms
- Error: %1$s
- Broker de ejemplo: configura el tuyo para alojarlo tú mismo
+ Probar conexión
+ Probando…
+ Conectado en %1$d ms
+ Error: %1$s
Conexión
- Credenciales
- Identidad
Diagnóstico
@@ -327,9 +313,6 @@
Conectando…
Código de la sala
ABC123
- Broker: %1$s
- Configura un broker en Ajustes para crear una sala o unirte a una.
- Ajustes del broker
Conectado
Reconectando…
Copiar código
@@ -521,14 +504,9 @@
Sugerir al Jam
- No hay ningún broker de Jam configurado
- El host del broker está vacío
Error de conexión
Se perdió la conexión
-
- Broker
- anónimo
Añadida a la cola del Jam
diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
index 51374e7e..3c0dbc1a 100644
--- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
@@ -292,25 +292,11 @@
WebView
نمایش محتوای وب درون برنامه
جم گروهی
- بروکر MQTT
- میزبان بروکر
- %1$s:%2$d
- درگاه بروکر
- استفاده از TLS
- نام کاربری (اختیاری)
- گذرواژه (اختیاری)
- پیشوند شناسهٔ کلاینت
- برای شناسایی این نسخه از Spotube استفاده میشود
- رمزگذاری اتصال به بروکر
- پیش از شروع اتاق، تنظیمات فعلی بروکر را بررسی کنید.
- آزمایش اتصال
- در حال آزمایش…
- در %1$d میلیثانیه متصل شد
- ناموفق: %1$s
- بروکر نمونه — برای میزبانی شخصی، بروکر خودتان را پیکربندی کنید
+ آزمایش اتصال
+ در حال آزمایش…
+ در %1$d میلیثانیه متصل شد
+ ناموفق: %1$s
اتصال
- اطلاعات ورود
- هویت
عیبیابی
@@ -323,9 +309,6 @@
در حال اتصال…
کد اتاق
ABC123
- بروکر: %1$s
- برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راهاندازی کنید.
- تنظیمات بروکر
متصل
در حال اتصال دوباره…
کپی کد
@@ -517,14 +500,9 @@
پیشنهاد به جم
- هیچ بروکر جمی پیکربندی نشده است
- میزبان بروکر خالی است
اتصال ناموفق بود
اتصال قطع شد
-
- بروکر
- ناشناس
به صف جم اضافه شد
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 45a31bd6..4372e030 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -296,25 +296,11 @@
WebView
Afficher du contenu web dans l\'application
Jam de groupe
- Broker MQTT
- Hôte du broker
- %1$s:%2$d
- Port du broker
- Utiliser TLS
- Nom d\'utilisateur (facultatif)
- Mot de passe (facultatif)
- Préfixe de l\'ID client
- Sert à identifier cette instance de Spotube
- Chiffrer la connexion au broker
- Vérifiez les paramètres actuels du broker avant de créer une salle.
- Tester la connexion
- Test en cours…
- Connecté en %1$d ms
- Échec : %1$s
- Broker temporaire : configurez le vôtre pour l\'auto-héberger
+ Tester la connexion
+ Test en cours…
+ Connecté en %1$d ms
+ Échec : %1$s
Connexion
- Identifiants
- Identité
Diagnostic
@@ -327,9 +313,6 @@
Connexion…
Code de la salle
ABC123
- Broker : %1$s
- Configurez un broker dans les Paramètres pour créer ou rejoindre une salle.
- Paramètres du broker
Connecté
Reconnexion…
Copier le code
@@ -521,14 +504,9 @@
Proposer à la Jam
- Aucun broker Jam configuré
- L\'hôte du broker est vide
Échec de la connexion
Connexion perdue
-
- Broker
- anonyme
Ajouté à la file d\'attente de la Jam
diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
index 29e2b1d2..50c52c2d 100644
--- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
@@ -291,25 +291,11 @@
WebView
ऐप के अंदर वेब कंटेंट दिखाएं
ग्रुप जैम
- MQTT ब्रोकर
- ब्रोकर होस्ट
- %1$s:%2$d
- ब्रोकर पोर्ट
- TLS इस्तेमाल करें
- यूज़रनेम (वैकल्पिक)
- पासवर्ड (वैकल्पिक)
- क्लाइंट ID प्रीफ़िक्स
- इस Spotube की पहचान के लिए इस्तेमाल होता है
- ब्रोकर कनेक्शन एन्क्रिप्ट करें
- रूम शुरू करने से पहले मौजूदा ब्रोकर सेटिंग जांचें।
- कनेक्शन टेस्ट करें
- टेस्ट हो रहा है…
- %1$d ms में कनेक्ट हुआ
- विफल: %1$s
- प्लेसहोल्डर ब्रोकर — खुद होस्ट करने के लिए अपना ब्रोकर सेट करें
+ कनेक्शन टेस्ट करें
+ टेस्ट हो रहा है…
+ %1$d ms में कनेक्ट हुआ
+ विफल: %1$s
कनेक्शन
- क्रेडेंशियल
- पहचान
डायग्नोस्टिक्स
@@ -322,9 +308,6 @@
कनेक्ट हो रहा है…
रूम कोड
ABC123
- ब्रोकर: %1$s
- रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें।
- ब्रोकर सेटिंग
कनेक्ट है
फिर से कनेक्ट हो रहा है…
कोड कॉपी करें
@@ -516,14 +499,9 @@
जैम में सुझाएं
- कोई जैम ब्रोकर सेट नहीं है
- ब्रोकर होस्ट खाली है
कनेक्शन विफल
कनेक्शन टूट गया
-
- ब्रोकर
- anonymous
जैम कतार में जोड़ा गया
diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml
index 3f46bc82..1b8ee7e5 100644
--- a/composeApp/src/commonMain/composeResources/values-id/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml
@@ -288,25 +288,11 @@
WebView
Menampilkan konten web di dalam aplikasi
Jam Grup
- Broker MQTT
- Host broker
- %1$s:%2$d
- Port broker
- Gunakan TLS
- Nama pengguna (opsional)
- Kata sandi (opsional)
- Prefiks ID klien
- Dipakai untuk mengidentifikasi Spotube ini
- Enkripsi koneksi broker
- Periksa pengaturan broker saat ini sebelum memulai ruang.
- Uji koneksi
- Menguji…
- Terhubung dalam %1$d ms
- Gagal: %1$s
- Broker sementara — konfigurasikan milikmu sendiri untuk self-host
+ Uji koneksi
+ Menguji…
+ Terhubung dalam %1$d ms
+ Gagal: %1$s
Koneksi
- Kredensial
- Identitas
Diagnostik
@@ -319,9 +305,6 @@
Menghubungkan…
Kode ruang
ABC123
- Broker: %1$s
- Atur broker di Pengaturan untuk membuat atau bergabung ke ruang.
- Pengaturan broker
Terhubung
Menghubungkan ulang…
Salin kode
@@ -513,14 +496,9 @@
Sarankan ke Jam
- Belum ada broker Jam yang dikonfigurasi
- Host broker kosong
Koneksi gagal
Koneksi terputus
-
- Broker
- anonim
Ditambahkan ke antrean Jam
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index 2d956a2c..2a71283c 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -296,25 +296,11 @@
WebView
Mostrare contenuti web all\'interno dell\'app
Jam di gruppo
- Broker MQTT
- Host del broker
- %1$s:%2$d
- Porta del broker
- Usa TLS
- Nome utente (facoltativo)
- Password (facoltativa)
- Prefisso ID client
- Usato per identificare questa istanza di Spotube
- Crittografa la connessione al broker
- Verifica le impostazioni attuali del broker prima di avviare una stanza.
- Verifica connessione
- Verifica in corso…
- Connesso in %1$d ms
- Non riuscito: %1$s
- Broker di esempio: configurane uno tuo per l\'hosting autonomo
+ Verifica connessione
+ Verifica in corso…
+ Connesso in %1$d ms
+ Non riuscito: %1$s
Connessione
- Credenziali
- Identità
Diagnostica
@@ -327,9 +313,6 @@
Connessione…
Codice stanza
ABC123
- Broker: %1$s
- Configura un broker nelle Impostazioni per ospitare una stanza o parteciparvi.
- Impostazioni broker
Connesso
Riconnessione…
Copia codice
@@ -521,14 +504,9 @@
Suggerisci alla Jam
- Nessun broker Jam configurato
- L\'host del broker è vuoto
Connessione non riuscita
Connessione persa
-
- Broker
- anonimo
Aggiunto alla coda della Jam
diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
index 27ea528a..473e213d 100644
--- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
@@ -288,25 +288,11 @@
WebView
アプリ内でのWebコンテンツの表示
グループジャム
- MQTTブローカー
- ブローカーのホスト
- %1$s:%2$d
- ブローカーのポート
- TLSを使用
- ユーザー名 (任意)
- パスワード (任意)
- クライアントIDのプレフィックス
- このSpotubeを識別するために使用されます
- ブローカーとの接続を暗号化します
- ルームを開始する前に、現在のブローカー設定を確認します。
- 接続をテスト
- テスト中…
- %1$d msで接続しました
- 失敗: %1$s
- 仮のブローカーです。セルフホストするには独自のブローカーを設定してください
+ 接続をテスト
+ テスト中…
+ %1$d msで接続しました
+ 失敗: %1$s
接続
- 認証情報
- 識別情報
診断
@@ -319,9 +305,6 @@
接続中…
ルームコード
ABC123
- ブローカー: %1$s
- ルームを主催または参加するには、設定でブローカーを設定してください。
- ブローカー設定
接続済み
再接続中…
コードをコピー
@@ -513,14 +496,9 @@
ジャムに提案
- ジャム用ブローカーが設定されていません
- ブローカーのホストが空です
接続に失敗しました
接続が切断されました
-
- ブローカー
- anonymous
ジャムのキューに追加しました
diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
index 67fb729e..69dac887 100644
--- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
@@ -288,25 +288,11 @@
WebView
앱 안에 웹 콘텐츠 표시
그룹 잼
- MQTT 브로커
- 브로커 호스트
- %1$s:%2$d
- 브로커 포트
- TLS 사용
- 사용자 이름(선택 사항)
- 비밀번호(선택 사항)
- 클라이언트 ID 접두사
- 이 Spotube 인스턴스를 식별하는 데 사용돼요
- 브로커 연결 암호화
- 방을 시작하기 전에 현재 브로커 설정을 확인하세요.
- 연결 테스트
- 테스트 중…
- %1$d ms 만에 연결됨
- 실패: %1$s
- 임시 브로커입니다 — 직접 호스팅하려면 브로커를 설정하세요
+ 연결 테스트
+ 테스트 중…
+ %1$d ms 만에 연결됨
+ 실패: %1$s
연결
- 인증 정보
- 식별 정보
진단
@@ -319,9 +305,6 @@
연결 중…
방 코드
ABC123
- 브로커: %1$s
- 방을 만들거나 참여하려면 설정에서 브로커를 설정하세요.
- 브로커 설정
연결됨
다시 연결 중…
코드 복사
@@ -513,14 +496,9 @@
잼에 추천하기
- 구성된 잼 브로커가 없어요
- 브로커 호스트가 비어 있어요
연결 실패
연결 끊김
-
- 브로커
- 익명
잼 대기열에 추가했어요
diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
index a5e4197e..c1b6c506 100644
--- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
@@ -292,25 +292,11 @@
WebView
Webcontent weergeven in de app
Group Jam
- MQTT-broker
- Broker-host
- %1$s:%2$d
- Broker-poort
- TLS gebruiken
- Gebruikersnaam (optioneel)
- Wachtwoord (optioneel)
- Voorvoegsel client-ID
- Wordt gebruikt om deze Spotube-installatie te herkennen
- De verbinding met de broker versleutelen
- Controleer de huidige broker-instellingen voordat je een Jam start.
- Verbinding testen
- Testen…
- Verbonden in %1$d ms
- Mislukt: %1$s
- Tijdelijke broker — stel je eigen broker in om zelf te hosten
+ Verbinding testen
+ Testen…
+ Verbonden in %1$d ms
+ Mislukt: %1$s
Verbinding
- Inloggegevens
- Identiteit
Diagnose
@@ -323,9 +309,6 @@
Verbinden…
Sessiecode
ABC123
- Broker: %1$s
- Stel een broker in bij Instellingen om een sessie te hosten of eraan deel te nemen.
- Broker-instellingen
Verbonden
Opnieuw verbinden…
Code kopiëren
@@ -517,14 +500,9 @@
Voorstellen aan Jam
- Geen Jam-broker ingesteld
- Broker-host is leeg
Verbinding mislukt
Verbinding verbroken
-
- Broker
- anoniem
Toegevoegd aan de Jam-wachtrij
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index 5af41a10..b749cd80 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -300,25 +300,11 @@
WebView
Wyświetlanie treści internetowych w aplikacji
Grupowy Jam
- Broker MQTT
- Host brokera
- %1$s:%2$d
- Port brokera
- Użyj TLS
- Nazwa użytkownika (opcjonalnie)
- Hasło (opcjonalnie)
- Prefiks ID klienta
- Służy do identyfikacji tej instancji Spotube
- Szyfruj połączenie z brokerem
- Sprawdź bieżące ustawienia brokera przed utworzeniem pokoju.
- Testuj połączenie
- Testowanie…
- Połączono w %1$d ms
- Niepowodzenie: %1$s
- Broker zastępczy – skonfiguruj własny, aby hostować samodzielnie
+ Testuj połączenie
+ Testowanie…
+ Połączono w %1$d ms
+ Niepowodzenie: %1$s
Połączenie
- Dane logowania
- Tożsamość
Diagnostyka
@@ -331,9 +317,6 @@
Łączenie…
Kod pokoju
ABC123
- Broker: %1$s
- Skonfiguruj brokera w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.
- Ustawienia brokera
Połączono
Ponowne łączenie…
Kopiuj kod
@@ -525,14 +508,9 @@
Zaproponuj w Jamie
- Nie skonfigurowano brokera Jam
- Host brokera jest pusty
Połączenie nieudane
Utracono połączenie
-
- Broker
- anonim
Dodano do kolejki Jamu
diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
index ce987e2b..c1fdef3b 100644
--- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
@@ -296,25 +296,11 @@
WebView
Exibir conteúdo da web dentro do app
Jam em grupo
- Broker MQTT
- Host do broker
- %1$s:%2$d
- Porta do broker
- Usar TLS
- Nome de usuário (opcional)
- Senha (opcional)
- Prefixo do ID do cliente
- Usado para identificar esta instância do Spotube
- Criptografa a conexão com o broker
- Verifique as configurações atuais do broker antes de criar uma sala.
- Testar conexão
- Testando…
- Conectado em %1$d ms
- Falhou: %1$s
- Broker provisório — configure o seu próprio para hospedar por conta própria
+ Testar conexão
+ Testando…
+ Conectado em %1$d ms
+ Falhou: %1$s
Conexão
- Credenciais
- Identidade
Diagnóstico
@@ -327,9 +313,6 @@
Conectando…
Código da sala
ABC123
- Broker: %1$s
- Configure um broker nas Configurações para criar ou entrar em uma sala.
- Configurações do broker
Conectado
Reconectando…
Copiar código
@@ -521,14 +504,9 @@
Sugerir na Jam
- Nenhum broker de Jam configurado
- O host do broker está vazio
Falha na conexão
Conexão perdida
-
- Broker
- anônimo
Adicionada à fila da Jam
diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
index 8128a8ce..eaa76c30 100644
--- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
@@ -300,25 +300,11 @@
WebView
Показ веб-контента внутри приложения
Групповой джем
- MQTT-брокер
- Хост брокера
- %1$s:%2$d
- Порт брокера
- Использовать TLS
- Имя пользователя (необязательно)
- Пароль (необязательно)
- Префикс ID клиента
- Используется для идентификации этого экземпляра Spotube
- Шифровать подключение к брокеру
- Проверьте текущие настройки брокера перед созданием комнаты.
- Проверить подключение
- Проверка…
- Подключено за %1$d мс
- Ошибка: %1$s
- Временный брокер — настройте свой, чтобы размещать джемы самостоятельно
+ Проверить подключение
+ Проверка…
+ Подключено за %1$d мс
+ Ошибка: %1$s
Подключение
- Учётные данные
- Идентификация
Диагностика
@@ -331,9 +317,6 @@
Подключение…
Код комнаты
ABC123
- Брокер: %1$s
- Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней.
- Настройки брокера
Подключено
Переподключение…
Копировать код
@@ -525,14 +508,9 @@
Предложить в джем
- Брокер для джема не настроен
- Не указан хост брокера
Не удалось подключиться
Соединение потеряно
-
- Брокер
- anonymous
Добавлено в очередь джема
diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml
index ab470a92..56a198a2 100644
--- a/composeApp/src/commonMain/composeResources/values-th/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml
@@ -288,25 +288,11 @@
WebView
แสดงเนื้อหาเว็บภายในแอป
แจมกลุ่ม
- MQTT Broker
- โฮสต์ของ Broker
- %1$s:%2$d
- พอร์ตของ Broker
- ใช้ TLS
- ชื่อผู้ใช้ (ไม่บังคับ)
- รหัสผ่าน (ไม่บังคับ)
- คำนำหน้า Client ID
- ใช้เพื่อระบุ Spotube เครื่องนี้
- เข้ารหัสการเชื่อมต่อกับ Broker
- ตรวจสอบการตั้งค่า Broker ปัจจุบันก่อนเริ่มห้อง
- ทดสอบการเชื่อมต่อ
- กำลังทดสอบ…
- เชื่อมต่อแล้วใน %1$d ms
- ล้มเหลว: %1$s
- Broker ตัวอย่าง — กำหนดค่าของคุณเองเพื่อโฮสต์เอง
+ ทดสอบการเชื่อมต่อ
+ กำลังทดสอบ…
+ เชื่อมต่อแล้วใน %1$d ms
+ ล้มเหลว: %1$s
การเชื่อมต่อ
- ข้อมูลเข้าสู่ระบบ
- ตัวตน
การวินิจฉัย
@@ -319,9 +305,6 @@
กำลังเชื่อมต่อ…
รหัสห้อง
ABC123
- Broker: %1$s
- ตั้งค่า Broker ในการตั้งค่าเพื่อสร้างหรือเข้าร่วมห้อง
- การตั้งค่า Broker
เชื่อมต่อแล้ว
กำลังเชื่อมต่อใหม่…
คัดลอกรหัส
@@ -513,14 +496,9 @@
เสนอให้แจม
- ยังไม่ได้กำหนดค่า Broker สำหรับแจม
- โฮสต์ของ Broker ว่างอยู่
การเชื่อมต่อล้มเหลว
การเชื่อมต่อขาดหาย
-
- Broker
- anonymous
เพิ่มลงในคิวแจมแล้ว
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index 54d5ed93..2f8c21e9 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -292,25 +292,11 @@
WebView
Uygulama içinde web içeriği görüntüleme
Grup Jam
- MQTT Aracısı
- Aracı ana bilgisayarı
- %1$s:%2$d
- Aracı portu
- TLS kullan
- Kullanıcı adı (isteğe bağlı)
- Şifre (isteğe bağlı)
- İstemci kimliği ön eki
- Bu Spotube örneğini tanımlamak için kullanılır
- Aracı bağlantısını şifrele
- Oda başlatmadan önce geçerli aracı ayarlarını kontrol et.
- Bağlantıyı test et
- Test ediliyor…
- %1$d ms\'de bağlandı
- Başarısız: %1$s
- Geçici aracı — kendin barındırmak için kendi aracını yapılandır
+ Bağlantıyı test et
+ Test ediliyor…
+ %1$d ms\'de bağlandı
+ Başarısız: %1$s
Bağlantı
- Kimlik bilgileri
- Kimlik
Tanılama
@@ -323,9 +309,6 @@
Bağlanıyor…
Oda kodu
ABC123
- Aracı: %1$s
- Oda açmak veya bir odaya katılmak için Ayarlar\'da bir aracı ayarla.
- Aracı ayarları
Bağlandı
Yeniden bağlanıyor…
Kodu kopyala
@@ -517,14 +500,9 @@
Jam\'e öner
- Jam aracısı yapılandırılmadı
- Aracı ana bilgisayarı boş
Bağlantı başarısız
Bağlantı kesildi
-
- Aracı
- anonymous
Jam sırasına eklendi
diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
index 0fd2d19b..9f87d27f 100644
--- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
@@ -300,25 +300,11 @@
WebView
Показ вебвмісту всередині застосунку
Груповий джем
- MQTT-брокер
- Хост брокера
- %1$s:%2$d
- Порт брокера
- Використовувати TLS
- Ім’я користувача (необов’язково)
- Пароль (необов’язково)
- Префікс ID клієнта
- Використовується для ідентифікації цього екземпляра Spotube
- Шифрувати підключення до брокера
- Перевірте поточні налаштування брокера перед створенням кімнати.
- Перевірити підключення
- Перевірка…
- Підключено за %1$d мс
- Помилка: %1$s
- Тимчасовий брокер — налаштуйте власний, щоб розміщувати джеми самостійно
+ Перевірити підключення
+ Перевірка…
+ Підключено за %1$d мс
+ Помилка: %1$s
Підключення
- Облікові дані
- Ідентифікація
Діагностика
@@ -331,9 +317,6 @@
Підключення…
Код кімнати
ABC123
- Брокер: %1$s
- Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї.
- Налаштування брокера
Підключено
Повторне підключення…
Копіювати код
@@ -525,14 +508,9 @@
Запропонувати в джем
- Брокер для джему не налаштовано
- Не вказано хост брокера
Не вдалося підключитися
З’єднання втрачено
-
- Брокер
- anonymous
Додано до черги джему
diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
index 39ae5edc..80a93679 100644
--- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
@@ -288,25 +288,11 @@
WebView
Hiển thị nội dung web trong ứng dụng
Jam nhóm
- MQTT Broker
- Máy chủ broker
- %1$s:%2$d
- Cổng broker
- Dùng TLS
- Tên người dùng (không bắt buộc)
- Mật khẩu (không bắt buộc)
- Tiền tố Client ID
- Dùng để nhận diện Spotube này
- Mã hóa kết nối tới broker
- Kiểm tra cài đặt broker hiện tại trước khi tạo phòng.
- Kiểm tra kết nối
- Đang kiểm tra…
- Đã kết nối trong %1$d ms
- Thất bại: %1$s
- Broker tạm thời — hãy cấu hình broker riêng để tự lưu trữ
+ Kiểm tra kết nối
+ Đang kiểm tra…
+ Đã kết nối trong %1$d ms
+ Thất bại: %1$s
Kết nối
- Thông tin đăng nhập
- Danh tính
Chẩn đoán
@@ -319,9 +305,6 @@
Đang kết nối…
Mã phòng
ABC123
- Broker: %1$s
- Hãy thiết lập broker trong Cài đặt để tạo hoặc tham gia phòng.
- Cài đặt broker
Đã kết nối
Đang kết nối lại…
Sao chép mã
@@ -513,14 +496,9 @@
Đề xuất cho Jam
- Chưa cấu hình broker cho Jam
- Máy chủ broker đang trống
Kết nối thất bại
Mất kết nối
-
- Broker
- ẩn danh
Đã thêm vào danh sách chờ của Jam
diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 2f59a04d..f3710bb5 100644
--- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -288,25 +288,11 @@
WebView
在應用程式內顯示網頁內容
群組 Jam
- MQTT 代理伺服器
- 代理伺服器主機
- %1$s:%2$d
- 代理伺服器連接埠
- 使用 TLS
- 使用者名稱(選填)
- 密碼(選填)
- 用戶端 ID 前綴
- 用於識別這個 Spotube
- 加密與代理伺服器的連線
- 在建立房間前檢查目前的代理伺服器設定。
- 測試連線
- 測試中…
- 已連線,耗時 %1$d 毫秒
- 失敗:%1$s
- 預留的代理伺服器 — 請設定你自己的伺服器以自行架設
+ 測試連線
+ 測試中…
+ 已連線,耗時 %1$d 毫秒
+ 失敗:%1$s
連線
- 憑證
- 身分
診斷
@@ -319,9 +305,6 @@
連線中…
房間代碼
ABC123
- 代理伺服器:%1$s
- 請先在「設定」中設定代理伺服器,才能建立或加入房間。
- 代理伺服器設定
已連線
重新連線中…
複製代碼
@@ -513,14 +496,9 @@
推薦到 Jam
- 尚未設定 Jam 代理伺服器
- 代理伺服器主機為空白
連線失敗
連線中斷
-
- 代理伺服器
- anonymous
已加入 Jam 待播清單
diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
index 774ab6ef..722ff10c 100644
--- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
@@ -288,25 +288,11 @@
WebView
在应用内显示网页内容
一起听
- MQTT 代理服务器
- 代理服务器主机
- %1$s:%2$d
- 代理服务器端口
- 使用 TLS
- 用户名(可选)
- 密码(可选)
- 客户端 ID 前缀
- 用于识别此 Spotube 实例
- 加密与代理服务器的连接
- 创建房间前,检查当前的代理服务器设置。
- 测试连接
- 正在测试…
- 已连接,耗时 %1$d 毫秒
- 失败:%1$s
- 占位代理服务器 — 如需自托管,请配置你自己的服务器
+ 测试连接
+ 正在测试…
+ 已连接,耗时 %1$d 毫秒
+ 失败:%1$s
连接
- 凭据
- 身份
诊断
@@ -319,9 +305,6 @@
正在连接…
房间码
ABC123
- 代理服务器:%1$s
- 请先在设置中配置代理服务器,才能创建或加入房间。
- 代理服务器设置
已连接
正在重新连接…
复制房间码
@@ -513,14 +496,9 @@
推荐到一起听
- 未配置一起听代理服务器
- 代理服务器主机为空
连接失败
连接已断开
-
- 代理服务器
- anonymous
已添加到一起听队列
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index 2fbae5e0..764bff82 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -310,25 +310,16 @@
WebView
Display web content inside the app
Group Jam
- MQTT Broker
- Broker host
- %1$s:%2$d
- Broker port
- Use TLS
- Username (optional)
- Password (optional)
- Client ID prefix
- Used to identify this Spotube instance
- Encrypt the broker connection
- Check the current broker settings before starting a room.
- Test connection
- Testing…
- Connected in %1$d ms
- Failed: %1$s
- Placeholder broker — configure your own to self-host
+ Room server
+ Server URL
+ Enter a URL starting with https://
+ Check the room server before starting a room.
+ Jam uses Spotube’s free room server. To self-host, deploy jam-server from the Spotube repository and enter its URL.
+ Test connection
+ Testing…
+ Connected in %1$d ms
+ Failed: %1$s
Connection
- Credentials
- Identity
Diagnostics
@@ -341,9 +332,9 @@
Connecting…
Room code
ABC123
- Broker: %1$s
- Set up a broker in Settings to host or join a room.
- Broker settings
+ Server: %1$s
+ Set a room server in Settings to host or join a room.
+ Server settings
Connected
Reconnecting…
Copy code
@@ -549,15 +540,17 @@
Suggest to Jam
- No jam broker configured
- Broker host is empty
+ No jam server configured
+ Couldn’t reach the jam server. Check your network or the server URL in Settings → Jam.
+ The jam server runs an incompatible version.
+ No room with that code. Check it and try again.
+ This room is full.
+ The host banned you from this room.
+ The host removed you from the room.
+ You joined this room from another device.
Connection failed
Connection lost
- Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam.
-
- Broker
- anonymous
Added to the jam queue
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt
index d7668946..4c45a3fd 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/di/Modules.kt
@@ -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() }
+ singleOf(::SettingsRepository) {
+ bind()
+ bind()
+ }
viewModelOf(::SettingsViewModel) { bind() }
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() }
single {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt
new file mode 100644
index 00000000..770a6834
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamClock.kt
@@ -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 .
+ */
+
+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()
+
+ /** 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()
+
+ /**
+ * [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()
+}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
index 03231e66..b01fe8e0 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamProtocol.kt
@@ -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(
- val items: List,
- 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,
- 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()
-}
+data class JamQueue(
+ val items: List,
+ 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,
+ 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(
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
index 07810202..c204ede2 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomClient.kt
@@ -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? = 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 = _isConnected.asStateFlow()
@@ -89,244 +125,413 @@ class JamRoomClient : KoinComponent {
private val _connectionError = MutableStateFlow(null)
val connectionError: StateFlow = _connectionError.asStateFlow()
- private val _state = MutableSharedFlow(replay = 1, extraBufferCapacity = 8)
- val state: SharedFlow = _state.asSharedFlow()
-
- private val _commands = MutableSharedFlow(extraBufferCapacity = 32)
- val commands: SharedFlow = _commands.asSharedFlow()
-
- private val _presence = MutableStateFlow