mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(jam): harden the room server, add device bans and friendly names
Security:
- Participants connect with a per-room key derived from a random per-install
secret; others only see an id derived from it, so nobody can reconnect as
someone else (including the host).
- "create" only works on a fresh code or for the room's own host, so empty
rooms can't be taken over.
- Per-socket message and queue budgets disconnect clients that flood; the
host throttles itself well under them.
- Bans also cover the device: a hashed digest of device traits combined with
the network, stored only as an opaque per-room value. Earlier members are
never caught by someone else's ban.
- Peer queue items and suggestions are sanitized (streaming tracks only, no
URLs, https covers); suggestions are credited to the server-attested sender;
local file paths go out as opaque ids. Names are cleaned server-side.
- Room codes and keys use a secure random source; the deploy workflow is
read-only.
Names:
- Participants get a stable generated name ("Mellow Otter") per install, and
can pick their own on the Jam screen or in Jam settings. The participant
list marks "(you)".
Also translate the Jam strings into all locales and add tests for the above.
This commit is contained in:
parent
0a0b0c9374
commit
45a7758eb0
5
.github/workflows/jam-server-deploy.yml
vendored
5
.github/workflows/jam-server-deploy.yml
vendored
@ -6,6 +6,11 @@ on:
|
|||||||
paths: ["jam-server/**"]
|
paths: ["jam-server/**"]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# The deploy only reads the repo; the Cloudflare token comes from secrets, which
|
||||||
|
# workflows triggered by pull requests from forks never receive.
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: jam-server-deploy
|
group: jam-server-deploy
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
|||||||
@ -30,6 +30,7 @@
|
|||||||
- Room server: `jam-server/` (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in `jam-server/src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes).
|
- Room server: `jam-server/` (Cloudflare Worker + one Durable Object per six-letter room code, TypeScript, wrangler). It relays/retains small JSON frames and serves the clock; no audio passes through it. Protocol in `jam-server/src/protocol.ts`; keep it in sync with the wire frames in `core/jam/JamRoomClient.kt` (bump `PROTOCOL_VERSION` on both sides for breaking changes).
|
||||||
- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off).
|
- App side: `core/jam/` — `JamRoomClient` (Ktor WebSocket, reconnects, keep-alives, clock pings), `JamClock`/`JamDriftCorrector` (sync math), `JamRoomService` (host broadcasts queue + playback anchors in server time; guests follow by nudging playback rate, seeking only when far off).
|
||||||
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See `jam-server/README.md`.
|
- Cost and battery: the room is billed per incoming message and storage write, and on phones each transmission wakes the cellular radio. Don't add periodic frames or polling. Keep-alives go out once a minute. Guests' sync loop sleeps while the room is paused or buffering, and the host checks its own drift only while playing. Anchors go out on change (plus host drift and a 60 s heartbeat), keep-alives are runtime auto-responses, and anchors aren't persisted. See `jam-server/README.md`.
|
||||||
|
- 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 `jam-server/README.md`.
|
||||||
- A network drop must never crash the app: `JamRoomClient.pump` turns socket failures into a reconnect. `JamSessionSimulationTest.roomSurvivesNetworkLoss` cuts the network with `TcpProxy`.
|
- 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`).
|
- Server URL setting: `UserSettings.jamServerUrl` (default `DEFAULT_JAM_SERVER_URL` in `SettingsModels.kt`).
|
||||||
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (start `npm run dev` in `jam-server/`).
|
- Tests: `JamSyncTest` (pure), plus `JamRoomClientIntegrationTest` and `JamSessionSimulationTest`, which only run with `JAM_TEST_URL` set (start `npm run dev` in `jam-server/`).
|
||||||
|
|||||||
@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import android.content.res.Resources
|
||||||
|
import android.os.Build
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
internal actual fun jamDeviceTraits(): List<String> {
|
||||||
|
val display = Resources.getSystem().displayMetrics
|
||||||
|
return listOf(
|
||||||
|
"android",
|
||||||
|
Build.MANUFACTURER,
|
||||||
|
Build.MODEL,
|
||||||
|
Build.DEVICE,
|
||||||
|
Build.VERSION.RELEASE,
|
||||||
|
Build.VERSION.SDK_INT.toString(),
|
||||||
|
"${minOf(display.widthPixels, display.heightPixels)}x${maxOf(display.widthPixels, display.heightPixels)}@${display.densityDpi}",
|
||||||
|
Locale.getDefault().toLanguageTag(),
|
||||||
|
TimeZone.getDefault().id)
|
||||||
|
}
|
||||||
@ -344,6 +344,28 @@
|
|||||||
<string name="jam_error_join">تعذّر الانضمام إلى جلسة الجام</string>
|
<string name="jam_error_join">تعذّر الانضمام إلى جلسة الجام</string>
|
||||||
<string name="jam_error_invalid_code">أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.</string>
|
<string name="jam_error_invalid_code">أدخل رمز غرفة مكوّنًا من ستة أحرف وأرقام.</string>
|
||||||
<string name="jam_share_message">انضم إلى جام Spotube الخاص بي باستخدام الرمز: %1$s</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. للاستضافة الذاتية، انشر jam-server من مستودع Spotube وأدخل رابطه.</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_go_to_album">الانتقال إلى الألبوم</string>
|
||||||
<string name="player_sleep_timer">مؤقت النوم</string>
|
<string name="player_sleep_timer">مؤقت النوم</string>
|
||||||
|
|||||||
@ -328,6 +328,28 @@
|
|||||||
<string name="jam_error_join">জ্যাম সেশনে যোগ দেওয়া যায়নি</string>
|
<string name="jam_error_join">জ্যাম সেশনে যোগ দেওয়া যায়নি</string>
|
||||||
<string name="jam_error_invalid_code">অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।</string>
|
<string name="jam_error_invalid_code">অক্ষর ও সংখ্যা দিয়ে ছয় অক্ষরের একটি রুম কোড দিন।</string>
|
||||||
<string name="jam_share_message">এই কোড দিয়ে আমার Spotube জ্যামে যোগ দিন: %1$s</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-এর বিনামূল্যের রুম সার্ভার ব্যবহার করে। নিজে হোস্ট করতে 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_go_to_album">অ্যালবামে যান</string>
|
||||||
<string name="player_sleep_timer">স্লিপ টাইমার</string>
|
<string name="player_sleep_timer">স্লিপ টাইমার</string>
|
||||||
|
|||||||
@ -328,6 +328,28 @@
|
|||||||
<string name="jam_error_join">Beitritt zur Jam-Session fehlgeschlagen</string>
|
<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_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="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 jam-server aus dem Spotube-Repository 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_go_to_album">Zum Album</string>
|
||||||
<string name="player_sleep_timer">Sleep-Timer</string>
|
<string name="player_sleep_timer">Sleep-Timer</string>
|
||||||
|
|||||||
@ -332,6 +332,28 @@
|
|||||||
<string name="jam_error_join">No se pudo unir a la sesión de Jam</string>
|
<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_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="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 jam-server desde el repositorio de Spotube 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_go_to_album">Ir al álbum</string>
|
||||||
<string name="player_sleep_timer">Temporizador</string>
|
<string name="player_sleep_timer">Temporizador</string>
|
||||||
|
|||||||
@ -328,6 +328,28 @@
|
|||||||
<string name="jam_error_join">پیوستن به جلسهٔ جم ناموفق بود</string>
|
<string name="jam_error_join">پیوستن به جلسهٔ جم ناموفق بود</string>
|
||||||
<string name="jam_error_invalid_code">یک کد اتاق ششنویسهای شامل حروف و اعداد وارد کنید.</string>
|
<string name="jam_error_invalid_code">یک کد اتاق ششنویسهای شامل حروف و اعداد وارد کنید.</string>
|
||||||
<string name="jam_share_message">با این کد به جم Spotube من بپیوندید: %1$s</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 استفاده میکند. برای میزبانی شخصی، jam-server را از مخزن Spotube راهاندازی کنید و نشانی آن را وارد کنید.</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_go_to_album">رفتن به آلبوم</string>
|
||||||
<string name="player_sleep_timer">زمانسنج خواب</string>
|
<string name="player_sleep_timer">زمانسنج خواب</string>
|
||||||
|
|||||||
@ -332,6 +332,28 @@
|
|||||||
<string name="jam_error_join">Impossible de rejoindre la session Jam</string>
|
<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_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="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 jam-server depuis le dépôt Spotube 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_go_to_album">Accéder à l\'album</string>
|
||||||
<string name="player_sleep_timer">Minuteur de veille</string>
|
<string name="player_sleep_timer">Minuteur de veille</string>
|
||||||
|
|||||||
@ -327,6 +327,28 @@
|
|||||||
<string name="jam_error_join">जैम सेशन से नहीं जुड़ सके</string>
|
<string name="jam_error_join">जैम सेशन से नहीं जुड़ सके</string>
|
||||||
<string name="jam_error_invalid_code">अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।</string>
|
<string name="jam_error_invalid_code">अक्षरों और अंकों वाला छह अक्षर का रूम कोड डालें।</string>
|
||||||
<string name="jam_share_message">मेरे Spotube जैम से जुड़ें, कोड: %1$s</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 के मुफ़्त रूम सर्वर का इस्तेमाल करता है। खुद होस्ट करने के लिए 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_go_to_album">एल्बम पर जाएं</string>
|
||||||
<string name="player_sleep_timer">स्लीप टाइमर</string>
|
<string name="player_sleep_timer">स्लीप टाइमर</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">Gagal bergabung ke sesi Jam</string>
|
<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_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="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 jam-server dari repositori Spotube 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_go_to_album">Buka album</string>
|
||||||
<string name="player_sleep_timer">Timer tidur</string>
|
<string name="player_sleep_timer">Timer tidur</string>
|
||||||
|
|||||||
@ -332,6 +332,28 @@
|
|||||||
<string name="jam_error_join">Impossibile partecipare alla sessione Jam</string>
|
<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_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="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 jam-server dal repository di Spotube 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_go_to_album">Vai all\'album</string>
|
||||||
<string name="player_sleep_timer">Timer di spegnimento</string>
|
<string name="player_sleep_timer">Timer di spegnimento</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">ジャムセッションに参加できませんでした</string>
|
<string name="jam_error_join">ジャムセッションに参加できませんでした</string>
|
||||||
<string name="jam_error_invalid_code">英数字6文字のルームコードを入力してください。</string>
|
<string name="jam_error_invalid_code">英数字6文字のルームコードを入力してください。</string>
|
||||||
<string name="jam_share_message">コード %1$s で私のSpotubeジャムに参加しよう</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の無料ルームサーバーを使います。自分でホストするには、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_go_to_album">アルバムへ移動</string>
|
||||||
<string name="player_sleep_timer">スリープタイマー</string>
|
<string name="player_sleep_timer">スリープタイマー</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">잼 세션에 참여하지 못했어요</string>
|
<string name="jam_error_join">잼 세션에 참여하지 못했어요</string>
|
||||||
<string name="jam_error_invalid_code">영문자와 숫자로 된 6자리 방 코드를 입력하세요.</string>
|
<string name="jam_error_invalid_code">영문자와 숫자로 된 6자리 방 코드를 입력하세요.</string>
|
||||||
<string name="jam_share_message">내 Spotube 잼에 참여하세요. 코드: %1$s</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의 무료 방 서버를 사용합니다. 직접 호스팅하려면 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_go_to_album">앨범으로 이동</string>
|
||||||
<string name="player_sleep_timer">취침 타이머</string>
|
<string name="player_sleep_timer">취침 타이머</string>
|
||||||
|
|||||||
@ -328,6 +328,28 @@
|
|||||||
<string name="jam_error_join">Deelnemen aan Jam-sessie mislukt</string>
|
<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_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="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 jam-server uit de Spotube-repository 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_go_to_album">Naar album</string>
|
||||||
<string name="player_sleep_timer">Slaaptimer</string>
|
<string name="player_sleep_timer">Slaaptimer</string>
|
||||||
|
|||||||
@ -336,6 +336,28 @@
|
|||||||
<string name="jam_error_join">Nie udało się dołączyć do sesji Jam</string>
|
<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_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="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óż jam-server z repozytorium Spotube 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_go_to_album">Przejdź do albumu</string>
|
||||||
<string name="player_sleep_timer">Wyłącznik czasowy</string>
|
<string name="player_sleep_timer">Wyłącznik czasowy</string>
|
||||||
|
|||||||
@ -332,6 +332,28 @@
|
|||||||
<string name="jam_error_join">Falha ao entrar na Jam</string>
|
<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_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="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 jam-server do repositório do Spotube 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_go_to_album">Ir para o álbum</string>
|
||||||
<string name="player_sleep_timer">Timer</string>
|
<string name="player_sleep_timer">Timer</string>
|
||||||
|
|||||||
@ -336,6 +336,28 @@
|
|||||||
<string name="jam_error_join">Не удалось присоединиться к джему</string>
|
<string name="jam_error_join">Не удалось присоединиться к джему</string>
|
||||||
<string name="jam_error_invalid_code">Введите код комнаты из шести букв и цифр.</string>
|
<string name="jam_error_invalid_code">Введите код комнаты из шести букв и цифр.</string>
|
||||||
<string name="jam_share_message">Присоединяйся к моему джему в Spotube по коду: %1$s</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. Чтобы развернуть свой, установите jam-server из репозитория Spotube и укажите его 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_go_to_album">Перейти к альбому</string>
|
||||||
<string name="player_sleep_timer">Таймер сна</string>
|
<string name="player_sleep_timer">Таймер сна</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">เข้าร่วมเซสชันแจมไม่สำเร็จ</string>
|
<string name="jam_error_join">เข้าร่วมเซสชันแจมไม่สำเร็จ</string>
|
||||||
<string name="jam_error_invalid_code">ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข</string>
|
<string name="jam_error_invalid_code">ใส่รหัสห้อง 6 ตัวอักษร ที่ประกอบด้วยตัวอักษรและตัวเลข</string>
|
||||||
<string name="jam_share_message">มาร่วมแจมกับฉันบน Spotube ด้วยรหัส: %1$s</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 หากต้องการโฮสต์เอง ให้ติดตั้ง jam-server จากรีโพสิทอรีของ Spotube แล้วใส่ 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_go_to_album">ไปที่อัลบั้ม</string>
|
||||||
<string name="player_sleep_timer">ตั้งเวลาปิด</string>
|
<string name="player_sleep_timer">ตั้งเวลาปิด</string>
|
||||||
|
|||||||
@ -328,6 +328,28 @@
|
|||||||
<string name="jam_error_join">Jam oturumuna katılınamadı</string>
|
<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_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="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 Spotube deposundaki jam-server’ı 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_go_to_album">Albüme git</string>
|
||||||
<string name="player_sleep_timer">Uyku zamanlayıcısı</string>
|
<string name="player_sleep_timer">Uyku zamanlayıcısı</string>
|
||||||
|
|||||||
@ -336,6 +336,28 @@
|
|||||||
<string name="jam_error_join">Не вдалося приєднатися до джему</string>
|
<string name="jam_error_join">Не вдалося приєднатися до джему</string>
|
||||||
<string name="jam_error_invalid_code">Введіть код кімнати з шести літер і цифр.</string>
|
<string name="jam_error_invalid_code">Введіть код кімнати з шести літер і цифр.</string>
|
||||||
<string name="jam_share_message">Приєднуйся до мого джему в Spotube за кодом: %1$s</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. Щоб розгорнути власний, встановіть jam-server із репозиторію Spotube і вкажіть його 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_go_to_album">Перейти до альбому</string>
|
||||||
<string name="player_sleep_timer">Таймер сну</string>
|
<string name="player_sleep_timer">Таймер сну</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">Không thể tham gia phiên Jam</string>
|
<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_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="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 jam-server từ kho mã Spotube 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_go_to_album">Đi tới album</string>
|
||||||
<string name="player_sleep_timer">Hẹn giờ tắt</string>
|
<string name="player_sleep_timer">Hẹn giờ tắt</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">無法加入 Jam</string>
|
<string name="jam_error_join">無法加入 Jam</string>
|
||||||
<string name="jam_error_invalid_code">請輸入由英文字母和數字組成的六位房間代碼。</string>
|
<string name="jam_error_invalid_code">請輸入由英文字母和數字組成的六位房間代碼。</string>
|
||||||
<string name="jam_share_message">用代碼 %1$s 加入我的 Spotube Jam</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 的免費房間伺服器。若要自行架設,請部署 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_go_to_album">前往專輯</string>
|
||||||
<string name="player_sleep_timer">睡眠定時器</string>
|
<string name="player_sleep_timer">睡眠定時器</string>
|
||||||
|
|||||||
@ -324,6 +324,28 @@
|
|||||||
<string name="jam_error_join">加入一起听会话失败</string>
|
<string name="jam_error_join">加入一起听会话失败</string>
|
||||||
<string name="jam_error_invalid_code">请输入由字母和数字组成的六位房间码。</string>
|
<string name="jam_error_invalid_code">请输入由字母和数字组成的六位房间码。</string>
|
||||||
<string name="jam_share_message">用房间码加入我的 Spotube 一起听:%1$s</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 的免费房间服务器。如需自行托管,请部署 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_go_to_album">转到专辑</string>
|
||||||
<string name="player_sleep_timer">睡眠定时器</string>
|
<string name="player_sleep_timer">睡眠定时器</string>
|
||||||
|
|||||||
@ -321,6 +321,7 @@
|
|||||||
<string name="settings_jam_server_test_fail">Failed: %1$s</string>
|
<string name="settings_jam_server_test_fail">Failed: %1$s</string>
|
||||||
<string name="settings_jam_section_connection">Connection</string>
|
<string name="settings_jam_section_connection">Connection</string>
|
||||||
<string name="settings_jam_section_diagnostics">Diagnostics</string>
|
<string name="settings_jam_section_diagnostics">Diagnostics</string>
|
||||||
|
<string name="settings_jam_section_you">You</string>
|
||||||
|
|
||||||
<!-- Group Jam -->
|
<!-- Group Jam -->
|
||||||
<string name="jam_screen_title">Group Jam</string>
|
<string name="jam_screen_title">Group Jam</string>
|
||||||
@ -343,6 +344,7 @@
|
|||||||
<string name="jam_participants_heading">Participants (%1$d)</string>
|
<string name="jam_participants_heading">Participants (%1$d)</string>
|
||||||
<string name="jam_participants_host">Host</string>
|
<string name="jam_participants_host">Host</string>
|
||||||
<string name="jam_participants_guest">Listener</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_waiting">Waiting for listeners…</string>
|
||||||
<string name="jam_participants_remove">Remove</string>
|
<string name="jam_participants_remove">Remove</string>
|
||||||
<string name="jam_participants_ban">Ban</string>
|
<string name="jam_participants_ban">Ban</string>
|
||||||
@ -354,6 +356,145 @@
|
|||||||
<string name="jam_error_join">Failed to join jam session</string>
|
<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_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_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_go_to_album">Go to album</string>
|
||||||
<string name="player_sleep_timer">Sleep timer</string>
|
<string name="player_sleep_timer">Sleep timer</string>
|
||||||
@ -547,7 +688,7 @@
|
|||||||
<string name="jam_error_room_full">This room is full.</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_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_kicked">The host removed you from the room.</string>
|
||||||
<string name="jam_error_replaced">You joined this room from another device.</string>
|
<string name="jam_error_replaced">This room was opened again on this device, so this connection was closed.</string>
|
||||||
<string name="jam_error_connection_failed">Connection failed</string>
|
<string name="jam_error_connection_failed">Connection failed</string>
|
||||||
<string name="jam_error_connection_lost">Connection lost</string>
|
<string name="jam_error_connection_lost">Connection lost</string>
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable traits of this device (model, OS version, locale, …) for jam bans. They never
|
||||||
|
* leave the device as they are: [jamDeviceDigest] hashes them with the room code, and the
|
||||||
|
* server hashes that again with the network, so a room only ever holds an opaque value.
|
||||||
|
*/
|
||||||
|
internal expect fun jamDeviceTraits(): List<String>
|
||||||
|
|
||||||
|
/** Per-room digest of [jamDeviceTraits]: unlinkable across rooms. */
|
||||||
|
internal fun jamDeviceDigest(roomCode: String): String? = runCatching {
|
||||||
|
val traits = jamDeviceTraits().filter { it.isNotBlank() }
|
||||||
|
if (traits.isEmpty()) return null
|
||||||
|
"spotube-jam-device:$roomCode:${traits.joinToString("|")}".encodeUtf8().sha256().hex()
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This install's participant secret for room [roomCode], derived from the install's
|
||||||
|
* [deviceKey] (random, kept in settings). The same install gets the same identity in a
|
||||||
|
* room every time, so a ban sticks across rejoins; different rooms get unrelated keys.
|
||||||
|
*/
|
||||||
|
internal fun jamParticipantKey(deviceKey: String, roomCode: String): String =
|
||||||
|
"spotube-jam:$roomCode".encodeUtf8().hmacSha256(deviceKey.encodeUtf8()).hex()
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import okio.ByteString.Companion.encodeUtf8
|
||||||
|
import org.jetbrains.compose.resources.getString
|
||||||
|
import org.jetbrains.compose.resources.getStringArray
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_adjectives
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_animals
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_format
|
||||||
|
|
||||||
|
/** Friendly default names for jam participants ("Mellow Otter"), like npm or Docker use. */
|
||||||
|
object JamNames {
|
||||||
|
/** The server's limit (`MAX_NAME_LENGTH` in `jam-server/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
|
||||||
|
}
|
||||||
@ -99,10 +99,35 @@ data class JamMediaItem(
|
|||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val protocol: String,
|
val protocol: String,
|
||||||
val addedBy: String = "") {
|
val addedBy: String = "") {
|
||||||
/** Identifies the track across devices: the metadata id, or the file for local tracks. */
|
/** Identifies the track across devices: the metadata id, or an opaque id for local files. */
|
||||||
val key: String get() = trackId.ifBlank { "local:$url" }
|
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 {
|
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) {
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||||
url = "",
|
url = "",
|
||||||
@ -117,8 +142,9 @@ data class JamMediaItem(
|
|||||||
protocol = entry.protocol.name,
|
protocol = entry.protocol.name,
|
||||||
addedBy = entry.addedBy)
|
addedBy = entry.addedBy)
|
||||||
|
|
||||||
|
// Only an opaque id: the path would show the host's folders (and user name) to everyone.
|
||||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
url = entry.url,
|
url = localTrackKey(entry.url),
|
||||||
trackId = "",
|
trackId = "",
|
||||||
title = entry.name,
|
title = entry.name,
|
||||||
artist = entry.artists.joinToString(", "),
|
artist = entry.artists.joinToString(", "),
|
||||||
|
|||||||
@ -117,6 +117,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
private var lastFrameAtMs = 0L
|
private var lastFrameAtMs = 0L
|
||||||
/** The room's host as last reported by the server. */
|
/** The room's host as last reported by the server. */
|
||||||
private var hostId: String? = null
|
private var hostId: String? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Our public participant id in the current room, as the server derived it from our
|
||||||
|
* secret key. Null until the first welcome.
|
||||||
|
*/
|
||||||
|
var selfId: String? = null
|
||||||
|
private set
|
||||||
private val pongs = MutableStateFlow(0)
|
private val pongs = MutableStateFlow(0)
|
||||||
|
|
||||||
private val _isConnected = MutableStateFlow(false)
|
private val _isConnected = MutableStateFlow(false)
|
||||||
@ -144,16 +151,22 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
* Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException]
|
* Opens room [code] on [serverUrl] and syncs the clock. Fails with [JamRoomException]
|
||||||
* when the server refuses (code taken, room not found, banned, …). Once connected,
|
* when the server refuses (code taken, room not found, banned, …). Once connected,
|
||||||
* drops are retried in the background until [disconnect] or a refusal.
|
* 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(
|
suspend fun connect(
|
||||||
serverUrl: String,
|
serverUrl: String,
|
||||||
code: String,
|
code: String,
|
||||||
clientId: String,
|
clientKey: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
create: Boolean): Result<JamRoomEvent.Welcome> {
|
create: Boolean,
|
||||||
|
deviceDigest: String? = null): Result<JamRoomEvent.Welcome> {
|
||||||
disconnect()
|
disconnect()
|
||||||
clock.reset()
|
clock.reset()
|
||||||
val welcome = runCatching { open(serverUrl, code, clientId, displayName, create) }
|
val opener = Opener(serverUrl, code, clientKey, displayName, deviceDigest)
|
||||||
|
val welcome = runCatching { open(opener, create) }
|
||||||
.onFailure { e ->
|
.onFailure { e ->
|
||||||
log.w(e) { "Failed to open jam room $code" }
|
log.w(e) { "Failed to open jam room $code" }
|
||||||
_connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed)
|
_connectionError.value = e.message ?: getString(Res.string.jam_error_connection_failed)
|
||||||
@ -176,11 +189,13 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)])
|
delay(RECONNECT_BACKOFF_MS[attempt.coerceAtMost(RECONNECT_BACKOFF_MS.lastIndex)])
|
||||||
attempt++
|
attempt++
|
||||||
var reopened = runCatching { open(serverUrl, code, clientId, displayName, create = false) }
|
var reopened = runCatching { open(opener, create = false) }
|
||||||
// Everyone was gone long enough for the room to expire: its host brings it back.
|
// Everyone was gone long enough for the room to expire: its host brings it back.
|
||||||
if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" && hostId == clientId) {
|
if ((reopened.exceptionOrNull() as? JamRoomException)?.code == "room_not_found" &&
|
||||||
|
hostId != null && hostId == selfId
|
||||||
|
) {
|
||||||
log.i { "Jam room $code expired while offline; recreating it" }
|
log.i { "Jam room $code expired while offline; recreating it" }
|
||||||
reopened = runCatching { open(serverUrl, code, clientId, displayName, create = true) }
|
reopened = runCatching { open(opener, create = true) }
|
||||||
}
|
}
|
||||||
val error = reopened.exceptionOrNull()
|
val error = reopened.exceptionOrNull()
|
||||||
if (error is JamRoomException) {
|
if (error is JamRoomException) {
|
||||||
@ -210,6 +225,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
connectionJob?.cancel()
|
connectionJob?.cancel()
|
||||||
connectionJob = null
|
connectionJob = null
|
||||||
hostId = null
|
hostId = null
|
||||||
|
selfId = null
|
||||||
reader?.cancel()
|
reader?.cancel()
|
||||||
reader = null
|
reader = null
|
||||||
val current = session
|
val current = session
|
||||||
@ -225,13 +241,17 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
* Connects, waits for the welcome (or refusal) and syncs the clock. Returns the welcome
|
* 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.
|
* and the socket's reader, which completes with the server's refusal (if any) on close.
|
||||||
*/
|
*/
|
||||||
private suspend fun open(
|
/** Everything needed to (re)open one room connection. */
|
||||||
serverUrl: String,
|
private data class Opener(
|
||||||
code: String,
|
val serverUrl: String,
|
||||||
clientId: String,
|
val code: String,
|
||||||
displayName: String,
|
val clientKey: String,
|
||||||
create: Boolean): Pair<JamRoomEvent.Welcome, Deferred<String?>> {
|
val displayName: String,
|
||||||
val socket = runCatching { http.webSocketSession(roomUrl(serverUrl, code, clientId, displayName, create)) }
|
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) }
|
.getOrElse { throw IllegalStateException(getString(Res.string.jam_error_server_unreachable), it) }
|
||||||
try {
|
try {
|
||||||
val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) }
|
val welcome = withTimeoutOrNull(CONNECT_TIMEOUT_MS) { awaitWelcome(socket) }
|
||||||
@ -241,6 +261,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session = socket
|
session = socket
|
||||||
|
selfId = welcome.you
|
||||||
hostId = welcome.host
|
hostId = welcome.host
|
||||||
lastFrameAtMs = now()
|
lastFrameAtMs = now()
|
||||||
_isConnected.value = true
|
_isConnected.value = true
|
||||||
@ -249,7 +270,7 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
val socketReader = scope.async { pump(socket) }
|
val socketReader = scope.async { pump(socket) }
|
||||||
reader = socketReader
|
reader = socketReader
|
||||||
syncClock(socket)
|
syncClock(socket)
|
||||||
log.i { "Joined jam room $code as $clientId (host=${welcome.host}, rtt=${clock.rttMs}ms)" }
|
log.i { "Joined jam room $code as ${welcome.you} (host=${welcome.host}, rtt=${clock.rttMs}ms)" }
|
||||||
return welcome.toEvent() to socketReader
|
return welcome.toEvent() to socketReader
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
runCatching { socket.close() }
|
runCatching { socket.close() }
|
||||||
@ -351,7 +372,8 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
|
is ServerFrame.Queue -> _events.emit(JamRoomEvent.Queue(decoded.body))
|
||||||
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
|
is ServerFrame.Anchor -> _events.emit(JamRoomEvent.Anchor(decoded.body))
|
||||||
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
|
is ServerFrame.Cmd -> _events.emit(JamRoomEvent.Command(decoded.from, decoded.body))
|
||||||
is ServerFrame.Error -> return decoded.code
|
// 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
|
null -> Unit
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
@ -395,13 +417,14 @@ class JamRoomClient(httpClientFactory: HttpClientFactory) {
|
|||||||
else -> getString(Res.string.jam_error_connection_failed)
|
else -> getString(Res.string.jam_error_connection_failed)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun roomUrl(serverUrl: String, code: String, clientId: String, displayName: String, create: Boolean) =
|
private fun roomUrl(opener: Opener, create: Boolean) =
|
||||||
URLBuilder(serverUrl.trim()).apply {
|
URLBuilder(opener.serverUrl.trim()).apply {
|
||||||
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
|
protocol = if (protocol == URLProtocol.HTTP || protocol == URLProtocol.WS) URLProtocol.WS else URLProtocol.WSS
|
||||||
appendPathSegments("v1", "rooms", code)
|
appendPathSegments("v1", "rooms", opener.code)
|
||||||
parameters.append("id", clientId)
|
parameters.append("key", opener.clientKey)
|
||||||
parameters.append("name", displayName)
|
parameters.append("name", opener.displayName)
|
||||||
parameters.append("mode", if (create) "create" else "join")
|
parameters.append("mode", if (create) "create" else "join")
|
||||||
|
opener.deviceDigest?.let { parameters.append("dev", it) }
|
||||||
}.buildString()
|
}.buildString()
|
||||||
|
|
||||||
private fun healthUrl(serverUrl: String) =
|
private fun healthUrl(serverUrl: String) =
|
||||||
|
|||||||
@ -17,7 +17,8 @@
|
|||||||
|
|
||||||
package org.spotube.spotube.core.jam
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
import kotlin.random.Random
|
import kotlin.uuid.ExperimentalUuidApi
|
||||||
|
import kotlin.uuid.Uuid
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Six-character room codes shared verbally / by text. Codes are opaque keys used
|
* Six-character room codes shared verbally / by text. Codes are opaque keys used
|
||||||
@ -30,12 +31,23 @@ object JamRoomCode {
|
|||||||
const val LENGTH = 6
|
const val LENGTH = 6
|
||||||
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
private const val ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
|
||||||
fun generate(): String = buildString(LENGTH) {
|
/** From a cryptographically secure source: a predictable code would let others in. */
|
||||||
repeat(LENGTH) {
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
append(ALPHABET[Random.nextInt(ALPHABET.length)])
|
fun generate(): String {
|
||||||
|
val bytes = Uuid.random().toByteArray()
|
||||||
|
// 32 symbols = 5 bits each; the low 5 bits of a random byte are uniform.
|
||||||
|
return buildString(LENGTH) {
|
||||||
|
repeat(LENGTH) { append(ALPHABET[bytes[it].toInt() and 31]) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participant's secret for one session, from a cryptographically secure source.
|
||||||
|
* The server derives the public participant id from it; only this device knows it.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
|
fun newClientKey(): String = Uuid.random().toHexString() + Uuid.random().toHexString()
|
||||||
|
|
||||||
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
|
/** Uppercases, strips separators/whitespace and truncates to [LENGTH]. */
|
||||||
fun normalize(input: String): String = input
|
fun normalize(input: String): String = input
|
||||||
.uppercase()
|
.uppercase()
|
||||||
|
|||||||
@ -35,7 +35,9 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.channels.Channel
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.conflate
|
||||||
import kotlinx.coroutines.flow.filter
|
import kotlinx.coroutines.flow.filter
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
@ -55,7 +57,6 @@ import kotlinx.coroutines.sync.Mutex
|
|||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withTimeoutOrNull
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
import kotlin.random.Random
|
|
||||||
import org.jetbrains.compose.resources.getString
|
import org.jetbrains.compose.resources.getString
|
||||||
import spotube.composeapp.generated.resources.Res
|
import spotube.composeapp.generated.resources.Res
|
||||||
import spotube.composeapp.generated.resources.jam_error_invalid_code
|
import spotube.composeapp.generated.resources.jam_error_invalid_code
|
||||||
@ -129,7 +130,6 @@ class JamRoomService(
|
|||||||
private val _shuffleEnabled = MutableStateFlow(false)
|
private val _shuffleEnabled = MutableStateFlow(false)
|
||||||
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
|
val shuffleEnabled: StateFlow<Boolean> = _shuffleEnabled.asStateFlow()
|
||||||
|
|
||||||
private var localClientId: String = ""
|
|
||||||
private var localDisplayName: String = ""
|
private var localDisplayName: String = ""
|
||||||
|
|
||||||
/** Display name of the local participant. */
|
/** Display name of the local participant. */
|
||||||
@ -138,7 +138,7 @@ class JamRoomService(
|
|||||||
|
|
||||||
/** Stable id of the local participant (stamped on items this device adds). */
|
/** Stable id of the local participant (stamped on items this device adds). */
|
||||||
val participantClientId: String
|
val participantClientId: String
|
||||||
get() = localClientId
|
get() = jamClient.selfId.orEmpty()
|
||||||
|
|
||||||
private var roleJob: Job? = null
|
private var roleJob: Job? = null
|
||||||
private var leaving = false
|
private var leaving = false
|
||||||
@ -175,6 +175,8 @@ class JamRoomService(
|
|||||||
private var ownSeekUntilMs = 0L
|
private var ownSeekUntilMs = 0L
|
||||||
private var entryChangedAtMs = 0L
|
private var entryChangedAtMs = 0L
|
||||||
|
|
||||||
|
private val anchorRequests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
// The host's last anchor, to notice its own player drifting away from it.
|
// The host's last anchor, to notice its own player drifting away from it.
|
||||||
private var sentAnchor: JamAnchor? = null
|
private var sentAnchor: JamAnchor? = null
|
||||||
private var sentAnchorAtMs = 0L
|
private var sentAnchorAtMs = 0L
|
||||||
@ -222,8 +224,8 @@ class JamRoomService(
|
|||||||
|
|
||||||
suspend fun createRoom(): Result<String> {
|
suspend fun createRoom(): Result<String> {
|
||||||
val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server)))
|
val serverUrl = serverUrl() ?: return Result.failure(IllegalStateException(getString(Res.string.jam_error_no_server)))
|
||||||
localClientId = newClientId()
|
val deviceKey = deviceKey()
|
||||||
localDisplayName = participantName("Host")
|
localDisplayName = participantName(deviceKey)
|
||||||
enter()
|
enter()
|
||||||
|
|
||||||
// A fresh code is almost never taken; retry the rare collision with another one.
|
// A fresh code is almost never taken; retry the rare collision with another one.
|
||||||
@ -232,7 +234,9 @@ class JamRoomService(
|
|||||||
var attempts = 0
|
var attempts = 0
|
||||||
do {
|
do {
|
||||||
code = JamRoomCode.generate()
|
code = JamRoomCode.generate()
|
||||||
result = jamClient.connect(serverUrl, code, localClientId, localDisplayName, create = true)
|
result = jamClient.connect(
|
||||||
|
serverUrl, code, jamParticipantKey(deviceKey, code), localDisplayName, create = true,
|
||||||
|
deviceDigest = jamDeviceDigest(code))
|
||||||
val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken"
|
val taken = (result.exceptionOrNull() as? JamRoomException)?.code == "room_taken"
|
||||||
} while (taken && ++attempts < CREATE_ATTEMPTS)
|
} while (taken && ++attempts < CREATE_ATTEMPTS)
|
||||||
|
|
||||||
@ -250,11 +254,13 @@ class JamRoomService(
|
|||||||
if (!JamRoomCode.isValid(normalized)) {
|
if (!JamRoomCode.isValid(normalized)) {
|
||||||
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code)))
|
return Result.failure(IllegalArgumentException(getString(Res.string.jam_error_invalid_code)))
|
||||||
}
|
}
|
||||||
localClientId = newClientId()
|
val deviceKey = deviceKey()
|
||||||
localDisplayName = participantName("Guest")
|
localDisplayName = participantName(deviceKey)
|
||||||
enter()
|
enter()
|
||||||
|
|
||||||
val welcome = jamClient.connect(serverUrl, normalized, localClientId, localDisplayName, create = false)
|
val welcome = jamClient.connect(
|
||||||
|
serverUrl, normalized, jamParticipantKey(deviceKey, normalized), localDisplayName, create = false,
|
||||||
|
deviceDigest = jamDeviceDigest(normalized))
|
||||||
.getOrElse {
|
.getOrElse {
|
||||||
inRoom = false
|
inRoom = false
|
||||||
return Result.failure(it)
|
return Result.failure(it)
|
||||||
@ -322,12 +328,12 @@ class JamRoomService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun suggestTrack(track: MetadataTrack) {
|
suspend fun suggestTrack(track: MetadataTrack) {
|
||||||
jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = localClientId))
|
jamClient.sendCommand(JamCommand.Suggest(listOf(JamMediaItem.fromTrack(track)), addedBy = participantClientId))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
|
suspend fun suggestPlaylist(tracks: List<MetadataTrack>) {
|
||||||
if (tracks.isEmpty()) return
|
if (tracks.isEmpty()) return
|
||||||
jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = localClientId))
|
jamClient.sendCommand(JamCommand.Suggest(tracks.map(JamMediaItem::fromTrack), addedBy = participantClientId))
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun kickParticipant(participantId: String) {
|
suspend fun kickParticipant(participantId: String) {
|
||||||
@ -343,7 +349,7 @@ class JamRoomService(
|
|||||||
/** The host applies its own controls; a guest's go to the host. */
|
/** The host applies its own controls; a guest's go to the host. */
|
||||||
private fun control(command: JamCommand) {
|
private fun control(command: JamCommand) {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
if (_role.value == JamRole.Host) applyCommand(command) else jamClient.sendCommand(command)
|
if (_role.value == JamRole.Host) applyCommand(command, from = participantClientId) else jamClient.sendCommand(command)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -367,7 +373,8 @@ class JamRoomService(
|
|||||||
anchor = event.anchor
|
anchor = event.anchor
|
||||||
if (_role.value == JamRole.Guest) onAnchor(event.anchor)
|
if (_role.value == JamRole.Guest) onAnchor(event.anchor)
|
||||||
}
|
}
|
||||||
is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command)
|
// [JamRoomEvent.Command.from] is the server's word for who sent it; the body is the sender's.
|
||||||
|
is JamRoomEvent.Command -> if (_role.value == JamRole.Host) applyCommand(event.command, event.from)
|
||||||
is JamRoomEvent.Closed -> {
|
is JamRoomEvent.Closed -> {
|
||||||
log.i { "Left jam room: ${event.code}" }
|
log.i { "Left jam room: ${event.code}" }
|
||||||
reset()
|
reset()
|
||||||
@ -385,7 +392,7 @@ class JamRoomService(
|
|||||||
|
|
||||||
private fun onMembership(host: String?, participants: List<JamParticipant>, force: Boolean = false) {
|
private fun onMembership(host: String?, participants: List<JamParticipant>, force: Boolean = false) {
|
||||||
_participants.value = participants
|
_participants.value = participants
|
||||||
val role = if (host == localClientId) JamRole.Host else JamRole.Guest
|
val role = if (host != null && host == jamClient.selfId) JamRole.Host else JamRole.Guest
|
||||||
// A (re)welcome restarts the role so a guest re-applies the room's state.
|
// A (re)welcome restarts the role so a guest re-applies the room's state.
|
||||||
if (role == _role.value && !force) return
|
if (role == _role.value && !force) return
|
||||||
log.i { "Jam role: ${_role.value} -> $role" }
|
log.i { "Jam role: ${_role.value} -> $role" }
|
||||||
@ -406,16 +413,28 @@ class JamRoomService(
|
|||||||
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
|
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
|
||||||
}
|
}
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
|
// At most one upload a second (the server's limit), always the latest queue.
|
||||||
|
.conflate()
|
||||||
.onEach { queue ->
|
.onEach { queue ->
|
||||||
_shuffleEnabled.value = queue.shuffleEnabled
|
_shuffleEnabled.value = queue.shuffleEnabled
|
||||||
// After a reconnect the server already has it: skip the re-upload.
|
// After a reconnect the server already has it: skip the re-upload.
|
||||||
if (queue != roomQueue) {
|
if (queue != roomQueue) {
|
||||||
roomQueue = queue
|
roomQueue = queue
|
||||||
jamClient.sendQueue(queue)
|
jamClient.sendQueue(queue)
|
||||||
|
delay(QUEUE_MIN_GAP_MS)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
|
|
||||||
|
// Anchors are built when sent, so coalescing a burst of requests (rapid skips)
|
||||||
|
// loses nothing; it keeps the host under the server's frame budget.
|
||||||
|
launch {
|
||||||
|
for (request in anchorRequests) {
|
||||||
|
sendAnchor()
|
||||||
|
delay(ANCHOR_MIN_GAP_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
combine(
|
combine(
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
playIntent,
|
playIntent,
|
||||||
@ -464,7 +483,12 @@ class JamRoomService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun publishAnchor() {
|
/** Asks the host loop to send a fresh anchor (coalesced, see [runHost]). */
|
||||||
|
private fun publishAnchor() {
|
||||||
|
anchorRequests.trySend(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun sendAnchor() {
|
||||||
if (_role.value != JamRole.Host) return
|
if (_role.value != JamRole.Host) return
|
||||||
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
|
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
|
||||||
val playing = playIntent.value
|
val playing = playIntent.value
|
||||||
@ -491,7 +515,7 @@ class JamRoomService(
|
|||||||
?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
|
?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyCommand(command: JamCommand) {
|
private suspend fun applyCommand(command: JamCommand, from: String) {
|
||||||
runCatching {
|
runCatching {
|
||||||
when (command) {
|
when (command) {
|
||||||
// Applied locally; the anchor carries the change to every guest.
|
// Applied locally; the anchor carries the change to every guest.
|
||||||
@ -503,15 +527,21 @@ class JamRoomService(
|
|||||||
seek(command.positionMs)
|
seek(command.positionMs)
|
||||||
publishAnchor()
|
publishAnchor()
|
||||||
}
|
}
|
||||||
is JamCommand.Suggest -> acceptSuggestion(command.items.map { it.copy(addedBy = command.addedBy) })
|
is JamCommand.Suggest -> acceptSuggestion(command.items, from)
|
||||||
}
|
}
|
||||||
}.onFailure { log.w(it) { "Failed to apply jam command $command" } }
|
}.onFailure { log.w(it) { "Failed to apply jam command $command" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun acceptSuggestion(items: List<JamMediaItem>) {
|
/** Suggestions come from other devices: only clean streaming items, credited to the actual sender. */
|
||||||
if (items.isEmpty()) return
|
private suspend fun acceptSuggestion(items: List<JamMediaItem>, from: String) {
|
||||||
log.i { "Accepting ${items.size} suggested item(s) into the jam queue" }
|
val accepted = items.asSequence()
|
||||||
audioPlayerQueue.addAllToQueue(items.map { it.toQueueEntry() })
|
.mapNotNull { it.sanitized() }
|
||||||
|
.take(MAX_SUGGESTED_ITEMS)
|
||||||
|
.map { it.copy(addedBy = from) }
|
||||||
|
.toList()
|
||||||
|
if (accepted.isEmpty()) return
|
||||||
|
log.i { "Accepting ${accepted.size} suggested item(s) into the jam queue" }
|
||||||
|
audioPlayerQueue.addAllToQueue(accepted.map { it.toQueueEntry() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- Guest ----------
|
// ---------- Guest ----------
|
||||||
@ -575,7 +605,8 @@ class JamRoomService(
|
|||||||
private suspend fun applyQueue(queue: JamQueue) {
|
private suspend fun applyQueue(queue: JamQueue) {
|
||||||
roomVersion.value++
|
roomVersion.value++
|
||||||
_shuffleEnabled.value = queue.shuffleEnabled
|
_shuffleEnabled.value = queue.shuffleEnabled
|
||||||
val items = queue.items.filter { it.trackId.isNotBlank() || it.url.isNotBlank() }
|
// The host's local files can't play here, and anything else is cleaned first.
|
||||||
|
val items = queue.items.mapNotNull { it.sanitized() }
|
||||||
if (items == lastAppliedItems) return
|
if (items == lastAppliedItems) return
|
||||||
val previous = lastAppliedItems
|
val previous = lastAppliedItems
|
||||||
lastAppliedItems = items
|
lastAppliedItems = items
|
||||||
@ -747,7 +778,7 @@ class JamRoomService(
|
|||||||
|
|
||||||
// The user seeked: it applies to the whole room.
|
// The user seeked: it applies to the whole room.
|
||||||
when (_role.value) {
|
when (_role.value) {
|
||||||
JamRole.Host -> scope.launch { publishAnchor() }
|
JamRole.Host -> publishAnchor()
|
||||||
JamRole.Guest -> {
|
JamRole.Guest -> {
|
||||||
holdSync(SEEK_HOLD_MS * 2)
|
holdSync(SEEK_HOLD_MS * 2)
|
||||||
jamClient.sendCommand(JamCommand.SeekTo(positionMs))
|
jamClient.sendCommand(JamCommand.SeekTo(positionMs))
|
||||||
@ -761,15 +792,29 @@ class JamRoomService(
|
|||||||
private fun serverUrl(): String? =
|
private fun serverUrl(): String? =
|
||||||
settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null }
|
settingsRepository.userSettings.value.jamServerUrl.trim().ifBlank { null }
|
||||||
|
|
||||||
private fun participantName(fallbackPrefix: String): String {
|
private suspend fun participantName(deviceKey: String): String =
|
||||||
val configured = settingsRepository.userSettings.value.jamParticipantName
|
JamNames.normalize(settingsRepository.userSettings.value.jamParticipantName)
|
||||||
return configured.ifBlank { "$fallbackPrefix-${Random.nextInt(1000, 9999)}" }
|
.take(JamNames.MAX_LENGTH)
|
||||||
|
.ifBlank { JamNames.generate(deviceKey) }
|
||||||
|
|
||||||
|
/** The name this device gets when the user hasn't picked one; the same every time. */
|
||||||
|
suspend fun defaultParticipantName(): String = JamNames.generate(deviceKey())
|
||||||
|
|
||||||
|
/** Saves the user's own name; blank goes back to [defaultParticipantName]. */
|
||||||
|
suspend fun setParticipantName(name: String) {
|
||||||
|
val settings = settingsRepository.userSettings.value
|
||||||
|
settingsRepository.updateSettings(
|
||||||
|
settings.copy(jamParticipantName = JamNames.normalize(name).take(JamNames.MAX_LENGTH)))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun newClientId(): String = buildString {
|
/** This install's jam secret, created on first use. */
|
||||||
append("sp-")
|
private suspend fun deviceKey(): String {
|
||||||
val chars = "0123456789abcdef"
|
val settings = settingsRepository.userSettings.value
|
||||||
repeat(12) { append(chars[Random.nextInt(chars.length)]) }
|
if (settings.jamDeviceKey.isNotBlank()) return settings.jamDeviceKey
|
||||||
|
val generated = JamRoomCode.newClientKey()
|
||||||
|
runCatching { settingsRepository.updateSettings(settings.copy(jamDeviceKey = generated)) }
|
||||||
|
.onFailure { log.w(it) { "Failed to save the jam device key" } }
|
||||||
|
return generated
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun persistLastCode(code: String) {
|
private fun persistLastCode(code: String) {
|
||||||
@ -785,7 +830,7 @@ class JamRoomService(
|
|||||||
|
|
||||||
private fun QueueEntry.jamKey(): String = when (this) {
|
private fun QueueEntry.jamKey(): String = when (this) {
|
||||||
is QueueEntry.StreamingTrack -> track.id
|
is QueueEntry.StreamingTrack -> track.id
|
||||||
is QueueEntry.LocalTrack -> "local:$url"
|
is QueueEntry.LocalTrack -> JamMediaItem.localTrackKey(url)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
private fun JamMediaItem.toQueueEntry(): QueueEntry = when {
|
||||||
@ -827,6 +872,10 @@ class JamRoomService(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val CREATE_ATTEMPTS = 3
|
private const val CREATE_ATTEMPTS = 3
|
||||||
|
/** Keeps the host under the server's budgets (see `jam-server/src/protocol.ts`). */
|
||||||
|
private const val QUEUE_MIN_GAP_MS = 1_000L
|
||||||
|
private const val ANCHOR_MIN_GAP_MS = 250L
|
||||||
|
private const val MAX_SUGGESTED_ITEMS = 500
|
||||||
private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L
|
private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L
|
||||||
private const val HOST_DRIFT_WINDOW = 5
|
private const val HOST_DRIFT_WINDOW = 5
|
||||||
/** Below the guests' dead band, so the host's own error never adds up to a correction. */
|
/** Below the guests' dead band, so the host's own error never adds up to a correction. */
|
||||||
|
|||||||
@ -62,6 +62,7 @@ import org.spotube.spotube.core.navigation.NavigationCommands
|
|||||||
import org.spotube.spotube.core.navigation.Routes
|
import org.spotube.spotube.core.navigation.Routes
|
||||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||||
import org.spotube.spotube.core.ui.theming.AppTheme
|
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.modules.shell.LocalAppShellBottomInset
|
||||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
import org.spotube.spotube.resources.iconsax.IconsaxSetting2
|
import org.spotube.spotube.resources.iconsax.IconsaxSetting2
|
||||||
@ -138,6 +139,15 @@ fun JamScreen(navigationCommands: NavigationCommands) {
|
|||||||
viewModel::createSession,
|
viewModel::createSession,
|
||||||
viewModel::joinSession
|
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) }
|
ServerStatus(state) { navigationCommands.navigateTo(Routes.JamSettings) }
|
||||||
}
|
}
|
||||||
@ -412,7 +422,11 @@ internal fun SessionPanel(
|
|||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
participant.displayName,
|
if (participant.isYou) {
|
||||||
|
stringResource(Res.string.jam_participants_you, participant.displayName)
|
||||||
|
} else {
|
||||||
|
participant.displayName
|
||||||
|
},
|
||||||
style = AppTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis
|
overflow = TextOverflow.Ellipsis
|
||||||
|
|||||||
@ -48,6 +48,7 @@ data class JamParticipantUi(
|
|||||||
val initial: String,
|
val initial: String,
|
||||||
val isHost: Boolean,
|
val isHost: Boolean,
|
||||||
val canModerate: Boolean,
|
val canModerate: Boolean,
|
||||||
|
val isYou: Boolean = false,
|
||||||
val menuExpanded: Boolean = false
|
val menuExpanded: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -68,7 +69,11 @@ data class JamUiState(
|
|||||||
val isLeaving: Boolean = false,
|
val isLeaving: Boolean = false,
|
||||||
val isHost: Boolean = false,
|
val isHost: Boolean = false,
|
||||||
val supportsNativeShare: Boolean = false,
|
val supportsNativeShare: Boolean = false,
|
||||||
val codeCopied: Boolean = false
|
val codeCopied: Boolean = false,
|
||||||
|
/** The name the user picked (blank when they haven't). */
|
||||||
|
val customName: String = "",
|
||||||
|
/** This device's generated name, used when [customName] is blank. */
|
||||||
|
val defaultName: String = ""
|
||||||
)
|
)
|
||||||
|
|
||||||
class JamViewModel(
|
class JamViewModel(
|
||||||
@ -85,6 +90,7 @@ class JamViewModel(
|
|||||||
private val codeInput = TextFieldState()
|
private val codeInput = TextFieldState()
|
||||||
private var copyFeedbackJob: Job? = null
|
private var copyFeedbackJob: Job? = null
|
||||||
private val participantMenu = MutableStateFlow<String?>(null)
|
private val participantMenu = MutableStateFlow<String?>(null)
|
||||||
|
private val defaultName = MutableStateFlow("")
|
||||||
|
|
||||||
val supportsNativeShare: Boolean =
|
val supportsNativeShare: Boolean =
|
||||||
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
getPlatform().type == PlatformType.Android || getPlatform().type == PlatformType.IOS
|
||||||
@ -102,7 +108,8 @@ class JamViewModel(
|
|||||||
codeCopied,
|
codeCopied,
|
||||||
snapshotFlow { codeInput.text.toString() },
|
snapshotFlow { codeInput.text.toString() },
|
||||||
participantMenu,
|
participantMenu,
|
||||||
isLeaving
|
isLeaving,
|
||||||
|
defaultName
|
||||||
) { values ->
|
) { values ->
|
||||||
@Suppress("UNCHECKED_CAST")
|
@Suppress("UNCHECKED_CAST")
|
||||||
val role = values[0] as JamRole?
|
val role = values[0] as JamRole?
|
||||||
@ -128,6 +135,7 @@ class JamViewModel(
|
|||||||
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
|
initial = participant.displayName.firstOrNull()?.uppercase() ?: "?",
|
||||||
isHost = participant.isHost,
|
isHost = participant.isHost,
|
||||||
canModerate = role == JamRole.Host && !participant.isHost,
|
canModerate = role == JamRole.Host && !participant.isHost,
|
||||||
|
isYou = participant.id == jamRoomService.participantClientId,
|
||||||
menuExpanded = values[11] == participant.id
|
menuExpanded = values[11] == participant.id
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@ -145,7 +153,9 @@ class JamViewModel(
|
|||||||
canJoin = serverConfigured &&
|
canJoin = serverConfigured &&
|
||||||
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
|
JamRoomCode.isValid(JamRoomCode.normalize(values[10] as String)),
|
||||||
isHost = role == JamRole.Host,
|
isHost = role == JamRole.Host,
|
||||||
supportsNativeShare = supportsNativeShare
|
supportsNativeShare = supportsNativeShare,
|
||||||
|
customName = settings?.jamParticipantName.orEmpty(),
|
||||||
|
defaultName = values[13] as String
|
||||||
)
|
)
|
||||||
}.stateIn(
|
}.stateIn(
|
||||||
viewModelScope,
|
viewModelScope,
|
||||||
@ -153,6 +163,14 @@ class JamViewModel(
|
|||||||
JamUiState(codeInput = codeInput)
|
JamUiState(codeInput = codeInput)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch { defaultName.value = jamRoomService.defaultParticipantName() }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setParticipantName(name: String) {
|
||||||
|
viewModelScope.launch { jamRoomService.setParticipantName(name) }
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Session lifecycle ----------
|
// ---------- Session lifecycle ----------
|
||||||
|
|
||||||
fun createSession() {
|
fun createSession() {
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||||
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
|
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.SettingsItemIcon
|
||||||
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
|
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
|
||||||
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
|
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
|
||||||
@ -33,6 +34,7 @@ import spotube.composeapp.generated.resources.Res
|
|||||||
import spotube.composeapp.generated.resources.jam_error_connection_failed
|
import spotube.composeapp.generated.resources.jam_error_connection_failed
|
||||||
import spotube.composeapp.generated.resources.settings_jam_section_connection
|
import spotube.composeapp.generated.resources.settings_jam_section_connection
|
||||||
import spotube.composeapp.generated.resources.settings_jam_section_diagnostics
|
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_diagnostics_subtitle
|
||||||
import spotube.composeapp.generated.resources.settings_jam_server_note
|
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
|
||||||
@ -77,6 +79,15 @@ internal fun JamSettingsContent(
|
|||||||
.widthIn(max = 1280.dp)
|
.widthIn(max = 1280.dp)
|
||||||
.align(Alignment.TopCenter),
|
.align(Alignment.TopCenter),
|
||||||
contentPadding = contentPadding) {
|
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)
|
settingsSectionHeader(Res.string.settings_jam_section_connection)
|
||||||
settingsSectionCard(
|
settingsSectionCard(
|
||||||
items = listOf(
|
items = listOf(
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package org.spotube.spotube.modules.settings
|
|||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import org.spotube.spotube.core.jam.JamRoomClient
|
import org.spotube.spotube.core.jam.JamRoomClient
|
||||||
|
import org.spotube.spotube.core.jam.JamRoomService
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
@ -12,15 +13,22 @@ import kotlinx.coroutines.launch
|
|||||||
data class JamSettingsUiState(
|
data class JamSettingsUiState(
|
||||||
val settings: UserSettings = UserSettings(),
|
val settings: UserSettings = UserSettings(),
|
||||||
val isTesting: Boolean = false,
|
val isTesting: Boolean = false,
|
||||||
val testResult: Result<String>? = null)
|
val testResult: Result<String>? = null,
|
||||||
|
/** This device's generated jam name, used while the user hasn't picked one. */
|
||||||
|
val defaultName: String = "")
|
||||||
|
|
||||||
class JamSettingsViewModel(
|
class JamSettingsViewModel(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val jamRoomClient: JamRoomClient) : ViewModel() {
|
private val jamRoomClient: JamRoomClient,
|
||||||
|
private val jamRoomService: JamRoomService) : ViewModel() {
|
||||||
private val _uiState = MutableStateFlow(JamSettingsUiState())
|
private val _uiState = MutableStateFlow(JamSettingsUiState())
|
||||||
val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow()
|
val uiState: StateFlow<JamSettingsUiState> = _uiState.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val name = jamRoomService.defaultParticipantName()
|
||||||
|
_uiState.update { it.copy(defaultName = name) }
|
||||||
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
settingsRepository.userSettings.collect { settings ->
|
settingsRepository.userSettings.collect { settings ->
|
||||||
_uiState.update { it.copy(settings = settings) }
|
_uiState.update { it.copy(settings = settings) }
|
||||||
@ -28,6 +36,10 @@ class JamSettingsViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setParticipantName(name: String) {
|
||||||
|
viewModelScope.launch { jamRoomService.setParticipantName(name) }
|
||||||
|
}
|
||||||
|
|
||||||
/** A blank URL goes back to the public server. */
|
/** A blank URL goes back to the public server. */
|
||||||
fun setServerUrl(url: String) {
|
fun setServerUrl(url: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
|||||||
@ -64,6 +64,8 @@ data class UserSettings(
|
|||||||
/** Room server (`jam-server/`); self-hosters point this at their own deployment. */
|
/** Room server (`jam-server/`); self-hosters point this at their own deployment. */
|
||||||
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
|
val jamServerUrl: String = DEFAULT_JAM_SERVER_URL,
|
||||||
val lastJamCode: String = "",
|
val lastJamCode: String = "",
|
||||||
|
/** Random per-install secret behind this device's jam identities (see `jamParticipantKey`). */
|
||||||
|
val jamDeviceKey: String = "",
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
val overloadedDownloadFolder: String? = null, // When null, uses default music folder
|
||||||
|
|||||||
@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.modules.settings.sections
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.spotube.spotube.core.jam.JamNames
|
||||||
|
import org.spotube.spotube.modules.settings.components.TextInputSettingCard
|
||||||
|
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||||
|
import org.spotube.spotube.resources.iconsax.IconsaxEdit
|
||||||
|
import spotube.composeapp.generated.resources.Res
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_description
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_subtitle
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_title
|
||||||
|
import spotube.composeapp.generated.resources.jam_name_too_long
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Your name" card for Group Jam. Shared by the Jam screen and Jam settings so both stay
|
||||||
|
* identical, like the remote control device name card.
|
||||||
|
*
|
||||||
|
* [customName] is what the user saved (blank = none), [defaultName] the generated one.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun JamNameSettingCard(
|
||||||
|
customName: String,
|
||||||
|
defaultName: String,
|
||||||
|
onNameSaved: (String) -> Unit,
|
||||||
|
enabled: Boolean = true
|
||||||
|
) {
|
||||||
|
val tooLong = stringResource(Res.string.jam_name_too_long, JamNames.MAX_LENGTH)
|
||||||
|
TextInputSettingCard(
|
||||||
|
title = stringResource(Res.string.jam_name_title),
|
||||||
|
subtitle = stringResource(Res.string.jam_name_subtitle, customName.ifBlank { defaultName }),
|
||||||
|
icon = { SettingsItemIcon(Iconsax.IconsaxEdit, stringResource(Res.string.jam_name_title)) },
|
||||||
|
value = customName,
|
||||||
|
dialogDescription = stringResource(Res.string.jam_name_description, defaultName),
|
||||||
|
placeholder = defaultName,
|
||||||
|
normalize = JamNames::normalize,
|
||||||
|
validate = { value -> if (value.length > JamNames.MAX_LENGTH) tooLong else null },
|
||||||
|
onValueSaved = onNameSaved,
|
||||||
|
enabled = enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class JamNamesTest {
|
||||||
|
@Test
|
||||||
|
fun sameSeedSameName() = runTest {
|
||||||
|
assertEquals(JamNames.generate("device-a"), JamNames.generate("device-a"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun namesAreTwoFriendlyWordsAndVaried() = runTest {
|
||||||
|
val names = List(60) { JamNames.generate("device-$it") }
|
||||||
|
assertTrue(names.all { Regex("^[A-Z][a-z]+ [A-Z][a-z]+$").matches(it) }, names.toString())
|
||||||
|
assertTrue(names.all { it.length <= JamNames.MAX_LENGTH })
|
||||||
|
assertTrue(names.toSet().size >= 50, "only ${names.toSet().size} distinct names")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun typedNamesAreCleaned() {
|
||||||
|
assertEquals("Kingkor Roy", JamNames.normalize(" Kingkor \u0000 \t Roy \n"))
|
||||||
|
assertEquals("", JamNames.normalize(" "))
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertNotEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class JamSecurityTest {
|
||||||
|
private fun item(trackId: String = "t1", url: String = "", coverUrl: String = "https://i.example/c.jpg") =
|
||||||
|
JamMediaItem(url = url, trackId = trackId, title = "Song", artist = "A", album = "B",
|
||||||
|
durationMs = 1_000, coverUrl = coverUrl, protocol = "PROGRESSIVE")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun peerItemsWithoutATrackIdAreDropped() {
|
||||||
|
assertNull(item(trackId = "", url = "http://attacker.example/track.mp3").sanitized())
|
||||||
|
assertNull(item(trackId = "", url = "file:///etc/passwd").sanitized())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun peerItemsLoseUrlsAndNonHttpsCovers() {
|
||||||
|
val clean = item(url = "http://attacker.example/x", coverUrl = "http://attacker.example/pixel.png").sanitized()!!
|
||||||
|
assertEquals("", clean.url)
|
||||||
|
assertEquals("", clean.coverUrl)
|
||||||
|
assertEquals("https://i.example/c.jpg", item().sanitized()!!.coverUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun peerTextIsBounded() {
|
||||||
|
val clean = item().copy(title = "x".repeat(10_000), durationMs = -5).sanitized()!!
|
||||||
|
assertTrue(clean.title.length <= 300)
|
||||||
|
assertEquals(0, clean.durationMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun localTrackKeysDontRevealPaths() {
|
||||||
|
val key = JamMediaItem.localTrackKey("/home/alice/Music/song.mp3")
|
||||||
|
assertFalse("alice" in key || "Music" in key)
|
||||||
|
assertEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/song.mp3"))
|
||||||
|
assertNotEquals(key, JamMediaItem.localTrackKey("/home/alice/Music/other.mp3"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun clientKeysAreLongRandomAndAcceptedByTheServer() {
|
||||||
|
val keys = List(100) { JamRoomCode.newClientKey() }
|
||||||
|
assertEquals(keys.size, keys.toSet().size)
|
||||||
|
// Mirrors CLIENT_KEY in jam-server/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 jam-server/src/protocol.ts.
|
||||||
|
assertTrue(Regex("^[A-Za-z0-9_-]{16,64}$").matches(digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun roomCodesAreValid() {
|
||||||
|
repeat(200) { assertTrue(JamRoomCode.isValid(JamRoomCode.generate())) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,36 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import platform.Foundation.NSLocale
|
||||||
|
import platform.Foundation.NSTimeZone
|
||||||
|
import platform.Foundation.currentLocale
|
||||||
|
import platform.Foundation.localTimeZone
|
||||||
|
import platform.Foundation.localeIdentifier
|
||||||
|
import platform.UIKit.UIDevice
|
||||||
|
|
||||||
|
internal actual fun jamDeviceTraits(): List<String> {
|
||||||
|
val device = UIDevice.currentDevice
|
||||||
|
return listOf(
|
||||||
|
"ios",
|
||||||
|
device.model,
|
||||||
|
device.systemName(),
|
||||||
|
device.systemVersion,
|
||||||
|
NSLocale.currentLocale.localeIdentifier,
|
||||||
|
NSTimeZone.localTimeZone.name)
|
||||||
|
}
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.spotube.spotube.core.jam
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import java.util.TimeZone
|
||||||
|
|
||||||
|
internal actual fun jamDeviceTraits(): List<String> = listOf(
|
||||||
|
"desktop",
|
||||||
|
System.getProperty("os.name").orEmpty(),
|
||||||
|
System.getProperty("os.version").orEmpty(),
|
||||||
|
System.getProperty("os.arch").orEmpty(),
|
||||||
|
Runtime.getRuntime().availableProcessors().toString(),
|
||||||
|
Locale.getDefault().toLanguageTag(),
|
||||||
|
TimeZone.getDefault().id)
|
||||||
@ -46,8 +46,9 @@ class JamRoomClientIntegrationTest {
|
|||||||
try {
|
try {
|
||||||
host.testConnection(url).getOrThrow()
|
host.testConnection(url).getOrThrow()
|
||||||
|
|
||||||
val hostWelcome = host.connect(url, code, "it-host-1", "Host", create = true).getOrThrow()
|
val hostWelcome = host.connect(url, code, JamRoomCode.newClientKey(), "Host", create = true).getOrThrow()
|
||||||
assertEquals("it-host-1", hostWelcome.host)
|
val hostId = hostWelcome.you
|
||||||
|
assertEquals(hostId, hostWelcome.host)
|
||||||
// Same machine: the clock offset is just measurement noise.
|
// Same machine: the clock offset is just measurement noise.
|
||||||
assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}")
|
assertTrue(abs(host.clock.offsetMs!!) < 50, "offset ${host.clock.offsetMs}")
|
||||||
|
|
||||||
@ -59,22 +60,24 @@ class JamRoomClientIntegrationTest {
|
|||||||
Thread.sleep(300)
|
Thread.sleep(300)
|
||||||
|
|
||||||
// Late joiners get the retained queue and anchor in the welcome.
|
// Late joiners get the retained queue and anchor in the welcome.
|
||||||
val guestWelcome = guest.connect(url, code, "it-guest-1", "Guest", create = false).getOrThrow()
|
val guestKey = JamRoomCode.newClientKey()
|
||||||
|
val guestWelcome = guest.connect(url, code, guestKey, "Guest", create = false).getOrThrow()
|
||||||
|
val guestId = guestWelcome.you
|
||||||
assertEquals(queue, guestWelcome.queue)
|
assertEquals(queue, guestWelcome.queue)
|
||||||
assertEquals(anchor, guestWelcome.anchor)
|
assertEquals(anchor, guestWelcome.anchor)
|
||||||
assertEquals(listOf("it-host-1", "it-guest-1"), guestWelcome.participants.map { it.id })
|
assertEquals(listOf(hostId, guestId), guestWelcome.participants.map { it.id })
|
||||||
|
|
||||||
val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance<JamRoomEvent.Command>().first() }
|
val received = async(start = CoroutineStart.UNDISPATCHED) { host.events.filterIsInstance<JamRoomEvent.Command>().first() }
|
||||||
guest.sendCommand(JamCommand.SeekTo(42_000))
|
guest.sendCommand(JamCommand.SeekTo(42_000))
|
||||||
val command = withTimeout(5_000) { received.await() }
|
val command = withTimeout(5_000) { received.await() }
|
||||||
assertEquals("it-guest-1", command.from)
|
assertEquals(guestId, command.from)
|
||||||
assertEquals(JamCommand.SeekTo(42_000), command.command)
|
assertEquals(JamCommand.SeekTo(42_000), command.command)
|
||||||
|
|
||||||
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance<JamRoomEvent.Closed>().first() }
|
val closed = async(start = CoroutineStart.UNDISPATCHED) { guest.events.filterIsInstance<JamRoomEvent.Closed>().first() }
|
||||||
host.kick("it-guest-1", ban = false)
|
host.kick(guestId, ban = false)
|
||||||
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
|
assertEquals("kicked", withTimeout(5_000) { closed.await() }.code)
|
||||||
|
|
||||||
val wrongRoom = guest.connect(url, JamRoomCode.generate(), "it-guest-1", "Guest", create = false)
|
val wrongRoom = guest.connect(url, JamRoomCode.generate(), guestKey, "Guest", create = false)
|
||||||
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
|
assertEquals("room_not_found", (wrongRoom.exceptionOrNull() as JamRoomException).code)
|
||||||
} finally {
|
} finally {
|
||||||
host.disconnect()
|
host.disconnect()
|
||||||
|
|||||||
@ -130,10 +130,17 @@ class JamSessionSimulationTest {
|
|||||||
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value)
|
assertEquals(PlayerState.PLAYING, guest.player.playerStateFlow.value)
|
||||||
assertInSync(host, guest, "after the skip")
|
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.
|
// Steady playback costs (almost) nothing: count the anchors a listener receives.
|
||||||
val listener = JamRoomClient(http)
|
val listener = JamRoomClient(http)
|
||||||
try {
|
try {
|
||||||
listener.connect(url, code, "it-listener", "Listener", create = false).getOrThrow()
|
listener.connect(url, code, JamRoomCode.newClientKey(), "Listener", create = false).getOrThrow()
|
||||||
delay(1_000) // The host answers the join with an anchor.
|
delay(1_000) // The host answers the join with an anchor.
|
||||||
var anchors = 0
|
var anchors = 0
|
||||||
val counting = launch {
|
val counting = launch {
|
||||||
|
|||||||
@ -35,6 +35,51 @@ A four-person room sends roughly 200 messages an hour (about 10 billed requests)
|
|||||||
The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is
|
The wire protocol is in [`src/protocol.ts`](src/protocol.ts). The app side is
|
||||||
`composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`.
|
`composeApp/src/commonMain/kotlin/org/spotube/spotube/core/jam/`.
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
Anyone who knows a room code can join it, as with any party-code app. Everything else
|
||||||
|
is enforced here, not in the app:
|
||||||
|
|
||||||
|
- **Identity**: each install keeps a random secret, and its key for a room is derived
|
||||||
|
from that secret and the room code. Everyone else only sees an id derived from the key
|
||||||
|
(`participantId`, different in every room). Knowing someone's id, the host's included,
|
||||||
|
doesn't let you connect as them, so nobody can take over hosting, kick people or replace
|
||||||
|
someone's connection.
|
||||||
|
- **Host powers**: only the current host can set the queue and anchor or kick and ban.
|
||||||
|
Guests can only send commands, and those go to the host, which decides what to apply.
|
||||||
|
- **Taking over rooms**: "create" only works on a fresh code, or for the room's own host
|
||||||
|
bringing back a room that expired while everyone was offline.
|
||||||
|
- **Abuse and cost**: connects are rate-limited per IP (60 a minute; also slows down code
|
||||||
|
guessing). Each socket has a message budget and a queue budget (`FRAME_BUDGET`,
|
||||||
|
`QUEUE_BUDGET`), and a client that floods is disconnected with `rate_limited`.
|
||||||
|
Frames over 512 KB are refused, and names are cleaned of control characters.
|
||||||
|
- **What the app does with peer data**: queue items and suggestions from other devices
|
||||||
|
are cleaned before use (`JamMediaItem.sanitized`): streaming tracks only, no URLs, only
|
||||||
|
https covers. The host credits suggestions to the sender the server reports, not to
|
||||||
|
whatever name the sender claims. The host's local files go out as an opaque id, never
|
||||||
|
as a path.
|
||||||
|
- **Privacy**: participants never see each other's IP addresses, since everything goes
|
||||||
|
through the room. The room keeps no history: the queue and bans are deleted 10 minutes
|
||||||
|
after the last person leaves.
|
||||||
|
|
||||||
|
Known limits:
|
||||||
|
|
||||||
|
- **Bans**: a ban covers the identity (the same install gets the same identity in a
|
||||||
|
room every time) and the device. The app sends a digest of stable device traits
|
||||||
|
(model, OS version, locale, …) hashed with the room code. The server hashes that again
|
||||||
|
with the network (IPv4 address or IPv6 /64), so the room only stores an opaque value
|
||||||
|
that means nothing outside it. Someone who reinstalls and rejoins from the same device
|
||||||
|
and network is refused. People who were already in the room aren't affected, so a
|
||||||
|
friend on the same Wi-Fi with the same phone is never caught by someone else's ban.
|
||||||
|
Ways around it: a different device or network, or a modified client. A new arrival
|
||||||
|
with exactly the same phone, OS version and settings on the banned person's network
|
||||||
|
would also be refused.
|
||||||
|
- **Room codes**: six characters, about a billion codes. That's fine against guessing
|
||||||
|
at the rate limit, but a code someone sees on your screen lets them in.
|
||||||
|
- **Spending**: on the paid plan, a determined attacker with many IPs can still run up
|
||||||
|
usage. Set a usage alert in the Cloudflare dashboard. The free plan just stops at its
|
||||||
|
daily limit (00:00 UTC reset) instead of billing.
|
||||||
|
|
||||||
## Develop
|
## Develop
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { CLIENT_ID, MAX_NAME_LENGTH, PROTOCOL_VERSION, ROOM_CODE } from "./protocol";
|
import { CLIENT_KEY, DEVICE_DIGEST, PROTOCOL_VERSION, ROOM_CODE, cleanName, deviceMark, participantId } from "./protocol";
|
||||||
|
|
||||||
export { JamRoom } from "./room";
|
export { JamRoom } from "./room";
|
||||||
|
|
||||||
@ -7,7 +7,10 @@ const JSON_HEADERS = { "content-type": "application/json" };
|
|||||||
/**
|
/**
|
||||||
* Routes:
|
* Routes:
|
||||||
* - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection")
|
* - `GET /v1/health` → `{ ok, v, now }` (used by the app's "Test connection")
|
||||||
* - `GET /v1/rooms/{CODE}?id=&name=&mode=create|join` (WebSocket) → the room's Durable Object
|
* - `GET /v1/rooms/{CODE}?key=&name=&mode=create|join[&dev=]` (WebSocket) → the room's Durable Object
|
||||||
|
*
|
||||||
|
* Durable Objects aren't reachable from the internet, only through this Worker, so the
|
||||||
|
* room trusts the `id` and `name` this Worker hands it.
|
||||||
*/
|
*/
|
||||||
export default {
|
export default {
|
||||||
async fetch(request, env): Promise<Response> {
|
async fetch(request, env): Promise<Response> {
|
||||||
@ -25,10 +28,17 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const code = match[1].toUpperCase();
|
const code = match[1].toUpperCase();
|
||||||
const id = url.searchParams.get("id") ?? "";
|
const key = url.searchParams.get("key") ?? "";
|
||||||
const name = (url.searchParams.get("name") ?? "").trim().slice(0, MAX_NAME_LENGTH);
|
const name = cleanName(url.searchParams.get("name") ?? "");
|
||||||
const mode = url.searchParams.get("mode");
|
const mode = url.searchParams.get("mode");
|
||||||
if (!ROOM_CODE.test(code) || !CLIENT_ID.test(id) || !name || (mode !== "create" && mode !== "join")) {
|
const dev = url.searchParams.get("dev");
|
||||||
|
if (
|
||||||
|
!ROOM_CODE.test(code) ||
|
||||||
|
!CLIENT_KEY.test(key) ||
|
||||||
|
!name ||
|
||||||
|
(mode !== "create" && mode !== "join") ||
|
||||||
|
(dev !== null && !DEVICE_DIGEST.test(dev))
|
||||||
|
) {
|
||||||
return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS });
|
return new Response(JSON.stringify({ error: "bad_request" }), { status: 400, headers: JSON_HEADERS });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -37,7 +47,12 @@ export default {
|
|||||||
if (!success) return new Response("Too many connections", { status: 429 });
|
if (!success) return new Response("Too many connections", { status: 429 });
|
||||||
|
|
||||||
const forwarded = new URL(request.url);
|
const forwarded = new URL(request.url);
|
||||||
forwarded.search = new URLSearchParams({ id, name, mode }).toString();
|
forwarded.search = new URLSearchParams({
|
||||||
|
id: await participantId(code, key),
|
||||||
|
name,
|
||||||
|
mode,
|
||||||
|
mark: (await deviceMark(code, dev, ip)) ?? "",
|
||||||
|
}).toString();
|
||||||
const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code));
|
const room = env.JAM_ROOM.get(env.JAM_ROOM.idFromName(code));
|
||||||
return room.fetch(new Request(forwarded, request));
|
return room.fetch(new Request(forwarded, request));
|
||||||
},
|
},
|
||||||
|
|||||||
@ -10,11 +10,30 @@ export const PROTOCOL_VERSION = 1;
|
|||||||
|
|
||||||
/** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */
|
/** Same alphabet as the app's `JamRoomCode` (no I, O, 0, 1). */
|
||||||
export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
export const ROOM_CODE = /^[ABCDEFGHJKLMNPQRSTUVWXYZ23456789]{6}$/;
|
||||||
export const CLIENT_ID = /^[A-Za-z0-9_-]{4,64}$/;
|
/**
|
||||||
|
* A participant's secret, chosen by the client (at least 128 random bits). The server
|
||||||
|
* never shares it: everyone else only sees the id derived from it ([participantId]),
|
||||||
|
* so knowing someone's id doesn't let you reconnect as them.
|
||||||
|
*/
|
||||||
|
export const CLIENT_KEY = /^[A-Za-z0-9_-]{22,64}$/;
|
||||||
export const MAX_NAME_LENGTH = 40;
|
export const MAX_NAME_LENGTH = 40;
|
||||||
export const MAX_PARTICIPANTS = 32;
|
export const MAX_PARTICIPANTS = 32;
|
||||||
/** A long queue with cover URLs is the largest frame, a few hundred KB at most. */
|
/** A long queue with cover URLs is the largest frame, a few hundred KB at most. */
|
||||||
export const MAX_FRAME_BYTES = 512 * 1024;
|
export const MAX_FRAME_BYTES = 512 * 1024;
|
||||||
|
export const MAX_BANS = 256;
|
||||||
|
/** Everyone who has been in the room, to tell a returning member from a new arrival. */
|
||||||
|
export const MAX_MEMBERS = 512;
|
||||||
|
|
||||||
|
/** Optional per-room device digest from the client (it never sends device details). */
|
||||||
|
export const DEVICE_DIGEST = /^[A-Za-z0-9_-]{16,64}$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-socket token buckets. Every incoming message is billed, so a client that floods
|
||||||
|
* is disconnected. Real clients stay far below: a few frames a minute, bursts of a
|
||||||
|
* dozen when skipping quickly, and the host sends at most one queue a second.
|
||||||
|
*/
|
||||||
|
export const FRAME_BUDGET = { burst: 40, perSecond: 6 };
|
||||||
|
export const QUEUE_BUDGET = { burst: 5, perSecond: 1 };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A host that drops keeps the room for this long before someone else takes over (long
|
* A host that drops keeps the room for this long before someone else takes over (long
|
||||||
@ -70,7 +89,8 @@ export type ErrorCode =
|
|||||||
| "banned"
|
| "banned"
|
||||||
| "kicked"
|
| "kicked"
|
||||||
| "replaced"
|
| "replaced"
|
||||||
| "frame_too_large";
|
| "frame_too_large"
|
||||||
|
| "rate_limited";
|
||||||
|
|
||||||
/** WebSocket close codes the app maps back to an [ErrorCode]. */
|
/** WebSocket close codes the app maps back to an [ErrorCode]. */
|
||||||
export const CLOSE_CODES: Record<ErrorCode, number> = {
|
export const CLOSE_CODES: Record<ErrorCode, number> = {
|
||||||
@ -82,4 +102,42 @@ export const CLOSE_CODES: Record<ErrorCode, number> = {
|
|||||||
kicked: 4008,
|
kicked: 4008,
|
||||||
replaced: 4009,
|
replaced: 4009,
|
||||||
frame_too_large: 4013,
|
frame_too_large: 4013,
|
||||||
|
rate_limited: 4029,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** First 96 bits of SHA-256, base64url. */
|
||||||
|
async function shortHash(text: string): Promise<string> {
|
||||||
|
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
|
||||||
|
return btoa(String.fromCharCode(...new Uint8Array(digest).slice(0, 12)))
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The public id for [key] in room [code]: a different id per room, so ids can't link rooms. */
|
||||||
|
export function participantId(code: string, key: string): Promise<string> {
|
||||||
|
return shortHash(`${code}:${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a ban remembers about a device besides its participant id: the client's device
|
||||||
|
* digest combined with its network, hashed again here so the room only ever stores an
|
||||||
|
* opaque value. Null without a digest.
|
||||||
|
*/
|
||||||
|
export function deviceMark(code: string, digest: string | null, ip: string): Promise<string> | null {
|
||||||
|
return digest ? shortHash(`${code}:${digest}:${networkOf(ip)}`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An IPv4 address, or an IPv6 /64 (one home or phone gets a whole /64). */
|
||||||
|
export function networkOf(ip: string): string {
|
||||||
|
if (!ip.includes(":")) return ip;
|
||||||
|
const [head, tail] = ip.split("::");
|
||||||
|
const left = head ? head.split(":") : [];
|
||||||
|
const right = tail ? tail.split(":") : [];
|
||||||
|
const groups = tail === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill("0"), ...right];
|
||||||
|
return groups.slice(0, 4).map((g) => parseInt(g || "0", 16).toString(16)).join(":") + "::/64";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Names are shown to everyone: no control or formatting characters, no padding. */
|
||||||
|
export function cleanName(raw: string): string {
|
||||||
|
return raw.replace(/[\p{C}]/gu, "").replace(/\s+/gu, " ").trim().slice(0, MAX_NAME_LENGTH);
|
||||||
|
}
|
||||||
|
|||||||
@ -4,10 +4,14 @@ import {
|
|||||||
KEEPALIVE_PING,
|
KEEPALIVE_PING,
|
||||||
KEEPALIVE_PONG,
|
KEEPALIVE_PONG,
|
||||||
EMPTY_ROOM_TTL_MS,
|
EMPTY_ROOM_TTL_MS,
|
||||||
|
FRAME_BUDGET,
|
||||||
HOST_GRACE_MS,
|
HOST_GRACE_MS,
|
||||||
|
MAX_BANS,
|
||||||
|
MAX_MEMBERS,
|
||||||
MAX_FRAME_BYTES,
|
MAX_FRAME_BYTES,
|
||||||
MAX_PARTICIPANTS,
|
MAX_PARTICIPANTS,
|
||||||
PROTOCOL_VERSION,
|
PROTOCOL_VERSION,
|
||||||
|
QUEUE_BUDGET,
|
||||||
type ClientFrame,
|
type ClientFrame,
|
||||||
type ErrorCode,
|
type ErrorCode,
|
||||||
type JoinMode,
|
type JoinMode,
|
||||||
@ -15,17 +19,35 @@ import {
|
|||||||
type ServerFrame,
|
type ServerFrame,
|
||||||
} from "./protocol";
|
} from "./protocol";
|
||||||
|
|
||||||
|
interface Bucket {
|
||||||
|
tokens: number;
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Takes one token; false when [bucket] is empty. */
|
||||||
|
function spend(bucket: Bucket, budget: { burst: number; perSecond: number }, now: number): boolean {
|
||||||
|
bucket.tokens = Math.min(budget.burst, bucket.tokens + ((now - bucket.at) / 1000) * budget.perSecond);
|
||||||
|
bucket.at = now;
|
||||||
|
if (bucket.tokens < 1) return false;
|
||||||
|
bucket.tokens -= 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** Kept on each socket so it survives hibernation. */
|
/** Kept on each socket so it survives hibernation. */
|
||||||
interface Attachment {
|
interface Attachment {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
joined: number;
|
joined: number;
|
||||||
|
/** See [deviceMark]; empty when the client sent no device digest. */
|
||||||
|
mark: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enum Key {
|
const enum Key {
|
||||||
Host = "host",
|
Host = "host",
|
||||||
Queue = "queue",
|
Queue = "queue",
|
||||||
Banned = "banned",
|
Banned = "banned",
|
||||||
|
BannedMarks = "bannedMarks",
|
||||||
|
Members = "members",
|
||||||
HostGoneAt = "hostGoneAt",
|
HostGoneAt = "hostGoneAt",
|
||||||
EmptySince = "emptySince",
|
EmptySince = "emptySince",
|
||||||
}
|
}
|
||||||
@ -46,6 +68,10 @@ const enum Key {
|
|||||||
*/
|
*/
|
||||||
export class JamRoom extends DurableObject<Env> {
|
export class JamRoom extends DurableObject<Env> {
|
||||||
private anchor: unknown = null;
|
private anchor: unknown = null;
|
||||||
|
// In memory: a room that hibernated starts everyone with a full budget again, which
|
||||||
|
// only matters to a client that floods, and that one wakes the room right away.
|
||||||
|
private frameBudgets = new WeakMap<WebSocket, Bucket>();
|
||||||
|
private queueBudgets = new WeakMap<WebSocket, Bucket>();
|
||||||
|
|
||||||
constructor(ctx: DurableObjectState, env: Env) {
|
constructor(ctx: DurableObjectState, env: Env) {
|
||||||
super(ctx, env);
|
super(ctx, env);
|
||||||
@ -57,16 +83,17 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
const id = url.searchParams.get("id")!;
|
const id = url.searchParams.get("id")!;
|
||||||
const name = url.searchParams.get("name")!;
|
const name = url.searchParams.get("name")!;
|
||||||
const mode = url.searchParams.get("mode") as JoinMode;
|
const mode = url.searchParams.get("mode") as JoinMode;
|
||||||
|
const mark = url.searchParams.get("mark") ?? "";
|
||||||
|
|
||||||
const { 0: client, 1: server } = new WebSocketPair();
|
const { 0: client, 1: server } = new WebSocketPair();
|
||||||
const previous = this.ctx.getWebSockets(id);
|
const previous = this.ctx.getWebSockets(id);
|
||||||
this.ctx.acceptWebSocket(server, [id]);
|
this.ctx.acceptWebSocket(server, [id]);
|
||||||
server.serializeAttachment({ id, name, joined: Date.now() } satisfies Attachment);
|
server.serializeAttachment({ id, name, joined: Date.now(), mark } satisfies Attachment);
|
||||||
|
|
||||||
// A reconnect with the same id (network change, app restart) takes over the old socket.
|
// A reconnect with the same id (network change, app restart) takes over the old socket.
|
||||||
for (const ws of previous) this.reject(ws, "replaced");
|
for (const ws of previous) this.reject(ws, "replaced");
|
||||||
|
|
||||||
const refusal = await this.admit(id, mode);
|
const refusal = await this.admit(id, mode, mark);
|
||||||
if (refusal) {
|
if (refusal) {
|
||||||
this.reject(server, refusal);
|
this.reject(server, refusal);
|
||||||
} else {
|
} else {
|
||||||
@ -94,6 +121,7 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
const size = typeof message === "string" ? message.length : message.byteLength;
|
const size = typeof message === "string" ? message.length : message.byteLength;
|
||||||
if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large");
|
if (size > MAX_FRAME_BYTES) return this.reject(ws, "frame_too_large");
|
||||||
if (typeof message !== "string") return;
|
if (typeof message !== "string") return;
|
||||||
|
if (!this.withinBudget(this.frameBudgets, ws, FRAME_BUDGET)) return this.reject(ws, "rate_limited");
|
||||||
|
|
||||||
let frame: ClientFrame;
|
let frame: ClientFrame;
|
||||||
try {
|
try {
|
||||||
@ -110,6 +138,7 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
|
|
||||||
case "queue":
|
case "queue":
|
||||||
if (!(await this.isHost(sender.id))) return;
|
if (!(await this.isHost(sender.id))) return;
|
||||||
|
if (!this.withinBudget(this.queueBudgets, ws, QUEUE_BUDGET)) return this.reject(ws, "rate_limited");
|
||||||
await this.ctx.storage.put(Key.Queue, frame.body);
|
await this.ctx.storage.put(Key.Queue, frame.body);
|
||||||
this.broadcast({ t: "queue", body: frame.body }, ws);
|
this.broadcast({ t: "queue", body: frame.body }, ws);
|
||||||
return;
|
return;
|
||||||
@ -133,12 +162,7 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
|
|
||||||
case "kick": {
|
case "kick": {
|
||||||
if (!(await this.isHost(sender.id)) || frame.target === sender.id) return;
|
if (!(await this.isHost(sender.id)) || frame.target === sender.id) return;
|
||||||
if (frame.ban) {
|
if (frame.ban) await this.ban(frame.target);
|
||||||
const banned = (await this.ctx.storage.get<string[]>(Key.Banned)) ?? [];
|
|
||||||
if (!banned.includes(frame.target)) {
|
|
||||||
await this.ctx.storage.put(Key.Banned, [...banned, frame.target]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const target of this.ctx.getWebSockets(frame.target)) {
|
for (const target of this.ctx.getWebSockets(frame.target)) {
|
||||||
this.reject(target, frame.ban ? "banned" : "kicked");
|
this.reject(target, frame.ban ? "banned" : "kicked");
|
||||||
}
|
}
|
||||||
@ -183,16 +207,32 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
|
|
||||||
// ---------- Membership ----------
|
// ---------- Membership ----------
|
||||||
|
|
||||||
private async admit(id: string, mode: JoinMode): Promise<ErrorCode | null> {
|
/**
|
||||||
const others = this.live().filter((ws) => this.attachment(ws).id !== id);
|
* Bans [id], and the device it's on: someone who comes back with a fresh identity from
|
||||||
|
* the same device and network is refused too, unless they were already a member.
|
||||||
|
*/
|
||||||
|
private async ban(id: string): Promise<void> {
|
||||||
|
const marks = this.ctx
|
||||||
|
.getWebSockets(id)
|
||||||
|
.map((ws) => this.attachment(ws).mark)
|
||||||
|
.filter((mark) => mark);
|
||||||
|
const [banned, bannedMarks] = await Promise.all([
|
||||||
|
this.ctx.storage.get<string[]>(Key.Banned),
|
||||||
|
this.ctx.storage.get<string[]>(Key.BannedMarks),
|
||||||
|
]);
|
||||||
|
await this.ctx.storage.put({
|
||||||
|
[Key.Banned]: [...new Set([...(banned ?? []), id])].slice(-MAX_BANS),
|
||||||
|
[Key.BannedMarks]: [...new Set([...(bannedMarks ?? []), ...marks])].slice(-MAX_BANS),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (mode === "create") {
|
private async admit(id: string, mode: JoinMode, mark: string): Promise<ErrorCode | null> {
|
||||||
if (others.length > 0) return "room_taken";
|
const others = this.live().filter((ws) => this.attachment(ws).id !== id);
|
||||||
// Leftovers of an expired-but-not-yet-cleaned room must not leak into a new one.
|
// An empty room past its TTL whose cleanup alarm hasn't run yet is gone.
|
||||||
|
const emptySince = await this.ctx.storage.get<number>(Key.EmptySince);
|
||||||
|
if (others.length === 0 && emptySince !== undefined && Date.now() - emptySince >= EMPTY_ROOM_TTL_MS) {
|
||||||
await this.ctx.storage.deleteAll();
|
await this.ctx.storage.deleteAll();
|
||||||
this.anchor = null;
|
this.anchor = null;
|
||||||
await this.ctx.storage.put(Key.Host, id);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const [host, banned, queue] = await Promise.all([
|
const [host, banned, queue] = await Promise.all([
|
||||||
@ -200,9 +240,27 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
this.ctx.storage.get<string[]>(Key.Banned),
|
this.ctx.storage.get<string[]>(Key.Banned),
|
||||||
this.ctx.storage.get(Key.Queue),
|
this.ctx.storage.get(Key.Queue),
|
||||||
]);
|
]);
|
||||||
if (host === undefined && queue === undefined && others.length === 0) return "room_not_found";
|
const exists = host !== undefined || queue !== undefined || others.length > 0;
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
// Only a fresh code, or the room's own host bringing it back. Anyone else
|
||||||
|
// "creating" an existing room (even an empty one) would take it over.
|
||||||
|
if (others.length > 0 || (exists && host !== id)) return "room_taken";
|
||||||
|
if (!exists) await this.ctx.storage.put({ [Key.Host]: id, [Key.Members]: [id] });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!exists) return "room_not_found";
|
||||||
if (banned?.includes(id)) return "banned";
|
if (banned?.includes(id)) return "banned";
|
||||||
|
const members = (await this.ctx.storage.get<string[]>(Key.Members)) ?? [];
|
||||||
|
const isMember = members.includes(id);
|
||||||
|
// A member who was in the room before a ban (a friend on the same Wi-Fi with the
|
||||||
|
// same phone, say) is never caught by another person's device ban.
|
||||||
|
if (mark && !isMember && (await this.ctx.storage.get<string[]>(Key.BannedMarks))?.includes(mark)) {
|
||||||
|
return "banned";
|
||||||
|
}
|
||||||
if (others.length >= MAX_PARTICIPANTS) return "room_full";
|
if (others.length >= MAX_PARTICIPANTS) return "room_full";
|
||||||
|
if (!isMember) await this.ctx.storage.put(Key.Members, [...members, id].slice(-MAX_MEMBERS));
|
||||||
|
|
||||||
// Joining a room whose host is gone (and whose grace period is over) promotes someone.
|
// Joining a room whose host is gone (and whose grace period is over) promotes someone.
|
||||||
const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host);
|
const hostLive = host === id || others.some((ws) => this.attachment(ws).id === host);
|
||||||
@ -276,6 +334,20 @@ export class JamRoom extends DurableObject<Env> {
|
|||||||
.map(({ id, name }) => ({ id, name }));
|
.map(({ id, name }) => ({ id, name }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private withinBudget(
|
||||||
|
buckets: WeakMap<WebSocket, Bucket>,
|
||||||
|
ws: WebSocket,
|
||||||
|
budget: { burst: number; perSecond: number },
|
||||||
|
): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
let bucket = buckets.get(ws);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = { tokens: budget.burst, at: now };
|
||||||
|
buckets.set(ws, bucket);
|
||||||
|
}
|
||||||
|
return spend(bucket, budget, now);
|
||||||
|
}
|
||||||
|
|
||||||
private attachment(ws: WebSocket): Attachment {
|
private attachment(ws: WebSocket): Attachment {
|
||||||
return ws.deserializeAttachment() as Attachment;
|
return ws.deserializeAttachment() as Attachment;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,9 +6,12 @@ const BASE = process.env.JAM_URL ?? "http://127.0.0.1:8787";
|
|||||||
const WS_BASE = BASE.replace(/^http/, "ws");
|
const WS_BASE = BASE.replace(/^http/, "ws");
|
||||||
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
const ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
||||||
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
|
const code = () => Array.from({ length: 6 }, () => ALPHABET[Math.floor(Math.random() * ALPHABET.length)]).join("");
|
||||||
|
const newKey = () => crypto.randomUUID().replaceAll("-", "");
|
||||||
|
|
||||||
function connect(room, id, mode, name = id) {
|
/** [id] is only a label for errors; the server assigns the real id (welcome.you). */
|
||||||
const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?id=${id}&name=${encodeURIComponent(name)}&mode=${mode}`);
|
function connect(room, id, mode, name = id, key = newKey(), dev = null) {
|
||||||
|
const devParam = dev ? `&dev=${dev}` : "";
|
||||||
|
const ws = new WebSocket(`${WS_BASE}/v1/rooms/${room}?key=${key}&name=${encodeURIComponent(name)}&mode=${mode}${devParam}`);
|
||||||
const inbox = [];
|
const inbox = [];
|
||||||
const waiters = [];
|
const waiters = [];
|
||||||
ws.addEventListener("message", (e) => {
|
ws.addEventListener("message", (e) => {
|
||||||
@ -20,6 +23,7 @@ function connect(room, id, mode, name = id) {
|
|||||||
const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code)));
|
const closed = new Promise((resolve) => ws.addEventListener("close", (e) => resolve(e.code)));
|
||||||
return {
|
return {
|
||||||
ws,
|
ws,
|
||||||
|
key,
|
||||||
closed,
|
closed,
|
||||||
send: (frame) => ws.send(JSON.stringify(frame)),
|
send: (frame) => ws.send(JSON.stringify(frame)),
|
||||||
next(t, pred = () => true, timeoutMs = 5_000) {
|
next(t, pred = () => true, timeoutMs = 5_000) {
|
||||||
@ -46,12 +50,14 @@ await step("health", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const room = code();
|
const room = code();
|
||||||
const host = connect(room, "host-0001", "create", "Host");
|
const host = connect(room, "host", "create", "Host");
|
||||||
let guest;
|
let guest, hostId, guestId;
|
||||||
|
|
||||||
await step("host creates the room", async () => {
|
await step("host creates the room", async () => {
|
||||||
const welcome = await host.next("welcome");
|
const welcome = await host.next("welcome");
|
||||||
assert.equal(welcome.host, "host-0001");
|
hostId = welcome.you;
|
||||||
|
assert.match(hostId, /^[A-Za-z0-9_-]{16}$/);
|
||||||
|
assert.equal(welcome.host, hostId);
|
||||||
host.send({ t: "queue", body: { items: [1, 2, 3] } });
|
host.send({ t: "queue", body: { items: [1, 2, 3] } });
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -59,11 +65,13 @@ await step("guest joins and gets the retained queue", async () => {
|
|||||||
// The host's queue frame is processed before the guest's join is.
|
// The host's queue frame is processed before the guest's join is.
|
||||||
host.send({ t: "ping", id: 1, c: 0 });
|
host.send({ t: "ping", id: 1, c: 0 });
|
||||||
await host.next("pong");
|
await host.next("pong");
|
||||||
guest = connect(room, "guest-0001", "join", "Guest");
|
guest = connect(room, "guest", "join", "Guest\u202e\u0000 1");
|
||||||
const welcome = await guest.next("welcome");
|
const welcome = await guest.next("welcome");
|
||||||
assert.equal(welcome.host, "host-0001");
|
guestId = welcome.you;
|
||||||
|
assert.equal(welcome.host, hostId);
|
||||||
assert.deepEqual(welcome.queue, { items: [1, 2, 3] });
|
assert.deepEqual(welcome.queue, { items: [1, 2, 3] });
|
||||||
assert.ok(welcome.participants.some((p) => p.id === "guest-0001"));
|
const me = welcome.participants.find((p) => p.id === guestId);
|
||||||
|
assert.equal(me.name, "Guest 1", "control and formatting characters are stripped");
|
||||||
await host.next("presence", (f) => f.participants.length === 2);
|
await host.next("presence", (f) => f.participants.length === 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -95,37 +103,99 @@ await step("guest anchors are ignored", async () => {
|
|||||||
await step("guest commands reach the host", async () => {
|
await step("guest commands reach the host", async () => {
|
||||||
guest.send({ t: "cmd", body: { type: "skipNext" } });
|
guest.send({ t: "cmd", body: { type: "skipNext" } });
|
||||||
const cmd = await host.next("cmd");
|
const cmd = await host.next("cmd");
|
||||||
assert.equal(cmd.from, "guest-0001");
|
assert.equal(cmd.from, guestId);
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("nobody can take over the host's identity", async () => {
|
||||||
|
// The host's id is public; using it as a key yields a different id.
|
||||||
|
const impostor = connect(room, "impostor", "join", "Host", hostId.padEnd(22, "x"));
|
||||||
|
const welcome = await impostor.next("welcome");
|
||||||
|
assert.notEqual(welcome.you, hostId);
|
||||||
|
assert.equal(welcome.host, hostId);
|
||||||
|
impostor.send({ t: "kick", target: guestId, ban: true });
|
||||||
|
impostor.send({ t: "anchor", body: { positionMs: 666 } });
|
||||||
|
impostor.send({ t: "ping", id: 9, c: 0 });
|
||||||
|
await impostor.next("pong");
|
||||||
|
await assert.rejects(guest.next("anchor", () => true, 500), "an impostor's anchor isn't relayed");
|
||||||
|
impostor.ws.close();
|
||||||
|
await host.next("presence", (f) => f.participants.length === 2);
|
||||||
});
|
});
|
||||||
|
|
||||||
await step("a taken code can't be created again", async () => {
|
await step("a taken code can't be created again", async () => {
|
||||||
const intruder = connect(room, "intruder-1", "create");
|
const intruder = connect(room, "intruder", "create");
|
||||||
const error = await intruder.next("error");
|
const error = await intruder.next("error");
|
||||||
assert.equal(error.code, "room_taken");
|
assert.equal(error.code, "room_taken");
|
||||||
assert.equal(await intruder.closed, 4001);
|
assert.equal(await intruder.closed, 4001);
|
||||||
});
|
});
|
||||||
|
|
||||||
await step("unknown rooms are not found", async () => {
|
await step("unknown rooms are not found", async () => {
|
||||||
const lost = connect(code(), "lost-0001", "join");
|
const lost = connect(code(), "lost", "join");
|
||||||
assert.equal((await lost.next("error")).code, "room_not_found");
|
assert.equal((await lost.next("error")).code, "room_not_found");
|
||||||
});
|
});
|
||||||
|
|
||||||
await step("ban closes the socket and blocks rejoining", async () => {
|
await step("ban closes the socket and blocks rejoining", async () => {
|
||||||
host.send({ t: "kick", target: "guest-0001", ban: true });
|
host.send({ t: "kick", target: guestId, ban: true });
|
||||||
assert.equal((await guest.next("error")).code, "banned");
|
assert.equal((await guest.next("error")).code, "banned");
|
||||||
assert.equal(await guest.closed, 4003);
|
assert.equal(await guest.closed, 4003);
|
||||||
const again = connect(room, "guest-0001", "join");
|
const again = connect(room, "guest", "join", "Guest", guest.key);
|
||||||
assert.equal((await again.next("error")).code, "banned");
|
assert.equal((await again.next("error")).code, "banned");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await step("a ban covers the device, not just the identity", async () => {
|
||||||
|
const party = code();
|
||||||
|
const phone = newKey(); // The same device digest: same model, OS, locale…
|
||||||
|
const owner = connect(party, "owner", "create", "Owner", newKey(), newKey());
|
||||||
|
await owner.next("welcome");
|
||||||
|
// A friend with an identical phone, already in the room before the ban.
|
||||||
|
const friend = connect(party, "friend", "join", "Friend", newKey(), phone);
|
||||||
|
const friendId = (await friend.next("welcome")).you;
|
||||||
|
const troll = connect(party, "troll", "join", "Troll", newKey(), phone);
|
||||||
|
const trollId = (await troll.next("welcome")).you;
|
||||||
|
|
||||||
|
owner.send({ t: "kick", target: trollId, ban: true });
|
||||||
|
assert.equal((await troll.next("error")).code, "banned");
|
||||||
|
|
||||||
|
// Back with a fresh identity (reinstall) from the same device and network: refused.
|
||||||
|
const again = connect(party, "troll-again", "join", "Troll", newKey(), phone);
|
||||||
|
assert.equal((await again.next("error")).code, "banned");
|
||||||
|
// The friend can still reconnect, and someone on another device can join.
|
||||||
|
friend.ws.close();
|
||||||
|
const friendBack = connect(party, "friend", "join", "Friend", friend.key, phone);
|
||||||
|
assert.equal((await friendBack.next("welcome")).you, friendId);
|
||||||
|
const stranger = connect(party, "stranger", "join", "Stranger", newKey(), newKey());
|
||||||
|
await stranger.next("welcome");
|
||||||
|
for (const c of [owner, friendBack, stranger]) c.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
await step("a client that floods is cut off", async () => {
|
||||||
|
const flooder = connect(code(), "flooder", "create");
|
||||||
|
await flooder.next("welcome");
|
||||||
|
for (let i = 0; i < 100; i++) flooder.send({ t: "ping", id: i, c: 0 });
|
||||||
|
assert.equal(await flooder.closed, 4029);
|
||||||
|
});
|
||||||
|
|
||||||
await step("host hands over after the grace period", async () => {
|
await step("host hands over after the grace period", async () => {
|
||||||
const heir = connect(room, "heir-0001", "join", "Heir");
|
const heir = connect(room, "heir", "join", "Heir");
|
||||||
await heir.next("welcome");
|
const heirId = (await heir.next("welcome")).you;
|
||||||
host.ws.close();
|
host.ws.close();
|
||||||
const presence = await heir.next("presence", (f) => f.host === "heir-0001", 45_000);
|
const presence = await heir.next("presence", (f) => f.host === heirId, 45_000);
|
||||||
assert.deepEqual(presence.participants.map((p) => p.id), ["heir-0001"]);
|
assert.deepEqual(presence.participants.map((p) => p.id), [heirId]);
|
||||||
heir.ws.close();
|
heir.ws.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await step("an empty room can't be taken over, but its host can bring it back", async () => {
|
||||||
|
const other = code();
|
||||||
|
const owner = connect(other, "owner", "create");
|
||||||
|
await owner.next("welcome");
|
||||||
|
owner.ws.close();
|
||||||
|
await owner.closed;
|
||||||
|
const squatter = connect(other, "squatter", "create");
|
||||||
|
assert.equal((await squatter.next("error")).code, "room_taken");
|
||||||
|
const back = connect(other, "owner", "create", "Owner", owner.key);
|
||||||
|
const welcome = await back.next("welcome");
|
||||||
|
assert.equal(welcome.host, welcome.you);
|
||||||
|
back.ws.close();
|
||||||
|
});
|
||||||
|
|
||||||
console.log("all smoke tests passed");
|
console.log("all smoke tests passed");
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
|
|||||||
@ -8,7 +8,9 @@
|
|||||||
"bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }]
|
"bindings": [{ "name": "JAM_ROOM", "class_name": "JamRoom" }]
|
||||||
},
|
},
|
||||||
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }],
|
"migrations": [{ "tag": "v1", "new_sqlite_classes": ["JamRoom"] }],
|
||||||
// Room connects per client IP; stops code guessing from waking (and billing) rooms.
|
// Room connects per client IP: slows down code guessing and connection floods, which
|
||||||
|
// wake (and bill) rooms. Generous because carrier-grade NAT puts many phones behind
|
||||||
|
// one IP. Enforced per Cloudflare location, so approximate.
|
||||||
"ratelimits": [
|
"ratelimits": [
|
||||||
{ "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
|
{ "name": "CONNECT_LIMITER", "namespace_id": "1001", "simple": { "limit": 60, "period": 60 } }
|
||||||
]
|
]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user