mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
6 Commits
42c2d0fa7e
...
57fdde5f85
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57fdde5f85 | ||
|
|
0541fd9d23 | ||
|
|
ac5971846b | ||
|
|
aaa4d66db1 | ||
|
|
45a7758eb0 | ||
|
|
0a0b0c9374 |
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal actual fun jamDeviceTraits(): List<String> {
|
||||
val display = Resources.getSystem().displayMetrics
|
||||
return listOf(
|
||||
"android",
|
||||
Build.MANUFACTURER,
|
||||
Build.MODEL,
|
||||
Build.DEVICE,
|
||||
Build.VERSION.RELEASE,
|
||||
Build.VERSION.SDK_INT.toString(),
|
||||
"${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}",
|
||||
Locale.getDefault().toLanguageTag(),
|
||||
TimeZone.getDefault().id)
|
||||
}
|
||||
@ -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())
|
||||
}
|
||||
|
||||
|
||||
@ -308,25 +308,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">عرض محتوى الويب داخل التطبيق</string>
|
||||
<string name="settings_section_jam">الجام الجماعي</string>
|
||||
<string name="settings_jam_broker_title">وسيط MQTT</string>
|
||||
<string name="settings_jam_broker_host">مضيف الوسيط</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">منفذ الوسيط</string>
|
||||
<string name="settings_jam_broker_tls">استخدام TLS</string>
|
||||
<string name="settings_jam_broker_username">اسم المستخدم (اختياري)</string>
|
||||
<string name="settings_jam_broker_password">كلمة المرور (اختياري)</string>
|
||||
<string name="settings_jam_broker_client_id">بادئة معرّف العميل</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">تُستخدم لتعريف نسخة Spotube هذه</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">تشفير الاتصال بالوسيط</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">تحقق من إعدادات الوسيط الحالية قبل بدء غرفة.</string>
|
||||
<string name="settings_jam_broker_test">اختبار الاتصال</string>
|
||||
<string name="settings_jam_broker_testing">جارٍ الاختبار…</string>
|
||||
<string name="settings_jam_broker_test_ok">تم الاتصال خلال %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">فشل: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">وسيط مؤقت — اضبط وسيطك الخاص للاستضافة الذاتية</string>
|
||||
<string name="settings_jam_server_test">اختبار الاتصال</string>
|
||||
<string name="settings_jam_server_testing">جارٍ الاختبار…</string>
|
||||
<string name="settings_jam_server_test_ok">تم الاتصال خلال %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">فشل: %1$s</string>
|
||||
<string name="settings_jam_section_connection">الاتصال</string>
|
||||
<string name="settings_jam_section_credentials">بيانات الاعتماد</string>
|
||||
<string name="settings_jam_section_identity">الهوية</string>
|
||||
<string name="settings_jam_section_diagnostics">التشخيص</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -339,9 +325,6 @@
|
||||
<string name="jam_setup_working">جارٍ الاتصال…</string>
|
||||
<string name="jam_setup_code_label">رمز الغرفة</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">الوسيط: %1$s</string>
|
||||
<string name="jam_connection_missing">اضبط وسيطًا في الإعدادات لاستضافة غرفة أو الانضمام إليها.</string>
|
||||
<string name="jam_connection_settings">إعدادات الوسيط</string>
|
||||
<string name="jam_session_connected">متصل</string>
|
||||
<string name="jam_session_connecting">جارٍ إعادة الاتصال…</string>
|
||||
<string name="jam_invite_copy">نسخ الرمز</string>
|
||||
@ -361,6 +344,28 @@
|
||||
<string name="jam_error_join">تعذّر الانضمام إلى جلسة الجام</string>
|
||||
<string name="jam_error_invalid_code">أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.</string>
|
||||
<string name="jam_share_message">انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s</string>
|
||||
<string name="settings_jam_server_title">خادم الغرف</string>
|
||||
<string name="settings_jam_server_url">رابط الخادم</string>
|
||||
<string name="settings_jam_server_url_invalid">أدخل رابطًا يبدأ بـ https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">تحقّق من خادم الغرف قبل بدء غرفة.</string>
|
||||
<string name="settings_jam_server_note">يستخدم الجام خادم الغرف المجاني من Spotube. للاستضافة الذاتية، انشر الخادم من github.com/team-spotube/jam-server وأدخل رابطه.</string>
|
||||
<string name="settings_jam_section_you">أنت</string>
|
||||
<string name="jam_connection_host">الخادم: %1$s</string>
|
||||
<string name="jam_connection_missing">حدّد خادم غرف في الإعدادات لاستضافة غرفة أو الانضمام إليها.</string>
|
||||
<string name="jam_connection_settings">إعدادات الخادم</string>
|
||||
<string name="jam_participants_you">%1$s (أنت)</string>
|
||||
<string name="jam_name_title">اسمك</string>
|
||||
<string name="jam_name_subtitle">يراك الآخرون في الجام باسم %1$s</string>
|
||||
<string name="jam_name_description">يظهر لجميع من في الجام. اتركه فارغًا لاستخدام %1$s. يُطبَّق الاسم الجديد في المرة القادمة التي تنشئ فيها غرفة أو تنضم إليها.</string>
|
||||
<string name="jam_name_too_long">استخدم %1$d حرفًا كحد أقصى.</string>
|
||||
<string name="jam_error_no_server">لم يتم إعداد خادم للجام</string>
|
||||
<string name="jam_error_server_unreachable">تعذّر الوصول إلى خادم الجام. تحقّق من الشبكة أو من رابط الخادم في الإعدادات ← الجام الجماعي.</string>
|
||||
<string name="jam_error_server_incompatible">يعمل خادم الجام بإصدار غير متوافق.</string>
|
||||
<string name="jam_error_room_not_found">لا توجد غرفة بهذا الرمز. تحقّق منه وحاول مجددًا.</string>
|
||||
<string name="jam_error_room_full">هذه الغرفة ممتلئة.</string>
|
||||
<string name="jam_error_banned">حظرك المضيف من هذه الغرفة.</string>
|
||||
<string name="jam_error_kicked">أزالك المضيف من الغرفة.</string>
|
||||
<string name="jam_error_replaced">فُتحت هذه الغرفة مجددًا على هذا الجهاز، لذا أُغلق هذا الاتصال.</string>
|
||||
|
||||
<string name="player_go_to_album">الانتقال إلى الألبوم</string>
|
||||
<string name="player_sleep_timer">مؤقت النوم</string>
|
||||
@ -533,14 +538,9 @@
|
||||
<string name="jam_destination_suggest">اقتراح للجام</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">لم يتم ضبط وسيط للجام</string>
|
||||
<string name="jam_error_broker_host_empty">مضيف الوسيط فارغ</string>
|
||||
<string name="jam_error_connection_failed">فشل الاتصال</string>
|
||||
<string name="jam_error_connection_lost">انقطع الاتصال</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">الوسيط</string>
|
||||
<string name="settings_jam_broker_username_placeholder">مجهول</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">تمت الإضافة إلى قائمة انتظار الجام</string>
|
||||
|
||||
@ -292,25 +292,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">অ্যাপের ভেতরে ওয়েব কনটেন্ট দেখানো</string>
|
||||
<string name="settings_section_jam">গ্রুপ জ্যাম</string>
|
||||
<string name="settings_jam_broker_title">MQTT ব্রোকার</string>
|
||||
<string name="settings_jam_broker_host">ব্রোকার হোস্ট</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">ব্রোকার পোর্ট</string>
|
||||
<string name="settings_jam_broker_tls">TLS ব্যবহার করুন</string>
|
||||
<string name="settings_jam_broker_username">ইউজারনেম (ঐচ্ছিক)</string>
|
||||
<string name="settings_jam_broker_password">পাসওয়ার্ড (ঐচ্ছিক)</string>
|
||||
<string name="settings_jam_broker_client_id">ক্লায়েন্ট ID প্রিফিক্স</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">এই Spotube শনাক্ত করতে ব্যবহৃত হয়</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">ব্রোকার কানেকশন এনক্রিপ্ট করুন</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">রুম শুরু করার আগে বর্তমান ব্রোকার সেটিংস যাচাই করুন।</string>
|
||||
<string name="settings_jam_broker_test">কানেকশন পরীক্ষা করুন</string>
|
||||
<string name="settings_jam_broker_testing">পরীক্ষা চলছে…</string>
|
||||
<string name="settings_jam_broker_test_ok">%1$d ms-এ কানেক্ট হয়েছে</string>
|
||||
<string name="settings_jam_broker_test_fail">ব্যর্থ: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">প্লেসহোল্ডার ব্রোকার — সেলফ-হোস্ট করতে নিজেরটি কনফিগার করুন</string>
|
||||
<string name="settings_jam_server_test">কানেকশন পরীক্ষা করুন</string>
|
||||
<string name="settings_jam_server_testing">পরীক্ষা চলছে…</string>
|
||||
<string name="settings_jam_server_test_ok">%1$d ms-এ কানেক্ট হয়েছে</string>
|
||||
<string name="settings_jam_server_test_fail">ব্যর্থ: %1$s</string>
|
||||
<string name="settings_jam_section_connection">কানেকশন</string>
|
||||
<string name="settings_jam_section_credentials">ক্রেডেনশিয়াল</string>
|
||||
<string name="settings_jam_section_identity">পরিচিতি</string>
|
||||
<string name="settings_jam_section_diagnostics">ডায়াগনস্টিকস</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -323,9 +309,6 @@
|
||||
<string name="jam_setup_working">কানেক্ট হচ্ছে…</string>
|
||||
<string name="jam_setup_code_label">রুম কোড</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">ব্রোকার: %1$s</string>
|
||||
<string name="jam_connection_missing">রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি ব্রোকার সেট আপ করুন।</string>
|
||||
<string name="jam_connection_settings">ব্রোকার সেটিংস</string>
|
||||
<string name="jam_session_connected">কানেক্টেড</string>
|
||||
<string name="jam_session_connecting">আবার কানেক্ট হচ্ছে…</string>
|
||||
<string name="jam_invite_copy">কোড কপি করুন</string>
|
||||
@ -345,6 +328,28 @@
|
||||
<string name="jam_error_join">জ্যাম সেশনে যোগ দেওয়া যায়নি</string>
|
||||
<string name="jam_error_invalid_code">অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।</string>
|
||||
<string name="jam_share_message">এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s</string>
|
||||
<string name="settings_jam_server_title">রুম সার্ভার</string>
|
||||
<string name="settings_jam_server_url">সার্ভার URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// দিয়ে শুরু হওয়া একটি URL দিন</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">রুম শুরু করার আগে রুম সার্ভার পরীক্ষা করুন।</string>
|
||||
<string name="settings_jam_server_note">জ্যাম Spotube-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে github.com/team-spotube/jam-server থেকে সার্ভার ডিপ্লয় করে তার URL দিন।</string>
|
||||
<string name="settings_jam_section_you">আপনি</string>
|
||||
<string name="jam_connection_host">সার্ভার: %1$s</string>
|
||||
<string name="jam_connection_missing">রুম হোস্ট করতে বা যোগ দিতে সেটিংসে একটি রুম সার্ভার সেট করুন।</string>
|
||||
<string name="jam_connection_settings">সার্ভার সেটিংস</string>
|
||||
<string name="jam_participants_you">%1$s (আপনি)</string>
|
||||
<string name="jam_name_title">আপনার নাম</string>
|
||||
<string name="jam_name_subtitle">জ্যামের অন্যরা আপনাকে %1$s নামে দেখবে</string>
|
||||
<string name="jam_name_description">জ্যামের সবাই দেখতে পাবে। %1$s ব্যবহার করতে ফাঁকা রাখুন। নতুন নাম পরের বার রুম তৈরি বা যোগ দেওয়ার সময় থেকে কার্যকর হবে।</string>
|
||||
<string name="jam_name_too_long">সর্বোচ্চ %1$dটি অক্ষর ব্যবহার করুন।</string>
|
||||
<string name="jam_error_no_server">কোনো জ্যাম সার্ভার সেট করা নেই</string>
|
||||
<string name="jam_error_server_unreachable">জ্যাম সার্ভারে পৌঁছানো যায়নি। আপনার নেটওয়ার্ক বা সেটিংস → গ্রুপ জ্যাম-এ সার্ভার URL পরীক্ষা করুন।</string>
|
||||
<string name="jam_error_server_incompatible">জ্যাম সার্ভারের সংস্করণটি সামঞ্জস্যপূর্ণ নয়।</string>
|
||||
<string name="jam_error_room_not_found">এই কোডের কোনো রুম নেই। কোডটি দেখে আবার চেষ্টা করুন।</string>
|
||||
<string name="jam_error_room_full">এই রুমটি পূর্ণ।</string>
|
||||
<string name="jam_error_banned">হোস্ট আপনাকে এই রুম থেকে ব্যান করেছেন।</string>
|
||||
<string name="jam_error_kicked">হোস্ট আপনাকে রুম থেকে সরিয়ে দিয়েছেন।</string>
|
||||
<string name="jam_error_replaced">এই রুমটি এই ডিভাইসে আবার খোলা হয়েছে, তাই এই কানেকশনটি বন্ধ করা হয়েছে।</string>
|
||||
|
||||
<string name="player_go_to_album">অ্যালবামে যান</string>
|
||||
<string name="player_sleep_timer">স্লিপ টাইমার</string>
|
||||
@ -517,14 +522,9 @@
|
||||
<string name="jam_destination_suggest">জ্যামে সাজেস্ট করুন</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">কোনো জ্যাম ব্রোকার কনফিগার করা নেই</string>
|
||||
<string name="jam_error_broker_host_empty">ব্রোকার হোস্ট ফাঁকা</string>
|
||||
<string name="jam_error_connection_failed">কানেকশন ব্যর্থ হয়েছে</string>
|
||||
<string name="jam_error_connection_lost">কানেকশন বিচ্ছিন্ন হয়েছে</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">ব্রোকার</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">জ্যাম কিউতে যোগ করা হয়েছে</string>
|
||||
|
||||
@ -292,25 +292,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Webinhalte in der App anzeigen</string>
|
||||
<string name="settings_section_jam">Gruppen-Jam</string>
|
||||
<string name="settings_jam_broker_title">MQTT-Broker</string>
|
||||
<string name="settings_jam_broker_host">Broker-Host</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Broker-Port</string>
|
||||
<string name="settings_jam_broker_tls">TLS verwenden</string>
|
||||
<string name="settings_jam_broker_username">Benutzername (optional)</string>
|
||||
<string name="settings_jam_broker_password">Passwort (optional)</string>
|
||||
<string name="settings_jam_broker_client_id">Client-ID-Präfix</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Dient zur Identifizierung dieser Spotube-Instanz</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Verbindung zum Broker verschlüsseln</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Prüfe die aktuellen Broker-Einstellungen, bevor du einen Raum startest.</string>
|
||||
<string name="settings_jam_broker_test">Verbindung testen</string>
|
||||
<string name="settings_jam_broker_testing">Wird getestet…</string>
|
||||
<string name="settings_jam_broker_test_ok">Verbunden in %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Fehlgeschlagen: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Platzhalter-Broker – richte deinen eigenen ein, um selbst zu hosten</string>
|
||||
<string name="settings_jam_server_test">Verbindung testen</string>
|
||||
<string name="settings_jam_server_testing">Wird getestet…</string>
|
||||
<string name="settings_jam_server_test_ok">Verbunden in %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Fehlgeschlagen: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Verbindung</string>
|
||||
<string name="settings_jam_section_credentials">Zugangsdaten</string>
|
||||
<string name="settings_jam_section_identity">Identität</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnose</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -323,9 +309,6 @@
|
||||
<string name="jam_setup_working">Verbinden…</string>
|
||||
<string name="jam_setup_code_label">Raumcode</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Richte in den Einstellungen einen Broker ein, um einen Raum zu hosten oder beizutreten.</string>
|
||||
<string name="jam_connection_settings">Broker-Einstellungen</string>
|
||||
<string name="jam_session_connected">Verbunden</string>
|
||||
<string name="jam_session_connecting">Verbindung wird wiederhergestellt…</string>
|
||||
<string name="jam_invite_copy">Code kopieren</string>
|
||||
@ -345,6 +328,28 @@
|
||||
<string name="jam_error_join">Beitritt zur Jam-Session fehlgeschlagen</string>
|
||||
<string name="jam_error_invalid_code">Gib einen sechsstelligen Raumcode aus Buchstaben und Ziffern ein.</string>
|
||||
<string name="jam_share_message">Tritt meiner Spotube-Jam bei, Code: %1$s</string>
|
||||
<string name="settings_jam_server_title">Raumserver</string>
|
||||
<string name="settings_jam_server_url">Server-URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Gib eine URL ein, die mit https:// beginnt.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Prüfe den Raumserver, bevor du einen Raum startest.</string>
|
||||
<string name="settings_jam_server_note">Jam nutzt den kostenlosen Raumserver von Spotube. Zum Selbsthosten stelle den Server von github.com/team-spotube/jam-server bereit und gib seine URL ein.</string>
|
||||
<string name="settings_jam_section_you">Du</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Lege in den Einstellungen einen Raumserver fest, um einen Raum zu hosten oder beizutreten.</string>
|
||||
<string name="jam_connection_settings">Servereinstellungen</string>
|
||||
<string name="jam_participants_you">%1$s (du)</string>
|
||||
<string name="jam_name_title">Dein Name</string>
|
||||
<string name="jam_name_subtitle">Andere im Jam sehen dich als %1$s</string>
|
||||
<string name="jam_name_description">Wird allen im Jam angezeigt. Lass das Feld leer, um %1$s zu verwenden. Ein neuer Name gilt ab dem nächsten Raum, den du startest oder dem du beitrittst.</string>
|
||||
<string name="jam_name_too_long">Verwende höchstens %1$d Zeichen.</string>
|
||||
<string name="jam_error_no_server">Kein Jam-Server eingerichtet</string>
|
||||
<string name="jam_error_server_unreachable">Der Jam-Server ist nicht erreichbar. Prüfe dein Netzwerk oder die Server-URL unter Einstellungen → Gruppen-Jam.</string>
|
||||
<string name="jam_error_server_incompatible">Der Jam-Server verwendet eine inkompatible Version.</string>
|
||||
<string name="jam_error_room_not_found">Kein Raum mit diesem Code. Prüfe ihn und versuche es erneut.</string>
|
||||
<string name="jam_error_room_full">Dieser Raum ist voll.</string>
|
||||
<string name="jam_error_banned">Der Host hat dich aus diesem Raum gesperrt.</string>
|
||||
<string name="jam_error_kicked">Der Host hat dich aus dem Raum entfernt.</string>
|
||||
<string name="jam_error_replaced">Dieser Raum wurde auf diesem Gerät erneut geöffnet, daher wurde diese Verbindung getrennt.</string>
|
||||
|
||||
<string name="player_go_to_album">Zum Album</string>
|
||||
<string name="player_sleep_timer">Sleep-Timer</string>
|
||||
@ -517,14 +522,9 @@
|
||||
<string name="jam_destination_suggest">Für Jam vorschlagen</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Kein Jam-Broker konfiguriert</string>
|
||||
<string name="jam_error_broker_host_empty">Broker-Host ist leer</string>
|
||||
<string name="jam_error_connection_failed">Verbindung fehlgeschlagen</string>
|
||||
<string name="jam_error_connection_lost">Verbindung unterbrochen</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonym</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Zur Jam-Warteschlange hinzugefügt</string>
|
||||
|
||||
@ -296,25 +296,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Mostrar contenido web dentro de la app</string>
|
||||
<string name="settings_section_jam">Jam en grupo</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Host del broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Puerto del broker</string>
|
||||
<string name="settings_jam_broker_tls">Usar TLS</string>
|
||||
<string name="settings_jam_broker_username">Usuario (opcional)</string>
|
||||
<string name="settings_jam_broker_password">Contraseña (opcional)</string>
|
||||
<string name="settings_jam_broker_client_id">Prefijo de ID de cliente</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Se usa para identificar esta instancia de Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Cifra la conexión con el broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Comprueba los ajustes actuales del broker antes de crear una sala.</string>
|
||||
<string name="settings_jam_broker_test">Probar conexión</string>
|
||||
<string name="settings_jam_broker_testing">Probando…</string>
|
||||
<string name="settings_jam_broker_test_ok">Conectado en %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Error: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker de ejemplo: configura el tuyo para alojarlo tú mismo</string>
|
||||
<string name="settings_jam_server_test">Probar conexión</string>
|
||||
<string name="settings_jam_server_testing">Probando…</string>
|
||||
<string name="settings_jam_server_test_ok">Conectado en %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Error: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Conexión</string>
|
||||
<string name="settings_jam_section_credentials">Credenciales</string>
|
||||
<string name="settings_jam_section_identity">Identidad</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnóstico</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -327,9 +313,6 @@
|
||||
<string name="jam_setup_working">Conectando…</string>
|
||||
<string name="jam_setup_code_label">Código de la sala</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Configura un broker en Ajustes para crear una sala o unirte a una.</string>
|
||||
<string name="jam_connection_settings">Ajustes del broker</string>
|
||||
<string name="jam_session_connected">Conectado</string>
|
||||
<string name="jam_session_connecting">Reconectando…</string>
|
||||
<string name="jam_invite_copy">Copiar código</string>
|
||||
@ -349,6 +332,28 @@
|
||||
<string name="jam_error_join">No se pudo unir a la sesión de Jam</string>
|
||||
<string name="jam_error_invalid_code">Introduce un código de sala de seis caracteres con letras y números.</string>
|
||||
<string name="jam_share_message">Únete a mi Jam de Spotube con el código: %1$s</string>
|
||||
<string name="settings_jam_server_title">Servidor de salas</string>
|
||||
<string name="settings_jam_server_url">URL del servidor</string>
|
||||
<string name="settings_jam_server_url_invalid">Introduce una URL que empiece por https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Comprueba el servidor de salas antes de crear una sala.</string>
|
||||
<string name="settings_jam_server_note">Jam usa el servidor de salas gratuito de Spotube. Para alojarlo tú mismo, despliega el servidor de github.com/team-spotube/jam-server e introduce su URL.</string>
|
||||
<string name="settings_jam_section_you">Tú</string>
|
||||
<string name="jam_connection_host">Servidor: %1$s</string>
|
||||
<string name="jam_connection_missing">Configura un servidor de salas en Ajustes para crear una sala o unirte a una.</string>
|
||||
<string name="jam_connection_settings">Ajustes del servidor</string>
|
||||
<string name="jam_participants_you">%1$s (tú)</string>
|
||||
<string name="jam_name_title">Tu nombre</string>
|
||||
<string name="jam_name_subtitle">Los demás en la jam te ven como %1$s</string>
|
||||
<string name="jam_name_description">Todos en la jam lo verán. Déjalo vacío para usar %1$s. Un nombre nuevo se aplica la próxima vez que crees una sala o te unas a una.</string>
|
||||
<string name="jam_name_too_long">Usa como máximo %1$d caracteres.</string>
|
||||
<string name="jam_error_no_server">No hay ningún servidor de jam configurado</string>
|
||||
<string name="jam_error_server_unreachable">No se pudo conectar con el servidor de jam. Revisa tu red o la URL del servidor en Ajustes → Jam en grupo.</string>
|
||||
<string name="jam_error_server_incompatible">El servidor de jam usa una versión incompatible.</string>
|
||||
<string name="jam_error_room_not_found">No hay ninguna sala con ese código. Revísalo e inténtalo de nuevo.</string>
|
||||
<string name="jam_error_room_full">Esta sala está llena.</string>
|
||||
<string name="jam_error_banned">El anfitrión te ha bloqueado en esta sala.</string>
|
||||
<string name="jam_error_kicked">El anfitrión te ha quitado de la sala.</string>
|
||||
<string name="jam_error_replaced">Esta sala se abrió de nuevo en este dispositivo, así que se cerró esta conexión.</string>
|
||||
|
||||
<string name="player_go_to_album">Ir al álbum</string>
|
||||
<string name="player_sleep_timer">Temporizador</string>
|
||||
@ -521,14 +526,9 @@
|
||||
<string name="jam_destination_suggest">Sugerir al Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">No hay ningún broker de Jam configurado</string>
|
||||
<string name="jam_error_broker_host_empty">El host del broker está vacío</string>
|
||||
<string name="jam_error_connection_failed">Error de conexión</string>
|
||||
<string name="jam_error_connection_lost">Se perdió la conexión</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anónimo</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Añadida a la cola del Jam</string>
|
||||
|
||||
@ -292,25 +292,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">نمایش محتوای وب درون برنامه</string>
|
||||
<string name="settings_section_jam">جم گروهی</string>
|
||||
<string name="settings_jam_broker_title">بروکر MQTT</string>
|
||||
<string name="settings_jam_broker_host">میزبان بروکر</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">درگاه بروکر</string>
|
||||
<string name="settings_jam_broker_tls">استفاده از TLS</string>
|
||||
<string name="settings_jam_broker_username">نام کاربری (اختیاری)</string>
|
||||
<string name="settings_jam_broker_password">گذرواژه (اختیاری)</string>
|
||||
<string name="settings_jam_broker_client_id">پیشوند شناسهٔ کلاینت</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">برای شناسایی این نسخه از Spotube استفاده میشود</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">رمزگذاری اتصال به بروکر</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">پیش از شروع اتاق، تنظیمات فعلی بروکر را بررسی کنید.</string>
|
||||
<string name="settings_jam_broker_test">آزمایش اتصال</string>
|
||||
<string name="settings_jam_broker_testing">در حال آزمایش…</string>
|
||||
<string name="settings_jam_broker_test_ok">در %1$d میلیثانیه متصل شد</string>
|
||||
<string name="settings_jam_broker_test_fail">ناموفق: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">بروکر نمونه — برای میزبانی شخصی، بروکر خودتان را پیکربندی کنید</string>
|
||||
<string name="settings_jam_server_test">آزمایش اتصال</string>
|
||||
<string name="settings_jam_server_testing">در حال آزمایش…</string>
|
||||
<string name="settings_jam_server_test_ok">در %1$d میلیثانیه متصل شد</string>
|
||||
<string name="settings_jam_server_test_fail">ناموفق: %1$s</string>
|
||||
<string name="settings_jam_section_connection">اتصال</string>
|
||||
<string name="settings_jam_section_credentials">اطلاعات ورود</string>
|
||||
<string name="settings_jam_section_identity">هویت</string>
|
||||
<string name="settings_jam_section_diagnostics">عیبیابی</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -323,9 +309,6 @@
|
||||
<string name="jam_setup_working">در حال اتصال…</string>
|
||||
<string name="jam_setup_code_label">کد اتاق</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">بروکر: %1$s</string>
|
||||
<string name="jam_connection_missing">برای میزبانی یا پیوستن به اتاق، یک بروکر در تنظیمات راهاندازی کنید.</string>
|
||||
<string name="jam_connection_settings">تنظیمات بروکر</string>
|
||||
<string name="jam_session_connected">متصل</string>
|
||||
<string name="jam_session_connecting">در حال اتصال دوباره…</string>
|
||||
<string name="jam_invite_copy">کپی کد</string>
|
||||
@ -345,6 +328,28 @@
|
||||
<string name="jam_error_join">پیوستن به جلسهٔ جم ناموفق بود</string>
|
||||
<string name="jam_error_invalid_code">یک کد اتاق ششنویسهای شامل حروف و اعداد وارد کنید.</string>
|
||||
<string name="jam_share_message">با این کد به جم Spotube من بپیوندید: %1$s</string>
|
||||
<string name="settings_jam_server_title">سرور اتاقها</string>
|
||||
<string name="settings_jam_server_url">نشانی سرور</string>
|
||||
<string name="settings_jam_server_url_invalid">نشانیای وارد کنید که با https:// شروع شود</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">پیش از شروع اتاق، سرور اتاقها را بررسی کنید.</string>
|
||||
<string name="settings_jam_server_note">جم از سرور رایگان اتاقهای Spotube استفاده میکند. برای میزبانی شخصی، سرور را از github.com/team-spotube/jam-server راهاندازی کنید و نشانی آن را وارد کنید.</string>
|
||||
<string name="settings_jam_section_you">شما</string>
|
||||
<string name="jam_connection_host">سرور: %1$s</string>
|
||||
<string name="jam_connection_missing">برای میزبانی یا پیوستن به اتاق، در تنظیمات یک سرور اتاق تعیین کنید.</string>
|
||||
<string name="jam_connection_settings">تنظیمات سرور</string>
|
||||
<string name="jam_participants_you">%1$s (شما)</string>
|
||||
<string name="jam_name_title">نام شما</string>
|
||||
<string name="jam_name_subtitle">دیگران در جم شما را با نام %1$s میبینند</string>
|
||||
<string name="jam_name_description">به همه در جم نشان داده میشود. برای استفاده از %1$s خالی بگذارید. نام جدید از دفعهٔ بعدی که اتاقی میسازید یا به آن میپیوندید اعمال میشود.</string>
|
||||
<string name="jam_name_too_long">حداکثر %1$d نویسه وارد کنید.</string>
|
||||
<string name="jam_error_no_server">هیچ سرور جمی تنظیم نشده است</string>
|
||||
<string name="jam_error_server_unreachable">دسترسی به سرور جم ممکن نیست. شبکه یا نشانی سرور را در تنظیمات ← جم گروهی بررسی کنید.</string>
|
||||
<string name="jam_error_server_incompatible">سرور جم نسخهٔ ناسازگاری اجرا میکند.</string>
|
||||
<string name="jam_error_room_not_found">اتاقی با این کد وجود ندارد. آن را بررسی کنید و دوباره امتحان کنید.</string>
|
||||
<string name="jam_error_room_full">این اتاق پر است.</string>
|
||||
<string name="jam_error_banned">میزبان شما را از این اتاق مسدود کرد.</string>
|
||||
<string name="jam_error_kicked">میزبان شما را از اتاق بیرون کرد.</string>
|
||||
<string name="jam_error_replaced">این اتاق دوباره روی همین دستگاه باز شد، بنابراین این اتصال بسته شد.</string>
|
||||
|
||||
<string name="player_go_to_album">رفتن به آلبوم</string>
|
||||
<string name="player_sleep_timer">زمانسنج خواب</string>
|
||||
@ -517,14 +522,9 @@
|
||||
<string name="jam_destination_suggest">پیشنهاد به جم</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">هیچ بروکر جمی پیکربندی نشده است</string>
|
||||
<string name="jam_error_broker_host_empty">میزبان بروکر خالی است</string>
|
||||
<string name="jam_error_connection_failed">اتصال ناموفق بود</string>
|
||||
<string name="jam_error_connection_lost">اتصال قطع شد</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">بروکر</string>
|
||||
<string name="settings_jam_broker_username_placeholder">ناشناس</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">به صف جم اضافه شد</string>
|
||||
|
||||
@ -296,25 +296,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Afficher du contenu web dans l\'application</string>
|
||||
<string name="settings_section_jam">Jam de groupe</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Hôte du broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Port du broker</string>
|
||||
<string name="settings_jam_broker_tls">Utiliser TLS</string>
|
||||
<string name="settings_jam_broker_username">Nom d\'utilisateur (facultatif)</string>
|
||||
<string name="settings_jam_broker_password">Mot de passe (facultatif)</string>
|
||||
<string name="settings_jam_broker_client_id">Préfixe de l\'ID client</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Sert à identifier cette instance de Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Chiffrer la connexion au broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Vérifiez les paramètres actuels du broker avant de créer une salle.</string>
|
||||
<string name="settings_jam_broker_test">Tester la connexion</string>
|
||||
<string name="settings_jam_broker_testing">Test en cours…</string>
|
||||
<string name="settings_jam_broker_test_ok">Connecté en %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Échec : %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker temporaire : configurez le vôtre pour l\'auto-héberger</string>
|
||||
<string name="settings_jam_server_test">Tester la connexion</string>
|
||||
<string name="settings_jam_server_testing">Test en cours…</string>
|
||||
<string name="settings_jam_server_test_ok">Connecté en %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Échec : %1$s</string>
|
||||
<string name="settings_jam_section_connection">Connexion</string>
|
||||
<string name="settings_jam_section_credentials">Identifiants</string>
|
||||
<string name="settings_jam_section_identity">Identité</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostic</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -327,9 +313,6 @@
|
||||
<string name="jam_setup_working">Connexion…</string>
|
||||
<string name="jam_setup_code_label">Code de la salle</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker : %1$s</string>
|
||||
<string name="jam_connection_missing">Configurez un broker dans les Paramètres pour créer ou rejoindre une salle.</string>
|
||||
<string name="jam_connection_settings">Paramètres du broker</string>
|
||||
<string name="jam_session_connected">Connecté</string>
|
||||
<string name="jam_session_connecting">Reconnexion…</string>
|
||||
<string name="jam_invite_copy">Copier le code</string>
|
||||
@ -349,6 +332,28 @@
|
||||
<string name="jam_error_join">Impossible de rejoindre la session Jam</string>
|
||||
<string name="jam_error_invalid_code">Saisissez un code de salle de six caractères (lettres et chiffres).</string>
|
||||
<string name="jam_share_message">Rejoignez ma Jam Spotube avec le code : %1$s</string>
|
||||
<string name="settings_jam_server_title">Serveur de salles</string>
|
||||
<string name="settings_jam_server_url">URL du serveur</string>
|
||||
<string name="settings_jam_server_url_invalid">Saisissez une URL commençant par https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Vérifiez le serveur de salles avant de lancer une salle.</string>
|
||||
<string name="settings_jam_server_note">Jam utilise le serveur de salles gratuit de Spotube. Pour l’héberger vous-même, déployez le serveur depuis github.com/team-spotube/jam-server et saisissez son URL.</string>
|
||||
<string name="settings_jam_section_you">Vous</string>
|
||||
<string name="jam_connection_host">Serveur : %1$s</string>
|
||||
<string name="jam_connection_missing">Définissez un serveur de salles dans les Paramètres pour créer ou rejoindre une salle.</string>
|
||||
<string name="jam_connection_settings">Paramètres du serveur</string>
|
||||
<string name="jam_participants_you">%1$s (vous)</string>
|
||||
<string name="jam_name_title">Votre nom</string>
|
||||
<string name="jam_name_subtitle">Les autres participants vous voient comme %1$s</string>
|
||||
<string name="jam_name_description">Visible par tous les participants de la jam. Laissez vide pour utiliser %1$s. Un nouveau nom s’applique la prochaine fois que vous créez ou rejoignez une salle.</string>
|
||||
<string name="jam_name_too_long">Utilisez au maximum %1$d caractères.</string>
|
||||
<string name="jam_error_no_server">Aucun serveur de jam configuré</string>
|
||||
<string name="jam_error_server_unreachable">Impossible de joindre le serveur de jam. Vérifiez votre réseau ou l’URL du serveur dans Paramètres → Jam de groupe.</string>
|
||||
<string name="jam_error_server_incompatible">Le serveur de jam utilise une version incompatible.</string>
|
||||
<string name="jam_error_room_not_found">Aucune salle avec ce code. Vérifiez-le et réessayez.</string>
|
||||
<string name="jam_error_room_full">Cette salle est pleine.</string>
|
||||
<string name="jam_error_banned">L’hôte vous a banni de cette salle.</string>
|
||||
<string name="jam_error_kicked">L’hôte vous a retiré de la salle.</string>
|
||||
<string name="jam_error_replaced">Cette salle a été rouverte sur cet appareil, cette connexion a donc été fermée.</string>
|
||||
|
||||
<string name="player_go_to_album">Accéder à l\'album</string>
|
||||
<string name="player_sleep_timer">Minuteur de veille</string>
|
||||
@ -521,14 +526,9 @@
|
||||
<string name="jam_destination_suggest">Proposer à la Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Aucun broker Jam configuré</string>
|
||||
<string name="jam_error_broker_host_empty">L\'hôte du broker est vide</string>
|
||||
<string name="jam_error_connection_failed">Échec de la connexion</string>
|
||||
<string name="jam_error_connection_lost">Connexion perdue</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonyme</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Ajouté à la file d\'attente de la Jam</string>
|
||||
|
||||
@ -291,25 +291,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">ऐप के अंदर वेब कंटेंट दिखाएं</string>
|
||||
<string name="settings_section_jam">ग्रुप जैम</string>
|
||||
<string name="settings_jam_broker_title">MQTT ब्रोकर</string>
|
||||
<string name="settings_jam_broker_host">ब्रोकर होस्ट</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">ब्रोकर पोर्ट</string>
|
||||
<string name="settings_jam_broker_tls">TLS इस्तेमाल करें</string>
|
||||
<string name="settings_jam_broker_username">यूज़रनेम (वैकल्पिक)</string>
|
||||
<string name="settings_jam_broker_password">पासवर्ड (वैकल्पिक)</string>
|
||||
<string name="settings_jam_broker_client_id">क्लाइंट ID प्रीफ़िक्स</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">इस Spotube की पहचान के लिए इस्तेमाल होता है</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">ब्रोकर कनेक्शन एन्क्रिप्ट करें</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">रूम शुरू करने से पहले मौजूदा ब्रोकर सेटिंग जांचें।</string>
|
||||
<string name="settings_jam_broker_test">कनेक्शन टेस्ट करें</string>
|
||||
<string name="settings_jam_broker_testing">टेस्ट हो रहा है…</string>
|
||||
<string name="settings_jam_broker_test_ok">%1$d ms में कनेक्ट हुआ</string>
|
||||
<string name="settings_jam_broker_test_fail">विफल: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">प्लेसहोल्डर ब्रोकर — खुद होस्ट करने के लिए अपना ब्रोकर सेट करें</string>
|
||||
<string name="settings_jam_server_test">कनेक्शन टेस्ट करें</string>
|
||||
<string name="settings_jam_server_testing">टेस्ट हो रहा है…</string>
|
||||
<string name="settings_jam_server_test_ok">%1$d ms में कनेक्ट हुआ</string>
|
||||
<string name="settings_jam_server_test_fail">विफल: %1$s</string>
|
||||
<string name="settings_jam_section_connection">कनेक्शन</string>
|
||||
<string name="settings_jam_section_credentials">क्रेडेंशियल</string>
|
||||
<string name="settings_jam_section_identity">पहचान</string>
|
||||
<string name="settings_jam_section_diagnostics">डायग्नोस्टिक्स</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -322,9 +308,6 @@
|
||||
<string name="jam_setup_working">कनेक्ट हो रहा है…</string>
|
||||
<string name="jam_setup_code_label">रूम कोड</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">ब्रोकर: %1$s</string>
|
||||
<string name="jam_connection_missing">रूम होस्ट करने या उससे जुड़ने के लिए सेटिंग में ब्रोकर सेट करें।</string>
|
||||
<string name="jam_connection_settings">ब्रोकर सेटिंग</string>
|
||||
<string name="jam_session_connected">कनेक्ट है</string>
|
||||
<string name="jam_session_connecting">फिर से कनेक्ट हो रहा है…</string>
|
||||
<string name="jam_invite_copy">कोड कॉपी करें</string>
|
||||
@ -344,6 +327,28 @@
|
||||
<string name="jam_error_join">जैम सेशन से नहीं जुड़ सके</string>
|
||||
<string name="jam_error_invalid_code">अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।</string>
|
||||
<string name="jam_share_message">मेरे Spotube जैम से जुड़ें, कोड: %1$s</string>
|
||||
<string name="settings_jam_server_title">रूम सर्वर</string>
|
||||
<string name="settings_jam_server_url">सर्वर URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// से शुरू होने वाला URL डालें</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">रूम शुरू करने से पहले रूम सर्वर जांच लें।</string>
|
||||
<string name="settings_jam_server_note">जैम Spotube के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए github.com/team-spotube/jam-server से सर्वर डिप्लॉय करें और उसका URL डालें।</string>
|
||||
<string name="settings_jam_section_you">आप</string>
|
||||
<string name="jam_connection_host">सर्वर: %1$s</string>
|
||||
<string name="jam_connection_missing">रूम होस्ट करने या उसमें शामिल होने के लिए सेटिंग में रूम सर्वर सेट करें।</string>
|
||||
<string name="jam_connection_settings">सर्वर सेटिंग</string>
|
||||
<string name="jam_participants_you">%1$s (आप)</string>
|
||||
<string name="jam_name_title">आपका नाम</string>
|
||||
<string name="jam_name_subtitle">जैम में बाकी लोग आपको %1$s के रूप में देखते हैं</string>
|
||||
<string name="jam_name_description">जैम में सभी को दिखता है। %1$s इस्तेमाल करने के लिए खाली छोड़ें। नया नाम अगली बार रूम बनाने या उसमें शामिल होने पर लागू होगा।</string>
|
||||
<string name="jam_name_too_long">ज़्यादा से ज़्यादा %1$d अक्षर इस्तेमाल करें।</string>
|
||||
<string name="jam_error_no_server">कोई जैम सर्वर सेट नहीं है</string>
|
||||
<string name="jam_error_server_unreachable">जैम सर्वर तक नहीं पहुंच पाए। अपना नेटवर्क या सेटिंग → ग्रुप जैम में सर्वर URL जांचें।</string>
|
||||
<string name="jam_error_server_incompatible">जैम सर्वर का वर्शन संगत नहीं है।</string>
|
||||
<string name="jam_error_room_not_found">इस कोड वाला कोई रूम नहीं है। कोड जांचकर फिर से कोशिश करें।</string>
|
||||
<string name="jam_error_room_full">यह रूम भर गया है।</string>
|
||||
<string name="jam_error_banned">होस्ट ने आपको इस रूम से बैन कर दिया है।</string>
|
||||
<string name="jam_error_kicked">होस्ट ने आपको रूम से हटा दिया है।</string>
|
||||
<string name="jam_error_replaced">यह रूम इस डिवाइस पर फिर से खोला गया, इसलिए यह कनेक्शन बंद कर दिया गया।</string>
|
||||
|
||||
<string name="player_go_to_album">एल्बम पर जाएं</string>
|
||||
<string name="player_sleep_timer">स्लीप टाइमर</string>
|
||||
@ -516,14 +521,9 @@
|
||||
<string name="jam_destination_suggest">जैम में सुझाएं</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">कोई जैम ब्रोकर सेट नहीं है</string>
|
||||
<string name="jam_error_broker_host_empty">ब्रोकर होस्ट खाली है</string>
|
||||
<string name="jam_error_connection_failed">कनेक्शन विफल</string>
|
||||
<string name="jam_error_connection_lost">कनेक्शन टूट गया</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">ब्रोकर</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">जैम कतार में जोड़ा गया</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Menampilkan konten web di dalam aplikasi</string>
|
||||
<string name="settings_section_jam">Jam Grup</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Host broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Port broker</string>
|
||||
<string name="settings_jam_broker_tls">Gunakan TLS</string>
|
||||
<string name="settings_jam_broker_username">Nama pengguna (opsional)</string>
|
||||
<string name="settings_jam_broker_password">Kata sandi (opsional)</string>
|
||||
<string name="settings_jam_broker_client_id">Prefiks ID klien</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Dipakai untuk mengidentifikasi Spotube ini</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Enkripsi koneksi broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Periksa pengaturan broker saat ini sebelum memulai ruang.</string>
|
||||
<string name="settings_jam_broker_test">Uji koneksi</string>
|
||||
<string name="settings_jam_broker_testing">Menguji…</string>
|
||||
<string name="settings_jam_broker_test_ok">Terhubung dalam %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Gagal: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker sementara — konfigurasikan milikmu sendiri untuk self-host</string>
|
||||
<string name="settings_jam_server_test">Uji koneksi</string>
|
||||
<string name="settings_jam_server_testing">Menguji…</string>
|
||||
<string name="settings_jam_server_test_ok">Terhubung dalam %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Gagal: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Koneksi</string>
|
||||
<string name="settings_jam_section_credentials">Kredensial</string>
|
||||
<string name="settings_jam_section_identity">Identitas</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostik</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">Menghubungkan…</string>
|
||||
<string name="jam_setup_code_label">Kode ruang</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Atur broker di Pengaturan untuk membuat atau bergabung ke ruang.</string>
|
||||
<string name="jam_connection_settings">Pengaturan broker</string>
|
||||
<string name="jam_session_connected">Terhubung</string>
|
||||
<string name="jam_session_connecting">Menghubungkan ulang…</string>
|
||||
<string name="jam_invite_copy">Salin kode</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">Gagal bergabung ke sesi Jam</string>
|
||||
<string name="jam_error_invalid_code">Masukkan kode ruang enam karakter berisi huruf dan angka.</string>
|
||||
<string name="jam_share_message">Gabung ke Jam Spotube-ku dengan kode: %1$s</string>
|
||||
<string name="settings_jam_server_title">Server ruang</string>
|
||||
<string name="settings_jam_server_url">URL server</string>
|
||||
<string name="settings_jam_server_url_invalid">Masukkan URL yang diawali https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Periksa server ruang sebelum memulai ruang.</string>
|
||||
<string name="settings_jam_server_note">Jam memakai server ruang gratis milik Spotube. Untuk meng-host sendiri, deploy server dari github.com/team-spotube/jam-server lalu masukkan URL-nya.</string>
|
||||
<string name="settings_jam_section_you">Kamu</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Atur server ruang di Pengaturan untuk membuat atau bergabung ke ruang.</string>
|
||||
<string name="jam_connection_settings">Pengaturan server</string>
|
||||
<string name="jam_participants_you">%1$s (kamu)</string>
|
||||
<string name="jam_name_title">Namamu</string>
|
||||
<string name="jam_name_subtitle">Orang lain di jam melihatmu sebagai %1$s</string>
|
||||
<string name="jam_name_description">Ditampilkan ke semua orang di jam. Biarkan kosong untuk memakai %1$s. Nama baru berlaku saat kamu berikutnya membuat atau bergabung ke ruang.</string>
|
||||
<string name="jam_name_too_long">Gunakan paling banyak %1$d karakter.</string>
|
||||
<string name="jam_error_no_server">Belum ada server jam yang diatur</string>
|
||||
<string name="jam_error_server_unreachable">Tidak bisa menjangkau server jam. Periksa jaringan atau URL server di Pengaturan → Jam Grup.</string>
|
||||
<string name="jam_error_server_incompatible">Server jam menjalankan versi yang tidak kompatibel.</string>
|
||||
<string name="jam_error_room_not_found">Tidak ada ruang dengan kode itu. Periksa lalu coba lagi.</string>
|
||||
<string name="jam_error_room_full">Ruang ini sudah penuh.</string>
|
||||
<string name="jam_error_banned">Host memblokirmu dari ruang ini.</string>
|
||||
<string name="jam_error_kicked">Host mengeluarkanmu dari ruang.</string>
|
||||
<string name="jam_error_replaced">Ruang ini dibuka lagi di perangkat ini, jadi koneksi ini ditutup.</string>
|
||||
|
||||
<string name="player_go_to_album">Buka album</string>
|
||||
<string name="player_sleep_timer">Timer tidur</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">Sarankan ke Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Belum ada broker Jam yang dikonfigurasi</string>
|
||||
<string name="jam_error_broker_host_empty">Host broker kosong</string>
|
||||
<string name="jam_error_connection_failed">Koneksi gagal</string>
|
||||
<string name="jam_error_connection_lost">Koneksi terputus</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonim</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Ditambahkan ke antrean Jam</string>
|
||||
|
||||
@ -296,25 +296,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Mostrare contenuti web all\'interno dell\'app</string>
|
||||
<string name="settings_section_jam">Jam di gruppo</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Host del broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Porta del broker</string>
|
||||
<string name="settings_jam_broker_tls">Usa TLS</string>
|
||||
<string name="settings_jam_broker_username">Nome utente (facoltativo)</string>
|
||||
<string name="settings_jam_broker_password">Password (facoltativa)</string>
|
||||
<string name="settings_jam_broker_client_id">Prefisso ID client</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Usato per identificare questa istanza di Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Crittografa la connessione al broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Verifica le impostazioni attuali del broker prima di avviare una stanza.</string>
|
||||
<string name="settings_jam_broker_test">Verifica connessione</string>
|
||||
<string name="settings_jam_broker_testing">Verifica in corso…</string>
|
||||
<string name="settings_jam_broker_test_ok">Connesso in %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Non riuscito: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker di esempio: configurane uno tuo per l\'hosting autonomo</string>
|
||||
<string name="settings_jam_server_test">Verifica connessione</string>
|
||||
<string name="settings_jam_server_testing">Verifica in corso…</string>
|
||||
<string name="settings_jam_server_test_ok">Connesso in %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Non riuscito: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Connessione</string>
|
||||
<string name="settings_jam_section_credentials">Credenziali</string>
|
||||
<string name="settings_jam_section_identity">Identità</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostica</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -327,9 +313,6 @@
|
||||
<string name="jam_setup_working">Connessione…</string>
|
||||
<string name="jam_setup_code_label">Codice stanza</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Configura un broker nelle Impostazioni per ospitare una stanza o parteciparvi.</string>
|
||||
<string name="jam_connection_settings">Impostazioni broker</string>
|
||||
<string name="jam_session_connected">Connesso</string>
|
||||
<string name="jam_session_connecting">Riconnessione…</string>
|
||||
<string name="jam_invite_copy">Copia codice</string>
|
||||
@ -349,6 +332,28 @@
|
||||
<string name="jam_error_join">Impossibile partecipare alla sessione Jam</string>
|
||||
<string name="jam_error_invalid_code">Inserisci un codice stanza di sei caratteri composto da lettere e cifre.</string>
|
||||
<string name="jam_share_message">Unisciti alla mia Jam su Spotube con il codice: %1$s</string>
|
||||
<string name="settings_jam_server_title">Server delle stanze</string>
|
||||
<string name="settings_jam_server_url">URL del server</string>
|
||||
<string name="settings_jam_server_url_invalid">Inserisci un URL che inizi con https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Controlla il server delle stanze prima di avviare una stanza.</string>
|
||||
<string name="settings_jam_server_note">Jam usa il server delle stanze gratuito di Spotube. Per ospitarlo in proprio, distribuisci il server da github.com/team-spotube/jam-server e inseriscine l’URL.</string>
|
||||
<string name="settings_jam_section_you">Tu</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Imposta un server delle stanze nelle Impostazioni per ospitare una stanza o entrarci.</string>
|
||||
<string name="jam_connection_settings">Impostazioni del server</string>
|
||||
<string name="jam_participants_you">%1$s (tu)</string>
|
||||
<string name="jam_name_title">Il tuo nome</string>
|
||||
<string name="jam_name_subtitle">Gli altri nella jam ti vedono come %1$s</string>
|
||||
<string name="jam_name_description">Visibile a tutti nella jam. Lascia vuoto per usare %1$s. Un nuovo nome vale dalla prossima volta che avvii una stanza o ci entri.</string>
|
||||
<string name="jam_name_too_long">Usa al massimo %1$d caratteri.</string>
|
||||
<string name="jam_error_no_server">Nessun server jam configurato</string>
|
||||
<string name="jam_error_server_unreachable">Impossibile raggiungere il server jam. Controlla la rete o l’URL del server in Impostazioni → Jam di gruppo.</string>
|
||||
<string name="jam_error_server_incompatible">Il server jam usa una versione non compatibile.</string>
|
||||
<string name="jam_error_room_not_found">Nessuna stanza con questo codice. Controllalo e riprova.</string>
|
||||
<string name="jam_error_room_full">Questa stanza è piena.</string>
|
||||
<string name="jam_error_banned">L’host ti ha bannato da questa stanza.</string>
|
||||
<string name="jam_error_kicked">L’host ti ha rimosso dalla stanza.</string>
|
||||
<string name="jam_error_replaced">Questa stanza è stata riaperta su questo dispositivo, quindi questa connessione è stata chiusa.</string>
|
||||
|
||||
<string name="player_go_to_album">Vai all\'album</string>
|
||||
<string name="player_sleep_timer">Timer di spegnimento</string>
|
||||
@ -521,14 +526,9 @@
|
||||
<string name="jam_destination_suggest">Suggerisci alla Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Nessun broker Jam configurato</string>
|
||||
<string name="jam_error_broker_host_empty">L\'host del broker è vuoto</string>
|
||||
<string name="jam_error_connection_failed">Connessione non riuscita</string>
|
||||
<string name="jam_error_connection_lost">Connessione persa</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonimo</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Aggiunto alla coda della Jam</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">アプリ内でのWebコンテンツの表示</string>
|
||||
<string name="settings_section_jam">グループジャム</string>
|
||||
<string name="settings_jam_broker_title">MQTTブローカー</string>
|
||||
<string name="settings_jam_broker_host">ブローカーのホスト</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">ブローカーのポート</string>
|
||||
<string name="settings_jam_broker_tls">TLSを使用</string>
|
||||
<string name="settings_jam_broker_username">ユーザー名 (任意)</string>
|
||||
<string name="settings_jam_broker_password">パスワード (任意)</string>
|
||||
<string name="settings_jam_broker_client_id">クライアントIDのプレフィックス</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">このSpotubeを識別するために使用されます</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">ブローカーとの接続を暗号化します</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">ルームを開始する前に、現在のブローカー設定を確認します。</string>
|
||||
<string name="settings_jam_broker_test">接続をテスト</string>
|
||||
<string name="settings_jam_broker_testing">テスト中…</string>
|
||||
<string name="settings_jam_broker_test_ok">%1$d msで接続しました</string>
|
||||
<string name="settings_jam_broker_test_fail">失敗: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">仮のブローカーです。セルフホストするには独自のブローカーを設定してください</string>
|
||||
<string name="settings_jam_server_test">接続をテスト</string>
|
||||
<string name="settings_jam_server_testing">テスト中…</string>
|
||||
<string name="settings_jam_server_test_ok">%1$d msで接続しました</string>
|
||||
<string name="settings_jam_server_test_fail">失敗: %1$s</string>
|
||||
<string name="settings_jam_section_connection">接続</string>
|
||||
<string name="settings_jam_section_credentials">認証情報</string>
|
||||
<string name="settings_jam_section_identity">識別情報</string>
|
||||
<string name="settings_jam_section_diagnostics">診断</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">接続中…</string>
|
||||
<string name="jam_setup_code_label">ルームコード</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">ブローカー: %1$s</string>
|
||||
<string name="jam_connection_missing">ルームを主催または参加するには、設定でブローカーを設定してください。</string>
|
||||
<string name="jam_connection_settings">ブローカー設定</string>
|
||||
<string name="jam_session_connected">接続済み</string>
|
||||
<string name="jam_session_connecting">再接続中…</string>
|
||||
<string name="jam_invite_copy">コードをコピー</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">ジャムセッションに参加できませんでした</string>
|
||||
<string name="jam_error_invalid_code">英数字6文字のルームコードを入力してください。</string>
|
||||
<string name="jam_share_message">コード %1$s で私のSpotubeジャムに参加しよう</string>
|
||||
<string name="settings_jam_server_title">ルームサーバー</string>
|
||||
<string name="settings_jam_server_url">サーバーURL</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// で始まるURLを入力してください。</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">ルームを始める前にルームサーバーを確認します。</string>
|
||||
<string name="settings_jam_server_note">ジャムはSpotubeの無料ルームサーバーを使います。自分でホストするには、github.com/team-spotube/jam-server のサーバーをデプロイし、そのURLを入力してください。</string>
|
||||
<string name="settings_jam_section_you">あなた</string>
|
||||
<string name="jam_connection_host">サーバー: %1$s</string>
|
||||
<string name="jam_connection_missing">ルームをホストまたは参加するには、設定でルームサーバーを指定してください。</string>
|
||||
<string name="jam_connection_settings">サーバー設定</string>
|
||||
<string name="jam_participants_you">%1$s(あなた)</string>
|
||||
<string name="jam_name_title">あなたの名前</string>
|
||||
<string name="jam_name_subtitle">ジャムの他の参加者には %1$s と表示されます</string>
|
||||
<string name="jam_name_description">ジャムの全員に表示されます。空欄にすると %1$s を使います。新しい名前は次にルームを作成または参加したときから使われます。</string>
|
||||
<string name="jam_name_too_long">%1$d 文字以内で入力してください。</string>
|
||||
<string name="jam_error_no_server">ジャムサーバーが設定されていません</string>
|
||||
<string name="jam_error_server_unreachable">ジャムサーバーに接続できません。ネットワーク、または 設定 → グループジャム のサーバーURLを確認してください。</string>
|
||||
<string name="jam_error_server_incompatible">ジャムサーバーのバージョンに互換性がありません。</string>
|
||||
<string name="jam_error_room_not_found">そのコードのルームはありません。コードを確認してもう一度お試しください。</string>
|
||||
<string name="jam_error_room_full">このルームは満員です。</string>
|
||||
<string name="jam_error_banned">ホストによってこのルームからBANされました。</string>
|
||||
<string name="jam_error_kicked">ホストによってルームから削除されました。</string>
|
||||
<string name="jam_error_replaced">このデバイスでこのルームが再度開かれたため、この接続は閉じられました。</string>
|
||||
|
||||
<string name="player_go_to_album">アルバムへ移動</string>
|
||||
<string name="player_sleep_timer">スリープタイマー</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">ジャムに提案</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">ジャム用ブローカーが設定されていません</string>
|
||||
<string name="jam_error_broker_host_empty">ブローカーのホストが空です</string>
|
||||
<string name="jam_error_connection_failed">接続に失敗しました</string>
|
||||
<string name="jam_error_connection_lost">接続が切断されました</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">ブローカー</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">ジャムのキューに追加しました</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">앱 안에 웹 콘텐츠 표시</string>
|
||||
<string name="settings_section_jam">그룹 잼</string>
|
||||
<string name="settings_jam_broker_title">MQTT 브로커</string>
|
||||
<string name="settings_jam_broker_host">브로커 호스트</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">브로커 포트</string>
|
||||
<string name="settings_jam_broker_tls">TLS 사용</string>
|
||||
<string name="settings_jam_broker_username">사용자 이름(선택 사항)</string>
|
||||
<string name="settings_jam_broker_password">비밀번호(선택 사항)</string>
|
||||
<string name="settings_jam_broker_client_id">클라이언트 ID 접두사</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">이 Spotube 인스턴스를 식별하는 데 사용돼요</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">브로커 연결 암호화</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">방을 시작하기 전에 현재 브로커 설정을 확인하세요.</string>
|
||||
<string name="settings_jam_broker_test">연결 테스트</string>
|
||||
<string name="settings_jam_broker_testing">테스트 중…</string>
|
||||
<string name="settings_jam_broker_test_ok">%1$d ms 만에 연결됨</string>
|
||||
<string name="settings_jam_broker_test_fail">실패: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">임시 브로커입니다 — 직접 호스팅하려면 브로커를 설정하세요</string>
|
||||
<string name="settings_jam_server_test">연결 테스트</string>
|
||||
<string name="settings_jam_server_testing">테스트 중…</string>
|
||||
<string name="settings_jam_server_test_ok">%1$d ms 만에 연결됨</string>
|
||||
<string name="settings_jam_server_test_fail">실패: %1$s</string>
|
||||
<string name="settings_jam_section_connection">연결</string>
|
||||
<string name="settings_jam_section_credentials">인증 정보</string>
|
||||
<string name="settings_jam_section_identity">식별 정보</string>
|
||||
<string name="settings_jam_section_diagnostics">진단</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">연결 중…</string>
|
||||
<string name="jam_setup_code_label">방 코드</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">브로커: %1$s</string>
|
||||
<string name="jam_connection_missing">방을 만들거나 참여하려면 설정에서 브로커를 설정하세요.</string>
|
||||
<string name="jam_connection_settings">브로커 설정</string>
|
||||
<string name="jam_session_connected">연결됨</string>
|
||||
<string name="jam_session_connecting">다시 연결 중…</string>
|
||||
<string name="jam_invite_copy">코드 복사</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">잼 세션에 참여하지 못했어요</string>
|
||||
<string name="jam_error_invalid_code">영문자와 숫자로 된 6자리 방 코드를 입력하세요.</string>
|
||||
<string name="jam_share_message">내 Spotube 잼에 참여하세요. 코드: %1$s</string>
|
||||
<string name="settings_jam_server_title">방 서버</string>
|
||||
<string name="settings_jam_server_url">서버 URL</string>
|
||||
<string name="settings_jam_server_url_invalid">https://로 시작하는 URL을 입력하세요.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">방을 시작하기 전에 방 서버를 확인하세요.</string>
|
||||
<string name="settings_jam_server_note">잼은 Spotube의 무료 방 서버를 사용합니다. 직접 호스팅하려면 github.com/team-spotube/jam-server의 서버를 배포하고 URL을 입력하세요.</string>
|
||||
<string name="settings_jam_section_you">나</string>
|
||||
<string name="jam_connection_host">서버: %1$s</string>
|
||||
<string name="jam_connection_missing">방을 만들거나 참여하려면 설정에서 방 서버를 지정하세요.</string>
|
||||
<string name="jam_connection_settings">서버 설정</string>
|
||||
<string name="jam_participants_you">%1$s (나)</string>
|
||||
<string name="jam_name_title">내 이름</string>
|
||||
<string name="jam_name_subtitle">잼의 다른 사람들에게 %1$s(으)로 표시됩니다</string>
|
||||
<string name="jam_name_description">잼의 모든 사람에게 표시됩니다. 비워 두면 %1$s을(를) 사용합니다. 새 이름은 다음에 방을 만들거나 참여할 때부터 적용됩니다.</string>
|
||||
<string name="jam_name_too_long">최대 %1$d자까지 입력하세요.</string>
|
||||
<string name="jam_error_no_server">설정된 잼 서버가 없습니다</string>
|
||||
<string name="jam_error_server_unreachable">잼 서버에 연결할 수 없습니다. 네트워크 또는 설정 → 그룹 잼의 서버 URL을 확인하세요.</string>
|
||||
<string name="jam_error_server_incompatible">잼 서버의 버전이 호환되지 않습니다.</string>
|
||||
<string name="jam_error_room_not_found">해당 코드의 방이 없습니다. 코드를 확인하고 다시 시도하세요.</string>
|
||||
<string name="jam_error_room_full">이 방은 가득 찼습니다.</string>
|
||||
<string name="jam_error_banned">호스트가 이 방에서 나를 차단했습니다.</string>
|
||||
<string name="jam_error_kicked">호스트가 나를 방에서 내보냈습니다.</string>
|
||||
<string name="jam_error_replaced">이 기기에서 이 방이 다시 열려 이 연결이 종료되었습니다.</string>
|
||||
|
||||
<string name="player_go_to_album">앨범으로 이동</string>
|
||||
<string name="player_sleep_timer">취침 타이머</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">잼에 추천하기</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">구성된 잼 브로커가 없어요</string>
|
||||
<string name="jam_error_broker_host_empty">브로커 호스트가 비어 있어요</string>
|
||||
<string name="jam_error_connection_failed">연결 실패</string>
|
||||
<string name="jam_error_connection_lost">연결 끊김</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">브로커</string>
|
||||
<string name="settings_jam_broker_username_placeholder">익명</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">잼 대기열에 추가했어요</string>
|
||||
|
||||
@ -292,25 +292,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Webcontent weergeven in de app</string>
|
||||
<string name="settings_section_jam">Group Jam</string>
|
||||
<string name="settings_jam_broker_title">MQTT-broker</string>
|
||||
<string name="settings_jam_broker_host">Broker-host</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Broker-poort</string>
|
||||
<string name="settings_jam_broker_tls">TLS gebruiken</string>
|
||||
<string name="settings_jam_broker_username">Gebruikersnaam (optioneel)</string>
|
||||
<string name="settings_jam_broker_password">Wachtwoord (optioneel)</string>
|
||||
<string name="settings_jam_broker_client_id">Voorvoegsel client-ID</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Wordt gebruikt om deze Spotube-installatie te herkennen</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">De verbinding met de broker versleutelen</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Controleer de huidige broker-instellingen voordat je een Jam start.</string>
|
||||
<string name="settings_jam_broker_test">Verbinding testen</string>
|
||||
<string name="settings_jam_broker_testing">Testen…</string>
|
||||
<string name="settings_jam_broker_test_ok">Verbonden in %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Mislukt: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Tijdelijke broker — stel je eigen broker in om zelf te hosten</string>
|
||||
<string name="settings_jam_server_test">Verbinding testen</string>
|
||||
<string name="settings_jam_server_testing">Testen…</string>
|
||||
<string name="settings_jam_server_test_ok">Verbonden in %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Mislukt: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Verbinding</string>
|
||||
<string name="settings_jam_section_credentials">Inloggegevens</string>
|
||||
<string name="settings_jam_section_identity">Identiteit</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnose</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -323,9 +309,6 @@
|
||||
<string name="jam_setup_working">Verbinden…</string>
|
||||
<string name="jam_setup_code_label">Sessiecode</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Stel een broker in bij Instellingen om een sessie te hosten of eraan deel te nemen.</string>
|
||||
<string name="jam_connection_settings">Broker-instellingen</string>
|
||||
<string name="jam_session_connected">Verbonden</string>
|
||||
<string name="jam_session_connecting">Opnieuw verbinden…</string>
|
||||
<string name="jam_invite_copy">Code kopiëren</string>
|
||||
@ -345,6 +328,28 @@
|
||||
<string name="jam_error_join">Deelnemen aan Jam-sessie mislukt</string>
|
||||
<string name="jam_error_invalid_code">Voer een sessiecode van zes tekens in met letters en cijfers.</string>
|
||||
<string name="jam_share_message">Doe mee met mijn Spotube Jam met code: %1$s</string>
|
||||
<string name="settings_jam_server_title">Sessieserver</string>
|
||||
<string name="settings_jam_server_url">Server-URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Voer een URL in die begint met https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Controleer de sessieserver voordat je een sessie start.</string>
|
||||
<string name="settings_jam_server_note">Jam gebruikt de gratis sessieserver van Spotube. Wil je hem zelf hosten, deploy dan de server van github.com/team-spotube/jam-server en voer de URL in.</string>
|
||||
<string name="settings_jam_section_you">Jij</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Stel in Instellingen een sessieserver in om een sessie te hosten of eraan deel te nemen.</string>
|
||||
<string name="jam_connection_settings">Serverinstellingen</string>
|
||||
<string name="jam_participants_you">%1$s (jij)</string>
|
||||
<string name="jam_name_title">Je naam</string>
|
||||
<string name="jam_name_subtitle">Anderen in de jam zien je als %1$s</string>
|
||||
<string name="jam_name_description">Zichtbaar voor iedereen in de jam. Laat leeg om %1$s te gebruiken. Een nieuwe naam geldt vanaf de volgende keer dat je een sessie start of eraan deelneemt.</string>
|
||||
<string name="jam_name_too_long">Gebruik maximaal %1$d tekens.</string>
|
||||
<string name="jam_error_no_server">Geen jamserver ingesteld</string>
|
||||
<string name="jam_error_server_unreachable">Kan de jamserver niet bereiken. Controleer je netwerk of de server-URL in Instellingen → Group Jam.</string>
|
||||
<string name="jam_error_server_incompatible">De jamserver draait een incompatibele versie.</string>
|
||||
<string name="jam_error_room_not_found">Geen sessie met die code. Controleer de code en probeer het opnieuw.</string>
|
||||
<string name="jam_error_room_full">Deze sessie is vol.</string>
|
||||
<string name="jam_error_banned">De host heeft je uit deze sessie verbannen.</string>
|
||||
<string name="jam_error_kicked">De host heeft je uit de sessie verwijderd.</string>
|
||||
<string name="jam_error_replaced">Deze sessie is opnieuw geopend op dit apparaat, dus deze verbinding is gesloten.</string>
|
||||
|
||||
<string name="player_go_to_album">Naar album</string>
|
||||
<string name="player_sleep_timer">Slaaptimer</string>
|
||||
@ -517,14 +522,9 @@
|
||||
<string name="jam_destination_suggest">Voorstellen aan Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Geen Jam-broker ingesteld</string>
|
||||
<string name="jam_error_broker_host_empty">Broker-host is leeg</string>
|
||||
<string name="jam_error_connection_failed">Verbinding mislukt</string>
|
||||
<string name="jam_error_connection_lost">Verbinding verbroken</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anoniem</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Toegevoegd aan de Jam-wachtrij</string>
|
||||
|
||||
@ -300,25 +300,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Wyświetlanie treści internetowych w aplikacji</string>
|
||||
<string name="settings_section_jam">Grupowy Jam</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Host brokera</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Port brokera</string>
|
||||
<string name="settings_jam_broker_tls">Użyj TLS</string>
|
||||
<string name="settings_jam_broker_username">Nazwa użytkownika (opcjonalnie)</string>
|
||||
<string name="settings_jam_broker_password">Hasło (opcjonalnie)</string>
|
||||
<string name="settings_jam_broker_client_id">Prefiks ID klienta</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Służy do identyfikacji tej instancji Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Szyfruj połączenie z brokerem</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Sprawdź bieżące ustawienia brokera przed utworzeniem pokoju.</string>
|
||||
<string name="settings_jam_broker_test">Testuj połączenie</string>
|
||||
<string name="settings_jam_broker_testing">Testowanie…</string>
|
||||
<string name="settings_jam_broker_test_ok">Połączono w %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Niepowodzenie: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker zastępczy – skonfiguruj własny, aby hostować samodzielnie</string>
|
||||
<string name="settings_jam_server_test">Testuj połączenie</string>
|
||||
<string name="settings_jam_server_testing">Testowanie…</string>
|
||||
<string name="settings_jam_server_test_ok">Połączono w %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Niepowodzenie: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Połączenie</string>
|
||||
<string name="settings_jam_section_credentials">Dane logowania</string>
|
||||
<string name="settings_jam_section_identity">Tożsamość</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostyka</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -331,9 +317,6 @@
|
||||
<string name="jam_setup_working">Łączenie…</string>
|
||||
<string name="jam_setup_code_label">Kod pokoju</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Skonfiguruj brokera w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.</string>
|
||||
<string name="jam_connection_settings">Ustawienia brokera</string>
|
||||
<string name="jam_session_connected">Połączono</string>
|
||||
<string name="jam_session_connecting">Ponowne łączenie…</string>
|
||||
<string name="jam_invite_copy">Kopiuj kod</string>
|
||||
@ -353,6 +336,28 @@
|
||||
<string name="jam_error_join">Nie udało się dołączyć do sesji Jam</string>
|
||||
<string name="jam_error_invalid_code">Wpisz sześcioznakowy kod pokoju złożony z liter i cyfr.</string>
|
||||
<string name="jam_share_message">Dołącz do mojego Jamu w Spotube, kod: %1$s</string>
|
||||
<string name="settings_jam_server_title">Serwer pokojów</string>
|
||||
<string name="settings_jam_server_url">Adres URL serwera</string>
|
||||
<string name="settings_jam_server_url_invalid">Wpisz adres URL zaczynający się od https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Sprawdź serwer pokojów przed utworzeniem pokoju.</string>
|
||||
<string name="settings_jam_server_note">Jam korzysta z darmowego serwera pokojów Spotube. Aby hostować własny, wdróż serwer z github.com/team-spotube/jam-server i wpisz jego adres URL.</string>
|
||||
<string name="settings_jam_section_you">Ty</string>
|
||||
<string name="jam_connection_host">Serwer: %1$s</string>
|
||||
<string name="jam_connection_missing">Ustaw serwer pokojów w Ustawieniach, aby utworzyć pokój lub do niego dołączyć.</string>
|
||||
<string name="jam_connection_settings">Ustawienia serwera</string>
|
||||
<string name="jam_participants_you">%1$s (ty)</string>
|
||||
<string name="jam_name_title">Twoja nazwa</string>
|
||||
<string name="jam_name_subtitle">Inni w jamie widzą cię jako %1$s</string>
|
||||
<string name="jam_name_description">Widoczna dla wszystkich w jamie. Zostaw puste, aby użyć %1$s. Nowa nazwa zacznie obowiązywać przy następnym utworzeniu pokoju lub dołączeniu do niego.</string>
|
||||
<string name="jam_name_too_long">Użyj maksymalnie %1$d znaków.</string>
|
||||
<string name="jam_error_no_server">Nie skonfigurowano serwera jamu</string>
|
||||
<string name="jam_error_server_unreachable">Nie można połączyć się z serwerem jamu. Sprawdź sieć lub adres URL serwera w Ustawienia → Grupowy Jam.</string>
|
||||
<string name="jam_error_server_incompatible">Serwer jamu używa niezgodnej wersji.</string>
|
||||
<string name="jam_error_room_not_found">Nie ma pokoju o tym kodzie. Sprawdź go i spróbuj ponownie.</string>
|
||||
<string name="jam_error_room_full">Ten pokój jest pełny.</string>
|
||||
<string name="jam_error_banned">Gospodarz zablokował cię w tym pokoju.</string>
|
||||
<string name="jam_error_kicked">Gospodarz usunął cię z pokoju.</string>
|
||||
<string name="jam_error_replaced">Ten pokój został ponownie otwarty na tym urządzeniu, więc to połączenie zostało zamknięte.</string>
|
||||
|
||||
<string name="player_go_to_album">Przejdź do albumu</string>
|
||||
<string name="player_sleep_timer">Wyłącznik czasowy</string>
|
||||
@ -525,14 +530,9 @@
|
||||
<string name="jam_destination_suggest">Zaproponuj w Jamie</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Nie skonfigurowano brokera Jam</string>
|
||||
<string name="jam_error_broker_host_empty">Host brokera jest pusty</string>
|
||||
<string name="jam_error_connection_failed">Połączenie nieudane</string>
|
||||
<string name="jam_error_connection_lost">Utracono połączenie</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonim</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Dodano do kolejki Jamu</string>
|
||||
|
||||
@ -296,25 +296,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Exibir conteúdo da web dentro do app</string>
|
||||
<string name="settings_section_jam">Jam em grupo</string>
|
||||
<string name="settings_jam_broker_title">Broker MQTT</string>
|
||||
<string name="settings_jam_broker_host">Host do broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Porta do broker</string>
|
||||
<string name="settings_jam_broker_tls">Usar TLS</string>
|
||||
<string name="settings_jam_broker_username">Nome de usuário (opcional)</string>
|
||||
<string name="settings_jam_broker_password">Senha (opcional)</string>
|
||||
<string name="settings_jam_broker_client_id">Prefixo do ID do cliente</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Usado para identificar esta instância do Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Criptografa a conexão com o broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Verifique as configurações atuais do broker antes de criar uma sala.</string>
|
||||
<string name="settings_jam_broker_test">Testar conexão</string>
|
||||
<string name="settings_jam_broker_testing">Testando…</string>
|
||||
<string name="settings_jam_broker_test_ok">Conectado em %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Falhou: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker provisório — configure o seu próprio para hospedar por conta própria</string>
|
||||
<string name="settings_jam_server_test">Testar conexão</string>
|
||||
<string name="settings_jam_server_testing">Testando…</string>
|
||||
<string name="settings_jam_server_test_ok">Conectado em %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Falhou: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Conexão</string>
|
||||
<string name="settings_jam_section_credentials">Credenciais</string>
|
||||
<string name="settings_jam_section_identity">Identidade</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnóstico</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -327,9 +313,6 @@
|
||||
<string name="jam_setup_working">Conectando…</string>
|
||||
<string name="jam_setup_code_label">Código da sala</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Configure um broker nas Configurações para criar ou entrar em uma sala.</string>
|
||||
<string name="jam_connection_settings">Configurações do broker</string>
|
||||
<string name="jam_session_connected">Conectado</string>
|
||||
<string name="jam_session_connecting">Reconectando…</string>
|
||||
<string name="jam_invite_copy">Copiar código</string>
|
||||
@ -349,6 +332,28 @@
|
||||
<string name="jam_error_join">Falha ao entrar na Jam</string>
|
||||
<string name="jam_error_invalid_code">Digite um código de sala com seis caracteres, usando letras e números.</string>
|
||||
<string name="jam_share_message">Entre na minha Jam do Spotube com o código: %1$s</string>
|
||||
<string name="settings_jam_server_title">Servidor de salas</string>
|
||||
<string name="settings_jam_server_url">URL do servidor</string>
|
||||
<string name="settings_jam_server_url_invalid">Digite uma URL que comece com https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Verifique o servidor de salas antes de criar uma sala.</string>
|
||||
<string name="settings_jam_server_note">O Jam usa o servidor de salas gratuito do Spotube. Para hospedar o seu, implante o servidor de github.com/team-spotube/jam-server e informe a URL dele.</string>
|
||||
<string name="settings_jam_section_you">Você</string>
|
||||
<string name="jam_connection_host">Servidor: %1$s</string>
|
||||
<string name="jam_connection_missing">Defina um servidor de salas nas Configurações para criar ou entrar em uma sala.</string>
|
||||
<string name="jam_connection_settings">Configurações do servidor</string>
|
||||
<string name="jam_participants_you">%1$s (você)</string>
|
||||
<string name="jam_name_title">Seu nome</string>
|
||||
<string name="jam_name_subtitle">Os outros no jam veem você como %1$s</string>
|
||||
<string name="jam_name_description">Visível para todos no jam. Deixe em branco para usar %1$s. Um novo nome vale a partir da próxima vez que você criar ou entrar em uma sala.</string>
|
||||
<string name="jam_name_too_long">Use no máximo %1$d caracteres.</string>
|
||||
<string name="jam_error_no_server">Nenhum servidor de jam configurado</string>
|
||||
<string name="jam_error_server_unreachable">Não foi possível acessar o servidor de jam. Verifique sua rede ou a URL do servidor em Configurações → Jam em grupo.</string>
|
||||
<string name="jam_error_server_incompatible">O servidor de jam usa uma versão incompatível.</string>
|
||||
<string name="jam_error_room_not_found">Nenhuma sala com esse código. Confira e tente de novo.</string>
|
||||
<string name="jam_error_room_full">Esta sala está cheia.</string>
|
||||
<string name="jam_error_banned">O anfitrião baniu você desta sala.</string>
|
||||
<string name="jam_error_kicked">O anfitrião removeu você da sala.</string>
|
||||
<string name="jam_error_replaced">Esta sala foi aberta de novo neste dispositivo, então esta conexão foi encerrada.</string>
|
||||
|
||||
<string name="player_go_to_album">Ir para o álbum</string>
|
||||
<string name="player_sleep_timer">Timer</string>
|
||||
@ -521,14 +526,9 @@
|
||||
<string name="jam_destination_suggest">Sugerir na Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Nenhum broker de Jam configurado</string>
|
||||
<string name="jam_error_broker_host_empty">O host do broker está vazio</string>
|
||||
<string name="jam_error_connection_failed">Falha na conexão</string>
|
||||
<string name="jam_error_connection_lost">Conexão perdida</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anônimo</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Adicionada à fila da Jam</string>
|
||||
|
||||
@ -300,25 +300,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Показ веб-контента внутри приложения</string>
|
||||
<string name="settings_section_jam">Групповой джем</string>
|
||||
<string name="settings_jam_broker_title">MQTT-брокер</string>
|
||||
<string name="settings_jam_broker_host">Хост брокера</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Порт брокера</string>
|
||||
<string name="settings_jam_broker_tls">Использовать TLS</string>
|
||||
<string name="settings_jam_broker_username">Имя пользователя (необязательно)</string>
|
||||
<string name="settings_jam_broker_password">Пароль (необязательно)</string>
|
||||
<string name="settings_jam_broker_client_id">Префикс ID клиента</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Используется для идентификации этого экземпляра Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Шифровать подключение к брокеру</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Проверьте текущие настройки брокера перед созданием комнаты.</string>
|
||||
<string name="settings_jam_broker_test">Проверить подключение</string>
|
||||
<string name="settings_jam_broker_testing">Проверка…</string>
|
||||
<string name="settings_jam_broker_test_ok">Подключено за %1$d мс</string>
|
||||
<string name="settings_jam_broker_test_fail">Ошибка: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Временный брокер — настройте свой, чтобы размещать джемы самостоятельно</string>
|
||||
<string name="settings_jam_server_test">Проверить подключение</string>
|
||||
<string name="settings_jam_server_testing">Проверка…</string>
|
||||
<string name="settings_jam_server_test_ok">Подключено за %1$d мс</string>
|
||||
<string name="settings_jam_server_test_fail">Ошибка: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Подключение</string>
|
||||
<string name="settings_jam_section_credentials">Учётные данные</string>
|
||||
<string name="settings_jam_section_identity">Идентификация</string>
|
||||
<string name="settings_jam_section_diagnostics">Диагностика</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -331,9 +317,6 @@
|
||||
<string name="jam_setup_working">Подключение…</string>
|
||||
<string name="jam_setup_code_label">Код комнаты</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Брокер: %1$s</string>
|
||||
<string name="jam_connection_missing">Настройте брокер в настройках, чтобы создать комнату или присоединиться к ней.</string>
|
||||
<string name="jam_connection_settings">Настройки брокера</string>
|
||||
<string name="jam_session_connected">Подключено</string>
|
||||
<string name="jam_session_connecting">Переподключение…</string>
|
||||
<string name="jam_invite_copy">Копировать код</string>
|
||||
@ -353,6 +336,28 @@
|
||||
<string name="jam_error_join">Не удалось присоединиться к джему</string>
|
||||
<string name="jam_error_invalid_code">Введите код комнаты из шести букв и цифр.</string>
|
||||
<string name="jam_share_message">Присоединяйся к моему джему в Spotube по коду: %1$s</string>
|
||||
<string name="settings_jam_server_title">Сервер комнат</string>
|
||||
<string name="settings_jam_server_url">URL сервера</string>
|
||||
<string name="settings_jam_server_url_invalid">Введите URL, начинающийся с https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Проверьте сервер комнат перед созданием комнаты.</string>
|
||||
<string name="settings_jam_server_note">Джем использует бесплатный сервер комнат Spotube. Чтобы развернуть свой, установите сервер из github.com/team-spotube/jam-server и укажите его URL.</string>
|
||||
<string name="settings_jam_section_you">Вы</string>
|
||||
<string name="jam_connection_host">Сервер: %1$s</string>
|
||||
<string name="jam_connection_missing">Укажите сервер комнат в настройках, чтобы создать комнату или присоединиться к ней.</string>
|
||||
<string name="jam_connection_settings">Настройки сервера</string>
|
||||
<string name="jam_participants_you">%1$s (вы)</string>
|
||||
<string name="jam_name_title">Ваше имя</string>
|
||||
<string name="jam_name_subtitle">Другие участники джема видят вас как %1$s</string>
|
||||
<string name="jam_name_description">Видно всем в джеме. Оставьте пустым, чтобы использовать %1$s. Новое имя применится, когда вы в следующий раз создадите комнату или присоединитесь к ней.</string>
|
||||
<string name="jam_name_too_long">Не больше %1$d символов.</string>
|
||||
<string name="jam_error_no_server">Сервер джема не настроен</string>
|
||||
<string name="jam_error_server_unreachable">Не удалось подключиться к серверу джема. Проверьте сеть или URL сервера в разделе Настройки → Групповой джем.</string>
|
||||
<string name="jam_error_server_incompatible">Сервер джема использует несовместимую версию.</string>
|
||||
<string name="jam_error_room_not_found">Комнаты с таким кодом нет. Проверьте код и попробуйте снова.</string>
|
||||
<string name="jam_error_room_full">Эта комната заполнена.</string>
|
||||
<string name="jam_error_banned">Организатор заблокировал вас в этой комнате.</string>
|
||||
<string name="jam_error_kicked">Организатор удалил вас из комнаты.</string>
|
||||
<string name="jam_error_replaced">Эта комната была снова открыта на этом устройстве, поэтому это подключение закрыто.</string>
|
||||
|
||||
<string name="player_go_to_album">Перейти к альбому</string>
|
||||
<string name="player_sleep_timer">Таймер сна</string>
|
||||
@ -525,14 +530,9 @@
|
||||
<string name="jam_destination_suggest">Предложить в джем</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Брокер для джема не настроен</string>
|
||||
<string name="jam_error_broker_host_empty">Не указан хост брокера</string>
|
||||
<string name="jam_error_connection_failed">Не удалось подключиться</string>
|
||||
<string name="jam_error_connection_lost">Соединение потеряно</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Брокер</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Добавлено в очередь джема</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">แสดงเนื้อหาเว็บภายในแอป</string>
|
||||
<string name="settings_section_jam">แจมกลุ่ม</string>
|
||||
<string name="settings_jam_broker_title">MQTT Broker</string>
|
||||
<string name="settings_jam_broker_host">โฮสต์ของ Broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">พอร์ตของ Broker</string>
|
||||
<string name="settings_jam_broker_tls">ใช้ TLS</string>
|
||||
<string name="settings_jam_broker_username">ชื่อผู้ใช้ (ไม่บังคับ)</string>
|
||||
<string name="settings_jam_broker_password">รหัสผ่าน (ไม่บังคับ)</string>
|
||||
<string name="settings_jam_broker_client_id">คำนำหน้า Client ID</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">ใช้เพื่อระบุ Spotube เครื่องนี้</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">เข้ารหัสการเชื่อมต่อกับ Broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">ตรวจสอบการตั้งค่า Broker ปัจจุบันก่อนเริ่มห้อง</string>
|
||||
<string name="settings_jam_broker_test">ทดสอบการเชื่อมต่อ</string>
|
||||
<string name="settings_jam_broker_testing">กำลังทดสอบ…</string>
|
||||
<string name="settings_jam_broker_test_ok">เชื่อมต่อแล้วใน %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">ล้มเหลว: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker ตัวอย่าง — กำหนดค่าของคุณเองเพื่อโฮสต์เอง</string>
|
||||
<string name="settings_jam_server_test">ทดสอบการเชื่อมต่อ</string>
|
||||
<string name="settings_jam_server_testing">กำลังทดสอบ…</string>
|
||||
<string name="settings_jam_server_test_ok">เชื่อมต่อแล้วใน %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">ล้มเหลว: %1$s</string>
|
||||
<string name="settings_jam_section_connection">การเชื่อมต่อ</string>
|
||||
<string name="settings_jam_section_credentials">ข้อมูลเข้าสู่ระบบ</string>
|
||||
<string name="settings_jam_section_identity">ตัวตน</string>
|
||||
<string name="settings_jam_section_diagnostics">การวินิจฉัย</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">กำลังเชื่อมต่อ…</string>
|
||||
<string name="jam_setup_code_label">รหัสห้อง</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">ตั้งค่า Broker ในการตั้งค่าเพื่อสร้างหรือเข้าร่วมห้อง</string>
|
||||
<string name="jam_connection_settings">การตั้งค่า Broker</string>
|
||||
<string name="jam_session_connected">เชื่อมต่อแล้ว</string>
|
||||
<string name="jam_session_connecting">กำลังเชื่อมต่อใหม่…</string>
|
||||
<string name="jam_invite_copy">คัดลอกรหัส</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">เข้าร่วมเซสชันแจมไม่สำเร็จ</string>
|
||||
<string name="jam_error_invalid_code">ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข</string>
|
||||
<string name="jam_share_message">มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s</string>
|
||||
<string name="settings_jam_server_title">เซิร์ฟเวอร์ห้อง</string>
|
||||
<string name="settings_jam_server_url">URL ของเซิร์ฟเวอร์</string>
|
||||
<string name="settings_jam_server_url_invalid">ใส่ URL ที่ขึ้นต้นด้วย https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">ตรวจสอบเซิร์ฟเวอร์ห้องก่อนเริ่มห้อง</string>
|
||||
<string name="settings_jam_server_note">แจมใช้เซิร์ฟเวอร์ห้องฟรีของ Spotube หากต้องการโฮสต์เอง ให้ติดตั้งเซิร์ฟเวอร์จาก github.com/team-spotube/jam-server แล้วใส่ URL ของเซิร์ฟเวอร์นั้น</string>
|
||||
<string name="settings_jam_section_you">คุณ</string>
|
||||
<string name="jam_connection_host">เซิร์ฟเวอร์: %1$s</string>
|
||||
<string name="jam_connection_missing">ตั้งค่าเซิร์ฟเวอร์ห้องในการตั้งค่าเพื่อโฮสต์หรือเข้าร่วมห้อง</string>
|
||||
<string name="jam_connection_settings">การตั้งค่าเซิร์ฟเวอร์</string>
|
||||
<string name="jam_participants_you">%1$s (คุณ)</string>
|
||||
<string name="jam_name_title">ชื่อของคุณ</string>
|
||||
<string name="jam_name_subtitle">คนอื่นในแจมจะเห็นคุณเป็น %1$s</string>
|
||||
<string name="jam_name_description">ทุกคนในแจมจะเห็นชื่อนี้ เว้นว่างไว้เพื่อใช้ %1$s ชื่อใหม่จะมีผลเมื่อคุณสร้างหรือเข้าร่วมห้องครั้งถัดไป</string>
|
||||
<string name="jam_name_too_long">ใช้ได้ไม่เกิน %1$d ตัวอักษร</string>
|
||||
<string name="jam_error_no_server">ยังไม่ได้ตั้งค่าเซิร์ฟเวอร์แจม</string>
|
||||
<string name="jam_error_server_unreachable">เชื่อมต่อเซิร์ฟเวอร์แจมไม่ได้ ตรวจสอบเครือข่ายหรือ URL ของเซิร์ฟเวอร์ใน การตั้งค่า → แจมกลุ่ม</string>
|
||||
<string name="jam_error_server_incompatible">เซิร์ฟเวอร์แจมใช้เวอร์ชันที่เข้ากันไม่ได้</string>
|
||||
<string name="jam_error_room_not_found">ไม่มีห้องที่ใช้รหัสนี้ ตรวจสอบแล้วลองอีกครั้ง</string>
|
||||
<string name="jam_error_room_full">ห้องนี้เต็มแล้ว</string>
|
||||
<string name="jam_error_banned">โฮสต์แบนคุณออกจากห้องนี้</string>
|
||||
<string name="jam_error_kicked">โฮสต์นำคุณออกจากห้อง</string>
|
||||
<string name="jam_error_replaced">ห้องนี้ถูกเปิดอีกครั้งบนอุปกรณ์นี้ จึงปิดการเชื่อมต่อนี้</string>
|
||||
|
||||
<string name="player_go_to_album">ไปที่อัลบั้ม</string>
|
||||
<string name="player_sleep_timer">ตั้งเวลาปิด</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">เสนอให้แจม</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">ยังไม่ได้กำหนดค่า Broker สำหรับแจม</string>
|
||||
<string name="jam_error_broker_host_empty">โฮสต์ของ Broker ว่างอยู่</string>
|
||||
<string name="jam_error_connection_failed">การเชื่อมต่อล้มเหลว</string>
|
||||
<string name="jam_error_connection_lost">การเชื่อมต่อขาดหาย</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">เพิ่มลงในคิวแจมแล้ว</string>
|
||||
|
||||
@ -292,25 +292,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Uygulama içinde web içeriği görüntüleme</string>
|
||||
<string name="settings_section_jam">Grup Jam</string>
|
||||
<string name="settings_jam_broker_title">MQTT Aracısı</string>
|
||||
<string name="settings_jam_broker_host">Aracı ana bilgisayarı</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Aracı portu</string>
|
||||
<string name="settings_jam_broker_tls">TLS kullan</string>
|
||||
<string name="settings_jam_broker_username">Kullanıcı adı (isteğe bağlı)</string>
|
||||
<string name="settings_jam_broker_password">Şifre (isteğe bağlı)</string>
|
||||
<string name="settings_jam_broker_client_id">İstemci kimliği ön eki</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Bu Spotube örneğini tanımlamak için kullanılır</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Aracı bağlantısını şifrele</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Oda başlatmadan önce geçerli aracı ayarlarını kontrol et.</string>
|
||||
<string name="settings_jam_broker_test">Bağlantıyı test et</string>
|
||||
<string name="settings_jam_broker_testing">Test ediliyor…</string>
|
||||
<string name="settings_jam_broker_test_ok">%1$d ms\'de bağlandı</string>
|
||||
<string name="settings_jam_broker_test_fail">Başarısız: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Geçici aracı — kendin barındırmak için kendi aracını yapılandır</string>
|
||||
<string name="settings_jam_server_test">Bağlantıyı test et</string>
|
||||
<string name="settings_jam_server_testing">Test ediliyor…</string>
|
||||
<string name="settings_jam_server_test_ok">%1$d ms\'de bağlandı</string>
|
||||
<string name="settings_jam_server_test_fail">Başarısız: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Bağlantı</string>
|
||||
<string name="settings_jam_section_credentials">Kimlik bilgileri</string>
|
||||
<string name="settings_jam_section_identity">Kimlik</string>
|
||||
<string name="settings_jam_section_diagnostics">Tanılama</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -323,9 +309,6 @@
|
||||
<string name="jam_setup_working">Bağlanıyor…</string>
|
||||
<string name="jam_setup_code_label">Oda kodu</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Aracı: %1$s</string>
|
||||
<string name="jam_connection_missing">Oda açmak veya bir odaya katılmak için Ayarlar\'da bir aracı ayarla.</string>
|
||||
<string name="jam_connection_settings">Aracı ayarları</string>
|
||||
<string name="jam_session_connected">Bağlandı</string>
|
||||
<string name="jam_session_connecting">Yeniden bağlanıyor…</string>
|
||||
<string name="jam_invite_copy">Kodu kopyala</string>
|
||||
@ -345,6 +328,28 @@
|
||||
<string name="jam_error_join">Jam oturumuna katılınamadı</string>
|
||||
<string name="jam_error_invalid_code">Harf ve rakamlardan oluşan altı karakterli bir oda kodu gir.</string>
|
||||
<string name="jam_share_message">Spotube Jam\'ime şu kodla katıl: %1$s</string>
|
||||
<string name="settings_jam_server_title">Oda sunucusu</string>
|
||||
<string name="settings_jam_server_url">Sunucu URL’si</string>
|
||||
<string name="settings_jam_server_url_invalid">https:// ile başlayan bir URL gir.</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Oda başlatmadan önce oda sunucusunu kontrol et.</string>
|
||||
<string name="settings_jam_server_note">Jam, Spotube’un ücretsiz oda sunucusunu kullanır. Kendin barındırmak için github.com/team-spotube/jam-server adresindeki sunucuyu kur ve URL’sini gir.</string>
|
||||
<string name="settings_jam_section_you">Sen</string>
|
||||
<string name="jam_connection_host">Sunucu: %1$s</string>
|
||||
<string name="jam_connection_missing">Oda açmak veya bir odaya katılmak için Ayarlar’da bir oda sunucusu belirle.</string>
|
||||
<string name="jam_connection_settings">Sunucu ayarları</string>
|
||||
<string name="jam_participants_you">%1$s (sen)</string>
|
||||
<string name="jam_name_title">Adın</string>
|
||||
<string name="jam_name_subtitle">Jam’deki diğer kişiler seni %1$s olarak görür</string>
|
||||
<string name="jam_name_description">Jam’deki herkese gösterilir. %1$s adını kullanmak için boş bırak. Yeni ad, bir sonraki oda açışında veya katılışında geçerli olur.</string>
|
||||
<string name="jam_name_too_long">En fazla %1$d karakter kullan.</string>
|
||||
<string name="jam_error_no_server">Jam sunucusu ayarlanmamış</string>
|
||||
<string name="jam_error_server_unreachable">Jam sunucusuna ulaşılamadı. Ağını veya Ayarlar → Grup Jam bölümündeki sunucu URL’sini kontrol et.</string>
|
||||
<string name="jam_error_server_incompatible">Jam sunucusu uyumsuz bir sürüm çalıştırıyor.</string>
|
||||
<string name="jam_error_room_not_found">Bu koda sahip bir oda yok. Kodu kontrol edip tekrar dene.</string>
|
||||
<string name="jam_error_room_full">Bu oda dolu.</string>
|
||||
<string name="jam_error_banned">Oda sahibi seni bu odadan engelledi.</string>
|
||||
<string name="jam_error_kicked">Oda sahibi seni odadan çıkardı.</string>
|
||||
<string name="jam_error_replaced">Bu oda bu cihazda yeniden açıldı, bu yüzden bu bağlantı kapatıldı.</string>
|
||||
|
||||
<string name="player_go_to_album">Albüme git</string>
|
||||
<string name="player_sleep_timer">Uyku zamanlayıcısı</string>
|
||||
@ -517,14 +522,9 @@
|
||||
<string name="jam_destination_suggest">Jam\'e öner</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Jam aracısı yapılandırılmadı</string>
|
||||
<string name="jam_error_broker_host_empty">Aracı ana bilgisayarı boş</string>
|
||||
<string name="jam_error_connection_failed">Bağlantı başarısız</string>
|
||||
<string name="jam_error_connection_lost">Bağlantı kesildi</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Aracı</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Jam sırasına eklendi</string>
|
||||
|
||||
@ -300,25 +300,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Показ вебвмісту всередині застосунку</string>
|
||||
<string name="settings_section_jam">Груповий джем</string>
|
||||
<string name="settings_jam_broker_title">MQTT-брокер</string>
|
||||
<string name="settings_jam_broker_host">Хост брокера</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Порт брокера</string>
|
||||
<string name="settings_jam_broker_tls">Використовувати TLS</string>
|
||||
<string name="settings_jam_broker_username">Ім’я користувача (необов’язково)</string>
|
||||
<string name="settings_jam_broker_password">Пароль (необов’язково)</string>
|
||||
<string name="settings_jam_broker_client_id">Префікс ID клієнта</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Використовується для ідентифікації цього екземпляра Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Шифрувати підключення до брокера</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Перевірте поточні налаштування брокера перед створенням кімнати.</string>
|
||||
<string name="settings_jam_broker_test">Перевірити підключення</string>
|
||||
<string name="settings_jam_broker_testing">Перевірка…</string>
|
||||
<string name="settings_jam_broker_test_ok">Підключено за %1$d мс</string>
|
||||
<string name="settings_jam_broker_test_fail">Помилка: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Тимчасовий брокер — налаштуйте власний, щоб розміщувати джеми самостійно</string>
|
||||
<string name="settings_jam_server_test">Перевірити підключення</string>
|
||||
<string name="settings_jam_server_testing">Перевірка…</string>
|
||||
<string name="settings_jam_server_test_ok">Підключено за %1$d мс</string>
|
||||
<string name="settings_jam_server_test_fail">Помилка: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Підключення</string>
|
||||
<string name="settings_jam_section_credentials">Облікові дані</string>
|
||||
<string name="settings_jam_section_identity">Ідентифікація</string>
|
||||
<string name="settings_jam_section_diagnostics">Діагностика</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -331,9 +317,6 @@
|
||||
<string name="jam_setup_working">Підключення…</string>
|
||||
<string name="jam_setup_code_label">Код кімнати</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Брокер: %1$s</string>
|
||||
<string name="jam_connection_missing">Налаштуйте брокер у налаштуваннях, щоб створити кімнату або приєднатися до неї.</string>
|
||||
<string name="jam_connection_settings">Налаштування брокера</string>
|
||||
<string name="jam_session_connected">Підключено</string>
|
||||
<string name="jam_session_connecting">Повторне підключення…</string>
|
||||
<string name="jam_invite_copy">Копіювати код</string>
|
||||
@ -353,6 +336,28 @@
|
||||
<string name="jam_error_join">Не вдалося приєднатися до джему</string>
|
||||
<string name="jam_error_invalid_code">Введіть код кімнати з шести літер і цифр.</string>
|
||||
<string name="jam_share_message">Приєднуйся до мого джему в Spotube за кодом: %1$s</string>
|
||||
<string name="settings_jam_server_title">Сервер кімнат</string>
|
||||
<string name="settings_jam_server_url">URL сервера</string>
|
||||
<string name="settings_jam_server_url_invalid">Введіть URL, що починається з https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Перевірте сервер кімнат перед створенням кімнати.</string>
|
||||
<string name="settings_jam_server_note">Джем використовує безкоштовний сервер кімнат Spotube. Щоб розгорнути власний, встановіть сервер із github.com/team-spotube/jam-server і вкажіть його URL.</string>
|
||||
<string name="settings_jam_section_you">Ви</string>
|
||||
<string name="jam_connection_host">Сервер: %1$s</string>
|
||||
<string name="jam_connection_missing">Укажіть сервер кімнат у налаштуваннях, щоб створити кімнату або приєднатися до неї.</string>
|
||||
<string name="jam_connection_settings">Налаштування сервера</string>
|
||||
<string name="jam_participants_you">%1$s (ви)</string>
|
||||
<string name="jam_name_title">Ваше ім’я</string>
|
||||
<string name="jam_name_subtitle">Інші учасники джему бачать вас як %1$s</string>
|
||||
<string name="jam_name_description">Видно всім у джемі. Залиште порожнім, щоб використовувати %1$s. Нове ім’я застосується, коли ви наступного разу створите кімнату або приєднаєтеся до неї.</string>
|
||||
<string name="jam_name_too_long">Не більше %1$d символів.</string>
|
||||
<string name="jam_error_no_server">Сервер джему не налаштовано</string>
|
||||
<string name="jam_error_server_unreachable">Не вдалося підключитися до сервера джему. Перевірте мережу або URL сервера в розділі Налаштування → Груповий джем.</string>
|
||||
<string name="jam_error_server_incompatible">Сервер джему використовує несумісну версію.</string>
|
||||
<string name="jam_error_room_not_found">Кімнати з таким кодом немає. Перевірте код і спробуйте ще раз.</string>
|
||||
<string name="jam_error_room_full">Ця кімната заповнена.</string>
|
||||
<string name="jam_error_banned">Організатор заблокував вас у цій кімнаті.</string>
|
||||
<string name="jam_error_kicked">Організатор видалив вас із кімнати.</string>
|
||||
<string name="jam_error_replaced">Цю кімнату знову відкрито на цьому пристрої, тому це підключення закрито.</string>
|
||||
|
||||
<string name="player_go_to_album">Перейти до альбому</string>
|
||||
<string name="player_sleep_timer">Таймер сну</string>
|
||||
@ -525,14 +530,9 @@
|
||||
<string name="jam_destination_suggest">Запропонувати в джем</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Брокер для джему не налаштовано</string>
|
||||
<string name="jam_error_broker_host_empty">Не вказано хост брокера</string>
|
||||
<string name="jam_error_connection_failed">Не вдалося підключитися</string>
|
||||
<string name="jam_error_connection_lost">З’єднання втрачено</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Брокер</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Додано до черги джему</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Hiển thị nội dung web trong ứng dụng</string>
|
||||
<string name="settings_section_jam">Jam nhóm</string>
|
||||
<string name="settings_jam_broker_title">MQTT Broker</string>
|
||||
<string name="settings_jam_broker_host">Máy chủ broker</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Cổng broker</string>
|
||||
<string name="settings_jam_broker_tls">Dùng TLS</string>
|
||||
<string name="settings_jam_broker_username">Tên người dùng (không bắt buộc)</string>
|
||||
<string name="settings_jam_broker_password">Mật khẩu (không bắt buộc)</string>
|
||||
<string name="settings_jam_broker_client_id">Tiền tố Client ID</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Dùng để nhận diện Spotube này</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Mã hóa kết nối tới broker</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Kiểm tra cài đặt broker hiện tại trước khi tạo phòng.</string>
|
||||
<string name="settings_jam_broker_test">Kiểm tra kết nối</string>
|
||||
<string name="settings_jam_broker_testing">Đang kiểm tra…</string>
|
||||
<string name="settings_jam_broker_test_ok">Đã kết nối trong %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Thất bại: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Broker tạm thời — hãy cấu hình broker riêng để tự lưu trữ</string>
|
||||
<string name="settings_jam_server_test">Kiểm tra kết nối</string>
|
||||
<string name="settings_jam_server_testing">Đang kiểm tra…</string>
|
||||
<string name="settings_jam_server_test_ok">Đã kết nối trong %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Thất bại: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Kết nối</string>
|
||||
<string name="settings_jam_section_credentials">Thông tin đăng nhập</string>
|
||||
<string name="settings_jam_section_identity">Danh tính</string>
|
||||
<string name="settings_jam_section_diagnostics">Chẩn đoán</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">Đang kết nối…</string>
|
||||
<string name="jam_setup_code_label">Mã phòng</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Hãy thiết lập broker trong Cài đặt để tạo hoặc tham gia phòng.</string>
|
||||
<string name="jam_connection_settings">Cài đặt broker</string>
|
||||
<string name="jam_session_connected">Đã kết nối</string>
|
||||
<string name="jam_session_connecting">Đang kết nối lại…</string>
|
||||
<string name="jam_invite_copy">Sao chép mã</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">Không thể tham gia phiên Jam</string>
|
||||
<string name="jam_error_invalid_code">Nhập mã phòng gồm sáu ký tự chữ và số.</string>
|
||||
<string name="jam_share_message">Tham gia Jam Spotube của mình với mã: %1$s</string>
|
||||
<string name="settings_jam_server_title">Máy chủ phòng</string>
|
||||
<string name="settings_jam_server_url">URL máy chủ</string>
|
||||
<string name="settings_jam_server_url_invalid">Nhập URL bắt đầu bằng https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Kiểm tra máy chủ phòng trước khi tạo phòng.</string>
|
||||
<string name="settings_jam_server_note">Jam dùng máy chủ phòng miễn phí của Spotube. Để tự lưu trữ, hãy triển khai máy chủ từ github.com/team-spotube/jam-server rồi nhập URL của nó.</string>
|
||||
<string name="settings_jam_section_you">Bạn</string>
|
||||
<string name="jam_connection_host">Máy chủ: %1$s</string>
|
||||
<string name="jam_connection_missing">Đặt máy chủ phòng trong Cài đặt để tạo hoặc tham gia phòng.</string>
|
||||
<string name="jam_connection_settings">Cài đặt máy chủ</string>
|
||||
<string name="jam_participants_you">%1$s (bạn)</string>
|
||||
<string name="jam_name_title">Tên của bạn</string>
|
||||
<string name="jam_name_subtitle">Mọi người trong jam thấy bạn là %1$s</string>
|
||||
<string name="jam_name_description">Hiển thị cho mọi người trong jam. Để trống để dùng %1$s. Tên mới sẽ áp dụng từ lần tới bạn tạo hoặc tham gia phòng.</string>
|
||||
<string name="jam_name_too_long">Dùng tối đa %1$d ký tự.</string>
|
||||
<string name="jam_error_no_server">Chưa cấu hình máy chủ jam</string>
|
||||
<string name="jam_error_server_unreachable">Không kết nối được máy chủ jam. Hãy kiểm tra mạng hoặc URL máy chủ trong Cài đặt → Jam nhóm.</string>
|
||||
<string name="jam_error_server_incompatible">Máy chủ jam đang chạy phiên bản không tương thích.</string>
|
||||
<string name="jam_error_room_not_found">Không có phòng nào với mã này. Hãy kiểm tra và thử lại.</string>
|
||||
<string name="jam_error_room_full">Phòng này đã đầy.</string>
|
||||
<string name="jam_error_banned">Chủ phòng đã cấm bạn khỏi phòng này.</string>
|
||||
<string name="jam_error_kicked">Chủ phòng đã xóa bạn khỏi phòng.</string>
|
||||
<string name="jam_error_replaced">Phòng này đã được mở lại trên thiết bị này nên kết nối này đã bị đóng.</string>
|
||||
|
||||
<string name="player_go_to_album">Đi tới album</string>
|
||||
<string name="player_sleep_timer">Hẹn giờ tắt</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">Đề xuất cho Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">Chưa cấu hình broker cho Jam</string>
|
||||
<string name="jam_error_broker_host_empty">Máy chủ broker đang trống</string>
|
||||
<string name="jam_error_connection_failed">Kết nối thất bại</string>
|
||||
<string name="jam_error_connection_lost">Mất kết nối</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">ẩn danh</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Đã thêm vào danh sách chờ của Jam</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">在應用程式內顯示網頁內容</string>
|
||||
<string name="settings_section_jam">群組 Jam</string>
|
||||
<string name="settings_jam_broker_title">MQTT 代理伺服器</string>
|
||||
<string name="settings_jam_broker_host">代理伺服器主機</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">代理伺服器連接埠</string>
|
||||
<string name="settings_jam_broker_tls">使用 TLS</string>
|
||||
<string name="settings_jam_broker_username">使用者名稱(選填)</string>
|
||||
<string name="settings_jam_broker_password">密碼(選填)</string>
|
||||
<string name="settings_jam_broker_client_id">用戶端 ID 前綴</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">用於識別這個 Spotube</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">加密與代理伺服器的連線</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">在建立房間前檢查目前的代理伺服器設定。</string>
|
||||
<string name="settings_jam_broker_test">測試連線</string>
|
||||
<string name="settings_jam_broker_testing">測試中…</string>
|
||||
<string name="settings_jam_broker_test_ok">已連線,耗時 %1$d 毫秒</string>
|
||||
<string name="settings_jam_broker_test_fail">失敗:%1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">預留的代理伺服器 — 請設定你自己的伺服器以自行架設</string>
|
||||
<string name="settings_jam_server_test">測試連線</string>
|
||||
<string name="settings_jam_server_testing">測試中…</string>
|
||||
<string name="settings_jam_server_test_ok">已連線,耗時 %1$d 毫秒</string>
|
||||
<string name="settings_jam_server_test_fail">失敗:%1$s</string>
|
||||
<string name="settings_jam_section_connection">連線</string>
|
||||
<string name="settings_jam_section_credentials">憑證</string>
|
||||
<string name="settings_jam_section_identity">身分</string>
|
||||
<string name="settings_jam_section_diagnostics">診斷</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">連線中…</string>
|
||||
<string name="jam_setup_code_label">房間代碼</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">代理伺服器:%1$s</string>
|
||||
<string name="jam_connection_missing">請先在「設定」中設定代理伺服器,才能建立或加入房間。</string>
|
||||
<string name="jam_connection_settings">代理伺服器設定</string>
|
||||
<string name="jam_session_connected">已連線</string>
|
||||
<string name="jam_session_connecting">重新連線中…</string>
|
||||
<string name="jam_invite_copy">複製代碼</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">無法加入 Jam</string>
|
||||
<string name="jam_error_invalid_code">請輸入由英文字母和數字組成的六位房間代碼。</string>
|
||||
<string name="jam_share_message">用代碼 %1$s 加入我的 Spotube Jam</string>
|
||||
<string name="settings_jam_server_title">房間伺服器</string>
|
||||
<string name="settings_jam_server_url">伺服器網址</string>
|
||||
<string name="settings_jam_server_url_invalid">請輸入以 https:// 開頭的網址</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">建立房間前先檢查房間伺服器。</string>
|
||||
<string name="settings_jam_server_note">Jam 使用 Spotube 的免費房間伺服器。若要自行架設,請部署 github.com/team-spotube/jam-server 中的伺服器並輸入其網址。</string>
|
||||
<string name="settings_jam_section_you">你</string>
|
||||
<string name="jam_connection_host">伺服器:%1$s</string>
|
||||
<string name="jam_connection_missing">請在設定中指定房間伺服器,以建立或加入房間。</string>
|
||||
<string name="jam_connection_settings">伺服器設定</string>
|
||||
<string name="jam_participants_you">%1$s(你)</string>
|
||||
<string name="jam_name_title">你的名稱</string>
|
||||
<string name="jam_name_subtitle">Jam 裡的其他人會看到你是 %1$s</string>
|
||||
<string name="jam_name_description">Jam 裡的每個人都看得到。留空即使用 %1$s。新名稱會在你下次建立或加入房間時生效。</string>
|
||||
<string name="jam_name_too_long">最多 %1$d 個字元。</string>
|
||||
<string name="jam_error_no_server">尚未設定 Jam 伺服器</string>
|
||||
<string name="jam_error_server_unreachable">無法連線到 Jam 伺服器。請檢查網路,或 設定 → 群組 Jam 中的伺服器網址。</string>
|
||||
<string name="jam_error_server_incompatible">Jam 伺服器的版本不相容。</string>
|
||||
<string name="jam_error_room_not_found">沒有使用此代碼的房間。請檢查後再試一次。</string>
|
||||
<string name="jam_error_room_full">此房間已滿。</string>
|
||||
<string name="jam_error_banned">主持人已將你從此房間封鎖。</string>
|
||||
<string name="jam_error_kicked">主持人已將你移出房間。</string>
|
||||
<string name="jam_error_replaced">此房間已在這部裝置上再次開啟,因此這個連線已關閉。</string>
|
||||
|
||||
<string name="player_go_to_album">前往專輯</string>
|
||||
<string name="player_sleep_timer">睡眠定時器</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">推薦到 Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">尚未設定 Jam 代理伺服器</string>
|
||||
<string name="jam_error_broker_host_empty">代理伺服器主機為空白</string>
|
||||
<string name="jam_error_connection_failed">連線失敗</string>
|
||||
<string name="jam_error_connection_lost">連線中斷</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">代理伺服器</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">已加入 Jam 待播清單</string>
|
||||
|
||||
@ -288,25 +288,11 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">在应用内显示网页内容</string>
|
||||
<string name="settings_section_jam">一起听</string>
|
||||
<string name="settings_jam_broker_title">MQTT 代理服务器</string>
|
||||
<string name="settings_jam_broker_host">代理服务器主机</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">代理服务器端口</string>
|
||||
<string name="settings_jam_broker_tls">使用 TLS</string>
|
||||
<string name="settings_jam_broker_username">用户名(可选)</string>
|
||||
<string name="settings_jam_broker_password">密码(可选)</string>
|
||||
<string name="settings_jam_broker_client_id">客户端 ID 前缀</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">用于识别此 Spotube 实例</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">加密与代理服务器的连接</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">创建房间前,检查当前的代理服务器设置。</string>
|
||||
<string name="settings_jam_broker_test">测试连接</string>
|
||||
<string name="settings_jam_broker_testing">正在测试…</string>
|
||||
<string name="settings_jam_broker_test_ok">已连接,耗时 %1$d 毫秒</string>
|
||||
<string name="settings_jam_broker_test_fail">失败:%1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">占位代理服务器 — 如需自托管,请配置你自己的服务器</string>
|
||||
<string name="settings_jam_server_test">测试连接</string>
|
||||
<string name="settings_jam_server_testing">正在测试…</string>
|
||||
<string name="settings_jam_server_test_ok">已连接,耗时 %1$d 毫秒</string>
|
||||
<string name="settings_jam_server_test_fail">失败:%1$s</string>
|
||||
<string name="settings_jam_section_connection">连接</string>
|
||||
<string name="settings_jam_section_credentials">凭据</string>
|
||||
<string name="settings_jam_section_identity">身份</string>
|
||||
<string name="settings_jam_section_diagnostics">诊断</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
@ -319,9 +305,6 @@
|
||||
<string name="jam_setup_working">正在连接…</string>
|
||||
<string name="jam_setup_code_label">房间码</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">代理服务器:%1$s</string>
|
||||
<string name="jam_connection_missing">请先在设置中配置代理服务器,才能创建或加入房间。</string>
|
||||
<string name="jam_connection_settings">代理服务器设置</string>
|
||||
<string name="jam_session_connected">已连接</string>
|
||||
<string name="jam_session_connecting">正在重新连接…</string>
|
||||
<string name="jam_invite_copy">复制房间码</string>
|
||||
@ -341,6 +324,28 @@
|
||||
<string name="jam_error_join">加入一起听会话失败</string>
|
||||
<string name="jam_error_invalid_code">请输入由字母和数字组成的六位房间码。</string>
|
||||
<string name="jam_share_message">用房间码加入我的 Spotube 一起听:%1$s</string>
|
||||
<string name="settings_jam_server_title">房间服务器</string>
|
||||
<string name="settings_jam_server_url">服务器网址</string>
|
||||
<string name="settings_jam_server_url_invalid">请输入以 https:// 开头的网址</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">创建房间前先检查房间服务器。</string>
|
||||
<string name="settings_jam_server_note">一起听使用 Spotube 的免费房间服务器。如需自行托管,请部署 github.com/team-spotube/jam-server 中的服务器并填写其网址。</string>
|
||||
<string name="settings_jam_section_you">你</string>
|
||||
<string name="jam_connection_host">服务器:%1$s</string>
|
||||
<string name="jam_connection_missing">请在设置中指定房间服务器,以创建或加入房间。</string>
|
||||
<string name="jam_connection_settings">服务器设置</string>
|
||||
<string name="jam_participants_you">%1$s(你)</string>
|
||||
<string name="jam_name_title">你的名字</string>
|
||||
<string name="jam_name_subtitle">房间里的其他人看到你的名字是 %1$s</string>
|
||||
<string name="jam_name_description">房间里的所有人都能看到。留空则使用 %1$s。新名字将在你下次创建或加入房间时生效。</string>
|
||||
<string name="jam_name_too_long">最多 %1$d 个字符。</string>
|
||||
<string name="jam_error_no_server">未配置一起听服务器</string>
|
||||
<string name="jam_error_server_unreachable">无法连接到一起听服务器。请检查网络,或在 设置 → 一起听 中检查服务器网址。</string>
|
||||
<string name="jam_error_server_incompatible">一起听服务器的版本不兼容。</string>
|
||||
<string name="jam_error_room_not_found">没有使用该代码的房间。请检查后重试。</string>
|
||||
<string name="jam_error_room_full">该房间已满。</string>
|
||||
<string name="jam_error_banned">房主已将你从此房间封禁。</string>
|
||||
<string name="jam_error_kicked">房主已将你移出房间。</string>
|
||||
<string name="jam_error_replaced">此房间已在本设备上再次打开,因此此连接已关闭。</string>
|
||||
|
||||
<string name="player_go_to_album">转到专辑</string>
|
||||
<string name="player_sleep_timer">睡眠定时器</string>
|
||||
@ -513,14 +518,9 @@
|
||||
<string name="jam_destination_suggest">推荐到一起听</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">未配置一起听代理服务器</string>
|
||||
<string name="jam_error_broker_host_empty">代理服务器主机为空</string>
|
||||
<string name="jam_error_connection_failed">连接失败</string>
|
||||
<string name="jam_error_connection_lost">连接已断开</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">代理服务器</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">已添加到一起听队列</string>
|
||||
|
||||
@ -310,26 +310,18 @@
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Display web content inside the app</string>
|
||||
<string name="settings_section_jam">Group Jam</string>
|
||||
<string name="settings_jam_broker_title">MQTT Broker</string>
|
||||
<string name="settings_jam_broker_host">Broker host</string>
|
||||
<string name="settings_jam_broker_host_subtitle">%1$s:%2$d</string>
|
||||
<string name="settings_jam_broker_port">Broker port</string>
|
||||
<string name="settings_jam_broker_tls">Use TLS</string>
|
||||
<string name="settings_jam_broker_username">Username (optional)</string>
|
||||
<string name="settings_jam_broker_password">Password (optional)</string>
|
||||
<string name="settings_jam_broker_client_id">Client ID prefix</string>
|
||||
<string name="settings_jam_broker_client_id_subtitle">Used to identify this Spotube instance</string>
|
||||
<string name="settings_jam_broker_tls_subtitle">Encrypt the broker connection</string>
|
||||
<string name="settings_jam_broker_diagnostics_subtitle">Check the current broker settings before starting a room.</string>
|
||||
<string name="settings_jam_broker_test">Test connection</string>
|
||||
<string name="settings_jam_broker_testing">Testing…</string>
|
||||
<string name="settings_jam_broker_test_ok">Connected in %1$d ms</string>
|
||||
<string name="settings_jam_broker_test_fail">Failed: %1$s</string>
|
||||
<string name="settings_jam_broker_placeholder_note">Placeholder broker — configure your own to self-host</string>
|
||||
<string name="settings_jam_server_title">Room server</string>
|
||||
<string name="settings_jam_server_url">Server URL</string>
|
||||
<string name="settings_jam_server_url_invalid">Enter a URL starting with https://</string>
|
||||
<string name="settings_jam_server_diagnostics_subtitle">Check the room server before starting a room.</string>
|
||||
<string name="settings_jam_server_note">Jam uses Spotube’s free room server. To self-host, deploy the server from github.com/team-spotube/jam-server and enter its URL.</string>
|
||||
<string name="settings_jam_server_test">Test connection</string>
|
||||
<string name="settings_jam_server_testing">Testing…</string>
|
||||
<string name="settings_jam_server_test_ok">Connected in %1$d ms</string>
|
||||
<string name="settings_jam_server_test_fail">Failed: %1$s</string>
|
||||
<string name="settings_jam_section_connection">Connection</string>
|
||||
<string name="settings_jam_section_credentials">Credentials</string>
|
||||
<string name="settings_jam_section_identity">Identity</string>
|
||||
<string name="settings_jam_section_diagnostics">Diagnostics</string>
|
||||
<string name="settings_jam_section_you">You</string>
|
||||
|
||||
<!-- Group Jam -->
|
||||
<string name="jam_screen_title">Group Jam</string>
|
||||
@ -341,9 +333,9 @@
|
||||
<string name="jam_setup_working">Connecting…</string>
|
||||
<string name="jam_setup_code_label">Room code</string>
|
||||
<string name="jam_setup_code_placeholder">ABC123</string>
|
||||
<string name="jam_connection_host">Broker: %1$s</string>
|
||||
<string name="jam_connection_missing">Set up a broker in Settings to host or join a room.</string>
|
||||
<string name="jam_connection_settings">Broker settings</string>
|
||||
<string name="jam_connection_host">Server: %1$s</string>
|
||||
<string name="jam_connection_missing">Set a room server in Settings to host or join a room.</string>
|
||||
<string name="jam_connection_settings">Server settings</string>
|
||||
<string name="jam_session_connected">Connected</string>
|
||||
<string name="jam_session_connecting">Reconnecting…</string>
|
||||
<string name="jam_invite_copy">Copy code</string>
|
||||
@ -352,6 +344,7 @@
|
||||
<string name="jam_participants_heading">Participants (%1$d)</string>
|
||||
<string name="jam_participants_host">Host</string>
|
||||
<string name="jam_participants_guest">Listener</string>
|
||||
<string name="jam_participants_you">%1$s (you)</string>
|
||||
<string name="jam_participants_waiting">Waiting for listeners…</string>
|
||||
<string name="jam_participants_remove">Remove</string>
|
||||
<string name="jam_participants_ban">Ban</string>
|
||||
@ -363,6 +356,145 @@
|
||||
<string name="jam_error_join">Failed to join jam session</string>
|
||||
<string name="jam_error_invalid_code">Enter a six-character room code using letters and digits.</string>
|
||||
<string name="jam_share_message">Join my Spotube Jam with code: %1$s</string>
|
||||
<string name="jam_name_title">Your name</string>
|
||||
<string name="jam_name_subtitle">Others in the jam see you as %1$s</string>
|
||||
<string name="jam_name_description">Shown to everyone in the jam. Leave it empty to use %1$s. A new name applies the next time you start or join a room.</string>
|
||||
<string name="jam_name_too_long">Use at most %1$d characters.</string>
|
||||
<!-- A generated name: adjective, then animal (e.g. "Mellow Otter"). Reorder for your language if needed. -->
|
||||
<string name="jam_name_format">%1$s %2$s</string>
|
||||
<!-- Word lists for generated jam names. Translations may use their own words and any number of them. -->
|
||||
<string-array name="jam_name_adjectives">
|
||||
<item>Brave</item>
|
||||
<item>Bright</item>
|
||||
<item>Calm</item>
|
||||
<item>Cheerful</item>
|
||||
<item>Chill</item>
|
||||
<item>Clever</item>
|
||||
<item>Cosmic</item>
|
||||
<item>Cozy</item>
|
||||
<item>Curious</item>
|
||||
<item>Dapper</item>
|
||||
<item>Dreamy</item>
|
||||
<item>Eager</item>
|
||||
<item>Electric</item>
|
||||
<item>Fancy</item>
|
||||
<item>Fearless</item>
|
||||
<item>Fizzy</item>
|
||||
<item>Fluffy</item>
|
||||
<item>Funky</item>
|
||||
<item>Gentle</item>
|
||||
<item>Giddy</item>
|
||||
<item>Glowing</item>
|
||||
<item>Golden</item>
|
||||
<item>Groovy</item>
|
||||
<item>Happy</item>
|
||||
<item>Humble</item>
|
||||
<item>Jazzy</item>
|
||||
<item>Jolly</item>
|
||||
<item>Jumpy</item>
|
||||
<item>Kind</item>
|
||||
<item>Lively</item>
|
||||
<item>Lucky</item>
|
||||
<item>Lunar</item>
|
||||
<item>Mellow</item>
|
||||
<item>Merry</item>
|
||||
<item>Mighty</item>
|
||||
<item>Misty</item>
|
||||
<item>Nimble</item>
|
||||
<item>Peppy</item>
|
||||
<item>Plucky</item>
|
||||
<item>Polite</item>
|
||||
<item>Quick</item>
|
||||
<item>Quiet</item>
|
||||
<item>Rapid</item>
|
||||
<item>Rhythmic</item>
|
||||
<item>Rosy</item>
|
||||
<item>Silly</item>
|
||||
<item>Sleepy</item>
|
||||
<item>Smooth</item>
|
||||
<item>Snappy</item>
|
||||
<item>Soulful</item>
|
||||
<item>Sparkly</item>
|
||||
<item>Speedy</item>
|
||||
<item>Spicy</item>
|
||||
<item>Sunny</item>
|
||||
<item>Swift</item>
|
||||
<item>Tidy</item>
|
||||
<item>Tiny</item>
|
||||
<item>Vivid</item>
|
||||
<item>Wavy</item>
|
||||
<item>Whistling</item>
|
||||
<item>Witty</item>
|
||||
<item>Zany</item>
|
||||
<item>Zesty</item>
|
||||
<item>Zippy</item>
|
||||
</string-array>
|
||||
<string-array name="jam_name_animals">
|
||||
<item>Alpaca</item>
|
||||
<item>Badger</item>
|
||||
<item>Beaver</item>
|
||||
<item>Bison</item>
|
||||
<item>Bunny</item>
|
||||
<item>Capybara</item>
|
||||
<item>Cheetah</item>
|
||||
<item>Chipmunk</item>
|
||||
<item>Cobra</item>
|
||||
<item>Corgi</item>
|
||||
<item>Coyote</item>
|
||||
<item>Crane</item>
|
||||
<item>Dingo</item>
|
||||
<item>Dolphin</item>
|
||||
<item>Duckling</item>
|
||||
<item>Eagle</item>
|
||||
<item>Falcon</item>
|
||||
<item>Ferret</item>
|
||||
<item>Finch</item>
|
||||
<item>Flamingo</item>
|
||||
<item>Fox</item>
|
||||
<item>Gazelle</item>
|
||||
<item>Gecko</item>
|
||||
<item>Giraffe</item>
|
||||
<item>Hedgehog</item>
|
||||
<item>Heron</item>
|
||||
<item>Hippo</item>
|
||||
<item>Ibis</item>
|
||||
<item>Jaguar</item>
|
||||
<item>Koala</item>
|
||||
<item>Lemur</item>
|
||||
<item>Llama</item>
|
||||
<item>Lynx</item>
|
||||
<item>Magpie</item>
|
||||
<item>Manatee</item>
|
||||
<item>Marmot</item>
|
||||
<item>Meerkat</item>
|
||||
<item>Mongoose</item>
|
||||
<item>Moose</item>
|
||||
<item>Narwhal</item>
|
||||
<item>Ocelot</item>
|
||||
<item>Octopus</item>
|
||||
<item>Orca</item>
|
||||
<item>Otter</item>
|
||||
<item>Owl</item>
|
||||
<item>Panda</item>
|
||||
<item>Pangolin</item>
|
||||
<item>Parrot</item>
|
||||
<item>Pelican</item>
|
||||
<item>Penguin</item>
|
||||
<item>Puffin</item>
|
||||
<item>Quokka</item>
|
||||
<item>Raccoon</item>
|
||||
<item>Raven</item>
|
||||
<item>Robin</item>
|
||||
<item>Seal</item>
|
||||
<item>Sloth</item>
|
||||
<item>Sparrow</item>
|
||||
<item>Squirrel</item>
|
||||
<item>Tapir</item>
|
||||
<item>Tiger</item>
|
||||
<item>Toucan</item>
|
||||
<item>Walrus</item>
|
||||
<item>Wombat</item>
|
||||
</string-array>
|
||||
|
||||
<string name="player_go_to_album">Go to album</string>
|
||||
<string name="player_sleep_timer">Sleep timer</string>
|
||||
@ -549,15 +681,17 @@
|
||||
<string name="jam_destination_suggest">Suggest to Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<string name="jam_error_no_broker">No jam broker configured</string>
|
||||
<string name="jam_error_broker_host_empty">Broker host is empty</string>
|
||||
<string name="jam_error_no_server">No jam server configured</string>
|
||||
<string name="jam_error_server_unreachable">Couldn’t reach the jam server. Check your network or the server URL in Settings → Jam.</string>
|
||||
<string name="jam_error_server_incompatible">The jam server runs an incompatible version.</string>
|
||||
<string name="jam_error_room_not_found">No room with that code. Check it and try again.</string>
|
||||
<string name="jam_error_room_full">This room is full.</string>
|
||||
<string name="jam_error_banned">The host banned you from this room.</string>
|
||||
<string name="jam_error_kicked">The host removed you from the room.</string>
|
||||
<string name="jam_error_replaced">This room was opened again on this device, so this connection was closed.</string>
|
||||
<string name="jam_error_connection_failed">Connection failed</string>
|
||||
<string name="jam_error_connection_lost">Connection lost</string>
|
||||
<string name="jam_error_broker_unreachable">Couldn't reach %1$s:%2$d. Check your network or pick another broker in Settings → Jam.</string>
|
||||
|
||||
<!-- Settings: Group Jam broker -->
|
||||
<string name="settings_jam_broker_content_description">Broker</string>
|
||||
<string name="settings_jam_broker_username_placeholder">anonymous</string>
|
||||
|
||||
<!-- Group Jam: queue snackbar -->
|
||||
<string name="jam_queue_added_track">Added to the jam queue</string>
|
||||
|
||||
@ -44,6 +44,12 @@ interface AudioPlayerQueue {
|
||||
suspend fun getCurrentCollectionEntry(): QueueCollectionEntry?
|
||||
suspend fun getCollectionHistory(): List<QueueCollectionEntry>
|
||||
|
||||
/**
|
||||
* Stops endless playback from adding recommendations when the queue runs out. A jam
|
||||
* guest's queue belongs to the host; recommendations of its own would split them.
|
||||
*/
|
||||
fun setEndlessPlaybackSuppressed(suppressed: Boolean) = Unit
|
||||
|
||||
fun isPlaylistPlaying(playlistId: String? = null): Boolean {
|
||||
val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false
|
||||
return playlistId == null || entry.id == playlistId
|
||||
|
||||
@ -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<List<QueueEntry>> = 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<QueueEntry?> = 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<QueueCollectionEntry?> =
|
||||
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
|
||||
|
||||
@ -86,6 +86,7 @@ import org.spotube.spotube.modules.update.AppUpdateService
|
||||
import org.spotube.spotube.modules.update.PluginUpdateService
|
||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import org.spotube.spotube.modules.settings.UserSettingsStore
|
||||
import org.spotube.spotube.modules.settings.UserSettingsSource
|
||||
import org.spotube.spotube.modules.settings.SettingsViewModel
|
||||
import org.spotube.spotube.modules.settings.JamSettingsViewModel
|
||||
@ -151,7 +152,10 @@ val sharedModules = module {
|
||||
viewModelOf(::MetadataPluginGuardViewModel)
|
||||
|
||||
// Settings
|
||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||
singleOf(::SettingsRepository) {
|
||||
bind<UserSettingsSource>()
|
||||
bind<UserSettingsStore>()
|
||||
}
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
viewModel {
|
||||
@ -283,7 +287,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<CacheManager>()::invalidateCacheEntry)
|
||||
}
|
||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||
|
||||
@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
/**
|
||||
* Estimates the offset between this device's clock and the room server's (NTP-style).
|
||||
*
|
||||
* Each ping is sent at local time `c` and answered with server time `s`; it arrives back
|
||||
* at local time `r`. Assuming symmetric latency, the server read its clock halfway
|
||||
* through, so `offset = s - (c + r) / 2`. Samples with the lowest round trip have the
|
||||
* least room for asymmetry, so the estimate uses the fastest of the recent samples.
|
||||
* Samples older than [maxAgeMs] are dropped, so the estimate follows slow clock drift.
|
||||
*/
|
||||
class JamClock(private val window: Int = 16, private val maxAgeMs: Long = 15 * 60_000L) {
|
||||
private data class Sample(val rttMs: Long, val offsetMs: Long, val atMs: Long)
|
||||
|
||||
private val samples = ArrayDeque<Sample>()
|
||||
|
||||
/** Local-to-server offset in ms, or null before the first sample. */
|
||||
var offsetMs: Long? = null
|
||||
private set
|
||||
|
||||
/** Round trip of the sample the offset came from. */
|
||||
var rttMs: Long? = null
|
||||
private set
|
||||
|
||||
fun addSample(clientSentMs: Long, serverMs: Long, clientReceivedMs: Long) {
|
||||
val rtt = clientReceivedMs - clientSentMs
|
||||
if (rtt < 0) return
|
||||
samples.addLast(Sample(rtt, serverMs - (clientSentMs + clientReceivedMs) / 2, clientReceivedMs))
|
||||
while (samples.size > window || samples.first().atMs < clientReceivedMs - maxAgeMs) samples.removeFirst()
|
||||
val best = samples.minBy { it.rttMs }
|
||||
offsetMs = best.offsetMs
|
||||
rttMs = best.rttMs
|
||||
}
|
||||
|
||||
fun serverTime(localMs: Long): Long = localMs + (offsetMs ?: 0L)
|
||||
|
||||
fun reset() {
|
||||
samples.clear()
|
||||
offsetMs = null
|
||||
rttMs = null
|
||||
}
|
||||
}
|
||||
|
||||
/** What a guest does about the gap between where the room is and where it is. */
|
||||
sealed class JamCorrection {
|
||||
/** In sync: play at the room's rate. */
|
||||
data object None : JamCorrection()
|
||||
|
||||
/** Slightly off: speed up or slow down until caught up (inaudible, no gap). */
|
||||
data class Nudge(val rate: Float) : JamCorrection()
|
||||
|
||||
/** Too far off to nudge: jump to [positionMs]. */
|
||||
data class Seek(val positionMs: Long) : JamCorrection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns drift into a [JamCorrection]. Players report their position coarsely (VLC in
|
||||
* ~250 ms steps), so decisions use the median of the last few drift readings, and
|
||||
* small drift inside [deadBandMs] is left alone.
|
||||
*/
|
||||
class JamDriftCorrector(
|
||||
private val deadBandMs: Long = 120,
|
||||
private val seekThresholdMs: Long = 1_500,
|
||||
/** Drift is closed over roughly this long while nudging. */
|
||||
private val catchUpMs: Long = 3_000,
|
||||
private val maxNudge: Float = 0.05f,
|
||||
private val window: Int = 5,
|
||||
/** Readings needed before a seek, so one noisy report right after a change can't cause one. */
|
||||
private val minReadingsToSeek: Int = 3) {
|
||||
private val drifts = ArrayDeque<Long>()
|
||||
|
||||
/**
|
||||
* [expectedMs] is the room's position, [actualMs] ours, [roomRate] the host's rate.
|
||||
* [seekLeadMs] is how long a seek takes to resume here; a seek aims that far ahead.
|
||||
*/
|
||||
fun correct(expectedMs: Long, actualMs: Long, roomRate: Float, seekLeadMs: Long): JamCorrection {
|
||||
val drift = expectedMs - actualMs
|
||||
// A huge jump is acted on at once; waiting for the median would replay it for seconds.
|
||||
if (kotlin.math.abs(drift) > seekThresholdMs * 2) {
|
||||
reset()
|
||||
return JamCorrection.Seek(expectedMs + seekLeadMs)
|
||||
}
|
||||
drifts.addLast(drift)
|
||||
while (drifts.size > window) drifts.removeFirst()
|
||||
val smoothed = drifts.sorted()[drifts.size / 2]
|
||||
|
||||
return when {
|
||||
kotlin.math.abs(smoothed) > seekThresholdMs && drifts.size >= minReadingsToSeek -> {
|
||||
reset()
|
||||
JamCorrection.Seek(expectedMs + seekLeadMs)
|
||||
}
|
||||
kotlin.math.abs(smoothed) <= deadBandMs -> JamCorrection.None
|
||||
else -> {
|
||||
val nudge = (smoothed.toFloat() / catchUpMs).coerceIn(-maxNudge, maxNudge)
|
||||
JamCorrection.Nudge(roomRate * (1f + nudge))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun reset() = drifts.clear()
|
||||
}
|
||||
@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
|
||||
/**
|
||||
* Stable traits of this device (model, OS version, locale, …) for jam bans. They never
|
||||
* leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the
|
||||
* server hashes that again with the network, so a room only ever holds an opaque value.
|
||||
*/
|
||||
internal expect fun jamDeviceTraits(): List<String>
|
||||
|
||||
/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */
|
||||
internal fun jamDeviceDigest(roomCode: String): String? = runCatching {
|
||||
val traits = jamDeviceTraits().filter { it.isNotBlank() }
|
||||
if (traits.isEmpty()) return null
|
||||
"spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex()
|
||||
}.getOrNull()
|
||||
|
||||
/**
|
||||
* This install's participant secret for room [roomCode], derived from the install's
|
||||
* [deviceKey] (random, kept in settings). The same install gets the same identity in a
|
||||
* room every time, so a ban sticks across rejoins; different rooms get unrelated keys.
|
||||
*/
|
||||
internal fun jamParticipantKey(deviceKey: String, roomCode: String): String =
|
||||
"spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex()
|
||||
@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import org.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
|
||||
}
|
||||
@ -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(
|
||||
data class JamQueue(
|
||||
val items: List<JamMediaItem>,
|
||||
val currentIndex: Int,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
/**
|
||||
* Whether guests should follow the host's current index. True when the
|
||||
* host manually skipped/jumped or loaded a queue; false when the host
|
||||
* merely auto-advanced because its song ended (guests stay put).
|
||||
*/
|
||||
val follow: Boolean = false,
|
||||
/** Room-wide play/pause; guests follow every change of it. */
|
||||
val isPlaying: Boolean = false) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestTrack")
|
||||
data class SuggestTrack(
|
||||
val mediaItem: JamMediaItem,
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(
|
||||
val tracks: List<JamMediaItem>,
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
data class Kick(
|
||||
val participantId: String,
|
||||
val reason: String = "kicked") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("leave")
|
||||
data class Leave(val reason: String = "user_left") : JamMessage()
|
||||
}
|
||||
val shuffleEnabled: Boolean = false)
|
||||
|
||||
/**
|
||||
* Playback commands. Queue navigation and play/pause are global — seek,
|
||||
* volume, shuffle and loop are local to each participant.
|
||||
* A playback anchor: track [trackKey] (at queue [index]) was at [positionMs] at server
|
||||
* time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at
|
||||
* [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead.
|
||||
*/
|
||||
/** 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<JamTrackSource> = 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<JamMediaItem>,
|
||||
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(", "),
|
||||
|
||||
@ -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<String?>? = null
|
||||
private var pingId = 0
|
||||
/** When the current socket last received anything; a silent socket is a dead one. */
|
||||
private var lastFrameAtMs = 0L
|
||||
/** The room's host as last reported by the server. */
|
||||
private var hostId: String? = null
|
||||
|
||||
/**
|
||||
* Our public participant id in the current room, as the server derived it from our
|
||||
* secret key. Null until the first welcome.
|
||||
*/
|
||||
var selfId: String? = null
|
||||
private set
|
||||
private val pongs = MutableStateFlow(0)
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
@ -89,244 +132,429 @@ class JamRoomClient : KoinComponent {
|
||||
private val _connectionError = MutableStateFlow<String?>(null)
|
||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||
|
||||
private val _state = MutableSharedFlow<JamMessage.QueueState>(replay = 1, extraBufferCapacity = 8)
|
||||
val state: SharedFlow<JamMessage.QueueState> = _state.asSharedFlow()
|
||||
|
||||
private val _commands = MutableSharedFlow<JamMessage>(extraBufferCapacity = 32)
|
||||
val commands: SharedFlow<JamMessage> = _commands.asSharedFlow()
|
||||
|
||||
private val _presence = MutableStateFlow<Map<String, JamPresence>>(emptyMap())
|
||||
val presence: StateFlow<Map<String, JamPresence>> = _presence.asStateFlow()
|
||||
private val _events = MutableSharedFlow<JamRoomEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<JamRoomEvent> = _events.asSharedFlow()
|
||||
|
||||
// ---------- Public API ----------
|
||||
|
||||
/** One-off connection check used by the settings screen. Returns latency description. */
|
||||
suspend fun testConnection(broker: JamBroker): Result<String> {
|
||||
if (broker.host.isBlank()) return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_broker_host_empty)))
|
||||
/** Checks that [serverUrl] runs a compatible room server. Returns a latency description. */
|
||||
suspend fun testConnection(serverUrl: String): Result<String> = runCatching {
|
||||
val started = kotlin.time.TimeSource.Monotonic.markNow()
|
||||
return runCatching {
|
||||
val client = startClient(broker, clientId = "${broker.clientIdPrefix}-test")
|
||||
try {
|
||||
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
||||
client.awaitConnectivity()
|
||||
}
|
||||
getString(Res.string.settings_jam_broker_test_ok, started.elapsedNow().inWholeMilliseconds)
|
||||
} finally {
|
||||
runCatching { client.shutdown(sendDisconnect = true, drain = false) }
|
||||
}
|
||||
}
|
||||
val response = plainHttp.get(healthUrl(serverUrl))
|
||||
if (!response.status.isSuccess()) error(getString(Res.string.jam_error_server_unreachable))
|
||||
val health = json.decodeFromString(Health.serializer(), response.bodyAsText())
|
||||
if (!health.ok || health.v != PROTOCOL_VERSION) error(getString(Res.string.jam_error_server_incompatible))
|
||||
getString(Res.string.settings_jam_server_test_ok, started.elapsedNow().inWholeMilliseconds)
|
||||
}
|
||||
|
||||
/** Connects to [code] on [broker] and starts routing room messages. */
|
||||
/**
|
||||
* Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException]
|
||||
* when the server refuses (code taken, room not found, banned, …). Once connected,
|
||||
* drops are retried in the background until [disconnect] or a refusal.
|
||||
*
|
||||
* [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<Unit> {
|
||||
create: Boolean,
|
||||
deviceDigest: String? = null): Result<JamRoomEvent.Welcome> {
|
||||
disconnect()
|
||||
if (broker.host.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_no_broker)))
|
||||
}
|
||||
return runCatching {
|
||||
roomCode = code
|
||||
localPresence = JamPresence(
|
||||
clientId = clientId,
|
||||
displayName = displayName,
|
||||
isHost = isHost,
|
||||
left = false)
|
||||
|
||||
val mqtt = startClient(broker, clientId)
|
||||
client = mqtt
|
||||
try {
|
||||
withTimeout(broker.connectionTimeoutSeconds.seconds) {
|
||||
// connectionState replays its current value, unlike awaitConnectivity's
|
||||
// broadcast flow, which loses a CONNACK that lands before it is collected.
|
||||
mqtt.connectionState.first { it is ConnectionState.Connected }
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
throw IllegalStateException(
|
||||
getString(Res.string.jam_error_broker_unreachable, broker.host, broker.port), e)
|
||||
}
|
||||
_connectionError.value = null
|
||||
log.i { "Connected to ${broker.host}:${broker.port} room=$code as $clientId" }
|
||||
|
||||
mqtt.connectionState
|
||||
.onEach { onConnectionStateChanged(it) }
|
||||
.launchIn(scope)
|
||||
Unit
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to connect to jam broker" }
|
||||
clock.reset()
|
||||
val 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)
|
||||
}
|
||||
.getOrElse { return Result.failure(it) }
|
||||
|
||||
connectionJob = scope.launch {
|
||||
var current = welcome.second
|
||||
var attempt = 0
|
||||
while (isActive) {
|
||||
val refusal = runCatching { current.await() }.getOrNull()
|
||||
ensureActive()
|
||||
_isConnected.value = false
|
||||
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
|
||||
client = null
|
||||
if (refusal != null) {
|
||||
fail(refusal)
|
||||
return@launch
|
||||
}
|
||||
_connectionError.value = getString(Res.string.jam_error_connection_lost)
|
||||
// Rejoin: the room exists now, so even a former host comes back with "join".
|
||||
while (isActive) {
|
||||
delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)])
|
||||
attempt++
|
||||
var reopened = runCatching { open(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<JamRoomEvent.Welcome, Deferred<String?>> {
|
||||
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))
|
||||
}
|
||||
|
||||
private fun onConnectionStateChanged(state: ConnectionState) {
|
||||
when (state) {
|
||||
is ConnectionState.Connected -> {
|
||||
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 suspend fun awaitWelcome(socket: DefaultWebSocketSession): ServerFrame.Welcome {
|
||||
while (true) {
|
||||
val frame = socket.incoming.receiveCatching().getOrNull()
|
||||
?: throw IllegalStateException(getString(Res.string.jam_error_connection_failed))
|
||||
when (val decoded = decode(frame)) {
|
||||
is ServerFrame.Welcome -> return decoded
|
||||
is ServerFrame.Error -> throw JamRoomException(decoded.code, refusalMessage(decoded.code))
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A burst of clock pings (playback can't sync before the clock is known); waits for
|
||||
* their pongs, so the first sync is done when this returns.
|
||||
*/
|
||||
private suspend fun syncClock(socket: DefaultWebSocketSession, pings: Int = INITIAL_PINGS) {
|
||||
val target = pongs.value + pings
|
||||
repeat(pings) {
|
||||
ping(socket)
|
||||
delay(PING_SPACING_MS)
|
||||
}
|
||||
withTimeoutOrNull(CLOCK_SYNC_TIMEOUT_MS) { pongs.first { it >= target } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads frames until the socket closes or fails (a dropped network surfaces here as
|
||||
* an exception). Returns the server's refusal, if any.
|
||||
*/
|
||||
private suspend fun pump(socket: DefaultWebSocketSession): String? = withKeepAlive(socket) {
|
||||
var refusal: String? = null
|
||||
try {
|
||||
for (frame in socket.incoming) {
|
||||
lastFrameAtMs = now()
|
||||
refusal = handle(frame) ?: refusal
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
log.i { "Jam connection dropped: ${e.message}" }
|
||||
}
|
||||
refusal
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the socket alive (and NAT mappings open) with free keep-alives, gives up on a
|
||||
* socket that has gone silent (a half-open connection never errors by itself), and
|
||||
* re-syncs the clock now and then.
|
||||
*/
|
||||
private suspend fun <T> withKeepAlive(
|
||||
socket: DefaultWebSocketSession,
|
||||
block: suspend () -> T): T {
|
||||
val keeper = scope.launch {
|
||||
var lastClockSyncMs = now()
|
||||
while (isActive) {
|
||||
delay(KEEPALIVE_INTERVAL_MS)
|
||||
if (now() - lastFrameAtMs > DEAD_AFTER_MS) {
|
||||
log.i { "Jam connection went silent; reconnecting" }
|
||||
runCatching { socket.close() }
|
||||
socket.cancel()
|
||||
return@launch
|
||||
}
|
||||
runCatching { socket.send(Frame.Text(KEEPALIVE_PING)) }
|
||||
if (now() - lastClockSyncMs >= CLOCK_RESYNC_INTERVAL_MS) {
|
||||
lastClockSyncMs = now()
|
||||
runCatching { syncClock(socket, RESYNC_PINGS) }
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
keeper.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes one frame; returns an error code when the server refused us. */
|
||||
private suspend fun handle(frame: Frame): String? {
|
||||
if (frame is Frame.Text && frame.readText() == KEEPALIVE_PONG) return null
|
||||
when (val decoded = decode(frame)) {
|
||||
is ServerFrame.Pong -> {
|
||||
clock.addSample(decoded.c, decoded.s, now())
|
||||
pongs.value++
|
||||
}
|
||||
is ServerFrame.Welcome -> {
|
||||
hostId = decoded.host
|
||||
_events.emit(decoded.toEvent())
|
||||
}
|
||||
is ServerFrame.Presence -> {
|
||||
hostId = decoded.host
|
||||
_events.emit(JamRoomEvent.Presence(decoded.host, decoded.participants.toParticipants(decoded.host)))
|
||||
}
|
||||
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
|
||||
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
|
||||
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
|
||||
// Sent too much too fast (a burst of skips, say): not a refusal, just reconnect.
|
||||
is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" }
|
||||
null -> Unit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun fail(code: String) {
|
||||
val message = refusalMessage(code)
|
||||
log.i { "Jam room closed by the server: $code" }
|
||||
session = null
|
||||
_connectionError.value = message
|
||||
_events.emit(JamRoomEvent.Closed(code, message))
|
||||
}
|
||||
|
||||
private suspend fun ping(socket: DefaultWebSocketSession) {
|
||||
socket.send(Frame.Text(json.encodeToString(ClientFrame.serializer(), ClientFrame.Ping(++pingId, now()))))
|
||||
}
|
||||
|
||||
private fun send(frame: ClientFrame) {
|
||||
val socket = session ?: return
|
||||
val text = json.encodeToString(ClientFrame.serializer(), frame)
|
||||
scope.launch {
|
||||
// Every connection (initial + reconnects) must re-establish the
|
||||
// broker-side subscription (clean session) and re-publish our
|
||||
// retained presence to clear any Last Will. Re-subscribing with
|
||||
// the same filter replaces the previous dispatcher handler.
|
||||
subscribeRoom()
|
||||
publishPresence()
|
||||
runCatching { socket.send(Frame.Text(text)) }
|
||||
.onFailure { log.w(it) { "Failed to send jam frame" } }
|
||||
}
|
||||
}
|
||||
|
||||
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
|
||||
_isConnected.value = false
|
||||
private fun decode(frame: Frame): ServerFrame? {
|
||||
if (frame !is Frame.Text) return null
|
||||
val text = frame.readText()
|
||||
return runCatching { json.decodeFromString(ServerFrame.serializer(), text) }
|
||||
.onFailure { log.w(it) { "Failed to decode jam frame: ${text.take(200)}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
else -> {
|
||||
_isConnected.value = false
|
||||
scope.launch {
|
||||
val message = getString(Res.string.jam_error_connection_lost)
|
||||
if (!_isConnected.value) _connectionError.value = message
|
||||
}
|
||||
}
|
||||
}
|
||||
private suspend fun refusalMessage(code: String): String = when (code) {
|
||||
"room_not_found" -> getString(Res.string.jam_error_room_not_found)
|
||||
"room_full" -> getString(Res.string.jam_error_room_full)
|
||||
"banned" -> getString(Res.string.jam_error_banned)
|
||||
"kicked" -> getString(Res.string.jam_error_kicked)
|
||||
"replaced" -> getString(Res.string.jam_error_replaced)
|
||||
else -> getString(Res.string.jam_error_connection_failed)
|
||||
}
|
||||
|
||||
private suspend fun subscribeRoom() {
|
||||
val mqtt = client ?: return
|
||||
val code = roomCode ?: return
|
||||
val operation = mqtt.subscribe(
|
||||
roomFilter(code),
|
||||
OpaquePublishPayloadCodec,
|
||||
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||
route(publish.topic.toString(), payload)
|
||||
}
|
||||
runCatching { operation.subAck.await() }
|
||||
.onFailure { log.w(it) { "Subscribe ack failed for room $code" } }
|
||||
}
|
||||
private fun roomUrl(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 route(topic: String, payload: com.ditchoom.mqtt.controlpacket.OpaquePublishPayload) {
|
||||
val text = runCatching {
|
||||
val buffer = payload.handle.asReadBuffer()
|
||||
buffer.readString(buffer.remaining(), Charset.UTF8)
|
||||
}.getOrElse { e ->
|
||||
log.w(e) { "Failed to read jam payload on $topic" }
|
||||
return
|
||||
}
|
||||
private fun healthUrl(serverUrl: String) =
|
||||
URLBuilder(serverUrl.trim()).apply {
|
||||
if (protocol == URLProtocol.WS) protocol = URLProtocol.HTTP
|
||||
if (protocol == URLProtocol.WSS) protocol = URLProtocol.HTTPS
|
||||
appendPathSegments("v1", "health")
|
||||
}.buildString()
|
||||
|
||||
runCatching {
|
||||
when {
|
||||
topic.endsWith("/state") -> {
|
||||
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
|
||||
}
|
||||
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
topic.endsWith("/cmd") -> {
|
||||
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
|
||||
companion object {
|
||||
const val PROTOCOL_VERSION = 1
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000L
|
||||
private const val INITIAL_PINGS = 6
|
||||
private const val RESYNC_PINGS = 3
|
||||
private const val PING_SPACING_MS = 100L
|
||||
private const val CLOCK_SYNC_TIMEOUT_MS = 3_000L
|
||||
/** Clocks drift by a few ms per minute at most; this keeps sync error well under 50 ms. */
|
||||
private const val CLOCK_RESYNC_INTERVAL_MS = 10 * 60_000L
|
||||
/**
|
||||
* As rare as possible (each one wakes a phone's radio) while staying under the
|
||||
* 100 s idle timeout of Cloudflare's proxy. A dropped idle socket would only cost
|
||||
* a reconnect, so there's no need for margin beyond that.
|
||||
*/
|
||||
private const val KEEPALIVE_INTERVAL_MS = 60_000L
|
||||
/** No pong to two keep-alives in a row: the connection is gone. */
|
||||
private const val DEAD_AFTER_MS = 2 * KEEPALIVE_INTERVAL_MS + 10_000L
|
||||
private const val KEEPALIVE_PING = "ping"
|
||||
private const val KEEPALIVE_PONG = "pong"
|
||||
/** Quick at first (most drops are a network switch), then slower to spare the battery. */
|
||||
private val RECONNECT_BACKOFF_MS = longArrayOf(500, 1_000, 2_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 60_000)
|
||||
}
|
||||
|
||||
topic.contains("/presence/") -> {
|
||||
val presence = json.decodeFromString(JamPresence.serializer(), text)
|
||||
_presence.value = _presence.value + (presence.clientId to presence)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to decode jam message on $topic: $text" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun publishJson(topic: String, payload: String, retain: Boolean) {
|
||||
val mqtt = client ?: return
|
||||
runCatching {
|
||||
mqtt.publish(
|
||||
topicName = topic,
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
payload = payload.toReadBuffer(Charset.UTF8),
|
||||
retain = retain)
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to publish to $topic" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateTopic(code: String) = "spotube/jam/$code/state"
|
||||
private fun cmdTopic(code: String) = "spotube/jam/$code/cmd"
|
||||
private fun presenceTopic(code: String, clientId: String) = "spotube/jam/$code/presence/$clientId"
|
||||
private fun roomFilter(code: String) = "spotube/jam/$code/#"
|
||||
}
|
||||
|
||||
/** Something that happened in the room, for [JamRoomService]. */
|
||||
sealed interface JamRoomEvent {
|
||||
/** (Re)joined the room; carries the retained queue and anchor. */
|
||||
data class Welcome(
|
||||
val you: String,
|
||||
val host: String?,
|
||||
val participants: List<JamParticipant>,
|
||||
val queue: JamQueue?,
|
||||
val anchor: JamAnchor?) : JamRoomEvent
|
||||
|
||||
data class Presence(val host: String?, val participants: List<JamParticipant>) : JamRoomEvent
|
||||
data class Queue(val queue: JamQueue) : JamRoomEvent
|
||||
data class Anchor(val anchor: JamAnchor) : JamRoomEvent
|
||||
data class Command(val from: String, val command: JamCommand) : JamRoomEvent
|
||||
|
||||
/** The server ended our membership ([code]: kicked, banned, …); no retry follows. */
|
||||
data class Closed(val code: String, val message: String) : JamRoomEvent
|
||||
}
|
||||
|
||||
/** The server refused to let us into the room. */
|
||||
class JamRoomException(val code: String, message: String) : Exception(message)
|
||||
|
||||
// ---------- Wire frames (see 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<WireParticipant>.toParticipants(host: String?) =
|
||||
map { JamParticipant(it.id, it.name, isHost = it.id == host) }
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
@JsonClassDiscriminator("t")
|
||||
private sealed class ClientFrame {
|
||||
@Serializable
|
||||
@SerialName("ping")
|
||||
data class Ping(val id: Int, val c: Long) : ClientFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queue")
|
||||
data class Queue(val body: JamQueue) : ClientFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("anchor")
|
||||
data class Anchor(val body: JamAnchor) : ClientFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd")
|
||||
data class Cmd(val body: JamCommand) : ClientFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
data class Kick(val target: String, val ban: Boolean) : ClientFrame()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
@Serializable
|
||||
@JsonClassDiscriminator("t")
|
||||
private sealed class ServerFrame {
|
||||
@Serializable
|
||||
@SerialName("welcome")
|
||||
data class Welcome(
|
||||
val v: Int,
|
||||
val you: String,
|
||||
val host: String? = null,
|
||||
val participants: List<WireParticipant> = emptyList(),
|
||||
val queue: JamQueue? = null,
|
||||
val anchor: JamAnchor? = null) : ServerFrame() {
|
||||
fun toEvent() = JamRoomEvent.Welcome(you, host, participants.toParticipants(host), queue, anchor)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
@SerialName("pong")
|
||||
data class Pong(val id: Int, val c: Long, val s: Long) : ServerFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("presence")
|
||||
data class Presence(val host: String? = null, val participants: List<WireParticipant> = emptyList()) : ServerFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queue")
|
||||
data class Queue(val body: JamQueue) : ServerFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("anchor")
|
||||
data class Anchor(val body: JamAnchor) : ServerFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("cmd")
|
||||
data class Cmd(val from: String, val body: JamCommand) : ServerFrame()
|
||||
|
||||
@Serializable
|
||||
@SerialName("error")
|
||||
data class Error(val code: String) : ServerFrame()
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<JamParticipantUi> = emptyList(),
|
||||
val roomCode: String? = null,
|
||||
val brokerHost: String = "",
|
||||
val brokerConfigured: Boolean = false,
|
||||
val serverHost: String = "",
|
||||
val serverConfigured: Boolean = false,
|
||||
val error: String? = null,
|
||||
val setupMode: JamSetupMode = JamSetupMode.Create,
|
||||
val codeInput: TextFieldState = TextFieldState(),
|
||||
@ -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<String?>(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() {
|
||||
|
||||
@ -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)
|
||||
)
|
||||
})
|
||||
stringResource(Res.string.settings_jam_server_title))
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_tls),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
|
||||
checked = broker.useTls,
|
||||
onCheckedChange = { tls ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxWifiSquare,
|
||||
stringResource(Res.string.settings_jam_broker_tls)
|
||||
)
|
||||
})
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_credentials)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_username),
|
||||
value = broker.username.orEmpty(),
|
||||
placeholder = stringResource(Res.string.settings_jam_broker_username_placeholder),
|
||||
onValueSaved = { username ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.User,
|
||||
stringResource(Res.string.settings_jam_broker_username)
|
||||
)
|
||||
})
|
||||
},
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_password),
|
||||
value = broker.password.orEmpty(),
|
||||
placeholder = "••••••••",
|
||||
onValueSaved = { password ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxEye,
|
||||
stringResource(Res.string.settings_jam_broker_password)
|
||||
)
|
||||
})
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_identity)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_client_id),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
|
||||
value = broker.clientIdPrefix,
|
||||
placeholder = "spotube",
|
||||
onValueSaved = { prefix ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxTag,
|
||||
stringResource(Res.string.settings_jam_broker_client_id)
|
||||
)
|
||||
})
|
||||
onValueSaved = viewModel::setServerUrl)
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
val canTest = broker.host.isNotBlank() && !uiState.isTesting
|
||||
val resultMessage = uiState.testResult?.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
stringResource(
|
||||
Res.string.settings_jam_broker_test_fail,
|
||||
Res.string.settings_jam_server_test_fail,
|
||||
it.message ?: stringResource(Res.string.jam_error_connection_failed))
|
||||
})
|
||||
|
||||
SettingCardItem(
|
||||
enabled = canTest,
|
||||
title = stringResource(Res.string.settings_jam_broker_test),
|
||||
enabled = !uiState.isTesting,
|
||||
title = stringResource(Res.string.settings_jam_server_test),
|
||||
subtitle = when {
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_server_testing)
|
||||
resultMessage != null -> resultMessage
|
||||
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
|
||||
else -> stringResource(Res.string.settings_jam_server_diagnostics_subtitle)
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxInformation,
|
||||
stringResource(Res.string.settings_jam_broker_test))
|
||||
stringResource(Res.string.settings_jam_server_test))
|
||||
},
|
||||
trailingContent = {
|
||||
MillenniumButton(
|
||||
onClick = viewModel::testConnection,
|
||||
enabled = broker.host.isNotBlank(),
|
||||
loading = uiState.isTesting) {
|
||||
Text(stringResource(Res.string.settings_jam_broker_test))
|
||||
Text(stringResource(Res.string.settings_jam_server_test))
|
||||
}
|
||||
},
|
||||
onClick = viewModel::testConnection)
|
||||
}))
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
||||
text = stringResource(Res.string.settings_jam_server_note),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
|
||||
@ -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<String>? = null)
|
||||
val testResult: Result<String>? = null,
|
||||
/** This device's generated jam name, used while the user hasn't picked one. */
|
||||
val defaultName: String = "")
|
||||
|
||||
class JamSettingsViewModel(
|
||||
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<JamSettingsUiState> = _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) }
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -34,7 +34,12 @@ interface UserSettingsSource {
|
||||
val userSettings: StateFlow<UserSettings>
|
||||
}
|
||||
|
||||
class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||
/** [UserSettingsSource] that can also save, for consumers that update a setting or two. */
|
||||
interface UserSettingsStore : UserSettingsSource {
|
||||
suspend fun updateSettings(newSettings: UserSettings)
|
||||
}
|
||||
|
||||
class SettingsRepository(private val database: Database) : UserSettingsStore {
|
||||
companion object {
|
||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||
|
||||
@ -64,7 +69,7 @@ class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||
initialValue = UserSettings() // Default value
|
||||
)
|
||||
|
||||
suspend fun updateSettings(newSettings: UserSettings) {
|
||||
override suspend fun updateSettings(newSettings: UserSettings) {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings)
|
||||
}
|
||||
|
||||
@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.modules.settings.sections
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.spotube.spotube.core.jam.JamNames
|
||||
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxEdit
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.jam_name_description
|
||||
import spotube.composeapp.generated.resources.jam_name_subtitle
|
||||
import spotube.composeapp.generated.resources.jam_name_title
|
||||
import spotube.composeapp.generated.resources.jam_name_too_long
|
||||
|
||||
/**
|
||||
* "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay
|
||||
* identical, like the remote control device name card.
|
||||
*
|
||||
* [customName] is what the user saved (blank = none), [defaultName] the generated one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun JamNameSettingCard(
|
||||
customName: String,
|
||||
defaultName: String,
|
||||
onNameSaved: (String) -> Unit,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH)
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.jam_name_title),
|
||||
subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }),
|
||||
icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) },
|
||||
value = customName,
|
||||
dialogDescription = stringResource(Res.string.jam_name_description, defaultName),
|
||||
placeholder = defaultName,
|
||||
normalize = JamNames::normalize,
|
||||
validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null },
|
||||
onValueSaved = onNameSaved,
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
@ -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) })
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JamNamesTest {
|
||||
@Test
|
||||
fun sameSeedSameName() = runTest {
|
||||
assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun namesAreTwoFriendlyWordsAndVaried() = runTest {
|
||||
val names = List(60) { JamNames.generate("device-$it") }
|
||||
assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString())
|
||||
assertTrue(names.all { it.length <= JamNames.MAX_LENGTH })
|
||||
assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedNamesAreCleaned() {
|
||||
assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n"))
|
||||
assertEquals("", JamNames.normalize(" "))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,93 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JamSecurityTest {
|
||||
private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") =
|
||||
JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B",
|
||||
durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE")
|
||||
|
||||
@Test
|
||||
fun peerItemsWithoutATrackIdAreDropped() {
|
||||
assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized())
|
||||
assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peerItemsLoseUrlsAndNonHttpsCovers() {
|
||||
val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!!
|
||||
assertEquals("", clean.url)
|
||||
assertEquals("", clean.coverUrl)
|
||||
assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun peerTextIsBounded() {
|
||||
val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!!
|
||||
assertTrue(clean.title.length <= 300)
|
||||
assertEquals(0, clean.durationMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun localTrackKeysDontRevealPaths() {
|
||||
val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")
|
||||
assertFalse("alice" in key || "Music" in key)
|
||||
assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3"))
|
||||
assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientKeysAreLongRandomAndAcceptedByTheServer() {
|
||||
val keys = List(100) { JamRoomCode.newClientKey() }
|
||||
assertEquals(keys.size, keys.toSet().size)
|
||||
// Mirrors CLIENT_KEY in the jam-server repository's src/protocol.ts.
|
||||
val serverPattern = Regex("^[A-Za-z0-9_-]{22,64}$")
|
||||
assertTrue(keys.all { serverPattern.matches(it) })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun participantKeysAreStablePerRoomAndUnlinkableAcrossRooms() {
|
||||
val device = JamRoomCode.newClientKey()
|
||||
assertEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEF"))
|
||||
assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(device, "ABCDEG"))
|
||||
assertNotEquals(jamParticipantKey(device, "ABCDEF"), jamParticipantKey(JamRoomCode.newClientKey(), "ABCDEF"))
|
||||
assertTrue(Regex("^[A-Za-z0-9_-]{22,64}$").matches(jamParticipantKey(device, "ABCDEF")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deviceDigestsRevealNothingAndDifferPerRoom() {
|
||||
val digest = jamDeviceDigest("ABCDEF")!!
|
||||
assertEquals(digest, jamDeviceDigest("ABCDEF"))
|
||||
assertNotEquals(digest, jamDeviceDigest("ABCDEG"))
|
||||
assertTrue(jamDeviceTraits().none { trait -> trait.length > 3 && trait in digest })
|
||||
// Mirrors DEVICE_DIGEST in the jam-server repository's src/protocol.ts.
|
||||
assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun roomCodesAreValid() {
|
||||
repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) }
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class JamSyncTest {
|
||||
|
||||
// --- JamClock ---
|
||||
|
||||
@Test
|
||||
fun clockIsUnknownBeforeTheFirstSample() {
|
||||
val clock = JamClock()
|
||||
assertNull(clock.offsetMs)
|
||||
assertEquals(1_000L, clock.serverTime(1_000L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clockAssumesSymmetricLatency() {
|
||||
val clock = JamClock()
|
||||
// Server is 5 s ahead; 40 ms each way.
|
||||
clock.addSample(clientSentMs = 1_000, serverMs = 6_040, clientReceivedMs = 1_080)
|
||||
assertEquals(5_000L, clock.offsetMs)
|
||||
assertEquals(80L, clock.rttMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clockTrustsTheFastestRoundTrip() {
|
||||
val clock = JamClock()
|
||||
clock.addSample(clientSentMs = 0, serverMs = 5_030, clientReceivedMs = 60) // offset 5000
|
||||
// A slow sample with a lopsided path skews the estimate; it must not win.
|
||||
clock.addSample(clientSentMs = 1_000, serverMs = 6_400, clientReceivedMs = 1_500) // offset 5150
|
||||
assertEquals(5_000L, clock.offsetMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clockForgetsSamplesOutsideTheWindow() {
|
||||
val clock = JamClock(window = 2)
|
||||
clock.addSample(0, 5_010, 20) // fast, offset 5000
|
||||
clock.addSample(100, 5_250, 300) // offset 5050
|
||||
clock.addSample(400, 5_660, 600) // offset 5160 — the fast one is now gone
|
||||
assertEquals(5_050L, clock.offsetMs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clockIgnoresNegativeRoundTrips() {
|
||||
val clock = JamClock()
|
||||
clock.addSample(clientSentMs = 100, serverMs = 0, clientReceivedMs = 50)
|
||||
assertNull(clock.offsetMs)
|
||||
}
|
||||
|
||||
// --- JamAnchor ---
|
||||
|
||||
@Test
|
||||
fun anchorExtrapolatesWhileAdvancing() {
|
||||
val anchor = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = true, rate = 1.5f)
|
||||
assertEquals(13_000L, anchor.positionAt(52_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anchorHoldsStillWhenNotAdvancing() {
|
||||
val paused = JamAnchor("t", 0, positionMs = 10_000, serverTimeMs = 50_000, playing = false)
|
||||
assertEquals(10_000L, paused.positionAt(60_000))
|
||||
val buffering = JamAnchor("t", 0, 10_000, 50_000, playing = true, advancing = false)
|
||||
assertEquals(10_000L, buffering.positionAt(60_000))
|
||||
}
|
||||
|
||||
// --- JamDriftCorrector ---
|
||||
|
||||
@Test
|
||||
fun smallDriftIsLeftAlone() {
|
||||
val corrector = JamDriftCorrector()
|
||||
assertEquals(JamCorrection.None, corrector.correct(10_080, 10_000, 1f, seekLeadMs = 300))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun behindSpeedsUpAndAheadSlowsDown() {
|
||||
val behind = JamDriftCorrector().correct(10_600, 10_000, 1f, seekLeadMs = 300)
|
||||
assertIs<JamCorrection.Nudge>(behind)
|
||||
assertTrue(behind.rate > 1f && behind.rate <= 1.05f)
|
||||
|
||||
val ahead = JamDriftCorrector().correct(10_000, 10_600, 1f, seekLeadMs = 300)
|
||||
assertIs<JamCorrection.Nudge>(ahead)
|
||||
assertTrue(ahead.rate < 1f && ahead.rate >= 0.95f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nudgeFollowsTheRoomRate() {
|
||||
val nudge = JamDriftCorrector().correct(10_300, 10_000, 1.25f, seekLeadMs = 0)
|
||||
assertIs<JamCorrection.Nudge>(nudge)
|
||||
assertEquals(1.25f * 1.05f, nudge.rate, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeDriftSeeksAheadByTheLead() {
|
||||
val seek = JamDriftCorrector().correct(20_000, 10_000, 1f, seekLeadMs = 400)
|
||||
assertEquals(JamCorrection.Seek(20_400), seek)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aSingleNoisyReadingDoesNotSeek() {
|
||||
val corrector = JamDriftCorrector(seekThresholdMs = 1_500)
|
||||
repeat(4) { corrector.correct(10_000, 10_000, 1f, 0) }
|
||||
// One coarse report 2 s off (under the immediate-seek bar) is outvoted by the median.
|
||||
assertEquals(JamCorrection.None, corrector.correct(12_000, 10_000, 1f, 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistentDriftSeeksOnceTheMedianAgrees() {
|
||||
val corrector = JamDriftCorrector(seekThresholdMs = 1_500)
|
||||
val results = List(3) { corrector.correct(12_000, 10_000, 1f, 0) }
|
||||
assertIs<JamCorrection.Nudge>(results[0])
|
||||
assertIs<JamCorrection.Nudge>(results[1])
|
||||
assertEquals(JamCorrection.Seek(12_000), results[2])
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import platform.Foundation.NSLocale
|
||||
import platform.Foundation.NSTimeZone
|
||||
import platform.Foundation.currentLocale
|
||||
import platform.Foundation.localTimeZone
|
||||
import platform.Foundation.localeIdentifier
|
||||
import platform.UIKit.UIDevice
|
||||
|
||||
internal actual fun jamDeviceTraits(): List<String> {
|
||||
val device = UIDevice.currentDevice
|
||||
return listOf(
|
||||
"ios",
|
||||
device.model,
|
||||
device.systemName(),
|
||||
device.systemVersion,
|
||||
NSLocale.currentLocale.localeIdentifier,
|
||||
NSTimeZone.localTimeZone.name)
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
internal actual fun jamDeviceTraits(): List<String> = listOf(
|
||||
"desktop",
|
||||
System.getProperty("os.name").orEmpty(),
|
||||
System.getProperty("os.version").orEmpty(),
|
||||
System.getProperty("os.arch").orEmpty(),
|
||||
Runtime.getRuntime().availableProcessors().toString(),
|
||||
Locale.getDefault().toLanguageTag(),
|
||||
TimeZone.getDefault().id)
|
||||
@ -0,0 +1,88 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Runs the real client against a room server. Skipped unless `JAM_TEST_URL` is set:
|
||||
* run `npm run dev` in a checkout of https://github.com/team-spotube/jam-server, then `JAM_TEST_URL=http://127.0.0.1:8787 ./gradlew :composeApp:jvmTest`.
|
||||
*/
|
||||
class JamRoomClientIntegrationTest {
|
||||
private val serverUrl: String? = System.getenv("JAM_TEST_URL")
|
||||
|
||||
@Test
|
||||
fun hostAndGuestShareARoom() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val host = JamRoomClient(http)
|
||||
val guest = JamRoomClient(http)
|
||||
val code = JamRoomCode.generate()
|
||||
try {
|
||||
host.testConnection(url).getOrThrow()
|
||||
|
||||
val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow()
|
||||
val hostId = hostWelcome.you
|
||||
assertEquals(hostId, hostWelcome.host)
|
||||
// Same machine: the clock offset is just measurement noise.
|
||||
assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}")
|
||||
|
||||
val queue = JamQueue(listOf(JamMediaItem(url = "", trackId = "t1", title = "Song", artist = "A",
|
||||
album = "", durationMs = 1_000, coverUrl = "", protocol = "PROGRESSIVE")))
|
||||
host.sendQueue(queue)
|
||||
val anchor = JamAnchor("t1", 0, positionMs = 1_234, serverTimeMs = host.clock.serverTime(0), playing = true)
|
||||
host.sendAnchor(anchor)
|
||||
Thread.sleep(300)
|
||||
|
||||
// Late joiners get the retained queue and anchor in the welcome.
|
||||
val guestKey = JamRoomCode.newClientKey()
|
||||
val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow()
|
||||
val guestId = guestWelcome.you
|
||||
assertEquals(queue, guestWelcome.queue)
|
||||
assertEquals(anchor, guestWelcome.anchor)
|
||||
assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id })
|
||||
|
||||
val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance<JamRoomEvent.Command>().first() }
|
||||
guest.sendCommand(JamCommand.SeekTo(42_000))
|
||||
val command = withTimeout(5_000) { received.await() }
|
||||
assertEquals(guestId, command.from)
|
||||
assertEquals(JamCommand.SeekTo(42_000), command.command)
|
||||
|
||||
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance<JamRoomEvent.Closed>().first() }
|
||||
host.kick(guestId, ban = false)
|
||||
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
|
||||
|
||||
val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false)
|
||||
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
|
||||
} finally {
|
||||
host.disconnect()
|
||||
guest.disconnect()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,705 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||
import org.spotube.spotube.core.audioplayer.FakeAudioPlayerQueueRepository
|
||||
import org.spotube.spotube.core.audioplayer.FakeBlacklistRepository
|
||||
import org.spotube.spotube.core.audioplayer.FakePluginProvider
|
||||
import org.spotube.spotube.core.audioplayer.FakeSettingsProvider
|
||||
import org.spotube.spotube.core.audioplayer.LoopState
|
||||
import org.spotube.spotube.core.audioplayer.MediaItem
|
||||
import org.spotube.spotube.core.audioplayer.PlayerState
|
||||
import org.spotube.spotube.core.audioplayer.QueueCollectionEntry
|
||||
import org.spotube.spotube.core.audioplayer.QueueEntry
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.spotube.spotube.core.server.FakeAudioPluginService
|
||||
import org.spotube.spotube.core.server.FakeAudioPluginSource
|
||||
import org.spotube.spotube.core.server.FakeStreamingAudioPlayerQueue
|
||||
import org.spotube.spotube.core.server.FakeTrackSourceRepository
|
||||
import org.spotube.spotube.core.server.FakeUserSettingsSource
|
||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.modules.settings.UserSettings
|
||||
import org.spotube.spotube.modules.settings.UserSettingsStore
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.math.abs
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* A host and a guest [JamRoomService], each driving a simulated player, in one room on a
|
||||
* real room server. The guest's player runs 1% fast, the host's 0.1% slow (so it has to
|
||||
* re-anchor), and both report positions coarsely, like real players. Skipped unless `JAM_TEST_URL` is set (see [JamRoomClientIntegrationTest]).
|
||||
*/
|
||||
class JamSessionSimulationTest {
|
||||
private val serverUrl: String? = System.getenv("JAM_TEST_URL")
|
||||
|
||||
@BeforeTest
|
||||
fun setup() {
|
||||
startKoin { modules(module { factory { (tag: String?) -> Logger.withTag(tag ?: "test") } }) }
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun teardown() {
|
||||
stopKoin()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun guestFollowsTheHost() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val tracks = List(3) { track("track-$it") }
|
||||
val hostSource = source("host-upload")
|
||||
val host = Participant(http, url, speedError = 0.999)
|
||||
val guest = Participant(http, url, speedError = 1.01)
|
||||
host.sources.seed(tracks[0], hostSource, PLUGIN)
|
||||
guest.sources.seed(tracks[0], source("other-upload"), PLUGIN)
|
||||
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(2_000)
|
||||
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
assertEquals(JamRole.Host, host.service.role.value)
|
||||
assertEquals(JamRole.Guest, guest.service.role.value)
|
||||
|
||||
delay(7_000)
|
||||
assertInSync(host, guest, "after joining")
|
||||
assertEquals(hostSource, guest.sources.rawEntryFor(tracks[0].id)?.source, "guest uses the host's source")
|
||||
|
||||
host.player.seekTo(60_000.milliseconds)
|
||||
delay(5_000)
|
||||
assertInSync(host, guest, "after the host seeked")
|
||||
|
||||
// A guest's seek moves the whole room.
|
||||
guest.player.seekTo(90_000.milliseconds)
|
||||
// Two hops (guest -> host -> guest) and two seeks: allow longer to settle.
|
||||
delay(8_000)
|
||||
assertTrue(host.player.truePosition() in 96_000..100_000, "host followed the guest's seek: ${host.player.truePosition()}")
|
||||
assertInSync(host, guest, "after the guest seeked")
|
||||
|
||||
guest.player.pause()
|
||||
delay(1_500)
|
||||
assertEquals(PlayerState.PAUSED, host.player.playerStateFlow.value, "guest paused the room")
|
||||
|
||||
host.player.play()
|
||||
host.service.skipNext()
|
||||
delay(6_000)
|
||||
assertEquals(1, host.player.index.value)
|
||||
assertEquals(1, guest.player.index.value, "guest followed the skip")
|
||||
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value)
|
||||
assertInSync(host, guest, "after the skip")
|
||||
|
||||
// Mashing skip stays within the server's flood limits (the host isn't cut off).
|
||||
repeat(20) { if (it % 2 == 0) host.service.skipNext() else host.service.skipPrevious() }
|
||||
delay(6_000)
|
||||
assertEquals(true, host.service.isConnected.value, "host still connected after mashing skip")
|
||||
assertEquals(host.player.index.value, guest.player.index.value, "guest followed the skips")
|
||||
assertInSync(host, guest, "after mashing skip")
|
||||
|
||||
// Steady playback costs (almost) nothing: count the anchors a listener receives.
|
||||
val listener = JamRoomClient(http)
|
||||
try {
|
||||
listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow()
|
||||
delay(1_000) // The host answers the join with an anchor.
|
||||
var anchors = 0
|
||||
val counting = launch {
|
||||
listener.events.collect { if (it is JamRoomEvent.Anchor) anchors++ }
|
||||
}
|
||||
delay(STEADY_WINDOW_MS)
|
||||
counting.cancel()
|
||||
println("Jam simulation: $anchors anchor(s) in ${STEADY_WINDOW_MS / 1000}s of steady playback")
|
||||
assertTrue(anchors <= 2, "steady playback sent $anchors anchors")
|
||||
assertInSync(host, guest, "after steady playback")
|
||||
} finally {
|
||||
listener.disconnect()
|
||||
}
|
||||
} finally {
|
||||
guest.close()
|
||||
host.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Songs that end on their own. The guest has its own uploads of every song (a shorter
|
||||
* intro) and its player loads the next song halfway through the current one, so it
|
||||
* must have the host's recording before then, or it plays a different one.
|
||||
*/
|
||||
@Test
|
||||
fun usesTheHostsRecordingForEverySong() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
|
||||
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||
tracks.forEach {
|
||||
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
|
||||
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
|
||||
}
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(500)
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
delay(6_000)
|
||||
assertEquals("host-song-0", guest.player.currentSource, "guest plays the host's recording of song 0")
|
||||
assertInSync(host, guest, "during song 0")
|
||||
|
||||
for (song in 1..2) {
|
||||
while (host.player.index.value < song) delay(100)
|
||||
delay(5_000)
|
||||
assertEquals(song, guest.player.index.value, "guest on song $song")
|
||||
assertEquals("host-song-$song", guest.player.currentSource, "guest plays the host's recording of song $song")
|
||||
assertInSync(host, guest, "5 s into song $song after the previous one ended")
|
||||
}
|
||||
} finally {
|
||||
guest.close()
|
||||
host.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A guest that can't use the host's recordings (another audio plugin) finishes each
|
||||
* song early. It must wait for the host, not jump back to the song that just ended.
|
||||
*/
|
||||
@Test
|
||||
fun aGuestThatFinishesASongFirstWaitsForTheHost() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
|
||||
val host = Participant(http, url, speedError = 1.0)
|
||||
val guest = Participant(http, url, speedError = 1.0, pluginId = "other-plugin")
|
||||
tracks.forEach { guest.sources.seed(it, source("own-${it.id}"), "other-plugin") }
|
||||
val jumpedBack = CopyOnWriteArrayList<String>()
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(500)
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
delay(4_000)
|
||||
val watcher = launch {
|
||||
var last = guest.player.index.value
|
||||
guest.player.index.collect { now ->
|
||||
if (now < last) jumpedBack += "guest went back from song $last to $now"
|
||||
last = now
|
||||
}
|
||||
}
|
||||
for (song in 1..2) {
|
||||
while (host.player.index.value < song) delay(100)
|
||||
delay(5_000)
|
||||
assertEquals(song, guest.player.index.value, "guest on song $song")
|
||||
}
|
||||
watcher.cancel()
|
||||
assertTrue(jumpedBack.isEmpty(), jumpedBack.toString())
|
||||
} finally {
|
||||
guest.close()
|
||||
host.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A jam must not keep the CPU busy (a spinning loop allocates nonstop and freezes the
|
||||
* app in GC). Measures the process's CPU time while the room sits in each state.
|
||||
*/
|
||||
@Test
|
||||
fun idleStatesDoNotBurnCpu() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val tracks = List(3) { track("cpu-$it") }
|
||||
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||
val os = java.lang.management.ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean
|
||||
suspend fun cpuDuring(label: String, block: suspend () -> Unit) {
|
||||
val before = os.processCpuTime
|
||||
val start = System.nanoTime()
|
||||
block()
|
||||
val share = (os.processCpuTime - before).toDouble() / (System.nanoTime() - start)
|
||||
println("Jam simulation: CPU ${"%.0f".format(share * 100)}% of a core while $label")
|
||||
assertTrue(share < 0.5, "CPU ${"%.0f".format(share * 100)}% of a core while $label")
|
||||
}
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(500)
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
delay(5_000)
|
||||
cpuDuring("playing in sync") { delay(8_000) }
|
||||
host.player.pause()
|
||||
delay(2_000)
|
||||
cpuDuring("the room is paused") { delay(8_000) }
|
||||
host.player.play()
|
||||
delay(3_000)
|
||||
host.player.stall()
|
||||
delay(1_000)
|
||||
cpuDuring("the host is buffering") { delay(8_000) }
|
||||
host.player.unstall()
|
||||
delay(3_000)
|
||||
guest.player.stall()
|
||||
cpuDuring("the guest is buffering") { delay(8_000) }
|
||||
guest.player.unstall()
|
||||
} finally {
|
||||
guest.close()
|
||||
host.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Several minutes of jam (songs ending, recordings switching, seeks) must not grow the
|
||||
* heap. Opt-in, since it takes minutes: set `JAM_SOAK=1`.
|
||||
*/
|
||||
@Test
|
||||
fun longSessionDoesNotLeak() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
if (System.getenv("JAM_SOAK") == null) return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val tracks = List(8) { track("soak-$it", durationMs = SONG_MS) }
|
||||
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||
tracks.forEach {
|
||||
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
|
||||
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
|
||||
}
|
||||
val runtime = Runtime.getRuntime()
|
||||
fun usedMb(): Long {
|
||||
repeat(3) { System.gc(); Thread.sleep(200) }
|
||||
return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
|
||||
}
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(500)
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
delay(20_000)
|
||||
val baseline = usedMb()
|
||||
val samples = mutableListOf(baseline)
|
||||
repeat(6) { round ->
|
||||
if (round % 2 == 0) host.player.seekTo(10_000.milliseconds) else guest.player.seekTo(20_000.milliseconds)
|
||||
delay(30_000)
|
||||
samples += usedMb()
|
||||
}
|
||||
println("Jam simulation: heap after GC over ${samples.size} samples (MB): $samples")
|
||||
assertTrue(samples.last() - baseline < 30, "heap grew from $baseline MB to ${samples.last()} MB")
|
||||
assertInSync(host, guest, "after a long session")
|
||||
} finally {
|
||||
guest.close()
|
||||
host.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** Losing the network (guest, then host) must not crash, and the room picks up again. */
|
||||
@Test
|
||||
fun roomSurvivesNetworkLoss() = runBlocking {
|
||||
val url = serverUrl ?: return@runBlocking
|
||||
val http = HttpClientFactory()
|
||||
val hostNet = TcpProxy(url)
|
||||
val guestNet = TcpProxy(url)
|
||||
val tracks = List(3) { track("track-$it") }
|
||||
val host = Participant(http, hostNet.url, speedError = 1.0)
|
||||
val guest = Participant(http, guestNet.url, speedError = 1.01)
|
||||
val crashes = CopyOnWriteArrayList<Throwable>()
|
||||
val previousHandler = Thread.getDefaultUncaughtExceptionHandler()
|
||||
Thread.setDefaultUncaughtExceptionHandler { _, e -> crashes += e }
|
||||
|
||||
try {
|
||||
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||
delay(1_000)
|
||||
val code = host.service.createRoom().getOrThrow()
|
||||
guest.service.joinRoom(code).getOrThrow()
|
||||
delay(5_000)
|
||||
assertInSync(host, guest, "before the guest lost its network")
|
||||
|
||||
guestNet.goOffline()
|
||||
delay(1_000)
|
||||
assertEquals(false, guest.service.isConnected.value, "guest noticed the drop")
|
||||
assertEquals(code, guest.service.roomCode.value, "guest stays in the room while offline")
|
||||
// Meanwhile the room moves on without the guest.
|
||||
host.player.seekTo(40_000.milliseconds)
|
||||
delay(4_000)
|
||||
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing offline")
|
||||
|
||||
guestNet.goOnline()
|
||||
delay(10_000) // Up to one backoff step, then resync.
|
||||
assertEquals(true, guest.service.isConnected.value, "guest reconnected")
|
||||
assertEquals(JamRole.Guest, guest.service.role.value)
|
||||
assertInSync(host, guest, "after the guest reconnected")
|
||||
|
||||
// A host that drops for less than the grace period keeps the room.
|
||||
hostNet.goOffline()
|
||||
delay(5_000)
|
||||
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value, "guest keeps playing while the host is away")
|
||||
hostNet.goOnline()
|
||||
delay(10_000)
|
||||
assertEquals(true, host.service.isConnected.value, "host reconnected")
|
||||
assertEquals(JamRole.Host, host.service.role.value, "host kept the room")
|
||||
host.player.seekTo(100_000.milliseconds)
|
||||
delay(5_000)
|
||||
assertInSync(host, guest, "after the host reconnected and seeked")
|
||||
|
||||
assertTrue(crashes.isEmpty(), "uncaught exceptions: $crashes")
|
||||
} finally {
|
||||
Thread.setDefaultUncaughtExceptionHandler(previousHandler)
|
||||
guest.close()
|
||||
host.close()
|
||||
hostNet.close()
|
||||
guestNet.close()
|
||||
http.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun assertInSync(host: Participant, guest: Participant, phase: String) {
|
||||
assertEquals(host.player.index.value, guest.player.index.value, "same track $phase")
|
||||
val gap = guest.player.songPosition() - host.player.songPosition()
|
||||
println("Jam simulation: guest is ${gap}ms off $phase")
|
||||
assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase")
|
||||
}
|
||||
|
||||
private class Participant(
|
||||
http: HttpClientFactory,
|
||||
serverUrl: String,
|
||||
speedError: Double,
|
||||
pluginId: String = PLUGIN,
|
||||
/** The app's own queue (stateIn flows and all) instead of [SimQueue]. */
|
||||
realQueue: Boolean = false) {
|
||||
val sources = FakeTrackSourceRepository()
|
||||
val player = SimPlayer(speedError)
|
||||
val queue: AudioPlayerQueue = if (realQueue) {
|
||||
player.resolveSource = { i ->
|
||||
val id = player.playlistFlow.value.getOrNull(i)?.url?.substringAfterLast('/')
|
||||
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
|
||||
}
|
||||
DeviceAudioPlayerQueue(
|
||||
player, FakeSettingsProvider(), FakeAudioPlayerQueueRepository(),
|
||||
FakePluginProvider(), FakeBlacklistRepository())
|
||||
} else {
|
||||
SimQueue(player, sources)
|
||||
}
|
||||
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(pluginId))
|
||||
val service = JamRoomService(
|
||||
jamClient = JamRoomClient(http),
|
||||
audioPlayer = player,
|
||||
audioPlayerQueue = queue,
|
||||
settingsRepository = FakeSettingsStore(UserSettings(jamServerUrl = serverUrl)),
|
||||
audioPluginSource = plugins,
|
||||
trackSources = sources,
|
||||
streamingUrlRepository = StreamingUrlRepository(
|
||||
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()),
|
||||
evictCachedAudio = {})
|
||||
|
||||
suspend fun close() {
|
||||
service.leaveRoom()
|
||||
player.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeSettingsStore(initial: UserSettings) : UserSettingsStore {
|
||||
override val userSettings = MutableStateFlow(initial)
|
||||
override suspend fun updateSettings(newSettings: UserSettings) {
|
||||
userSettings.value = newSettings
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as
|
||||
* a drifting device would), positions are reported every 250 ms rounded to 100 ms, and
|
||||
* every seek or track change buffers briefly.
|
||||
*
|
||||
* Like ExoPlayer, it picks a song's recording (source) when it loads the song: the next
|
||||
* song is loaded halfway through the current one, and jumping to the song already
|
||||
* playing keeps its recording. Recordings differ in intro length ([introMs]).
|
||||
*/
|
||||
private class SimPlayer(private val speedError: Double) : AudioPlayerInterface {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private var durations: List<Long> = emptyList()
|
||||
/** The source the queue would resolve for a song now. */
|
||||
var resolveSource: (Int) -> String = { "default" }
|
||||
private val loadedSources = mutableMapOf<Int, String>()
|
||||
val currentSource: String? get() = loadedSources[index.value]
|
||||
private var basePosition = 0L
|
||||
private var baseAt = now()
|
||||
private var speed = 1f
|
||||
private var playWhenReady = false
|
||||
|
||||
val index = MutableStateFlow(-1)
|
||||
private val _state = MutableStateFlow(PlayerState.IDLE)
|
||||
private val _position = MutableStateFlow(Duration.ZERO)
|
||||
private val _duration = MutableStateFlow(Duration.ZERO)
|
||||
private val _speed = MutableStateFlow(1f)
|
||||
private val _playlist = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||
private val _currentItem = MutableStateFlow<MediaItem?>(null)
|
||||
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
delay(250)
|
||||
val position = truePosition()
|
||||
val duration = currentDuration() ?: continue
|
||||
val next = index.value + 1
|
||||
if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) {
|
||||
loadedSources[next] = resolveSource(next)
|
||||
}
|
||||
if (position >= duration) {
|
||||
_completion.tryEmit(Unit)
|
||||
if (index.value < durations.lastIndex) advanceGapless()
|
||||
continue
|
||||
}
|
||||
_position.value = (position / 100 * 100).milliseconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Where in the song (not the recording) playback is. */
|
||||
fun songPosition(): Long = truePosition() - introMs(currentSource)
|
||||
|
||||
private fun currentDuration(): Long? =
|
||||
durations.getOrNull(index.value)?.let { it + introMs(currentSource) }
|
||||
|
||||
@Synchronized
|
||||
fun truePosition(): Long =
|
||||
if (_state.value == PlayerState.PLAYING) {
|
||||
basePosition + ((now() - baseAt) * speed * speedError).toLong()
|
||||
} else {
|
||||
basePosition
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun rebase(position: Long = truePosition()) {
|
||||
basePosition = position
|
||||
baseAt = now()
|
||||
}
|
||||
|
||||
fun loadTracks(trackDurations: List<Long>, start: Int, autoPlay: Boolean) {
|
||||
durations = trackDurations
|
||||
playWhenReady = autoPlay
|
||||
loadedSources.clear()
|
||||
startTrack(start)
|
||||
}
|
||||
|
||||
/**
|
||||
* A song ending on its own, like ExoPlayer's gapless transition: no buffering, and
|
||||
* the position keeps the old song's value until the next report.
|
||||
*/
|
||||
private fun advanceGapless() {
|
||||
val next = index.value + 1
|
||||
loadedSources.getOrPut(next) { resolveSource(next) }
|
||||
index.value = next
|
||||
_currentItem.value = _playlist.value.getOrNull(next)
|
||||
_duration.value = currentDuration()!!.milliseconds
|
||||
rebase(0)
|
||||
}
|
||||
|
||||
fun startTrack(i: Int) {
|
||||
// Jumping to the song already playing seeks within it (ExoPlayer's
|
||||
// seekToDefaultPosition): the loaded recording stays.
|
||||
if (i != index.value || i !in loadedSources) loadedSources[i] = loadedSources[i] ?: resolveSource(i)
|
||||
index.value = i
|
||||
_currentItem.value = _playlist.value.getOrNull(i)
|
||||
_duration.value = currentDuration()!!.milliseconds
|
||||
rebase(0)
|
||||
_position.value = Duration.ZERO
|
||||
buffer()
|
||||
}
|
||||
|
||||
private fun buffer() {
|
||||
rebase()
|
||||
_state.value = PlayerState.BUFFERING
|
||||
scope.launch {
|
||||
delay(BUFFER_MS)
|
||||
rebase()
|
||||
_state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
|
||||
}
|
||||
}
|
||||
|
||||
/** Network stall: buffering until [unstall]. */
|
||||
fun stall() {
|
||||
rebase()
|
||||
_state.value = PlayerState.BUFFERING
|
||||
}
|
||||
|
||||
fun unstall() {
|
||||
rebase()
|
||||
_state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
|
||||
}
|
||||
|
||||
override suspend fun play() {
|
||||
playWhenReady = true
|
||||
rebase()
|
||||
if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PLAYING
|
||||
}
|
||||
|
||||
override suspend fun pause() {
|
||||
playWhenReady = false
|
||||
rebase()
|
||||
if (_state.value != PlayerState.BUFFERING) _state.value = PlayerState.PAUSED
|
||||
}
|
||||
|
||||
override suspend fun seekTo(position: Duration) {
|
||||
rebase(position.inWholeMilliseconds)
|
||||
_position.value = position
|
||||
buffer()
|
||||
}
|
||||
|
||||
override suspend fun setPlaybackSpeed(speed: Float) {
|
||||
rebase()
|
||||
this.speed = speed
|
||||
_speed.value = speed
|
||||
}
|
||||
|
||||
override suspend fun skipToNext() = startTrack((index.value + 1).coerceAtMost(durations.lastIndex))
|
||||
override suspend fun skipToPrevious() = startTrack((index.value - 1).coerceAtLeast(0))
|
||||
override suspend fun jumpTo(index: Int) = startTrack(index)
|
||||
|
||||
override val playerStateFlow: StateFlow<PlayerState> = _state
|
||||
override val positionFlow: StateFlow<Duration> = _position
|
||||
override val durationFlow: StateFlow<Duration> = _duration
|
||||
override val playbackSpeedFlow: StateFlow<Float> = _speed
|
||||
override val completionFlow: Flow<Unit> = _completion
|
||||
override val shuffleModeFlow: StateFlow<Boolean> = MutableStateFlow(false)
|
||||
override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentItem
|
||||
override val playlistFlow: StateFlow<List<MediaItem>> = _playlist
|
||||
override val bufferingPositionFlow: StateFlow<Duration> = MutableStateFlow(Duration.ZERO)
|
||||
override val loopStateFlow: StateFlow<LoopState> = MutableStateFlow(LoopState.NONE)
|
||||
override val volumeFlow: StateFlow<Float> = MutableStateFlow(1f)
|
||||
override val errorFlow: Flow<Throwable> = MutableSharedFlow()
|
||||
|
||||
override suspend fun stop() = pause()
|
||||
override suspend fun loop(state: LoopState) = Unit
|
||||
override suspend fun shuffle(enabled: Boolean) = Unit
|
||||
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
|
||||
_playlist.value = playlist
|
||||
loadTracks(playlist.map { it.duration.inWholeMilliseconds }, startPosition, autoPlay)
|
||||
}
|
||||
|
||||
override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||
_playlist.value += mediaItem
|
||||
durations = durations + mediaItem.duration.inWholeMilliseconds
|
||||
}
|
||||
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit
|
||||
override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit
|
||||
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit
|
||||
override suspend fun setVolume(volume: Float) = Unit
|
||||
override fun isDisposed(): Boolean = !scope.isActive
|
||||
override fun dispose() = scope.cancel()
|
||||
}
|
||||
|
||||
private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val entries = MutableStateFlow<List<QueueEntry>>(emptyList())
|
||||
|
||||
init {
|
||||
player.resolveSource = { i ->
|
||||
val id = (entries.value.getOrNull(i) as? QueueEntry.StreamingTrack)?.track?.id
|
||||
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
|
||||
}
|
||||
}
|
||||
|
||||
override val queueFlow: StateFlow<List<QueueEntry>> = entries
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> =
|
||||
combine(entries, player.index) { list, i -> list.getOrNull(i) }
|
||||
.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> = MutableStateFlow(null)
|
||||
override val collectionHistoryFlow: StateFlow<List<QueueCollectionEntry>> = MutableStateFlow(emptyList())
|
||||
|
||||
override suspend fun load(
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?) {
|
||||
this.entries.value = entries
|
||||
player.loadTracks(entries.map { (it as QueueEntry.StreamingTrack).track.durationMs }, startPosition, autoPlay)
|
||||
}
|
||||
|
||||
override suspend fun jumpTo(index: Int, autoPlay: Boolean) {
|
||||
if (autoPlay) player.play() else player.pause()
|
||||
player.startTrack(index)
|
||||
}
|
||||
|
||||
override suspend fun reloadCurrent() = jumpTo(player.index.value, autoPlay = true)
|
||||
override suspend fun addToQueue(entry: QueueEntry) = addAllToQueue(listOf(entry), null)
|
||||
override suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry?) {
|
||||
this.entries.value += entries
|
||||
}
|
||||
|
||||
override suspend fun addAllAfterCurrent(entries: List<QueueEntry>) = addAllToQueue(entries, null)
|
||||
override suspend fun removeFromQueue(entry: QueueEntry) = Unit
|
||||
override suspend fun removeFromQueueByMediaUrl(mediaUrl: String) = Unit
|
||||
override suspend fun move(fromIndex: Int, toIndex: Int) = Unit
|
||||
override suspend fun clear() = Unit
|
||||
override suspend fun getQueue(): List<QueueEntry> = entries.value
|
||||
override suspend fun getCurrentQueueEntry(): QueueEntry? = currentQueueEntryFlow.value
|
||||
override suspend fun getCurrentCollectionEntry(): QueueCollectionEntry? = null
|
||||
override suspend fun getCollectionHistory(): List<QueueCollectionEntry> = emptyList()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val PLUGIN = "test-audio-plugin"
|
||||
private const val BUFFER_MS = 200L
|
||||
private const val MAX_GAP_MS = 200L
|
||||
private const val STEADY_WINDOW_MS = 40_000L
|
||||
private const val SONG_MS = 30_000L
|
||||
|
||||
/** How much longer a recording's intro is than the original's ("own" uploads cut 2.5 s). */
|
||||
private fun introMs(source: String?): Long = if (source?.startsWith("own-") == true) -2_500 else 0
|
||||
|
||||
private fun now() = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
private fun track(id: String, durationMs: Long = 180_000) = MetadataTrack(
|
||||
id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null,
|
||||
artists = emptyList(), album = null, thumbnails = null, explicit = null,
|
||||
popularity = null, isrcCode = null, externalUri = null)
|
||||
|
||||
private fun source(id: String) = AudioSource.Basic(
|
||||
id = id, title = id, artist = null, album = null, thumbnails = emptyList(),
|
||||
externalUri = null, confidence = 1f)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.URI
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
/**
|
||||
* A TCP proxy in front of the room server that a test can cut, like a phone losing its
|
||||
* network: [goOffline] resets every open connection and refuses new ones until [goOnline].
|
||||
*/
|
||||
class TcpProxy(target: String) : AutoCloseable {
|
||||
private val targetHost: String
|
||||
private val targetPort: Int
|
||||
private val server = ServerSocket().apply {
|
||||
reuseAddress = true
|
||||
bind(InetSocketAddress("127.0.0.1", 0))
|
||||
}
|
||||
private val sockets = CopyOnWriteArrayList<Socket>()
|
||||
|
||||
@Volatile
|
||||
private var online = true
|
||||
|
||||
init {
|
||||
val uri = URI(target)
|
||||
targetHost = uri.host
|
||||
targetPort = if (uri.port > 0) uri.port else 80
|
||||
thread(isDaemon = true, name = "tcp-proxy") {
|
||||
while (!server.isClosed) {
|
||||
val client = runCatching { server.accept() }.getOrNull() ?: break
|
||||
if (!online) {
|
||||
reset(client)
|
||||
continue
|
||||
}
|
||||
val upstream = runCatching { Socket(targetHost, targetPort) }.getOrElse {
|
||||
reset(client)
|
||||
continue
|
||||
}
|
||||
sockets += client
|
||||
sockets += upstream
|
||||
pipe(client, upstream)
|
||||
pipe(upstream, client)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val url: String get() = "http://127.0.0.1:${server.localPort}"
|
||||
|
||||
fun goOffline() {
|
||||
online = false
|
||||
sockets.forEach(::reset)
|
||||
sockets.clear()
|
||||
}
|
||||
|
||||
fun goOnline() {
|
||||
online = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
goOffline()
|
||||
server.close()
|
||||
}
|
||||
|
||||
private fun pipe(from: Socket, to: Socket) = thread(isDaemon = true, name = "tcp-proxy-pipe") {
|
||||
runCatching { from.getInputStream().copyTo(to.getOutputStream()) }
|
||||
reset(from)
|
||||
reset(to)
|
||||
}
|
||||
|
||||
/** Closes with a TCP reset, as an abrupt network loss looks to the other side. */
|
||||
private fun reset(socket: Socket) {
|
||||
runCatching {
|
||||
socket.setSoLinger(true, 0)
|
||||
socket.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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" }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user