diff --git a/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt
new file mode 100644
index 00000000..2f73a824
--- /dev/null
+++ b/composeApp/src/androidMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.android.kt
@@ -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 .
+ */
+
+package org.spotube.spotube.core.discovery
+
+// The OS's own service-discovery stack advertises on the right network.
+internal actual fun preferredAdvertiseAddress(): String? = null
diff --git a/composeApp/src/commonMain/composeResources/values-ar/strings.xml b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
index 674b83a9..af2a5da6 100644
--- a/composeApp/src/commonMain/composeResources/values-ar/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ar/strings.xml
@@ -225,8 +225,6 @@
يجب أن يكون الحجم 0 أو رقمًا موجبًا.
تفعيل التشغيل اللانهائي
واصل التشغيل بمقاطع مقترحة عند انتهاء قائمة الانتظار.
- تفعيل Connect
- إتاحة عناصر التحكم في التشغيل عن بُعد عبر ميزة Spotube Connect.
منفذ خادم وكيل التشغيل
السماح بالتحكم عن بُعد
اسمح للأجهزة الأخرى على الشبكة نفسها بالتحكم في التشغيل عند اتصالها.
@@ -511,7 +509,6 @@
الأجهزة
تحديث
- متصل بـ %1$s:%2$d
قطع الاتصال
جارٍ الاتصال...
جارٍ البحث عن أجهزة على الشبكة...
@@ -519,20 +516,13 @@
تأكد من تفعيل \"السماح بالتحكم عن بُعد\" في إعدادات الجهاز الآخر.
جهاز غير معروف
جارٍ التحديد...
- المعرّف: %1$s...
التحكم عن بُعد
غير متصل
خطأ في الاتصال: %1$s
- غلاف الألبوم
مقطع غير معروف
فنان غير معروف
- التقديم والترجيع
- ترتيب عشوائي
- السابق
- التالي
- وضع التكرار
مستوى الصوت
إزالة من قائمة الانتظار
خيارات إضافية
diff --git a/composeApp/src/commonMain/composeResources/values-bn/strings.xml b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
index 03b55163..cf54df53 100644
--- a/composeApp/src/commonMain/composeResources/values-bn/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-bn/strings.xml
@@ -213,8 +213,6 @@
সাইজ 0 বা একটি ধনাত্মক সংখ্যা হতে হবে।
অবিরাম প্লেব্যাক চালু করুন
কিউ শেষ হলে সাজেস্ট করা ট্র্যাক দিয়ে প্লেব্যাক চালু রাখুন।
- Connect চালু করুন
- Spotube Connect ফিচারের মাধ্যমে রিমোট প্লেব্যাক কন্ট্রোল উপলব্ধ করুন।
প্লেব্যাক প্রক্সি সার্ভার পোর্ট
রিমোট কন্ট্রোলের অনুমতি দিন
একই নেটওয়ার্কের অন্য ডিভাইস কানেক্ট হলে প্লেব্যাক নিয়ন্ত্রণ করতে দিন।
@@ -495,7 +493,6 @@
ডিভাইস
রিফ্রেশ
- %1$s:%2$d-এ কানেক্টেড
ডিসকানেক্ট
কানেক্ট হচ্ছে...
নেটওয়ার্কে ডিভাইস খোঁজা হচ্ছে...
@@ -503,20 +500,13 @@
অন্য ডিভাইসের সেটিংসে \"রিমোট কন্ট্রোলের অনুমতি দিন\" চালু আছে কিনা নিশ্চিত করুন।
অজানা ডিভাইস
খোঁজা হচ্ছে...
- ID: %1$s...
রিমোট কন্ট্রোল
ডিসকানেক্টেড
কানেকশন ত্রুটি: %1$s
- অ্যালবাম কভার
অজানা ট্র্যাক
অজানা শিল্পী
- সিক
- শাফল
- আগের
- পরের
- লুপ মোড
ভলিউম
কিউ থেকে সরান
আরও অপশন
diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml
index 71f1278a..22f864c8 100644
--- a/composeApp/src/commonMain/composeResources/values-de/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml
@@ -213,8 +213,6 @@
Die Größe muss 0 oder eine positive Zahl sein.
Endlose Wiedergabe aktivieren
Wenn deine Warteschlange endet, geht es mit empfohlenen Titeln weiter.
- Connect aktivieren
- Fernsteuerung der Wiedergabe über Spotube Connect bereitstellen.
Port des Wiedergabe-Proxyservers
Fernsteuerung erlauben
Andere Geräte im selben Netzwerk dürfen die Wiedergabe steuern, wenn sie sich verbinden.
@@ -495,7 +493,6 @@
Geräte
Aktualisieren
- Verbunden mit %1$s:%2$d
Trennen
Verbinden...
Suche nach Geräten im Netzwerk...
@@ -503,20 +500,13 @@
Stelle sicher, dass auf dem anderen Gerät „Fernsteuerung erlauben“ in den Einstellungen aktiviert ist.
Unbekanntes Gerät
Wird aufgelöst...
- ID: %1$s...
Fernsteuerung
Getrennt
Verbindungsfehler: %1$s
- Albumcover
Unbekannter Titel
Unbekannter Künstler
- Spulen
- Zufallswiedergabe
- Zurück
- Weiter
- Wiederholungsmodus
Lautstärke
Aus Warteschlange entfernen
Weitere Optionen
diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml
index f6322e76..10092442 100644
--- a/composeApp/src/commonMain/composeResources/values-es/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml
@@ -216,8 +216,6 @@
El tamaño debe ser 0 o un número positivo.
Activar reproducción infinita
Sigue reproduciendo canciones recomendadas cuando termine la cola.
- Activar Connect
- Ofrece controles de reproducción remota mediante la función Spotube Connect.
Puerto del servidor proxy de reproducción
Permitir control remoto
Permite que otros dispositivos de la misma red controlen la reproducción al conectarse.
@@ -499,7 +497,6 @@
Dispositivos
Actualizar
- Conectado a %1$s:%2$d
Desconectar
Conectando...
Buscando dispositivos en la red...
@@ -507,20 +504,13 @@
Asegúrate de que el otro dispositivo tenga activado \"Permitir control remoto\" en los ajustes.
Dispositivo desconocido
Resolviendo...
- ID: %1$s...
Control remoto
Desconectado
Error de conexión: %1$s
- Portada del álbum
Canción desconocida
Artista desconocido
- Avanzar
- Aleatorio
- Anterior
- Siguiente
- Modo de repetición
Volumen
Quitar de la cola
Más opciones
diff --git a/composeApp/src/commonMain/composeResources/values-fa/strings.xml b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
index 89556d91..84c45926 100644
--- a/composeApp/src/commonMain/composeResources/values-fa/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fa/strings.xml
@@ -213,8 +213,6 @@
حجم باید 0 یا یک عدد مثبت باشد.
فعالسازی پخش بیپایان
وقتی صف پخش تمام شد، پخش با آهنگهای پیشنهادی ادامه پیدا کند.
- فعالسازی Connect
- کنترلهای پخش از راه دور را از طریق قابلیت Spotube Connect در دسترس قرار دهید.
درگاه سرور پراکسی پخش
اجازهٔ کنترل از راه دور
به دستگاههای دیگر در همان شبکه اجازه دهید هنگام اتصال، پخش را کنترل کنند.
@@ -495,7 +493,6 @@
دستگاهها
بهروزرسانی
- متصل به %1$s:%2$d
قطع اتصال
در حال اتصال...
در حال جستجوی دستگاهها در شبکه...
@@ -503,20 +500,13 @@
مطمئن شوید گزینهٔ \"اجازهٔ کنترل از راه دور\" در تنظیمات دستگاه دیگر فعال است.
دستگاه ناشناس
در حال شناسایی...
- شناسه: %1$s...
کنترل از راه دور
قطع شد
خطای اتصال: %1$s
- کاور آلبوم
آهنگ ناشناس
هنرمند ناشناس
- جابهجایی
- پخش تصادفی
- قبلی
- بعدی
- حالت تکرار
صدا
حذف از صف
گزینههای بیشتر
diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
index 9c4bf680..b5be84d8 100644
--- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml
@@ -216,8 +216,6 @@
La taille doit être 0 ou un nombre positif.
Activer la lecture continue
Poursuivre la lecture avec des titres recommandés à la fin de la file d\'attente.
- Activer Connect
- Proposer des commandes de lecture à distance via la fonctionnalité Spotube Connect.
Port du serveur proxy de lecture
Autoriser le contrôle à distance
Permettre aux autres appareils du même réseau de contrôler la lecture lorsqu\'ils se connectent.
@@ -499,7 +497,6 @@
Appareils
Actualiser
- Connecté à %1$s:%2$d
Se déconnecter
Connexion...
Recherche d\'appareils sur le réseau...
@@ -507,20 +504,13 @@
Assurez-vous que l\'option \"Autoriser le contrôle à distance\" est activée dans les paramètres de l\'autre appareil.
Appareil inconnu
Résolution...
- ID : %1$s...
Contrôle à distance
Déconnecté
Erreur de connexion : %1$s
- Pochette d\'album
Titre inconnu
Artiste inconnu
- Avancer/reculer
- Lecture aléatoire
- Précédent
- Suivant
- Mode de répétition
Volume
Retirer de la file d\'attente
Plus d\'options
diff --git a/composeApp/src/commonMain/composeResources/values-hi/strings.xml b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
index 569467ed..15344dcd 100644
--- a/composeApp/src/commonMain/composeResources/values-hi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-hi/strings.xml
@@ -212,8 +212,6 @@
साइज़ 0 या कोई पॉज़िटिव संख्या होनी चाहिए।
लगातार प्लेबैक चालू करें
कतार खत्म होने पर सुझाए गए ट्रैक के साथ प्लेबैक जारी रखें।
- Connect चालू करें
- Spotube Connect फ़ीचर के ज़रिए रिमोट प्लेबैक कंट्रोल उपलब्ध कराएं।
प्लेबैक प्रॉक्सी सर्वर पोर्ट
रिमोट कंट्रोल की अनुमति दें
एक ही नेटवर्क के दूसरे डिवाइस कनेक्ट होने पर प्लेबैक कंट्रोल कर सकें।
@@ -494,7 +492,6 @@
डिवाइस
रीफ़्रेश करें
- %1$s:%2$d से कनेक्ट है
डिस्कनेक्ट करें
कनेक्ट हो रहा है...
नेटवर्क पर डिवाइस खोजे जा रहे हैं...
@@ -502,20 +499,13 @@
पक्का करें कि दूसरे डिवाइस की सेटिंग में \"रिमोट कंट्रोल की अनुमति दें\" चालू है।
अज्ञात डिवाइस
पता लगाया जा रहा है...
- ID: %1$s...
रिमोट कंट्रोल
डिस्कनेक्ट हो गया
कनेक्शन में गड़बड़ी: %1$s
- एल्बम कवर
अज्ञात ट्रैक
अज्ञात कलाकार
- आगे-पीछे करें
- शफ़ल
- पिछला
- अगला
- रिपीट मोड
वॉल्यूम
कतार से हटाएं
और विकल्प
diff --git a/composeApp/src/commonMain/composeResources/values-id/strings.xml b/composeApp/src/commonMain/composeResources/values-id/strings.xml
index fbc02ba5..f189a383 100644
--- a/composeApp/src/commonMain/composeResources/values-id/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-id/strings.xml
@@ -210,8 +210,6 @@
Ukuran harus 0 atau bilangan positif.
Aktifkan pemutaran tanpa henti
Lanjutkan pemutaran dengan lagu rekomendasi saat antrean habis.
- Aktifkan Connect
- Tampilkan kontrol pemutaran jarak jauh melalui fitur Spotube Connect.
Port server proxy pemutaran
Izinkan kontrol jarak jauh
Izinkan perangkat lain di jaringan yang sama mengontrol pemutaran saat terhubung.
@@ -491,7 +489,6 @@
Perangkat
Muat ulang
- Terhubung ke %1$s:%2$d
Putuskan
Menghubungkan...
Mencari perangkat di jaringan...
@@ -499,20 +496,13 @@
Pastikan perangkat lain telah mengaktifkan \"Izinkan kontrol jarak jauh\" di pengaturan.
Perangkat Tidak Dikenal
Menyelesaikan...
- ID: %1$s...
Kontrol Jarak Jauh
Terputus
Kesalahan koneksi: %1$s
- Sampul album
Lagu Tidak Dikenal
Artis Tidak Dikenal
- Cari posisi
- Acak
- Sebelumnya
- Berikutnya
- Mode ulang
Volume
Hapus dari antrean
Opsi lainnya
diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml
index 4a3475b6..67ce22bd 100644
--- a/composeApp/src/commonMain/composeResources/values-it/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml
@@ -216,8 +216,6 @@
La dimensione deve essere 0 o un numero positivo.
Attiva riproduzione continua
Continua la riproduzione con brani consigliati quando la coda finisce.
- Attiva Connect
- Rendi disponibili i controlli di riproduzione remota tramite la funzione Spotube Connect.
Porta del server proxy di riproduzione
Consenti controllo remoto
Consenti agli altri dispositivi sulla stessa rete di controllare la riproduzione quando si connettono.
@@ -499,7 +497,6 @@
Dispositivi
Aggiorna
- Connesso a %1$s:%2$d
Disconnetti
Connessione...
Ricerca di dispositivi sulla rete...
@@ -507,20 +504,13 @@
Assicurati che sull\'altro dispositivo sia attivo \"Consenti controllo remoto\" nelle impostazioni.
Dispositivo sconosciuto
Risoluzione...
- ID: %1$s...
Controllo remoto
Disconnesso
Errore di connessione: %1$s
- Copertina dell\'album
Brano sconosciuto
Artista sconosciuto
- Posizione
- Riproduzione casuale
- Precedente
- Successivo
- Modalità ripetizione
Volume
Rimuovi dalla coda
Altre opzioni
diff --git a/composeApp/src/commonMain/composeResources/values-ja/strings.xml b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
index 9976483c..c01033a0 100644
--- a/composeApp/src/commonMain/composeResources/values-ja/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ja/strings.xml
@@ -210,8 +210,6 @@
サイズは0または正の数で指定してください。
エンドレス再生を有効化
キューが終わったら、おすすめの曲で再生を続けます。
- Connectを有効化
- Spotube Connect機能でリモート再生コントロールを公開します。
再生プロキシサーバーのポート
リモート操作を許可
同じネットワーク上の他のデバイスが接続したときに再生を操作できるようにします。
@@ -491,7 +489,6 @@
デバイス
更新
- %1$s:%2$d に接続済み
切断
接続中...
ネットワーク上のデバイスを検索中...
@@ -499,20 +496,13 @@
相手のデバイスの設定で「リモート操作を許可」が有効になっていることを確認してください。
不明なデバイス
解決中...
- ID: %1$s...
リモート操作
切断されました
接続エラー: %1$s
- アルバムカバー
不明な曲
不明なアーティスト
- シーク
- シャッフル
- 前へ
- 次へ
- リピートモード
音量
キューから削除
その他のオプション
diff --git a/composeApp/src/commonMain/composeResources/values-ko/strings.xml b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
index f0b8409e..1db8b5e5 100644
--- a/composeApp/src/commonMain/composeResources/values-ko/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ko/strings.xml
@@ -210,8 +210,6 @@
용량은 0 또는 양수여야 해요.
끝없는 재생 사용
대기열이 끝나면 추천 트랙으로 계속 재생해요.
- Connect 사용
- Spotube Connect 기능을 통해 원격 재생 컨트롤을 제공해요.
재생 프록시 서버 포트
원격 제어 허용
같은 네트워크의 다른 기기가 연결되면 재생을 제어할 수 있도록 허용해요.
@@ -491,7 +489,6 @@
기기
새로고침
- %1$s:%2$d에 연결됨
연결 해제
연결 중...
네트워크에서 기기를 찾는 중...
@@ -499,20 +496,13 @@
상대 기기의 설정에서 “원격 제어 허용”이 켜져 있는지 확인하세요.
알 수 없는 기기
확인 중...
- ID: %1$s...
원격 제어
연결 끊김
연결 오류: %1$s
- 앨범 커버
알 수 없는 트랙
알 수 없는 아티스트
- 탐색
- 셔플
- 이전
- 다음
- 반복 모드
볼륨
대기열에서 삭제
옵션 더보기
diff --git a/composeApp/src/commonMain/composeResources/values-nl/strings.xml b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
index 2c79ad97..b6ab0848 100644
--- a/composeApp/src/commonMain/composeResources/values-nl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-nl/strings.xml
@@ -213,8 +213,6 @@
Grootte moet 0 of een positief getal zijn.
Eindeloos afspelen inschakelen
Blijf afspelen met aanbevolen nummers als je wachtrij leeg is.
- Connect inschakelen
- Maak bediening op afstand beschikbaar via Spotube Connect.
Poort van afspeelproxyserver
Bediening op afstand toestaan
Laat andere apparaten op hetzelfde netwerk het afspelen bedienen als ze verbinding maken.
@@ -495,7 +493,6 @@
Apparaten
Vernieuwen
- Verbonden met %1$s:%2$d
Verbinding verbreken
Verbinden...
Zoeken naar apparaten op het netwerk...
@@ -503,20 +500,13 @@
Zorg dat \"Bediening op afstand toestaan\" is ingeschakeld in de instellingen van het andere apparaat.
Onbekend apparaat
Bezig met ophalen...
- ID: %1$s...
Bediening op afstand
Niet verbonden
Verbindingsfout: %1$s
- Albumhoes
Onbekend nummer
Onbekende artiest
- Zoeken in nummer
- Shuffle
- Vorige
- Volgende
- Herhaalmodus
Volume
Verwijderen uit wachtrij
Meer opties
diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
index ddc97d41..56dda439 100644
--- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml
@@ -219,8 +219,6 @@
Rozmiar musi wynosić 0 lub być liczbą dodatnią.
Włącz nieskończone odtwarzanie
Gdy kolejka się skończy, odtwarzaj dalej polecane utwory.
- Włącz Connect
- Udostępniaj sterowanie odtwarzaniem przez funkcję Spotube Connect.
Port serwera proxy odtwarzania
Zezwól na zdalne sterowanie
Pozwól innym urządzeniom w tej samej sieci sterować odtwarzaniem po połączeniu.
@@ -503,7 +501,6 @@
Urządzenia
Odśwież
- Połączono z %1$s:%2$d
Rozłącz
Łączenie...
Wyszukiwanie urządzeń w sieci...
@@ -511,20 +508,13 @@
Upewnij się, że na drugim urządzeniu w ustawieniach włączono opcję „Zezwól na zdalne sterowanie”.
Nieznane urządzenie
Ustalanie adresu...
- ID: %1$s...
Zdalne sterowanie
Rozłączono
Błąd połączenia: %1$s
- Okładka albumu
Nieznany utwór
Nieznany wykonawca
- Przewiń
- Odtwarzanie losowe
- Poprzedni
- Następny
- Tryb powtarzania
Głośność
Usuń z kolejki
Więcej opcji
diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
index 3694b5c2..95dcb260 100644
--- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml
@@ -216,8 +216,6 @@
O tamanho deve ser 0 ou um número positivo.
Ativar reprodução contínua
Continua tocando faixas recomendadas quando sua fila acabar.
- Ativar Connect
- Disponibiliza controles de reprodução remota pelo recurso Spotube Connect.
Porta do servidor proxy de reprodução
Permitir controle remoto
Permite que outros dispositivos na mesma rede controlem a reprodução quando se conectarem.
@@ -499,7 +497,6 @@
Dispositivos
Atualizar
- Conectado a %1$s:%2$d
Desconectar
Conectando...
Procurando dispositivos na rede...
@@ -507,20 +504,13 @@
Verifique se o outro dispositivo está com \"Permitir controle remoto\" ativado nas configurações.
Dispositivo desconhecido
Resolvendo...
- ID: %1$s...
Controle remoto
Desconectado
Erro de conexão: %1$s
- Capa do álbum
Faixa desconhecida
Artista desconhecido
- Avançar/retroceder
- Ordem aleatória
- Anterior
- Próxima
- Modo de repetição
Volume
Remover da fila
Mais opções
diff --git a/composeApp/src/commonMain/composeResources/values-ru/strings.xml b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
index 860089e6..2ce7ca5a 100644
--- a/composeApp/src/commonMain/composeResources/values-ru/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-ru/strings.xml
@@ -219,8 +219,6 @@
Размер должен быть 0 или положительным числом.
Бесконечное воспроизведение
Продолжать воспроизведение рекомендованными треками, когда очередь закончится.
- Включить Connect
- Предоставлять удалённое управление воспроизведением через Spotube Connect.
Порт прокси-сервера воспроизведения
Разрешить удалённое управление
Разрешить другим устройствам в той же сети управлять воспроизведением после подключения.
@@ -503,7 +501,6 @@
Устройства
Обновить
- Подключено к %1$s:%2$d
Отключиться
Подключение...
Поиск устройств в сети...
@@ -511,20 +508,13 @@
Убедитесь, что на другом устройстве в настройках включено «Разрешить удалённое управление».
Неизвестное устройство
Определение адреса...
- ID: %1$s...
Удалённое управление
Отключено
Ошибка подключения: %1$s
- Обложка альбома
Неизвестный трек
Неизвестный исполнитель
- Перемотка
- Перемешать
- Предыдущий
- Следующий
- Режим повтора
Громкость
Удалить из очереди
Другие действия
diff --git a/composeApp/src/commonMain/composeResources/values-th/strings.xml b/composeApp/src/commonMain/composeResources/values-th/strings.xml
index dffc10f4..246fb93e 100644
--- a/composeApp/src/commonMain/composeResources/values-th/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-th/strings.xml
@@ -210,8 +210,6 @@
ขนาดต้องเป็น 0 หรือจำนวนบวก
เปิดใช้การเล่นต่อเนื่องไม่สิ้นสุด
เล่นต่อด้วยเพลงแนะนำเมื่อคิวของคุณหมด
- เปิดใช้ Connect
- เปิดให้ควบคุมการเล่นจากระยะไกลผ่านฟีเจอร์ Spotube Connect
พอร์ตเซิร์ฟเวอร์พร็อกซีการเล่น
อนุญาตการควบคุมระยะไกล
ให้อุปกรณ์อื่นในเครือข่ายเดียวกันควบคุมการเล่นได้เมื่อเชื่อมต่อ
@@ -491,7 +489,6 @@
อุปกรณ์
รีเฟรช
- เชื่อมต่อกับ %1$s:%2$d แล้ว
ยกเลิกการเชื่อมต่อ
กำลังเชื่อมต่อ...
กำลังค้นหาอุปกรณ์ในเครือข่าย...
@@ -499,20 +496,13 @@
ตรวจสอบว่าอุปกรณ์อีกเครื่องเปิด “อนุญาตการควบคุมระยะไกล” ไว้ในการตั้งค่า
อุปกรณ์ที่ไม่รู้จัก
กำลังระบุ...
- ID: %1$s...
รีโมตคอนโทรล
ยกเลิกการเชื่อมต่อแล้ว
ข้อผิดพลาดในการเชื่อมต่อ: %1$s
- ปกอัลบั้ม
ไม่ทราบชื่อเพลง
ไม่ทราบศิลปิน
- เลื่อนตำแหน่ง
- สุ่มเพลง
- ก่อนหน้า
- ถัดไป
- โหมดเล่นซ้ำ
ระดับเสียง
นำออกจากคิว
ตัวเลือกเพิ่มเติม
diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
index 6f7bea1a..9024bbca 100644
--- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml
@@ -213,8 +213,6 @@
Boyut 0 veya pozitif bir sayı olmalı.
Sonsuz çalmayı etkinleştir
Sıran bittiğinde önerilen parçalarla çalmaya devam et.
- Connect\'i etkinleştir
- Uzaktan çalma kontrollerini Spotube Connect özelliğiyle sun.
Çalma proxy sunucusu portu
Uzaktan kontrole izin ver
Aynı ağdaki diğer cihazlar bağlandığında çalmayı kontrol edebilsin.
@@ -495,7 +493,6 @@
Cihazlar
Yenile
- %1$s:%2$d adresine bağlı
Bağlantıyı kes
Bağlanıyor...
Ağda cihaz aranıyor...
@@ -503,20 +500,13 @@
Diğer cihazın ayarlarında \"Uzaktan kontrole izin ver\" seçeneğinin açık olduğundan emin ol.
Bilinmeyen Cihaz
Çözümleniyor...
- Kimlik: %1$s...
Uzaktan Kontrol
Bağlantı kesildi
Bağlantı hatası: %1$s
- Albüm kapağı
Bilinmeyen Parça
Bilinmeyen Sanatçı
- Konum
- Karıştır
- Önceki
- Sonraki
- Tekrar modu
Ses düzeyi
Sıradan kaldır
Diğer seçenekler
diff --git a/composeApp/src/commonMain/composeResources/values-uk/strings.xml b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
index 37273b97..cb9bf14f 100644
--- a/composeApp/src/commonMain/composeResources/values-uk/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-uk/strings.xml
@@ -219,8 +219,6 @@
Розмір має бути 0 або додатним числом.
Безкінечне відтворення
Продовжувати відтворення рекомендованими треками, коли черга закінчиться.
- Увімкнути Connect
- Надавати віддалене керування відтворенням через Spotube Connect.
Порт проксі-сервера відтворення
Дозволити віддалене керування
Дозволити іншим пристроям у тій самій мережі керувати відтворенням після підключення.
@@ -503,7 +501,6 @@
Пристрої
Оновити
- Підключено до %1$s:%2$d
Від’єднатися
Підключення...
Пошук пристроїв у мережі...
@@ -511,20 +508,13 @@
Переконайтеся, що на іншому пристрої в налаштуваннях увімкнено «Дозволити віддалене керування».
Невідомий пристрій
Визначення адреси...
- ID: %1$s...
Віддалене керування
Від’єднано
Помилка підключення: %1$s
- Обкладинка альбому
Невідомий трек
Невідомий виконавець
- Перемотування
- Перемішати
- Попередній
- Наступний
- Режим повтору
Гучність
Видалити з черги
Інші дії
diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
index 4b81d8d0..6a6b75fb 100644
--- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml
@@ -210,8 +210,6 @@
Dung lượng phải là 0 hoặc một số dương.
Bật phát liên tục
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.
- Bật Connect
- Cung cấp điều khiển phát từ xa qua tính năng Spotube Connect.
Cổng máy chủ proxy phát lại
Cho phép điều khiển từ xa
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.
@@ -491,7 +489,6 @@
Thiết bị
Làm mới
- Đã kết nối với %1$s:%2$d
Ngắt kết nối
Đang kết nối...
Đang tìm thiết bị trong mạng...
@@ -499,20 +496,13 @@
Hãy đảm bảo thiết bị kia đã bật \"Cho phép điều khiển từ xa\" trong cài đặt.
Thiết bị không xác định
Đang phân giải...
- ID: %1$s...
Điều khiển từ xa
Đã ngắt kết nối
Lỗi kết nối: %1$s
- Ảnh bìa album
Bài hát không xác định
Nghệ sĩ không xác định
- Tua
- Phát ngẫu nhiên
- Trước
- Tiếp
- Chế độ lặp
Âm lượng
Xóa khỏi danh sách chờ
Tùy chọn khác
diff --git a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
index 6ab50884..7aaee59d 100644
--- a/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh-rTW/strings.xml
@@ -210,8 +210,6 @@
大小必須為 0 或正數。
啟用無限播放
待播清單播完後,以推薦曲目繼續播放。
- 啟用 Connect
- 透過 Spotube Connect 功能提供遠端播放控制。
播放代理伺服器連接埠
允許遠端控制
讓同一網路中的其他裝置在連線後控制播放。
@@ -491,7 +489,6 @@
裝置
重新整理
- 已連線至 %1$s:%2$d
中斷連線
連線中...
正在搜尋網路上的裝置...
@@ -499,20 +496,13 @@
請確認另一部裝置已在設定中啟用「允許遠端控制」。
未知裝置
解析中...
- ID:%1$s...
遠端控制
已中斷連線
連線錯誤:%1$s
- 專輯封面
未知曲目
未知藝人
- 播放進度
- 隨機播放
- 上一首
- 下一首
- 重複播放模式
音量
從待播清單移除
更多選項
diff --git a/composeApp/src/commonMain/composeResources/values-zh/strings.xml b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
index e95e0b45..1caaa3c2 100644
--- a/composeApp/src/commonMain/composeResources/values-zh/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values-zh/strings.xml
@@ -210,8 +210,6 @@
大小必须为 0 或正数。
启用无尽播放
队列播放完毕后,继续播放推荐的歌曲。
- 启用 Connect
- 通过 Spotube Connect 功能开放远程播放控制。
播放代理服务器端口
允许远程控制
允许同一网络中的其他设备在连接后控制播放。
@@ -491,7 +489,6 @@
设备
刷新
- 已连接到 %1$s:%2$d
断开连接
正在连接...
正在搜索网络中的设备...
@@ -499,20 +496,13 @@
请确保另一台设备已在设置中启用“允许远程控制”。
未知设备
正在解析...
- ID:%1$s...
远程控制
已断开连接
连接错误:%1$s
- 专辑封面
未知歌曲
未知艺人
- 进度
- 随机播放
- 上一首
- 下一首
- 循环模式
音量
从队列中移除
更多选项
diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml
index e4d68e93..f7acd6a9 100644
--- a/composeApp/src/commonMain/composeResources/values/strings.xml
+++ b/composeApp/src/commonMain/composeResources/values/strings.xml
@@ -215,8 +215,6 @@
Size must be 0 or a positive number.
Enable endless playback
Keep playback going with recommended tracks when your queue ends.
- Enable Connect
- Expose remote playback controls through the Spotube Connect feature.
Playback proxy server port
Allow remote control
Let other devices on the same network control playback when they connect.
@@ -504,6 +502,7 @@
Connection failed
Connection lost
+ The other device declined the request, or has remote control turned off.
Send failed
Discovery failed: %1$s
Failed to connect: %1$s
@@ -511,7 +510,6 @@
Devices
Refresh
- Connected to %1$s:%2$d
Disconnect
Connecting...
Searching for devices on the network...
@@ -519,23 +517,28 @@
Make sure the other device has \"Allow remote control\" enabled in settings.
Unknown Device
Resolving...
- ID: %1$s...
+ Connected
+ Available devices
+ Connected
+ Open remote
+ Search again
+ Still looking for more devices...
Remote Control
Disconnected
Connection error: %1$s
- Album cover
Unknown Track
Unknown Artist
- Seek
- Shuffle
- Previous
- Next
- Loop mode
Volume
Remove from queue
More options
+ Playing on %1$s
+ If asked, allow this device on the other Spotube first.
+ You\'re no longer controlling the other device. Pick it again to reconnect.
+ Back to devices
+ Nothing playing
+ Start something on the other device, or pick a track from its queue.
Play Where?
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt
new file mode 100644
index 00000000..d67d8efb
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.kt
@@ -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 .
+ */
+
+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?
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt
index 31d5c2d3..b0c0c2f2 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/discovery/DeviceDiscoveryService.kt
@@ -21,8 +21,12 @@ import com.appstractive.dnssd.DiscoveryEvent
import com.appstractive.dnssd.NetService
import com.appstractive.dnssd.createNetService
import com.appstractive.dnssd.discoverServices
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
import kotlin.text.decodeToString
data class DiscoveredDevice(
@@ -35,7 +39,8 @@ data class DiscoveredDevice(
}
sealed interface DiscoveryState {
- data class Discovered(val device: DiscoveredDevice, val resolve: () -> Unit) : DiscoveryState
+ /** [resolve] can block for seconds on desktop (JmDNS), so it's a suspend call that runs off the main thread. */
+ data class Discovered(val device: DiscoveredDevice, val resolve: suspend () -> Unit) : DiscoveryState
data class Resolved(val device: DiscoveredDevice) : DiscoveryState
data class Removed(val device: DiscoveredDevice) : DiscoveryState
}
@@ -46,6 +51,11 @@ class DeviceDiscoveryService {
const val TXT_DEVICE_ID = "deviceId"
}
+ /**
+ * Runs on [Dispatchers.IO]: on desktop, cancelling the underlying flow calls the blocking
+ * `JmDNS.close()` (it waits up to 5s for its shutdown), which froze the UI whenever the
+ * collector was cancelled on the main thread.
+ */
fun discover(): Flow = discoverServices(SERVICE_TYPE).map { event ->
when (event) {
is DiscoveryEvent.Discovered -> {
@@ -55,7 +65,9 @@ class DeviceDiscoveryService {
host = event.service.host,
port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
- DiscoveryState.Discovered(device = device, resolve = event.resolve)
+ DiscoveryState.Discovered(
+ device = device,
+ resolve = { withContext(Dispatchers.IO) { event.resolve() } })
}
is DiscoveryEvent.Resolved -> {
@@ -78,7 +90,7 @@ class DeviceDiscoveryService {
DiscoveryState.Removed(device = device)
}
}
- }
+ }.flowOn(Dispatchers.IO)
suspend fun advertise(
name: String,
@@ -97,5 +109,6 @@ class DeviceDiscoveryService {
type = SERVICE_TYPE,
name = name,
port = port,
+ addresses = preferredAdvertiseAddress()?.let { listOf(it) },
txt = mapOf(TXT_DEVICE_ID to deviceId))
}
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
index aef9d397..0410a281 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/navigation/NavigationModule.kt
@@ -192,7 +192,7 @@ val navigationModule = module {
BlacklistScreen()
}
navigation {
- DevicesScreen(navigationCommands = get())
+ DevicesScreen()
}
navigation {
RemoteControlScreen(
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt
index f4894ede..ed7dce2e 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/core/remote/RemoteControlClient.kt
@@ -26,7 +26,7 @@ import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.plugins.websocket.webSocketSession
import io.ktor.websocket.CloseReason
import io.ktor.websocket.Frame
-import io.ktor.websocket.WebSocketSession
+import io.ktor.websocket.DefaultWebSocketSession
import io.ktor.websocket.close
import io.ktor.websocket.readText
import kotlinx.coroutines.CoroutineScope
@@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import org.jetbrains.compose.resources.StringResource
import spotube.composeapp.generated.resources.Res
+import spotube.composeapp.generated.resources.devices_error_connection_denied
import spotube.composeapp.generated.resources.devices_error_connection_failed
import spotube.composeapp.generated.resources.devices_error_connection_lost
import spotube.composeapp.generated.resources.devices_error_send_failed
@@ -66,7 +67,7 @@ class RemoteControlClient {
}
}
- private var session: WebSocketSession? = null
+ private var session: DefaultWebSocketSession? = null
private val _connectionState = MutableStateFlow(ConnectionState.Disconnected)
val connectionState: StateFlow = _connectionState.asStateFlow()
@@ -77,12 +78,23 @@ class RemoteControlClient {
private val _latestQueue = MutableStateFlow(null)
val latestQueue: StateFlow = _latestQueue.asStateFlow()
- suspend fun connect(host: String, port: Int, deviceId: String, deviceName: String) {
+ /**
+ * [deviceName] identifies this controller to the host; [remoteDeviceName] is the
+ * host's advertised name, kept only so the UI can show what it is controlling.
+ */
+ suspend fun connect(
+ host: String,
+ port: Int,
+ deviceId: String,
+ deviceName: String,
+ remoteDeviceName: String = "") {
if (_connectionState.value is ConnectionState.Connected) {
logger.w { "Already connected" }
return
}
+ // Drop a stale attempt (e.g. one still waiting on the host's permission prompt).
+ closeSession()
_connectionState.value = ConnectionState.Connecting
try {
session = httpClient.webSocketSession {
@@ -95,16 +107,16 @@ class RemoteControlClient {
// Start receiving messages in a separate coroutine
scope.launch {
- receiveLoop(host, port)
+ receiveLoop(host, port, remoteDeviceName)
}
} catch (e: Exception) {
logger.e(e) { "Failed to connect to $host:$port" }
+ closeSession()
_connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_failed)
- disconnect()
}
}
- private suspend fun receiveLoop(host: String, port: Int) {
+ private suspend fun receiveLoop(host: String, port: Int, remoteDeviceName: String) {
val currentSession = session ?: return
logger.d { "Starting receive loop for $host:$port" }
try {
@@ -119,7 +131,7 @@ class RemoteControlClient {
when (event) {
is RemoteControlEvent.Connected -> {
logger.i { "Connection authorized by server" }
- _connectionState.value = ConnectionState.Connected(host, port)
+ _connectionState.value = ConnectionState.Connected(host, port, remoteDeviceName)
}
is RemoteControlEvent.WaitingForPermission -> {
logger.i { "Waiting for permission: ${event.message}" }
@@ -144,18 +156,37 @@ class RemoteControlClient {
logger.w(e) { "Failed to parse message: $text" }
}
}
- is Frame.Close -> {
- logger.i { "WebSocket closed by server" }
- _connectionState.value = ConnectionState.Disconnected
- break
- }
else -> {}
}
}
- logger.d { "Receive loop exited normally" }
+ // Ktor consumes Close frames itself and just ends `incoming`, so the server
+ // closing the socket (denied request, remote control turned off, host quit)
+ // only shows up here. Without this the state stayed Connecting/Connected forever
+ // and every later connect attempt was swallowed.
+ val reason = currentSession.closeReason.await()
+ logger.i { "WebSocket closed by server: $reason" }
+ onServerClosed(currentSession, reason)
} catch (e: Exception) {
logger.e(e) { "Error in receive loop" }
- _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
+ if (session === currentSession) {
+ session = null
+ _connectionState.value = ConnectionState.Error(e.message, Res.string.devices_error_connection_lost)
+ }
+ }
+ }
+
+ private fun onServerClosed(closedSession: DefaultWebSocketSession, reason: CloseReason?) {
+ // disconnect() (or a newer connect()) already replaced this session; nothing to report.
+ if (session !== closedSession) return
+ session = null
+ val wasAuthorized = _connectionState.value is ConnectionState.Connected
+ _connectionState.value = when {
+ // The host refuses with VIOLATED_POLICY before authorizing: the user hit Deny
+ // (or dismissed the prompt), or remote control is turned off over there.
+ !wasAuthorized && reason?.knownReason == CloseReason.Codes.VIOLATED_POLICY ->
+ ConnectionState.Error(null, Res.string.devices_error_connection_denied)
+ wasAuthorized -> ConnectionState.Disconnected
+ else -> ConnectionState.Error(reason?.message?.ifBlank { null }, Res.string.devices_error_connection_failed)
}
}
@@ -180,12 +211,18 @@ class RemoteControlClient {
}
suspend fun disconnect() {
- session?.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting"))
- session = null
+ closeSession()
_connectionState.value = ConnectionState.Disconnected
logger.i { "Disconnected" }
}
+ /** Detaches before closing so the receive loop doesn't report this as a server close. */
+ private suspend fun closeSession() {
+ val current = session ?: return
+ session = null
+ runCatching { current.close(CloseReason(CloseReason.Codes.NORMAL, "Client disconnecting")) }
+ }
+
private fun randomShortId(): String {
val chars = "0123456789abcdef"
return buildString(8) {
@@ -199,7 +236,7 @@ class RemoteControlClient {
sealed interface ConnectionState {
data object Disconnected : ConnectionState
data object Connecting : ConnectionState
- data class Connected(val host: String, val port: Int) : ConnectionState
+ data class Connected(val host: String, val port: Int, val deviceName: String = "") : ConnectionState
/**
* [message] is the raw (untranslated) cause when available; [fallback] is the
* localized description to show when it isn't.
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt
index ea92bd68..aef1a96f 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesScreen.kt
@@ -18,216 +18,195 @@
package org.spotube.spotube.modules.devices
-import org.spotube.millennium.composables.MillenniumSpokeSpinner
-import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
+import org.spotube.millennium.composables.MillenniumCard
+import org.spotube.millennium.composables.MillenniumIcon
+import org.spotube.millennium.composables.MillenniumIconButton
+import org.spotube.millennium.composables.MillenniumIconButtonStyle
+import org.spotube.millennium.composables.MillenniumSpokeSpinner
import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.LazyItemScope
+import androidx.compose.foundation.shape.CircleShape
import org.spotube.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
+import compose.icons.FeatherIcons
+import compose.icons.feathericons.ChevronRight
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import org.spotube.spotube.core.discovery.DiscoveredDevice
import org.spotube.spotube.core.discovery.rememberLocalNetworkPermissionRequester
-import org.spotube.spotube.core.navigation.NavigationCommands
-import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.ui.component.ApplicationMainBar
+import org.spotube.spotube.modules.settings.components.SettingCardItem
+import org.spotube.spotube.modules.settings.sections.SettingsItemIcon
+import org.spotube.spotube.modules.settings.sections.settingsSectionCard
+import org.spotube.spotube.modules.settings.sections.settingsSectionHeader
import org.spotube.spotube.modules.shell.LocalAppShellBottomInset
import org.spotube.spotube.resources.iconsax.Iconsax
+import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare
+import org.spotube.spotube.resources.iconsax.IconsaxInformation
import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen
import org.spotube.spotube.resources.iconsax.IconsaxRefreshRight
+import org.spotube.spotube.resources.iconsax.IconsaxWifiSquare
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
import spotube.composeapp.generated.resources.Res
-import spotube.composeapp.generated.resources.devices_connected_to
-import spotube.composeapp.generated.resources.devices_connecting
-import spotube.composeapp.generated.resources.devices_device_id
import spotube.composeapp.generated.resources.devices_disconnect
import spotube.composeapp.generated.resources.devices_empty
import spotube.composeapp.generated.resources.devices_empty_hint
+import spotube.composeapp.generated.resources.devices_open_remote
import spotube.composeapp.generated.resources.devices_refresh
import spotube.composeapp.generated.resources.devices_resolving
+import spotube.composeapp.generated.resources.devices_search_again
import spotube.composeapp.generated.resources.devices_searching
+import spotube.composeapp.generated.resources.devices_searching_more
+import spotube.composeapp.generated.resources.devices_section_available
+import spotube.composeapp.generated.resources.devices_section_connected
+import spotube.composeapp.generated.resources.devices_status_connected
import spotube.composeapp.generated.resources.devices_title
import spotube.composeapp.generated.resources.devices_unknown_device
import spotube.composeapp.generated.resources.jam_error_dismiss
+private val ContentMaxWidth = 720.dp
+
@Composable
fun DevicesScreen(
- navigationCommands: NavigationCommands) {
- val viewModel = koinViewModel()
- val devices by viewModel.devices.collectAsStateWithLifecycle()
- val isDiscovering by viewModel.isDiscovering.collectAsStateWithLifecycle()
- val connectingToDevice by viewModel.connectingToDevice.collectAsStateWithLifecycle()
- val connectionState by viewModel.connectionState.collectAsStateWithLifecycle()
- val error by viewModel.error.collectAsStateWithLifecycle()
+ viewModel: DevicesViewModel = koinViewModel()
+) {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
val requestLocalNetworkPermission = rememberLocalNetworkPermissionRequester()
+ val shellBottomInset = LocalAppShellBottomInset.current
DisposableEffect(Unit) {
// Android 16+ needs NEARBY_WIFI_DEVICES granted at runtime before mDNS works.
requestLocalNetworkPermission()
viewModel.startDiscovery()
onDispose {
+ // The remote connection itself outlives this screen; only discovery stops.
viewModel.stopDiscovery()
- // Don't disconnect here - the connection should persist when navigating to RemoteControlScreen
- // The RemoteControlViewModel will manage the connection lifecycle
}
}
Column(
modifier = Modifier
.fillMaxSize()
- .background(MillenniumTheme.colors.background)) {
+ .background(MillenniumTheme.colors.background)
+ ) {
ApplicationMainBar(
backButton = true,
title = { Text(stringResource(Res.string.devices_title)) },
actions = {
- if (isDiscovering && connectingToDevice == null) {
+ if (state.isDiscovering) {
MillenniumSpokeSpinner(
- size = 24.dp,
- modifier = Modifier.padding(end = 8.dp))
+ size = 20.dp,
+ modifier = Modifier.padding(horizontal = 10.dp)
+ )
} else {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxRefreshRight,
- 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(
- modifier = Modifier
- .fillMaxWidth()
- .padding(16.dp)) {
- Column {
- Text(
- text = errorMessage,
- color = AppTheme.colors.error,
- style = AppTheme.typography.bodyMedium)
- MillenniumButton(
- onClick = { viewModel.clearError() },
- modifier = Modifier.padding(top = 8.dp),
- style = MillenniumButtonStyle.Secondary) {
- Text(stringResource(Res.string.jam_error_dismiss))
- }
+ MillenniumIconButton(
+ onClick = viewModel::startDiscovery,
+ enabled = !state.isConnecting,
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxRefreshRight,
+ contentDescription = stringResource(Res.string.devices_refresh)
+ )
}
}
}
+ )
- // 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))
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth(),
+ contentAlignment = Alignment.TopCenter
+ ) {
+ LazyColumn(
+ modifier = Modifier
+ .widthIn(max = ContentMaxWidth)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(
+ start = 8.dp,
+ end = 8.dp,
+ top = 8.dp,
+ bottom = shellBottomInset + 16.dp
+ )
+ ) {
+ state.error?.let { error ->
+ item {
+ 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) }
+ )
}
}
- }
- }
- 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)
+ )
+ if (state.isDiscovering) {
+ item {
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),
+ text = stringResource(Res.string.devices_searching_more),
style = AppTheme.typography.bodySmall,
- color = AppTheme.colors.onSurfaceVariant,
- modifier = Modifier.padding(top = 8.dp, start = 32.dp, end = 32.dp))
+ color = MillenniumTheme.colors.muted,
+ textAlign = TextAlign.Center,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ )
}
}
- }
- } else {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .weight(1f),
- verticalArrangement = Arrangement.spacedBy(4.dp),
- contentPadding = androidx.compose.foundation.layout.PaddingValues(
- horizontal = 16.dp,
- vertical = 8.dp)) {
- items(devices.values.toList(), key = { it.key }) { device ->
- DeviceRow(
- device = device,
- isConnecting = connectingToDevice?.key == device.key,
- onClick = { viewModel.connectToDevice(device) })
- }
+ } else if (state.showEmptyState) {
item {
- Box(modifier = Modifier.padding(bottom = shellBottomInset))
+ EmptyState(
+ isDiscovering = state.isDiscovering,
+ onSearchAgain = viewModel::startDiscovery
+ )
}
}
}
@@ -236,48 +215,190 @@ fun DevicesScreen(
}
@Composable
-private fun DeviceRow(
- device: DiscoveredDevice,
- isConnecting: Boolean,
- onClick: () -> Unit) {
- Row(
+private fun DeviceTile(
+ device: DeviceItemUi,
+ enabled: Boolean,
+ onClick: () -> Unit
+) {
+ val name = device.name ?: stringResource(Res.string.devices_unknown_device)
+ SettingCardItem(
+ enabled = enabled,
+ title = name,
+ subtitle = device.address ?: stringResource(Res.string.devices_resolving),
+ icon = { SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name) },
+ trailingContent = {
+ when {
+ device.isConnecting -> MillenniumSpokeSpinner(size = 20.dp)
+ device.isConnected -> Text(
+ text = stringResource(Res.string.devices_status_connected),
+ style = AppTheme.typography.labelMedium,
+ color = AppTheme.colors.primary
+ )
+ else -> MillenniumIcon(
+ imageVector = FeatherIcons.ChevronRight,
+ contentDescription = null,
+ tint = AppTheme.colors.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
- .fillMaxWidth()
- .clickable(onClick = onClick, enabled = !isConnecting)
- .padding(vertical = 12.dp, horizontal = 8.dp),
- verticalAlignment = Alignment.CenterVertically,
- horizontalArrangement = Arrangement.spacedBy(12.dp)) {
- if (isConnecting) {
- MillenniumSpokeSpinner(size = 24.dp)
- } else {
- MillenniumIcon(
- imageVector = Iconsax.IconsaxMirroringScreen,
- contentDescription = null,
- tint = AppTheme.colors.primary)
- }
- Column(modifier = Modifier.weight(1f)) {
- Text(
- text = device.name.ifBlank { stringResource(Res.string.devices_unknown_device) },
- style = AppTheme.typography.bodyLarge,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis)
- Text(
- text = if (device.host.isNotBlank() && device.port > 0) {
- "${device.host}:${device.port}"
- } else {
- stringResource(Res.string.devices_resolving)
- },
- style = AppTheme.typography.bodySmall,
- color = AppTheme.colors.onSurfaceVariant,
- maxLines = 1,
- overflow = TextOverflow.Ellipsis)
- if (device.deviceId.isNotBlank()) {
+ .fillParentMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp),
+ maxWidth = ContentMaxWidth,
+ contentPadding = PaddingValues(16.dp),
+ contentSpacing = 16.dp,
+ shadowElevation = 6.dp
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ SettingsItemIcon(Iconsax.IconsaxMirroringScreen, name)
+ Column(modifier = Modifier.weight(1f)) {
Text(
- text = stringResource(Res.string.devices_device_id, device.deviceId.take(8)),
- style = AppTheme.typography.labelSmall,
- color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.6f),
+ text = name,
+ style = AppTheme.typography.titleMedium,
maxLines = 1,
- overflow = TextOverflow.Ellipsis)
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = device.address,
+ style = AppTheme.typography.bodySmall,
+ color = AppTheme.colors.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End)
+ ) {
+ MillenniumButton(
+ onClick = onDisconnect,
+ style = MillenniumButtonStyle.Secondary
+ ) {
+ Text(stringResource(Res.string.devices_disconnect))
+ }
+ MillenniumButton(
+ onClick = onOpenRemote,
+ style = MillenniumButtonStyle.Primary
+ ) {
+ Text(stringResource(Res.string.devices_open_remote))
+ }
+ }
+ }
+}
+
+@Composable
+private fun ErrorBanner(
+ message: String,
+ onDismiss: () -> Unit
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 8.dp, vertical = 4.dp)
+ .clip(AppTheme.shapes.medium)
+ .background(AppTheme.colors.errorContainer)
+ .padding(start = 12.dp, top = 4.dp, bottom = 4.dp, end = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxInformation,
+ contentDescription = null,
+ tint = AppTheme.colors.onErrorContainer,
+ modifier = Modifier.size(20.dp)
+ )
+ Text(
+ text = message,
+ style = AppTheme.typography.bodyMedium,
+ color = AppTheme.colors.onErrorContainer,
+ modifier = Modifier.weight(1f)
+ )
+ MillenniumIconButton(
+ onClick = onDismiss,
+ style = MillenniumIconButtonStyle.Quiet
+ ) {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxCloseSquare,
+ contentDescription = stringResource(Res.string.jam_error_dismiss),
+ tint = AppTheme.colors.onErrorContainer
+ )
+ }
+ }
+}
+
+@Composable
+private fun LazyItemScope.EmptyState(
+ isDiscovering: Boolean,
+ onSearchAgain: () -> Unit
+) {
+ Box(
+ modifier = Modifier
+ .fillParentMaxHeight(0.8f)
+ .fillParentMaxWidth()
+ .padding(24.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Column(
+ modifier = Modifier.widthIn(max = 360.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(72.dp)
+ .clip(CircleShape)
+ .background(AppTheme.colors.primary.copy(alpha = 0.12f)),
+ contentAlignment = Alignment.Center
+ ) {
+ if (isDiscovering) {
+ MillenniumSpokeSpinner(size = 32.dp)
+ } else {
+ MillenniumIcon(
+ imageVector = Iconsax.IconsaxWifiSquare,
+ contentDescription = null,
+ tint = AppTheme.colors.primary,
+ modifier = Modifier.size(32.dp)
+ )
+ }
+ }
+ Text(
+ text = stringResource(
+ if (isDiscovering) Res.string.devices_searching else Res.string.devices_empty
+ ),
+ style = AppTheme.typography.titleLarge,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.padding(top = 8.dp)
+ )
+ Text(
+ text = stringResource(Res.string.devices_empty_hint),
+ style = AppTheme.typography.bodyMedium,
+ color = AppTheme.colors.onSurfaceVariant,
+ textAlign = TextAlign.Center
+ )
+ if (!isDiscovering) {
+ MillenniumButton(
+ onClick = onSearchAgain,
+ style = MillenniumButtonStyle.Secondary,
+ modifier = Modifier.padding(top = 8.dp)
+ ) {
+ Text(stringResource(Res.string.devices_search_again))
+ }
}
}
}
diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt
index 65f59b5c..e9c24fa4 100644
--- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt
+++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/DevicesViewModel.kt
@@ -29,10 +29,13 @@ import org.spotube.spotube.core.remote.ConnectionState
import org.spotube.spotube.core.remote.RemoteControlClient
import org.spotube.spotube.core.remote.RemoteControlService
import org.spotube.spotube.modules.settings.SettingsProvider
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
@@ -52,6 +55,28 @@ data class DevicesError(
val args: List = emptyList(),
val rawMessage: String? = null)
+data class DeviceItemUi(
+ val key: String,
+ /** Null when the device advertised a blank name. */
+ val name: String?,
+ /** `host:port`, or null while the address is still being resolved. */
+ val address: String?,
+ val isConnecting: Boolean,
+ val isConnected: Boolean)
+
+data class ConnectedDeviceUi(
+ val name: String?,
+ val address: String)
+
+data class DevicesUiState(
+ val devices: List = emptyList(),
+ val isDiscovering: Boolean = false,
+ val isConnecting: Boolean = false,
+ val connectedDevice: ConnectedDeviceUi? = null,
+ val error: DevicesError? = null) {
+ val showEmptyState: Boolean get() = devices.isEmpty() && !isConnecting
+}
+
class DevicesViewModel(
private val navigationCommands: NavigationCommands) : ViewModel(), KoinComponent {
private val logger = Logger.withTag("DevicesViewModel")
@@ -61,36 +86,63 @@ class DevicesViewModel(
private val settingsProvider: SettingsProvider by inject()
private val _devices = MutableStateFlow