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:
Kingkor Roy Tirtho 2026-10-20 15:11:10 +06:00
parent facc8ed0b9
commit b38a4f6963
35 changed files with 1385 additions and 956 deletions

View File

@ -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

View File

@ -225,8 +225,6 @@
<string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string> <string name="settings_error_cache_size_range">يجب أن يكون الحجم 0 أو رقمًا موجبًا.</string>
<string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string> <string name="settings_enable_endless_playback_title">تفعيل التشغيل اللانهائي</string>
<string name="settings_enable_endless_playback_subtitle">واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.</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_playback_port_title">منفذ خادم وكيل التشغيل</string>
<string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string> <string name="settings_allow_remote_control_title">السماح بالتحكم عن بُعد</string>
<string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string> <string name="settings_allow_remote_control_subtitle">اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.</string>
@ -511,7 +509,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">الأجهزة</string> <string name="devices_title">الأجهزة</string>
<string name="devices_refresh">تحديث</string> <string name="devices_refresh">تحديث</string>
<string name="devices_connected_to">متصل بـ %1$s:%2$d</string>
<string name="devices_disconnect">قطع الاتصال</string> <string name="devices_disconnect">قطع الاتصال</string>
<string name="devices_connecting">جارٍ الاتصال...</string> <string name="devices_connecting">جارٍ الاتصال...</string>
<string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string> <string name="devices_searching">جارٍ البحث عن أجهزة على الشبكة...</string>
@ -519,20 +516,13 @@
<string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string> <string name="devices_empty_hint">تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.</string>
<string name="devices_unknown_device">جهاز غير معروف</string> <string name="devices_unknown_device">جهاز غير معروف</string>
<string name="devices_resolving">جارٍ التحديد...</string> <string name="devices_resolving">جارٍ التحديد...</string>
<string name="devices_device_id">المعرّف: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">التحكم عن بُعد</string> <string name="devices_remote_title">التحكم عن بُعد</string>
<string name="devices_remote_disconnected">غير متصل</string> <string name="devices_remote_disconnected">غير متصل</string>
<string name="devices_remote_connection_error">خطأ في الاتصال: %1$s</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_track">مقطع غير معروف</string>
<string name="devices_remote_unknown_artist">فنان غير معروف</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_volume">مستوى الصوت</string>
<string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string> <string name="devices_remote_remove_from_queue">إزالة من قائمة الانتظار</string>
<string name="devices_remote_more_options">خيارات إضافية</string> <string name="devices_remote_more_options">خيارات إضافية</string>

View File

@ -213,8 +213,6 @@
<string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string> <string name="settings_error_cache_size_range">সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।</string>
<string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string> <string name="settings_enable_endless_playback_title">অবিরাম প্লেব্যাক চালু করুন</string>
<string name="settings_enable_endless_playback_subtitle">কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।</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_playback_port_title">প্লেব্যাক প্রক্সি সার্ভার পোর্ট</string>
<string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string> <string name="settings_allow_remote_control_title">রিমোট কন্ট্রোলের অনুমতি দিন</string>
<string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string> <string name="settings_allow_remote_control_subtitle">একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।</string>
@ -495,7 +493,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">ডিভাইস</string> <string name="devices_title">ডিভাইস</string>
<string name="devices_refresh">রিফ্রেশ</string> <string name="devices_refresh">রিফ্রেশ</string>
<string name="devices_connected_to">%1$s:%2$d-এ কানেক্টেড</string>
<string name="devices_disconnect">ডিসকানেক্ট</string> <string name="devices_disconnect">ডিসকানেক্ট</string>
<string name="devices_connecting">কানেক্ট হচ্ছে...</string> <string name="devices_connecting">কানেক্ট হচ্ছে...</string>
<string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string> <string name="devices_searching">নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...</string>
@ -503,20 +500,13 @@
<string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string> <string name="devices_empty_hint">অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।</string>
<string name="devices_unknown_device">অজানা ডিভাইস</string> <string name="devices_unknown_device">অজানা ডিভাইস</string>
<string name="devices_resolving">খোঁজা হচ্ছে...</string> <string name="devices_resolving">খোঁজা হচ্ছে...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">রিমোট কন্ট্রোল</string> <string name="devices_remote_title">রিমোট কন্ট্রোল</string>
<string name="devices_remote_disconnected">ডিসকানেক্টেড</string> <string name="devices_remote_disconnected">ডিসকানেক্টেড</string>
<string name="devices_remote_connection_error">কানেকশন ত্রুটি: %1$s</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_track">অজানা ট্র্যাক</string>
<string name="devices_remote_unknown_artist">অজানা শিল্পী</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_volume">ভলিউম</string>
<string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string> <string name="devices_remote_remove_from_queue">কিউ থেকে সরান</string>
<string name="devices_remote_more_options">আরও অপশন</string> <string name="devices_remote_more_options">আরও অপশন</string>

View File

@ -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_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_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_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_playback_port_title">Port des Wiedergabe-Proxyservers</string>
<string name="settings_allow_remote_control_title">Fernsteuerung erlauben</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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Geräte</string> <string name="devices_title">Geräte</string>
<string name="devices_refresh">Aktualisieren</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_disconnect">Trennen</string>
<string name="devices_connecting">Verbinden...</string> <string name="devices_connecting">Verbinden...</string>
<string name="devices_searching">Suche nach Geräten im Netzwerk...</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_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_unknown_device">Unbekanntes Gerät</string>
<string name="devices_resolving">Wird aufgelöst...</string> <string name="devices_resolving">Wird aufgelöst...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Fernsteuerung</string> <string name="devices_remote_title">Fernsteuerung</string>
<string name="devices_remote_disconnected">Getrennt</string> <string name="devices_remote_disconnected">Getrennt</string>
<string name="devices_remote_connection_error">Verbindungsfehler: %1$s</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_track">Unbekannter Titel</string>
<string name="devices_remote_unknown_artist">Unbekannter Künstler</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_volume">Lautstärke</string>
<string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string> <string name="devices_remote_remove_from_queue">Aus Warteschlange entfernen</string>
<string name="devices_remote_more_options">Weitere Optionen</string> <string name="devices_remote_more_options">Weitere Optionen</string>

View File

@ -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_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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivos</string> <string name="devices_title">Dispositivos</string>
<string name="devices_refresh">Actualizar</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_disconnect">Desconectar</string>
<string name="devices_connecting">Conectando...</string> <string name="devices_connecting">Conectando...</string>
<string name="devices_searching">Buscando dispositivos en la red...</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_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_unknown_device">Dispositivo desconocido</string>
<string name="devices_resolving">Resolviendo...</string> <string name="devices_resolving">Resolviendo...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Control remoto</string> <string name="devices_remote_title">Control remoto</string>
<string name="devices_remote_disconnected">Desconectado</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_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_track">Canción desconocida</string>
<string name="devices_remote_unknown_artist">Artista desconocido</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_volume">Volumen</string>
<string name="devices_remote_remove_from_queue">Quitar de la cola</string> <string name="devices_remote_remove_from_queue">Quitar de la cola</string>
<string name="devices_remote_more_options">Más opciones</string> <string name="devices_remote_more_options">Más opciones</string>

View File

@ -213,8 +213,6 @@
<string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string> <string name="settings_error_cache_size_range">حجم باید 0 یا یک عدد مثبت باشد.</string>
<string name="settings_enable_endless_playback_title">فعال‌سازی پخش بی‌پایان</string> <string name="settings_enable_endless_playback_title">فعال‌سازی پخش بی‌پایان</string>
<string name="settings_enable_endless_playback_subtitle">وقتی صف پخش تمام شد، پخش با آهنگ‌های پیشنهادی ادامه پیدا کند.</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_playback_port_title">درگاه سرور پراکسی پخش</string>
<string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string> <string name="settings_allow_remote_control_title">اجازهٔ کنترل از راه دور</string>
<string name="settings_allow_remote_control_subtitle">به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string> <string name="settings_allow_remote_control_subtitle">به دستگاه‌های دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.</string>
@ -495,7 +493,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">دستگاه‌ها</string> <string name="devices_title">دستگاه‌ها</string>
<string name="devices_refresh">به‌روزرسانی</string> <string name="devices_refresh">به‌روزرسانی</string>
<string name="devices_connected_to">متصل به %1$s:%2$d</string>
<string name="devices_disconnect">قطع اتصال</string> <string name="devices_disconnect">قطع اتصال</string>
<string name="devices_connecting">در حال اتصال...</string> <string name="devices_connecting">در حال اتصال...</string>
<string name="devices_searching">در حال جستجوی دستگاه‌ها در شبکه...</string> <string name="devices_searching">در حال جستجوی دستگاه‌ها در شبکه...</string>
@ -503,20 +500,13 @@
<string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string> <string name="devices_empty_hint">مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.</string>
<string name="devices_unknown_device">دستگاه ناشناس</string> <string name="devices_unknown_device">دستگاه ناشناس</string>
<string name="devices_resolving">در حال شناسایی...</string> <string name="devices_resolving">در حال شناسایی...</string>
<string name="devices_device_id">شناسه: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">کنترل از راه دور</string> <string name="devices_remote_title">کنترل از راه دور</string>
<string name="devices_remote_disconnected">قطع شد</string> <string name="devices_remote_disconnected">قطع شد</string>
<string name="devices_remote_connection_error">خطای اتصال: %1$s</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_track">آهنگ ناشناس</string>
<string name="devices_remote_unknown_artist">هنرمند ناشناس</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_volume">صدا</string>
<string name="devices_remote_remove_from_queue">حذف از صف</string> <string name="devices_remote_remove_from_queue">حذف از صف</string>
<string name="devices_remote_more_options">گزینه‌های بیشتر</string> <string name="devices_remote_more_options">گزینه‌های بیشتر</string>

View File

@ -216,8 +216,6 @@
<string name="settings_error_cache_size_range">La taille doit être 0 ou un nombre positif.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Appareils</string> <string name="devices_title">Appareils</string>
<string name="devices_refresh">Actualiser</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_disconnect">Se déconnecter</string>
<string name="devices_connecting">Connexion...</string> <string name="devices_connecting">Connexion...</string>
<string name="devices_searching">Recherche d\'appareils sur le réseau...</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_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_unknown_device">Appareil inconnu</string>
<string name="devices_resolving">Résolution...</string> <string name="devices_resolving">Résolution...</string>
<string name="devices_device_id">ID : %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Contrôle à distance</string> <string name="devices_remote_title">Contrôle à distance</string>
<string name="devices_remote_disconnected">Déconnecté</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_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_track">Titre inconnu</string>
<string name="devices_remote_unknown_artist">Artiste 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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Retirer de la file d\'attente</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> <string name="devices_remote_more_options">Plus d\'options</string>

View File

@ -212,8 +212,6 @@
<string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string> <string name="settings_error_cache_size_range">साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।</string>
<string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string> <string name="settings_enable_endless_playback_title">लगातार प्लेबैक चालू करें</string>
<string name="settings_enable_endless_playback_subtitle">कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।</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_playback_port_title">प्लेबैक प्रॉक्सी सर्वर पोर्ट</string>
<string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string> <string name="settings_allow_remote_control_title">रिमोट कंट्रोल की अनुमति दें</string>
<string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string> <string name="settings_allow_remote_control_subtitle">एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।</string>
@ -494,7 +492,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">डिवाइस</string> <string name="devices_title">डिवाइस</string>
<string name="devices_refresh">रीफ़्रेश करें</string> <string name="devices_refresh">रीफ़्रेश करें</string>
<string name="devices_connected_to">%1$s:%2$d से कनेक्ट है</string>
<string name="devices_disconnect">डिस्कनेक्ट करें</string> <string name="devices_disconnect">डिस्कनेक्ट करें</string>
<string name="devices_connecting">कनेक्ट हो रहा है...</string> <string name="devices_connecting">कनेक्ट हो रहा है...</string>
<string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string> <string name="devices_searching">नेटवर्क पर डिवाइस खोजे जा रहे हैं...</string>
@ -502,20 +499,13 @@
<string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string> <string name="devices_empty_hint">पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।</string>
<string name="devices_unknown_device">अज्ञात डिवाइस</string> <string name="devices_unknown_device">अज्ञात डिवाइस</string>
<string name="devices_resolving">पता लगाया जा रहा है...</string> <string name="devices_resolving">पता लगाया जा रहा है...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">रिमोट कंट्रोल</string> <string name="devices_remote_title">रिमोट कंट्रोल</string>
<string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string> <string name="devices_remote_disconnected">डिस्कनेक्ट हो गया</string>
<string name="devices_remote_connection_error">कनेक्शन में गड़बड़ी: %1$s</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_track">अज्ञात ट्रैक</string>
<string name="devices_remote_unknown_artist">अज्ञात कलाकार</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_volume">वॉल्यूम</string>
<string name="devices_remote_remove_from_queue">कतार से हटाएं</string> <string name="devices_remote_remove_from_queue">कतार से हटाएं</string>
<string name="devices_remote_more_options">और विकल्प</string> <string name="devices_remote_more_options">और विकल्प</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">Ukuran harus 0 atau bilangan positif.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Perangkat</string> <string name="devices_title">Perangkat</string>
<string name="devices_refresh">Muat ulang</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_disconnect">Putuskan</string>
<string name="devices_connecting">Menghubungkan...</string> <string name="devices_connecting">Menghubungkan...</string>
<string name="devices_searching">Mencari perangkat di jaringan...</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_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_unknown_device">Perangkat Tidak Dikenal</string>
<string name="devices_resolving">Menyelesaikan...</string> <string name="devices_resolving">Menyelesaikan...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Kontrol Jarak Jauh</string> <string name="devices_remote_title">Kontrol Jarak Jauh</string>
<string name="devices_remote_disconnected">Terputus</string> <string name="devices_remote_disconnected">Terputus</string>
<string name="devices_remote_connection_error">Kesalahan koneksi: %1$s</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_track">Lagu Tidak Dikenal</string>
<string name="devices_remote_unknown_artist">Artis 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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Hapus dari antrean</string> <string name="devices_remote_remove_from_queue">Hapus dari antrean</string>
<string name="devices_remote_more_options">Opsi lainnya</string> <string name="devices_remote_more_options">Opsi lainnya</string>

View File

@ -216,8 +216,6 @@
<string name="settings_error_cache_size_range">La dimensione deve essere 0 o un numero positivo.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivi</string> <string name="devices_title">Dispositivi</string>
<string name="devices_refresh">Aggiorna</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_disconnect">Disconnetti</string>
<string name="devices_connecting">Connessione...</string> <string name="devices_connecting">Connessione...</string>
<string name="devices_searching">Ricerca di dispositivi sulla rete...</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_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_unknown_device">Dispositivo sconosciuto</string>
<string name="devices_resolving">Risoluzione...</string> <string name="devices_resolving">Risoluzione...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Controllo remoto</string> <string name="devices_remote_title">Controllo remoto</string>
<string name="devices_remote_disconnected">Disconnesso</string> <string name="devices_remote_disconnected">Disconnesso</string>
<string name="devices_remote_connection_error">Errore di connessione: %1$s</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_track">Brano sconosciuto</string>
<string name="devices_remote_unknown_artist">Artista 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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string> <string name="devices_remote_remove_from_queue">Rimuovi dalla coda</string>
<string name="devices_remote_more_options">Altre opzioni</string> <string name="devices_remote_more_options">Altre opzioni</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string> <string name="settings_error_cache_size_range">サイズは0または正の数で指定してください。</string>
<string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string> <string name="settings_enable_endless_playback_title">エンドレス再生を有効化</string>
<string name="settings_enable_endless_playback_subtitle">キューが終わったら、おすすめの曲で再生を続けます。</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_playback_port_title">再生プロキシサーバーのポート</string>
<string name="settings_allow_remote_control_title">リモート操作を許可</string> <string name="settings_allow_remote_control_title">リモート操作を許可</string>
<string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string> <string name="settings_allow_remote_control_subtitle">同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。</string>
@ -491,7 +489,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">デバイス</string> <string name="devices_title">デバイス</string>
<string name="devices_refresh">更新</string> <string name="devices_refresh">更新</string>
<string name="devices_connected_to">%1$s:%2$d に接続済み</string>
<string name="devices_disconnect">切断</string> <string name="devices_disconnect">切断</string>
<string name="devices_connecting">接続中...</string> <string name="devices_connecting">接続中...</string>
<string name="devices_searching">ネットワーク上のデバイスを検索中...</string> <string name="devices_searching">ネットワーク上のデバイスを検索中...</string>
@ -499,20 +496,13 @@
<string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string> <string name="devices_empty_hint">相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。</string>
<string name="devices_unknown_device">不明なデバイス</string> <string name="devices_unknown_device">不明なデバイス</string>
<string name="devices_resolving">解決中...</string> <string name="devices_resolving">解決中...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">リモート操作</string> <string name="devices_remote_title">リモート操作</string>
<string name="devices_remote_disconnected">切断されました</string> <string name="devices_remote_disconnected">切断されました</string>
<string name="devices_remote_connection_error">接続エラー: %1$s</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_track">不明な曲</string>
<string name="devices_remote_unknown_artist">不明なアーティスト</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_volume">音量</string>
<string name="devices_remote_remove_from_queue">キューから削除</string> <string name="devices_remote_remove_from_queue">キューから削除</string>
<string name="devices_remote_more_options">その他のオプション</string> <string name="devices_remote_more_options">その他のオプション</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string> <string name="settings_error_cache_size_range">용량은 0 또는 양수여야 해요.</string>
<string name="settings_enable_endless_playback_title">끝없는 재생 사용</string> <string name="settings_enable_endless_playback_title">끝없는 재생 사용</string>
<string name="settings_enable_endless_playback_subtitle">대기열이 끝나면 추천 트랙으로 계속 재생해요.</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_playback_port_title">재생 프록시 서버 포트</string>
<string name="settings_allow_remote_control_title">원격 제어 허용</string> <string name="settings_allow_remote_control_title">원격 제어 허용</string>
<string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string> <string name="settings_allow_remote_control_subtitle">같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.</string>
@ -491,7 +489,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">기기</string> <string name="devices_title">기기</string>
<string name="devices_refresh">새로고침</string> <string name="devices_refresh">새로고침</string>
<string name="devices_connected_to">%1$s:%2$d에 연결됨</string>
<string name="devices_disconnect">연결 해제</string> <string name="devices_disconnect">연결 해제</string>
<string name="devices_connecting">연결 중...</string> <string name="devices_connecting">연결 중...</string>
<string name="devices_searching">네트워크에서 기기를 찾는 중...</string> <string name="devices_searching">네트워크에서 기기를 찾는 중...</string>
@ -499,20 +496,13 @@
<string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string> <string name="devices_empty_hint">상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.</string>
<string name="devices_unknown_device">알 수 없는 기기</string> <string name="devices_unknown_device">알 수 없는 기기</string>
<string name="devices_resolving">확인 중...</string> <string name="devices_resolving">확인 중...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">원격 제어</string> <string name="devices_remote_title">원격 제어</string>
<string name="devices_remote_disconnected">연결 끊김</string> <string name="devices_remote_disconnected">연결 끊김</string>
<string name="devices_remote_connection_error">연결 오류: %1$s</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_track">알 수 없는 트랙</string>
<string name="devices_remote_unknown_artist">알 수 없는 아티스트</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_volume">볼륨</string>
<string name="devices_remote_remove_from_queue">대기열에서 삭제</string> <string name="devices_remote_remove_from_queue">대기열에서 삭제</string>
<string name="devices_remote_more_options">옵션 더보기</string> <string name="devices_remote_more_options">옵션 더보기</string>

View File

@ -213,8 +213,6 @@
<string name="settings_error_cache_size_range">Grootte moet 0 of een positief getal zijn.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Apparaten</string> <string name="devices_title">Apparaten</string>
<string name="devices_refresh">Vernieuwen</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_disconnect">Verbinding verbreken</string>
<string name="devices_connecting">Verbinden...</string> <string name="devices_connecting">Verbinden...</string>
<string name="devices_searching">Zoeken naar apparaten op het netwerk...</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_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_unknown_device">Onbekend apparaat</string>
<string name="devices_resolving">Bezig met ophalen...</string> <string name="devices_resolving">Bezig met ophalen...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Bediening op afstand</string> <string name="devices_remote_title">Bediening op afstand</string>
<string name="devices_remote_disconnected">Niet verbonden</string> <string name="devices_remote_disconnected">Niet verbonden</string>
<string name="devices_remote_connection_error">Verbindingsfout: %1$s</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_track">Onbekend nummer</string>
<string name="devices_remote_unknown_artist">Onbekende artiest</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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string> <string name="devices_remote_remove_from_queue">Verwijderen uit wachtrij</string>
<string name="devices_remote_more_options">Meer opties</string> <string name="devices_remote_more_options">Meer opties</string>

View File

@ -219,8 +219,6 @@
<string name="settings_error_cache_size_range">Rozmiar musi wynosić 0 lub być liczbą dodatnią.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Urządzenia</string> <string name="devices_title">Urządzenia</string>
<string name="devices_refresh">Odśwież</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_disconnect">Rozłącz</string>
<string name="devices_connecting">Łączenie...</string> <string name="devices_connecting">Łączenie...</string>
<string name="devices_searching">Wyszukiwanie urządzeń w sieci...</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_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_unknown_device">Nieznane urządzenie</string>
<string name="devices_resolving">Ustalanie adresu...</string> <string name="devices_resolving">Ustalanie adresu...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Zdalne sterowanie</string> <string name="devices_remote_title">Zdalne sterowanie</string>
<string name="devices_remote_disconnected">Rozłączono</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_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_track">Nieznany utwór</string>
<string name="devices_remote_unknown_artist">Nieznany wykonawca</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_volume">Głośność</string>
<string name="devices_remote_remove_from_queue">Usuń z kolejki</string> <string name="devices_remote_remove_from_queue">Usuń z kolejki</string>
<string name="devices_remote_more_options">Więcej opcji</string> <string name="devices_remote_more_options">Więcej opcji</string>

View File

@ -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_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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Dispositivos</string> <string name="devices_title">Dispositivos</string>
<string name="devices_refresh">Atualizar</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_disconnect">Desconectar</string>
<string name="devices_connecting">Conectando...</string> <string name="devices_connecting">Conectando...</string>
<string name="devices_searching">Procurando dispositivos na rede...</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_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_unknown_device">Dispositivo desconhecido</string>
<string name="devices_resolving">Resolvendo...</string> <string name="devices_resolving">Resolvendo...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Controle remoto</string> <string name="devices_remote_title">Controle remoto</string>
<string name="devices_remote_disconnected">Desconectado</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_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_track">Faixa desconhecida</string>
<string name="devices_remote_unknown_artist">Artista desconhecido</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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Remover da fila</string> <string name="devices_remote_remove_from_queue">Remover da fila</string>
<string name="devices_remote_more_options">Mais opções</string> <string name="devices_remote_more_options">Mais opções</string>

View File

@ -219,8 +219,6 @@
<string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string> <string name="settings_error_cache_size_range">Размер должен быть 0 или положительным числом.</string>
<string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string> <string name="settings_enable_endless_playback_title">Бесконечное воспроизведение</string>
<string name="settings_enable_endless_playback_subtitle">Продолжать воспроизведение рекомендованными треками, когда очередь закончится.</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_playback_port_title">Порт прокси-сервера воспроизведения</string>
<string name="settings_allow_remote_control_title">Разрешить удалённое управление</string> <string name="settings_allow_remote_control_title">Разрешить удалённое управление</string>
<string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string> <string name="settings_allow_remote_control_subtitle">Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.</string>
@ -503,7 +501,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Устройства</string> <string name="devices_title">Устройства</string>
<string name="devices_refresh">Обновить</string> <string name="devices_refresh">Обновить</string>
<string name="devices_connected_to">Подключено к %1$s:%2$d</string>
<string name="devices_disconnect">Отключиться</string> <string name="devices_disconnect">Отключиться</string>
<string name="devices_connecting">Подключение...</string> <string name="devices_connecting">Подключение...</string>
<string name="devices_searching">Поиск устройств в сети...</string> <string name="devices_searching">Поиск устройств в сети...</string>
@ -511,20 +508,13 @@
<string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string> <string name="devices_empty_hint">Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».</string>
<string name="devices_unknown_device">Неизвестное устройство</string> <string name="devices_unknown_device">Неизвестное устройство</string>
<string name="devices_resolving">Определение адреса...</string> <string name="devices_resolving">Определение адреса...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Удалённое управление</string> <string name="devices_remote_title">Удалённое управление</string>
<string name="devices_remote_disconnected">Отключено</string> <string name="devices_remote_disconnected">Отключено</string>
<string name="devices_remote_connection_error">Ошибка подключения: %1$s</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_track">Неизвестный трек</string>
<string name="devices_remote_unknown_artist">Неизвестный исполнитель</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_volume">Громкость</string>
<string name="devices_remote_remove_from_queue">Удалить из очереди</string> <string name="devices_remote_remove_from_queue">Удалить из очереди</string>
<string name="devices_remote_more_options">Другие действия</string> <string name="devices_remote_more_options">Другие действия</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string> <string name="settings_error_cache_size_range">ขนาดต้องเป็น 0 หรือจำนวนบวก</string>
<string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string> <string name="settings_enable_endless_playback_title">เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด</string>
<string name="settings_enable_endless_playback_subtitle">เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด</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_playback_port_title">พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น</string>
<string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string> <string name="settings_allow_remote_control_title">อนุญาตการควบคุมระยะไกล</string>
<string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string> <string name="settings_allow_remote_control_subtitle">ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ</string>
@ -491,7 +489,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">อุปกรณ์</string> <string name="devices_title">อุปกรณ์</string>
<string name="devices_refresh">รีเฟรช</string> <string name="devices_refresh">รีเฟรช</string>
<string name="devices_connected_to">เชื่อมต่อกับ %1$s:%2$d แล้ว</string>
<string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string> <string name="devices_disconnect">ยกเลิกการเชื่อมต่อ</string>
<string name="devices_connecting">กำลังเชื่อมต่อ...</string> <string name="devices_connecting">กำลังเชื่อมต่อ...</string>
<string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string> <string name="devices_searching">กำลังค้นหาอุปกรณ์ในเครือข่าย...</string>
@ -499,20 +496,13 @@
<string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string> <string name="devices_empty_hint">ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า</string>
<string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string> <string name="devices_unknown_device">อุปกรณ์ที่ไม่รู้จัก</string>
<string name="devices_resolving">กำลังระบุ...</string> <string name="devices_resolving">กำลังระบุ...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">รีโมตคอนโทรล</string> <string name="devices_remote_title">รีโมตคอนโทรล</string>
<string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string> <string name="devices_remote_disconnected">ยกเลิกการเชื่อมต่อแล้ว</string>
<string name="devices_remote_connection_error">ข้อผิดพลาดในการเชื่อมต่อ: %1$s</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_track">ไม่ทราบชื่อเพลง</string>
<string name="devices_remote_unknown_artist">ไม่ทราบศิลปิน</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_volume">ระดับเสียง</string>
<string name="devices_remote_remove_from_queue">นำออกจากคิว</string> <string name="devices_remote_remove_from_queue">นำออกจากคิว</string>
<string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string> <string name="devices_remote_more_options">ตัวเลือกเพิ่มเติม</string>

View File

@ -213,8 +213,6 @@
<string name="settings_error_cache_size_range">Boyut 0 veya pozitif bir sayı olmalı.</string> <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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Cihazlar</string> <string name="devices_title">Cihazlar</string>
<string name="devices_refresh">Yenile</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_disconnect">Bağlantıyı kes</string>
<string name="devices_connecting">Bağlanıyor...</string> <string name="devices_connecting">Bağlanıyor...</string>
<string name="devices_searching">Ağda cihaz aranı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_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_unknown_device">Bilinmeyen Cihaz</string>
<string name="devices_resolving">Çözümleniyor...</string> <string name="devices_resolving">Çözümleniyor...</string>
<string name="devices_device_id">Kimlik: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Uzaktan Kontrol</string> <string name="devices_remote_title">Uzaktan Kontrol</string>
<string name="devices_remote_disconnected">Bağlantı kesildi</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_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_track">Bilinmeyen Parça</string>
<string name="devices_remote_unknown_artist">Bilinmeyen Sanatçı</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_volume">Ses düzeyi</string>
<string name="devices_remote_remove_from_queue">Sıradan kaldır</string> <string name="devices_remote_remove_from_queue">Sıradan kaldır</string>
<string name="devices_remote_more_options">Diğer seçenekler</string> <string name="devices_remote_more_options">Diğer seçenekler</string>

View File

@ -219,8 +219,6 @@
<string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string> <string name="settings_error_cache_size_range">Розмір має бути 0 або додатним числом.</string>
<string name="settings_enable_endless_playback_title">Безкінечне відтворення</string> <string name="settings_enable_endless_playback_title">Безкінечне відтворення</string>
<string name="settings_enable_endless_playback_subtitle">Продовжувати відтворення рекомендованими треками, коли черга закінчиться.</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_playback_port_title">Порт проксі-сервера відтворення</string>
<string name="settings_allow_remote_control_title">Дозволити віддалене керування</string> <string name="settings_allow_remote_control_title">Дозволити віддалене керування</string>
<string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string> <string name="settings_allow_remote_control_subtitle">Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.</string>
@ -503,7 +501,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Пристрої</string> <string name="devices_title">Пристрої</string>
<string name="devices_refresh">Оновити</string> <string name="devices_refresh">Оновити</string>
<string name="devices_connected_to">Підключено до %1$s:%2$d</string>
<string name="devices_disconnect">Від’єднатися</string> <string name="devices_disconnect">Від’єднатися</string>
<string name="devices_connecting">Підключення...</string> <string name="devices_connecting">Підключення...</string>
<string name="devices_searching">Пошук пристроїв у мережі...</string> <string name="devices_searching">Пошук пристроїв у мережі...</string>
@ -511,20 +508,13 @@
<string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string> <string name="devices_empty_hint">Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».</string>
<string name="devices_unknown_device">Невідомий пристрій</string> <string name="devices_unknown_device">Невідомий пристрій</string>
<string name="devices_resolving">Визначення адреси...</string> <string name="devices_resolving">Визначення адреси...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Віддалене керування</string> <string name="devices_remote_title">Віддалене керування</string>
<string name="devices_remote_disconnected">Від’єднано</string> <string name="devices_remote_disconnected">Від’єднано</string>
<string name="devices_remote_connection_error">Помилка підключення: %1$s</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_track">Невідомий трек</string>
<string name="devices_remote_unknown_artist">Невідомий виконавець</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_volume">Гучність</string>
<string name="devices_remote_remove_from_queue">Видалити з черги</string> <string name="devices_remote_remove_from_queue">Видалити з черги</string>
<string name="devices_remote_more_options">Інші дії</string> <string name="devices_remote_more_options">Інші дії</string>

View File

@ -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_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_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_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_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_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> <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 --> <!-- Devices: discovery screen -->
<string name="devices_title">Thiết bị</string> <string name="devices_title">Thiết bị</string>
<string name="devices_refresh">Làm mới</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_disconnect">Ngắt kết nối</string>
<string name="devices_connecting">Đang 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> <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_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_unknown_device">Thiết bị không xác định</string>
<string name="devices_resolving">Đang phân giải...</string> <string name="devices_resolving">Đang phân giải...</string>
<string name="devices_device_id">ID: %1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Điều khiển từ xa</string> <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_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_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_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_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_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_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> <string name="devices_remote_more_options">Tùy chọn khác</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">大小必須為 0 或正數。</string> <string name="settings_error_cache_size_range">大小必須為 0 或正數。</string>
<string name="settings_enable_endless_playback_title">啟用無限播放</string> <string name="settings_enable_endless_playback_title">啟用無限播放</string>
<string name="settings_enable_endless_playback_subtitle">待播清單播完後,以推薦曲目繼續播放。</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_playback_port_title">播放代理伺服器連接埠</string>
<string name="settings_allow_remote_control_title">允許遠端控制</string> <string name="settings_allow_remote_control_title">允許遠端控制</string>
<string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string> <string name="settings_allow_remote_control_subtitle">讓同一網路中的其他裝置在連線後控制播放。</string>
@ -491,7 +489,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">裝置</string> <string name="devices_title">裝置</string>
<string name="devices_refresh">重新整理</string> <string name="devices_refresh">重新整理</string>
<string name="devices_connected_to">已連線至 %1$s:%2$d</string>
<string name="devices_disconnect">中斷連線</string> <string name="devices_disconnect">中斷連線</string>
<string name="devices_connecting">連線中...</string> <string name="devices_connecting">連線中...</string>
<string name="devices_searching">正在搜尋網路上的裝置...</string> <string name="devices_searching">正在搜尋網路上的裝置...</string>
@ -499,20 +496,13 @@
<string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string> <string name="devices_empty_hint">請確認另一部裝置已在設定中啟用「允許遠端控制」。</string>
<string name="devices_unknown_device">未知裝置</string> <string name="devices_unknown_device">未知裝置</string>
<string name="devices_resolving">解析中...</string> <string name="devices_resolving">解析中...</string>
<string name="devices_device_id">ID:%1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">遠端控制</string> <string name="devices_remote_title">遠端控制</string>
<string name="devices_remote_disconnected">已中斷連線</string> <string name="devices_remote_disconnected">已中斷連線</string>
<string name="devices_remote_connection_error">連線錯誤:%1$s</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_track">未知曲目</string>
<string name="devices_remote_unknown_artist">未知藝人</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_volume">音量</string>
<string name="devices_remote_remove_from_queue">從待播清單移除</string> <string name="devices_remote_remove_from_queue">從待播清單移除</string>
<string name="devices_remote_more_options">更多選項</string> <string name="devices_remote_more_options">更多選項</string>

View File

@ -210,8 +210,6 @@
<string name="settings_error_cache_size_range">大小必须为 0 或正数。</string> <string name="settings_error_cache_size_range">大小必须为 0 或正数。</string>
<string name="settings_enable_endless_playback_title">启用无尽播放</string> <string name="settings_enable_endless_playback_title">启用无尽播放</string>
<string name="settings_enable_endless_playback_subtitle">队列播放完毕后,继续播放推荐的歌曲。</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_playback_port_title">播放代理服务器端口</string>
<string name="settings_allow_remote_control_title">允许远程控制</string> <string name="settings_allow_remote_control_title">允许远程控制</string>
<string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string> <string name="settings_allow_remote_control_subtitle">允许同一网络中的其他设备在连接后控制播放。</string>
@ -491,7 +489,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">设备</string> <string name="devices_title">设备</string>
<string name="devices_refresh">刷新</string> <string name="devices_refresh">刷新</string>
<string name="devices_connected_to">已连接到 %1$s:%2$d</string>
<string name="devices_disconnect">断开连接</string> <string name="devices_disconnect">断开连接</string>
<string name="devices_connecting">正在连接...</string> <string name="devices_connecting">正在连接...</string>
<string name="devices_searching">正在搜索网络中的设备...</string> <string name="devices_searching">正在搜索网络中的设备...</string>
@ -499,20 +496,13 @@
<string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string> <string name="devices_empty_hint">请确保另一台设备已在设置中启用“允许远程控制”。</string>
<string name="devices_unknown_device">未知设备</string> <string name="devices_unknown_device">未知设备</string>
<string name="devices_resolving">正在解析...</string> <string name="devices_resolving">正在解析...</string>
<string name="devices_device_id">ID:%1$s...</string>
<!-- Devices: remote control screen --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">远程控制</string> <string name="devices_remote_title">远程控制</string>
<string name="devices_remote_disconnected">已断开连接</string> <string name="devices_remote_disconnected">已断开连接</string>
<string name="devices_remote_connection_error">连接错误:%1$s</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_track">未知歌曲</string>
<string name="devices_remote_unknown_artist">未知艺人</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_volume">音量</string>
<string name="devices_remote_remove_from_queue">从队列中移除</string> <string name="devices_remote_remove_from_queue">从队列中移除</string>
<string name="devices_remote_more_options">更多选项</string> <string name="devices_remote_more_options">更多选项</string>

View File

@ -215,8 +215,6 @@
<string name="settings_error_cache_size_range">Size must be 0 or a positive number.</string> <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_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_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_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_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> <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 --> <!-- Devices: connection errors -->
<string name="devices_error_connection_failed">Connection failed</string> <string name="devices_error_connection_failed">Connection failed</string>
<string name="devices_error_connection_lost">Connection lost</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_send_failed">Send failed</string>
<string name="devices_error_discovery_failed">Discovery failed: %1$s</string> <string name="devices_error_discovery_failed">Discovery failed: %1$s</string>
<string name="devices_error_connect_failed">Failed to connect: %1$s</string> <string name="devices_error_connect_failed">Failed to connect: %1$s</string>
@ -511,7 +510,6 @@
<!-- Devices: discovery screen --> <!-- Devices: discovery screen -->
<string name="devices_title">Devices</string> <string name="devices_title">Devices</string>
<string name="devices_refresh">Refresh</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_disconnect">Disconnect</string>
<string name="devices_connecting">Connecting...</string> <string name="devices_connecting">Connecting...</string>
<string name="devices_searching">Searching for devices on the network...</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_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_unknown_device">Unknown Device</string>
<string name="devices_resolving">Resolving...</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 --> <!-- Devices: remote control screen -->
<string name="devices_remote_title">Remote Control</string> <string name="devices_remote_title">Remote Control</string>
<string name="devices_remote_disconnected">Disconnected</string> <string name="devices_remote_disconnected">Disconnected</string>
<string name="devices_remote_connection_error">Connection error: %1$s</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_track">Unknown Track</string>
<string name="devices_remote_unknown_artist">Unknown Artist</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_volume">Volume</string>
<string name="devices_remote_remove_from_queue">Remove from queue</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_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 --> <!-- Group Jam: play destination picker -->
<string name="jam_destination_title">Play Where?</string> <string name="jam_destination_title">Play Where?</string>

View File

@ -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?

View File

@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent
import com.appstractive.dnssd.NetService import com.appstractive.dnssd.NetService
import com.appstractive.dnssd.createNetService import com.appstractive.dnssd.createNetService
import com.appstractive.dnssd.discoverServices import com.appstractive.dnssd.discoverServices
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.withContext
import kotlin.text.decodeToString import kotlin.text.decodeToString
data class DiscoveredDevice( data class DiscoveredDevice(
@ -35,7 +39,8 @@ data class DiscoveredDevice(
} }
sealed interface DiscoveryState { 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 Resolved(val device: DiscoveredDevice) : DiscoveryState
data class Removed(val device: DiscoveredDevice) : DiscoveryState data class Removed(val device: DiscoveredDevice) : DiscoveryState
} }
@ -46,6 +51,11 @@ class DeviceDiscoveryService {
const val TXT_DEVICE_ID = "deviceId" 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 -> fun discover(): Flow<DiscoveryState> = discoverServices(SERVICE_TYPE).map { event ->
when (event) { when (event) {
is DiscoveryEvent.Discovered -> { is DiscoveryEvent.Discovered -> {
@ -55,7 +65,9 @@ class DeviceDiscoveryService {
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty()) 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 -> { is DiscoveryEvent.Resolved -> {
@ -78,7 +90,7 @@ class DeviceDiscoveryService {
DiscoveryState.Removed(device = device) DiscoveryState.Removed(device = device)
} }
} }
} }.flowOn(Dispatchers.IO)
suspend fun advertise( suspend fun advertise(
name: String, name: String,
@ -97,5 +109,6 @@ class DeviceDiscoveryService {
type = SERVICE_TYPE, type = SERVICE_TYPE,
name = name, name = name,
port = port, port = port,
addresses = preferredAdvertiseAddress()?.let { listOf(it) },
txt = mapOf(TXT_DEVICE_ID to deviceId)) txt = mapOf(TXT_DEVICE_ID to deviceId))
} }

View File

@ -192,7 +192,7 @@ val navigationModule = module {
BlacklistScreen() BlacklistScreen()
} }
navigation<Routes.Devices> { navigation<Routes.Devices> {
DevicesScreen(navigationCommands = get()) DevicesScreen()
} }
navigation<Routes.RemoteControl> { navigation<Routes.RemoteControl> {
RemoteControlScreen( RemoteControlScreen(

View File

@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.websocket.CloseReason import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame import io.ktor.websocket.Frame
import io.ktor.websocket.WebSocketSession import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.close import io.ktor.websocket.close
import io.ktor.websocket.readText import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.Res 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_failed
import spotube.composeapp.generated.resources.devices_error_connection_lost import spotube.composeapp.generated.resources.devices_error_connection_lost
import spotube.composeapp.generated.resources.devices_error_send_failed 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) private val _connectionState = MutableStateFlow<ConnectionState>(ConnectionState.Disconnected)
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow()
@ -77,12 +78,23 @@ class RemoteControlClient {
private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null) private val _latestQueue = MutableStateFlow<RemoteControlEvent.QueueUpdated?>(null)
val latestQueue: StateFlow<RemoteControlEvent.QueueUpdated?> = _latestQueue.asStateFlow() 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) { if (_connectionState.value is ConnectionState.Connected) {
logger.w { "Already connected" } logger.w { "Already connected" }
return return
} }
// Drop a stale attempt (e.g. one still waiting on the host's permission prompt).
closeSession()
_connectionState.value = ConnectionState.Connecting _connectionState.value = ConnectionState.Connecting
try { try {
session = httpClient.webSocketSession { session = httpClient.webSocketSession {
@ -95,16 +107,16 @@ class RemoteControlClient {
// Start receiving messages in a separate coroutine // Start receiving messages in a separate coroutine
scope.launch { scope.launch {
receiveLoop(host, port) receiveLoop(host, port, remoteDeviceName)
} }
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Failed to connect to $host:$port" } logger.e(e) { "Failed to connect to $host:$port" }
closeSession()
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed) _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 val currentSession = session ?: return
logger.d { "Starting receive loop for $host:$port" } logger.d { "Starting receive loop for $host:$port" }
try { try {
@ -119,7 +131,7 @@ class RemoteControlClient {
when (event) { when (event) {
is RemoteControlEvent.Connected -> { is RemoteControlEvent.Connected -> {
logger.i { "Connection authorized by server" } logger.i { "Connection authorized by server" }
_connectionState.value = ConnectionState.Connected(host, port) _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName)
} }
is RemoteControlEvent.WaitingForPermission -> { is RemoteControlEvent.WaitingForPermission -> {
logger.i { "Waiting for permission: ${event.message}" } logger.i { "Waiting for permission: ${event.message}" }
@ -144,20 +156,39 @@ class RemoteControlClient {
logger.w(e) { "Failed to parse message: $text" } logger.w(e) { "Failed to parse message: $text" }
} }
} }
is Frame.Close -> {
logger.i { "WebSocket closed by server" }
_connectionState.value = ConnectionState.Disconnected
break
}
else -> {} 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) { } catch (e: Exception) {
logger.e(e) { "Error in receive loop" } logger.e(e) { "Error in receive loop" }
if (session === currentSession) {
session = null
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost) _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) { suspend fun sendCommand(command: RemoteControlCommand) {
val currentSession = session ?: run { val currentSession = session ?: run {
@ -180,12 +211,18 @@ class RemoteControlClient {
} }
suspend fun disconnect() { suspend fun disconnect() {
session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) closeSession()
session = null
_connectionState.value = ConnectionState.Disconnected _connectionState.value = ConnectionState.Disconnected
logger.i { "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 { private fun randomShortId(): String {
val chars = "0123456789abcdef" val chars = "0123456789abcdef"
return buildString(8) { return buildString(8) {
@ -199,7 +236,7 @@ class RemoteControlClient {
sealed interface ConnectionState { sealed interface ConnectionState {
data object Disconnected : ConnectionState data object Disconnected : ConnectionState
data object Connecting : 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 * [message] is the raw (untranslated) cause when available; [fallback] is the
* localized description to show when it isn't. * localized description to show when it isn't.

View File

@ -18,216 +18,195 @@
package org.spotube.spotube.modules.devices 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.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle 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 org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn 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 org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text import com.composeunstyled.Text
import compose.icons.FeatherIcons
import compose.icons.feathericons.ChevronRight
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.spotube.spotube.core.discovery.DiscoveredDevice
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester 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.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.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.resources.iconsax.Iconsax 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.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight
import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res 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_disconnect
import spotube.composeapp.generated.resources.devices_empty import spotube.composeapp.generated.resources.devices_empty
import spotube.composeapp.generated.resources.devices_empty_hint 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_refresh
import spotube.composeapp.generated.resources.devices_resolving 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
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_title
import spotube.composeapp.generated.resources.devices_unknown_device import spotube.composeapp.generated.resources.devices_unknown_device
import spotube.composeapp.generated.resources.jam_error_dismiss import spotube.composeapp.generated.resources.jam_error_dismiss
private val ContentMaxWidth = 720.dp
@Composable @Composable
fun DevicesScreen( fun DevicesScreen(
navigationCommands: NavigationCommands) { viewModel: DevicesViewModel = koinViewModel()
val viewModel = koinViewModel<DevicesViewModel>() ) {
val devices by viewModel.devices.collectAsStateWithLifecycle() val state by viewModel.uiState.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()
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester() val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
val shellBottomInset = LocalAppShellBottomInset.current
DisposableEffect(Unit) { DisposableEffect(Unit) {
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works. // Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
requestLocalNetworkPermission() requestLocalNetworkPermission()
viewModel.startDiscovery() viewModel.startDiscovery()
onDispose { onDispose {
// The remote connection itself outlives this screen; only discovery stops.
viewModel.stopDiscovery() viewModel.stopDiscovery()
// Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
// The RemoteControlViewModel will manage the connection lifecycle
} }
} }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(MillenniumTheme.colors.background)) { .background(MillenniumTheme.colors.background)
) {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,
title = { Text(stringResource(Res.string.devices_title)) }, title = { Text(stringResource(Res.string.devices_title)) },
actions = { actions = {
if (isDiscovering && connectingToDevice == null) { if (state.isDiscovering) {
MillenniumSpokeSpinner( MillenniumSpokeSpinner(
size = 24.dp, size = 20.dp,
modifier = Modifier.padding(end = 8.dp)) modifier = Modifier.padding(horizontal = 10.dp)
)
} else { } else {
MillenniumIconButton(
onClick = viewModel::startDiscovery,
enabled = !state.isConnecting,
style = MillenniumIconButtonStyle.Quiet
) {
MillenniumIcon( MillenniumIcon(
imageVector = Iconsax.IconsaxRefreshRight, imageVector = Iconsax.IconsaxRefreshRight,
contentDescription = stringResource(Res.string.devices_refresh), contentDescription = stringResource(Res.string.devices_refresh)
modifier = Modifier )
.size(24.dp)
.clickable(enabled = connectingToDevice == null) {
viewModel.startDiscovery()
})
} }
}) }
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( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .weight(1f)
.padding(16.dp)) { .fillMaxWidth(),
Column { contentAlignment = Alignment.TopCenter
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 {
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxSize() .widthIn(max = ContentMaxWidth)
.weight(1f), .fillMaxSize(),
verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = PaddingValues(
contentPadding = androidx.compose.foundation.layout.PaddingValues( start = 8.dp,
horizontal = 16.dp, end = 8.dp,
vertical = 8.dp)) { top = 8.dp,
items(devices.values.toList(), key = { it.key }) { device -> bottom = shellBottomInset + 16.dp
DeviceRow( )
device = device, ) {
isConnecting = connectingToDevice?.key == device.key, state.error?.let { error ->
onClick = { viewModel.connectToDevice(device) })
}
item { 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 @Composable
private fun DeviceRow( private fun DeviceTile(
device: DiscoveredDevice, device: DeviceItemUi,
isConnecting: Boolean, enabled: Boolean,
onClick: () -> Unit) { onClick: () -> Unit
Row( ) {
modifier = Modifier val name = device.name ?: stringResource(Res.string.devices_unknown_device)
.fillMaxWidth() SettingCardItem(
.clickable(onClick = onClick, enabled = !isConnecting) enabled = enabled,
.padding(vertical = 12.dp, horizontal = 8.dp), title = name,
verticalAlignment = Alignment.CenterVertically, subtitle = device.address ?: stringResource(Res.string.devices_resolving),
horizontalArrangement = Arrangement.spacedBy(12.dp)) { icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) },
if (isConnecting) { trailingContent = {
MillenniumSpokeSpinner(size = 24.dp) when {
} else { device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp)
MillenniumIcon( device.isConnected -> Text(
imageVector = Iconsax.IconsaxMirroringScreen, text = stringResource(Res.string.devices_status_connected),
style = AppTheme.typography.labelMedium,
color = AppTheme.colors.primary
)
else -> MillenniumIcon(
imageVector = FeatherIcons.ChevronRight,
contentDescription = null, 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)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) }, text = name,
style = AppTheme.typography.bodyLarge, style = AppTheme.typography.titleMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
)
Text( Text(
text = if (device.host.isNotBlank() && device.port > 0) { text = device.address,
"${device.host}:${device.port}"
} else {
stringResource(Res.string.devices_resolving)
},
style = AppTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = AppTheme.colors.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis) overflow = TextOverflow.Ellipsis
if (device.deviceId.isNotBlank()) { )
}
}
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(
text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)), text = message,
style = AppTheme.typography.labelSmall, style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f), color = AppTheme.colors.onErrorContainer,
maxLines = 1, modifier = Modifier.weight(1f)
overflow = TextOverflow.Ellipsis) )
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))
}
} }
} }
} }

View File

@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlClient
import org.spotube.spotube.core.remote.RemoteControlService import org.spotube.spotube.core.remote.RemoteControlService
import org.spotube.spotube.modules.settings.SettingsProvider import org.spotube.spotube.modules.settings.SettingsProvider
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
@ -52,6 +55,28 @@ data class DevicesError(
val args: List<String> = emptyList(), val args: List<String> = emptyList(),
val rawMessage: String? = null) 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( class DevicesViewModel(
private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent { private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent {
private val logger = Logger.withTag("DevicesViewModel") private val logger = Logger.withTag("DevicesViewModel")
@ -61,38 +86,65 @@ class DevicesViewModel(
private val settingsProvider: SettingsProvider by inject() private val settingsProvider: SettingsProvider by inject()
private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap()) private val _devices = MutableStateFlow<Map<String, DiscoveredDevice>>(emptyMap())
val devices: StateFlow<Map<String, DiscoveredDevice>> = _devices.asStateFlow()
private val _isDiscovering = MutableStateFlow(false) private val _isDiscovering = MutableStateFlow(false)
val isDiscovering: StateFlow<Boolean> = _isDiscovering.asStateFlow()
private val _connectingToDevice = MutableStateFlow<DiscoveredDevice?>(null) 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) 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 private var discoveryJob: Job? = null
init { init {
// Observe connection state from the client
viewModelScope.launch { viewModelScope.launch {
var previous: ConnectionState = remoteControlClient.connectionState.value
remoteControlClient.connectionState.collect { state -> remoteControlClient.connectionState.collect { state ->
_connectionState.value = state val wasConnecting = previous is ConnectionState.Connecting
if (state is ConnectionState.Error) { previous = state
when (state) {
is ConnectionState.Error -> {
_error.value = DevicesError(message = state.fallback, rawMessage = state.message) _error.value = DevicesError(message = state.fallback, rawMessage = state.message)
_connectingToDevice.value = null _connectingToDevice.value = null
} else if (state is ConnectionState.Disconnected) { }
_connectingToDevice.value = null // Only a drop *during* an attempt means it failed. Switching devices
} else if (state is ConnectionState.Connected) { // disconnects the old one first, and that must not cancel the new attempt.
// Navigate to remote control screen after successful connection 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 _connectingToDevice.value = null
navigationCommands.navigateTo(Routes.RemoteControl) navigationCommands.navigateTo(Routes.RemoteControl)
} }
} }
is ConnectionState.Connecting -> {}
}
}
} }
// Whenever the local device id is resolved, drop any of our own // Whenever the local device id is resolved, drop any of our own
// advertisements that may have been picked up before we knew our id. // advertisements that may have been picked up before we knew our id.
@ -119,7 +171,8 @@ class DevicesViewModel(
logger.d { "Discovery event: $event" } logger.d { "Discovery event: $event" }
when (event) { when (event) {
is DiscoveryState.Discovered -> { 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)) { if (!isSelf(event.device)) {
_devices.update { it + (event.device.key to event.device.copy()) } _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) { } catch (e: Exception) {
logger.e(e) { "Discovery failed" } logger.e(e) { "Discovery failed" }
_error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString()))
@ -175,7 +231,25 @@ class DevicesViewModel(
_isDiscovering.value = false _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) { if (_connectingToDevice.value != null) {
logger.w { "Already connecting to a device" } logger.w { "Already connecting to a device" }
return return
@ -200,11 +274,11 @@ class DevicesViewModel(
host = device.host, host = device.host,
port = device.port, port = device.port,
deviceId = deviceId, deviceId = deviceId,
deviceName = deviceName) deviceName = deviceName,
remoteDeviceName = device.name)
// Clear connecting state after connection attempt // connect() returns once the socket is open, but the host may still be
// The connectionState flow will show the actual connection status // asking its user for permission. The connection-state collector clears
_connectingToDevice.value = null // the connecting flag once the outcome is known.
} catch (e: Exception) { } catch (e: Exception) {
logger.e(e) { "Failed to connect to device" } logger.e(e) { "Failed to connect to device" }
_error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString()))
@ -223,3 +297,9 @@ class DevicesViewModel(
_error.value = null _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

View File

@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger
import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlClient
import org.spotube.spotube.core.remote.RemoteControlCommand import org.spotube.spotube.core.remote.RemoteControlCommand
import org.spotube.spotube.core.remote.RemoteControlEvent 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.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow 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.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject 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 isPlaying: Boolean = false,
val positionMs: Long = 0, val isShuffling: Boolean = false,
val durationMs: Long = 0, val loopMode: RemoteLoopMode = RemoteLoopMode.NONE,
val volume: Float = 1.0f, val canSeek: Boolean = false,
val shuffleEnabled: Boolean = false, val seekProgress: Float = 0f,
val loopMode: String = "none", val positionLabel: String = formatPlayerTime(0.milliseconds),
val currentTrackId: String? = null, val durationLabel: String = formatPlayerTime(0.milliseconds),
val currentTrackTitle: String? = null, val volume: Float = 1f,
val currentTrackArtists: String? = null, val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH,
val currentTrackAlbum: String? = null, val queue: List<RemoteQueueItemUi> = emptyList(),
val currentTrackCoverUrl: String? = null) val currentQueueIndex: Int = -1,
val isQueueVisible: Boolean = false)
data class RemoteQueueState( private data class LocalOverrides(
val entries: List<RemoteQueueEntry> = emptyList(), val seekProgress: Float?,
val currentIndex: Int = -1) val volume: Float?,
val isQueueVisible: Boolean)
class RemoteControlViewModel : ViewModel(), KoinComponent { class RemoteControlViewModel : ViewModel(), KoinComponent {
private val logger = Logger.withTag("RemoteControlViewModel")
private val remoteControlClient: RemoteControlClient by inject() private val remoteControlClient: RemoteControlClient by inject()
private val _playerState = MutableStateFlow(RemotePlayerState()) // While the user drags a slider these hold the local value, so the host's
val playerState: StateFlow<RemotePlayerState> = _playerState.asStateFlow() // 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) private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible ->
val connectionState: StateFlow<ConnectionState> = _connectionState.asStateFlow() LocalOverrides(seek, volume, queueVisible)
}
private val _queueState = MutableStateFlow(RemoteQueueState()) val uiState: StateFlow<RemoteControlUiState> = combine(
val queueState: StateFlow<RemoteQueueState> = _queueState.asStateFlow() remoteControlClient.connectionState,
remoteControlClient.latestPlayerState,
private val _isQueueVisible = MutableStateFlow(false) remoteControlClient.latestQueue,
val isQueueVisible: StateFlow<Boolean> = _isQueueVisible.asStateFlow() overrides
) { connection, player, queue, local ->
buildUiState(connection, player, queue, local)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState())
init { init {
// Stream volume changes while dragging, throttled so a fast drag doesn't
// flood the socket with one command per frame.
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.connectionState.collect { state -> volumeOverride.filterNotNull().collect { volume ->
_connectionState.value = state 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 { viewModelScope.launch {
remoteControlClient.latestPlayerState.collect { event -> remoteControlClient.sendCommand(command)
if (event != null) {
handlePlayerState(event)
}
} }
} }
/**
* 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 { viewModelScope.launch {
remoteControlClient.latestQueue.collect { event -> remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong()))
if (event != null) { releaseOverrideAfterEcho(seekOverride, progress)
handleQueueUpdated(event)
}
}
} }
} }
private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { fun onVolumeChange(volume: Float) {
_playerState.update { volumeOverride.value = volume.coerceIn(0f, 1f)
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)
}
} }
private fun handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { fun onVolumeChangeFinished() {
_queueState.value = RemoteQueueState( val volume = volumeOverride.value ?: return
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) {
viewModelScope.launch { viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume))
releaseOverrideAfterEcho(volumeOverride, volume)
} }
} }
fun toggleShuffle() { fun toggleShuffle() {
viewModelScope.launch { val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false
val newState = !_playerState.value.shuffleEnabled send(RemoteControlCommand.SetShuffle(!shuffling))
remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState))
}
} }
fun cycleLoopMode() { fun cycleLoopMode() {
viewModelScope.launch { val current = RemoteLoopMode.fromWire(
val currentMode = _playerState.value.loopMode remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue)
val newMode = when (currentMode) { send(RemoteControlCommand.SetLoopMode(current.next().wireValue))
"none" -> "one"
"one" -> "all"
else -> "none"
}
remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode))
}
} }
fun playQueueItem(index: Int) { fun playQueueItem(index: Int) {
if (index < 0) return if (index < 0) return
viewModelScope.launch { send(RemoteControlCommand.PlayIndex(index))
remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index))
}
} }
fun removeQueueItem(mediaUrl: String) { fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl))
viewModelScope.launch {
remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl))
}
}
fun toggleQueueVisibility() { fun toggleQueueVisibility() {
_isQueueVisible.update { !it } isQueueVisible.update { !it }
}
fun dismissQueue() {
isQueueVisible.value = false
} }
fun disconnect() { fun disconnect() {
@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent {
remoteControlClient.disconnect() remoteControlClient.disconnect()
} }
} }
private companion object {
const val VOLUME_THROTTLE_MS = 100L
const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L
}
} }

View File

@ -56,7 +56,6 @@ data class UserSettings(
val cacheFolder: String? = null, val cacheFolder: String? = null,
val cacheSizeLimitMB: Long = -1L, val cacheSizeLimitMB: Long = -1L,
val enableEndlessPlayback: Boolean = true, val enableEndlessPlayback: Boolean = true,
val enableConnect: Boolean = false,
val playbackProxyServerPort: Int = 14769, val playbackProxyServerPort: Int = 14769,
// Remote Control (LAN) // Remote Control (LAN)

View File

@ -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 { add {
SwitchSettingCard( SwitchSettingCard(
title = stringResource(Res.string.settings_allow_remote_control_title), title = stringResource(Res.string.settings_allow_remote_control_title),

View File

@ -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

View File

@ -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()