mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
7 Commits
d9f9c07807
...
57750d247e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57750d247e | ||
|
|
880235e43d | ||
|
|
08f8f65a2a | ||
|
|
07ad7124a9 | ||
|
|
05d92a73fd | ||
|
|
0d5b5b094e | ||
|
|
277140f567 |
10
AGENTS.md
10
AGENTS.md
@ -26,15 +26,6 @@
|
||||
- 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.
|
||||
@ -54,7 +45,6 @@
|
||||
|
||||
## UI component patterns
|
||||
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
|
||||
- Outside composables (ViewModels, services, snackbars), import `getString` / `getPluralString` / `getStringArray` from `org.spotube.spotube.core.i18n`, never from `org.jetbrains.compose.resources`. The library versions ask Skiko for the system theme on every call, which on Linux is a blocking D-Bus call that segfaulted the desktop app from background threads (#3183). `NonComposableStringLookupTest` enforces this.
|
||||
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
||||
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||
|
||||
@ -289,6 +289,12 @@ 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)
|
||||
@ -437,6 +443,14 @@ 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)
|
||||
@ -449,7 +463,7 @@ nucleus.application {
|
||||
"--enable-native-access=ALL-UNNAMED", // need for JNA for compose-webview (wry) to work
|
||||
// --- ADD THESE FOR SPEED & LOW RAM ---
|
||||
"-Xms64m", // Start with a tiny heap (prevents grabbing 500MB upfront)
|
||||
"-Xmx1g", // Ceiling, not a reservation: the heap only grows to what is live (#3200, #3193)
|
||||
"-Xmx384m", // Cap the max heap (VLC and WebView need a bit of room)
|
||||
"-XX:TieredStopAtLevel=1", // Disables the heavy C2 compiler. Huge startup speedup!
|
||||
"-XX:+UseSerialGC" // The Serial GC is the most efficient for heaps under 512MB
|
||||
)
|
||||
@ -556,7 +570,7 @@ nucleus.application {
|
||||
// ProGuard is not used for native images; -Os is the size equivalent
|
||||
optimization = NativeImageOptimization.SIZE
|
||||
// Same ceiling as the JVM distribution's -Xmx
|
||||
maxHeapSize = "1g"
|
||||
maxHeapSize = "384m"
|
||||
// Skiko is loaded from lib/ next to the binary (Nucleus copies it there); don't embed
|
||||
// a second ~30 MB copy as a resource
|
||||
buildArgs.add("-H:+UnlockExperimentalVMOptions")
|
||||
|
||||
@ -1,37 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
@ -41,7 +41,7 @@ import org.spotube.spotube.modules.saved_tracks.SavedTracksRepository
|
||||
import org.spotube.spotube.modules.search.SearchRepository
|
||||
import org.spotube.spotube.modules.settings.SettingsRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.library_tab_albums
|
||||
import spotube.composeapp.generated.resources.library_tab_artists
|
||||
|
||||
@ -55,7 +55,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.notification_playback_channel_description
|
||||
import spotube.composeapp.generated.resources.notification_playback_channel_name
|
||||
@ -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, jam client and every singleton still alive.
|
||||
// process cached with the proxy server, MQTT client and every singleton still alive.
|
||||
if (taskRemoved) Process.killProcess(Process.myPid())
|
||||
}
|
||||
|
||||
|
||||
@ -308,11 +308,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -325,6 +339,9 @@
|
||||
<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,28 +361,6 @@
|
||||
<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>
|
||||
@ -538,9 +533,14 @@
|
||||
<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,11 +292,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -309,6 +323,9 @@
|
||||
<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>
|
||||
@ -328,28 +345,6 @@
|
||||
<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>
|
||||
@ -522,9 +517,14 @@
|
||||
<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,11 +292,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -309,6 +323,9 @@
|
||||
<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>
|
||||
@ -328,28 +345,6 @@
|
||||
<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>
|
||||
@ -522,9 +517,14 @@
|
||||
<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,11 +296,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -313,6 +327,9 @@
|
||||
<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>
|
||||
@ -332,28 +349,6 @@
|
||||
<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>
|
||||
@ -526,9 +521,14 @@
|
||||
<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,11 +292,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -309,6 +323,9 @@
|
||||
<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>
|
||||
@ -328,28 +345,6 @@
|
||||
<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>
|
||||
@ -522,9 +517,14 @@
|
||||
<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,11 +296,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -313,6 +327,9 @@
|
||||
<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>
|
||||
@ -332,28 +349,6 @@
|
||||
<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>
|
||||
@ -526,9 +521,14 @@
|
||||
<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,11 +291,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -308,6 +322,9 @@
|
||||
<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>
|
||||
@ -327,28 +344,6 @@
|
||||
<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>
|
||||
@ -521,9 +516,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +296,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -313,6 +327,9 @@
|
||||
<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>
|
||||
@ -332,28 +349,6 @@
|
||||
<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>
|
||||
@ -526,9 +521,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +292,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -309,6 +323,9 @@
|
||||
<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>
|
||||
@ -328,28 +345,6 @@
|
||||
<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>
|
||||
@ -522,9 +517,14 @@
|
||||
<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,11 +300,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -317,6 +331,9 @@
|
||||
<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>
|
||||
@ -336,28 +353,6 @@
|
||||
<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>
|
||||
@ -530,9 +525,14 @@
|
||||
<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,11 +296,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -313,6 +327,9 @@
|
||||
<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>
|
||||
@ -332,28 +349,6 @@
|
||||
<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>
|
||||
@ -526,9 +521,14 @@
|
||||
<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,11 +300,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -317,6 +331,9 @@
|
||||
<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>
|
||||
@ -336,28 +353,6 @@
|
||||
<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>
|
||||
@ -530,9 +525,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +292,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -309,6 +323,9 @@
|
||||
<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>
|
||||
@ -328,28 +345,6 @@
|
||||
<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>
|
||||
@ -522,9 +517,14 @@
|
||||
<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,11 +300,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -317,6 +331,9 @@
|
||||
<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>
|
||||
@ -336,28 +353,6 @@
|
||||
<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>
|
||||
@ -530,9 +525,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,11 +288,25 @@
|
||||
<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_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_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_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 -->
|
||||
@ -305,6 +319,9 @@
|
||||
<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>
|
||||
@ -324,28 +341,6 @@
|
||||
<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>
|
||||
@ -518,9 +513,14 @@
|
||||
<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,18 +310,26 @@
|
||||
<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_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_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_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>
|
||||
@ -333,9 +341,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">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_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_session_connected">Connected</string>
|
||||
<string name="jam_session_connecting">Reconnecting…</string>
|
||||
<string name="jam_invite_copy">Copy code</string>
|
||||
@ -344,7 +352,6 @@
|
||||
<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>
|
||||
@ -356,145 +363,6 @@
|
||||
<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>
|
||||
@ -681,17 +549,15 @@
|
||||
<string name="jam_destination_suggest">Suggest to Jam</string>
|
||||
|
||||
<!-- Group Jam: connection errors -->
|
||||
<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_no_broker">No jam broker configured</string>
|
||||
<string name="jam_error_broker_host_empty">Broker host is empty</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>
|
||||
|
||||
@ -21,7 +21,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Transient
|
||||
import kotlin.time.Duration
|
||||
|
||||
data class MediaItem(
|
||||
@ -85,9 +84,7 @@ sealed interface QueueEntry {
|
||||
val artists: List<String>,
|
||||
val duration: Long,
|
||||
val album: String?,
|
||||
// Not persisted: the queue is saved on every change and covers made it hundreds of MB
|
||||
// of JSON (#3193). Restore fills them back in from the local media index.
|
||||
@Transient val coverBytes: ByteArray? = null,
|
||||
val coverBytes: ByteArray?,
|
||||
override val url: String,
|
||||
override val addedBy: String = "") : QueueEntry {
|
||||
override val artworkUrl: String? get() = null
|
||||
|
||||
@ -44,12 +44,6 @@ 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
|
||||
|
||||
@ -23,10 +23,8 @@ import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.core.di.injectLogger
|
||||
import org.spotube.spotube.core.server.StreamingUrlRepository
|
||||
import org.spotube.spotube.modules.blacklist.BlacklistRepository
|
||||
import org.spotube.spotube.modules.library.local_tracks.media.LocalMediaCacheRepository
|
||||
import org.spotube.spotube.modules.plugin.PluginProvider
|
||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
@ -42,7 +40,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.get
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.random.Random
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@ -54,7 +52,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
private val streamingUrlRepository = get<StreamingUrlRepository>()
|
||||
private val streamingUrlRepository by inject<StreamingUrlRepository>()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -64,22 +62,19 @@ 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.Eagerly, emptyList())
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
|
||||
currentCollectionEntryState.asStateFlow()
|
||||
@ -137,15 +132,14 @@ class DeviceAudioPlayerQueue(
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?) {
|
||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||
val resolved = resolveMediaItems(entries)
|
||||
val mediaItems = resolved.map { it.first }
|
||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||
if (mediaItems.isEmpty()) {
|
||||
logger.w { "load: no valid media items after filtering blanks" }
|
||||
}
|
||||
val normalizedStartPosition =
|
||||
if (mediaItems.isEmpty()) 0 else startPosition.coerceIn(0, mediaItems.lastIndex)
|
||||
logger.d { "Normalized start position: $startPosition -> $normalizedStartPosition" }
|
||||
entryByMediaUrl.value = entriesByUrl(resolved)
|
||||
entryByMediaUrl.value = mediaItems.associate { it.url to entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
||||
updateCollectionContext(collectionEntry)
|
||||
audioPlayer.load(mediaItems, autoPlay, normalizedStartPosition)
|
||||
persistQueueStateIfReady()
|
||||
@ -317,14 +311,9 @@ class DeviceAudioPlayerQueue(
|
||||
}
|
||||
|
||||
private var isFetchingRecommendations = false
|
||||
private var endlessPlaybackSuppressed = false
|
||||
|
||||
override fun setEndlessPlaybackSuppressed(suppressed: Boolean) {
|
||||
endlessPlaybackSuppressed = suppressed
|
||||
}
|
||||
|
||||
private suspend fun handleQueueCompletion() {
|
||||
if (isFetchingRecommendations || endlessPlaybackSuppressed) return
|
||||
if (isFetchingRecommendations) return
|
||||
|
||||
val settings = settingsProvider.settingsState.value ?: return
|
||||
if (!settings.enableEndlessPlayback) return
|
||||
@ -409,66 +398,19 @@ class DeviceAudioPlayerQueue(
|
||||
return
|
||||
}
|
||||
|
||||
val restored = resolveMediaItems(withLocalCovers(state.entries))
|
||||
if (restored.isEmpty()) {
|
||||
val mediaItems = state.entries.mapNotNull { it.toMediaItem() }
|
||||
if (mediaItems.isEmpty()) {
|
||||
logger.w { "restorePersistedState: all entries had blank urls" }
|
||||
return
|
||||
}
|
||||
val mediaItems = restored.map { it.first }
|
||||
entryByMediaUrl.value = entriesByUrl(restored)
|
||||
entryByMediaUrl.value = mediaItems.associate { it.url to state.entries.firstOrNull { e -> e.toMediaItem()?.url == it.url }!! }
|
||||
val normalizedStartPosition = state.currentIndex.coerceIn(0, mediaItems.lastIndex)
|
||||
logger.d { "Loading restored playlist with normalized start position: ${state.currentIndex} -> $normalizedStartPosition" }
|
||||
audioPlayer.load(mediaItems, autoPlay = false, startPosition = normalizedStartPosition)
|
||||
logger.i { "Persisted state restored successfully" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts each entry once. Looking every item's entry up again by converting the whole list
|
||||
* per item was quadratic: ~900k suspending conversions to start a 1353-track playlist.
|
||||
*/
|
||||
private suspend fun resolveMediaItems(entries: List<QueueEntry>): List<Pair<MediaItem, QueueEntry>> =
|
||||
entries.mapNotNull { entry -> entry.toMediaItem()?.let { it to entry } }
|
||||
|
||||
/** First entry wins for a repeated url, as before. */
|
||||
private fun entriesByUrl(resolved: List<Pair<MediaItem, QueueEntry>>): Map<String, QueueEntry> = buildMap {
|
||||
resolved.forEach { (item, entry) -> if (item.url !in this) put(item.url, entry) }
|
||||
}
|
||||
|
||||
/** Local covers aren't persisted with the queue; look them up in the local media index. */
|
||||
private suspend fun withLocalCovers(entries: List<QueueEntry>): List<QueueEntry> {
|
||||
if (entries.none { it is QueueEntry.LocalTrack }) return entries
|
||||
val covers = runCatching {
|
||||
get<LocalMediaCacheRepository>().cacheFlow.first().folders
|
||||
.flatMap { it.tracks }
|
||||
.mapNotNull { track -> track.coverBytes?.let { track.path to it } }
|
||||
.toMap()
|
||||
}.getOrElse {
|
||||
logger.w(it) { "Couldn't read local media index for queue covers" }
|
||||
emptyMap()
|
||||
}
|
||||
if (covers.isEmpty()) return entries
|
||||
return entries.map { entry ->
|
||||
if (entry is QueueEntry.LocalTrack && entry.coverBytes == null) {
|
||||
covers[entry.url]?.let { entry.copy(coverBytes = it) } ?: entry
|
||||
} else entry
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs from fire-and-forget collectors on a scope without an exception handler, where a
|
||||
* failed write (disk full, I/O error) would otherwise crash the app. Losing one save is fine.
|
||||
*/
|
||||
private suspend fun persistQueueStateIfReady() {
|
||||
try {
|
||||
persistQueueState()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to persist queue state" }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun persistQueueState() {
|
||||
if (isRestoringPersistedState) {
|
||||
logger.v { "Skipping persist: still restoring persisted state" }
|
||||
return
|
||||
|
||||
@ -61,13 +61,13 @@ class Database(val paths: Paths) {
|
||||
|
||||
val audioPlayerQueueDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath()) }
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_audio_player_queue.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
val localMediaDataStore: DataStore<Preferences> by lazy {
|
||||
PreferenceDataStoreFactory.createWithPath(
|
||||
produceFile = { dropIfOversized("${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath()) }
|
||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_local_media.preferences_pb".toPath() }
|
||||
)
|
||||
}
|
||||
|
||||
@ -83,23 +83,7 @@ class Database(val paths: Paths) {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DataStore reads a whole preferences file into the heap. Earlier builds stored full-size
|
||||
* embedded covers as JSON number arrays in the queue and local media stores, growing them to
|
||||
* hundreds of MB and crashing with OutOfMemoryError on load (#3193). Both are rebuildable, so
|
||||
* drop one that is far past any sane size instead of loading it.
|
||||
*/
|
||||
private fun dropIfOversized(path: okio.Path): okio.Path {
|
||||
runCatching {
|
||||
val size = FileSystem.SYSTEM.metadataOrNull(path)?.size ?: return path
|
||||
if (size > MAX_CACHE_STORE_BYTES) FileSystem.SYSTEM.delete(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val MAX_CACHE_STORE_BYTES = 8L * 1024 * 1024
|
||||
|
||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
||||
"spotube.preferences_pb",
|
||||
|
||||
@ -86,7 +86,6 @@ 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
|
||||
@ -152,10 +151,7 @@ val sharedModules = module {
|
||||
viewModelOf(::MetadataPluginGuardViewModel)
|
||||
|
||||
// Settings
|
||||
singleOf(::SettingsRepository) {
|
||||
bind<UserSettingsSource>()
|
||||
bind<UserSettingsStore>()
|
||||
}
|
||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||
viewModelOf(::JamSettingsViewModel)
|
||||
viewModel {
|
||||
@ -287,11 +283,7 @@ val sharedModules = module {
|
||||
}
|
||||
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||
singleOf(::JamRoomClient)
|
||||
single {
|
||||
JamRoomService(
|
||||
get(), get(), get(), get(), get(), get(), get(),
|
||||
evictCachedAudio = get<CacheManager>()::invalidateCacheEntry)
|
||||
}
|
||||
single { JamRoomService(get(), get(), get(), get()) }
|
||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||
single<AudioPlayerQueue> {
|
||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||
|
||||
@ -54,7 +54,6 @@ fun AppLocaleProvider(language: SupportedLanguages, content: @Composable () -> U
|
||||
layoutDirection?.let { LocalLayoutDirection provides it })
|
||||
CompositionLocalProvider(*locales.toTypedArray()) {
|
||||
key(languageTag) {
|
||||
PublishResourceEnvironment()
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,71 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.i18n
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import org.jetbrains.compose.resources.PluralStringResource
|
||||
import org.jetbrains.compose.resources.ResourceEnvironment
|
||||
import org.jetbrains.compose.resources.StringArrayResource
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getSystemResourceEnvironment
|
||||
import org.jetbrains.compose.resources.rememberResourceEnvironment
|
||||
import kotlin.concurrent.Volatile
|
||||
import org.jetbrains.compose.resources.getPluralString as composeGetPluralString
|
||||
import org.jetbrains.compose.resources.getString as composeGetString
|
||||
import org.jetbrains.compose.resources.getStringArray as composeGetStringArray
|
||||
|
||||
/*
|
||||
* Non-composable string lookups (ViewModels, services, snackbars). Use these instead of
|
||||
* org.jetbrains.compose.resources.getString/getPluralString/getStringArray.
|
||||
*
|
||||
* The library versions call getSystemResourceEnvironment() on every lookup. On desktop that
|
||||
* also asks Skiko for the system theme, and on Linux Skiko answers with a blocking D-Bus call to
|
||||
* the desktop portal on whatever thread is asking. Run from a background coroutine, that call
|
||||
* segfaulted the GraalVM build on GNOME (#3183). Strings never depend on the theme, so these
|
||||
* reuse the environment the UI already computed (on the UI thread, for stringResource) and only
|
||||
* fall back to the library's lookup before anything has been composed.
|
||||
*/
|
||||
|
||||
@Volatile
|
||||
private var composedEnvironment: ResourceEnvironment? = null
|
||||
|
||||
/** Publishes the UI's resource environment for [getString] and friends. Called by [AppLocaleProvider]. */
|
||||
@Composable
|
||||
internal fun PublishResourceEnvironment() {
|
||||
val environment = rememberResourceEnvironment()
|
||||
SideEffect { composedEnvironment = environment }
|
||||
}
|
||||
|
||||
private fun resourceEnvironment(): ResourceEnvironment =
|
||||
composedEnvironment ?: getSystemResourceEnvironment()
|
||||
|
||||
suspend fun getString(resource: StringResource): String =
|
||||
composeGetString(resourceEnvironment(), resource)
|
||||
|
||||
suspend fun getString(resource: StringResource, vararg formatArgs: Any): String =
|
||||
composeGetString(resourceEnvironment(), resource, *formatArgs)
|
||||
|
||||
suspend fun getPluralString(resource: PluralStringResource, quantity: Int): String =
|
||||
composeGetPluralString(resourceEnvironment(), resource, quantity)
|
||||
|
||||
suspend fun getPluralString(resource: PluralStringResource, quantity: Int, vararg formatArgs: Any): String =
|
||||
composeGetPluralString(resourceEnvironment(), resource, quantity, *formatArgs)
|
||||
|
||||
suspend fun getStringArray(resource: StringArrayResource): List<String> =
|
||||
composeGetStringArray(resourceEnvironment(), resource)
|
||||
@ -1,118 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
@ -1,42 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
@ -1,52 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import okio.ByteString.Companion.encodeUtf8
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.spotube.spotube.core.i18n.getStringArray
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.jam_name_adjectives
|
||||
import spotube.composeapp.generated.resources.jam_name_animals
|
||||
import spotube.composeapp.generated.resources.jam_name_format
|
||||
|
||||
/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */
|
||||
object JamNames {
|
||||
/** The server's limit (`MAX_NAME_LENGTH` in the jam-server repository's `src/protocol.ts`). */
|
||||
const val MAX_LENGTH = 40
|
||||
|
||||
/**
|
||||
* The generated name for [seed]. The same seed always gets the same name, so with this
|
||||
* install's key a device keeps its name across sessions until the user picks their own.
|
||||
*/
|
||||
suspend fun generate(seed: String): String {
|
||||
val adjectives = getStringArray(Res.array.jam_name_adjectives)
|
||||
val animals = getStringArray(Res.array.jam_name_animals)
|
||||
val hash = "spotube-jam-name:$seed".encodeUtf8().sha256()
|
||||
val adjective = adjectives[pick(hash[0], hash[1], adjectives.size)]
|
||||
val animal = animals[pick(hash[2], hash[3], animals.size)]
|
||||
return getString(Res.string.jam_name_format, adjective, animal).take(MAX_LENGTH)
|
||||
}
|
||||
|
||||
/** Cleans a name the user typed; blank means "use the generated one". */
|
||||
fun normalize(input: String): String =
|
||||
input.filterNot { it.isISOControl() }.replace(Regex("\\s+"), " ").trim()
|
||||
|
||||
private fun pick(high: Byte, low: Byte, size: Int): Int =
|
||||
(((high.toInt() and 0xFF) shl 8) or (low.toInt() and 0xFF)) % size
|
||||
}
|
||||
@ -20,83 +20,92 @@ 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
|
||||
|
||||
/**
|
||||
* Room payloads. The server only routes and retains them; their shape is owned here.
|
||||
* Jam messages exchanged over MQTT.
|
||||
*
|
||||
* - [JamQueue] (retained, host -> everyone): the shared queue.
|
||||
* - [JamAnchor] (retained, host -> everyone): where playback is, in server time.
|
||||
* - [JamCommand] (guest -> host): playback control and suggestions.
|
||||
* - `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)
|
||||
*/
|
||||
@Serializable
|
||||
data class JamQueue(
|
||||
val items: List<JamMediaItem>,
|
||||
val shuffleEnabled: Boolean = false)
|
||||
sealed class JamMessage {
|
||||
@Serializable
|
||||
@SerialName("queueState")
|
||||
data class QueueState(
|
||||
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()
|
||||
|
||||
/**
|
||||
* 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
|
||||
data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource)
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
data class PlaybackCommand(val command: PlaybackCmd) : JamMessage()
|
||||
|
||||
@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
|
||||
@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()
|
||||
}
|
||||
|
||||
/** Playback control and suggestions. Volume, loop and the local queue view stay local. */
|
||||
/**
|
||||
* Playback commands. Queue navigation and play/pause are global — seek,
|
||||
* volume, shuffle and loop are local to each participant.
|
||||
*/
|
||||
@Serializable
|
||||
sealed class JamCommand {
|
||||
sealed class PlaybackCmd {
|
||||
@Serializable
|
||||
@SerialName("setPlaying")
|
||||
data class SetPlaying(val playing: Boolean) : JamCommand()
|
||||
data class SetPlaying(val playing: Boolean) : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("skipNext")
|
||||
data object SkipNext : JamCommand()
|
||||
data object SkipNext : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("skipPrevious")
|
||||
data object SkipPrevious : JamCommand()
|
||||
data object SkipPrevious : PlaybackCmd()
|
||||
|
||||
@Serializable
|
||||
@SerialName("jumpTo")
|
||||
data class JumpTo(val index: Int) : JamCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("seekTo")
|
||||
data class SeekTo(val positionMs: Long) : JamCommand()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggest")
|
||||
data class Suggest(
|
||||
val items: List<JamMediaItem>,
|
||||
val addedBy: String = "") : JamCommand()
|
||||
data class JumpTo(val index: Int) : PlaybackCmd()
|
||||
}
|
||||
|
||||
/** 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
|
||||
data class JamMediaItem(
|
||||
val url: String,
|
||||
@ -108,35 +117,7 @@ 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 = "",
|
||||
@ -151,9 +132,8 @@ 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 = localTrackKey(entry.url),
|
||||
url = entry.url,
|
||||
trackId = "",
|
||||
title = entry.name,
|
||||
artist = entry.artists.joinToString(", "),
|
||||
|
||||
@ -18,31 +18,22 @@
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
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 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 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.currentCoroutineContext
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
@ -50,81 +41,47 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonClassDiscriminator
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.spotube.spotube.core.network.HttpClientFactory
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.jam_error_banned
|
||||
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_connection_failed
|
||||
import spotube.composeapp.generated.resources.jam_error_connection_lost
|
||||
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
|
||||
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
|
||||
|
||||
/**
|
||||
* WebSocket client for one jam room on the room server (https://github.com/team-spotube/jam-server, a Cloudflare
|
||||
* Durable Object per room code).
|
||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||
*
|
||||
* 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).
|
||||
* 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 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.
|
||||
* The library keeps the connection alive (auto-reconnect + backoff); this class only
|
||||
* re-establishes the room subscription and re-publishes presence after a reconnect.
|
||||
*/
|
||||
class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
||||
class JamRoomClient : KoinComponent {
|
||||
private val log = Logger.withTag("JamRoomClient")
|
||||
// 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 scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
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 }
|
||||
}
|
||||
|
||||
val clock = JamClock()
|
||||
private var client: MqttClient? = 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 var roomCode: String? = null
|
||||
private var localPresence: JamPresence? = null
|
||||
|
||||
private val _isConnected = MutableStateFlow(false)
|
||||
val isConnected: StateFlow<Boolean> = _isConnected.asStateFlow()
|
||||
@ -132,429 +89,244 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
||||
private val _connectionError = MutableStateFlow<String?>(null)
|
||||
val connectionError: StateFlow<String?> = _connectionError.asStateFlow()
|
||||
|
||||
private val _events = MutableSharedFlow<JamRoomEvent>(extraBufferCapacity = 64)
|
||||
val events: SharedFlow<JamRoomEvent> = _events.asSharedFlow()
|
||||
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()
|
||||
|
||||
// ---------- Public API ----------
|
||||
|
||||
/** Checks that [serverUrl] runs a compatible room server. Returns a latency description. */
|
||||
suspend fun testConnection(serverUrl: String): Result<String> = runCatching {
|
||||
/** 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)))
|
||||
val started = kotlin.time.TimeSource.Monotonic.markNow()
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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(
|
||||
serverUrl: String,
|
||||
code: String,
|
||||
clientKey: String,
|
||||
displayName: String,
|
||||
create: Boolean,
|
||||
deviceDigest: String? = null): Result<JamRoomEvent.Welcome> {
|
||||
disconnect()
|
||||
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
|
||||
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 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) }
|
||||
}
|
||||
}
|
||||
return Result.success(welcome.first)
|
||||
}
|
||||
|
||||
fun sendQueue(queue: JamQueue) = send(ClientFrame.Queue(queue))
|
||||
/** Connects to [code] on [broker] and starts routing room messages. */
|
||||
suspend fun connect(
|
||||
broker: JamBroker,
|
||||
code: String,
|
||||
clientId: String,
|
||||
displayName: String,
|
||||
isHost: Boolean): Result<Unit> {
|
||||
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)
|
||||
|
||||
fun sendAnchor(anchor: JamAnchor) = send(ClientFrame.Anchor(anchor))
|
||||
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" }
|
||||
|
||||
fun sendCommand(command: JamCommand) = send(ClientFrame.Cmd(command))
|
||||
mqtt.connectionState
|
||||
.onEach { onConnectionStateChanged(it) }
|
||||
.launchIn(scope)
|
||||
Unit
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to connect to jam broker" }
|
||||
_connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed)
|
||||
_isConnected.value = false
|
||||
runCatching { client?.shutdown(sendDisconnect = true, drain = false) }
|
||||
client = null
|
||||
}
|
||||
}
|
||||
|
||||
fun kick(participantId: String, ban: Boolean) = send(ClientFrame.Kick(participantId, ban))
|
||||
suspend fun publishState(state: JamMessage.QueueState) {
|
||||
val code = roomCode ?: return
|
||||
publishJson(stateTopic(code), json.encodeToString(JamMessage.QueueState.serializer(), state), retain = true)
|
||||
}
|
||||
|
||||
suspend fun publishCommand(message: JamMessage) {
|
||||
val code = roomCode ?: return
|
||||
publishJson(cmdTopic(code), json.encodeToString(JamMessage.serializer(), message), retain = false)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
|
||||
/** 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)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
connectionJob?.cancel()
|
||||
connectionJob = null
|
||||
hostId = null
|
||||
selfId = null
|
||||
reader?.cancel()
|
||||
reader = null
|
||||
val current = session
|
||||
session = null
|
||||
roomCode = null
|
||||
localPresence = null
|
||||
_isConnected.value = false
|
||||
_connectionError.value = null
|
||||
runCatching { current?.close() }
|
||||
_presence.value = emptyMap()
|
||||
val current = client
|
||||
client = null
|
||||
runCatching { current?.shutdown(sendDisconnect = true, drain = false) }
|
||||
}
|
||||
|
||||
// ---------- Internals ----------
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
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 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)
|
||||
}
|
||||
|
||||
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
|
||||
private fun onConnectionStateChanged(state: ConnectionState) {
|
||||
when (state) {
|
||||
is ConnectionState.Connected -> {
|
||||
_isConnected.value = true
|
||||
_connectionError.value = null
|
||||
scope.launch {
|
||||
// Every connection (initial + reconnects) must re-establish the
|
||||
// broker-side subscription (clean session) and re-publish our
|
||||
// retained presence to clear any Last Will. Re-subscribing with
|
||||
// the same filter replaces the previous dispatcher handler.
|
||||
subscribeRoom()
|
||||
publishPresence()
|
||||
}
|
||||
runCatching { socket.send(Frame.Text(KEEPALIVE_PING)) }
|
||||
if (now() - lastClockSyncMs >= CLOCK_RESYNC_INTERVAL_MS) {
|
||||
lastClockSyncMs = now()
|
||||
runCatching { syncClock(socket, RESYNC_PINGS) }
|
||||
}
|
||||
|
||||
ConnectionState.Disconnected, ConnectionState.Handshaking -> {
|
||||
_isConnected.value = false
|
||||
}
|
||||
|
||||
else -> {
|
||||
_isConnected.value = false
|
||||
scope.launch {
|
||||
val message = getString(Res.string.jam_error_connection_lost)
|
||||
if (!_isConnected.value) _connectionError.value = message
|
||||
}
|
||||
}
|
||||
}
|
||||
return try {
|
||||
block()
|
||||
} finally {
|
||||
keeper.cancel()
|
||||
}
|
||||
|
||||
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 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
|
||||
}
|
||||
|
||||
runCatching {
|
||||
when {
|
||||
topic.endsWith("/state") -> {
|
||||
_state.tryEmit(json.decodeFromString(JamMessage.QueueState.serializer(), text))
|
||||
}
|
||||
|
||||
topic.endsWith("/cmd") -> {
|
||||
_commands.tryEmit(json.decodeFromString(JamMessage.serializer(), text))
|
||||
}
|
||||
|
||||
topic.contains("/presence/") -> {
|
||||
val presence = json.decodeFromString(JamPresence.serializer(), text)
|
||||
_presence.value = _presence.value + (presence.clientId to presence)
|
||||
}
|
||||
}
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to decode jam message on $topic: $text" }
|
||||
}
|
||||
}
|
||||
|
||||
/** Routes one frame; returns an error code when the server refused us. */
|
||||
private suspend fun handle(frame: Frame): String? {
|
||||
if (frame is Frame.Text && frame.readText() == KEEPALIVE_PONG) return null
|
||||
when (val decoded = decode(frame)) {
|
||||
is ServerFrame.Pong -> {
|
||||
clock.addSample(decoded.c, decoded.s, now())
|
||||
pongs.value++
|
||||
}
|
||||
is ServerFrame.Welcome -> {
|
||||
hostId = decoded.host
|
||||
_events.emit(decoded.toEvent())
|
||||
}
|
||||
is ServerFrame.Presence -> {
|
||||
hostId = decoded.host
|
||||
_events.emit(JamRoomEvent.Presence(decoded.host, decoded.participants.toParticipants(decoded.host)))
|
||||
}
|
||||
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
|
||||
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
|
||||
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
|
||||
// Sent too much too fast (a burst of skips, say): not a refusal, just reconnect.
|
||||
is ServerFrame.Error -> return decoded.code.takeIf { it != "rate_limited" }
|
||||
null -> Unit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun fail(code: String) {
|
||||
val message = refusalMessage(code)
|
||||
log.i { "Jam room closed by the server: $code" }
|
||||
session = null
|
||||
_connectionError.value = message
|
||||
_events.emit(JamRoomEvent.Closed(code, message))
|
||||
}
|
||||
|
||||
private suspend fun ping(socket: DefaultWebSocketSession) {
|
||||
socket.send(Frame.Text(json.encodeToString(ClientFrame.serializer(), ClientFrame.Ping(++pingId, now()))))
|
||||
}
|
||||
|
||||
private fun send(frame: ClientFrame) {
|
||||
val socket = session ?: return
|
||||
val text = json.encodeToString(ClientFrame.serializer(), frame)
|
||||
scope.launch {
|
||||
runCatching { socket.send(Frame.Text(text)) }
|
||||
.onFailure { log.w(it) { "Failed to send jam frame" } }
|
||||
private 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 decode(frame: Frame): ServerFrame? {
|
||||
if (frame !is Frame.Text) return null
|
||||
val text = frame.readText()
|
||||
return runCatching { json.decodeFromString(ServerFrame.serializer(), text) }
|
||||
.onFailure { log.w(it) { "Failed to decode jam frame: ${text.take(200)}" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun refusalMessage(code: String): String = when (code) {
|
||||
"room_not_found" -> getString(Res.string.jam_error_room_not_found)
|
||||
"room_full" -> getString(Res.string.jam_error_room_full)
|
||||
"banned" -> getString(Res.string.jam_error_banned)
|
||||
"kicked" -> getString(Res.string.jam_error_kicked)
|
||||
"replaced" -> getString(Res.string.jam_error_replaced)
|
||||
else -> getString(Res.string.jam_error_connection_failed)
|
||||
}
|
||||
|
||||
private fun roomUrl(opener: Opener, create: Boolean) =
|
||||
URLBuilder(opener.serverUrl.trim()).apply {
|
||||
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
|
||||
appendPathSegments("v1", "rooms", opener.code)
|
||||
parameters.append("key", opener.clientKey)
|
||||
parameters.append("name", opener.displayName)
|
||||
parameters.append("mode", if (create) "create" else "join")
|
||||
opener.deviceDigest?.let { parameters.append("dev", it) }
|
||||
}.buildString()
|
||||
|
||||
private fun healthUrl(serverUrl: String) =
|
||||
URLBuilder(serverUrl.trim()).apply {
|
||||
if (protocol == URLProtocol.WS) protocol = URLProtocol.HTTP
|
||||
if (protocol == URLProtocol.WSS) protocol = URLProtocol.HTTPS
|
||||
appendPathSegments("v1", "health")
|
||||
}.buildString()
|
||||
|
||||
private fun now(): Long = Clock.System.now().toEpochMilliseconds()
|
||||
|
||||
companion object {
|
||||
const val PROTOCOL_VERSION = 1
|
||||
private const val CONNECT_TIMEOUT_MS = 10_000L
|
||||
private const val INITIAL_PINGS = 6
|
||||
private const val RESYNC_PINGS = 3
|
||||
private const val PING_SPACING_MS = 100L
|
||||
private const val CLOCK_SYNC_TIMEOUT_MS = 3_000L
|
||||
/** Clocks drift by a few ms per minute at most; this keeps sync error well under 50 ms. */
|
||||
private const val CLOCK_RESYNC_INTERVAL_MS = 10 * 60_000L
|
||||
/**
|
||||
* As rare as possible (each one wakes a phone's radio) while staying under the
|
||||
* 100 s idle timeout of Cloudflare's proxy. A dropped idle socket would only cost
|
||||
* a reconnect, so there's no need for margin beyond that.
|
||||
*/
|
||||
private const val KEEPALIVE_INTERVAL_MS = 60_000L
|
||||
/** No pong to two keep-alives in a row: the connection is gone. */
|
||||
private const val DEAD_AFTER_MS = 2 * KEEPALIVE_INTERVAL_MS + 10_000L
|
||||
private const val KEEPALIVE_PING = "ping"
|
||||
private const val KEEPALIVE_PONG = "pong"
|
||||
/** Quick at first (most drops are a network switch), then slower to spare the battery. */
|
||||
private val RECONNECT_BACKOFF_MS = longArrayOf(500, 1_000, 2_000, 4_000, 8_000, 8_000, 15_000, 15_000, 30_000, 60_000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Something that happened in the room, for [JamRoomService]. */
|
||||
sealed interface JamRoomEvent {
|
||||
/** (Re)joined the room; carries the retained queue and anchor. */
|
||||
data class Welcome(
|
||||
val you: String,
|
||||
val host: String?,
|
||||
val participants: List<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()
|
||||
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/#"
|
||||
}
|
||||
|
||||
@ -17,12 +17,11 @@
|
||||
|
||||
package org.spotube.spotube.core.jam
|
||||
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlin.random.Random
|
||||
|
||||
/**
|
||||
* Six-character room codes shared verbally / by text. Codes are opaque keys used
|
||||
* to name a jam room on the room server — they carry no connection details.
|
||||
* to namespace the MQTT topics of a jam room — they carry no connection details.
|
||||
*
|
||||
* The alphabet excludes look-alike characters (I, O, 0, 1) so codes are easy to
|
||||
* read aloud and retype.
|
||||
@ -31,23 +30,12 @@ object JamRoomCode {
|
||||
const val LENGTH = 6
|
||||
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
|
||||
/** 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]) }
|
||||
fun generate(): String = buildString(LENGTH) {
|
||||
repeat(LENGTH) {
|
||||
append(ALPHABET[Random.nextInt(ALPHABET.length)])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
@ -40,8 +40,8 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getPluralString
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getPluralString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.jam_queue_added_track
|
||||
import spotube.composeapp.generated.resources.jam_queue_added_tracks
|
||||
@ -202,7 +202,7 @@ class RemotePlaybackController(
|
||||
|
||||
/**
|
||||
* Adds a single track to the active jam queue. The host applies it to the
|
||||
* local (shared) queue directly; a guest suggests it to the host through the room server.
|
||||
* local (shared) queue directly; a guest suggests it to the host over MQTT.
|
||||
*/
|
||||
fun addTrackToJam(track: MetadataTrack) {
|
||||
if (jamRoomService.role.value == null) return
|
||||
|
||||
@ -196,18 +196,22 @@ class CacheManager(
|
||||
val (start, end) = range
|
||||
val length = end - start + 1
|
||||
val contentRange = "bytes $start-$end/$fileSize"
|
||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||
override val status: HttpStatusCode = HttpStatusCode.PartialContent
|
||||
override val contentLength: Long = length
|
||||
override val contentType: ContentType = entry.toContentType()
|
||||
override val headers: Headers = HeadersBuilder().apply {
|
||||
append(HttpHeaders.ContentRange, contentRange)
|
||||
append(HttpHeaders.AcceptRanges, "bytes")
|
||||
}.build()
|
||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
streamFileRange(fileSystem, filePath, start, length, channel)
|
||||
}
|
||||
})
|
||||
fileSystem.source(filePath).buffer().use { source ->
|
||||
source.skip(start)
|
||||
val bytes = source.readByteArray(length)
|
||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||
override val status: HttpStatusCode = HttpStatusCode.PartialContent
|
||||
override val contentLength: Long = bytes.size.toLong()
|
||||
override val contentType: ContentType = entry.toContentType()
|
||||
override val headers: Headers = HeadersBuilder().apply {
|
||||
append(HttpHeaders.ContentRange, contentRange)
|
||||
append(HttpHeaders.AcceptRanges, "bytes")
|
||||
}.build()
|
||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
channel.writeFully(bytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
return
|
||||
}
|
||||
call.respond(object : OutgoingContent.NoContent() {
|
||||
@ -219,42 +223,19 @@ class CacheManager(
|
||||
return
|
||||
}
|
||||
|
||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||
override val status: HttpStatusCode = HttpStatusCode.OK
|
||||
override val contentLength: Long = fileSize
|
||||
override val contentType: ContentType = entry.toContentType()
|
||||
override val headers: Headers = HeadersBuilder().apply {
|
||||
append(HttpHeaders.AcceptRanges, "bytes")
|
||||
}.build()
|
||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
streamFileRange(fileSystem, filePath, 0, fileSize, channel)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private const val STREAM_CHUNK_BYTES = 64 * 1024
|
||||
|
||||
/**
|
||||
* Copies [length] bytes from [start] in fixed-size chunks. Cached tracks are tens of MB and the
|
||||
* player re-requests them on every seek, so they must never be read into the heap whole.
|
||||
*/
|
||||
internal suspend fun streamFileRange(
|
||||
fileSystem: FileSystem,
|
||||
filePath: Path,
|
||||
start: Long,
|
||||
length: Long,
|
||||
channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
fileSystem.source(filePath).buffer().use { source ->
|
||||
source.skip(start)
|
||||
val chunk = ByteArray(STREAM_CHUNK_BYTES)
|
||||
var remaining = length
|
||||
while (remaining > 0) {
|
||||
val read = source.read(chunk, 0, minOf(chunk.size.toLong(), remaining).toInt())
|
||||
if (read == -1) break
|
||||
channel.writeFully(chunk, 0, read)
|
||||
remaining -= read
|
||||
fileSystem.source(filePath).buffer().use { source ->
|
||||
val bytes = source.readByteArray()
|
||||
call.respond(object : OutgoingContent.WriteChannelContent() {
|
||||
override val status: HttpStatusCode = HttpStatusCode.OK
|
||||
override val contentLength: Long = bytes.size.toLong()
|
||||
override val contentType: ContentType = entry.toContentType()
|
||||
override val headers: Headers = HeadersBuilder().apply {
|
||||
append(HttpHeaders.AcceptRanges, "bytes")
|
||||
}.build()
|
||||
override suspend fun writeTo(channel: io.ktor.utils.io.ByteWriteChannel) {
|
||||
channel.writeFully(bytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -46,7 +46,6 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@ -81,7 +80,6 @@ import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
@ -98,7 +96,6 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.ui.misc.SkeletonTree
|
||||
import org.spotube.spotube.core.ui.misc.uniqueLazyKeys
|
||||
import org.spotube.spotube.core.ui.misc.TextWithShimmer
|
||||
import org.spotube.spotube.core.ui.misc.shimmerApply
|
||||
import org.spotube.spotube.getPlatform
|
||||
@ -122,42 +119,6 @@ private val CompactTrackListBreakpoint = 600.dp
|
||||
private val LargeTrackListBreakpoint = 840.dp
|
||||
private const val ShimmerRowCount = 6
|
||||
|
||||
private val TrackCardOuterPadding = 8.dp
|
||||
private val TrackCardInnerPadding = 12.dp
|
||||
private const val TrackCardTopKey = "track-card-top"
|
||||
private const val TrackCardRowKeyPrefix = "track-card-row:"
|
||||
private const val TrackCardFooterKey = "track-card-footer"
|
||||
private const val TrackCardBottomKey = "track-card-bottom"
|
||||
|
||||
private fun Any.isTrackCardKey() = this is String && startsWith("track-card-")
|
||||
|
||||
/**
|
||||
* Sizes the card drawn behind a [TrackList]'s rows to the rows the list has laid out. Edges that
|
||||
* are scrolled away are pushed a few viewports past the screen, far enough that the card's rounded
|
||||
* corners, shadow and edge gradient stay off-screen, while the height stays bounded for huge lists.
|
||||
*/
|
||||
private fun Modifier.trackCardBounds(listState: LazyListState, edgeInsetPx: Int): Modifier =
|
||||
layout { measurable, constraints ->
|
||||
val info = listState.layoutInfo
|
||||
val cardItems = info.visibleItemsInfo.filter { it.key.isTrackCardKey() }
|
||||
if (cardItems.isEmpty()) return@layout layout(constraints.maxWidth, constraints.maxHeight) {}
|
||||
|
||||
// Item offsets are relative to the viewport start, which is negative by the top padding.
|
||||
val toLocal = -info.viewportStartOffset
|
||||
val viewport = info.viewportEndOffset - info.viewportStartOffset
|
||||
val overscan = viewport * 3
|
||||
val first = cardItems.first()
|
||||
val last = cardItems.last()
|
||||
val top = if (first.key == TrackCardTopKey) first.offset + toLocal + edgeInsetPx else -overscan
|
||||
val bottom = if (last.key == TrackCardBottomKey) {
|
||||
last.offset + last.size + toLocal - edgeInsetPx
|
||||
} else viewport + overscan
|
||||
val height = (bottom - top).coerceAtLeast(0)
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minHeight = height, maxHeight = height))
|
||||
layout(constraints.maxWidth, constraints.maxHeight) { placeable.place(0, top) }
|
||||
}
|
||||
|
||||
enum class TrackSortOption(val label: StringResource) {
|
||||
None(Res.string.track_list_sort_original),
|
||||
Title(Res.string.track_list_sort_title),
|
||||
@ -237,7 +198,7 @@ fun TrackList(
|
||||
val shouldLoadMore = remember(density) {
|
||||
derivedStateOf {
|
||||
val loadMoreThreshold = with(density) { 200.dp.toPx() }
|
||||
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == TrackCardBottomKey }
|
||||
val cardItem = listState.layoutInfo.visibleItemsInfo.find { it.key == "track-card" }
|
||||
// Trigger when the bottom of the track card is close to/inside the viewport,
|
||||
// which means the user has scrolled through (or is about to scroll through)
|
||||
// all loaded tracks.
|
||||
@ -258,124 +219,115 @@ fun TrackList(
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackRow: @Composable (Int, MetadataTrack) -> Unit = { displayedIndex, track ->
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
track = track,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
isCurrentTrack = track.id == currentTrackId,
|
||||
// Only the current row depends on it, so play/pause/buffering flips don't recompose every row.
|
||||
isCurrentTrackPlaying = isCurrentTrackPlaying && track.id == currentTrackId,
|
||||
isSelectionMode = isSelectionMode,
|
||||
isSelected = selectedTrackIds.contains(track.id),
|
||||
onTrackClick = {
|
||||
if (isSelectionMode) {
|
||||
selectedTrackIds =
|
||||
if (selectedTrackIds.contains(track.id)) {
|
||||
selectedTrackIds - track.id
|
||||
} else {
|
||||
selectedTrackIds + track.id
|
||||
}
|
||||
} else if (isJamGuest) {
|
||||
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||
} else {
|
||||
onTrackClick(track)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
if (!useDropdownForOptions && !isSelectionMode) {
|
||||
isSelectionMode = true
|
||||
selectedTrackIds = setOf(track.id)
|
||||
} else if (!useDropdownForOptions) {
|
||||
selectedTrackForOptions = track
|
||||
}
|
||||
},
|
||||
onSelectionToggle = { checked ->
|
||||
isSelectionMode = true
|
||||
selectedTrackIds = if (checked) {
|
||||
selectedTrackIds + track.id
|
||||
} else {
|
||||
selectedTrackIds - track.id
|
||||
}
|
||||
},
|
||||
onTrackOptionsAction = { action ->
|
||||
onTrackOptionsAction(
|
||||
track,
|
||||
action
|
||||
)
|
||||
},
|
||||
trackOptionsState = trackOptionsState(track),
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||
}
|
||||
|
||||
// Shimmer, error, empty and next-page rows at the end of the card.
|
||||
val trackCardFooter: @Composable () -> Unit = {
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = shimmerIndex + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
TrackListFeedbackRow(
|
||||
message = error,
|
||||
isError = true)
|
||||
}
|
||||
|
||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
|
||||
}
|
||||
|
||||
if (isLoadingNextPage) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = visibleTracks.size + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = TrackCardOuterPadding),
|
||||
.padding(vertical = 8.dp),
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = TrackCardInnerPadding, bottom = TrackCardInnerPadding)
|
||||
.padding(top = 12.dp, bottom = 12.dp)
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
MillenniumSeparator()
|
||||
}
|
||||
trackRow(displayedIndex, track)
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
track = track,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
isCurrentTrack = track.id == currentTrackId,
|
||||
isCurrentTrackPlaying = isCurrentTrackPlaying,
|
||||
isSelectionMode = isSelectionMode,
|
||||
isSelected = selectedTrackIds.contains(track.id),
|
||||
onTrackClick = {
|
||||
if (isSelectionMode) {
|
||||
selectedTrackIds =
|
||||
if (selectedTrackIds.contains(track.id)) {
|
||||
selectedTrackIds - track.id
|
||||
} else {
|
||||
selectedTrackIds + track.id
|
||||
}
|
||||
} else if (isJamGuest) {
|
||||
onTrackOptionsAction(track, TrackOptionsAction.AddToJam)
|
||||
} else {
|
||||
onTrackClick(track)
|
||||
}
|
||||
},
|
||||
onLongClick = {
|
||||
if (!useDropdownForOptions && !isSelectionMode) {
|
||||
isSelectionMode = true
|
||||
selectedTrackIds = setOf(track.id)
|
||||
} else if (!useDropdownForOptions) {
|
||||
selectedTrackForOptions = track
|
||||
}
|
||||
},
|
||||
onSelectionToggle = { checked ->
|
||||
isSelectionMode = true
|
||||
selectedTrackIds = if (checked) {
|
||||
selectedTrackIds + track.id
|
||||
} else {
|
||||
selectedTrackIds - track.id
|
||||
}
|
||||
},
|
||||
onTrackOptionsAction = { action ->
|
||||
onTrackOptionsAction(
|
||||
track,
|
||||
action
|
||||
)
|
||||
},
|
||||
trackOptionsState = trackOptionsState(track),
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||
}
|
||||
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = shimmerIndex + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
TrackListFeedbackRow(
|
||||
message = error,
|
||||
isError = true)
|
||||
}
|
||||
|
||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||
TrackListFeedbackRow(stringResource(Res.string.track_list_no_tracks))
|
||||
}
|
||||
|
||||
if (isLoadingNextPage) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = visibleTracks.size + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
trackCardFooter()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -502,57 +454,29 @@ fun TrackList(
|
||||
|
||||
if (scrollable) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp)) {
|
||||
// Rows are separate lazy items (only on-screen ones are composed, #3200), so the card
|
||||
// they sit on is drawn once behind the list, spanning the card's laid-out items.
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.trackCardBounds(listState, with(density) { TrackCardOuterPadding.roundToPx() }),
|
||||
contentPadding = PaddingValues(0.dp)) {}
|
||||
// No arrangement spacing: it would also open gaps between the card's rows. The 2.dp
|
||||
// gaps around the card are applied to the items before and after it instead.
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
contentPadding = contentPadding) {
|
||||
modifier = Modifier
|
||||
.widthIn(max = 1280.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (headerContent != null) {
|
||||
item {
|
||||
Box(Modifier.padding(bottom = 2.dp)) { headerContent() }
|
||||
headerContent()
|
||||
}
|
||||
}
|
||||
if (!simplified) {
|
||||
item { Box(Modifier.padding(bottom = 2.dp)) { filterSortRow() } }
|
||||
}
|
||||
item(key = TrackCardTopKey) {
|
||||
Spacer(Modifier.fillMaxWidth().height(TrackCardOuterPadding + TrackCardInnerPadding))
|
||||
}
|
||||
// Plugin ids can repeat (the same track twice in a playlist).
|
||||
val rowKeys = uniqueLazyKeys(visibleTracks) { it.id }
|
||||
items(visibleTracks.size, key = { TrackCardRowKeyPrefix + rowKeys[it] }) { displayedIndex ->
|
||||
Column(Modifier.fillMaxWidth()) {
|
||||
if (displayedIndex > 0) {
|
||||
MillenniumSeparator()
|
||||
}
|
||||
trackRow(displayedIndex, visibleTracks[displayedIndex])
|
||||
}
|
||||
}
|
||||
item(key = TrackCardFooterKey) {
|
||||
Column(Modifier.fillMaxWidth()) { trackCardFooter() }
|
||||
}
|
||||
item(key = TrackCardBottomKey) {
|
||||
Spacer(Modifier.fillMaxWidth().height(TrackCardInnerPadding + TrackCardOuterPadding))
|
||||
item { filterSortRow() }
|
||||
}
|
||||
item(key = "track-card") { trackCardContent() }
|
||||
if (footerContent != null) {
|
||||
item {
|
||||
Box(Modifier.padding(top = 2.dp)) { footerContent() }
|
||||
footerContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!useDropdownForOptions) {
|
||||
selectedTrackForOptions?.let { track ->
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
/*
|
||||
* 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.ui.misc
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridItemScope
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridScope
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/*
|
||||
* Lazy lists crash when two items share a key ("Key ... was already used", #3196). Ids that come
|
||||
* from plugins can be blank or repeat (the same playlist twice in a library page, a track added
|
||||
* twice), so lists keyed on them use these instead of `items(list, key = { it.id })`.
|
||||
*/
|
||||
|
||||
private const val DUPLICATE_SEPARATOR = '\u0000'
|
||||
|
||||
/**
|
||||
* One key per item: an item keeps its id the first time it appears, so scroll position and
|
||||
* animations stay stable; blank ids and repeats get an occurrence suffix that can't collide with
|
||||
* a real id.
|
||||
*/
|
||||
fun <T> uniqueLazyKeys(items: List<T>, id: (T) -> String): List<String> {
|
||||
val occurrences = HashMap<String, Int>(items.size)
|
||||
return items.map { item ->
|
||||
val base = id(item)
|
||||
val occurrence = occurrences[base] ?: 0
|
||||
occurrences[base] = occurrence + 1
|
||||
if (occurrence == 0 && base.isNotEmpty()) base else "$base$DUPLICATE_SEPARATOR$occurrence"
|
||||
}
|
||||
}
|
||||
|
||||
fun <T> LazyListScope.itemsWithUniqueKeys(
|
||||
items: List<T>,
|
||||
id: (T) -> String,
|
||||
keyPrefix: String = "",
|
||||
itemContent: @Composable LazyItemScope.(T) -> Unit
|
||||
) {
|
||||
val keys = uniqueLazyKeys(items, id)
|
||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
||||
}
|
||||
|
||||
fun <T> LazyGridScope.itemsWithUniqueKeys(
|
||||
items: List<T>,
|
||||
id: (T) -> String,
|
||||
keyPrefix: String = "",
|
||||
itemContent: @Composable LazyGridItemScope.(T) -> Unit
|
||||
) {
|
||||
val keys = uniqueLazyKeys(items, id)
|
||||
items(items.size, key = { keyPrefix + keys[it] }) { itemContent(items[it]) }
|
||||
}
|
||||
@ -51,7 +51,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
package org.spotube.spotube.modules.artist
|
||||
|
||||
|
||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
@ -639,7 +638,7 @@ private fun AlbumsSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
itemsWithUniqueKeys(albums, id = { it.id }) { album ->
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album)
|
||||
}
|
||||
}
|
||||
@ -677,7 +676,7 @@ private fun RelatedArtistsSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
itemsWithUniqueKeys(artists, id = { it.id }) { artist ->
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
ArtistCard(artist = artist)
|
||||
}
|
||||
}
|
||||
@ -715,7 +714,7 @@ private fun FeaturedPlaylistsSection(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top
|
||||
) {
|
||||
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
sealed interface ArtistScreenState {
|
||||
|
||||
@ -62,7 +62,7 @@ import okio.SYSTEM
|
||||
import okio.buffer
|
||||
import okio.use
|
||||
import kotlin.time.Clock
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
data class DownloadItem(
|
||||
|
||||
@ -35,7 +35,7 @@ import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -62,7 +62,6 @@ 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
|
||||
@ -139,17 +138,8 @@ 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
|
||||
)
|
||||
}
|
||||
}
|
||||
ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
|
||||
BrokerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -422,11 +412,7 @@ internal fun SessionPanel(
|
||||
}
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
if (participant.isYou) {
|
||||
stringResource(Res.string.jam_participants_you, participant.displayName)
|
||||
} else {
|
||||
participant.displayName
|
||||
},
|
||||
participant.displayName,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
@ -475,16 +461,16 @@ internal fun SessionPanel(
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ServerStatus(state: JamUiState, onConfigure: () -> Unit) {
|
||||
internal fun BrokerStatus(state: JamUiState, onConfigure: () -> Unit) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Text(
|
||||
if (state.serverConfigured) stringResource(
|
||||
if (state.brokerConfigured) stringResource(
|
||||
Res.string.jam_connection_host,
|
||||
state.serverHost
|
||||
state.brokerHost
|
||||
) else stringResource(Res.string.jam_connection_missing),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
|
||||
@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Job
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
enum class JamSetupMode { Create, Join }
|
||||
@ -48,7 +48,6 @@ data class JamParticipantUi(
|
||||
val initial: String,
|
||||
val isHost: Boolean,
|
||||
val canModerate: Boolean,
|
||||
val isYou: Boolean = false,
|
||||
val menuExpanded: Boolean = false
|
||||
)
|
||||
|
||||
@ -58,8 +57,8 @@ data class JamUiState(
|
||||
val role: JamRole? = null,
|
||||
val participants: List<JamParticipantUi> = emptyList(),
|
||||
val roomCode: String? = null,
|
||||
val serverHost: String = "",
|
||||
val serverConfigured: Boolean = false,
|
||||
val brokerHost: String = "",
|
||||
val brokerConfigured: Boolean = false,
|
||||
val error: String? = null,
|
||||
val setupMode: JamSetupMode = JamSetupMode.Create,
|
||||
val codeInput: TextFieldState = TextFieldState(),
|
||||
@ -69,11 +68,7 @@ data class JamUiState(
|
||||
val isLeaving: Boolean = false,
|
||||
val isHost: Boolean = false,
|
||||
val supportsNativeShare: 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 = ""
|
||||
val codeCopied: Boolean = false
|
||||
)
|
||||
|
||||
class JamViewModel(
|
||||
@ -90,7 +85,6 @@ 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
|
||||
@ -108,8 +102,7 @@ class JamViewModel(
|
||||
codeCopied,
|
||||
snapshotFlow { codeInput.text.toString() },
|
||||
participantMenu,
|
||||
isLeaving,
|
||||
defaultName
|
||||
isLeaving
|
||||
) { values ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val role = values[0] as JamRole?
|
||||
@ -122,7 +115,7 @@ class JamViewModel(
|
||||
val settings = values[5] as? org.spotube.spotube.modules.settings.UserSettings
|
||||
val localError = values[6] as String?
|
||||
val busy = values[8] as Boolean
|
||||
val serverConfigured = !settings?.jamServerUrl.isNullOrBlank()
|
||||
val brokerConfigured = !settings?.jamBroker?.host.isNullOrBlank()
|
||||
|
||||
JamUiState(
|
||||
isActive = role != null,
|
||||
@ -135,27 +128,23 @@ 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,
|
||||
serverHost = settings?.jamServerUrl.orEmpty()
|
||||
.substringAfter("://").substringBefore('/'),
|
||||
serverConfigured = serverConfigured,
|
||||
brokerHost = settings?.jamBroker?.host.orEmpty(),
|
||||
brokerConfigured = brokerConfigured,
|
||||
error = localError ?: connectionError,
|
||||
setupMode = values[7] as JamSetupMode,
|
||||
isBusy = busy,
|
||||
isLeaving = values[12] as Boolean,
|
||||
codeCopied = values[9] as Boolean,
|
||||
codeInput = codeInput,
|
||||
canCreate = serverConfigured,
|
||||
canJoin = serverConfigured &&
|
||||
canCreate = brokerConfigured,
|
||||
canJoin = brokerConfigured &&
|
||||
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
|
||||
isHost = role == JamRole.Host,
|
||||
supportsNativeShare = supportsNativeShare,
|
||||
customName = settings?.jamParticipantName.orEmpty(),
|
||||
defaultName = values[13] as String
|
||||
supportsNativeShare = supportsNativeShare
|
||||
)
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
@ -163,14 +152,6 @@ 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() {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
*/
|
||||
package org.spotube.spotube.modules.library
|
||||
|
||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@ -59,7 +58,7 @@ internal fun <T> LibraryMediaCollection(
|
||||
if (loading) items(8) { SkeletonTree(true) { skeleton(true) } }
|
||||
else {
|
||||
if (header != null) item(key = "library_header") { header(true) }
|
||||
itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, true) }
|
||||
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, true) }
|
||||
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(true) } }
|
||||
}
|
||||
}
|
||||
@ -72,7 +71,7 @@ internal fun <T> LibraryMediaCollection(
|
||||
if (loading) items(8) { SkeletonTree(true) { skeleton(false) } }
|
||||
else {
|
||||
if (header != null) item(key = "library_header") { header(false) }
|
||||
itemsWithUniqueKeys(items, id = itemKey, keyPrefix = "media:") { itemContent(it, false) }
|
||||
items(items, key = { "media:${itemKey(it)}" }) { itemContent(it, false) }
|
||||
if (loadingMore) items(4) { SkeletonTree(true) { skeleton(false) } }
|
||||
}
|
||||
}
|
||||
|
||||
@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -36,7 +36,7 @@ import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
import org.spotube.spotube.PlatformType
|
||||
import org.spotube.spotube.getPlatform
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.library_local_refresh_failed
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
package org.spotube.spotube.modules.library.playlist
|
||||
|
||||
|
||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
||||
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
@ -196,7 +195,7 @@ private fun PlaylistPickerContent(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
itemsWithUniqueKeys(playlists, id = { it.id }) { playlist ->
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
PlaylistPickerRow(
|
||||
playlist = playlist,
|
||||
onClick = { onPlaylistSelected(playlist.id) })
|
||||
|
||||
@ -37,7 +37,7 @@ import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import io.github.vinceglb.filekit.PlatformFile
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
package org.spotube.spotube.modules.playlist
|
||||
|
||||
|
||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
||||
import org.spotube.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||
import org.spotube.millennium.composables.MillenniumCircularProgress
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
@ -139,7 +138,7 @@ fun AddTracksToPlaylistDialog(
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
itemsWithUniqueKeys(state.results, id = { it.track.id }) { result ->
|
||||
items(state.results, key = { it.track.id }) { result ->
|
||||
TrackSearchResultRow(
|
||||
track = result.track,
|
||||
isAdding = result.isAdding,
|
||||
|
||||
@ -56,7 +56,7 @@ import org.spotube.spotube.modules.library.playlist.PlaylistFormUiState
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.common_unknown_error
|
||||
|
||||
|
||||
@ -61,7 +61,7 @@ import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
import okio.buffer
|
||||
import okio.use
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.koin.core.component.KoinComponent
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.plugin_action_install
|
||||
|
||||
@ -50,14 +50,6 @@ class SavedTracksRepository(
|
||||
|
||||
val savedTracksIdsFlow : StateFlow<Set<String>> = savedTrackIds.asStateFlow()
|
||||
|
||||
/**
|
||||
* Bumped only when the library itself changes (save, remove, re-login). [savedTracksIdsFlow]
|
||||
* also grows while pages and saved-states are fetched, so reloading the list on it restarted
|
||||
* paging on every page (#3200).
|
||||
*/
|
||||
private val _libraryChanges = MutableStateFlow(0)
|
||||
val libraryChanges: StateFlow<Int> = _libraryChanges.asStateFlow()
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
pluginManager.selectedMetadataPlugin
|
||||
@ -79,7 +71,6 @@ class SavedTracksRepository(
|
||||
savedTracksCache.invalidateAll()
|
||||
totalCountCache.invalidateAll()
|
||||
savedTrackIds.value = emptySet()
|
||||
_libraryChanges.value++
|
||||
}
|
||||
|
||||
suspend fun getSavedTracks(paginationStrategy: PaginationStrategy? = null) =
|
||||
@ -118,7 +109,6 @@ class SavedTracksRepository(
|
||||
savedTracksCache.invalidateAll()
|
||||
totalCountCache.invalidateAll()
|
||||
savedTrackIds.value += ids.toSet()
|
||||
_libraryChanges.value++
|
||||
}
|
||||
}
|
||||
|
||||
@ -132,7 +122,6 @@ class SavedTracksRepository(
|
||||
savedTracksCache.invalidateAll()
|
||||
totalCountCache.invalidateAll()
|
||||
savedTrackIds.value -= ids.toSet()
|
||||
_libraryChanges.value++
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -59,7 +59,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.compose.koinInject
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.*
|
||||
|
||||
const val SAVED_TRACKS_COLLECTION_ID = "saved_tracks"
|
||||
@ -152,7 +152,7 @@ class SavedTracksViewModel(
|
||||
.filterNotNull()
|
||||
.flatMapLatest { it.loggedInFlow }
|
||||
.distinctUntilChanged(),
|
||||
repository.libraryChanges
|
||||
repository.savedTracksIdsFlow
|
||||
) { _, _ -> }.collect {
|
||||
loadInitialData()
|
||||
loadCurrentUser()
|
||||
|
||||
@ -34,7 +34,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.launch
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.search_error_no_metadata_plugin
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.search_error_capabilities
|
||||
import spotube.composeapp.generated.resources.search_error_search
|
||||
|
||||
@ -18,32 +18,46 @@ 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.jam_error_connection_failed
|
||||
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_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_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
|
||||
|
||||
@Composable
|
||||
fun JamSettingsScreen(
|
||||
@ -66,6 +80,7 @@ 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)
|
||||
@ -79,71 +94,166 @@ 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_server_url),
|
||||
value = uiState.settings.jamServerUrl,
|
||||
placeholder = DEFAULT_JAM_SERVER_URL,
|
||||
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) },
|
||||
validate = { value ->
|
||||
if (value.isBlank() || isJamServerUrl(value)) null else invalidUrl
|
||||
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())) }
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.CustomServer,
|
||||
stringResource(Res.string.settings_jam_server_title))
|
||||
stringResource(Res.string.settings_jam_broker_port)
|
||||
)
|
||||
})
|
||||
},
|
||||
{
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_tls),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_tls_subtitle),
|
||||
checked = broker.useTls,
|
||||
onCheckedChange = { tls ->
|
||||
viewModel.update { copy(jamBroker = jamBroker.copy(useTls = tls)) }
|
||||
},
|
||||
onValueSaved = viewModel::setServerUrl)
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxWifiSquare,
|
||||
stringResource(Res.string.settings_jam_broker_tls)
|
||||
)
|
||||
})
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_credentials)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_username),
|
||||
value = broker.username.orEmpty(),
|
||||
placeholder = stringResource(Res.string.settings_jam_broker_username_placeholder),
|
||||
onValueSaved = { username ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(username = username.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.User,
|
||||
stringResource(Res.string.settings_jam_broker_username)
|
||||
)
|
||||
})
|
||||
},
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_password),
|
||||
value = broker.password.orEmpty(),
|
||||
placeholder = "••••••••",
|
||||
onValueSaved = { password ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(password = password.ifBlank { null }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxEye,
|
||||
stringResource(Res.string.settings_jam_broker_password)
|
||||
)
|
||||
})
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_identity)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
TextInputSettingCard(
|
||||
title = stringResource(Res.string.settings_jam_broker_client_id),
|
||||
subtitle = stringResource(Res.string.settings_jam_broker_client_id_subtitle),
|
||||
value = broker.clientIdPrefix,
|
||||
placeholder = "spotube",
|
||||
onValueSaved = { prefix ->
|
||||
viewModel.update {
|
||||
copy(jamBroker = jamBroker.copy(clientIdPrefix = prefix.ifBlank { "spotube" }))
|
||||
}
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxTag,
|
||||
stringResource(Res.string.settings_jam_broker_client_id)
|
||||
)
|
||||
})
|
||||
}))
|
||||
settingsSectionHeader(Res.string.settings_jam_section_diagnostics)
|
||||
settingsSectionCard(
|
||||
items = listOf(
|
||||
{
|
||||
val canTest = broker.host.isNotBlank() && !uiState.isTesting
|
||||
val resultMessage = uiState.testResult?.fold(
|
||||
onSuccess = { it },
|
||||
onFailure = {
|
||||
stringResource(
|
||||
Res.string.settings_jam_server_test_fail,
|
||||
Res.string.settings_jam_broker_test_fail,
|
||||
it.message ?: stringResource(Res.string.jam_error_connection_failed))
|
||||
})
|
||||
|
||||
SettingCardItem(
|
||||
enabled = !uiState.isTesting,
|
||||
title = stringResource(Res.string.settings_jam_server_test),
|
||||
enabled = canTest,
|
||||
title = stringResource(Res.string.settings_jam_broker_test),
|
||||
subtitle = when {
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_server_testing)
|
||||
uiState.isTesting -> stringResource(Res.string.settings_jam_broker_testing)
|
||||
resultMessage != null -> resultMessage
|
||||
else -> stringResource(Res.string.settings_jam_server_diagnostics_subtitle)
|
||||
else -> stringResource(Res.string.settings_jam_broker_diagnostics_subtitle)
|
||||
},
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.IconsaxInformation,
|
||||
stringResource(Res.string.settings_jam_server_test))
|
||||
stringResource(Res.string.settings_jam_broker_test))
|
||||
},
|
||||
trailingContent = {
|
||||
MillenniumButton(
|
||||
onClick = viewModel::testConnection,
|
||||
enabled = broker.host.isNotBlank(),
|
||||
loading = uiState.isTesting) {
|
||||
Text(stringResource(Res.string.settings_jam_server_test))
|
||||
Text(stringResource(Res.string.settings_jam_broker_test))
|
||||
}
|
||||
},
|
||||
onClick = viewModel::testConnection)
|
||||
}))
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_jam_server_note),
|
||||
text = stringResource(Res.string.settings_jam_broker_placeholder_note),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp))
|
||||
|
||||
@ -3,7 +3,6 @@ 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
|
||||
@ -13,22 +12,15 @@ import kotlinx.coroutines.launch
|
||||
data class JamSettingsUiState(
|
||||
val settings: UserSettings = UserSettings(),
|
||||
val isTesting: Boolean = false,
|
||||
val testResult: Result<String>? = null,
|
||||
/** This device's generated jam name, used while the user hasn't picked one. */
|
||||
val defaultName: String = "")
|
||||
val testResult: Result<String>? = null)
|
||||
|
||||
class JamSettingsViewModel(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val jamRoomClient: JamRoomClient,
|
||||
private val jamRoomService: JamRoomService) : ViewModel() {
|
||||
private val jamRoomClient: JamRoomClient) : 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) }
|
||||
@ -36,16 +28,9 @@ class JamSettingsViewModel(
|
||||
}
|
||||
}
|
||||
|
||||
fun setParticipantName(name: String) {
|
||||
viewModelScope.launch { jamRoomService.setParticipantName(name) }
|
||||
}
|
||||
|
||||
/** A blank URL goes back to the public server. */
|
||||
fun setServerUrl(url: String) {
|
||||
fun update(transform: UserSettings.() -> UserSettings) {
|
||||
viewModelScope.launch {
|
||||
val serverUrl = url.trim().trimEnd('/').ifBlank { DEFAULT_JAM_SERVER_URL }
|
||||
settingsRepository.updateSettings(uiState.value.settings.copy(jamServerUrl = serverUrl))
|
||||
_uiState.update { it.copy(testResult = null) }
|
||||
settingsRepository.updateSettings(uiState.value.settings.transform())
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,7 +39,7 @@ class JamSettingsViewModel(
|
||||
_uiState.update { it.copy(isTesting = true, testResult = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val result = jamRoomClient.testConnection(uiState.value.settings.jamServerUrl)
|
||||
val result = jamRoomClient.testConnection(uiState.value.settings.jamBroker)
|
||||
_uiState.update { it.copy(testResult = result) }
|
||||
} finally {
|
||||
_uiState.update { it.copy(isTesting = false) }
|
||||
|
||||
@ -59,13 +59,10 @@ data class UserSettings(
|
||||
val remoteControlDeviceName: String = "",
|
||||
val remoteControlDeviceId: String = "",
|
||||
|
||||
// Group Jam
|
||||
// Group Jam (MQTT)
|
||||
val jamParticipantName: String = "",
|
||||
/** 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 jamBroker: JamBroker = JamBroker(),
|
||||
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
|
||||
@ -94,12 +91,21 @@ data class UserSettings(
|
||||
val supportRemindersEnabled: Boolean = true,
|
||||
val lastSupportReminderEpochMs: Long = 0L)
|
||||
|
||||
/** 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())
|
||||
/**
|
||||
* 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)
|
||||
|
||||
/**
|
||||
* opus/webm wherever the platform's player can open WebM; iOS (AVPlayer) can't, so it streams AAC.
|
||||
|
||||
@ -34,12 +34,7 @@ interface UserSettingsSource {
|
||||
val userSettings: StateFlow<UserSettings>
|
||||
}
|
||||
|
||||
/** [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 {
|
||||
class SettingsRepository(private val database: Database) : UserSettingsSource {
|
||||
companion object {
|
||||
private val SETTINGS_KEY = stringPreferencesKey("user_settings")
|
||||
|
||||
@ -69,7 +64,7 @@ class SettingsRepository(private val database: Database) : UserSettingsStore {
|
||||
initialValue = UserSettings() // Default value
|
||||
)
|
||||
|
||||
override suspend fun updateSettings(newSettings: UserSettings) {
|
||||
suspend fun updateSettings(newSettings: UserSettings) {
|
||||
database.settingsDataStore.edit { prefs ->
|
||||
prefs[SETTINGS_KEY] = settingsJson.encodeToString(newSettings)
|
||||
}
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
/*
|
||||
* 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,7 +24,8 @@ 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_server_title
|
||||
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_section_jam
|
||||
|
||||
internal fun LazyListScope.jamSection(
|
||||
@ -35,17 +36,20 @@ internal fun LazyListScope.jamSection(
|
||||
items = listOf(
|
||||
{
|
||||
SettingCardItem(
|
||||
title = stringResource(Res.string.settings_jam_server_title),
|
||||
subtitle = settings.jamServerUrl,
|
||||
title = stringResource(Res.string.settings_jam_broker_title),
|
||||
subtitle = stringResource(
|
||||
Res.string.settings_jam_broker_host_subtitle,
|
||||
settings.jamBroker.host.ifBlank { "—" },
|
||||
settings.jamBroker.port),
|
||||
icon = {
|
||||
SettingsItemIcon(
|
||||
Iconsax.CustomServer,
|
||||
stringResource(Res.string.settings_jam_server_title))
|
||||
stringResource(Res.string.settings_jam_broker_title))
|
||||
},
|
||||
trailingContent = {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.ChevronRight,
|
||||
contentDescription = stringResource(Res.string.settings_jam_server_title),
|
||||
contentDescription = stringResource(Res.string.settings_jam_broker_title),
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
},
|
||||
onClick = { navigationCommands.navigateTo(Routes.JamSettings) })
|
||||
|
||||
@ -123,7 +123,7 @@ import org.koin.compose.koinInject
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import org.koin.core.parameter.parametersOf
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.downloads_status_cancelled
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
package org.spotube.spotube.modules.shell.alternative_track
|
||||
|
||||
|
||||
import org.spotube.spotube.core.ui.misc.itemsWithUniqueKeys
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumListTile
|
||||
@ -120,9 +119,9 @@ fun AlternativeTrackContent(
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
itemsWithUniqueKeys(
|
||||
items(
|
||||
items = uiState.alternatives,
|
||||
id = { source -> source.id }) { source ->
|
||||
key = { source -> source.id }) { source ->
|
||||
AlternativeSourceRow(
|
||||
source = source,
|
||||
isActive = source.id == uiState.activeSourceId,
|
||||
|
||||
@ -8,10 +8,8 @@ import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
import org.koin.dsl.module
|
||||
@ -425,10 +423,7 @@ class DeviceAudioPlayerQueueTest {
|
||||
)
|
||||
|
||||
val queue = createQueue()
|
||||
// Restore runs on Dispatchers.IO; wait for it on real time rather than assuming one yield is enough.
|
||||
withContext(Dispatchers.Default) {
|
||||
withTimeout(5_000) { while (fakePlayer.loadCallCount == 0) delay(10) }
|
||||
}
|
||||
withContext(Dispatchers.Default) { } // yield to let init coroutine run
|
||||
|
||||
assertEquals(1, fakePlayer.loadCallCount)
|
||||
assertEquals(QueueCollectionEntry.Playlist("pl-1"), queue.getCurrentCollectionEntry())
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
/*
|
||||
* 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(" "))
|
||||
}
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
/*
|
||||
* 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())) }
|
||||
}
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
/*
|
||||
* 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])
|
||||
}
|
||||
}
|
||||
@ -23,9 +23,9 @@ import kotlinx.cinterop.ExperimentalForeignApi
|
||||
import platform.AVFAudio.AVAudioSession
|
||||
import platform.AVFAudio.AVAudioSessionCategoryPlayback
|
||||
import platform.AVFAudio.AVAudioSessionModeDefault
|
||||
import platform.AVFAudio.setActive
|
||||
import platform.Foundation.NSDictionary
|
||||
import platform.Foundation.NSMutableDictionary
|
||||
import platform.Foundation.NSNotificationCenter
|
||||
import platform.Foundation.NSNumber
|
||||
import platform.Foundation.NSOperationQueue
|
||||
import platform.MediaPlayer.MPChangePlaybackPositionCommandEvent
|
||||
import platform.MediaPlayer.MPMediaItemPropertyAlbumTitle
|
||||
@ -99,21 +99,20 @@ internal class IosMediaSession {
|
||||
return
|
||||
}
|
||||
|
||||
val info = buildMap<Any?, Any?> {
|
||||
put(MPMediaItemPropertyTitle, item.title)
|
||||
put(MPMediaItemPropertyArtist, item.artist)
|
||||
item.album.takeIf { it.isNotBlank() }?.let {
|
||||
put(MPMediaItemPropertyAlbumTitle, it)
|
||||
}
|
||||
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
||||
put(MPMediaItemPropertyPlaybackDuration, durationSeconds)
|
||||
}
|
||||
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
||||
put(MPNowPlayingInfoPropertyElapsedPlaybackTime, positionSeconds)
|
||||
}
|
||||
put(MPNowPlayingInfoPropertyPlaybackRate, if (isPlaying) 1.0 else 0.0)
|
||||
val info = NSMutableDictionary()
|
||||
info.setObject(item.title, forKey = MPMediaItemPropertyTitle)
|
||||
info.setObject(item.artist, forKey = MPMediaItemPropertyArtist)
|
||||
item.album.takeIf { it.isNotBlank() }?.let {
|
||||
info.setObject(it, forKey = MPMediaItemPropertyAlbumTitle)
|
||||
}
|
||||
nowPlayingInfoCenter.nowPlayingInfo = info
|
||||
if (durationSeconds.isFinite() && durationSeconds > 0) {
|
||||
info.setObject(durationSeconds, forKey = MPMediaItemPropertyPlaybackDuration)
|
||||
}
|
||||
if (positionSeconds.isFinite() && positionSeconds >= 0) {
|
||||
info.setObject(positionSeconds, forKey = MPNowPlayingInfoPropertyElapsedPlaybackTime)
|
||||
}
|
||||
info.setObject(if (isPlaying) 1.0 else 0.0, forKey = MPNowPlayingInfoPropertyPlaybackRate)
|
||||
nowPlayingInfoCenter.nowPlayingInfo = info as NSDictionary
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
@ -131,42 +130,38 @@ internal class IosMediaSession {
|
||||
}
|
||||
|
||||
private fun registerAudioSessionNotifications() {
|
||||
notificationCenter.addObserverForName(
|
||||
notificationTokens += notificationCenter.addObserverForName(
|
||||
name = platform.AVFAudio.AVAudioSessionInterruptionNotification,
|
||||
`object` = audioSession,
|
||||
queue = NSOperationQueue.mainQueue()) { notification ->
|
||||
val type = notification?.userInfo?.get(
|
||||
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? NSNumber
|
||||
if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
||||
platform.AVFAudio.AVAudioSessionInterruptionTypeKey) as? Number
|
||||
if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeBegan) {
|
||||
onPause?.invoke()
|
||||
} else if (type?.unsignedLongValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
||||
} else if (type?.intValue == platform.AVFAudio.AVAudioSessionInterruptionTypeEnded) {
|
||||
activate()
|
||||
val options = notification?.userInfo?.get(
|
||||
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? NSNumber
|
||||
val shouldResume = options?.unsignedLongValue
|
||||
?.and(platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume)
|
||||
?.let { it != 0UL } == true
|
||||
platform.AVFAudio.AVAudioSessionInterruptionOptionKey) as? Number
|
||||
val shouldResume = (options?.intValue ?: 0) and
|
||||
platform.AVFAudio.AVAudioSessionInterruptionOptionShouldResume.toInt() != 0
|
||||
if (shouldResume) onPlay?.invoke()
|
||||
}
|
||||
}?.let { notificationTokens += it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun registerRemoteCommands() {
|
||||
fun register(command: MPRemoteCommand, handler: (platform.MediaPlayer.MPRemoteCommandEvent?) -> platform.MediaPlayer.MPRemoteCommandHandlerStatus) {
|
||||
command.addTargetWithHandler(handler)?.let {
|
||||
remoteCommandTokens += command to it
|
||||
}
|
||||
}
|
||||
|
||||
register(remoteCommandCenter.playCommand) {
|
||||
remoteCommandTokens += remoteCommandCenter.playCommand to
|
||||
remoteCommandCenter.playCommand.addTargetWithHandler {
|
||||
onPlay?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.pauseCommand) {
|
||||
remoteCommandTokens += remoteCommandCenter.pauseCommand to
|
||||
remoteCommandCenter.pauseCommand.addTargetWithHandler {
|
||||
onPause?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.changePlaybackPositionCommand) { event ->
|
||||
remoteCommandTokens += remoteCommandCenter.changePlaybackPositionCommand to
|
||||
remoteCommandCenter.changePlaybackPositionCommand.addTargetWithHandler { event ->
|
||||
val position = (event as? MPChangePlaybackPositionCommandEvent)?.positionTime
|
||||
if (position != null && position.isFinite() && position >= 0) {
|
||||
onSeek?.invoke(position)
|
||||
@ -175,11 +170,13 @@ internal class IosMediaSession {
|
||||
MPRemoteCommandHandlerStatusCommandFailed
|
||||
}
|
||||
}
|
||||
register(remoteCommandCenter.nextTrackCommand) {
|
||||
remoteCommandTokens += remoteCommandCenter.nextTrackCommand to
|
||||
remoteCommandCenter.nextTrackCommand.addTargetWithHandler {
|
||||
onNext?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
register(remoteCommandCenter.previousTrackCommand) {
|
||||
remoteCommandTokens += remoteCommandCenter.previousTrackCommand to
|
||||
remoteCommandCenter.previousTrackCommand.addTargetWithHandler {
|
||||
onPrevious?.invoke()
|
||||
MPRemoteCommandHandlerStatusSuccess
|
||||
}
|
||||
|
||||
@ -1,36 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
@ -1,30 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
@ -19,13 +19,6 @@ package org.spotube.spotube.modules.library.local_tracks.media
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import org.jetbrains.skia.FilterMipmap
|
||||
import org.jetbrains.skia.FilterMode
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.MipmapMode
|
||||
import org.jetbrains.skia.Rect
|
||||
import org.jetbrains.skia.Surface
|
||||
import java.nio.file.FileSystems
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
@ -77,7 +70,7 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
|
||||
artists = metadata?.artists?.takeIf { it.isNotEmpty() } ?: emptyList(),
|
||||
durationMs = metadata?.durationMs ?: 0L,
|
||||
album = metadata?.album,
|
||||
coverBytes = metadata?.coverBytes?.let(::coverThumbnail))
|
||||
coverBytes = metadata?.coverBytes)
|
||||
folders.getOrPut(parent) { mutableListOf() }.add(track)
|
||||
}
|
||||
}
|
||||
@ -194,30 +187,3 @@ class JvmLocalMediaDiscoveryService : LocalMediaDiscoveryService {
|
||||
"wma")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedded covers can be several MB each and the index is serialised to JSON, so keep a small
|
||||
* JPEG like Android does (#3193). Returns null for unreadable art.
|
||||
*/
|
||||
internal fun coverThumbnail(bytes: ByteArray, size: Int = COVER_THUMBNAIL_SIZE): ByteArray? = runCatching {
|
||||
Image.makeFromEncoded(bytes).use { source ->
|
||||
val scale = size.toFloat() / minOf(source.width, source.height)
|
||||
if (scale >= 1f && bytes.size <= MAX_SMALL_COVER_BYTES) return@runCatching bytes
|
||||
val width = (source.width * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
|
||||
val height = (source.height * scale.coerceAtMost(1f)).toInt().coerceAtLeast(1)
|
||||
Surface.makeRasterN32Premul(width, height).use { surface ->
|
||||
surface.canvas.drawImageRect(
|
||||
source,
|
||||
Rect.makeWH(source.width.toFloat(), source.height.toFloat()),
|
||||
Rect.makeWH(width.toFloat(), height.toFloat()),
|
||||
FilterMipmap(FilterMode.LINEAR, MipmapMode.NONE),
|
||||
null,
|
||||
true)
|
||||
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.JPEG, 85)?.bytes }
|
||||
}
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
private const val COVER_THUMBNAIL_SIZE = 160
|
||||
private const val MAX_SMALL_COVER_BYTES = 64 * 1024
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ import androidx.compose.ui.unit.LayoutDirection
|
||||
import androidx.compose.ui.use
|
||||
import org.spotube.spotube.modules.settings.SupportedLanguages
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.spotube.spotube.core.i18n.getString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
|
||||
@ -1,46 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package org.spotube.spotube.core.i18n
|
||||
|
||||
import java.io.File
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* The library's non-composable getters query the system theme over D-Bus on Linux on every
|
||||
* call, which crashed the desktop app (#3183). App code must use the ones in Strings.kt.
|
||||
*/
|
||||
class NonComposableStringLookupTest {
|
||||
@Test
|
||||
fun appCodeUsesTheAppStringLookups() {
|
||||
val forbidden = Regex("""^import org\.jetbrains\.compose\.resources\.get(String|PluralString|StringArray)\s*$""")
|
||||
val offenders = File("src").walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.flatMap { file ->
|
||||
file.readLines().withIndex()
|
||||
.filter { (_, line) -> forbidden.matches(line) }
|
||||
.map { (index, _) -> "${file.path}:${index + 1}" }
|
||||
}
|
||||
.toList()
|
||||
|
||||
assertTrue(
|
||||
offenders.isEmpty(),
|
||||
"Import getString/getPluralString/getStringArray from org.spotube.spotube.core.i18n instead:\n" +
|
||||
offenders.joinToString("\n"))
|
||||
}
|
||||
}
|
||||
@ -1,88 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,705 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
}
|
||||
}
|
||||
@ -1,96 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
/*
|
||||
* 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.server
|
||||
|
||||
import io.ktor.utils.io.ByteChannel
|
||||
import io.ktor.utils.io.readRemaining
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.io.readByteArray
|
||||
import okio.FileSystem
|
||||
import okio.Path.Companion.toOkioPath
|
||||
import java.nio.file.Files
|
||||
import kotlin.random.Random
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
|
||||
class StreamFileRangeTest {
|
||||
// Several chunks plus a partial one.
|
||||
private val data = Random(42).nextBytes(64 * 1024 * 3 + 1234)
|
||||
|
||||
private fun copy(start: Long, length: Long): ByteArray = runBlocking {
|
||||
val file = Files.createTempFile("spotube-cache", ".bin").also { it.toFile().deleteOnExit() }
|
||||
Files.write(file, data)
|
||||
val channel = ByteChannel()
|
||||
launch {
|
||||
streamFileRange(FileSystem.SYSTEM, file.toOkioPath(), start, length, channel)
|
||||
channel.flushAndClose()
|
||||
}
|
||||
channel.readRemaining().readByteArray()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wholeFile() = assertContentEquals(data, copy(0, data.size.toLong()))
|
||||
|
||||
@Test
|
||||
fun rangeAcrossChunks() {
|
||||
val start = 70_000L
|
||||
val length = 120_000L
|
||||
assertContentEquals(data.copyOfRange(start.toInt(), (start + length).toInt()), copy(start, length))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lengthPastEndStopsAtEof() = assertContentEquals(data.copyOfRange(100, data.size), copy(100, data.size.toLong()))
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
/*
|
||||
* 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.ui.component
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.ImageComposeScene
|
||||
import androidx.compose.ui.semantics.SemanticsNode
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.semantics.getOrNull
|
||||
import androidx.compose.ui.use
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||
import org.spotube.spotube.core.ui.theming.SpotubeTheme
|
||||
import org.spotube.spotube.modules.settings.Theme
|
||||
import org.spotube.spotube.modules.settings.UserSettings
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/** #3200: big track lists must only compose the rows on screen. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
class TrackListVirtualizationTest {
|
||||
private fun track(index: Int) = MetadataTrack(
|
||||
// Every 100th id repeats: playlists can hold the same track twice.
|
||||
id = if (index % 100 == 0) "dup" else "t$index",
|
||||
title = "Song $index",
|
||||
durationMs = 200_000L,
|
||||
trackNumber = 1,
|
||||
discNumber = 1,
|
||||
artists = listOf(MetadataArtist.Basic(id = "a", name = "Artist", thumbnails = emptyList(), externalUri = null)),
|
||||
album = null,
|
||||
thumbnails = emptyList(),
|
||||
explicit = false,
|
||||
popularity = 0,
|
||||
isrcCode = null,
|
||||
externalUri = null)
|
||||
|
||||
private fun allNodes(scene: ImageComposeScene): List<SemanticsNode> {
|
||||
fun flatten(node: SemanticsNode): List<SemanticsNode> = listOf(node) + node.children.flatMap(::flatten)
|
||||
return scene.semanticsOwners.flatMap { flatten(it.rootSemanticsNode) }
|
||||
}
|
||||
|
||||
private fun songTitles(scene: ImageComposeScene): Set<String> = allNodes(scene)
|
||||
.flatMap { it.config.getOrNull(SemanticsProperties.Text).orEmpty() }
|
||||
.map { it.text }
|
||||
.filter { it.startsWith("Song ") }
|
||||
.toSet()
|
||||
|
||||
@Test
|
||||
fun onlyVisibleRowsAreComposedAndTheEndLoadsMore() {
|
||||
val tracks = (0 until 2000).map(::track)
|
||||
val listState = LazyListState()
|
||||
var scope: CoroutineScope? = null
|
||||
var nextPageRequests = 0
|
||||
|
||||
ImageComposeScene(1000, 800) {
|
||||
scope = rememberCoroutineScope()
|
||||
SpotubeTheme(UserSettings(theme = Theme.DARK)) {
|
||||
TrackList(
|
||||
tracks = tracks,
|
||||
hasMore = true,
|
||||
state = listState,
|
||||
onLoadNextPage = { nextPageRequests++ })
|
||||
}
|
||||
}.use { scene ->
|
||||
repeat(3) { scene.render().close() }
|
||||
|
||||
val visible = songTitles(scene)
|
||||
assertTrue("Song 1" in visible, "first rows render: $visible")
|
||||
assertTrue(visible.size in 3..60, "expected only on-screen rows, composed ${visible.size}")
|
||||
assertEquals(0, nextPageRequests)
|
||||
|
||||
scope!!.launch { listState.scrollToItem(listState.layoutInfo.totalItemsCount - 1) }
|
||||
repeat(3) { scene.render().close() }
|
||||
|
||||
val atEnd = songTitles(scene)
|
||||
assertTrue("Song 1999" in atEnd, "last rows render: ${atEnd.sorted().takeLast(5)}")
|
||||
assertTrue("Song 1" !in atEnd)
|
||||
assertEquals(1, nextPageRequests)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,40 +0,0 @@
|
||||
/*
|
||||
* 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.ui.misc
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class UniqueLazyKeysTest {
|
||||
@Test
|
||||
fun blankAndRepeatedIdsGetDistinctKeys() {
|
||||
val ids = listOf("a", "", "b", "a", "", "a")
|
||||
val keys = uniqueLazyKeys(ids) { it }
|
||||
|
||||
assertEquals(ids.size, keys.toSet().size)
|
||||
// First occurrences keep their id so scroll position stays stable.
|
||||
assertEquals("a", keys[0])
|
||||
assertEquals("b", keys[2])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun suffixesCannotCollideWithRealIds() {
|
||||
val ids = listOf("a", "a", "a#1", "a1", "a_1")
|
||||
assertEquals(ids.size, uniqueLazyKeys(ids) { it }.toSet().size)
|
||||
}
|
||||
}
|
||||
@ -65,20 +65,20 @@ class JamLayoutTest {
|
||||
assertEquals(100.dp, viewModel.compactChromeUiState.value.measuredBottomBarHeight)
|
||||
}
|
||||
|
||||
/** Actual Compose controls rendered without native-window capture or a room server connection. */
|
||||
/** Actual Compose controls rendered without native-window capture or an MQTT connection. */
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Test
|
||||
fun renderRoomControlsAtNarrowAndWideSizes() {
|
||||
val output = File("build/reports/jam-ui").apply { mkdirs() }
|
||||
val states = mapOf(
|
||||
"create" to JamUiState(
|
||||
serverConfigured = true,
|
||||
serverHost = "jam.example.com",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
canCreate = true
|
||||
),
|
||||
"join" to JamUiState(
|
||||
serverConfigured = true,
|
||||
serverHost = "jam.example.com",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
setupMode = JamSetupMode.Join
|
||||
),
|
||||
"host" to JamUiState(
|
||||
@ -86,8 +86,8 @@ class JamLayoutTest {
|
||||
isConnected = true,
|
||||
isHost = true,
|
||||
roomCode = "RC5FJ8",
|
||||
serverConfigured = true,
|
||||
serverHost = "jam.example.com",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
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",
|
||||
serverConfigured = true,
|
||||
serverHost = "jam.example.com",
|
||||
brokerConfigured = true,
|
||||
brokerHost = "test.mosquitto.org",
|
||||
participants = listOf(JamParticipantUi("host", "Host-8316", "H", true, false))
|
||||
)
|
||||
)
|
||||
@ -161,7 +161,7 @@ class JamLayoutTest {
|
||||
{},
|
||||
{})
|
||||
else SetupPanel(state, {}, {}, {})
|
||||
ServerStatus(state, {})
|
||||
BrokerStatus(state, {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,56 +0,0 @@
|
||||
/*
|
||||
* 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.library.local_tracks.media
|
||||
|
||||
import org.jetbrains.skia.EncodedImageFormat
|
||||
import org.jetbrains.skia.Image
|
||||
import org.jetbrains.skia.Paint
|
||||
import org.jetbrains.skia.Rect
|
||||
import org.jetbrains.skia.Surface
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class CoverThumbnailTest {
|
||||
private fun png(width: Int, height: Int): ByteArray = Surface.makeRasterN32Premul(width, height).use { surface ->
|
||||
// Noise-free but non-uniform so the PNG isn't trivially small.
|
||||
for (x in 0 until width step 7) {
|
||||
surface.canvas.drawRect(Rect.makeXYWH(x.toFloat(), 0f, 3f, height.toFloat()), Paint().apply { color = (0xFF000000.toInt() or (x * 2654435761L).toInt()) })
|
||||
}
|
||||
surface.makeImageSnapshot().use { it.encodeToData(EncodedImageFormat.PNG)!!.bytes }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun largeCoverIsShrunkToASmallJpeg() {
|
||||
val original = png(1600, 1200)
|
||||
val thumbnail = assertNotNull(coverThumbnail(original))
|
||||
|
||||
Image.makeFromEncoded(thumbnail).use { image ->
|
||||
assertEquals(160, minOf(image.width, image.height))
|
||||
assertEquals(213, image.width)
|
||||
}
|
||||
assertTrue(thumbnail.size < 32 * 1024, "thumbnail is ${thumbnail.size} bytes")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unreadableCoverIsDropped() {
|
||||
assertNull(coverThumbnail(byteArrayOf(1, 2, 3, 4)))
|
||||
}
|
||||
}
|
||||
@ -36,6 +36,8 @@ 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"
|
||||
@ -73,7 +75,7 @@ appdirs = { module = "net.harawata:appdirs", version.ref = "appdirs" }
|
||||
cache4k = { module = "io.github.reactivecircus.cache4k:cache4k", version.ref = "cache4k" }
|
||||
androidx-car-app = { module = "androidx.car.app:app", version.ref = "carApp" }
|
||||
compose-shimmer = { module = "com.valentinilk.shimmer:compose-shimmer", version.ref = "composeShimmer" }
|
||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs_nio", version.ref = "desugar_jdk_libs" }
|
||||
desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar_jdk_libs" }
|
||||
dns-sd-kt = { module = "com.appstractive:dns-sd-kt", version.ref = "dnssdkt" }
|
||||
haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" }
|
||||
haze-materials = { module = "dev.chrisbanes.haze:haze-blur-materials", version.ref = "haze" }
|
||||
@ -103,6 +105,10 @@ 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" }
|
||||
|
||||
@ -8,9 +8,8 @@ command: spotube
|
||||
finish-args:
|
||||
- --share=ipc
|
||||
- --share=network
|
||||
# The window is a Tao (Wayland/X11) one, so Wayland is used natively and X11 only as a fallback
|
||||
- --socket=wayland
|
||||
- --socket=fallback-x11
|
||||
# Compose renders through AWT/Skiko, which needs X11 (XWayland on Wayland sessions)
|
||||
- --socket=x11
|
||||
- --socket=pulseaudio
|
||||
- --device=dri
|
||||
# Downloaded tracks go to ~/Downloads/Spotube; local tracks are scanned from ~/Music and ~/Downloads
|
||||
|
||||
Loading…
Reference in New Issue
Block a user