mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
feat(remote): redesign remote control and devices screens, fix connection issues
- Remove the unused "Enable Connect" setting and its strings - Redesign RemoteControlScreen to match the expanded player: artwork sized to fit, side-by-side queue panel on wide screens, proper connecting / disconnected / error states, "Playing on <device>" subtitle - Seek and volume sliders keep local drag state and only commit on release (volume throttled while dragging) instead of sending a command per frame - Redesign DevicesScreen with the settings card components, a connected device card, error banner and empty state; ViewModels expose one UiState - Fix tapping an already-connected device doing nothing; reopen its remote screen instead, and switch devices by disconnecting first - Fix the controller getting stuck in Connecting after the host denies a request: Ktor never delivers Close frames to `incoming`, so detect the close reason after the receive loop ends - Fix a UI freeze when leaving the devices screen on desktop: run discovery (and JmDNS's blocking close/resolve) on Dispatchers.IO - Advertise the LAN address of the default-route interface on desktop; dns-sd-kt otherwise picks any site-local address, e.g. a Docker bridge
This commit is contained in:
parent
facc8ed0b9
commit
b38a4f6963
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.discovery
|
||||
|
||||
// The OS's own service-discovery stack advertises on the right network.
|
||||
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||
@ -225,8 +225,6 @@
|
||||
<string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string>
|
||||
<string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.</string>
|
||||
<string name="settings_enable_connect_title">تفعيل Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">منفذ خادم وكيل التشغيل</string>
|
||||
<string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string>
|
||||
<string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string>
|
||||
@ -511,7 +509,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">الأجهزة</string>
|
||||
<string name="devices_refresh">تحديث</string>
|
||||
<string name="devices_connected_to">متصل بـ %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">قطع الاتصال</string>
|
||||
<string name="devices_connecting">جارٍ الاتصال...</string>
|
||||
<string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string>
|
||||
@ -519,20 +516,13 @@
|
||||
<string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string>
|
||||
<string name="devices_unknown_device">جهاز غير معروف</string>
|
||||
<string name="devices_resolving">جارٍ التحديد...</string>
|
||||
<string name="devices_device_id">المعرّف: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">التحكم عن بُعد</string>
|
||||
<string name="devices_remote_disconnected">غير متصل</string>
|
||||
<string name="devices_remote_connection_error">خطأ في الاتصال: %1$s</string>
|
||||
<string name="devices_remote_album_cover">غلاف الألبوم</string>
|
||||
<string name="devices_remote_unknown_track">مقطع غير معروف</string>
|
||||
<string name="devices_remote_unknown_artist">فنان غير معروف</string>
|
||||
<string name="devices_remote_seek">التقديم والترجيع</string>
|
||||
<string name="devices_remote_shuffle">ترتيب عشوائي</string>
|
||||
<string name="devices_remote_previous">السابق</string>
|
||||
<string name="devices_remote_next">التالي</string>
|
||||
<string name="devices_remote_loop_mode">وضع التكرار</string>
|
||||
<string name="devices_remote_volume">مستوى الصوت</string>
|
||||
<string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string>
|
||||
<string name="devices_remote_more_options">خيارات إضافية</string>
|
||||
|
||||
@ -213,8 +213,6 @@
|
||||
<string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string>
|
||||
<string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।</string>
|
||||
<string name="settings_enable_connect_title">Connect চালু করুন</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন।</string>
|
||||
<string name="settings_playback_port_title">প্লেব্যাক প্রক্সি সার্ভার পোর্ট</string>
|
||||
<string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string>
|
||||
<string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string>
|
||||
@ -495,7 +493,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">ডিভাইস</string>
|
||||
<string name="devices_refresh">রিফ্রেশ</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d-এ কানেক্টেড</string>
|
||||
<string name="devices_disconnect">ডিসকানেক্ট</string>
|
||||
<string name="devices_connecting">কানেক্ট হচ্ছে...</string>
|
||||
<string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string>
|
||||
@ -503,20 +500,13 @@
|
||||
<string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string>
|
||||
<string name="devices_unknown_device">অজানা ডিভাইস</string>
|
||||
<string name="devices_resolving">খোঁজা হচ্ছে...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">রিমোট কন্ট্রোল</string>
|
||||
<string name="devices_remote_disconnected">ডিসকানেক্টেড</string>
|
||||
<string name="devices_remote_connection_error">কানেকশন ত্রুটি: %1$s</string>
|
||||
<string name="devices_remote_album_cover">অ্যালবাম কভার</string>
|
||||
<string name="devices_remote_unknown_track">অজানা ট্র্যাক</string>
|
||||
<string name="devices_remote_unknown_artist">অজানা শিল্পী</string>
|
||||
<string name="devices_remote_seek">সিক</string>
|
||||
<string name="devices_remote_shuffle">শাফল</string>
|
||||
<string name="devices_remote_previous">আগের</string>
|
||||
<string name="devices_remote_next">পরের</string>
|
||||
<string name="devices_remote_loop_mode">লুপ মোড</string>
|
||||
<string name="devices_remote_volume">ভলিউম</string>
|
||||
<string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string>
|
||||
<string name="devices_remote_more_options">আরও অপশন</string>
|
||||
|
||||
@ -213,8 +213,6 @@
|
||||
<string name="settings_error_cache_size_range">Die Größe muss 0 oder eine positive Zahl sein.</string>
|
||||
<string name="settings_enable_endless_playback_title">Endlose Wiedergabe aktivieren</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter.</string>
|
||||
<string name="settings_enable_connect_title">Connect aktivieren</string>
|
||||
<string name="settings_enable_connect_subtitle">Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen.</string>
|
||||
<string name="settings_playback_port_title">Port des Wiedergabe-Proxyservers</string>
|
||||
<string name="settings_allow_remote_control_title">Fernsteuerung erlauben</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden.</string>
|
||||
@ -495,7 +493,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Geräte</string>
|
||||
<string name="devices_refresh">Aktualisieren</string>
|
||||
<string name="devices_connected_to">Verbunden mit %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Trennen</string>
|
||||
<string name="devices_connecting">Verbinden...</string>
|
||||
<string name="devices_searching">Suche nach Geräten im Netzwerk...</string>
|
||||
@ -503,20 +500,13 @@
|
||||
<string name="devices_empty_hint">Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist.</string>
|
||||
<string name="devices_unknown_device">Unbekanntes Gerät</string>
|
||||
<string name="devices_resolving">Wird aufgelöst...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Fernsteuerung</string>
|
||||
<string name="devices_remote_disconnected">Getrennt</string>
|
||||
<string name="devices_remote_connection_error">Verbindungsfehler: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albumcover</string>
|
||||
<string name="devices_remote_unknown_track">Unbekannter Titel</string>
|
||||
<string name="devices_remote_unknown_artist">Unbekannter Künstler</string>
|
||||
<string name="devices_remote_seek">Spulen</string>
|
||||
<string name="devices_remote_shuffle">Zufallswiedergabe</string>
|
||||
<string name="devices_remote_previous">Zurück</string>
|
||||
<string name="devices_remote_next">Weiter</string>
|
||||
<string name="devices_remote_loop_mode">Wiederholungsmodus</string>
|
||||
<string name="devices_remote_volume">Lautstärke</string>
|
||||
<string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string>
|
||||
<string name="devices_remote_more_options">Weitere Optionen</string>
|
||||
|
||||
@ -216,8 +216,6 @@
|
||||
<string name="settings_error_cache_size_range">El tamaño debe ser 0 o un número positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Activar reproducción infinita</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Sigue reproduciendo canciones recomendadas cuando termine la cola.</string>
|
||||
<string name="settings_enable_connect_title">Activar Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Ofrece controles de reproducción remota mediante la función Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Puerto del servidor proxy de reproducción</string>
|
||||
<string name="settings_allow_remote_control_title">Permitir control remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permite que otros dispositivos de la misma red controlen la reproducción al conectarse.</string>
|
||||
@ -499,7 +497,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivos</string>
|
||||
<string name="devices_refresh">Actualizar</string>
|
||||
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Desconectar</string>
|
||||
<string name="devices_connecting">Conectando...</string>
|
||||
<string name="devices_searching">Buscando dispositivos en la red...</string>
|
||||
@ -507,20 +504,13 @@
|
||||
<string name="devices_empty_hint">Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes.</string>
|
||||
<string name="devices_unknown_device">Dispositivo desconocido</string>
|
||||
<string name="devices_resolving">Resolviendo...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Control remoto</string>
|
||||
<string name="devices_remote_disconnected">Desconectado</string>
|
||||
<string name="devices_remote_connection_error">Error de conexión: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Portada del álbum</string>
|
||||
<string name="devices_remote_unknown_track">Canción desconocida</string>
|
||||
<string name="devices_remote_unknown_artist">Artista desconocido</string>
|
||||
<string name="devices_remote_seek">Avanzar</string>
|
||||
<string name="devices_remote_shuffle">Aleatorio</string>
|
||||
<string name="devices_remote_previous">Anterior</string>
|
||||
<string name="devices_remote_next">Siguiente</string>
|
||||
<string name="devices_remote_loop_mode">Modo de repetición</string>
|
||||
<string name="devices_remote_volume">Volumen</string>
|
||||
<string name="devices_remote_remove_from_queue">Quitar de la cola</string>
|
||||
<string name="devices_remote_more_options">Más opciones</string>
|
||||
|
||||
@ -213,8 +213,6 @@
|
||||
<string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string>
|
||||
<string name="settings_enable_endless_playback_title">فعالسازی پخش بیپایان</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">وقتی صف پخش تمام شد، پخش با آهنگهای پیشنهادی ادامه پیدا کند.</string>
|
||||
<string name="settings_enable_connect_title">فعالسازی Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">کنترلهای پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید.</string>
|
||||
<string name="settings_playback_port_title">درگاه سرور پراکسی پخش</string>
|
||||
<string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string>
|
||||
<string name="settings_allow_remote_control_subtitle">به دستگاههای دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string>
|
||||
@ -495,7 +493,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">دستگاهها</string>
|
||||
<string name="devices_refresh">بهروزرسانی</string>
|
||||
<string name="devices_connected_to">متصل به %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">قطع اتصال</string>
|
||||
<string name="devices_connecting">در حال اتصال...</string>
|
||||
<string name="devices_searching">در حال جستجوی دستگاهها در شبکه...</string>
|
||||
@ -503,20 +500,13 @@
|
||||
<string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string>
|
||||
<string name="devices_unknown_device">دستگاه ناشناس</string>
|
||||
<string name="devices_resolving">در حال شناسایی...</string>
|
||||
<string name="devices_device_id">شناسه: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">کنترل از راه دور</string>
|
||||
<string name="devices_remote_disconnected">قطع شد</string>
|
||||
<string name="devices_remote_connection_error">خطای اتصال: %1$s</string>
|
||||
<string name="devices_remote_album_cover">کاور آلبوم</string>
|
||||
<string name="devices_remote_unknown_track">آهنگ ناشناس</string>
|
||||
<string name="devices_remote_unknown_artist">هنرمند ناشناس</string>
|
||||
<string name="devices_remote_seek">جابهجایی</string>
|
||||
<string name="devices_remote_shuffle">پخش تصادفی</string>
|
||||
<string name="devices_remote_previous">قبلی</string>
|
||||
<string name="devices_remote_next">بعدی</string>
|
||||
<string name="devices_remote_loop_mode">حالت تکرار</string>
|
||||
<string name="devices_remote_volume">صدا</string>
|
||||
<string name="devices_remote_remove_from_queue">حذف از صف</string>
|
||||
<string name="devices_remote_more_options">گزینههای بیشتر</string>
|
||||
|
||||
@ -216,8 +216,6 @@
|
||||
<string name="settings_error_cache_size_range">La taille doit être 0 ou un nombre positif.</string>
|
||||
<string name="settings_enable_endless_playback_title">Activer la lecture continue</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente.</string>
|
||||
<string name="settings_enable_connect_title">Activer Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port du serveur proxy de lecture</string>
|
||||
<string name="settings_allow_remote_control_title">Autoriser le contrôle à distance</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent.</string>
|
||||
@ -499,7 +497,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Appareils</string>
|
||||
<string name="devices_refresh">Actualiser</string>
|
||||
<string name="devices_connected_to">Connecté à %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Se déconnecter</string>
|
||||
<string name="devices_connecting">Connexion...</string>
|
||||
<string name="devices_searching">Recherche d\'appareils sur le réseau...</string>
|
||||
@ -507,20 +504,13 @@
|
||||
<string name="devices_empty_hint">Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil.</string>
|
||||
<string name="devices_unknown_device">Appareil inconnu</string>
|
||||
<string name="devices_resolving">Résolution...</string>
|
||||
<string name="devices_device_id">ID : %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Contrôle à distance</string>
|
||||
<string name="devices_remote_disconnected">Déconnecté</string>
|
||||
<string name="devices_remote_connection_error">Erreur de connexion : %1$s</string>
|
||||
<string name="devices_remote_album_cover">Pochette d\'album</string>
|
||||
<string name="devices_remote_unknown_track">Titre inconnu</string>
|
||||
<string name="devices_remote_unknown_artist">Artiste inconnu</string>
|
||||
<string name="devices_remote_seek">Avancer/reculer</string>
|
||||
<string name="devices_remote_shuffle">Lecture aléatoire</string>
|
||||
<string name="devices_remote_previous">Précédent</string>
|
||||
<string name="devices_remote_next">Suivant</string>
|
||||
<string name="devices_remote_loop_mode">Mode de répétition</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Retirer de la file d\'attente</string>
|
||||
<string name="devices_remote_more_options">Plus d\'options</string>
|
||||
|
||||
@ -212,8 +212,6 @@
|
||||
<string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string>
|
||||
<string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।</string>
|
||||
<string name="settings_enable_connect_title">Connect चालू करें</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं।</string>
|
||||
<string name="settings_playback_port_title">प्लेबैक प्रॉक्सी सर्वर पोर्ट</string>
|
||||
<string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string>
|
||||
<string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string>
|
||||
@ -494,7 +492,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">डिवाइस</string>
|
||||
<string name="devices_refresh">रीफ़्रेश करें</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d से कनेक्ट है</string>
|
||||
<string name="devices_disconnect">डिस्कनेक्ट करें</string>
|
||||
<string name="devices_connecting">कनेक्ट हो रहा है...</string>
|
||||
<string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string>
|
||||
@ -502,20 +499,13 @@
|
||||
<string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string>
|
||||
<string name="devices_unknown_device">अज्ञात डिवाइस</string>
|
||||
<string name="devices_resolving">पता लगाया जा रहा है...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">रिमोट कंट्रोल</string>
|
||||
<string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string>
|
||||
<string name="devices_remote_connection_error">कनेक्शन में गड़बड़ी: %1$s</string>
|
||||
<string name="devices_remote_album_cover">एल्बम कवर</string>
|
||||
<string name="devices_remote_unknown_track">अज्ञात ट्रैक</string>
|
||||
<string name="devices_remote_unknown_artist">अज्ञात कलाकार</string>
|
||||
<string name="devices_remote_seek">आगे-पीछे करें</string>
|
||||
<string name="devices_remote_shuffle">शफ़ल</string>
|
||||
<string name="devices_remote_previous">पिछला</string>
|
||||
<string name="devices_remote_next">अगला</string>
|
||||
<string name="devices_remote_loop_mode">रिपीट मोड</string>
|
||||
<string name="devices_remote_volume">वॉल्यूम</string>
|
||||
<string name="devices_remote_remove_from_queue">कतार से हटाएं</string>
|
||||
<string name="devices_remote_more_options">और विकल्प</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">Ukuran harus 0 atau bilangan positif.</string>
|
||||
<string name="settings_enable_endless_playback_title">Aktifkan pemutaran tanpa henti</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis.</string>
|
||||
<string name="settings_enable_connect_title">Aktifkan Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port server proxy pemutaran</string>
|
||||
<string name="settings_allow_remote_control_title">Izinkan kontrol jarak jauh</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung.</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Perangkat</string>
|
||||
<string name="devices_refresh">Muat ulang</string>
|
||||
<string name="devices_connected_to">Terhubung ke %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Putuskan</string>
|
||||
<string name="devices_connecting">Menghubungkan...</string>
|
||||
<string name="devices_searching">Mencari perangkat di jaringan...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan.</string>
|
||||
<string name="devices_unknown_device">Perangkat Tidak Dikenal</string>
|
||||
<string name="devices_resolving">Menyelesaikan...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Kontrol Jarak Jauh</string>
|
||||
<string name="devices_remote_disconnected">Terputus</string>
|
||||
<string name="devices_remote_connection_error">Kesalahan koneksi: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Sampul album</string>
|
||||
<string name="devices_remote_unknown_track">Lagu Tidak Dikenal</string>
|
||||
<string name="devices_remote_unknown_artist">Artis Tidak Dikenal</string>
|
||||
<string name="devices_remote_seek">Cari posisi</string>
|
||||
<string name="devices_remote_shuffle">Acak</string>
|
||||
<string name="devices_remote_previous">Sebelumnya</string>
|
||||
<string name="devices_remote_next">Berikutnya</string>
|
||||
<string name="devices_remote_loop_mode">Mode ulang</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Hapus dari antrean</string>
|
||||
<string name="devices_remote_more_options">Opsi lainnya</string>
|
||||
|
||||
@ -216,8 +216,6 @@
|
||||
<string name="settings_error_cache_size_range">La dimensione deve essere 0 o un numero positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Attiva riproduzione continua</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Continua la riproduzione con brani consigliati quando la coda finisce.</string>
|
||||
<string name="settings_enable_connect_title">Attiva Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Porta del server proxy di riproduzione</string>
|
||||
<string name="settings_allow_remote_control_title">Consenti controllo remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono.</string>
|
||||
@ -499,7 +497,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivi</string>
|
||||
<string name="devices_refresh">Aggiorna</string>
|
||||
<string name="devices_connected_to">Connesso a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Disconnetti</string>
|
||||
<string name="devices_connecting">Connessione...</string>
|
||||
<string name="devices_searching">Ricerca di dispositivi sulla rete...</string>
|
||||
@ -507,20 +504,13 @@
|
||||
<string name="devices_empty_hint">Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni.</string>
|
||||
<string name="devices_unknown_device">Dispositivo sconosciuto</string>
|
||||
<string name="devices_resolving">Risoluzione...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Controllo remoto</string>
|
||||
<string name="devices_remote_disconnected">Disconnesso</string>
|
||||
<string name="devices_remote_connection_error">Errore di connessione: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Copertina dell\'album</string>
|
||||
<string name="devices_remote_unknown_track">Brano sconosciuto</string>
|
||||
<string name="devices_remote_unknown_artist">Artista sconosciuto</string>
|
||||
<string name="devices_remote_seek">Posizione</string>
|
||||
<string name="devices_remote_shuffle">Riproduzione casuale</string>
|
||||
<string name="devices_remote_previous">Precedente</string>
|
||||
<string name="devices_remote_next">Successivo</string>
|
||||
<string name="devices_remote_loop_mode">Modalità ripetizione</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string>
|
||||
<string name="devices_remote_more_options">Altre opzioni</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string>
|
||||
<string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">キューが終わったら、おすすめの曲で再生を続けます。</string>
|
||||
<string name="settings_enable_connect_title">Connectを有効化</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect機能でリモート再生コントロールを公開します。</string>
|
||||
<string name="settings_playback_port_title">再生プロキシサーバーのポート</string>
|
||||
<string name="settings_allow_remote_control_title">リモート操作を許可</string>
|
||||
<string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">デバイス</string>
|
||||
<string name="devices_refresh">更新</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d に接続済み</string>
|
||||
<string name="devices_disconnect">切断</string>
|
||||
<string name="devices_connecting">接続中...</string>
|
||||
<string name="devices_searching">ネットワーク上のデバイスを検索中...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string>
|
||||
<string name="devices_unknown_device">不明なデバイス</string>
|
||||
<string name="devices_resolving">解決中...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">リモート操作</string>
|
||||
<string name="devices_remote_disconnected">切断されました</string>
|
||||
<string name="devices_remote_connection_error">接続エラー: %1$s</string>
|
||||
<string name="devices_remote_album_cover">アルバムカバー</string>
|
||||
<string name="devices_remote_unknown_track">不明な曲</string>
|
||||
<string name="devices_remote_unknown_artist">不明なアーティスト</string>
|
||||
<string name="devices_remote_seek">シーク</string>
|
||||
<string name="devices_remote_shuffle">シャッフル</string>
|
||||
<string name="devices_remote_previous">前へ</string>
|
||||
<string name="devices_remote_next">次へ</string>
|
||||
<string name="devices_remote_loop_mode">リピートモード</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">キューから削除</string>
|
||||
<string name="devices_remote_more_options">その他のオプション</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string>
|
||||
<string name="settings_enable_endless_playback_title">끝없는 재생 사용</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">대기열이 끝나면 추천 트랙으로 계속 재생해요.</string>
|
||||
<string name="settings_enable_connect_title">Connect 사용</string>
|
||||
<string name="settings_enable_connect_subtitle">Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요.</string>
|
||||
<string name="settings_playback_port_title">재생 프록시 서버 포트</string>
|
||||
<string name="settings_allow_remote_control_title">원격 제어 허용</string>
|
||||
<string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">기기</string>
|
||||
<string name="devices_refresh">새로고침</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d에 연결됨</string>
|
||||
<string name="devices_disconnect">연결 해제</string>
|
||||
<string name="devices_connecting">연결 중...</string>
|
||||
<string name="devices_searching">네트워크에서 기기를 찾는 중...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string>
|
||||
<string name="devices_unknown_device">알 수 없는 기기</string>
|
||||
<string name="devices_resolving">확인 중...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">원격 제어</string>
|
||||
<string name="devices_remote_disconnected">연결 끊김</string>
|
||||
<string name="devices_remote_connection_error">연결 오류: %1$s</string>
|
||||
<string name="devices_remote_album_cover">앨범 커버</string>
|
||||
<string name="devices_remote_unknown_track">알 수 없는 트랙</string>
|
||||
<string name="devices_remote_unknown_artist">알 수 없는 아티스트</string>
|
||||
<string name="devices_remote_seek">탐색</string>
|
||||
<string name="devices_remote_shuffle">셔플</string>
|
||||
<string name="devices_remote_previous">이전</string>
|
||||
<string name="devices_remote_next">다음</string>
|
||||
<string name="devices_remote_loop_mode">반복 모드</string>
|
||||
<string name="devices_remote_volume">볼륨</string>
|
||||
<string name="devices_remote_remove_from_queue">대기열에서 삭제</string>
|
||||
<string name="devices_remote_more_options">옵션 더보기</string>
|
||||
|
||||
@ -213,8 +213,6 @@
|
||||
<string name="settings_error_cache_size_range">Grootte moet 0 of een positief getal zijn.</string>
|
||||
<string name="settings_enable_endless_playback_title">Eindeloos afspelen inschakelen</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Blijf afspelen met aanbevolen nummers als je wachtrij leeg is.</string>
|
||||
<string name="settings_enable_connect_title">Connect inschakelen</string>
|
||||
<string name="settings_enable_connect_subtitle">Maak bediening op afstand beschikbaar via Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Poort van afspeelproxyserver</string>
|
||||
<string name="settings_allow_remote_control_title">Bediening op afstand toestaan</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken.</string>
|
||||
@ -495,7 +493,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Apparaten</string>
|
||||
<string name="devices_refresh">Vernieuwen</string>
|
||||
<string name="devices_connected_to">Verbonden met %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Verbinding verbreken</string>
|
||||
<string name="devices_connecting">Verbinden...</string>
|
||||
<string name="devices_searching">Zoeken naar apparaten op het netwerk...</string>
|
||||
@ -503,20 +500,13 @@
|
||||
<string name="devices_empty_hint">Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat.</string>
|
||||
<string name="devices_unknown_device">Onbekend apparaat</string>
|
||||
<string name="devices_resolving">Bezig met ophalen...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Bediening op afstand</string>
|
||||
<string name="devices_remote_disconnected">Niet verbonden</string>
|
||||
<string name="devices_remote_connection_error">Verbindingsfout: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albumhoes</string>
|
||||
<string name="devices_remote_unknown_track">Onbekend nummer</string>
|
||||
<string name="devices_remote_unknown_artist">Onbekende artiest</string>
|
||||
<string name="devices_remote_seek">Zoeken in nummer</string>
|
||||
<string name="devices_remote_shuffle">Shuffle</string>
|
||||
<string name="devices_remote_previous">Vorige</string>
|
||||
<string name="devices_remote_next">Volgende</string>
|
||||
<string name="devices_remote_loop_mode">Herhaalmodus</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string>
|
||||
<string name="devices_remote_more_options">Meer opties</string>
|
||||
|
||||
@ -219,8 +219,6 @@
|
||||
<string name="settings_error_cache_size_range">Rozmiar musi wynosić 0 lub być liczbą dodatnią.</string>
|
||||
<string name="settings_enable_endless_playback_title">Włącz nieskończone odtwarzanie</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Gdy kolejka się skończy, odtwarzaj dalej polecane utwory.</string>
|
||||
<string name="settings_enable_connect_title">Włącz Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port serwera proxy odtwarzania</string>
|
||||
<string name="settings_allow_remote_control_title">Zezwól na zdalne sterowanie</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu.</string>
|
||||
@ -503,7 +501,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Urządzenia</string>
|
||||
<string name="devices_refresh">Odśwież</string>
|
||||
<string name="devices_connected_to">Połączono z %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Rozłącz</string>
|
||||
<string name="devices_connecting">Łączenie...</string>
|
||||
<string name="devices_searching">Wyszukiwanie urządzeń w sieci...</string>
|
||||
@ -511,20 +508,13 @@
|
||||
<string name="devices_empty_hint">Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”.</string>
|
||||
<string name="devices_unknown_device">Nieznane urządzenie</string>
|
||||
<string name="devices_resolving">Ustalanie adresu...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Zdalne sterowanie</string>
|
||||
<string name="devices_remote_disconnected">Rozłączono</string>
|
||||
<string name="devices_remote_connection_error">Błąd połączenia: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Okładka albumu</string>
|
||||
<string name="devices_remote_unknown_track">Nieznany utwór</string>
|
||||
<string name="devices_remote_unknown_artist">Nieznany wykonawca</string>
|
||||
<string name="devices_remote_seek">Przewiń</string>
|
||||
<string name="devices_remote_shuffle">Odtwarzanie losowe</string>
|
||||
<string name="devices_remote_previous">Poprzedni</string>
|
||||
<string name="devices_remote_next">Następny</string>
|
||||
<string name="devices_remote_loop_mode">Tryb powtarzania</string>
|
||||
<string name="devices_remote_volume">Głośność</string>
|
||||
<string name="devices_remote_remove_from_queue">Usuń z kolejki</string>
|
||||
<string name="devices_remote_more_options">Więcej opcji</string>
|
||||
|
||||
@ -216,8 +216,6 @@
|
||||
<string name="settings_error_cache_size_range">O tamanho deve ser 0 ou um número positivo.</string>
|
||||
<string name="settings_enable_endless_playback_title">Ativar reprodução contínua</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Continua tocando faixas recomendadas quando sua fila acabar.</string>
|
||||
<string name="settings_enable_connect_title">Ativar Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Disponibiliza controles de reprodução remota pelo recurso Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Porta do servidor proxy de reprodução</string>
|
||||
<string name="settings_allow_remote_control_title">Permitir controle remoto</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem.</string>
|
||||
@ -499,7 +497,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Dispositivos</string>
|
||||
<string name="devices_refresh">Atualizar</string>
|
||||
<string name="devices_connected_to">Conectado a %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Desconectar</string>
|
||||
<string name="devices_connecting">Conectando...</string>
|
||||
<string name="devices_searching">Procurando dispositivos na rede...</string>
|
||||
@ -507,20 +504,13 @@
|
||||
<string name="devices_empty_hint">Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações.</string>
|
||||
<string name="devices_unknown_device">Dispositivo desconhecido</string>
|
||||
<string name="devices_resolving">Resolvendo...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Controle remoto</string>
|
||||
<string name="devices_remote_disconnected">Desconectado</string>
|
||||
<string name="devices_remote_connection_error">Erro de conexão: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Capa do álbum</string>
|
||||
<string name="devices_remote_unknown_track">Faixa desconhecida</string>
|
||||
<string name="devices_remote_unknown_artist">Artista desconhecido</string>
|
||||
<string name="devices_remote_seek">Avançar/retroceder</string>
|
||||
<string name="devices_remote_shuffle">Ordem aleatória</string>
|
||||
<string name="devices_remote_previous">Anterior</string>
|
||||
<string name="devices_remote_next">Próxima</string>
|
||||
<string name="devices_remote_loop_mode">Modo de repetição</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Remover da fila</string>
|
||||
<string name="devices_remote_more_options">Mais opções</string>
|
||||
|
||||
@ -219,8 +219,6 @@
|
||||
<string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string>
|
||||
<string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Продолжать воспроизведение рекомендованными треками, когда очередь закончится.</string>
|
||||
<string name="settings_enable_connect_title">Включить Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Предоставлять удалённое управление воспроизведением через Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Порт прокси-сервера воспроизведения</string>
|
||||
<string name="settings_allow_remote_control_title">Разрешить удалённое управление</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string>
|
||||
@ -503,7 +501,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Устройства</string>
|
||||
<string name="devices_refresh">Обновить</string>
|
||||
<string name="devices_connected_to">Подключено к %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Отключиться</string>
|
||||
<string name="devices_connecting">Подключение...</string>
|
||||
<string name="devices_searching">Поиск устройств в сети...</string>
|
||||
@ -511,20 +508,13 @@
|
||||
<string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string>
|
||||
<string name="devices_unknown_device">Неизвестное устройство</string>
|
||||
<string name="devices_resolving">Определение адреса...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Удалённое управление</string>
|
||||
<string name="devices_remote_disconnected">Отключено</string>
|
||||
<string name="devices_remote_connection_error">Ошибка подключения: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Обложка альбома</string>
|
||||
<string name="devices_remote_unknown_track">Неизвестный трек</string>
|
||||
<string name="devices_remote_unknown_artist">Неизвестный исполнитель</string>
|
||||
<string name="devices_remote_seek">Перемотка</string>
|
||||
<string name="devices_remote_shuffle">Перемешать</string>
|
||||
<string name="devices_remote_previous">Предыдущий</string>
|
||||
<string name="devices_remote_next">Следующий</string>
|
||||
<string name="devices_remote_loop_mode">Режим повтора</string>
|
||||
<string name="devices_remote_volume">Громкость</string>
|
||||
<string name="devices_remote_remove_from_queue">Удалить из очереди</string>
|
||||
<string name="devices_remote_more_options">Другие действия</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string>
|
||||
<string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด</string>
|
||||
<string name="settings_enable_connect_title">เปิดใช้ Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect</string>
|
||||
<string name="settings_playback_port_title">พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น</string>
|
||||
<string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string>
|
||||
<string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">อุปกรณ์</string>
|
||||
<string name="devices_refresh">รีเฟรช</string>
|
||||
<string name="devices_connected_to">เชื่อมต่อกับ %1$s:%2$d แล้ว</string>
|
||||
<string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string>
|
||||
<string name="devices_connecting">กำลังเชื่อมต่อ...</string>
|
||||
<string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string>
|
||||
<string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string>
|
||||
<string name="devices_resolving">กำลังระบุ...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">รีโมตคอนโทรล</string>
|
||||
<string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string>
|
||||
<string name="devices_remote_connection_error">ข้อผิดพลาดในการเชื่อมต่อ: %1$s</string>
|
||||
<string name="devices_remote_album_cover">ปกอัลบั้ม</string>
|
||||
<string name="devices_remote_unknown_track">ไม่ทราบชื่อเพลง</string>
|
||||
<string name="devices_remote_unknown_artist">ไม่ทราบศิลปิน</string>
|
||||
<string name="devices_remote_seek">เลื่อนตำแหน่ง</string>
|
||||
<string name="devices_remote_shuffle">สุ่มเพลง</string>
|
||||
<string name="devices_remote_previous">ก่อนหน้า</string>
|
||||
<string name="devices_remote_next">ถัดไป</string>
|
||||
<string name="devices_remote_loop_mode">โหมดเล่นซ้ำ</string>
|
||||
<string name="devices_remote_volume">ระดับเสียง</string>
|
||||
<string name="devices_remote_remove_from_queue">นำออกจากคิว</string>
|
||||
<string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string>
|
||||
|
||||
@ -213,8 +213,6 @@
|
||||
<string name="settings_error_cache_size_range">Boyut 0 veya pozitif bir sayı olmalı.</string>
|
||||
<string name="settings_enable_endless_playback_title">Sonsuz çalmayı etkinleştir</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Sıran bittiğinde önerilen parçalarla çalmaya devam et.</string>
|
||||
<string name="settings_enable_connect_title">Connect\'i etkinleştir</string>
|
||||
<string name="settings_enable_connect_subtitle">Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun.</string>
|
||||
<string name="settings_playback_port_title">Çalma proxy sunucusu portu</string>
|
||||
<string name="settings_allow_remote_control_title">Uzaktan kontrole izin ver</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin.</string>
|
||||
@ -495,7 +493,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Cihazlar</string>
|
||||
<string name="devices_refresh">Yenile</string>
|
||||
<string name="devices_connected_to">%1$s:%2$d adresine bağlı</string>
|
||||
<string name="devices_disconnect">Bağlantıyı kes</string>
|
||||
<string name="devices_connecting">Bağlanıyor...</string>
|
||||
<string name="devices_searching">Ağda cihaz aranıyor...</string>
|
||||
@ -503,20 +500,13 @@
|
||||
<string name="devices_empty_hint">Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol.</string>
|
||||
<string name="devices_unknown_device">Bilinmeyen Cihaz</string>
|
||||
<string name="devices_resolving">Çözümleniyor...</string>
|
||||
<string name="devices_device_id">Kimlik: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Uzaktan Kontrol</string>
|
||||
<string name="devices_remote_disconnected">Bağlantı kesildi</string>
|
||||
<string name="devices_remote_connection_error">Bağlantı hatası: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Albüm kapağı</string>
|
||||
<string name="devices_remote_unknown_track">Bilinmeyen Parça</string>
|
||||
<string name="devices_remote_unknown_artist">Bilinmeyen Sanatçı</string>
|
||||
<string name="devices_remote_seek">Konum</string>
|
||||
<string name="devices_remote_shuffle">Karıştır</string>
|
||||
<string name="devices_remote_previous">Önceki</string>
|
||||
<string name="devices_remote_next">Sonraki</string>
|
||||
<string name="devices_remote_loop_mode">Tekrar modu</string>
|
||||
<string name="devices_remote_volume">Ses düzeyi</string>
|
||||
<string name="devices_remote_remove_from_queue">Sıradan kaldır</string>
|
||||
<string name="devices_remote_more_options">Diğer seçenekler</string>
|
||||
|
||||
@ -219,8 +219,6 @@
|
||||
<string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string>
|
||||
<string name="settings_enable_endless_playback_title">Безкінечне відтворення</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Продовжувати відтворення рекомендованими треками, коли черга закінчиться.</string>
|
||||
<string name="settings_enable_connect_title">Увімкнути Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Надавати віддалене керування відтворенням через Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Порт проксі-сервера відтворення</string>
|
||||
<string name="settings_allow_remote_control_title">Дозволити віддалене керування</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string>
|
||||
@ -503,7 +501,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Пристрої</string>
|
||||
<string name="devices_refresh">Оновити</string>
|
||||
<string name="devices_connected_to">Підключено до %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Від’єднатися</string>
|
||||
<string name="devices_connecting">Підключення...</string>
|
||||
<string name="devices_searching">Пошук пристроїв у мережі...</string>
|
||||
@ -511,20 +508,13 @@
|
||||
<string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string>
|
||||
<string name="devices_unknown_device">Невідомий пристрій</string>
|
||||
<string name="devices_resolving">Визначення адреси...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Віддалене керування</string>
|
||||
<string name="devices_remote_disconnected">Від’єднано</string>
|
||||
<string name="devices_remote_connection_error">Помилка підключення: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Обкладинка альбому</string>
|
||||
<string name="devices_remote_unknown_track">Невідомий трек</string>
|
||||
<string name="devices_remote_unknown_artist">Невідомий виконавець</string>
|
||||
<string name="devices_remote_seek">Перемотування</string>
|
||||
<string name="devices_remote_shuffle">Перемішати</string>
|
||||
<string name="devices_remote_previous">Попередній</string>
|
||||
<string name="devices_remote_next">Наступний</string>
|
||||
<string name="devices_remote_loop_mode">Режим повтору</string>
|
||||
<string name="devices_remote_volume">Гучність</string>
|
||||
<string name="devices_remote_remove_from_queue">Видалити з черги</string>
|
||||
<string name="devices_remote_more_options">Інші дії</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">Dung lượng phải là 0 hoặc một số dương.</string>
|
||||
<string name="settings_enable_endless_playback_title">Bật phát liên tục</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Tiếp tục phát với các bài hát được đề xuất khi danh sách chờ kết thúc.</string>
|
||||
<string name="settings_enable_connect_title">Bật Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Cổng máy chủ proxy phát lại</string>
|
||||
<string name="settings_allow_remote_control_title">Cho phép điều khiển từ xa</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Cho phép các thiết bị khác trong cùng mạng điều khiển phát lại khi kết nối.</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Thiết bị</string>
|
||||
<string name="devices_refresh">Làm mới</string>
|
||||
<string name="devices_connected_to">Đã kết nối với %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Ngắt kết nối</string>
|
||||
<string name="devices_connecting">Đang kết nối...</string>
|
||||
<string name="devices_searching">Đang tìm thiết bị trong mạng...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt.</string>
|
||||
<string name="devices_unknown_device">Thiết bị không xác định</string>
|
||||
<string name="devices_resolving">Đang phân giải...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Điều khiển từ xa</string>
|
||||
<string name="devices_remote_disconnected">Đã ngắt kết nối</string>
|
||||
<string name="devices_remote_connection_error">Lỗi kết nối: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Ảnh bìa album</string>
|
||||
<string name="devices_remote_unknown_track">Bài hát không xác định</string>
|
||||
<string name="devices_remote_unknown_artist">Nghệ sĩ không xác định</string>
|
||||
<string name="devices_remote_seek">Tua</string>
|
||||
<string name="devices_remote_shuffle">Phát ngẫu nhiên</string>
|
||||
<string name="devices_remote_previous">Trước</string>
|
||||
<string name="devices_remote_next">Tiếp</string>
|
||||
<string name="devices_remote_loop_mode">Chế độ lặp</string>
|
||||
<string name="devices_remote_volume">Âm lượng</string>
|
||||
<string name="devices_remote_remove_from_queue">Xóa khỏi danh sách chờ</string>
|
||||
<string name="devices_remote_more_options">Tùy chọn khác</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">大小必須為 0 或正數。</string>
|
||||
<string name="settings_enable_endless_playback_title">啟用無限播放</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">待播清單播完後,以推薦曲目繼續播放。</string>
|
||||
<string name="settings_enable_connect_title">啟用 Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">透過 Spotube Connect 功能提供遠端播放控制。</string>
|
||||
<string name="settings_playback_port_title">播放代理伺服器連接埠</string>
|
||||
<string name="settings_allow_remote_control_title">允許遠端控制</string>
|
||||
<string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">裝置</string>
|
||||
<string name="devices_refresh">重新整理</string>
|
||||
<string name="devices_connected_to">已連線至 %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">中斷連線</string>
|
||||
<string name="devices_connecting">連線中...</string>
|
||||
<string name="devices_searching">正在搜尋網路上的裝置...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string>
|
||||
<string name="devices_unknown_device">未知裝置</string>
|
||||
<string name="devices_resolving">解析中...</string>
|
||||
<string name="devices_device_id">ID:%1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">遠端控制</string>
|
||||
<string name="devices_remote_disconnected">已中斷連線</string>
|
||||
<string name="devices_remote_connection_error">連線錯誤:%1$s</string>
|
||||
<string name="devices_remote_album_cover">專輯封面</string>
|
||||
<string name="devices_remote_unknown_track">未知曲目</string>
|
||||
<string name="devices_remote_unknown_artist">未知藝人</string>
|
||||
<string name="devices_remote_seek">播放進度</string>
|
||||
<string name="devices_remote_shuffle">隨機播放</string>
|
||||
<string name="devices_remote_previous">上一首</string>
|
||||
<string name="devices_remote_next">下一首</string>
|
||||
<string name="devices_remote_loop_mode">重複播放模式</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">從待播清單移除</string>
|
||||
<string name="devices_remote_more_options">更多選項</string>
|
||||
|
||||
@ -210,8 +210,6 @@
|
||||
<string name="settings_error_cache_size_range">大小必须为 0 或正数。</string>
|
||||
<string name="settings_enable_endless_playback_title">启用无尽播放</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">队列播放完毕后,继续播放推荐的歌曲。</string>
|
||||
<string name="settings_enable_connect_title">启用 Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">通过 Spotube Connect 功能开放远程播放控制。</string>
|
||||
<string name="settings_playback_port_title">播放代理服务器端口</string>
|
||||
<string name="settings_allow_remote_control_title">允许远程控制</string>
|
||||
<string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string>
|
||||
@ -491,7 +489,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">设备</string>
|
||||
<string name="devices_refresh">刷新</string>
|
||||
<string name="devices_connected_to">已连接到 %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">断开连接</string>
|
||||
<string name="devices_connecting">正在连接...</string>
|
||||
<string name="devices_searching">正在搜索网络中的设备...</string>
|
||||
@ -499,20 +496,13 @@
|
||||
<string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string>
|
||||
<string name="devices_unknown_device">未知设备</string>
|
||||
<string name="devices_resolving">正在解析...</string>
|
||||
<string name="devices_device_id">ID:%1$s...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">远程控制</string>
|
||||
<string name="devices_remote_disconnected">已断开连接</string>
|
||||
<string name="devices_remote_connection_error">连接错误:%1$s</string>
|
||||
<string name="devices_remote_album_cover">专辑封面</string>
|
||||
<string name="devices_remote_unknown_track">未知歌曲</string>
|
||||
<string name="devices_remote_unknown_artist">未知艺人</string>
|
||||
<string name="devices_remote_seek">进度</string>
|
||||
<string name="devices_remote_shuffle">随机播放</string>
|
||||
<string name="devices_remote_previous">上一首</string>
|
||||
<string name="devices_remote_next">下一首</string>
|
||||
<string name="devices_remote_loop_mode">循环模式</string>
|
||||
<string name="devices_remote_volume">音量</string>
|
||||
<string name="devices_remote_remove_from_queue">从队列中移除</string>
|
||||
<string name="devices_remote_more_options">更多选项</string>
|
||||
|
||||
@ -215,8 +215,6 @@
|
||||
<string name="settings_error_cache_size_range">Size must be 0 or a positive number.</string>
|
||||
<string name="settings_enable_endless_playback_title">Enable endless playback</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Keep playback going with recommended tracks when your queue ends.</string>
|
||||
<string name="settings_enable_connect_title">Enable Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Expose remote playback controls through the Spotube Connect feature.</string>
|
||||
<string name="settings_playback_port_title">Playback proxy server port</string>
|
||||
<string name="settings_allow_remote_control_title">Allow remote control</string>
|
||||
<string name="settings_allow_remote_control_subtitle">Let other devices on the same network control playback when they connect.</string>
|
||||
@ -504,6 +502,7 @@
|
||||
<!-- Devices: connection errors -->
|
||||
<string name="devices_error_connection_failed">Connection failed</string>
|
||||
<string name="devices_error_connection_lost">Connection lost</string>
|
||||
<string name="devices_error_connection_denied">The other device declined the request, or has remote control turned off.</string>
|
||||
<string name="devices_error_send_failed">Send failed</string>
|
||||
<string name="devices_error_discovery_failed">Discovery failed: %1$s</string>
|
||||
<string name="devices_error_connect_failed">Failed to connect: %1$s</string>
|
||||
@ -511,7 +510,6 @@
|
||||
<!-- Devices: discovery screen -->
|
||||
<string name="devices_title">Devices</string>
|
||||
<string name="devices_refresh">Refresh</string>
|
||||
<string name="devices_connected_to">Connected to %1$s:%2$d</string>
|
||||
<string name="devices_disconnect">Disconnect</string>
|
||||
<string name="devices_connecting">Connecting...</string>
|
||||
<string name="devices_searching">Searching for devices on the network...</string>
|
||||
@ -519,23 +517,28 @@
|
||||
<string name="devices_empty_hint">Make sure the other device has \"Allow remote control\" enabled in settings.</string>
|
||||
<string name="devices_unknown_device">Unknown Device</string>
|
||||
<string name="devices_resolving">Resolving...</string>
|
||||
<string name="devices_device_id">ID: %1$s...</string>
|
||||
<string name="devices_section_connected">Connected</string>
|
||||
<string name="devices_section_available">Available devices</string>
|
||||
<string name="devices_status_connected">Connected</string>
|
||||
<string name="devices_open_remote">Open remote</string>
|
||||
<string name="devices_search_again">Search again</string>
|
||||
<string name="devices_searching_more">Still looking for more devices...</string>
|
||||
|
||||
<!-- Devices: remote control screen -->
|
||||
<string name="devices_remote_title">Remote Control</string>
|
||||
<string name="devices_remote_disconnected">Disconnected</string>
|
||||
<string name="devices_remote_connection_error">Connection error: %1$s</string>
|
||||
<string name="devices_remote_album_cover">Album cover</string>
|
||||
<string name="devices_remote_unknown_track">Unknown Track</string>
|
||||
<string name="devices_remote_unknown_artist">Unknown Artist</string>
|
||||
<string name="devices_remote_seek">Seek</string>
|
||||
<string name="devices_remote_shuffle">Shuffle</string>
|
||||
<string name="devices_remote_previous">Previous</string>
|
||||
<string name="devices_remote_next">Next</string>
|
||||
<string name="devices_remote_loop_mode">Loop mode</string>
|
||||
<string name="devices_remote_volume">Volume</string>
|
||||
<string name="devices_remote_remove_from_queue">Remove from queue</string>
|
||||
<string name="devices_remote_more_options">More options</string>
|
||||
<string name="devices_remote_playing_on">Playing on %1$s</string>
|
||||
<string name="devices_remote_connecting_hint">If asked, allow this device on the other Spotube first.</string>
|
||||
<string name="devices_remote_disconnected_hint">You\'re no longer controlling the other device. Pick it again to reconnect.</string>
|
||||
<string name="devices_remote_back_to_devices">Back to devices</string>
|
||||
<string name="devices_remote_nothing_playing">Nothing playing</string>
|
||||
<string name="devices_remote_nothing_playing_hint">Start something on the other device, or pick a track from its queue.</string>
|
||||
|
||||
<!-- Group Jam: play destination picker -->
|
||||
<string name="jam_destination_title">Play Where?</string>
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
/*
|
||||
* 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.discovery
|
||||
|
||||
/**
|
||||
* The LAN address to publish in our mDNS advertisement, or null to let the platform decide.
|
||||
*
|
||||
* Only the desktop needs this: there the dns-sd library otherwise grabs the first site-local
|
||||
* address of *any* interface, which on a dev box is often a Docker/VM bridge (e.g. minikube's
|
||||
* 192.168.49.1) that other devices can't reach.
|
||||
*/
|
||||
internal expect fun preferredAdvertiseAddress(): String?
|
||||
@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent
|
||||
import com.appstractive.dnssd.NetService
|
||||
import com.appstractive.dnssd.createNetService
|
||||
import com.appstractive.dnssd.discoverServices
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.IO
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.text.decodeToString
|
||||
|
||||
data class DiscoveredDevice(
|
||||
@ -35,7 +39,8 @@ data class DiscoveredDevice(
|
||||
}
|
||||
|
||||
sealed interface DiscoveryState {
|
||||
data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState
|
||||
/** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */
|
||||
data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState
|
||||
data class Resolved(val device: DiscoveredDevice) : DiscoveryState
|
||||
data class Removed(val device: DiscoveredDevice) : DiscoveryState
|
||||
}
|
||||
@ -46,6 +51,11 @@ class DeviceDiscoveryService {
|
||||
const val TXT_DEVICE_ID = "deviceId"
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking
|
||||
* `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the
|
||||
* collector was cancelled on the main thread.
|
||||
*/
|
||||
fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event ->
|
||||
when (event) {
|
||||
is DiscoveryEvent.Discovered -> {
|
||||
@ -55,7 +65,9 @@ class DeviceDiscoveryService {
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||
DiscoveryState.Discovered(
|
||||
device = device,
|
||||
resolve = { withContext(Dispatchers.IO) { event.resolve() } })
|
||||
}
|
||||
|
||||
is DiscoveryEvent.Resolved -> {
|
||||
@ -78,7 +90,7 @@ class DeviceDiscoveryService {
|
||||
DiscoveryState.Removed(device = device)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
|
||||
suspend fun advertise(
|
||||
name: String,
|
||||
@ -97,5 +109,6 @@ class DeviceDiscoveryService {
|
||||
type = SERVICE_TYPE,
|
||||
name = name,
|
||||
port = port,
|
||||
addresses = preferredAdvertiseAddress()?.let { listOf(it) },
|
||||
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
||||
}
|
||||
@ -192,7 +192,7 @@ val navigationModule = module {
|
||||
BlacklistScreen()
|
||||
}
|
||||
navigation<Routes.Devices> {
|
||||
DevicesScreen(navigationCommands = get())
|
||||
DevicesScreen()
|
||||
}
|
||||
navigation<Routes.RemoteControl> {
|
||||
RemoteControlScreen(
|
||||
|
||||
@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets
|
||||
import io.ktor.client.plugins.websocket.webSocketSession
|
||||
import io.ktor.websocket.CloseReason
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.WebSocketSession
|
||||
import io.ktor.websocket.DefaultWebSocketSession
|
||||
import io.ktor.websocket.close
|
||||
import io.ktor.websocket.readText
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_denied
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_failed
|
||||
import spotube.composeapp.generated.resources.devices_error_connection_lost
|
||||
import spotube.composeapp.generated.resources.devices_error_send_failed
|
||||
@ -66,7 +67,7 @@ class RemoteControlClient {
|
||||
}
|
||||
}
|
||||
|
||||
private var session: WebSocketSession? = null
|
||||
private var session: DefaultWebSocketSession? = null
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
@ -77,12 +78,23 @@ class RemoteControlClient {
|
||||
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
|
||||
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow()
|
||||
|
||||
suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
|
||||
/**
|
||||
* [deviceName] identifies this controller to the host; [remoteDeviceName] is the
|
||||
* host's advertised name, kept only so the UI can show what it is controlling.
|
||||
*/
|
||||
suspend fun connect(
|
||||
host: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
deviceName: String,
|
||||
remoteDeviceName: String = "") {
|
||||
if (_connectionState.value is ConnectionState.Connected) {
|
||||
logger.w { "Already connected" }
|
||||
return
|
||||
}
|
||||
|
||||
// Drop a stale attempt (e.g. one still waiting on the host's permission prompt).
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Connecting
|
||||
try {
|
||||
session = httpClient.webSocketSession {
|
||||
@ -95,16 +107,16 @@ class RemoteControlClient {
|
||||
|
||||
// Start receiving messages in a separate coroutine
|
||||
scope.launch {
|
||||
receiveLoop(host, port)
|
||||
receiveLoop(host, port, remoteDeviceName)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to connect to $host:$port" }
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed)
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun receiveLoop(host: String, port: Int) {
|
||||
private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) {
|
||||
val currentSession = session ?: return
|
||||
logger.d { "Starting receive loop for $host:$port" }
|
||||
try {
|
||||
@ -119,7 +131,7 @@ class RemoteControlClient {
|
||||
when (event) {
|
||||
is RemoteControlEvent.Connected -> {
|
||||
logger.i { "Connection authorized by server" }
|
||||
_connectionState.value = ConnectionState.Connected(host, port)
|
||||
_connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName)
|
||||
}
|
||||
is RemoteControlEvent.WaitingForPermission -> {
|
||||
logger.i { "Waiting for permission: ${event.message}" }
|
||||
@ -144,20 +156,39 @@ class RemoteControlClient {
|
||||
logger.w(e) { "Failed to parse message: $text" }
|
||||
}
|
||||
}
|
||||
is Frame.Close -> {
|
||||
logger.i { "WebSocket closed by server" }
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
break
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
logger.d { "Receive loop exited normally" }
|
||||
// Ktor consumes Close frames itself and just ends `incoming`, so the server
|
||||
// closing the socket (denied request, remote control turned off, host quit)
|
||||
// only shows up here. Without this the state stayed Connecting/Connected forever
|
||||
// and every later connect attempt was swallowed.
|
||||
val reason = currentSession.closeReason.await()
|
||||
logger.i { "WebSocket closed by server: $reason" }
|
||||
onServerClosed(currentSession, reason)
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Error in receive loop" }
|
||||
if (session === currentSession) {
|
||||
session = null
|
||||
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) {
|
||||
// disconnect() (or a newer connect()) already replaced this session; nothing to report.
|
||||
if (session !== closedSession) return
|
||||
session = null
|
||||
val wasAuthorized = _connectionState.value is ConnectionState.Connected
|
||||
_connectionState.value = when {
|
||||
// The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny
|
||||
// (or dismissed the prompt), or remote control is turned off over there.
|
||||
!wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY ->
|
||||
ConnectionState.Error(null, Res.string.devices_error_connection_denied)
|
||||
wasAuthorized -> ConnectionState.Disconnected
|
||||
else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun sendCommand(command: RemoteControlCommand) {
|
||||
val currentSession = session ?: run {
|
||||
@ -180,12 +211,18 @@ class RemoteControlClient {
|
||||
}
|
||||
|
||||
suspend fun disconnect() {
|
||||
session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
|
||||
session = null
|
||||
closeSession()
|
||||
_connectionState.value = ConnectionState.Disconnected
|
||||
logger.i { "Disconnected" }
|
||||
}
|
||||
|
||||
/** Detaches before closing so the receive loop doesn't report this as a server close. */
|
||||
private suspend fun closeSession() {
|
||||
val current = session ?: return
|
||||
session = null
|
||||
runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) }
|
||||
}
|
||||
|
||||
private fun randomShortId(): String {
|
||||
val chars = "0123456789abcdef"
|
||||
return buildString(8) {
|
||||
@ -199,7 +236,7 @@ class RemoteControlClient {
|
||||
sealed interface ConnectionState {
|
||||
data object Disconnected : ConnectionState
|
||||
data object Connecting : ConnectionState
|
||||
data class Connected(val host: String, val port: Int) : ConnectionState
|
||||
data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState
|
||||
/**
|
||||
* [message] is the raw (untranslated) cause when available; [fallback] is the
|
||||
* localized description to show when it isn't.
|
||||
|
||||
@ -18,216 +18,195 @@
|
||||
package org.spotube.spotube.modules.devices
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumButton
|
||||
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
import org.spotube.millennium.composables.MillenniumSpokeSpinner
|
||||
import org.spotube.millennium.theme.MillenniumTheme
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
|
||||
import org.spotube.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import org.spotube.spotube.core.discovery.DiscoveredDevice
|
||||
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester
|
||||
import org.spotube.spotube.core.navigation.NavigationCommands
|
||||
import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.ui.component.ApplicationMainBar
|
||||
import org.spotube.spotube.modules.settings.components.SettingCardItem
|
||||
import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
|
||||
import org.spotube.spotube.modules.settings.sections.settingsSectionCard
|
||||
import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
|
||||
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
|
||||
import org.spotube.spotube.resources.iconsax.Iconsax
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxInformation
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight
|
||||
import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
import spotube.composeapp.generated.resources.Res
|
||||
import spotube.composeapp.generated.resources.devices_connected_to
|
||||
import spotube.composeapp.generated.resources.devices_connecting
|
||||
import spotube.composeapp.generated.resources.devices_device_id
|
||||
import spotube.composeapp.generated.resources.devices_disconnect
|
||||
import spotube.composeapp.generated.resources.devices_empty
|
||||
import spotube.composeapp.generated.resources.devices_empty_hint
|
||||
import spotube.composeapp.generated.resources.devices_open_remote
|
||||
import spotube.composeapp.generated.resources.devices_refresh
|
||||
import spotube.composeapp.generated.resources.devices_resolving
|
||||
import spotube.composeapp.generated.resources.devices_search_again
|
||||
import spotube.composeapp.generated.resources.devices_searching
|
||||
import spotube.composeapp.generated.resources.devices_searching_more
|
||||
import spotube.composeapp.generated.resources.devices_section_available
|
||||
import spotube.composeapp.generated.resources.devices_section_connected
|
||||
import spotube.composeapp.generated.resources.devices_status_connected
|
||||
import spotube.composeapp.generated.resources.devices_title
|
||||
import spotube.composeapp.generated.resources.devices_unknown_device
|
||||
import spotube.composeapp.generated.resources.jam_error_dismiss
|
||||
|
||||
private val ContentMaxWidth = 720.dp
|
||||
|
||||
@Composable
|
||||
fun DevicesScreen(
|
||||
navigationCommands: NavigationCommands) {
|
||||
val viewModel = koinViewModel<DevicesViewModel>()
|
||||
val devices by viewModel.devices.collectAsStateWithLifecycle()
|
||||
val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle()
|
||||
val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle()
|
||||
val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
|
||||
val error by viewModel.error.collectAsStateWithLifecycle()
|
||||
viewModel: DevicesViewModel = koinViewModel()
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
|
||||
requestLocalNetworkPermission()
|
||||
viewModel.startDiscovery()
|
||||
onDispose {
|
||||
// The remote connection itself outlives this screen; only discovery stops.
|
||||
viewModel.stopDiscovery()
|
||||
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
|
||||
// The RemoteControlViewModel will manage the connection lifecycle
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
.background(MillenniumTheme.colors.background)
|
||||
) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = { Text(stringResource(Res.string.devices_title)) },
|
||||
actions = {
|
||||
if (isDiscovering && connectingToDevice == null) {
|
||||
if (state.isDiscovering) {
|
||||
MillenniumSpokeSpinner(
|
||||
size = 24.dp,
|
||||
modifier = Modifier.padding(end = 8.dp))
|
||||
size = 20.dp,
|
||||
modifier = Modifier.padding(horizontal = 10.dp)
|
||||
)
|
||||
} else {
|
||||
MillenniumIconButton(
|
||||
onClick = viewModel::startDiscovery,
|
||||
enabled = !state.isConnecting,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxRefreshRight,
|
||||
contentDescription = stringResource(Res.string.devices_refresh),
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable(enabled = connectingToDevice == null) {
|
||||
viewModel.startDiscovery()
|
||||
})
|
||||
contentDescription = stringResource(Res.string.devices_refresh)
|
||||
)
|
||||
}
|
||||
})
|
||||
val shellBottomInset = LocalAppShellBottomInset.current
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f)) {
|
||||
// Error banner
|
||||
error?.let { devicesError ->
|
||||
val errorMessage = devicesError.rawMessage
|
||||
?: stringResource(devicesError.message, *devicesError.args.toTypedArray())
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
MillenniumButton(
|
||||
onClick = { viewModel.clearError() },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(stringResource(Res.string.jam_error_dismiss))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Connection status
|
||||
when (val state = connectionState) {
|
||||
is ConnectionState.Connected -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp)) {
|
||||
Column {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_connected_to, state.host, state.port),
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.primary)
|
||||
MillenniumButton(
|
||||
onClick = { viewModel.disconnect() },
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(stringResource(Res.string.devices_disconnect))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connecting -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
contentAlignment = Alignment.CenterStart) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumSpokeSpinner(size = 24.dp)
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_connecting),
|
||||
style = AppTheme.typography.bodyMedium)
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
|
||||
// Device list
|
||||
if (devices.isEmpty() && connectingToDevice == null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = shellBottomInset),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(
|
||||
text = if (isDiscovering) {
|
||||
stringResource(Res.string.devices_searching)
|
||||
} else {
|
||||
stringResource(Res.string.devices_empty)
|
||||
},
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
if (!isDiscovering) {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_empty_hint),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
.weight(1f)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(
|
||||
horizontal = 16.dp,
|
||||
vertical = 8.dp)) {
|
||||
items(devices.values.toList(), key = { it.key }) { device ->
|
||||
DeviceRow(
|
||||
device = device,
|
||||
isConnecting = connectingToDevice?.key == device.key,
|
||||
onClick = { viewModel.connectToDevice(device) })
|
||||
}
|
||||
.widthIn(max = ContentMaxWidth)
|
||||
.fillMaxSize(),
|
||||
contentPadding = PaddingValues(
|
||||
start = 8.dp,
|
||||
end = 8.dp,
|
||||
top = 8.dp,
|
||||
bottom = shellBottomInset + 16.dp
|
||||
)
|
||||
) {
|
||||
state.error?.let { error ->
|
||||
item {
|
||||
Box(modifier = Modifier.padding(bottom = shellBottomInset))
|
||||
ErrorBanner(
|
||||
message = error.rawMessage
|
||||
?: stringResource(error.message, *error.args.toTypedArray()),
|
||||
onDismiss = viewModel::clearError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
state.connectedDevice?.let { device ->
|
||||
settingsSectionHeader(Res.string.devices_section_connected)
|
||||
item {
|
||||
ConnectedDeviceCard(
|
||||
device = device,
|
||||
onOpenRemote = viewModel::openRemote,
|
||||
onDisconnect = viewModel::disconnect
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.devices.isNotEmpty()) {
|
||||
settingsSectionHeader(Res.string.devices_section_available)
|
||||
settingsSectionCard(
|
||||
items = state.devices.map { device ->
|
||||
{
|
||||
DeviceTile(
|
||||
device = device,
|
||||
enabled = !state.isConnecting || device.isConnecting,
|
||||
onClick = { viewModel.onDeviceClick(device.key) }
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
if (state.isDiscovering) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_searching_more),
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = MillenniumTheme.colors.muted,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 12.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else if (state.showEmptyState) {
|
||||
item {
|
||||
EmptyState(
|
||||
isDiscovering = state.isDiscovering,
|
||||
onSearchAgain = viewModel::startDiscovery
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -236,48 +215,190 @@ fun DevicesScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceRow(
|
||||
device: DiscoveredDevice,
|
||||
isConnecting: Boolean,
|
||||
onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick, enabled = !isConnecting)
|
||||
.padding(vertical = 12.dp, horizontal = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
if (isConnecting) {
|
||||
MillenniumSpokeSpinner(size = 24.dp)
|
||||
} else {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxMirroringScreen,
|
||||
private fun DeviceTile(
|
||||
device: DeviceItemUi,
|
||||
enabled: Boolean,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
|
||||
SettingCardItem(
|
||||
enabled = enabled,
|
||||
title = name,
|
||||
subtitle = device.address ?: stringResource(Res.string.devices_resolving),
|
||||
icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) },
|
||||
trailingContent = {
|
||||
when {
|
||||
device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp)
|
||||
device.isConnected -> Text(
|
||||
text = stringResource(Res.string.devices_status_connected),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.primary
|
||||
)
|
||||
else -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.ChevronRight,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.primary)
|
||||
tint = AppTheme.colors.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
},
|
||||
onClick = onClick
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyItemScope.ConnectedDeviceCard(
|
||||
device: ConnectedDeviceUi,
|
||||
onOpenRemote: () -> Unit,
|
||||
onDisconnect: () -> Unit
|
||||
) {
|
||||
val name = device.name ?: stringResource(Res.string.devices_unknown_device)
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillParentMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
maxWidth = ContentMaxWidth,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
contentSpacing = 16.dp,
|
||||
shadowElevation = 6.dp
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name)
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) },
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
text = name,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Text(
|
||||
text = if (device.host.isNotBlank() && device.port > 0) {
|
||||
"${device.host}:${device.port}"
|
||||
} else {
|
||||
stringResource(Res.string.devices_resolving)
|
||||
},
|
||||
text = device.address,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
if (device.deviceId.isNotBlank()) {
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
|
||||
) {
|
||||
MillenniumButton(
|
||||
onClick = onDisconnect,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_disconnect))
|
||||
}
|
||||
MillenniumButton(
|
||||
onClick = onOpenRemote,
|
||||
style = MillenniumButtonStyle.Primary
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_open_remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorBanner(
|
||||
message: String,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.background(AppTheme.colors.errorContainer)
|
||||
.padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxInformation,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.onErrorContainer,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)),
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
text = message,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onErrorContainer,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MillenniumIconButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxCloseSquare,
|
||||
contentDescription = stringResource(Res.string.jam_error_dismiss),
|
||||
tint = AppTheme.colors.onErrorContainer
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LazyItemScope.EmptyState(
|
||||
isDiscovering: Boolean,
|
||||
onSearchAgain: () -> Unit
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillParentMaxHeight(0.8f)
|
||||
.fillParentMaxWidth()
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.widthIn(max = 360.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(72.dp)
|
||||
.clip(CircleShape)
|
||||
.background(AppTheme.colors.primary.copy(alpha = 0.12f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (isDiscovering) {
|
||||
MillenniumSpokeSpinner(size = 32.dp)
|
||||
} else {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxWifiSquare,
|
||||
contentDescription = null,
|
||||
tint = AppTheme.colors.primary,
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty
|
||||
),
|
||||
style = AppTheme.typography.titleLarge,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.devices_empty_hint),
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
if (!isDiscovering) {
|
||||
MillenniumButton(
|
||||
onClick = onSearchAgain,
|
||||
style = MillenniumButtonStyle.Secondary,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
) {
|
||||
Text(stringResource(Res.string.devices_search_again))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.remote.RemoteControlClient
|
||||
import org.spotube.spotube.core.remote.RemoteControlService
|
||||
import org.spotube.spotube.modules.settings.SettingsProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.koin.core.component.KoinComponent
|
||||
@ -52,6 +55,28 @@ data class DevicesError(
|
||||
val args: List<String> = emptyList(),
|
||||
val rawMessage: String? = null)
|
||||
|
||||
data class DeviceItemUi(
|
||||
val key: String,
|
||||
/** Null when the device advertised a blank name. */
|
||||
val name: String?,
|
||||
/** `host:port`, or null while the address is still being resolved. */
|
||||
val address: String?,
|
||||
val isConnecting: Boolean,
|
||||
val isConnected: Boolean)
|
||||
|
||||
data class ConnectedDeviceUi(
|
||||
val name: String?,
|
||||
val address: String)
|
||||
|
||||
data class DevicesUiState(
|
||||
val devices: List<DeviceItemUi> = emptyList(),
|
||||
val isDiscovering: Boolean = false,
|
||||
val isConnecting: Boolean = false,
|
||||
val connectedDevice: ConnectedDeviceUi? = null,
|
||||
val error: DevicesError? = null) {
|
||||
val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting
|
||||
}
|
||||
|
||||
class DevicesViewModel(
|
||||
private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent {
|
||||
private val logger = Logger.withTag("DevicesViewModel")
|
||||
@ -61,38 +86,65 @@ class DevicesViewModel(
|
||||
private val settingsProvider: SettingsProvider by inject()
|
||||
|
||||
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
|
||||
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
|
||||
|
||||
private val _isDiscovering = MutableStateFlow(false)
|
||||
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
|
||||
|
||||
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null)
|
||||
val connectingToDevice: StateFlow<DiscoveredDevice?> = _connectingToDevice.asStateFlow()
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
|
||||
private val _error = MutableStateFlow<DevicesError?>(null)
|
||||
val error: StateFlow<DevicesError?> = _error.asStateFlow()
|
||||
|
||||
val uiState: StateFlow<DevicesUiState> = combine(
|
||||
_devices,
|
||||
_isDiscovering,
|
||||
_connectingToDevice,
|
||||
remoteControlClient.connectionState,
|
||||
_error
|
||||
) { devices, isDiscovering, connectingTo, connection, error ->
|
||||
val connected = connection as? ConnectionState.Connected
|
||||
DevicesUiState(
|
||||
devices = devices.values.map { device ->
|
||||
DeviceItemUi(
|
||||
key = device.key,
|
||||
name = device.name.ifBlank { null },
|
||||
address = device.address(),
|
||||
isConnecting = connectingTo?.key == device.key,
|
||||
isConnected = connected != null && device.matches(connected))
|
||||
},
|
||||
isDiscovering = isDiscovering,
|
||||
isConnecting = connectingTo != null,
|
||||
connectedDevice = connected?.let {
|
||||
ConnectedDeviceUi(
|
||||
name = it.deviceName.ifBlank { null },
|
||||
address = "${it.host}:${it.port}")
|
||||
},
|
||||
error = error)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState())
|
||||
|
||||
private var discoveryJob: Job? = null
|
||||
|
||||
init {
|
||||
// Observe connection state from the client
|
||||
viewModelScope.launch {
|
||||
var previous: ConnectionState = remoteControlClient.connectionState.value
|
||||
remoteControlClient.connectionState.collect { state ->
|
||||
_connectionState.value = state
|
||||
if (state is ConnectionState.Error) {
|
||||
val wasConnecting = previous is ConnectionState.Connecting
|
||||
previous = state
|
||||
when (state) {
|
||||
is ConnectionState.Error -> {
|
||||
_error.value = DevicesError(message = state.fallback, rawMessage = state.message)
|
||||
_connectingToDevice.value = null
|
||||
} else if (state is ConnectionState.Disconnected) {
|
||||
_connectingToDevice.value = null
|
||||
} else if (state is ConnectionState.Connected) {
|
||||
// Navigate to remote control screen after successful connection
|
||||
}
|
||||
// Only a drop *during* an attempt means it failed. Switching devices
|
||||
// disconnects the old one first, and that must not cancel the new attempt.
|
||||
is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null
|
||||
is ConnectionState.Connected -> {
|
||||
// Only navigate for a connection this screen started. A connection
|
||||
// that already existed when the screen opened must not bounce the
|
||||
// user straight to the remote screen.
|
||||
if (_connectingToDevice.value != null) {
|
||||
_connectingToDevice.value = null
|
||||
navigationCommands.navigateTo(Routes.RemoteControl)
|
||||
}
|
||||
}
|
||||
is ConnectionState.Connecting -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Whenever the local device id is resolved, drop any of our own
|
||||
// advertisements that may have been picked up before we knew our id.
|
||||
@ -119,7 +171,8 @@ class DevicesViewModel(
|
||||
logger.d { "Discovery event: $event" }
|
||||
when (event) {
|
||||
is DiscoveryState.Discovered -> {
|
||||
event.resolve()
|
||||
// Don't hold up the rest of the events while this device resolves.
|
||||
launch { event.resolve() }
|
||||
if (!isSelf(event.device)) {
|
||||
_devices.update { it + (event.device.key to event.device.copy()) }
|
||||
}
|
||||
@ -137,6 +190,9 @@ class DevicesViewModel(
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// stopDiscovery() (e.g. leaving the screen) is not a failure.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Discovery failed" }
|
||||
_error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString()))
|
||||
@ -175,7 +231,25 @@ class DevicesViewModel(
|
||||
_isDiscovering.value = false
|
||||
}
|
||||
|
||||
fun connectToDevice(device: DiscoveredDevice) {
|
||||
fun onDeviceClick(key: String) {
|
||||
val device = _devices.value[key] ?: return
|
||||
val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected
|
||||
when {
|
||||
// Already controlling it (e.g. came back from the remote screen): just reopen it.
|
||||
connected != null && device.matches(connected) -> openRemote()
|
||||
connected != null -> viewModelScope.launch {
|
||||
remoteControlClient.disconnect()
|
||||
connectToDevice(device)
|
||||
}
|
||||
else -> connectToDevice(device)
|
||||
}
|
||||
}
|
||||
|
||||
fun openRemote() {
|
||||
navigationCommands.navigateTo(Routes.RemoteControl)
|
||||
}
|
||||
|
||||
private fun connectToDevice(device: DiscoveredDevice) {
|
||||
if (_connectingToDevice.value != null) {
|
||||
logger.w { "Already connecting to a device" }
|
||||
return
|
||||
@ -200,11 +274,11 @@ class DevicesViewModel(
|
||||
host = device.host,
|
||||
port = device.port,
|
||||
deviceId = deviceId,
|
||||
deviceName = deviceName)
|
||||
|
||||
// Clear connecting state after connection attempt
|
||||
// The connectionState flow will show the actual connection status
|
||||
_connectingToDevice.value = null
|
||||
deviceName = deviceName,
|
||||
remoteDeviceName = device.name)
|
||||
// connect() returns once the socket is open, but the host may still be
|
||||
// asking its user for permission. The connection-state collector clears
|
||||
// the connecting flag once the outcome is known.
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "Failed to connect to device" }
|
||||
_error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString()))
|
||||
@ -223,3 +297,9 @@ class DevicesViewModel(
|
||||
_error.value = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun DiscoveredDevice.address(): String? =
|
||||
if (host.isNotBlank() && port > 0) "$host:$port" else null
|
||||
|
||||
private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean =
|
||||
host == connected.host && port == connected.port
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import co.touchlab.kermit.Logger
|
||||
import org.spotube.spotube.core.remote.ConnectionState
|
||||
import org.spotube.spotube.core.remote.RemoteControlClient
|
||||
import org.spotube.spotube.core.remote.RemoteControlCommand
|
||||
import org.spotube.spotube.core.remote.RemoteControlEvent
|
||||
import org.spotube.spotube.core.remote.RemoteQueueEntry
|
||||
import org.spotube.spotube.modules.shell.formatPlayerTime
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
data class RemotePlayerState(
|
||||
enum class RemoteLoopMode(val wireValue: String) {
|
||||
NONE("none"),
|
||||
ONE("one"),
|
||||
ALL("all");
|
||||
|
||||
fun next(): RemoteLoopMode = when (this) {
|
||||
NONE -> ONE
|
||||
ONE -> ALL
|
||||
ALL -> NONE
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE
|
||||
}
|
||||
}
|
||||
|
||||
enum class RemoteVolumeLevel { MUTED, LOW, HIGH }
|
||||
|
||||
data class RemoteQueueItemUi(
|
||||
val key: String,
|
||||
val index: Int,
|
||||
val mediaUrl: String,
|
||||
val title: String,
|
||||
val artists: String,
|
||||
val coverUrl: String?,
|
||||
val durationLabel: String,
|
||||
val isCurrent: Boolean)
|
||||
|
||||
data class RemoteControlUiState(
|
||||
val connection: ConnectionState = ConnectionState.Disconnected,
|
||||
val deviceName: String = "",
|
||||
val hasTrack: Boolean = false,
|
||||
val title: String? = null,
|
||||
val artists: String? = null,
|
||||
val album: String? = null,
|
||||
val coverUrl: String? = null,
|
||||
val isPlaying: Boolean = false,
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val volume: Float = 1.0f,
|
||||
val shuffleEnabled: Boolean = false,
|
||||
val loopMode: String = "none",
|
||||
val currentTrackId: String? = null,
|
||||
val currentTrackTitle: String? = null,
|
||||
val currentTrackArtists: String? = null,
|
||||
val currentTrackAlbum: String? = null,
|
||||
val currentTrackCoverUrl: String? = null)
|
||||
val isShuffling: Boolean = false,
|
||||
val loopMode: RemoteLoopMode = RemoteLoopMode.NONE,
|
||||
val canSeek: Boolean = false,
|
||||
val seekProgress: Float = 0f,
|
||||
val positionLabel: String = formatPlayerTime(0.milliseconds),
|
||||
val durationLabel: String = formatPlayerTime(0.milliseconds),
|
||||
val volume: Float = 1f,
|
||||
val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH,
|
||||
val queue: List<RemoteQueueItemUi> = emptyList(),
|
||||
val currentQueueIndex: Int = -1,
|
||||
val isQueueVisible: Boolean = false)
|
||||
|
||||
data class RemoteQueueState(
|
||||
val entries: List<RemoteQueueEntry> = emptyList(),
|
||||
val currentIndex: Int = -1)
|
||||
private data class LocalOverrides(
|
||||
val seekProgress: Float?,
|
||||
val volume: Float?,
|
||||
val isQueueVisible: Boolean)
|
||||
|
||||
class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
private val logger = Logger.withTag("RemoteControlViewModel")
|
||||
private val remoteControlClient: RemoteControlClient by inject()
|
||||
|
||||
private val _playerState = MutableStateFlow(RemotePlayerState())
|
||||
val playerState: StateFlow<RemotePlayerState> = _playerState.asStateFlow()
|
||||
// While the user drags a slider these hold the local value, so the host's
|
||||
// periodic state pushes don't yank the thumb back mid-gesture.
|
||||
private val seekOverride = MutableStateFlow<Float?>(null)
|
||||
private val volumeOverride = MutableStateFlow<Float?>(null)
|
||||
private val isQueueVisible = MutableStateFlow(false)
|
||||
|
||||
private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
|
||||
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
|
||||
private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible ->
|
||||
LocalOverrides(seek, volume, queueVisible)
|
||||
}
|
||||
|
||||
private val _queueState = MutableStateFlow(RemoteQueueState())
|
||||
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow()
|
||||
|
||||
private val _isQueueVisible = MutableStateFlow(false)
|
||||
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow()
|
||||
val uiState: StateFlow<RemoteControlUiState> = combine(
|
||||
remoteControlClient.connectionState,
|
||||
remoteControlClient.latestPlayerState,
|
||||
remoteControlClient.latestQueue,
|
||||
overrides
|
||||
) { connection, player, queue, local ->
|
||||
buildUiState(connection, player, queue, local)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState())
|
||||
|
||||
init {
|
||||
// Stream volume changes while dragging, throttled so a fast drag doesn't
|
||||
// flood the socket with one command per frame.
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.connectionState.collect { state ->
|
||||
_connectionState.value = state
|
||||
volumeOverride.filterNotNull().collect { volume ->
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
|
||||
delay(VOLUME_THROTTLE_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildUiState(
|
||||
connection: ConnectionState,
|
||||
player: RemoteControlEvent.PlayerState?,
|
||||
queue: RemoteControlEvent.QueueUpdated?,
|
||||
local: LocalOverrides
|
||||
): RemoteControlUiState {
|
||||
val durationMs = player?.durationMs ?: 0L
|
||||
val canSeek = durationMs > 0L
|
||||
val liveProgress = if (canSeek) {
|
||||
((player?.positionMs ?: 0L).toFloat() / durationMs).coerceIn(0f, 1f)
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
val seekProgress = local.seekProgress ?: liveProgress
|
||||
val currentIndex = queue?.currentIndex ?: -1
|
||||
val volume = local.volume ?: player?.volume ?: 1f
|
||||
|
||||
return RemoteControlUiState(
|
||||
connection = connection,
|
||||
deviceName = (connection as? ConnectionState.Connected)?.deviceName.orEmpty(),
|
||||
hasTrack = player?.currentTrackId != null || player?.currentTrackTitle != null,
|
||||
title = player?.currentTrackTitle,
|
||||
artists = player?.currentTrackArtists,
|
||||
album = player?.currentTrackAlbum,
|
||||
coverUrl = player?.currentTrackCoverUrl,
|
||||
isPlaying = player?.isPlaying ?: false,
|
||||
isShuffling = player?.shuffleEnabled ?: false,
|
||||
loopMode = RemoteLoopMode.fromWire(player?.loopMode ?: RemoteLoopMode.NONE.wireValue),
|
||||
canSeek = canSeek,
|
||||
seekProgress = seekProgress,
|
||||
positionLabel = formatPlayerTime((durationMs * seekProgress).toLong().milliseconds),
|
||||
durationLabel = formatPlayerTime(durationMs.milliseconds),
|
||||
volume = volume,
|
||||
volumeLevel = when {
|
||||
volume <= 0f -> RemoteVolumeLevel.MUTED
|
||||
volume < 0.5f -> RemoteVolumeLevel.LOW
|
||||
else -> RemoteVolumeLevel.HIGH
|
||||
},
|
||||
queue = queue?.entries.orEmpty().mapIndexed { index, entry ->
|
||||
RemoteQueueItemUi(
|
||||
key = "${entry.mediaUrl}@$index",
|
||||
index = index,
|
||||
mediaUrl = entry.mediaUrl,
|
||||
title = entry.title,
|
||||
artists = entry.artists,
|
||||
coverUrl = entry.coverUrl,
|
||||
durationLabel = formatPlayerTime(entry.durationMs.milliseconds),
|
||||
isCurrent = index == currentIndex)
|
||||
},
|
||||
currentQueueIndex = currentIndex,
|
||||
isQueueVisible = local.isQueueVisible)
|
||||
}
|
||||
|
||||
private fun send(command: RemoteControlCommand) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestPlayerState.collect { event ->
|
||||
if (event != null) {
|
||||
handlePlayerState(event)
|
||||
}
|
||||
remoteControlClient.sendCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps [override] at [value] until the host reports back (or a timeout), so
|
||||
* the slider doesn't flash the stale value in between. Skips the reset if the
|
||||
* user already started a new drag.
|
||||
*/
|
||||
private suspend fun releaseOverrideAfterEcho(override: MutableStateFlow<Float?>, value: Float) {
|
||||
withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) {
|
||||
remoteControlClient.latestPlayerState.drop(1).first()
|
||||
}
|
||||
override.compareAndSet(value, null)
|
||||
}
|
||||
|
||||
fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause)
|
||||
|
||||
fun skipNext() = send(RemoteControlCommand.SkipNext)
|
||||
|
||||
fun skipPrevious() = send(RemoteControlCommand.SkipPrevious)
|
||||
|
||||
fun onSeekChange(progress: Float) {
|
||||
seekOverride.value = progress.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
fun onSeekFinished() {
|
||||
val progress = seekOverride.value ?: return
|
||||
val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L
|
||||
if (durationMs <= 0L) {
|
||||
seekOverride.value = null
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.latestQueue.collect { event ->
|
||||
if (event != null) {
|
||||
handleQueueUpdated(event)
|
||||
}
|
||||
}
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong()))
|
||||
releaseOverrideAfterEcho(seekOverride, progress)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) {
|
||||
_playerState.update {
|
||||
it.copy(
|
||||
isPlaying = event.isPlaying,
|
||||
positionMs = event.positionMs,
|
||||
durationMs = event.durationMs,
|
||||
volume = event.volume,
|
||||
shuffleEnabled = event.shuffleEnabled,
|
||||
loopMode = event.loopMode,
|
||||
currentTrackId = event.currentTrackId,
|
||||
currentTrackTitle = event.currentTrackTitle,
|
||||
currentTrackArtists = event.currentTrackArtists,
|
||||
currentTrackAlbum = event.currentTrackAlbum,
|
||||
currentTrackCoverUrl = event.currentTrackCoverUrl)
|
||||
}
|
||||
fun onVolumeChange(volume: Float) {
|
||||
volumeOverride.value = volume.coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) {
|
||||
_queueState.value = RemoteQueueState(
|
||||
entries = event.entries,
|
||||
currentIndex = event.currentIndex)
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause)
|
||||
}
|
||||
}
|
||||
|
||||
fun skipNext() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SkipNext)
|
||||
}
|
||||
}
|
||||
|
||||
fun skipPrevious() {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious)
|
||||
}
|
||||
}
|
||||
|
||||
fun seek(positionMs: Long) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs))
|
||||
}
|
||||
}
|
||||
|
||||
fun setVolume(volume: Float) {
|
||||
fun onVolumeChangeFinished() {
|
||||
val volume = volumeOverride.value ?: return
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
|
||||
releaseOverrideAfterEcho(volumeOverride, volume)
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShuffle() {
|
||||
viewModelScope.launch {
|
||||
val newState = !_playerState.value.shuffleEnabled
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState))
|
||||
}
|
||||
val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false
|
||||
send(RemoteControlCommand.SetShuffle(!shuffling))
|
||||
}
|
||||
|
||||
fun cycleLoopMode() {
|
||||
viewModelScope.launch {
|
||||
val currentMode = _playerState.value.loopMode
|
||||
val newMode = when (currentMode) {
|
||||
"none" -> "one"
|
||||
"one" -> "all"
|
||||
else -> "none"
|
||||
}
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode))
|
||||
}
|
||||
val current = RemoteLoopMode.fromWire(
|
||||
remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue)
|
||||
send(RemoteControlCommand.SetLoopMode(current.next().wireValue))
|
||||
}
|
||||
|
||||
fun playQueueItem(index: Int) {
|
||||
if (index < 0) return
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
|
||||
}
|
||||
send(RemoteControlCommand.PlayIndex(index))
|
||||
}
|
||||
|
||||
fun removeQueueItem(mediaUrl: String) {
|
||||
viewModelScope.launch {
|
||||
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
|
||||
}
|
||||
}
|
||||
fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl))
|
||||
|
||||
fun toggleQueueVisibility() {
|
||||
_isQueueVisible.update { !it }
|
||||
isQueueVisible.update { !it }
|
||||
}
|
||||
|
||||
fun dismissQueue() {
|
||||
isQueueVisible.value = false
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
|
||||
remoteControlClient.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val VOLUME_THROTTLE_MS = 100L
|
||||
const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,7 +56,6 @@ data class UserSettings(
|
||||
val cacheFolder: String? = null,
|
||||
val cacheSizeLimitMB: Long = -1L,
|
||||
val enableEndlessPlayback: Boolean = true,
|
||||
val enableConnect: Boolean = false,
|
||||
val playbackProxyServerPort: Int = 14769,
|
||||
|
||||
// Remote Control (LAN)
|
||||
|
||||
@ -122,21 +122,6 @@ internal fun LazyListScope.playbackSection(
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_enable_connect_title),
|
||||
subtitle = stringResource(Res.string.settings_enable_connect_subtitle),
|
||||
icon = {
|
||||
SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title))
|
||||
},
|
||||
checked = settings.enableConnect,
|
||||
onCheckedChange = { enabled ->
|
||||
settingsViewModel.updateSettings {
|
||||
copy(enableConnect = enabled)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
add {
|
||||
SwitchSettingCard(
|
||||
title = stringResource(Res.string.settings_allow_remote_control_title),
|
||||
|
||||
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* 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.discovery
|
||||
|
||||
// The OS's own service-discovery stack advertises on the right network.
|
||||
internal actual fun preferredAdvertiseAddress(): String? = null
|
||||
@ -0,0 +1,53 @@
|
||||
/*
|
||||
* 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.discovery
|
||||
|
||||
import java.net.DatagramSocket
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
|
||||
// Interface name prefixes for container/VM/VPN links other LAN devices can't reach.
|
||||
private val VirtualInterfacePrefixes = listOf(
|
||||
"docker", "br-", "veth", "virbr", "vmnet", "vboxnet", "cni", "flannel", "podman",
|
||||
"lxc", "lxd", "tun", "tap", "wg", "zt", "tailscale", "utun", "ham")
|
||||
|
||||
internal actual fun preferredAdvertiseAddress(): String? =
|
||||
(routedLanAddress() ?: firstPhysicalLanAddress())?.hostAddress
|
||||
|
||||
/**
|
||||
* The source address the OS would use to reach the outside world, i.e. the interface that
|
||||
* owns the default route. A connected UDP socket sends nothing; it only resolves the route.
|
||||
*/
|
||||
private fun routedLanAddress(): InetAddress? = runCatching {
|
||||
DatagramSocket().use { socket ->
|
||||
socket.connect(InetAddress.getByName("192.0.2.1"), 9) // TEST-NET-1, never routed anywhere
|
||||
socket.localAddress
|
||||
}
|
||||
}.getOrNull()?.takeIf { it is Inet4Address && it.isSiteLocalAddress }
|
||||
|
||||
/** Fallback for networks without a default route (e.g. an offline LAN). */
|
||||
private fun firstPhysicalLanAddress(): InetAddress? = runCatching {
|
||||
NetworkInterface.getNetworkInterfaces().toList()
|
||||
.filter { iface ->
|
||||
iface.isUp && !iface.isLoopback && !iface.isVirtual && !iface.isPointToPoint &&
|
||||
VirtualInterfacePrefixes.none { iface.name.startsWith(it) }
|
||||
}
|
||||
.flatMap { it.inetAddresses.toList() }
|
||||
.firstOrNull { it is Inet4Address && it.isSiteLocalAddress }
|
||||
}.getOrNull()
|
||||
Loading…
Reference in New Issue
Block a user