diff --git a/AGENTS.md b/AGENTS.md index 816d9590..9a0999af 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,6 +26,15 @@ - Never call `HttpClient(...)` directly. Inject `HttpClientFactory` (`commonMain/.../core/network/HttpClientFactory.kt`, a Koin single) and use `.default` / `.json` (shared, never close them) or `.create { ... }` for extra plugins (caller owns and may close it). It owns the one engine-backed client, so the platform default engine is used everywhere (Darwin on iOS, OkHttp on Android/desktop). - Do not add `ktor-client-cio`: on iOS it competes with Darwin as the default engine and has no TLS on Kotlin/Native, so every HTTPS request fails. +## Group Jam +- Room server: separate repository, https://github.com/team-spotube/jam-server (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in its `src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes, and land both changes together). +- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off). +- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See the jam-server README. +- Security: the server enforces identity (per-install secret → per-room key → public id), device-level bans (`JamDevice.kt` traits, only ever sent hashed), host-only powers and flood limits. The app treats everything from peers as untrusted and runs it through `JamMediaItem.sanitized` before loading it. See "Security" in the jam-server README. +- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`. +- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`). +- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (run `npm run dev` in a jam-server checkout). + ## GraalVM native image (desktop) - 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/core/jam/JamDevice.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt new file mode 100644 index 00000000..45d18513 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/jam/JamDevice.android.kt @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import android.content.res.Resources +import android.os.Build +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List { + val display = Resources.getSystem().displayMetrics + return listOf( + "android", + Build.MANUFACTURER, + Build.MODEL, + Build.DEVICE, + Build.VERSION.RELEASE, + Build.VERSION.SDK_INT.toString(), + "${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}", + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) +} 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..cc82d900 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 - اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها. - إعدادات الوسيط متصل جارٍ إعادة الاتصال… نسخ الرمز @@ -361,6 +344,28 @@ تعذّر الانضمام إلى جلسة الجام أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام. انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s + خادم الغرف + رابط الخادم + أدخل رابطًا يبدأ بـ https:// + تحقّق من خادم الغرف قبل بدء غرفة. + يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر الخادم من github.com/team-spotube/jam-server وأدخل رابطه. + أنت + الخادم: %1$s + حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها. + إعدادات الخادم + %1$s (أنت) + اسمك + يراك الآخرون في الجام باسم %1$s + يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها. + استخدم %1$d حرفًا كحد أقصى. + لم يتم إعداد خادم للجام + تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي. + يعمل خادم الجام بإصدار غير متوافق. + لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا. + هذه الغرفة ممتلئة. + حظرك المضيف من هذه الغرفة. + أزالك المضيف من الغرفة. + فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال. الانتقال إلى الألبوم مؤقت النوم @@ -533,14 +538,9 @@ اقتراح للجام - لم يتم ضبط وسيط للجام - مضيف الوسيط فارغ فشل الاتصال انقطع الاتصال - - الوسيط - مجهول تمت الإضافة إلى قائمة انتظار الجام diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml index 268cdf95..a111017f 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 - রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন। - ব্রোকার সেটিংস কানেক্টেড আবার কানেক্ট হচ্ছে… কোড কপি করুন @@ -345,6 +328,28 @@ জ্যাম সেশনে যোগ দেওয়া যায়নি অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন। এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s + রুম সার্ভার + সার্ভার URL + https:// দিয়ে শুরু হওয়া একটি URL দিন + রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন। + জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে github.com/team-spotube/jam-server থেকে সার্ভার ডিপ্লয় করে তার URL দিন। + আপনি + সার্ভার: %1$s + রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন। + সার্ভার সেটিংস + %1$s (আপনি) + আপনার নাম + জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে + জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে। + সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন। + কোনো জ্যাম সার্ভার সেট করা নেই + জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন। + জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়। + এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন। + এই রুমটি পূর্ণ। + হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন। + হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন। + এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে। অ্যালবামে যান স্লিপ টাইমার @@ -517,14 +522,9 @@ জ্যামে সাজেস্ট করুন - কোনো জ্যাম ব্রোকার কনফিগার করা নেই - ব্রোকার হোস্ট ফাঁকা কানেকশন ব্যর্থ হয়েছে কানেকশন বিচ্ছিন্ন হয়েছে - - ব্রোকার - anonymous জ্যাম কিউতে যোগ করা হয়েছে diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml index 2f965807..3fb3063b 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 @@ -345,6 +328,28 @@ Beitritt zur Jam-Session fehlgeschlagen Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein. Tritt meiner Spotube-Jam bei, Code: %1$s + Raumserver + Server-URL + Gib eine URL ein, die mit https:// beginnt. + Prüfe den Raumserver, bevor du einen Raum startest. + Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle den Server von github.com/team-spotube/jam-server bereit und gib seine URL ein. + Du + Server: %1$s + Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten. + Servereinstellungen + %1$s (du) + Dein Name + Andere im Jam sehen dich als %1$s + Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst. + Verwende höchstens %1$d Zeichen. + Kein Jam-Server eingerichtet + Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam. + Der Jam-Server verwendet eine inkompatible Version. + Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut. + Dieser Raum ist voll. + Der Host hat dich aus diesem Raum gesperrt. + Der Host hat dich aus dem Raum entfernt. + Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt. Zum Album Sleep-Timer @@ -517,14 +522,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..3a79f26d 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 @@ -349,6 +332,28 @@ No se pudo unir a la sesión de Jam Introduce un código de sala de seis caracteres con letras y números. Únete a mi Jam de Spotube con el código: %1$s + Servidor de salas + URL del servidor + Introduce una URL que empiece por https:// + Comprueba el servidor de salas antes de crear una sala. + Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega el servidor de github.com/team-spotube/jam-server e introduce su URL. + Tú + Servidor: %1$s + Configura un servidor de salas en Ajustes para crear una sala o unirte a una. + Ajustes del servidor + %1$s (tú) + Tu nombre + Los demás en la jam te ven como %1$s + Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una. + Usa como máximo %1$d caracteres. + No hay ningún servidor de jam configurado + No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo. + El servidor de jam usa una versión incompatible. + No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo. + Esta sala está llena. + El anfitrión te ha bloqueado en esta sala. + El anfitrión te ha quitado de la sala. + Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión. Ir al álbum Temporizador @@ -521,14 +526,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..7ad121f8 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 - برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راه‌اندازی کنید. - تنظیمات بروکر متصل در حال اتصال دوباره… کپی کد @@ -345,6 +328,28 @@ پیوستن به جلسهٔ جم ناموفق بود یک کد اتاق شش‌نویسه‌ای شامل حروف و اعداد وارد کنید. با این کد به جم Spotube من بپیوندید: %1$s + سرور اتاق‌ها + نشانی سرور + نشانی‌ای وارد کنید که با https:// شروع شود + پیش از شروع اتاق، سرور اتاق‌ها را بررسی کنید. + جم از سرور رایگان اتاق‌های Spotube استفاده می‌کند. برای میزبانی شخصی، سرور را از github.com/team-spotube/jam-server راه‌اندازی کنید و نشانی آن را وارد کنید. + شما + سرور: %1$s + برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید. + تنظیمات سرور + %1$s (شما) + نام شما + دیگران در جم شما را با نام %1$s می‌بینند + به همه در جم نشان داده می‌شود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی می‌سازید یا به آن می‌پیوندید اعمال می‌شود. + حداکثر %1$d نویسه وارد کنید. + هیچ سرور جمی تنظیم نشده است + دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید. + سرور جم نسخهٔ ناسازگاری اجرا می‌کند. + اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید. + این اتاق پر است. + میزبان شما را از این اتاق مسدود کرد. + میزبان شما را از اتاق بیرون کرد. + این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد. رفتن به آلبوم زمان‌سنج خواب @@ -517,14 +522,9 @@ پیشنهاد به جم - هیچ بروکر جمی پیکربندی نشده است - میزبان بروکر خالی است اتصال ناموفق بود اتصال قطع شد - - بروکر - ناشناس به صف جم اضافه شد diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 45a31bd6..a96a3b83 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 @@ -349,6 +332,28 @@ Impossible de rejoindre la session Jam Saisissez un code de salle de six caractères (lettres et chiffres). Rejoignez ma Jam Spotube avec le code : %1$s + Serveur de salles + URL du serveur + Saisissez une URL commençant par https:// + Vérifiez le serveur de salles avant de lancer une salle. + Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez le serveur depuis github.com/team-spotube/jam-server et saisissez son URL. + Vous + Serveur : %1$s + Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle. + Paramètres du serveur + %1$s (vous) + Votre nom + Les autres participants vous voient comme %1$s + Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle. + Utilisez au maximum %1$d caractères. + Aucun serveur de jam configuré + Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe. + Le serveur de jam utilise une version incompatible. + Aucune salle avec ce code. Vérifiez-le et réessayez. + Cette salle est pleine. + L’hôte vous a banni de cette salle. + L’hôte vous a retiré de la salle. + Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée. Accéder à l\'album Minuteur de veille @@ -521,14 +526,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..5de37959 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 - रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें। - ब्रोकर सेटिंग कनेक्ट है फिर से कनेक्ट हो रहा है… कोड कॉपी करें @@ -344,6 +327,28 @@ जैम सेशन से नहीं जुड़ सके अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें। मेरे Spotube जैम से जुड़ें, कोड: %1$s + रूम सर्वर + सर्वर URL + https:// से शुरू होने वाला URL डालें + रूम शुरू करने से पहले रूम सर्वर जांच लें। + जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए github.com/team-spotube/jam-server से सर्वर डिप्लॉय करें और उसका URL डालें। + आप + सर्वर: %1$s + रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें। + सर्वर सेटिंग + %1$s (आप) + आपका नाम + जैम में बाकी लोग आपको %1$s के रूप में देखते हैं + जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा। + ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें। + कोई जैम सर्वर सेट नहीं है + जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें। + जैम सर्वर का वर्शन संगत नहीं है। + इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें। + यह रूम भर गया है। + होस्ट ने आपको इस रूम से बैन कर दिया है। + होस्ट ने आपको रूम से हटा दिया है। + यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया। एल्बम पर जाएं स्लीप टाइमर @@ -516,14 +521,9 @@ जैम में सुझाएं - कोई जैम ब्रोकर सेट नहीं है - ब्रोकर होस्ट खाली है कनेक्शन विफल कनेक्शन टूट गया - - ब्रोकर - anonymous जैम कतार में जोड़ा गया diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml index 3f46bc82..0cd52e2e 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 @@ -341,6 +324,28 @@ Gagal bergabung ke sesi Jam Masukkan kode ruang enam karakter berisi huruf dan angka. Gabung ke Jam Spotube-ku dengan kode: %1$s + Server ruang + URL server + Masukkan URL yang diawali https:// + Periksa server ruang sebelum memulai ruang. + Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy server dari github.com/team-spotube/jam-server lalu masukkan URL-nya. + Kamu + Server: %1$s + Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang. + Pengaturan server + %1$s (kamu) + Namamu + Orang lain di jam melihatmu sebagai %1$s + Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang. + Gunakan paling banyak %1$d karakter. + Belum ada server jam yang diatur + Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup. + Server jam menjalankan versi yang tidak kompatibel. + Tidak ada ruang dengan kode itu. Periksa lalu coba lagi. + Ruang ini sudah penuh. + Host memblokirmu dari ruang ini. + Host mengeluarkanmu dari ruang. + Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup. Buka album Timer tidur @@ -513,14 +518,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..5ddcc190 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 @@ -349,6 +332,28 @@ Impossibile partecipare alla sessione Jam Inserisci un codice stanza di sei caratteri composto da lettere e cifre. Unisciti alla mia Jam su Spotube con il codice: %1$s + Server delle stanze + URL del server + Inserisci un URL che inizi con https:// + Controlla il server delle stanze prima di avviare una stanza. + Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci il server da github.com/team-spotube/jam-server e inseriscine l’URL. + Tu + Server: %1$s + Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci. + Impostazioni del server + %1$s (tu) + Il tuo nome + Gli altri nella jam ti vedono come %1$s + Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri. + Usa al massimo %1$d caratteri. + Nessun server jam configurato + Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo. + Il server jam usa una versione non compatibile. + Nessuna stanza con questo codice. Controllalo e riprova. + Questa stanza è piena. + L’host ti ha bannato da questa stanza. + L’host ti ha rimosso dalla stanza. + Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa. Vai all\'album Timer di spegnimento @@ -521,14 +526,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..1464d695 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 - ルームを主催または参加するには、設定でブローカーを設定してください。 - ブローカー設定 接続済み 再接続中… コードをコピー @@ -341,6 +324,28 @@ ジャムセッションに参加できませんでした 英数字6文字のルームコードを入力してください。 コード %1$s で私のSpotubeジャムに参加しよう + ルームサーバー + サーバーURL + https:// で始まるURLを入力してください。 + ルームを始める前にルームサーバーを確認します。 + ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、github.com/team-spotube/jam-server のサーバーをデプロイし、そのURLを入力してください。 + あなた + サーバー: %1$s + ルームをホストまたは参加するには、設定でルームサーバーを指定してください。 + サーバー設定 + %1$s(あなた) + あなたの名前 + ジャムの他の参加者には %1$s と表示されます + ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。 + %1$d 文字以内で入力してください。 + ジャムサーバーが設定されていません + ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。 + ジャムサーバーのバージョンに互換性がありません。 + そのコードのルームはありません。コードを確認してもう一度お試しください。 + このルームは満員です。 + ホストによってこのルームからBANされました。 + ホストによってルームから削除されました。 + このデバイスでこのルームが再度開かれたため、この接続は閉じられました。 アルバムへ移動 スリープタイマー @@ -513,14 +518,9 @@ ジャムに提案 - ジャム用ブローカーが設定されていません - ブローカーのホストが空です 接続に失敗しました 接続が切断されました - - ブローカー - anonymous ジャムのキューに追加しました diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml index 67fb729e..39b8854c 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 - 방을 만들거나 참여하려면 설정에서 브로커를 설정하세요. - 브로커 설정 연결됨 다시 연결 중… 코드 복사 @@ -341,6 +324,28 @@ 잼 세션에 참여하지 못했어요 영문자와 숫자로 된 6자리 방 코드를 입력하세요. 내 Spotube 잼에 참여하세요. 코드: %1$s + 방 서버 + 서버 URL + https://로 시작하는 URL을 입력하세요. + 방을 시작하기 전에 방 서버를 확인하세요. + 잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 github.com/team-spotube/jam-server의 서버를 배포하고 URL을 입력하세요. + 나 + 서버: %1$s + 방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요. + 서버 설정 + %1$s (나) + 내 이름 + 잼의 다른 사람들에게 %1$s(으)로 표시됩니다 + 잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다. + 최대 %1$d자까지 입력하세요. + 설정된 잼 서버가 없습니다 + 잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요. + 잼 서버의 버전이 호환되지 않습니다. + 해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요. + 이 방은 가득 찼습니다. + 호스트가 이 방에서 나를 차단했습니다. + 호스트가 나를 방에서 내보냈습니다. + 이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다. 앨범으로 이동 취침 타이머 @@ -513,14 +518,9 @@ 잼에 추천하기 - 구성된 잼 브로커가 없어요 - 브로커 호스트가 비어 있어요 연결 실패 연결 끊김 - - 브로커 - 익명 잼 대기열에 추가했어요 diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml index a5e4197e..e3520c74 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 @@ -345,6 +328,28 @@ Deelnemen aan Jam-sessie mislukt Voer een sessiecode van zes tekens in met letters en cijfers. Doe mee met mijn Spotube Jam met code: %1$s + Sessieserver + Server-URL + Voer een URL in die begint met https:// + Controleer de sessieserver voordat je een sessie start. + Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan de server van github.com/team-spotube/jam-server en voer de URL in. + Jij + Server: %1$s + Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen. + Serverinstellingen + %1$s (jij) + Je naam + Anderen in de jam zien je als %1$s + Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt. + Gebruik maximaal %1$d tekens. + Geen jamserver ingesteld + Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam. + De jamserver draait een incompatibele versie. + Geen sessie met die code. Controleer de code en probeer het opnieuw. + Deze sessie is vol. + De host heeft je uit deze sessie verbannen. + De host heeft je uit de sessie verwijderd. + Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten. Naar album Slaaptimer @@ -517,14 +522,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..92c9b443 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 @@ -353,6 +336,28 @@ Nie udało się dołączyć do sesji Jam Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr. Dołącz do mojego Jamu w Spotube, kod: %1$s + Serwer pokojów + Adres URL serwera + Wpisz adres URL zaczynający się od https:// + Sprawdź serwer pokojów przed utworzeniem pokoju. + Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż serwer z github.com/team-spotube/jam-server i wpisz jego adres URL. + Ty + Serwer: %1$s + Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć. + Ustawienia serwera + %1$s (ty) + Twoja nazwa + Inni w jamie widzą cię jako %1$s + Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego. + Użyj maksymalnie %1$d znaków. + Nie skonfigurowano serwera jamu + Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam. + Serwer jamu używa niezgodnej wersji. + Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie. + Ten pokój jest pełny. + Gospodarz zablokował cię w tym pokoju. + Gospodarz usunął cię z pokoju. + Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte. Przejdź do albumu Wyłącznik czasowy @@ -525,14 +530,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..0f971be6 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 @@ -349,6 +332,28 @@ Falha ao entrar na Jam Digite um código de sala com seis caracteres, usando letras e números. Entre na minha Jam do Spotube com o código: %1$s + Servidor de salas + URL do servidor + Digite uma URL que comece com https:// + Verifique o servidor de salas antes de criar uma sala. + O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o servidor de github.com/team-spotube/jam-server e informe a URL dele. + Você + Servidor: %1$s + Defina um servidor de salas nas Configurações para criar ou entrar em uma sala. + Configurações do servidor + %1$s (você) + Seu nome + Os outros no jam veem você como %1$s + Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala. + Use no máximo %1$d caracteres. + Nenhum servidor de jam configurado + Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo. + O servidor de jam usa uma versão incompatível. + Nenhuma sala com esse código. Confira e tente de novo. + Esta sala está cheia. + O anfitrião baniu você desta sala. + O anfitrião removeu você da sala. + Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada. Ir para o álbum Timer @@ -521,14 +526,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..b413416b 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 - Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней. - Настройки брокера Подключено Переподключение… Копировать код @@ -353,6 +336,28 @@ Не удалось присоединиться к джему Введите код комнаты из шести букв и цифр. Присоединяйся к моему джему в Spotube по коду: %1$s + Сервер комнат + URL сервера + Введите URL, начинающийся с https:// + Проверьте сервер комнат перед созданием комнаты. + Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите сервер из github.com/team-spotube/jam-server и укажите его URL. + Вы + Сервер: %1$s + Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней. + Настройки сервера + %1$s (вы) + Ваше имя + Другие участники джема видят вас как %1$s + Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней. + Не больше %1$d символов. + Сервер джема не настроен + Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем. + Сервер джема использует несовместимую версию. + Комнаты с таким кодом нет. Проверьте код и попробуйте снова. + Эта комната заполнена. + Организатор заблокировал вас в этой комнате. + Организатор удалил вас из комнаты. + Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто. Перейти к альбому Таймер сна @@ -525,14 +530,9 @@ Предложить в джем - Брокер для джема не настроен - Не указан хост брокера Не удалось подключиться Соединение потеряно - - Брокер - anonymous Добавлено в очередь джема diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml index ab470a92..6498a53b 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 เชื่อมต่อแล้ว กำลังเชื่อมต่อใหม่… คัดลอกรหัส @@ -341,6 +324,28 @@ เข้าร่วมเซสชันแจมไม่สำเร็จ ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s + เซิร์ฟเวอร์ห้อง + URL ของเซิร์ฟเวอร์ + ใส่ URL ที่ขึ้นต้นด้วย https:// + ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง + แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้งเซิร์ฟเวอร์จาก github.com/team-spotube/jam-server แล้วใส่ URL ของเซิร์ฟเวอร์นั้น + คุณ + เซิร์ฟเวอร์: %1$s + ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง + การตั้งค่าเซิร์ฟเวอร์ + %1$s (คุณ) + ชื่อของคุณ + คนอื่นในแจมจะเห็นคุณเป็น %1$s + ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป + ใช้ได้ไม่เกิน %1$d ตัวอักษร + ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม + เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม + เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้ + ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง + ห้องนี้เต็มแล้ว + โฮสต์แบนคุณออกจากห้องนี้ + โฮสต์นำคุณออกจากห้อง + ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้ ไปที่อัลบั้ม ตั้งเวลาปิด @@ -513,14 +518,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..fe357c8c 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 @@ -345,6 +328,28 @@ Jam oturumuna katılınamadı Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir. Spotube Jam\'ime şu kodla katıl: %1$s + Oda sunucusu + Sunucu URL’si + https:// ile başlayan bir URL gir. + Oda başlatmadan önce oda sunucusunu kontrol et. + Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için github.com/team-spotube/jam-server adresindeki sunucuyu kur ve URL’sini gir. + Sen + Sunucu: %1$s + Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle. + Sunucu ayarları + %1$s (sen) + Adın + Jam’deki diğer kişiler seni %1$s olarak görür + Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur. + En fazla %1$d karakter kullan. + Jam sunucusu ayarlanmamış + Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et. + Jam sunucusu uyumsuz bir sürüm çalıştırıyor. + Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene. + Bu oda dolu. + Oda sahibi seni bu odadan engelledi. + Oda sahibi seni odadan çıkardı. + Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı. Albüme git Uyku zamanlayıcısı @@ -517,14 +522,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..966a429c 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 - Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї. - Налаштування брокера Підключено Повторне підключення… Копіювати код @@ -353,6 +336,28 @@ Не вдалося приєднатися до джему Введіть код кімнати з шести літер і цифр. Приєднуйся до мого джему в Spotube за кодом: %1$s + Сервер кімнат + URL сервера + Введіть URL, що починається з https:// + Перевірте сервер кімнат перед створенням кімнати. + Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть сервер із github.com/team-spotube/jam-server і вкажіть його URL. + Ви + Сервер: %1$s + Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї. + Налаштування сервера + %1$s (ви) + Ваше ім’я + Інші учасники джему бачать вас як %1$s + Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї. + Не більше %1$d символів. + Сервер джему не налаштовано + Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем. + Сервер джему використовує несумісну версію. + Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз. + Ця кімната заповнена. + Організатор заблокував вас у цій кімнаті. + Організатор видалив вас із кімнати. + Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито. Перейти до альбому Таймер сну @@ -525,14 +530,9 @@ Запропонувати в джем - Брокер для джему не налаштовано - Не вказано хост брокера Не вдалося підключитися З’єднання втрачено - - Брокер - anonymous Додано до черги джему diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 39ae5edc..7866e671 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ã @@ -341,6 +324,28 @@ Không thể tham gia phiên Jam Nhập mã phòng gồm sáu ký tự chữ và số. Tham gia Jam Spotube của mình với mã: %1$s + Máy chủ phòng + URL máy chủ + Nhập URL bắt đầu bằng https:// + Kiểm tra máy chủ phòng trước khi tạo phòng. + Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai máy chủ từ github.com/team-spotube/jam-server rồi nhập URL của nó. + Bạn + Máy chủ: %1$s + Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng. + Cài đặt máy chủ + %1$s (bạn) + Tên của bạn + Mọi người trong jam thấy bạn là %1$s + Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng. + Dùng tối đa %1$d ký tự. + Chưa cấu hình máy chủ jam + Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm. + Máy chủ jam đang chạy phiên bản không tương thích. + Không có phòng nào với mã này. Hãy kiểm tra và thử lại. + Phòng này đã đầy. + Chủ phòng đã cấm bạn khỏi phòng này. + Chủ phòng đã xóa bạn khỏi phòng. + Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng. Đi tới album Hẹn giờ tắt @@ -513,14 +518,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..0bbee39f 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 - 請先在「設定」中設定代理伺服器,才能建立或加入房間。 - 代理伺服器設定 已連線 重新連線中… 複製代碼 @@ -341,6 +324,28 @@ 無法加入 Jam 請輸入由英文字母和數字組成的六位房間代碼。 用代碼 %1$s 加入我的 Spotube Jam + 房間伺服器 + 伺服器網址 + 請輸入以 https:// 開頭的網址 + 建立房間前先檢查房間伺服器。 + Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 github.com/team-spotube/jam-server 中的伺服器並輸入其網址。 + 你 + 伺服器:%1$s + 請在設定中指定房間伺服器,以建立或加入房間。 + 伺服器設定 + %1$s(你) + 你的名稱 + Jam 裡的其他人會看到你是 %1$s + Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。 + 最多 %1$d 個字元。 + 尚未設定 Jam 伺服器 + 無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。 + Jam 伺服器的版本不相容。 + 沒有使用此代碼的房間。請檢查後再試一次。 + 此房間已滿。 + 主持人已將你從此房間封鎖。 + 主持人已將你移出房間。 + 此房間已在這部裝置上再次開啟,因此這個連線已關閉。 前往專輯 睡眠定時器 @@ -513,14 +518,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..c357b046 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 - 请先在设置中配置代理服务器,才能创建或加入房间。 - 代理服务器设置 已连接 正在重新连接… 复制房间码 @@ -341,6 +324,28 @@ 加入一起听会话失败 请输入由字母和数字组成的六位房间码。 用房间码加入我的 Spotube 一起听:%1$s + 房间服务器 + 服务器网址 + 请输入以 https:// 开头的网址 + 创建房间前先检查房间服务器。 + 一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 github.com/team-spotube/jam-server 中的服务器并填写其网址。 + 你 + 服务器:%1$s + 请在设置中指定房间服务器,以创建或加入房间。 + 服务器设置 + %1$s(你) + 你的名字 + 房间里的其他人看到你的名字是 %1$s + 房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。 + 最多 %1$d 个字符。 + 未配置一起听服务器 + 无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。 + 一起听服务器的版本不兼容。 + 没有使用该代码的房间。请检查后重试。 + 该房间已满。 + 房主已将你从此房间封禁。 + 房主已将你移出房间。 + 此房间已在本设备上再次打开,因此此连接已关闭。 转到专辑 睡眠定时器 @@ -513,14 +518,9 @@ 推荐到一起听 - 未配置一起听代理服务器 - 代理服务器主机为空 连接失败 连接已断开 - - 代理服务器 - anonymous 已添加到一起听队列 diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 2fbae5e0..7784db8d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -310,26 +310,18 @@ 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 the server from github.com/team-spotube/jam-server and enter its URL. + Test connection + Testing… + Connected in %1$d ms + Failed: %1$s Connection - Credentials - Identity Diagnostics + You Group Jam @@ -341,9 +333,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 @@ -352,6 +344,7 @@ Participants (%1$d) Host Listener + %1$s (you) Waiting for listeners… Remove Ban @@ -363,6 +356,145 @@ Failed to join jam session Enter a six-character room code using letters and digits. Join my Spotube Jam with code: %1$s + Your name + Others in the jam see you as %1$s + Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room. + Use at most %1$d characters. + + %1$s %2$s + + + Brave + Bright + Calm + Cheerful + Chill + Clever + Cosmic + Cozy + Curious + Dapper + Dreamy + Eager + Electric + Fancy + Fearless + Fizzy + Fluffy + Funky + Gentle + Giddy + Glowing + Golden + Groovy + Happy + Humble + Jazzy + Jolly + Jumpy + Kind + Lively + Lucky + Lunar + Mellow + Merry + Mighty + Misty + Nimble + Peppy + Plucky + Polite + Quick + Quiet + Rapid + Rhythmic + Rosy + Silly + Sleepy + Smooth + Snappy + Soulful + Sparkly + Speedy + Spicy + Sunny + Swift + Tidy + Tiny + Vivid + Wavy + Whistling + Witty + Zany + Zesty + Zippy + + + Alpaca + Badger + Beaver + Bison + Bunny + Capybara + Cheetah + Chipmunk + Cobra + Corgi + Coyote + Crane + Dingo + Dolphin + Duckling + Eagle + Falcon + Ferret + Finch + Flamingo + Fox + Gazelle + Gecko + Giraffe + Hedgehog + Heron + Hippo + Ibis + Jaguar + Koala + Lemur + Llama + Lynx + Magpie + Manatee + Marmot + Meerkat + Mongoose + Moose + Narwhal + Ocelot + Octopus + Orca + Otter + Owl + Panda + Pangolin + Parrot + Pelican + Penguin + Puffin + Quokka + Raccoon + Raven + Robin + Seal + Sloth + Sparrow + Squirrel + Tapir + Tiger + Toucan + Walrus + Wombat + Go to album Sleep timer @@ -549,15 +681,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. + This room was opened again on this device, so this connection was closed. 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/audioplayer/AudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt index 51d47129..96989f17 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/AudioPlayerQueue.kt @@ -44,6 +44,12 @@ interface AudioPlayerQueue { suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? suspend fun getCollectionHistory(): List + /** + * Stops endless playback from adding recommendations when the queue runs out. A jam + * guest's queue belongs to the host; recommendations of its own would split them. + */ + fun setEndlessPlaybackSuppressed(suppressed: Boolean) = Unit + fun isPlaylistPlaying(playlistId: String? = null): Boolean { val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false return playlistId == null || entry.id == playlistId diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt index fb283055..ee7f5542 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/audioplayer/DeviceAudioPlayerQueue.kt @@ -57,19 +57,22 @@ class DeviceAudioPlayerQueue( private var isRestoringPersistedState = true + // Eagerly: callers read `.value` (the stream proxy looks up the track it's asked to play, + // Jam follows the room). With WhileSubscribed, `.value` went stale whenever nothing on + // screen was collecting, so the proxy answered "not in the queue" (404) for new tracks. override val queueFlow: StateFlow> = combine( audioPlayer.playlistFlow, entryByMediaUrl) { playlist, knownEntries -> playlist.map { mediaItem -> knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() } - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList()) + }.stateIn(scope, SharingStarted.Eagerly, emptyList()) override val currentQueueEntryFlow: StateFlow = combine( audioPlayer.currentMediaItemFlow, entryByMediaUrl) { mediaItem, knownEntries -> mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } - }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) + }.stateIn(scope, SharingStarted.Eagerly, null) override val currentCollectionEntryFlow: StateFlow = currentCollectionEntryState.asStateFlow() @@ -306,9 +309,14 @@ class DeviceAudioPlayerQueue( } private var isFetchingRecommendations = false + private var endlessPlaybackSuppressed = false + + override fun setEndlessPlaybackSuppressed(suppressed: Boolean) { + endlessPlaybackSuppressed = suppressed + } private suspend fun handleQueueCompletion() { - if (isFetchingRecommendations) return + if (isFetchingRecommendations || endlessPlaybackSuppressed) return val settings = settingsProvider.settingsState.value ?: return if (!settings.enableEndlessPlayback) return 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..98d487df 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,11 @@ 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(), + evictCachedAudio = get()::invalidateCacheEntry) + } 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/JamDevice.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt new file mode 100644 index 00000000..ebef9b60 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamDevice.kt @@ -0,0 +1,42 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 + +/** + * Stable traits of this device (model, OS version, locale, …) for jam bans. They never + * leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the + * server hashes that again with the network, so a room only ever holds an opaque value. + */ +internal expect fun jamDeviceTraits(): List + +/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */ +internal fun jamDeviceDigest(roomCode: String): String? = runCatching { + val traits = jamDeviceTraits().filter { it.isNotBlank() } + if (traits.isEmpty()) return null + "spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex() +}.getOrNull() + +/** + * This install's participant secret for room [roomCode], derived from the install's + * [deviceKey] (random, kept in settings). The same install gets the same identity in a + * room every time, so a ban sticks across rejoins; different rooms get unrelated keys. + */ +internal fun jamParticipantKey(deviceKey: String, roomCode: String): String = + "spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt new file mode 100644 index 00000000..bc35d56e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamNames.kt @@ -0,0 +1,52 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import okio.ByteString.Companion.encodeUtf8 +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.getStringArray +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_adjectives +import spotube.composeapp.generated.resources.jam_name_animals +import spotube.composeapp.generated.resources.jam_name_format + +/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */ +object JamNames { + /** The server's limit (`MAX_NAME_LENGTH` in the jam-server repository's `src/protocol.ts`). */ + const val MAX_LENGTH = 40 + + /** + * The generated name for [seed]. The same seed always gets the same name, so with this + * install's key a device keeps its name across sessions until the user picks their own. + */ + suspend fun generate(seed: String): String { + val adjectives = getStringArray(Res.array.jam_name_adjectives) + val animals = getStringArray(Res.array.jam_name_animals) + val hash = "spotube-jam-name:$seed".encodeUtf8().sha256() + val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)] + val animal = animals[pick(hash[2], hash[3], animals.size)] + return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH) + } + + /** Cleans a name the user typed; blank means "use the generated one". */ + fun normalize(input: String): String = + input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim() + + private fun pick(high: Byte, low: Byte, size: Int): Int = + (((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size +} 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..195e3fe3 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,82 @@ 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. */ +/** The audio source the host plays [trackKey] from. */ @Serializable -sealed class PlaybackCmd { +data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource) + +@Serializable +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 host's sources for the next songs. A player loads the next song long before it + * plays, so guests need the host's recording of it well before then. + */ + val upcoming: List = emptyList()) { + /** 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,7 +108,35 @@ data class JamMediaItem( val coverUrl: String, val protocol: String, val addedBy: String = "") { + /** Identifies the track across devices: the metadata id, or an opaque id for local files. */ + val key: String get() = trackId.ifBlank { url } + + /** + * This item as received from another participant, made safe to load: streaming tracks + * only (a peer's local file can't play here, and a URL would make this device fetch + * whatever a peer chose), https covers only, and bounded text. Null if unusable. + */ + fun sanitized(): JamMediaItem? { + if (trackId.isBlank() || trackId.length > MAX_ID_LENGTH) return null + return copy( + url = "", + title = title.take(MAX_TEXT_LENGTH), + artist = artist.take(MAX_TEXT_LENGTH), + album = album.take(MAX_TEXT_LENGTH), + durationMs = durationMs.coerceAtLeast(0), + coverUrl = coverUrl.takeIf { it.startsWith("https://") && it.length <= MAX_URL_LENGTH }.orEmpty(), + protocol = protocol.take(MAX_ID_LENGTH), + addedBy = addedBy.take(MAX_ID_LENGTH)) + } + companion object { + private const val MAX_ID_LENGTH = 200 + private const val MAX_TEXT_LENGTH = 300 + private const val MAX_URL_LENGTH = 2048 + + /** The cross-device key of a local file at [path], which doesn't reveal the path. */ + fun localTrackKey(path: String): String = "local:" + path.hashCode().toUInt().toString(16) + fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { is QueueEntry.StreamingTrack -> JamMediaItem( url = "", @@ -132,8 +151,9 @@ data class JamMediaItem( protocol = entry.protocol.name, addedBy = entry.addedBy) + // Only an opaque id: the path would show the host's folders (and user name) to everyone. is QueueEntry.LocalTrack -> JamMediaItem( - url = entry.url, + url = localTrackKey(entry.url), trackId = "", title = entry.name, artist = entry.artists.joinToString(", "), 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..5be59de1 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,81 @@ 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 (https://github.com/team-spotube/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 + + /** + * Our public participant id in the current room, as the server derived it from our + * secret key. Null until the first welcome. + */ + var selfId: String? = null + private set + private val pongs = MutableStateFlow(0) private val _isConnected = MutableStateFlow(false) val isConnected: StateFlow = _isConnected.asStateFlow() @@ -89,244 +132,429 @@ 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>(emptyMap()) - val presence: StateFlow> = _presence.asStateFlow() + private val _events = MutableSharedFlow(extraBufferCapacity = 64) + val events: SharedFlow = _events.asSharedFlow() // ---------- Public API ---------- - /** One-off connection check used by the settings screen. Returns latency description. */ - suspend fun testConnection(broker: JamBroker): Result { - 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 = 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. + * + * [clientKey] is this participant's secret ([jamParticipantKey]): reconnecting with + * it makes us the same participant, so it never leaves this device except to the + * server. [deviceDigest] ([jamDeviceDigest]) lets a ban cover this device too. + */ suspend fun connect( - broker: JamBroker, + serverUrl: String, code: String, - clientId: String, + clientKey: String, displayName: String, - isHost: Boolean): Result { + create: Boolean, + deviceDigest: String? = null): Result { 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) + clock.reset() + val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest) + val welcome = runCatching { open(opener, create) } + .onFailure { e -> + log.w(e) { "Failed to open jam room $code" } + _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) } - _connectionError.value = null - log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" } + .getOrElse { return Result.failure(it) } - mqtt.connectionState - .onEach { onConnectionStateChanged(it) } - .launchIn(scope) - Unit - }.onFailure { e -> - log.w(e) { "Failed to connect to jam broker" } - _connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed) - _isConnected.value = false - runCatching { client?.shutdown(sendDisconnect = true, drain = false) } - client = null + connectionJob = scope.launch { + var current = welcome.second + var attempt = 0 + while (isActive) { + val refusal = runCatching { current.await() }.getOrNull() + ensureActive() + _isConnected.value = false + 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(opener, create = false) } + // Everyone was gone long enough for the room to expire: its host brings it back. + if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && + hostId != null && hostId == selfId + ) { + log.i { "Jam room $code expired while offline; recreating it" } + reopened = runCatching { open(opener, create = true) } + } + val error = reopened.exceptionOrNull() + if (error is JamRoomException) { + fail(error.code) + return@launch + } + val (event, socket) = reopened.getOrNull() ?: continue + attempt = 0 + current = socket + _events.emit(event) + break + } + } } + return Result.success(welcome.first) } - suspend fun publishState(state: JamMessage.QueueState) { - 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 + selfId = 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. + */ + /** Everything needed to (re)open one room connection. */ + private data class Opener( + val serverUrl: String, + val code: String, + val clientKey: String, + val displayName: String, + val deviceDigest: String?) + + private suspend fun open(opener: Opener, create: Boolean): Pair> { + val code = opener.code + val socket = runCatching { http.webSocketSession(roomUrl(opener, 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)) + } + + session = socket + selfId = welcome.you + 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 ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" } + return welcome.toEvent() to socketReader + } catch (e: Throwable) { + runCatching { socket.close() } + throw e + } } - private fun onConnectionStateChanged(state: ConnectionState) { - when (state) { - is ConnectionState.Connected -> { - _isConnected.value = true - _connectionError.value = null - 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() - } - } - - ConnectionState.Disconnected, ConnectionState.Handshaking -> { - _isConnected.value = false - } - - else -> { - _isConnected.value = false - scope.launch { - val message = getString(Res.string.jam_error_connection_lost) - if (!_isConnected.value) _connectionError.value = message - } + 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 } } } - 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) + /** + * 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) } - runCatching { operation.subAck.await() } - .onFailure { log.w(it) { "Subscribe ack failed for room $code" } } + withTimeoutOrNull(CLOCK_SYNC_TIMEOUT_MS) { pongs.first { it >= target } } } - 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 + /** + * 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 + } - runCatching { - when { - topic.endsWith("/state") -> { - _state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text)) + /** + * 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 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 } - - topic.endsWith("/cmd") -> { - _commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text)) - } - - topic.contains("/presence/") -> { - val presence = json.decodeFromString(JamPresence.serializer(), text) - _presence.value = _presence.value + (presence.clientId to presence) + runCatching { socket.send(Frame.Text(KEEPALIVE_PING)) } + if (now() - lastClockSyncMs >= CLOCK_RESYNC_INTERVAL_MS) { + lastClockSyncMs = now() + runCatching { syncClock(socket, RESYNC_PINGS) } } } - }.onFailure { e -> - log.w(e) { "Failed to decode jam message on $topic: $text" } + } + return try { + block() + } finally { + keeper.cancel() } } - 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" } + /** Routes one frame; returns an error code when the server refused us. */ + private suspend fun handle(frame: Frame): String? { + if (frame is Frame.Text && frame.readText() == KEEPALIVE_PONG) return null + when (val decoded = decode(frame)) { + is ServerFrame.Pong -> { + clock.addSample(decoded.c, decoded.s, now()) + pongs.value++ + } + is ServerFrame.Welcome -> { + hostId = decoded.host + _events.emit(decoded.toEvent()) + } + is ServerFrame.Presence -> { + hostId = decoded.host + _events.emit(JamRoomEvent.Presence(decoded.host, decoded.participants.toParticipants(decoded.host))) + } + is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body)) + is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body)) + is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body)) + // Sent too much too fast (a burst of skips, say): not a refusal, just reconnect. + is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" } + null -> Unit + } + return null + } + + private suspend fun fail(code: String) { + val message = refusalMessage(code) + log.i { "Jam room closed by the server: $code" } + session = null + _connectionError.value = message + _events.emit(JamRoomEvent.Closed(code, message)) + } + + private suspend fun ping(socket: DefaultWebSocketSession) { + socket.send(Frame.Text(json.encodeToString(ClientFrame.serializer(), ClientFrame.Ping(++pingId, now())))) + } + + private fun send(frame: ClientFrame) { + val socket = session ?: return + val text = json.encodeToString(ClientFrame.serializer(), frame) + scope.launch { + runCatching { socket.send(Frame.Text(text)) } + .onFailure { log.w(it) { "Failed to send jam frame" } } } } - 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/#" + 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() + } + + 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 fun roomUrl(opener: Opener, create: Boolean) = + URLBuilder(opener.serverUrl.trim()).apply { + protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS + appendPathSegments("v1", "rooms", opener.code) + parameters.append("key", opener.clientKey) + parameters.append("name", opener.displayName) + parameters.append("mode", if (create) "create" else "join") + opener.deviceDigest?.let { parameters.append("dev", it) } + }.buildString() + + private fun healthUrl(serverUrl: String) = + URLBuilder(serverUrl.trim()).apply { + if (protocol == URLProtocol.WS) protocol = URLProtocol.HTTP + if (protocol == URLProtocol.WSS) protocol = URLProtocol.HTTPS + appendPathSegments("v1", "health") + }.buildString() + + private fun now(): Long = Clock.System.now().toEpochMilliseconds() + + companion object { + const val PROTOCOL_VERSION = 1 + private const val CONNECT_TIMEOUT_MS = 10_000L + private const val INITIAL_PINGS = 6 + private const val RESYNC_PINGS = 3 + private const val PING_SPACING_MS = 100L + private const val CLOCK_SYNC_TIMEOUT_MS = 3_000L + /** Clocks drift by a few ms per minute at most; this keeps sync error well under 50 ms. */ + private const val CLOCK_RESYNC_INTERVAL_MS = 10 * 60_000L + /** + * As rare as possible (each one wakes a phone's radio) while staying under the + * 100 s idle timeout of Cloudflare's proxy. A dropped idle socket would only cost + * a reconnect, so there's no need for margin beyond that. + */ + private const val KEEPALIVE_INTERVAL_MS = 60_000L + /** No pong to two keep-alives in a row: the connection is gone. */ + private const val DEAD_AFTER_MS = 2 * KEEPALIVE_INTERVAL_MS + 10_000L + private const val KEEPALIVE_PING = "ping" + private const val KEEPALIVE_PONG = "pong" + /** Quick at first (most drops are a network switch), then slower to spare the battery. */ + private val RECONNECT_BACKOFF_MS = longArrayOf(500, 1_000, 2_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 60_000) + } +} + +/** 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, + val queue: JamQueue?, + val anchor: JamAnchor?) : JamRoomEvent + + data class Presence(val host: String?, val participants: List) : 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 src/protocol.ts in https://github.com/team-spotube/jam-server) ---------- + +@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.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 = 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 = 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() } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt index f8d0f19b..3846e813 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomCode.kt @@ -17,11 +17,12 @@ package org.spotube.spotube.core.jam -import kotlin.random.Random +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid /** * Six-character room codes shared verbally / by text. Codes are opaque keys used - * 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. @@ -30,12 +31,23 @@ object JamRoomCode { const val LENGTH = 6 private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" - fun generate(): String = buildString(LENGTH) { - repeat(LENGTH) { - append(ALPHABET[Random.nextInt(ALPHABET.length)]) + /** From a cryptographically secure source: a predictable code would let others in. */ + @OptIn(ExperimentalUuidApi::class) + fun generate(): String { + val bytes = Uuid.random().toByteArray() + // 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform. + return buildString(LENGTH) { + repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) } } } + /** + * A participant's secret for one session, from a cryptographically secure source. + * The server derives the public participant id from it; only this device knows it. + */ + @OptIn(ExperimentalUuidApi::class) + fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString() + /** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */ fun normalize(input: String): String = input .uppercase() diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt index f317e6ec..90ea8e6b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/JamRoomService.kt @@ -26,53 +26,79 @@ import org.spotube.spotube.core.audioplayer.AudioPlayerInterface import org.spotube.spotube.core.audioplayer.AudioPlayerQueue import org.spotube.spotube.core.audioplayer.PlayerState import org.spotube.spotube.core.audioplayer.QueueEntry -import org.spotube.spotube.modules.settings.SettingsRepository +import org.spotube.spotube.core.server.PluginTaggedAudioSource +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.core.server.TrackSourceRepository +import org.spotube.spotube.modules.plugin.AudioPluginSource +import org.spotube.spotube.modules.settings.UserSettingsStore import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.conflate +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlin.random.Random +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.math.abs import org.jetbrains.compose.resources.getString import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.jam_error_invalid_code -import spotube.composeapp.generated.resources.jam_error_no_broker +import spotube.composeapp.generated.resources.jam_error_no_server import kotlin.time.Clock +import kotlin.time.Duration.Companion.milliseconds /** - * A jam session over MQTT (star topology, host-authoritative queue). + * A jam session: one host, any number of guests, all playing the same thing at the same + * moment. * - * Sync rules: - * - The queue list and current index are global. [skipNext]/[skipPrevious]/[jumpTo] - * from anyone are applied by the host, then broadcast via the retained state topic. - * - Play/pause is global: anyone's play/pause is sent to the host, which applies it - * and broadcasts it; guests follow every change of the host's play state. - * - Seek, volume and loop are local to each device — never broadcast. - * - Shuffle is host-only; guests mirror the host's shuffle setting. - * - Guests can only add to the queue (suggest); the host applies suggestions. - * - If the host leaves, the participant with the lowest client id takes over. + * Sync model: + * - The host owns the queue and broadcasts it ([JamQueue]), plus a playback [JamAnchor] + * ("track X was at position P at server time T") whenever playback changes. Times are + * in the room server's clock, which every device estimates with [JamClock], so guests + * extrapolate an anchor on their own; the host only re-anchors when its player drifts + * from its last anchor (and rarely, as a safety net). Rooms are billed per message, + * so nothing is sent periodically. + * - Battery: nothing polls while there's nothing to follow. A paused room, a buffering + * host or a missing queue puts the guest's sync loop to sleep until the room or the + * player changes, and the host only checks its own drift while playing. + * - Guests follow the anchor's track and play state, and keep their position within + * ~150 ms of the room by nudging their playback rate a few percent ([JamDriftCorrector]), + * seeking only when far off. + * - Anyone's play/pause, skip, jump and seek go to the host, which applies them for the + * whole room. Volume and loop stay local. + * - Guests stream each track from the host's audio source when they use the same audio + * plugin, so everyone hears the same recording (different uploads don't line up). + * - The server hands hosting to the longest-connected guest when the host leaves. */ -private data class PlaybackBroadcast( - val queue: List, - val current: QueueEntry?, - val shuffle: Boolean, - val isPlaying: Boolean) - class JamRoomService( private val jamClient: JamRoomClient, private val audioPlayer: AudioPlayerInterface, private val audioPlayerQueue: AudioPlayerQueue, - private val settingsRepository: SettingsRepository) { + private val settingsRepository: UserSettingsStore, + private val audioPluginSource: AudioPluginSource, + private val trackSources: TrackSourceRepository, + private val streamingUrlRepository: StreamingUrlRepository, + /** Deletes a track's audio from the disk cache (`CacheManager.invalidateCacheEntry`). */ + private val evictCachedAudio: suspend (trackId: String) -> Unit) { private val log = Logger.withTag("JamRoomService") private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -108,7 +134,6 @@ class JamRoomService( private val _shuffleEnabled = MutableStateFlow(false) val shuffleEnabled: StateFlow = _shuffleEnabled.asStateFlow() - private var localClientId: String = "" private var localDisplayName: String = "" /** Display name of the local participant. */ @@ -117,29 +142,54 @@ class JamRoomService( /** Stable id of the local participant (stamped on items this device adds). */ val participantClientId: String - get() = localClientId + get() = jamClient.selfId.orEmpty() - private var hostBroadcastJob: Job? = null + private var roleJob: Job? = null + private var leaving = false + /** Joining or in a room: room events apply (some arrive before join returns). */ + private var inRoom = false - /** Guest side: last queue snapshot applied to the local player. */ - private var lastAppliedItems: List = emptyList() - private var lastAppliedIndex = -1 - - /** - * Host side: a song completed, so index changes within this window are - * auto-advance (guests must not follow). The completion event, the player - * state change and the media transition arrive as separate flow emissions, - * so the window covers the whole sequence instead of a single flag. - */ - private var autoAdvanceDeadlineMs = 0L - - /** Guest side: the room's play state from the last host broadcast (null until the first one). */ + /** Room-wide play state from the last anchor (null until the first one). */ private var lastHostIsPlaying: Boolean? = null - /** Host side: client ids banned for this session. */ - private val bannedClientIds = mutableSetOf() + /** Our playback speed before we joined, restored when sync stops changing it. */ + private var speedBeforeJam: Float? = null - private var leaving = false + // Latest room state from the host (kept in any role, applied while a guest) + private var roomQueue: JamQueue? = null + private var anchor: JamAnchor? = null + private var lastAppliedItems: List = emptyList() + private val corrector = JamDriftCorrector() + private val syncMutex = Mutex() + private var appliedRate: Float? = null + /** How far ahead to aim a seek so playback resumes on time; learned from each seek. */ + private var seekLeadMs = INITIAL_SEEK_LEAD_MS + private var lastCorrectionWasSeek = false + /** Don't correct before this (a seek or track change is still settling). */ + private var holdSyncUntilMs = 0L + private val pinnedSources = mutableSetOf() + /** Bumped on every anchor and queue from the host; wakes an idle guest sync loop. */ + private val roomVersion = MutableStateFlow(0) + + // Position tracking, used to extrapolate the player's coarse position reports + // and to tell user seeks apart from playback. + private var positionSampleMs = 0L + private var positionSampledAtMs = 0L + private var positionAdvancing = false + private var ownSeekUntilMs = 0L + private var entryChangedAtMs = 0L + /** The song [positionSampleMs] belongs to; a new song starts the tracking from zero. */ + private var positionEntryKey: String? = null + + /** Host: its sources for the next songs, sent with every anchor. */ + private var upcomingSources: List = emptyList() + + private val anchorRequests = Channel(Channel.CONFLATED) + + // The host's last anchor, to notice its own player drifting away from it. + private var sentAnchor: JamAnchor? = null + private var sentAnchorAtMs = 0L + private val hostDrifts = ArrayDeque() init { jamClient.isConnected @@ -148,19 +198,23 @@ class JamRoomService( jamClient.connectionError .onEach { _connectionError.value = it } .launchIn(scope) - jamClient.state - .onEach { onRemoteState(it) } + jamClient.events + .onEach { onEvent(it) } .launchIn(scope) - jamClient.commands - .onEach { onCommand(it) } + audioPlayerQueue.currentQueueEntryFlow + .onEach { onEntryChanged(it?.jamKey()) } .launchIn(scope) - jamClient.presence - .onEach { onPresence(it) } + audioPlayer.positionFlow + .onEach { onPosition(it.inWholeMilliseconds) } .launchIn(scope) - // A naturally-completed song means the host's next index change is an - // auto-advance — guests must NOT follow those, only manual skips. - audioPlayer.completionFlow - .onEach { autoAdvanceDeadlineMs = now() + AUTO_ADVANCE_WINDOW_MS } + // Extrapolation restarts from wherever the player was when it started or stopped + // moving. (Not from positionFlow's value: a seek's report must stay a jump for onPosition.) + audioPlayer.playerStateFlow + .onEach { state -> + positionSampleMs = localPositionMs() + positionSampledAtMs = now() + positionAdvancing = state == PlayerState.PLAYING + } .launchIn(scope) // A guest's own play/pause (UI, notification, headset) goes to the host, which // applies it for the whole room. Intent that matches the room's state came from @@ -169,7 +223,7 @@ class JamRoomService( .onEach { playing -> val roomPlaying = lastHostIsPlaying if (_role.value == JamRole.Guest && !leaving && roomPlaying != null && playing != roomPlaying) { - publishCommand(PlaybackCmd.SetPlaying(playing)) + jamClient.sendCommand(JamCommand.SetPlaying(playing)) } } .launchIn(scope) @@ -178,95 +232,103 @@ class JamRoomService( // ---------- Session lifecycle ---------- suspend fun createRoom(): Result { - val broker = settingsRepository.userSettings.value.jamBroker - if (broker.host.isBlank()) { - return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) - } - val code = JamRoomCode.generate() - localClientId = newClientId(broker) - val name = participantName("Host") - localDisplayName = name + val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server))) + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) + enter() - return jamClient.connect( - broker = broker, - code = code, - clientId = localClientId, - displayName = name, - isHost = true).map { - _role.value = JamRole.Host - _roomCode.value = code - _participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) - lastAppliedItems = emptyList() - lastAppliedIndex = -1 - bannedClientIds.clear() - leaving = false - autoAdvanceDeadlineMs = 0L - lastHostIsPlaying = null - startHostBroadcast() - persistLastCode(code) - code + // A fresh code is almost never taken; retry the rare collision with another one. + var result: Result + var code: String + var attempts = 0 + do { + code = JamRoomCode.generate() + result = jamClient.connect( + serverUrl, code, jamParticipantKey(deviceKey, code), localDisplayName, create = true, + deviceDigest = jamDeviceDigest(code)) + val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken" + } while (taken && ++attempts < CREATE_ATTEMPTS) + + val welcome = result.getOrElse { + inRoom = false + return Result.failure(it) } + start(code, welcome) + return Result.success(code) } suspend fun joinRoom(code: String): Result { - val broker = settingsRepository.userSettings.value.jamBroker - if (broker.host.isBlank()) { - return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_broker))) - } + val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server))) val normalized = JamRoomCode.normalize(code) if (!JamRoomCode.isValid(normalized)) { return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code))) } - localClientId = newClientId(broker) - val name = participantName("Guest") - localDisplayName = name + val deviceKey = deviceKey() + localDisplayName = participantName(deviceKey) + enter() - return jamClient.connect( - broker = broker, - code = normalized, - clientId = localClientId, - displayName = name, - isHost = false).map { - _role.value = JamRole.Guest - _roomCode.value = normalized - _participants.value = emptyList() - lastAppliedItems = emptyList() - lastAppliedIndex = -1 - leaving = false - lastHostIsPlaying = null - persistLastCode(normalized) - } + val welcome = jamClient.connect( + serverUrl, normalized, jamParticipantKey(deviceKey, normalized), localDisplayName, create = false, + deviceDigest = jamDeviceDigest(normalized)) + .getOrElse { + inRoom = false + return Result.failure(it) + } + start(normalized, welcome) + return Result.success(Unit) } suspend fun leaveRoom() { leaving = true - stopHostBroadcast() - runCatching { jamClient.leavePresence() } jamClient.disconnect() + reset() + } + + private fun enter() { + inRoom = true + roomQueue = null + anchor = null + } + + private suspend fun start(code: String, welcome: JamRoomEvent.Welcome) { + leaving = false + lastHostIsPlaying = null + speedBeforeJam = audioPlayer.playbackSpeedFlow.value + _roomCode.value = code + _connectionError.value = null + persistLastCode(code) + // Frames that arrived while the clock synced are newer than the welcome's snapshot. + onWelcome(welcome.copy(queue = roomQueue ?: welcome.queue, anchor = anchor ?: welcome.anchor)) + } + + private suspend fun reset() { + inRoom = false + audioPlayerQueue.setEndlessPlaybackSuppressed(false) + upcomingSources = emptyList() + roleJob?.cancel() + roleJob = null + restoreSpeed() _role.value = null _roomCode.value = null _participants.value = emptyList() _shuffleEnabled.value = false _isConnected.value = false + roomQueue = null + anchor = null lastAppliedItems = emptyList() - lastAppliedIndex = -1 - bannedClientIds.clear() lastHostIsPlaying = null + pinnedSources.clear() + corrector.reset() + speedBeforeJam = null } // ---------- Controls (called from the UI) ---------- - fun skipNext() { - publishCommand(PlaybackCmd.SkipNext) - } + fun skipNext() = control(JamCommand.SkipNext) - fun skipPrevious() { - publishCommand(PlaybackCmd.SkipPrevious) - } + fun skipPrevious() = control(JamCommand.SkipPrevious) - fun jumpTo(index: Int) { - publishCommand(PlaybackCmd.JumpTo(index)) - } + fun jumpTo(index: Int) = control(JamCommand.JumpTo(index)) /** Host-only. Applied locally; the queue broadcast carries the new shuffle flag. */ fun toggleShuffle() { @@ -277,241 +339,602 @@ class JamRoomService( } suspend fun suggestTrack(track: MetadataTrack) { - jamClient.publishCommand( - JamMessage.SuggestTrack( - mediaItem = JamMediaItem.fromTrack(track), - addedBy = localClientId) - ) + jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = participantClientId)) } suspend fun suggestPlaylist(tracks: List) { if (tracks.isEmpty()) return - jamClient.publishCommand( - JamMessage.SuggestPlaylist( - tracks = tracks.map(JamMediaItem::fromTrack), - addedBy = localClientId) - ) + jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = participantClientId)) } - suspend fun kickParticipant(participantId: String, reason: String = "kicked by host") { + suspend fun kickParticipant(participantId: String) { if (_role.value != JamRole.Host) return - jamClient.publishCommand(JamMessage.Kick(participantId, reason)) + jamClient.kick(participantId, ban = false) } suspend fun banParticipant(participantId: String) { if (_role.value != JamRole.Host) return - bannedClientIds += participantId - kickParticipant(participantId, "banned by host") + jamClient.kick(participantId, ban = true) } - // ---------- Host: broadcast ---------- - - private fun startHostBroadcast() { - if (hostBroadcastJob?.isActive == true) return - hostBroadcastJob = scope.launch { - combine( - audioPlayerQueue.queueFlow, - audioPlayerQueue.currentQueueEntryFlow, - audioPlayer.shuffleModeFlow, - playIntent) { queue, current, shuffle, isPlaying -> - PlaybackBroadcast(queue, current, shuffle, isPlaying) - } - .onEach { broadcast -> - if (_role.value != JamRole.Host) return@onEach - val index = if (broadcast.current != null) { - broadcast.queue.indexOfFirst { it.matchesEntry(broadcast.current) } - } else { - -1 - } - _shuffleEnabled.value = broadcast.shuffle - jamClient.publishState( - JamMessage.QueueState( - items = broadcast.queue.map(JamMediaItem::fromQueueEntry), - currentIndex = index.coerceAtLeast(0), - shuffleEnabled = broadcast.shuffle, - follow = now() > autoAdvanceDeadlineMs, - isPlaying = broadcast.isPlaying) - ) - } - .launchIn(this) + /** The host applies its own controls; a guest's go to the host. */ + private fun control(command: JamCommand) { + scope.launch { + if (_role.value == JamRole.Host) applyCommand(command, from = participantClientId) else jamClient.sendCommand(command) } } - private fun stopHostBroadcast() { - hostBroadcastJob?.cancel() - hostBroadcastJob = null + // ---------- Room events ---------- + + private suspend fun onEvent(event: JamRoomEvent) { + if (!inRoom) return + when (event) { + is JamRoomEvent.Welcome -> onWelcome(event) + is JamRoomEvent.Presence -> { + onMembership(event.host, event.participants) + // The server keeps anchors in memory only; a joiner of a room that was + // asleep gets none in its welcome, so the host answers every join. + if (_role.value == JamRole.Host) publishAnchor() + } + is JamRoomEvent.Queue -> { + roomQueue = event.queue + if (_role.value == JamRole.Guest) applyQueue(event.queue) + } + is JamRoomEvent.Anchor -> { + anchor = event.anchor + if (_role.value == JamRole.Guest) onAnchor(event.anchor) + } + // [JamRoomEvent.Command.from] is the server's word for who sent it; the body is the sender's. + is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command, event.from) + is JamRoomEvent.Closed -> { + log.i { "Left jam room: ${event.code}" } + reset() + _connectionError.value = event.message + } + } } - // ---------- Guest: apply remote state ---------- + /** First join and every reconnect: the server's view of the room replaces ours. */ + private fun onWelcome(welcome: JamRoomEvent.Welcome) { + welcome.queue?.let { roomQueue = it } + welcome.anchor?.let { anchor = it } + onMembership(welcome.host, welcome.participants, force = true) + } - private suspend fun onRemoteState(state: JamMessage.QueueState) { - if (_role.value != JamRole.Guest) return - if (leaving) return + private fun onMembership(host: String?, participants: List, force: Boolean = false) { + _participants.value = participants + val role = if (host != null && host == jamClient.selfId) JamRole.Host else JamRole.Guest + // A (re)welcome restarts the role so a guest re-applies the room's state. + if (role == _role.value && !force) return + log.i { "Jam role: ${_role.value} -> $role" } + _role.value = role + roleJob?.cancel() + roleJob = scope.launch { + if (role == JamRole.Host) runHost() else runGuest() + } + } - _shuffleEnabled.value = state.shuffleEnabled - runCatching { audioPlayer.shuffle(state.shuffleEnabled) } + // ---------- Host ---------- - // Play/pause is room-wide: follow every change of the host's play state. The room + private suspend fun runHost() { + restoreSpeed() + audioPlayerQueue.setEndlessPlaybackSuppressed(false) + sentAnchor = null + upcomingSources = emptyList() + kotlinx.coroutines.coroutineScope { + combine(audioPlayerQueue.queueFlow, audioPlayer.shuffleModeFlow) { queue, shuffle -> + JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle) + } + .distinctUntilChanged() + // At most one upload a second (the server's limit), always the latest queue. + .conflate() + .onEach { queue -> + _shuffleEnabled.value = queue.shuffleEnabled + // After a reconnect the server already has it: skip the re-upload. + if (queue != roomQueue) { + roomQueue = queue + jamClient.sendQueue(queue) + delay(QUEUE_MIN_GAP_MS) + } + } + .launchIn(this) + + // Anchors are built when sent, so coalescing a burst of requests (rapid skips) + // loses nothing; it keeps the host under the server's frame budget. + launch { + for (request in anchorRequests) { + sendAnchor() + delay(ANCHOR_MIN_GAP_MS) + } + } + + combine( + audioPlayerQueue.currentQueueEntryFlow, + playIntent, + audioPlayer.playerStateFlow, + audioPlayer.playbackSpeedFlow) { _, _, _, _ -> } + .onEach { publishAnchor() } + .launchIn(this) + + // Share the next songs' sources as soon as a song starts: a guest's player loads + // the next song long before it plays, and must find the host's recording by then. + launch { + combine( + audioPlayerQueue.currentQueueEntryFlow.map { it?.jamKey() }.distinctUntilChanged(), + audioPlayerQueue.queueFlow) { key, queue -> key to queue } + .collectLatest { (key, queue) -> + val start = queue.indexOfFirst { it.jamKey() == key } + 1 + if (start <= 0) return@collectLatest + val upcoming = queue.drop(start).take(UPCOMING_SOURCES) + .filterIsInstance() + .mapNotNull { entry -> + hostSource(entry.track, match = true)?.let { JamTrackSource(entry.track.id, it) } + } + if (upcoming != upcomingSources) { + upcomingSources = upcoming + publishAnchor() + } + } + } + + // Drift only builds up while playing; a paused host checks nothing. + launch { + while (isActive) { + audioPlayer.playerStateFlow.first { it == PlayerState.PLAYING } + delay(HOST_DRIFT_CHECK_INTERVAL_MS) + checkHostDrift() + } + } + } + } + + /** + * Guests extrapolate the last anchor with the shared clock, so it stays valid as long + * as the host's player keeps pace with its own clock. When it doesn't (audio clock + * drift, a stall the player didn't report as buffering), re-anchor. Readings are + * smoothed like a guest's, since players report position coarsely. + */ + private suspend fun checkHostDrift() { + val last = sentAnchor ?: return + val now = now() + if (now - sentAnchorAtMs >= HOST_ANCHOR_HEARTBEAT_MS) { + publishAnchor() + return + } + if (!last.advancing || audioPlayer.playerStateFlow.value != PlayerState.PLAYING) { + hostDrifts.clear() + return + } + hostDrifts.addLast(localPositionMs() - last.positionAt(jamClient.clock.serverTime(now))) + while (hostDrifts.size > HOST_DRIFT_WINDOW) hostDrifts.removeFirst() + val smoothed = hostDrifts.sorted()[hostDrifts.size / 2] + if (hostDrifts.size == HOST_DRIFT_WINDOW && + abs(smoothed) > HOST_REANCHOR_DRIFT_MS && + now - sentAnchorAtMs >= HOST_REANCHOR_MIN_INTERVAL_MS + ) { + log.d { "Jam host drifted ${smoothed}ms from its anchor; re-anchoring" } + publishAnchor() + } + } + + /** Asks the host loop to send a fresh anchor (coalesced, see [runHost]). */ + private fun publishAnchor() { + anchorRequests.trySend(Unit) + } + + private suspend fun sendAnchor() { + if (_role.value != JamRole.Host) return + val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return + onEntryChanged(current.jamKey()) + val playing = playIntent.value + val next = JamAnchor( + trackKey = current.jamKey(), + index = audioPlayerQueue.queueFlow.value.indexOfFirst { it.jamKey() == current.jamKey() }, + positionMs = localPositionMs(), + serverTimeMs = jamClient.clock.serverTime(now()), + playing = playing, + advancing = playing && audioPlayer.playerStateFlow.value == PlayerState.PLAYING, + rate = audioPlayer.playbackSpeedFlow.value, + source = (current as? QueueEntry.StreamingTrack)?.let { hostSource(it.track, match = false) }, + upcoming = upcomingSources) + sentAnchor = next + sentAnchorAtMs = now() + hostDrifts.clear() + jamClient.sendAnchor(next) + } + + /** + * The source the host's audio plugin matched to [track], so guests can use the same one. + * With [match], a track not matched yet is matched now (as the player would when it got + * there); the current song is being matched by the player already. + */ + private suspend fun hostSource(track: MetadataTrack, match: Boolean): PluginTaggedAudioSource? { + val plugin = audioPluginSource.selectedAudioPlugin.value ?: return null + suspend fun saved() = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull() + val source = saved() ?: if (match) { + runCatching { streamingUrlRepository.resolveStreamInfo(track) } + saved() + } else { + null + } + return source?.let { PluginTaggedAudioSource(plugin.pluginId, it) } + } + + private suspend fun applyCommand(command: JamCommand, from: String) { + runCatching { + when (command) { + // Applied locally; the anchor carries the change to every guest. + is JamCommand.SetPlaying -> if (command.playing) audioPlayer.play() else audioPlayer.pause() + JamCommand.SkipNext -> audioPlayer.skipToNext() + JamCommand.SkipPrevious -> audioPlayer.skipToPrevious() + is JamCommand.JumpTo -> audioPlayer.jumpTo(command.index) + is JamCommand.SeekTo -> { + seek(command.positionMs) + publishAnchor() + } + is JamCommand.Suggest -> acceptSuggestion(command.items, from) + } + }.onFailure { log.w(it) { "Failed to apply jam command $command" } } + } + + /** Suggestions come from other devices: only clean streaming items, credited to the actual sender. */ + private suspend fun acceptSuggestion(items: List, from: String) { + val accepted = items.asSequence() + .mapNotNull { it.sanitized() } + .take(MAX_SUGGESTED_ITEMS) + .map { it.copy(addedBy = from) } + .toList() + if (accepted.isEmpty()) return + log.i { "Accepting ${accepted.size} suggested item(s) into the jam queue" } + audioPlayerQueue.addAllToQueue(accepted.map { it.toQueueEntry() }) + } + + // ---------- Guest ---------- + + private suspend fun runGuest() { + // Guests follow the host's order through anchors; a local shuffle would only + // make auto-advance pick a different track for a moment. + runCatching { audioPlayer.shuffle(false) } + audioPlayerQueue.setEndlessPlaybackSuppressed(true) + corrector.reset() + // The host's recordings first, so the queue's first load already uses them. + anchor?.let { pinSources(it) } + roomQueue?.let { applyQueue(it) } + anchor?.let { onAnchor(it) } + while (true) { + // Captured before the check, so a change during it still wakes the wait below. + val seenRoom = roomVersion.value + val seenState = audioPlayer.playerStateFlow.value + val seenQueue = audioPlayerQueue.queueFlow.value + val status = syncMutex.withLock { + runCatching { syncOnce() } + .onFailure { log.w(it) { "Jam sync failed" } } + .getOrDefault(SyncStatus.Busy) + } + when (status) { + // While playing the CPU is awake for audio anyway, so a fast check is free + // and catches drift early (slower checks made seeks settle visibly later). + SyncStatus.Busy, SyncStatus.InSync -> delay(SYNC_INTERVAL_MS) + // Sleep until the room or our player changes (the timeout is a safety net). + SyncStatus.Idle -> withTimeoutOrNull(IDLE_RECHECK_MS) { + merge( + roomVersion.filter { it != seenRoom }, + audioPlayer.playerStateFlow.filter { it != seenState }, + audioPlayerQueue.queueFlow.filter { it !== seenQueue }).first() + } + } + } + } + + private enum class SyncStatus { + /** Correcting or settling: check again soon. */ + Busy, + /** Playing in sync: drift builds up slowly. */ + InSync, + /** Nothing to follow until the room or the player changes. */ + Idle, + } + + private suspend fun onAnchor(next: JamAnchor) { + anchor = next + roomVersion.value++ + // Play/pause is room-wide: follow every change of the room's play state. The room // state is recorded first, so the resulting local intent isn't sent back as a command. - if (state.isPlaying != lastHostIsPlaying) { - lastHostIsPlaying = state.isPlaying - if (state.isPlaying != playIntent.value) { - runCatching { if (state.isPlaying) audioPlayer.play() else audioPlayer.pause() } + if (next.playing != lastHostIsPlaying) { + lastHostIsPlaying = next.playing + if (next.playing != playIntent.value) { + runCatching { if (next.playing) audioPlayer.play() else audioPlayer.pause() } .onFailure { log.w(it) { "Failed to follow the host's play state" } } } } + pinSources(next) + } - val items = state.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() } + private suspend fun pinSources(anchor: JamAnchor) { + anchor.source?.let { pinSource(anchor.trackKey, it) } + anchor.upcoming.forEach { pinSource(it.trackKey, it.source) } + } - if (items != lastAppliedItems) { - val previous = lastAppliedItems - lastAppliedItems = items - val guestCurrent = audioPlayerQueue.currentQueueEntryFlow.value - val guestIndex = items.indexOfFirst { it.matchesEntry(guestCurrent) } + private suspend fun applyQueue(queue: JamQueue) { + roomVersion.value++ + _shuffleEnabled.value = queue.shuffleEnabled + // The host's local files can't play here, and anything else is cleaned first. + val items = queue.items.mapNotNull { it.sanitized() } + if (items == lastAppliedItems) return + val previous = lastAppliedItems + lastAppliedItems = items + // Sources that arrived before the queue had their tracks, before the queue loads them. + anchor?.let { pinSources(it) } - // The host only appended items (e.g. accepted suggestions): merge - // them in without resetting playback or the guest's position. - if (previous.isNotEmpty() && items.size > previous.size && - items.take(previous.size) == previous && guestIndex >= 0 - ) { - lastAppliedIndex = guestIndex - val appended = items.drop(previous.size) - runCatching { - audioPlayerQueue.addAllToQueue(appended.map { it.toQueueEntry() }) - }.onFailure { log.w(it) { "Failed to append jam queue items" } } - return - } - - // Full re-sync. Keep the guest's current track when it still exists - // in the synced queue; otherwise take the host's position. - val startIndex = if (guestIndex >= 0) guestIndex - else state.currentIndex.coerceIn(0, items.lastIndex.coerceAtLeast(0)) - lastAppliedIndex = startIndex - runCatching { - audioPlayerQueue.load( - entries = items.map { it.toQueueEntry() }, - autoPlay = state.isPlaying, - startPosition = startIndex) - }.onFailure { log.w(it) { "Failed to apply jam queue" } } + // The host only appended items (e.g. accepted suggestions): merge them in + // without disturbing playback. + val current = audioPlayerQueue.currentQueueEntryFlow.value + if (previous.isNotEmpty() && items.size > previous.size && + items.take(previous.size) == previous && current != null + ) { + runCatching { audioPlayerQueue.addAllToQueue(items.drop(previous.size).map { it.toQueueEntry() }) } + .onFailure { log.w(it) { "Failed to append jam queue items" } } return } - // Same queue content: only follow the host when it moved manually - // (skip/jump). Natural auto-advance keeps everyone where they are. - if (state.follow && state.currentIndex != lastAppliedIndex) { - lastAppliedIndex = state.currentIndex - runCatching { - audioPlayerQueue.jumpTo(state.currentIndex.coerceAtLeast(0), autoPlay = false) - }.onFailure { log.w(it) { "Failed to follow jam queue index" } } - } - } - - // ---------- Incoming commands ---------- - - private suspend fun onCommand(message: JamMessage) { - when (message) { - is JamMessage.PlaybackCommand -> { - if (_role.value != JamRole.Host) return - applyCommand(message.command) - } - - is JamMessage.SuggestTrack -> { - if (_role.value == JamRole.Host) { - acceptSuggestion(listOf(message.mediaItem.copy(addedBy = message.addedBy))) - } - } - - is JamMessage.SuggestPlaylist -> { - if (_role.value == JamRole.Host) { - acceptSuggestion(message.tracks.map { it.copy(addedBy = message.addedBy) }) - } - } - - is JamMessage.Kick -> { - if (_role.value == JamRole.Guest && message.participantId == localClientId) { - log.i { "Kicked from jam room: ${message.reason}" } - leaveRoom() - } - } - - else -> Unit - } - } - - private suspend fun applyCommand(command: PlaybackCmd) { - when (command) { - // Applied locally; the host broadcast carries the new play state to every guest - is PlaybackCmd.SetPlaying -> runCatching { - if (command.playing) audioPlayer.play() else audioPlayer.pause() - } - PlaybackCmd.SkipNext -> runCatching { audioPlayer.skipToNext() } - PlaybackCmd.SkipPrevious -> runCatching { audioPlayer.skipToPrevious() } - is PlaybackCmd.JumpTo -> runCatching { audioPlayer.jumpTo(command.index) } - } - } - - private suspend fun acceptSuggestion(items: List) { - if (items.isEmpty()) return - log.i { "Accepting ${items.size} suggested item(s) into the jam queue" } + val room = anchor + val startIndex = room?.let { a -> items.indexOfFirst { it.key == a.trackKey } }?.takeIf { it >= 0 } + ?: items.indexOfFirst { it.key == current?.jamKey() }.takeIf { it >= 0 } + ?: 0 + holdSync(TRACK_CHANGE_HOLD_MS) runCatching { - audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() }) + audioPlayerQueue.load( + entries = items.map { it.toQueueEntry() }, + autoPlay = room?.playing ?: false, + startPosition = startIndex) + }.onFailure { log.w(it) { "Failed to apply jam queue" } } + } + + /** One step of following the room: right track, then right position. */ + private suspend fun syncOnce(): SyncStatus { + if (_role.value != JamRole.Guest || leaving) return SyncStatus.Idle + val room = anchor ?: return SyncStatus.Idle + val now = now() + if (now < holdSyncUntilMs) return SyncStatus.Busy + + val current = audioPlayerQueue.currentQueueEntryFlow.value + onEntryChanged(current?.jamKey()) + if (current?.jamKey() != room.trackKey) { + val queue = audioPlayerQueue.queueFlow.value + val index = queue.indexOfFirst { it.jamKey() == room.trackKey } + if (index < 0) return SyncStatus.Idle // The queue with this track hasn't arrived yet. + if (finishedSongFirst(queue, current, index, room, now)) return SyncStatus.Busy + log.d { "Jam sync: switching to queue index $index" } + holdSync(TRACK_CHANGE_HOLD_MS) + corrector.reset() + audioPlayerQueue.jumpTo(index, autoPlay = room.playing) + return SyncStatus.Busy + } + + val expected = room.positionAt(jamClient.clock.serverTime(now)) + val actual = localPositionMs() + + if (!room.playing) { + // Paused room: line up once, so everyone resumes from the same spot. + if (abs(expected - actual) > PAUSED_ALIGN_THRESHOLD_MS) { + seek(expected) + return SyncStatus.Busy + } + return SyncStatus.Idle + } + // A buffering host isn't moving; guests wait for its next anchor instead of + // chasing a position that is about to be corrected. A buffering guest wakes + // up when its player starts playing. + if (!room.advancing || audioPlayer.playerStateFlow.value != PlayerState.PLAYING) return SyncStatus.Idle + val durationMs = audioPlayer.durationFlow.value.inWholeMilliseconds + if (expected < 0 || (durationMs > 0 && expected >= durationMs - END_GUARD_MS)) return SyncStatus.Busy + + val drift = expected - actual + if (lastCorrectionWasSeek) { + // Whatever is left after a seek is how far off the lead was. + seekLeadMs = (seekLeadMs + drift / 2).coerceIn(0L, MAX_SEEK_LEAD_MS) + lastCorrectionWasSeek = false + } + + return when (val correction = corrector.correct(expected, actual, room.rate, seekLeadMs)) { + JamCorrection.None -> { + // Exactly the room's rate (normally 1x): any other rate makes the player + // time-stretch, which costs CPU for as long as it lasts. + setRate(room.rate) + SyncStatus.InSync + } + is JamCorrection.Nudge -> { + setRate(correction.rate) + SyncStatus.Busy + } + is JamCorrection.Seek -> { + log.d { "Jam sync: ${drift}ms off, seeking (lead ${seekLeadMs}ms)" } + setRate(room.rate) + seek(correction.positionMs) + holdSync(SEEK_HOLD_MS) + lastCorrectionWasSeek = true + SyncStatus.Busy + } } } - // ---------- Presence & host takeover ---------- + /** + * Our song ended a moment before the host's (players and recordings differ slightly in + * length), so we're on the song after the room's. Wait for the host to get there + * instead of going back to replay the ending. The room has to be near the end of its + * song: a host that went back a song is followed right away. + */ + private fun finishedSongFirst( + queue: List, + current: QueueEntry?, + roomIndex: Int, + room: JamAnchor, + now: Long): Boolean { + if (!room.playing || current == null) return false + if (queue.indexOfFirst { it.jamKey() == current.jamKey() } != roomIndex + 1) return false + if (now - entryChangedAtMs > NEXT_SONG_WAIT_MS) return false + val roomSongMs = roomQueue?.items?.firstOrNull { it.key == room.trackKey }?.durationMs + ?: (queue[roomIndex] as? QueueEntry.StreamingTrack)?.track?.durationMs + ?: return false + return roomSongMs > 0 && room.positionAt(jamClient.clock.serverTime(now)) >= roomSongMs - NEAR_SONG_END_MS + } - private fun onPresence(all: Map) { - if (_role.value == null) return - val live = all.values.filter { !it.left } - _participants.value = live - .sortedBy { it.clientId } - .map { JamParticipant(it.clientId, it.displayName, it.isHost) } + /** + * Uses the host's audio source for [trackKey] when we use the same audio plugin, so + * both sides stream the same recording. Uploads of one song differ in intro length, + * which no amount of clock sync can fix. Upcoming songs are pinned well before our + * player loads them; a song already playing from another recording is reloaded. + */ + private suspend fun pinSource(trackKey: String, tagged: PluginTaggedAudioSource) { + val pin = "$trackKey|${tagged.source.id}" + if (pin in pinnedSources) return + val plugin = audioPluginSource.selectedAudioPlugin.value ?: return + if (plugin.pluginId != tagged.pluginId) return + // Not in the room's queue yet: tried again when the queue arrives (applyQueue). + val track = roomTrack(trackKey) ?: return + pinnedSources += pin + val mine = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull() + if (mine == tagged.source) return - // Host-side: auto-kick banned participants that rejoin. - if (_role.value == JamRole.Host) { - live.filter { it.clientId in bannedClientIds }.forEach { banned -> - scope.launch { kickParticipant(banned.clientId, "banned by host") } + log.i { "Jam: using the host's source ${tagged.source.id} for $trackKey" } + runCatching { + trackSources.saveTrackSource(track, tagged.source, plugin.pluginId) + streamingUrlRepository.invalidateCachedStreamUrl(trackKey) + // A cached copy of another recording would be played instead. + evictCachedAudio(trackKey) + if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) reloadForNewSource(trackKey) + }.onFailure { log.w(it) { "Failed to switch to the host's audio source" } } + } + + /** [trackKey]'s track, from our queue or (before it has loaded) the room's. */ + private fun roomTrack(trackKey: String): MetadataTrack? = + audioPlayerQueue.queueFlow.value + .firstNotNullOfOrNull { (it as? QueueEntry.StreamingTrack)?.track?.takeIf { t -> t.id == trackKey } } + ?: roomQueue?.items?.firstOrNull { it.trackId == trackKey }?.sanitized() + ?.let { (it.toQueueEntry() as? QueueEntry.StreamingTrack)?.track } + + /** + * Plays the current song again from the newly pinned source. Jumping to the song that's + * playing only seeks within what's already loaded on Android (ExoPlayer), so the queue + * is reloaded at this song instead. + */ + private suspend fun reloadForNewSource(trackKey: String) = syncMutex.withLock { + val queue = audioPlayerQueue.queueFlow.value + val index = queue.indexOfFirst { it.jamKey() == trackKey }.takeIf { it >= 0 } ?: return@withLock + holdSync(TRACK_CHANGE_HOLD_MS) + positionEntryKey = null + audioPlayerQueue.load(queue, autoPlay = anchor?.playing ?: true, startPosition = index) + onEntryChanged(trackKey) + } + + private suspend fun setRate(rate: Float) { + val rounded = (rate * 200).toInt() / 200f + if (appliedRate == rounded) return + // Only while playing: on iOS a non-zero rate also starts playback. + if (audioPlayer.playerStateFlow.value != PlayerState.PLAYING) return + runCatching { audioPlayer.setPlaybackSpeed(rounded) } + .onSuccess { appliedRate = rounded } + } + + private suspend fun restoreSpeed() { + if (appliedRate == null) return + appliedRate = null + val speed = speedBeforeJam ?: 1f + runCatching { audioPlayer.setPlaybackSpeed(speed) } + } + + private fun holdSync(durationMs: Long) { + holdSyncUntilMs = maxOf(holdSyncUntilMs, now() + durationMs) + } + + // ---------- Position tracking ---------- + + /** + * A new song starts from zero. Without this, the old song's last position would carry + * over: a song that ends on its own doesn't make the player report a jump like a skip + * does, and its next report can still be the old song's. + */ + private fun onEntryChanged(key: String?) { + if (key == positionEntryKey) return + positionEntryKey = key + entryChangedAtMs = now() + positionSampleMs = 0 + positionSampledAtMs = entryChangedAtMs + positionAdvancing = audioPlayer.playerStateFlow.value == PlayerState.PLAYING + corrector.reset() + } + + /** The player's position now, extrapolated from its last (coarse) report. */ + private fun localPositionMs(): Long { + if (!positionAdvancing) return positionSampleMs + val elapsed = now() - positionSampledAtMs + return positionSampleMs + (elapsed * audioPlayer.playbackSpeedFlow.value).toLong() + } + + private suspend fun seek(positionMs: Long) { + ownSeekUntilMs = now() + OWN_SEEK_WINDOW_MS + audioPlayer.seekTo(positionMs.coerceAtLeast(0).milliseconds) + positionSampleMs = positionMs + positionSampledAtMs = now() + } + + private fun onPosition(positionMs: Long) { + val now = now() + onEntryChanged(audioPlayerQueue.currentQueueEntryFlow.value?.jamKey()) + // Right after a song change, a report can still be the old song's position. + val sinceChange = now - entryChangedAtMs + if (sinceChange < TRACK_CHANGE_HOLD_MS && + positionMs > sinceChange * audioPlayer.playbackSpeedFlow.value + STALE_REPORT_SLACK_MS + ) return + // A seek moves the position away from both the last report and the extrapolation; + // a stall (or a long pause) only from the extrapolation. + val jumped = abs(positionMs - positionSampleMs) > USER_SEEK_THRESHOLD_MS && + abs(positionMs - localPositionMs()) > USER_SEEK_THRESHOLD_MS && + now > ownSeekUntilMs && + now - entryChangedAtMs > TRACK_CHANGE_HOLD_MS + positionSampleMs = positionMs + positionSampledAtMs = now + if (!jumped || _roomCode.value == null || leaving) return + + // The user seeked: it applies to the whole room. + when (_role.value) { + JamRole.Host -> publishAnchor() + JamRole.Guest -> { + holdSync(SEEK_HOLD_MS * 2) + jamClient.sendCommand(JamCommand.SeekTo(positionMs)) } - return - } - - val host = live.firstOrNull { it.isHost } - if (host != null) return - - // Host left: the lowest client id takes over (deterministic, clock-free). - val candidate = live.minByOrNull { it.clientId } ?: return - if (candidate.clientId != localClientId) return - - scope.launch { - delay(HOST_TAKEOVER_DELAY_MS) - val stillNoHost = jamClient.presence.value.values.none { !it.left && it.isHost } - if (!stillNoHost || leaving || _role.value != JamRole.Guest) return@launch - log.i { "Taking over as jam host (previous host left)" } - _role.value = JamRole.Host - jamClient.claimHost() - startHostBroadcast() + null -> Unit } } // ---------- Helpers ---------- - private fun publishCommand(command: PlaybackCmd) { - scope.launch { - jamClient.publishCommand(JamMessage.PlaybackCommand(command)) - } + private fun serverUrl(): String? = + settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null } + + private suspend fun participantName(deviceKey: String): String = + JamNames.normalize(settingsRepository.userSettings.value.jamParticipantName) + .take(JamNames.MAX_LENGTH) + .ifBlank { JamNames.generate(deviceKey) } + + /** The name this device gets when the user hasn't picked one; the same every time. */ + suspend fun defaultParticipantName(): String = JamNames.generate(deviceKey()) + + /** Saves the user's own name; blank goes back to [defaultParticipantName]. */ + suspend fun setParticipantName(name: String) { + val settings = settingsRepository.userSettings.value + settingsRepository.updateSettings( + settings.copy(jamParticipantName = JamNames.normalize(name).take(JamNames.MAX_LENGTH))) } - private fun participantName(fallbackPrefix: String): String { - val configured = settingsRepository.userSettings.value.jamParticipantName - return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" } - } - - private fun newClientId(broker: org.spotube.spotube.modules.settings.JamBroker): String { - val suffix = buildString(6) { - val chars = "0123456789abcdef" - repeat(6) { append(chars[Random.nextInt(chars.length)]) } - } - return "${broker.clientIdPrefix.ifBlank { "spotube" }}-$suffix" + /** This install's jam secret, created on first use. */ + private suspend fun deviceKey(): String { + val settings = settingsRepository.userSettings.value + if (settings.jamDeviceKey.isNotBlank()) return settings.jamDeviceKey + val generated = JamRoomCode.newClientKey() + runCatching { settingsRepository.updateSettings(settings.copy(jamDeviceKey = generated)) } + .onFailure { log.w(it) { "Failed to save the jam device key" } } + return generated } private fun persistLastCode(code: String) { @@ -525,6 +948,11 @@ class JamRoomService( } } + private fun QueueEntry.jamKey(): String = when (this) { + is QueueEntry.StreamingTrack -> track.id + is QueueEntry.LocalTrack -> JamMediaItem.localTrackKey(url) + } + private fun JamMediaItem.toQueueEntry(): QueueEntry = when { trackId.isNotBlank() -> QueueEntry.StreamingTrack( track = MetadataTrack( @@ -560,31 +988,38 @@ class JamRoomService( addedBy = addedBy) } - private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { - if (entry == null) return false - return when (entry) { - is QueueEntry.StreamingTrack -> - trackId.isNotBlank() && entry.track.id == trackId - - is QueueEntry.LocalTrack -> - url.isNotBlank() && entry.url == url && entry.name == title - } - } - - private fun QueueEntry.matchesEntry(other: QueueEntry): Boolean = when { - this is QueueEntry.StreamingTrack && other is QueueEntry.StreamingTrack -> - this.track.id == other.track.id - - this is QueueEntry.LocalTrack && other is QueueEntry.LocalTrack -> - this.url == other.url && this.name == other.name - - else -> false - } - private fun now(): Long = Clock.System.now().toEpochMilliseconds() companion object { - private const val HOST_TAKEOVER_DELAY_MS = 1_500L - private const val AUTO_ADVANCE_WINDOW_MS = 2_000L + private const val CREATE_ATTEMPTS = 3 + /** Keeps the host under the server's budgets (see `src/protocol.ts` in the jam-server repository). */ + private const val QUEUE_MIN_GAP_MS = 1_000L + private const val ANCHOR_MIN_GAP_MS = 250L + private const val MAX_SUGGESTED_ITEMS = 500 + /** How many next songs the host shares its sources for. */ + private const val UPCOMING_SOURCES = 2 + /** How long a guest that finished a song first waits for the host to finish it too. */ + private const val NEXT_SONG_WAIT_MS = 15_000L + /** "Near the end" of the room's song, allowing for recordings of different lengths. */ + private const val NEAR_SONG_END_MS = 15_000L + private const val STALE_REPORT_SLACK_MS = 1_500L + private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L + private const val HOST_DRIFT_WINDOW = 5 + /** Below the guests' dead band, so the host's own error never adds up to a correction. */ + private const val HOST_REANCHOR_DRIFT_MS = 80L + private const val HOST_REANCHOR_MIN_INTERVAL_MS = 10_000L + /** A safety net only (e.g. a lost frame); costs one message a minute. */ + private const val HOST_ANCHOR_HEARTBEAT_MS = 60_000L + private const val SYNC_INTERVAL_MS = 500L + private const val IDLE_RECHECK_MS = 30_000L + private const val INITIAL_SEEK_LEAD_MS = 300L + private const val MAX_SEEK_LEAD_MS = 3_000L + private const val SEEK_HOLD_MS = 1_500L + private const val TRACK_CHANGE_HOLD_MS = 2_000L + private const val PAUSED_ALIGN_THRESHOLD_MS = 1_500L + private const val USER_SEEK_THRESHOLD_MS = 2_500L + private const val OWN_SEEK_WINDOW_MS = 1_500L + /** Near a track's end the next anchor (new track) is due; don't seek into the outro. */ + private const val END_GUARD_MS = 1_000L } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt index 3b37ff81..5ea612a6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemotePlaybackController.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt index 568a018b..9c827d29 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamScreen.kt @@ -62,6 +62,7 @@ import org.spotube.spotube.core.navigation.NavigationCommands import org.spotube.spotube.core.navigation.Routes import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.core.ui.theming.AppTheme +import org.spotube.spotube.modules.settings.sections.JamNameSettingCard import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.IconsaxSetting2 @@ -138,8 +139,17 @@ fun JamScreen(navigationCommands: NavigationCommands) { viewModel::createSession, viewModel::joinSession ) + // Pick a name before starting or joining, without a detour through Settings. + JamPanel { + JamNameSettingCard( + customName = state.customName, + defaultName = state.defaultName, + onNameSaved = viewModel::setParticipantName, + enabled = !state.isBusy + ) + } } - BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } + ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) } } } } @@ -412,7 +422,11 @@ internal fun SessionPanel( } Column(Modifier.weight(1f)) { Text( - participant.displayName, + if (participant.isYou) { + stringResource(Res.string.jam_participants_you, participant.displayName) + } else { + participant.displayName + }, style = AppTheme.typography.bodyMedium, maxLines = 1, overflow = TextOverflow.Ellipsis @@ -461,16 +475,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, diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt index 5041e371..97799ccb 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/jam/JamViewModel.kt @@ -48,6 +48,7 @@ data class JamParticipantUi( val initial: String, val isHost: Boolean, val canModerate: Boolean, + val isYou: Boolean = false, val menuExpanded: Boolean = false ) @@ -57,8 +58,8 @@ data class JamUiState( val role: JamRole? = null, val participants: List = 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(), @@ -68,7 +69,11 @@ data class JamUiState( val isLeaving: Boolean = false, val isHost: Boolean = false, val supportsNativeShare: Boolean = false, - val codeCopied: Boolean = false + val codeCopied: Boolean = false, + /** The name the user picked (blank when they haven't). */ + val customName: String = "", + /** This device's generated name, used when [customName] is blank. */ + val defaultName: String = "" ) class JamViewModel( @@ -85,6 +90,7 @@ class JamViewModel( private val codeInput = TextFieldState() private var copyFeedbackJob: Job? = null private val participantMenu = MutableStateFlow(null) + private val defaultName = MutableStateFlow("") val supportsNativeShare: Boolean = getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS @@ -102,7 +108,8 @@ class JamViewModel( codeCopied, snapshotFlow { codeInput.text.toString() }, participantMenu, - isLeaving + isLeaving, + defaultName ) { values -> @Suppress("UNCHECKED_CAST") val role = values[0] as JamRole? @@ -115,7 +122,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, @@ -128,23 +135,27 @@ class JamViewModel( initial = participant.displayName.firstOrNull()?.uppercase() ?: "?", isHost = participant.isHost, canModerate = role == JamRole.Host && !participant.isHost, + isYou = participant.id == jamRoomService.participantClientId, menuExpanded = values[11] == participant.id ) }, 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 + supportsNativeShare = supportsNativeShare, + customName = settings?.jamParticipantName.orEmpty(), + defaultName = values[13] as String ) }.stateIn( viewModelScope, @@ -152,6 +163,14 @@ class JamViewModel( JamUiState(codeInput = codeInput) ) + init { + viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() } + } + + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + // ---------- Session lifecycle ---------- fun createSession() { diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt index 0643cb4b..295b7536 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsScreen.kt @@ -18,46 +18,32 @@ 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.JamNameSettingCard 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_section_you +import spotube.composeapp.generated.resources.settings_jam_server_diagnostics_subtitle +import spotube.composeapp.generated.resources.settings_jam_server_note +import spotube.composeapp.generated.resources.settings_jam_server_test +import spotube.composeapp.generated.resources.settings_jam_server_test_fail +import spotube.composeapp.generated.resources.settings_jam_server_testing +import spotube.composeapp.generated.resources.settings_jam_server_title +import spotube.composeapp.generated.resources.settings_jam_server_url +import spotube.composeapp.generated.resources.settings_jam_server_url_invalid +import spotube.composeapp.generated.resources.settings_section_jam @Composable fun JamSettingsScreen( @@ -80,7 +66,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) @@ -94,166 +79,71 @@ internal fun JamSettingsContent( .widthIn(max = 1280.dp) .align(Alignment.TopCenter), contentPadding = contentPadding) { + settingsSectionHeader(Res.string.settings_jam_section_you) + settingsSectionCard( + items = listOf( + { + JamNameSettingCard( + customName = uiState.settings.jamParticipantName, + defaultName = uiState.defaultName, + onNameSaved = viewModel::setParticipantName) + })) settingsSectionHeader(Res.string.settings_jam_section_connection) 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) - ) - }) - }, - { - 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)) } + stringResource(Res.string.settings_jam_server_title)) }, - 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)) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt index 61e58722..ea58bd38 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/JamSettingsViewModel.kt @@ -3,6 +3,7 @@ package org.spotube.spotube.modules.settings import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import org.spotube.spotube.core.jam.JamRoomClient +import org.spotube.spotube.core.jam.JamRoomService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,15 +13,22 @@ import kotlinx.coroutines.launch data class JamSettingsUiState( val settings: UserSettings = UserSettings(), val isTesting: Boolean = false, - val testResult: Result? = null) + val testResult: Result? = null, + /** This device's generated jam name, used while the user hasn't picked one. */ + val defaultName: String = "") class JamSettingsViewModel( private val settingsRepository: SettingsRepository, - private val jamRoomClient: JamRoomClient) : ViewModel() { + private val jamRoomClient: JamRoomClient, + private val jamRoomService: JamRoomService) : ViewModel() { private val _uiState = MutableStateFlow(JamSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() init { + viewModelScope.launch { + val name = jamRoomService.defaultParticipantName() + _uiState.update { it.copy(defaultName = name) } + } viewModelScope.launch { settingsRepository.userSettings.collect { settings -> _uiState.update { it.copy(settings = settings) } @@ -28,9 +36,16 @@ class JamSettingsViewModel( } } - fun update(transform: UserSettings.() -> UserSettings) { + fun setParticipantName(name: String) { + viewModelScope.launch { jamRoomService.setParticipantName(name) } + } + + /** A blank URL goes back to the public server. */ + fun setServerUrl(url: String) { viewModelScope.launch { - settingsRepository.updateSettings(uiState.value.settings.transform()) + val serverUrl = url.trim().trimEnd('/').ifBlank { DEFAULT_JAM_SERVER_URL } + settingsRepository.updateSettings(uiState.value.settings.copy(jamServerUrl = serverUrl)) + _uiState.update { it.copy(testResult = null) } } } @@ -39,7 +54,7 @@ class JamSettingsViewModel( _uiState.update { it.copy(isTesting = true, testResult = null) } 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) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 90f87e3e..fe66b4d9 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -59,10 +59,13 @@ data class UserSettings( val remoteControlDeviceName: String = "", val remoteControlDeviceId: String = "", - // Group Jam (MQTT) + // Group Jam val jamParticipantName: String = "", - val jamBroker: JamBroker = JamBroker(), + /** Room server (https://github.com/team-spotube/jam-server); self-hosters point this at their own deployment. */ + val jamServerUrl: String = DEFAULT_JAM_SERVER_URL, val lastJamCode: String = "", + /** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */ + val jamDeviceKey: String = "", // Downloads val overloadedDownloadFolder: String? = null, // When null, uses default music folder @@ -91,21 +94,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. diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt index b8b3772c..0c4f62c1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsRepository.kt @@ -34,7 +34,12 @@ interface UserSettingsSource { val userSettings: StateFlow } -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) } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt new file mode 100644 index 00000000..843c3402 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamNameSetting.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.modules.settings.sections + +import androidx.compose.runtime.Composable +import org.jetbrains.compose.resources.stringResource +import org.spotube.spotube.core.jam.JamNames +import org.spotube.spotube.modules.settings.components.TextInputSettingCard +import org.spotube.spotube.resources.iconsax.Iconsax +import org.spotube.spotube.resources.iconsax.IconsaxEdit +import spotube.composeapp.generated.resources.Res +import spotube.composeapp.generated.resources.jam_name_description +import spotube.composeapp.generated.resources.jam_name_subtitle +import spotube.composeapp.generated.resources.jam_name_title +import spotube.composeapp.generated.resources.jam_name_too_long + +/** + * "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay + * identical, like the remote control device name card. + * + * [customName] is what the user saved (blank = none), [defaultName] the generated one. + */ +@Composable +internal fun JamNameSettingCard( + customName: String, + defaultName: String, + onNameSaved: (String) -> Unit, + enabled: Boolean = true +) { + val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH) + TextInputSettingCard( + title = stringResource(Res.string.jam_name_title), + subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }), + icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) }, + value = customName, + dialogDescription = stringResource(Res.string.jam_name_description, defaultName), + placeholder = defaultName, + normalize = JamNames::normalize, + validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null }, + onValueSaved = onNameSaved, + enabled = enabled + ) +} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt index 53192bff..fcbfff82 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/JamSection.kt @@ -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) }) diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt new file mode 100644 index 00000000..ab3b9691 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamNamesTest.kt @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class JamNamesTest { + @Test + fun sameSeedSameName() = runTest { + assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a")) + } + + @Test + fun namesAreTwoFriendlyWordsAndVaried() = runTest { + val names = List(60) { JamNames.generate("device-$it") } + assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString()) + assertTrue(names.all { it.length <= JamNames.MAX_LENGTH }) + assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names") + } + + @Test + fun typedNamesAreCleaned() { + assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n")) + assertEquals("", JamNames.normalize(" ")) + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt new file mode 100644 index 00000000..7902dc3b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSecurityTest.kt @@ -0,0 +1,93 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class JamSecurityTest { + private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") = + JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B", + durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE") + + @Test + fun peerItemsWithoutATrackIdAreDropped() { + assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized()) + assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized()) + } + + @Test + fun peerItemsLoseUrlsAndNonHttpsCovers() { + val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!! + assertEquals("", clean.url) + assertEquals("", clean.coverUrl) + assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl) + } + + @Test + fun peerTextIsBounded() { + val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!! + assertTrue(clean.title.length <= 300) + assertEquals(0, clean.durationMs) + } + + @Test + fun localTrackKeysDontRevealPaths() { + val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3") + assertFalse("alice" in key || "Music" in key) + assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")) + assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3")) + } + + @Test + fun clientKeysAreLongRandomAndAcceptedByTheServer() { + val keys = List(100) { JamRoomCode.newClientKey() } + assertEquals(keys.size, keys.toSet().size) + // Mirrors CLIENT_KEY in the jam-server repository's src/protocol.ts. + val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$") + assertTrue(keys.all { serverPattern.matches(it) }) + } + + @Test + fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() { + val device = JamRoomCode.newClientKey() + assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG")) + assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF")) + assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF"))) + } + + @Test + fun deviceDigestsRevealNothingAndDifferPerRoom() { + val digest = jamDeviceDigest("ABCDEF")!! + assertEquals(digest, jamDeviceDigest("ABCDEF")) + assertNotEquals(digest, jamDeviceDigest("ABCDEG")) + assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest }) + // Mirrors DEVICE_DIGEST in the jam-server repository's src/protocol.ts. + assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest)) + } + + @Test + fun roomCodesAreValid() { + repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) } + } +} diff --git a/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt new file mode 100644 index 00000000..914ea50a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/org/spotube/spotube/core/jam/JamSyncTest.kt @@ -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 . + */ + +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(behind) + assertTrue(behind.rate > 1f && behind.rate <= 1.05f) + + val ahead = JamDriftCorrector().correct(10_000, 10_600, 1f, seekLeadMs = 300) + assertIs(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(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(results[0]) + assertIs(results[1]) + assertEquals(JamCorrection.Seek(12_000), results[2]) + } +} diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt new file mode 100644 index 00000000..ff45cbf5 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/jam/JamDevice.ios.kt @@ -0,0 +1,36 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import platform.Foundation.NSLocale +import platform.Foundation.NSTimeZone +import platform.Foundation.currentLocale +import platform.Foundation.localTimeZone +import platform.Foundation.localeIdentifier +import platform.UIKit.UIDevice + +internal actual fun jamDeviceTraits(): List { + val device = UIDevice.currentDevice + return listOf( + "ios", + device.model, + device.systemName(), + device.systemVersion, + NSLocale.currentLocale.localeIdentifier, + NSTimeZone.localTimeZone.name) +} diff --git a/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt new file mode 100644 index 00000000..c37e8a47 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/jam/JamDevice.jvm.kt @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import java.util.Locale +import java.util.TimeZone + +internal actual fun jamDeviceTraits(): List = listOf( + "desktop", + System.getProperty("os.name").orEmpty(), + System.getProperty("os.version").orEmpty(), + System.getProperty("os.arch").orEmpty(), + Runtime.getRuntime().availableProcessors().toString(), + Locale.getDefault().toLanguageTag(), + TimeZone.getDefault().id) diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt new file mode 100644 index 00000000..32224f97 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamRoomClientIntegrationTest.kt @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.spotube.spotube.core.network.HttpClientFactory +import kotlin.math.abs +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Runs the real client against a room server. Skipped unless `JAM_TEST_URL` is set: + * run `npm run dev` in a checkout of https://github.com/team-spotube/jam-server, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`. + */ +class JamRoomClientIntegrationTest { + private val serverUrl: String? = System.getenv("JAM_TEST_URL") + + @Test + fun hostAndGuestShareARoom() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val host = JamRoomClient(http) + val guest = JamRoomClient(http) + val code = JamRoomCode.generate() + try { + host.testConnection(url).getOrThrow() + + val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow() + val hostId = hostWelcome.you + assertEquals(hostId, hostWelcome.host) + // Same machine: the clock offset is just measurement noise. + assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}") + + val queue = JamQueue(listOf(JamMediaItem(url = "", trackId = "t1", title = "Song", artist = "A", + album = "", durationMs = 1_000, coverUrl = "", protocol = "PROGRESSIVE"))) + host.sendQueue(queue) + val anchor = JamAnchor("t1", 0, positionMs = 1_234, serverTimeMs = host.clock.serverTime(0), playing = true) + host.sendAnchor(anchor) + Thread.sleep(300) + + // Late joiners get the retained queue and anchor in the welcome. + val guestKey = JamRoomCode.newClientKey() + val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow() + val guestId = guestWelcome.you + assertEquals(queue, guestWelcome.queue) + assertEquals(anchor, guestWelcome.anchor) + assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id }) + + val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance().first() } + guest.sendCommand(JamCommand.SeekTo(42_000)) + val command = withTimeout(5_000) { received.await() } + assertEquals(guestId, command.from) + assertEquals(JamCommand.SeekTo(42_000), command.command) + + val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance().first() } + host.kick(guestId, ban = false) + assertEquals("kicked", withTimeout(5_000) { closed.await() }.code) + + val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false) + assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code) + } finally { + host.disconnect() + guest.disconnect() + http.close() + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt new file mode 100644 index 00000000..1bba4a76 --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/JamSessionSimulationTest.kt @@ -0,0 +1,705 @@ +/* + * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package org.spotube.spotube.core.jam + +import co.touchlab.kermit.Logger +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.koin.core.context.startKoin +import org.koin.core.context.stopKoin +import org.koin.dsl.module +import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource +import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack +import org.spotube.spotube.core.audioplayer.AudioPlayerInterface +import org.spotube.spotube.core.audioplayer.AudioPlayerQueue +import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue +import org.spotube.spotube.core.audioplayer.FakeAudioPlayerQueueRepository +import org.spotube.spotube.core.audioplayer.FakeBlacklistRepository +import org.spotube.spotube.core.audioplayer.FakePluginProvider +import org.spotube.spotube.core.audioplayer.FakeSettingsProvider +import org.spotube.spotube.core.audioplayer.LoopState +import org.spotube.spotube.core.audioplayer.MediaItem +import org.spotube.spotube.core.audioplayer.PlayerState +import org.spotube.spotube.core.audioplayer.QueueCollectionEntry +import org.spotube.spotube.core.audioplayer.QueueEntry +import org.spotube.spotube.core.network.HttpClientFactory +import org.spotube.spotube.core.server.FakeAudioPluginService +import org.spotube.spotube.core.server.FakeAudioPluginSource +import org.spotube.spotube.core.server.FakeStreamingAudioPlayerQueue +import org.spotube.spotube.core.server.FakeTrackSourceRepository +import org.spotube.spotube.core.server.FakeUserSettingsSource +import org.spotube.spotube.core.server.StreamingUrlRepository +import org.spotube.spotube.modules.settings.UserSettings +import org.spotube.spotube.modules.settings.UserSettingsStore +import java.util.concurrent.CopyOnWriteArrayList +import kotlin.math.abs +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlin.time.Clock +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * A host and a guest [JamRoomService], each driving a simulated player, in one room on a + * real room server. The guest's player runs 1% fast, the host's 0.1% slow (so it has to + * re-anchor), and both report positions coarsely, like real players. Skipped unless `JAM_TEST_URL` is set (see [JamRoomClientIntegrationTest]). + */ +class JamSessionSimulationTest { + private val serverUrl: String? = System.getenv("JAM_TEST_URL") + + @BeforeTest + fun setup() { + startKoin { modules(module { factory { (tag: String?) -> Logger.withTag(tag ?: "test") } }) } + } + + @AfterTest + fun teardown() { + stopKoin() + } + + @Test + fun guestFollowsTheHost() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("track-$it") } + val hostSource = source("host-upload") + val host = Participant(http, url, speedError = 0.999) + val guest = Participant(http, url, speedError = 1.01) + host.sources.seed(tracks[0], hostSource, PLUGIN) + guest.sources.seed(tracks[0], source("other-upload"), PLUGIN) + + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(2_000) + + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + assertEquals(JamRole.Host, host.service.role.value) + assertEquals(JamRole.Guest, guest.service.role.value) + + delay(7_000) + assertInSync(host, guest, "after joining") + assertEquals(hostSource, guest.sources.rawEntryFor(tracks[0].id)?.source, "guest uses the host's source") + + host.player.seekTo(60_000.milliseconds) + delay(5_000) + assertInSync(host, guest, "after the host seeked") + + // A guest's seek moves the whole room. + guest.player.seekTo(90_000.milliseconds) + // Two hops (guest -> host -> guest) and two seeks: allow longer to settle. + delay(8_000) + assertTrue(host.player.truePosition() in 96_000..100_000, "host followed the guest's seek: ${host.player.truePosition()}") + assertInSync(host, guest, "after the guest seeked") + + guest.player.pause() + delay(1_500) + assertEquals(PlayerState.PAUSED, host.player.playerStateFlow.value, "guest paused the room") + + host.player.play() + host.service.skipNext() + delay(6_000) + assertEquals(1, host.player.index.value) + assertEquals(1, guest.player.index.value, "guest followed the skip") + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value) + assertInSync(host, guest, "after the skip") + + // Mashing skip stays within the server's flood limits (the host isn't cut off). + repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() } + delay(6_000) + assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip") + assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips") + assertInSync(host, guest, "after mashing skip") + + // Steady playback costs (almost) nothing: count the anchors a listener receives. + val listener = JamRoomClient(http) + try { + listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow() + delay(1_000) // The host answers the join with an anchor. + var anchors = 0 + val counting = launch { + listener.events.collect { if (it is JamRoomEvent.Anchor) anchors++ } + } + delay(STEADY_WINDOW_MS) + counting.cancel() + println("Jam simulation: $anchors anchor(s) in ${STEADY_WINDOW_MS / 1000}s of steady playback") + assertTrue(anchors <= 2, "steady playback sent $anchors anchors") + assertInSync(host, guest, "after steady playback") + } finally { + listener.disconnect() + } + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * Songs that end on their own. The guest has its own uploads of every song (a shorter + * intro) and its player loads the next song halfway through the current one, so it + * must have the host's recording before then, or it plays a different one. + */ + @Test + fun usesTheHostsRecordingForEverySong() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("song-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + tracks.forEach { + host.sources.seed(it, source("host-${it.id}"), PLUGIN) + guest.sources.seed(it, source("own-${it.id}"), PLUGIN) + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(6_000) + assertEquals("host-song-0", guest.player.currentSource, "guest plays the host's recording of song 0") + assertInSync(host, guest, "during song 0") + + for (song in 1..2) { + while (host.player.index.value < song) delay(100) + delay(5_000) + assertEquals(song, guest.player.index.value, "guest on song $song") + assertEquals("host-song-$song", guest.player.currentSource, "guest plays the host's recording of song $song") + assertInSync(host, guest, "5 s into song $song after the previous one ended") + } + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * A guest that can't use the host's recordings (another audio plugin) finishes each + * song early. It must wait for the host, not jump back to the song that just ended. + */ + @Test + fun aGuestThatFinishesASongFirstWaitsForTheHost() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("song-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0) + val guest = Participant(http, url, speedError = 1.0, pluginId = "other-plugin") + tracks.forEach { guest.sources.seed(it, source("own-${it.id}"), "other-plugin") } + val jumpedBack = CopyOnWriteArrayList() + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(4_000) + val watcher = launch { + var last = guest.player.index.value + guest.player.index.collect { now -> + if (now < last) jumpedBack += "guest went back from song $last to $now" + last = now + } + } + for (song in 1..2) { + while (host.player.index.value < song) delay(100) + delay(5_000) + assertEquals(song, guest.player.index.value, "guest on song $song") + } + watcher.cancel() + assertTrue(jumpedBack.isEmpty(), jumpedBack.toString()) + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * A jam must not keep the CPU busy (a spinning loop allocates nonstop and freezes the + * app in GC). Measures the process's CPU time while the room sits in each state. + */ + @Test + fun idleStatesDoNotBurnCpu() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val tracks = List(3) { track("cpu-$it") } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + val os = java.lang.management.ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean + suspend fun cpuDuring(label: String, block: suspend () -> Unit) { + val before = os.processCpuTime + val start = System.nanoTime() + block() + val share = (os.processCpuTime - before).toDouble() / (System.nanoTime() - start) + println("Jam simulation: CPU ${"%.0f".format(share * 100)}% of a core while $label") + assertTrue(share < 0.5, "CPU ${"%.0f".format(share * 100)}% of a core while $label") + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(5_000) + cpuDuring("playing in sync") { delay(8_000) } + host.player.pause() + delay(2_000) + cpuDuring("the room is paused") { delay(8_000) } + host.player.play() + delay(3_000) + host.player.stall() + delay(1_000) + cpuDuring("the host is buffering") { delay(8_000) } + host.player.unstall() + delay(3_000) + guest.player.stall() + cpuDuring("the guest is buffering") { delay(8_000) } + guest.player.unstall() + } finally { + guest.close() + host.close() + http.close() + } + } + + /** + * Several minutes of jam (songs ending, recordings switching, seeks) must not grow the + * heap. Opt-in, since it takes minutes: set `JAM_SOAK=1`. + */ + @Test + fun longSessionDoesNotLeak() = runBlocking { + val url = serverUrl ?: return@runBlocking + if (System.getenv("JAM_SOAK") == null) return@runBlocking + val http = HttpClientFactory() + val tracks = List(8) { track("soak-$it", durationMs = SONG_MS) } + val host = Participant(http, url, speedError = 1.0, realQueue = true) + val guest = Participant(http, url, speedError = 1.01, realQueue = true) + tracks.forEach { + host.sources.seed(it, source("host-${it.id}"), PLUGIN) + guest.sources.seed(it, source("own-${it.id}"), PLUGIN) + } + val runtime = Runtime.getRuntime() + fun usedMb(): Long { + repeat(3) { System.gc(); Thread.sleep(200) } + return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024) + } + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(500) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(20_000) + val baseline = usedMb() + val samples = mutableListOf(baseline) + repeat(6) { round -> + if (round % 2 == 0) host.player.seekTo(10_000.milliseconds) else guest.player.seekTo(20_000.milliseconds) + delay(30_000) + samples += usedMb() + } + println("Jam simulation: heap after GC over ${samples.size} samples (MB): $samples") + assertTrue(samples.last() - baseline < 30, "heap grew from $baseline MB to ${samples.last()} MB") + assertInSync(host, guest, "after a long session") + } finally { + guest.close() + host.close() + http.close() + } + } + + /** Losing the network (guest, then host) must not crash, and the room picks up again. */ + @Test + fun roomSurvivesNetworkLoss() = runBlocking { + val url = serverUrl ?: return@runBlocking + val http = HttpClientFactory() + val hostNet = TcpProxy(url) + val guestNet = TcpProxy(url) + val tracks = List(3) { track("track-$it") } + val host = Participant(http, hostNet.url, speedError = 1.0) + val guest = Participant(http, guestNet.url, speedError = 1.01) + val crashes = CopyOnWriteArrayList() + val previousHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, e -> crashes += e } + + try { + host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0) + delay(1_000) + val code = host.service.createRoom().getOrThrow() + guest.service.joinRoom(code).getOrThrow() + delay(5_000) + assertInSync(host, guest, "before the guest lost its network") + + guestNet.goOffline() + delay(1_000) + assertEquals(false, guest.service.isConnected.value, "guest noticed the drop") + assertEquals(code, guest.service.roomCode.value, "guest stays in the room while offline") + // Meanwhile the room moves on without the guest. + host.player.seekTo(40_000.milliseconds) + delay(4_000) + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing offline") + + guestNet.goOnline() + delay(10_000) // Up to one backoff step, then resync. + assertEquals(true, guest.service.isConnected.value, "guest reconnected") + assertEquals(JamRole.Guest, guest.service.role.value) + assertInSync(host, guest, "after the guest reconnected") + + // A host that drops for less than the grace period keeps the room. + hostNet.goOffline() + delay(5_000) + assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing while the host is away") + hostNet.goOnline() + delay(10_000) + assertEquals(true, host.service.isConnected.value, "host reconnected") + assertEquals(JamRole.Host, host.service.role.value, "host kept the room") + host.player.seekTo(100_000.milliseconds) + delay(5_000) + assertInSync(host, guest, "after the host reconnected and seeked") + + assertTrue(crashes.isEmpty(), "uncaught exceptions: $crashes") + } finally { + Thread.setDefaultUncaughtExceptionHandler(previousHandler) + guest.close() + host.close() + hostNet.close() + guestNet.close() + http.close() + } + } + + private fun assertInSync(host: Participant, guest: Participant, phase: String) { + assertEquals(host.player.index.value, guest.player.index.value, "same track $phase") + val gap = guest.player.songPosition() - host.player.songPosition() + println("Jam simulation: guest is ${gap}ms off $phase") + assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase") + } + + private class Participant( + http: HttpClientFactory, + serverUrl: String, + speedError: Double, + pluginId: String = PLUGIN, + /** The app's own queue (stateIn flows and all) instead of [SimQueue]. */ + realQueue: Boolean = false) { + val sources = FakeTrackSourceRepository() + val player = SimPlayer(speedError) + val queue: AudioPlayerQueue = if (realQueue) { + player.resolveSource = { i -> + val id = player.playlistFlow.value.getOrNull(i)?.url?.substringAfterLast('/') + id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id" + } + DeviceAudioPlayerQueue( + player, FakeSettingsProvider(), FakeAudioPlayerQueueRepository(), + FakePluginProvider(), FakeBlacklistRepository()) + } else { + SimQueue(player, sources) + } + private val plugins = FakeAudioPluginSource(FakeAudioPluginService(pluginId)) + val service = JamRoomService( + jamClient = JamRoomClient(http), + audioPlayer = player, + audioPlayerQueue = queue, + settingsRepository = FakeSettingsStore(UserSettings(jamServerUrl = serverUrl)), + audioPluginSource = plugins, + trackSources = sources, + streamingUrlRepository = StreamingUrlRepository( + plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()), + evictCachedAudio = {}) + + suspend fun close() { + service.leaveRoom() + player.dispose() + } + } + + private class FakeSettingsStore(initial: UserSettings) : UserSettingsStore { + override val userSettings = MutableStateFlow(initial) + override suspend fun updateSettings(newSettings: UserSettings) { + userSettings.value = newSettings + } + } + + /** + * Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as + * a drifting device would), positions are reported every 250 ms rounded to 100 ms, and + * every seek or track change buffers briefly. + * + * Like ExoPlayer, it picks a song's recording (source) when it loads the song: the next + * song is loaded halfway through the current one, and jumping to the song already + * playing keeps its recording. Recordings differ in intro length ([introMs]). + */ + private class SimPlayer(private val speedError: Double) : AudioPlayerInterface { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private var durations: List = emptyList() + /** The source the queue would resolve for a song now. */ + var resolveSource: (Int) -> String = { "default" } + private val loadedSources = mutableMapOf() + val currentSource: String? get() = loadedSources[index.value] + private var basePosition = 0L + private var baseAt = now() + private var speed = 1f + private var playWhenReady = false + + val index = MutableStateFlow(-1) + private val _state = MutableStateFlow(PlayerState.IDLE) + private val _position = MutableStateFlow(Duration.ZERO) + private val _duration = MutableStateFlow(Duration.ZERO) + private val _speed = MutableStateFlow(1f) + private val _playlist = MutableStateFlow>(emptyList()) + private val _currentItem = MutableStateFlow(null) + private val _completion = MutableSharedFlow(extraBufferCapacity = 4) + + init { + scope.launch { + while (isActive) { + delay(250) + val position = truePosition() + val duration = currentDuration() ?: continue + val next = index.value + 1 + if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) { + loadedSources[next] = resolveSource(next) + } + if (position >= duration) { + _completion.tryEmit(Unit) + if (index.value < durations.lastIndex) advanceGapless() + continue + } + _position.value = (position / 100 * 100).milliseconds + } + } + } + + /** Where in the song (not the recording) playback is. */ + fun songPosition(): Long = truePosition() - introMs(currentSource) + + private fun currentDuration(): Long? = + durations.getOrNull(index.value)?.let { it + introMs(currentSource) } + + @Synchronized + fun truePosition(): Long = + if (_state.value == PlayerState.PLAYING) { + basePosition + ((now() - baseAt) * speed * speedError).toLong() + } else { + basePosition + } + + @Synchronized + private fun rebase(position: Long = truePosition()) { + basePosition = position + baseAt = now() + } + + fun loadTracks(trackDurations: List, start: Int, autoPlay: Boolean) { + durations = trackDurations + playWhenReady = autoPlay + loadedSources.clear() + startTrack(start) + } + + /** + * A song ending on its own, like ExoPlayer's gapless transition: no buffering, and + * the position keeps the old song's value until the next report. + */ + private fun advanceGapless() { + val next = index.value + 1 + loadedSources.getOrPut(next) { resolveSource(next) } + index.value = next + _currentItem.value = _playlist.value.getOrNull(next) + _duration.value = currentDuration()!!.milliseconds + rebase(0) + } + + fun startTrack(i: Int) { + // Jumping to the song already playing seeks within it (ExoPlayer's + // seekToDefaultPosition): the loaded recording stays. + if (i != index.value || i !in loadedSources) loadedSources[i] = loadedSources[i] ?: resolveSource(i) + index.value = i + _currentItem.value = _playlist.value.getOrNull(i) + _duration.value = currentDuration()!!.milliseconds + rebase(0) + _position.value = Duration.ZERO + buffer() + } + + private fun buffer() { + rebase() + _state.value = PlayerState.BUFFERING + scope.launch { + delay(BUFFER_MS) + rebase() + _state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + } + } + + /** Network stall: buffering until [unstall]. */ + fun stall() { + rebase() + _state.value = PlayerState.BUFFERING + } + + fun unstall() { + rebase() + _state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED + } + + override suspend fun play() { + playWhenReady = true + rebase() + if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PLAYING + } + + override suspend fun pause() { + playWhenReady = false + rebase() + if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PAUSED + } + + override suspend fun seekTo(position: Duration) { + rebase(position.inWholeMilliseconds) + _position.value = position + buffer() + } + + override suspend fun setPlaybackSpeed(speed: Float) { + rebase() + this.speed = speed + _speed.value = speed + } + + override suspend fun skipToNext() = startTrack((index.value + 1).coerceAtMost(durations.lastIndex)) + override suspend fun skipToPrevious() = startTrack((index.value - 1).coerceAtLeast(0)) + override suspend fun jumpTo(index: Int) = startTrack(index) + + override val playerStateFlow: StateFlow = _state + override val positionFlow: StateFlow = _position + override val durationFlow: StateFlow = _duration + override val playbackSpeedFlow: StateFlow = _speed + override val completionFlow: Flow = _completion + override val shuffleModeFlow: StateFlow = MutableStateFlow(false) + override val currentMediaItemFlow: StateFlow = _currentItem + override val playlistFlow: StateFlow> = _playlist + override val bufferingPositionFlow: StateFlow = MutableStateFlow(Duration.ZERO) + override val loopStateFlow: StateFlow = MutableStateFlow(LoopState.NONE) + override val volumeFlow: StateFlow = MutableStateFlow(1f) + override val errorFlow: Flow = 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, autoPlay: Boolean, startPosition: Int) { + _playlist.value = playlist + loadTracks(playlist.map { it.duration.inWholeMilliseconds }, startPosition, autoPlay) + } + + override suspend fun addMediaItem(mediaItem: MediaItem) { + _playlist.value += mediaItem + durations = durations + mediaItem.duration.inWholeMilliseconds + } + override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit + override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit + override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit + override suspend fun setVolume(volume: Float) = Unit + override fun isDisposed(): Boolean = !scope.isActive + override fun dispose() = scope.cancel() + } + + private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val entries = MutableStateFlow>(emptyList()) + + init { + player.resolveSource = { i -> + val id = (entries.value.getOrNull(i) as? QueueEntry.StreamingTrack)?.track?.id + id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id" + } + } + + override val queueFlow: StateFlow> = entries + override val currentQueueEntryFlow: StateFlow = + combine(entries, player.index) { list, i -> list.getOrNull(i) } + .stateIn(scope, SharingStarted.Eagerly, null) + override val currentCollectionEntryFlow: StateFlow = MutableStateFlow(null) + override val collectionHistoryFlow: StateFlow> = MutableStateFlow(emptyList()) + + override suspend fun load( + entries: List, + 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, collectionEntry: QueueCollectionEntry?) { + this.entries.value += entries + } + + override suspend fun addAllAfterCurrent(entries: List) = 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 = entries.value + override suspend fun getCurrentQueueEntry(): QueueEntry? = currentQueueEntryFlow.value + override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null + override suspend fun getCollectionHistory(): List = emptyList() + } + + companion object { + private const val PLUGIN = "test-audio-plugin" + private const val BUFFER_MS = 200L + private const val MAX_GAP_MS = 200L + private const val STEADY_WINDOW_MS = 40_000L + private const val SONG_MS = 30_000L + + /** How much longer a recording's intro is than the original's ("own" uploads cut 2.5 s). */ + private fun introMs(source: String?): Long = if (source?.startsWith("own-") == true) -2_500 else 0 + + private fun now() = Clock.System.now().toEpochMilliseconds() + + private fun track(id: String, durationMs: Long = 180_000) = MetadataTrack( + id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null, + artists = emptyList(), album = null, thumbnails = null, explicit = null, + popularity = null, isrcCode = null, externalUri = null) + + private fun source(id: String) = AudioSource.Basic( + id = id, title = id, artist = null, album = null, thumbnails = emptyList(), + externalUri = null, confidence = 1f) + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt new file mode 100644 index 00000000..9eb8e2db --- /dev/null +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/core/jam/TcpProxy.kt @@ -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 . + */ + +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() + + @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() + } + } +} diff --git a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt index 7a67d3bb..823248e9 100644 --- a/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt +++ b/composeApp/src/jvmTest/kotlin/org/spotube/spotube/modules/jam/JamLayoutTest.kt @@ -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, {}) } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d8ec04eb..dbf032fe 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }