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>(emptyMap()) - val devices: StateFlow> = _devices.asStateFlow() - private val _isDiscovering = MutableStateFlow(false) - val isDiscovering: StateFlow = _isDiscovering.asStateFlow() - private val _connectingToDevice = MutableStateFlow(null) - val connectingToDevice: StateFlow = _connectingToDevice.asStateFlow() - - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState.asStateFlow() - private val _error = MutableStateFlow(null) - val error: StateFlow = _error.asStateFlow() + + val uiState: StateFlow = combine( + _devices, + _isDiscovering, + _connectingToDevice, + remoteControlClient.connectionState, + _error + ) { devices, isDiscovering, connectingTo, connection, error -> + val connected = connection as? ConnectionState.Connected + DevicesUiState( + devices = devices.values.map { device -> + DeviceItemUi( + key = device.key, + name = device.name.ifBlank { null }, + address = device.address(), + isConnecting = connectingTo?.key == device.key, + isConnected = connected != null && device.matches(connected)) + }, + isDiscovering = isDiscovering, + isConnecting = connectingTo != null, + connectedDevice = connected?.let { + ConnectedDeviceUi( + name = it.deviceName.ifBlank { null }, + address = "${it.host}:${it.port}") + }, + error = error) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DevicesUiState()) private var discoveryJob: Job? = null init { - // Observe connection state from the client viewModelScope.launch { + var previous: ConnectionState = remoteControlClient.connectionState.value remoteControlClient.connectionState.collect { state -> - _connectionState.value = state - if (state is ConnectionState.Error) { - _error.value = DevicesError(message = state.fallback, rawMessage = state.message) - _connectingToDevice.value = null - } else if (state is ConnectionState.Disconnected) { - _connectingToDevice.value = null - } else if (state is ConnectionState.Connected) { - // Navigate to remote control screen after successful connection - _connectingToDevice.value = null - navigationCommands.navigateTo(Routes.RemoteControl) + val wasConnecting = previous is ConnectionState.Connecting + previous = state + when (state) { + is ConnectionState.Error -> { + _error.value = DevicesError(message = state.fallback, rawMessage = state.message) + _connectingToDevice.value = null + } + // Only a drop *during* an attempt means it failed. Switching devices + // disconnects the old one first, and that must not cancel the new attempt. + is ConnectionState.Disconnected -> if (wasConnecting) _connectingToDevice.value = null + is ConnectionState.Connected -> { + // Only navigate for a connection this screen started. A connection + // that already existed when the screen opened must not bounce the + // user straight to the remote screen. + if (_connectingToDevice.value != null) { + _connectingToDevice.value = null + navigationCommands.navigateTo(Routes.RemoteControl) + } + } + is ConnectionState.Connecting -> {} } } } @@ -119,7 +171,8 @@ class DevicesViewModel( logger.d { "Discovery event: $event" } when (event) { is DiscoveryState.Discovered -> { - event.resolve() + // Don't hold up the rest of the events while this device resolves. + launch { event.resolve() } if (!isSelf(event.device)) { _devices.update { it + (event.device.key to event.device.copy()) } } @@ -137,6 +190,9 @@ class DevicesViewModel( } } } + } catch (e: CancellationException) { + // stopDiscovery() (e.g. leaving the screen) is not a failure. + throw e } catch (e: Exception) { logger.e(e) { "Discovery failed" } _error.value = DevicesError(Res.string.devices_error_discovery_failed, listOf(e.message.toString())) @@ -175,7 +231,25 @@ class DevicesViewModel( _isDiscovering.value = false } - fun connectToDevice(device: DiscoveredDevice) { + fun onDeviceClick(key: String) { + val device = _devices.value[key] ?: return + val connected = remoteControlClient.connectionState.value as? ConnectionState.Connected + when { + // Already controlling it (e.g. came back from the remote screen): just reopen it. + connected != null && device.matches(connected) -> openRemote() + connected != null -> viewModelScope.launch { + remoteControlClient.disconnect() + connectToDevice(device) + } + else -> connectToDevice(device) + } + } + + fun openRemote() { + navigationCommands.navigateTo(Routes.RemoteControl) + } + + private fun connectToDevice(device: DiscoveredDevice) { if (_connectingToDevice.value != null) { logger.w { "Already connecting to a device" } return @@ -200,11 +274,11 @@ class DevicesViewModel( host = device.host, port = device.port, deviceId = deviceId, - deviceName = deviceName) - - // Clear connecting state after connection attempt - // The connectionState flow will show the actual connection status - _connectingToDevice.value = null + deviceName = deviceName, + remoteDeviceName = device.name) + // connect() returns once the socket is open, but the host may still be + // asking its user for permission. The connection-state collector clears + // the connecting flag once the outcome is known. } catch (e: Exception) { logger.e(e) { "Failed to connect to device" } _error.value = DevicesError(Res.string.devices_error_connect_failed, listOf(e.message.toString())) @@ -223,3 +297,9 @@ class DevicesViewModel( _error.value = null } } + +private fun DiscoveredDevice.address(): String? = + if (host.isNotBlank() && port > 0) "$host:$port" else null + +private fun DiscoveredDevice.matches(connected: ConnectionState.Connected): Boolean = + host == connected.host && port == connected.port diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt index bf67dcc8..765df60b 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlScreen.kt @@ -18,35 +18,40 @@ package org.spotube.spotube.modules.devices +import org.spotube.millennium.composables.MillenniumButton +import org.spotube.millennium.composables.MillenniumButtonStyle +import org.spotube.millennium.composables.MillenniumCard +import org.spotube.millennium.composables.MillenniumDropdownMenu import org.spotube.millennium.composables.MillenniumDropdownMenuItem -import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.millennium.composables.MillenniumIcon import org.spotube.millennium.composables.MillenniumIconButton import org.spotube.millennium.composables.MillenniumIconButtonStyle import org.spotube.millennium.composables.MillenniumListTile import org.spotube.millennium.composables.MillenniumSlider -import org.spotube.millennium.composables.MillenniumDropdownMenu +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.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -61,475 +66,675 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign 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 coil3.compose.AsyncImage import org.spotube.spotube.core.remote.ConnectionState -import org.spotube.spotube.core.remote.RemoteQueueEntry import org.spotube.spotube.core.ui.component.ApplicationMainBar import org.spotube.spotube.modules.shell.LocalAppShellBottomInset import org.spotube.spotube.modules.shell.player_queue.QueueSheet import org.spotube.spotube.resources.iconsax.Iconsax import org.spotube.spotube.resources.iconsax.Iconsax3DotsMore import org.spotube.spotube.resources.iconsax.IconsaxCloseSquare +import org.spotube.spotube.resources.iconsax.IconsaxMirroringScreen +import org.spotube.spotube.resources.iconsax.IconsaxMusic import org.spotube.spotube.resources.iconsax.IconsaxMusicFilter +import org.spotube.spotube.resources.iconsax.IconsaxMusicSquareRemove import org.spotube.spotube.resources.iconsax.IconsaxNext import org.spotube.spotube.resources.iconsax.IconsaxPause import org.spotube.spotube.resources.iconsax.IconsaxPlay import org.spotube.spotube.resources.iconsax.IconsaxPrevious +import org.spotube.spotube.resources.iconsax.IconsaxRepeatMusic import org.spotube.spotube.resources.iconsax.IconsaxRepeateMusic +import org.spotube.spotube.resources.iconsax.IconsaxRepeateOne import org.spotube.spotube.resources.iconsax.IconsaxShuffle +import org.spotube.spotube.resources.iconsax.IconsaxVolumeCross import org.spotube.spotube.resources.iconsax.IconsaxVolumeHigh +import org.spotube.spotube.resources.iconsax.IconsaxVolumeLow import org.jetbrains.compose.resources.stringResource import org.koin.compose.viewmodel.koinViewModel import spotube.composeapp.generated.resources.Res import spotube.composeapp.generated.resources.devices_connecting import spotube.composeapp.generated.resources.devices_disconnect -import spotube.composeapp.generated.resources.devices_remote_album_cover +import spotube.composeapp.generated.resources.devices_remote_back_to_devices +import spotube.composeapp.generated.resources.devices_remote_connecting_hint import spotube.composeapp.generated.resources.devices_remote_connection_error import spotube.composeapp.generated.resources.devices_remote_disconnected -import spotube.composeapp.generated.resources.devices_remote_loop_mode +import spotube.composeapp.generated.resources.devices_remote_disconnected_hint import spotube.composeapp.generated.resources.devices_remote_more_options -import spotube.composeapp.generated.resources.devices_remote_next -import spotube.composeapp.generated.resources.devices_remote_previous +import spotube.composeapp.generated.resources.devices_remote_nothing_playing +import spotube.composeapp.generated.resources.devices_remote_nothing_playing_hint +import spotube.composeapp.generated.resources.devices_remote_playing_on import spotube.composeapp.generated.resources.devices_remote_remove_from_queue -import spotube.composeapp.generated.resources.devices_remote_seek -import spotube.composeapp.generated.resources.devices_remote_shuffle import spotube.composeapp.generated.resources.devices_remote_title import spotube.composeapp.generated.resources.devices_remote_unknown_artist import spotube.composeapp.generated.resources.devices_remote_unknown_track import spotube.composeapp.generated.resources.devices_remote_volume +import spotube.composeapp.generated.resources.player_disable_shuffle_content_description +import spotube.composeapp.generated.resources.player_enable_shuffle_content_description +import spotube.composeapp.generated.resources.player_loop_mode_all +import spotube.composeapp.generated.resources.player_loop_mode_none +import spotube.composeapp.generated.resources.player_loop_mode_one +import spotube.composeapp.generated.resources.player_next_content_description import spotube.composeapp.generated.resources.player_pause_content_description import spotube.composeapp.generated.resources.player_play_content_description +import spotube.composeapp.generated.resources.player_previous_content_description +import spotube.composeapp.generated.resources.player_seek_label import spotube.composeapp.generated.resources.queue_no_queue_entries import spotube.composeapp.generated.resources.queue_title -import kotlin.time.Duration.Companion.milliseconds + +// Below this content width the queue moves into a sheet; above it, it sits beside the player. +private val WideLayoutMinWidth = 760.dp +private val QueuePanelWidth = 380.dp +private val NowPlayingMaxWidth = 520.dp + +// Rough height of everything under the artwork (track info, seek bar, controls, volume). +// The artwork gets what's left, so the whole player fits without scrolling. +private val ControlsReservedHeight = 330.dp +private val MinArtworkSize = 140.dp +private val MaxArtworkSize = 460.dp @Composable fun RemoteControlScreen( - onDisconnect: () -> Unit + onDisconnect: () -> Unit, + viewModel: RemoteControlViewModel = koinViewModel() ) { - val viewModel = koinViewModel() - val playerState by viewModel.playerState.collectAsStateWithLifecycle() - val connectionState by viewModel.connectionState.collectAsStateWithLifecycle() - val queueState by viewModel.queueState.collectAsStateWithLifecycle() - val isQueueVisible by viewModel.isQueueVisible.collectAsStateWithLifecycle() + val state by viewModel.uiState.collectAsStateWithLifecycle() val shellBottomInset = LocalAppShellBottomInset.current + val isConnected = state.connection is ConnectionState.Connected - // Center the mobile-inspired layout and limit its width so it doesn't - // stretch awkwardly on large screens. - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.TopCenter + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MillenniumTheme.colors.background) ) { - Column( - modifier = Modifier - .fillMaxHeight() - .background(MillenniumTheme.colors.background) - ) { + val isWide = maxWidth >= WideLayoutMinWidth + + Column(modifier = Modifier.fillMaxSize()) { ApplicationMainBar( title = { Text(stringResource(Res.string.devices_remote_title)) }, + subtitle = { + if (isConnected && state.deviceName.isNotBlank()) { + Text(stringResource(Res.string.devices_remote_playing_on, state.deviceName)) + } + }, backButton = true, actions = { - MillenniumIconButton( - onClick = viewModel::toggleQueueVisibility, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = stringResource(Res.string.queue_title), - tint = if (isQueueVisible) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) + if (isConnected && !isWide) { + MillenniumIconButton( + onClick = viewModel::toggleQueueVisibility, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusicFilter, + contentDescription = stringResource(Res.string.queue_title), + tint = if (state.isQueueVisible) { + AppTheme.colors.primary + } else { + AppTheme.colors.onSurfaceVariant + } + ) + } } - MillenniumIconButton( - onClick = { - viewModel.disconnect() - onDisconnect() - }, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxCloseSquare, - contentDescription = stringResource(Res.string.devices_disconnect) - ) + if (isConnected) { + MillenniumIconButton( + onClick = { + viewModel.disconnect() + onDisconnect() + }, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxCloseSquare, + contentDescription = stringResource(Res.string.devices_disconnect) + ) + } } } ) - Box(modifier = Modifier.weight(1f)) { - when (connectionState) { + + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(bottom = shellBottomInset) + ) { + when (val connection = state.connection) { is ConnectionState.Connected -> { - RemoteControlContent( - playerState = playerState, - onTogglePlayPause = viewModel::togglePlayPause, - onSkipNext = viewModel::skipNext, - onSkipPrevious = viewModel::skipPrevious, - onSeek = viewModel::seek, - onSetVolume = viewModel::setVolume, - onToggleShuffle = viewModel::toggleShuffle, - onCycleLoopMode = viewModel::cycleLoopMode, - modifier = Modifier.padding(bottom = shellBottomInset) + if (isWide) { + Row( + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalArrangement = Arrangement.spacedBy(24.dp) + ) { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier + .weight(1f) + .fillMaxHeight() + ) + MillenniumCard( + modifier = Modifier + .width(QueuePanelWidth) + .fillMaxHeight(), + maxWidth = QueuePanelWidth, + contentPadding = PaddingValues(12.dp), + shadowElevation = 2.dp + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) + } + } + } else { + NowPlayingPane( + state = state, + viewModel = viewModel, + modifier = Modifier.fillMaxSize() + ) + } + } + + is ConnectionState.Connecting -> ConnectionStatus( + title = stringResource(Res.string.devices_connecting), + message = stringResource(Res.string.devices_remote_connecting_hint), + leading = { MillenniumSpokeSpinner(size = 40.dp) } + ) + + is ConnectionState.Disconnected -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource(Res.string.devices_remote_disconnected_hint), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = false) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + + is ConnectionState.Error -> ConnectionStatus( + title = stringResource(Res.string.devices_remote_disconnected), + message = stringResource( + Res.string.devices_remote_connection_error, + connection.message ?: stringResource(connection.fallback) + ), + leading = { StatusIcon(Iconsax.IconsaxMirroringScreen, isError = true) }, + actionLabel = stringResource(Res.string.devices_remote_back_to_devices), + onAction = onDisconnect + ) + } + } + } + + if (isConnected && !isWide) { + // Click-outside scrim for the sliding drawer variant of the queue sheet. + // (The bottom sheet variant has its own built-in scrim.) + if (state.isQueueVisible) { + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = viewModel::dismissQueue ) - } - - is ConnectionState.Connecting -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.devices_connecting)) - } - } - - is ConnectionState.Disconnected -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - Text(stringResource(Res.string.devices_remote_disconnected)) - } - } - - is ConnectionState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset), - contentAlignment = Alignment.Center - ) { - val errorState = connectionState as ConnectionState.Error - Text( - stringResource( - Res.string.devices_remote_connection_error, - errorState.message ?: stringResource(errorState.fallback))) - } - } + ) + } + Box( + modifier = Modifier + .fillMaxSize() + .padding(bottom = shellBottomInset) + ) { + QueueSheet( + isVisible = state.isQueueVisible, + onDismiss = viewModel::dismissQueue, + modifier = Modifier.fillMaxSize() + ) { + RemoteQueueList( + state = state, + onPlayQueueItem = viewModel::playQueueItem, + onRemoveQueueItem = viewModel::removeQueueItem + ) } } } } +} - // Click-outside scrim for the sliding queue sheet on large screens. - // (The ModalBottomSheet variant has its own built-in scrim.) - if (isQueueVisible) { - Box( +@Composable +private fun NowPlayingPane( + state: RemoteControlUiState, + viewModel: RemoteControlViewModel, + modifier: Modifier = Modifier +) { + BoxWithConstraints(modifier = modifier) { + val artworkSize = minOf( + maxWidth - 32.dp, + maxHeight - ControlsReservedHeight, + MaxArtworkSize + ).coerceAtLeast(MinArtworkSize) + + // Centered when it fits; scrolls only on very short windows. + Column( modifier = Modifier - .fillMaxSize() - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = null, - onClick = { viewModel.toggleQueueVisibility() }) - ) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .heightIn(min = maxHeight) + .padding(horizontal = 16.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Column( + modifier = Modifier.widthIn(max = NowPlayingMaxWidth), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Artwork( + coverUrl = state.coverUrl, + contentDescription = state.title, + size = artworkSize + ) + TrackInfo( + state = state, + modifier = Modifier + .fillMaxWidth() + .padding(top = 24.dp) + ) + SeekBar( + state = state, + onSeekChange = viewModel::onSeekChange, + onSeekFinished = viewModel::onSeekFinished, + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp) + ) + PlaybackControls( + state = state, + onToggleShuffle = viewModel::toggleShuffle, + onSkipPrevious = viewModel::skipPrevious, + onTogglePlayPause = viewModel::togglePlayPause, + onSkipNext = viewModel::skipNext, + onCycleLoopMode = viewModel::cycleLoopMode, + modifier = Modifier + .fillMaxWidth() + .padding(top = 16.dp, bottom = 12.dp) + ) + VolumeControl( + state = state, + onVolumeChange = viewModel::onVolumeChange, + onVolumeChangeFinished = viewModel::onVolumeChangeFinished, + modifier = Modifier.fillMaxWidth() + ) + } + } } +} - // Keep the sliding sheet above the AppLargePlayer on large screens. +@Composable +private fun Artwork( + coverUrl: String?, + contentDescription: String?, + size: Dp +) { Box( modifier = Modifier - .fillMaxSize() - .padding(bottom = shellBottomInset) + .size(size) + .clip(RoundedCornerShape(22.dp)) + .background(AppTheme.colors.surfaceContainerHighest), + contentAlignment = Alignment.Center ) { - QueueSheet( - isVisible = isQueueVisible, - onDismiss = { viewModel.toggleQueueVisibility() }, - modifier = Modifier.fillMaxSize() - ) { - RemoteQueueSection( - queueState = queueState, - onPlayQueueItem = viewModel::playQueueItem, - onRemoveQueueItem = viewModel::removeQueueItem + if (coverUrl != null) { + AsyncImage( + model = coverUrl, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop + ) + } else { + MillenniumIcon( + imageVector = Iconsax.IconsaxMusic, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(56.dp) ) } } } @Composable -private fun RemoteControlContent( - playerState: RemotePlayerState, +private fun TrackInfo( + state: RemoteControlUiState, + modifier: Modifier = Modifier +) { + Column( + modifier = modifier, + horizontalAlignment = Alignment.CenterHorizontally + ) { + if (!state.hasTrack) { + Text( + text = stringResource(Res.string.devices_remote_nothing_playing), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1 + ) + Text( + text = stringResource(Res.string.devices_remote_nothing_playing_hint), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 4.dp) + ) + return@Column + } + + Text( + text = state.title ?: stringResource(Res.string.devices_remote_unknown_track), + style = AppTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + Text( + text = state.artists ?: stringResource(Res.string.devices_remote_unknown_artist), + style = AppTheme.typography.bodyLarge, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + state.album?.let { album -> + Text( + text = album, + style = AppTheme.typography.bodySmall, + color = MillenniumTheme.colors.muted, + textAlign = TextAlign.Center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = 2.dp) + ) + } + } +} + +@Composable +private fun SeekBar( + state: RemoteControlUiState, + onSeekChange: (Float) -> Unit, + onSeekFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Column(modifier = modifier) { + MillenniumSlider( + value = state.seekProgress, + onValueChange = onSeekChange, + onValueChangeFinished = onSeekFinished, + enabled = state.canSeek, + label = stringResource(Res.string.player_seek_label), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = 2.dp), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = state.positionLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + Text( + text = state.durationLabel, + style = AppTheme.typography.labelSmall, + color = AppTheme.colors.onSurfaceVariant + ) + } + } +} + +@Composable +private fun PlaybackControls( + state: RemoteControlUiState, + onToggleShuffle: () -> Unit, + onSkipPrevious: () -> Unit, onTogglePlayPause: () -> Unit, onSkipNext: () -> Unit, - onSkipPrevious: () -> Unit, - onSeek: (Long) -> Unit, - onSetVolume: (Float) -> Unit, - onToggleShuffle: () -> Unit, onCycleLoopMode: () -> Unit, modifier: Modifier = Modifier ) { - Box(modifier = Modifier.fillMaxSize()) { - Column( - modifier = modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 24.dp) - .widthIn(max = 480.dp) - .align(Alignment.TopCenter), - horizontalAlignment = Alignment.CenterHorizontally + Row( + modifier = modifier, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + MillenniumIconButton( + onClick = onToggleShuffle, + style = MillenniumIconButtonStyle.Quiet ) { - Spacer(modifier = Modifier.height(32.dp)) - - // Album art - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(1f) - .clip(RoundedCornerShape(16.dp)) - .background(AppTheme.colors.surfaceVariant) - ) { - if (playerState.currentTrackCoverUrl != null) { - AsyncImage( - model = playerState.currentTrackCoverUrl, - contentDescription = stringResource(Res.string.devices_remote_album_cover), - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop - ) - } else { - Box( - modifier = Modifier.fillMaxSize(), - contentAlignment = Alignment.Center - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxMusicFilter, - contentDescription = null, - tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(48.dp) - ) + MillenniumIcon( + imageVector = Iconsax.IconsaxShuffle, + contentDescription = stringResource( + if (state.isShuffling) { + Res.string.player_disable_shuffle_content_description + } else { + Res.string.player_enable_shuffle_content_description } + ), + tint = if (state.isShuffling) AppTheme.colors.primary else AppTheme.colors.onSurfaceVariant + ) + } + MillenniumIconButton( + onClick = onSkipPrevious, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxPrevious, + contentDescription = stringResource(Res.string.player_previous_content_description) + ) + } + MillenniumIconButton( + onClick = onTogglePlayPause, + style = MillenniumIconButtonStyle.Primary + ) { + MillenniumIcon( + imageVector = if (state.isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, + contentDescription = stringResource( + if (state.isPlaying) { + Res.string.player_pause_content_description + } else { + Res.string.player_play_content_description + } + ), + modifier = Modifier.size(30.dp), + tint = LocalTextStyle.current.color + ) + } + MillenniumIconButton( + onClick = onSkipNext, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = Iconsax.IconsaxNext, + contentDescription = stringResource(Res.string.player_next_content_description) + ) + } + MillenniumIconButton( + onClick = onCycleLoopMode, + style = MillenniumIconButtonStyle.Quiet + ) { + MillenniumIcon( + imageVector = when (state.loopMode) { + RemoteLoopMode.NONE -> Iconsax.IconsaxRepeateMusic + RemoteLoopMode.ONE -> Iconsax.IconsaxRepeateOne + RemoteLoopMode.ALL -> Iconsax.IconsaxRepeatMusic + }, + contentDescription = stringResource( + when (state.loopMode) { + RemoteLoopMode.NONE -> Res.string.player_loop_mode_none + RemoteLoopMode.ONE -> Res.string.player_loop_mode_one + RemoteLoopMode.ALL -> Res.string.player_loop_mode_all + } + ), + tint = if (state.loopMode == RemoteLoopMode.NONE) { + AppTheme.colors.onSurfaceVariant + } else { + AppTheme.colors.primary } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Track info - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Text( - text = playerState.currentTrackTitle ?: stringResource(Res.string.devices_remote_unknown_track), - style = AppTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - textAlign = TextAlign.Center, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - text = playerState.currentTrackArtists ?: stringResource(Res.string.devices_remote_unknown_artist), - style = AppTheme.typography.bodyLarge, - color = AppTheme.colors.onSurfaceVariant, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - - if (playerState.currentTrackAlbum != null) { - Spacer(modifier = Modifier.height(4.dp)) - Text( - text = playerState.currentTrackAlbum!!, - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant.copy(alpha = 0.7f), - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Seek bar - Column( - modifier = Modifier.fillMaxWidth() - ) { - MillenniumSlider( - value = playerState.positionMs.toFloat(), - onValueChange = { onSeek(it.toLong()) }, - label = stringResource(Res.string.devices_remote_seek), - valueRange = 0f..playerState.durationMs.toFloat().coerceAtLeast(1f), - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = formatDuration(playerState.positionMs), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - Text( - text = formatDuration(playerState.durationMs), - style = AppTheme.typography.bodySmall, - color = AppTheme.colors.onSurfaceVariant - ) - } - } - - Spacer(modifier = Modifier.height(24.dp)) - - // Playback controls - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceEvenly, - verticalAlignment = Alignment.CenterVertically - ) { - // Shuffle - MillenniumIconButton( - onClick = onToggleShuffle, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxShuffle, - contentDescription = stringResource(Res.string.devices_remote_shuffle), - tint = if (playerState.shuffleEnabled) { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) - } - - // Skip previous - MillenniumIconButton( - onClick = onSkipPrevious, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxPrevious, - contentDescription = stringResource(Res.string.devices_remote_previous), - modifier = Modifier.size(32.dp) - ) - } - - // Play/Pause - MillenniumIconButton( - onClick = onTogglePlayPause, - style = MillenniumIconButtonStyle.Primary - ) { - MillenniumIcon( - imageVector = if (playerState.isPlaying) { - Iconsax.IconsaxPause - } else { - Iconsax.IconsaxPlay - }, - contentDescription = if (playerState.isPlaying) { - stringResource(Res.string.player_pause_content_description) - } else { - stringResource(Res.string.player_play_content_description) - }, - modifier = Modifier.size(40.dp), - tint = LocalTextStyle.current.color - ) - } - - // Skip next - MillenniumIconButton( - onClick = onSkipNext, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxNext, - contentDescription = stringResource(Res.string.devices_remote_next), - modifier = Modifier.size(32.dp) - ) - } - - // Loop mode - MillenniumIconButton( - onClick = onCycleLoopMode, - style = MillenniumIconButtonStyle.Quiet - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxRepeateMusic, - contentDescription = stringResource(Res.string.devices_remote_loop_mode), - tint = if (playerState.loopMode != "none") { - AppTheme.colors.primary - } else { - AppTheme.colors.onSurfaceVariant - } - ) - } - } - - Spacer(modifier = Modifier.height(32.dp)) - - // Volume control - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) - ) { - MillenniumIcon( - imageVector = Iconsax.IconsaxVolumeHigh, - contentDescription = stringResource(Res.string.devices_remote_volume), - tint = AppTheme.colors.onSurfaceVariant, - modifier = Modifier.size(24.dp) - ) - - MillenniumSlider( - value = playerState.volume, - onValueChange = onSetVolume, - label = stringResource(Res.string.devices_remote_volume), - valueRange = 0f..1f, - modifier = Modifier.weight(1f) - ) - } - - Spacer(modifier = Modifier.height(32.dp)) + ) } } } @Composable -private fun RemoteQueueSection( - queueState: RemoteQueueState, +private fun VolumeControl( + state: RemoteControlUiState, + onVolumeChange: (Float) -> Unit, + onVolumeChangeFinished: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + MillenniumIcon( + imageVector = when (state.volumeLevel) { + RemoteVolumeLevel.MUTED -> Iconsax.IconsaxVolumeCross + RemoteVolumeLevel.LOW -> Iconsax.IconsaxVolumeLow + RemoteVolumeLevel.HIGH -> Iconsax.IconsaxVolumeHigh + }, + contentDescription = null, + tint = AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(20.dp) + ) + MillenniumSlider( + value = state.volume, + onValueChange = onVolumeChange, + onValueChangeFinished = onVolumeChangeFinished, + label = stringResource(Res.string.devices_remote_volume), + modifier = Modifier.weight(1f) + ) + } +} + +@Composable +private fun ConnectionStatus( + title: String, + message: String, + leading: @Composable () -> Unit, + actionLabel: String? = null, + onAction: () -> Unit = {} +) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center + ) { + Column( + modifier = Modifier.widthIn(max = 360.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + leading() + Text( + text = title, + style = AppTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.padding(top = 8.dp) + ) + Text( + text = message, + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant, + textAlign = TextAlign.Center + ) + if (actionLabel != null) { + MillenniumButton( + onClick = onAction, + style = MillenniumButtonStyle.Secondary, + modifier = Modifier.padding(top = 8.dp) + ) { + Text(actionLabel) + } + } + } + } +} + +@Composable +private fun StatusIcon(icon: ImageVector, isError: Boolean) { + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background( + if (isError) AppTheme.colors.errorContainer else AppTheme.colors.surfaceContainerHighest + ), + contentAlignment = Alignment.Center + ) { + MillenniumIcon( + imageVector = icon, + contentDescription = null, + tint = if (isError) AppTheme.colors.onErrorContainer else AppTheme.colors.onSurfaceVariant, + modifier = Modifier.size(32.dp) + ) + } +} + +@Composable +private fun RemoteQueueList( + state: RemoteControlUiState, onPlayQueueItem: (Int) -> Unit, onRemoveQueueItem: (String) -> Unit ) { Column( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), + modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp) ) { Text( text = stringResource(Res.string.queue_title), - style = AppTheme.typography.titleLarge + style = AppTheme.typography.titleLarge, + modifier = Modifier.padding(horizontal = 4.dp) ) - if (queueState.entries.isEmpty()) { - Text( - text = stringResource(Res.string.queue_no_queue_entries), - style = AppTheme.typography.bodyMedium, - color = AppTheme.colors.onSurfaceVariant - ) + if (state.queue.isEmpty()) { + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + contentAlignment = Alignment.Center + ) { + Text( + text = stringResource(Res.string.queue_no_queue_entries), + style = AppTheme.typography.bodyMedium, + color = AppTheme.colors.onSurfaceVariant + ) + } } else { + // Open with the current track in view; after that the user owns the scroll position. + val listState = rememberLazyListState( + initialFirstVisibleItemIndex = state.currentQueueIndex.coerceAtLeast(0) + ) LazyColumn( - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = listState, contentPadding = PaddingValues(bottom = 8.dp), verticalArrangement = Arrangement.spacedBy(4.dp) ) { - itemsIndexed( - items = queueState.entries, - key = { index, entry -> "${entry.mediaUrl}@$index" }) { index, entry -> + items(items = state.queue, key = { it.key }) { item -> RemoteQueueItemRow( - entry = entry, - index = index, - isCurrent = index == queueState.currentIndex, - onPlayClick = { onPlayQueueItem(index) }, - onRemoveClick = { onRemoveQueueItem(entry.mediaUrl) }) + item = item, + onPlayClick = { onPlayQueueItem(item.index) }, + onRemoveClick = { onRemoveQueueItem(item.mediaUrl) } + ) } } } @@ -538,9 +743,7 @@ private fun RemoteQueueSection( @Composable private fun RemoteQueueItemRow( - entry: RemoteQueueEntry, - index: Int, - isCurrent: Boolean, + item: RemoteQueueItemUi, onPlayClick: () -> Unit, onRemoveClick: () -> Unit ) { @@ -548,26 +751,25 @@ private fun RemoteQueueItemRow( MillenniumListTile( onClick = onPlayClick, - selected = isCurrent, - modifier = Modifier, + selected = item.isCurrent, leading = { Box( modifier = Modifier - .size(48.dp) + .size(40.dp) .clip(AppTheme.shapes.small) .background(AppTheme.colors.surfaceVariant), contentAlignment = Alignment.Center ) { - if (entry.coverUrl != null) { + if (item.coverUrl != null) { AsyncImage( - model = entry.coverUrl, + model = item.coverUrl, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop ) } else { Text( - text = "${index + 1}", + text = "${item.index + 1}", style = AppTheme.typography.labelMedium, color = AppTheme.colors.onSurfaceVariant ) @@ -576,11 +778,11 @@ private fun RemoteQueueItemRow( }, title = { Text( - text = entry.title, + text = item.title, style = AppTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, - color = if (isCurrent) { + color = if (item.isCurrent) { AppTheme.colors.onSecondaryContainer } else { AppTheme.colors.onSurface @@ -589,7 +791,7 @@ private fun RemoteQueueItemRow( }, subtitle = { Text( - text = entry.artists, + text = item.artists, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant, maxLines = 1, @@ -598,7 +800,7 @@ private fun RemoteQueueItemRow( }, trailing = { Text( - text = formatDuration(entry.durationMs), + text = item.durationLabel, style = AppTheme.typography.bodySmall, color = AppTheme.colors.onSurfaceVariant ) @@ -613,8 +815,7 @@ private fun RemoteQueueItemRow( ) { MillenniumIcon( Iconsax.Iconsax3DotsMore, - contentDescription = stringResource(Res.string.devices_remote_more_options), - modifier = Modifier.size(18.dp) + contentDescription = stringResource(Res.string.devices_remote_more_options) ) } }) { @@ -630,10 +831,3 @@ private fun RemoteQueueItemRow( } ) } - -private fun formatDuration(ms: Long): String { - val duration = ms.milliseconds - val minutes = duration.inWholeMinutes - val seconds = duration.inWholeSeconds % 60 - return "$minutes:${seconds.toString().padStart(2, '0')}" -} diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt index 9b4c0896..0c5d78c1 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/devices/RemoteControlViewModel.kt @@ -19,164 +19,244 @@ package org.spotube.spotube.modules.devices import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import co.touchlab.kermit.Logger import org.spotube.spotube.core.remote.ConnectionState import org.spotube.spotube.core.remote.RemoteControlClient import org.spotube.spotube.core.remote.RemoteControlCommand import org.spotube.spotube.core.remote.RemoteControlEvent -import org.spotube.spotube.core.remote.RemoteQueueEntry +import org.spotube.spotube.modules.shell.formatPlayerTime +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import org.koin.core.component.KoinComponent import org.koin.core.component.inject +import kotlin.time.Duration.Companion.milliseconds -data class RemotePlayerState( +enum class RemoteLoopMode(val wireValue: String) { + NONE("none"), + ONE("one"), + ALL("all"); + + fun next(): RemoteLoopMode = when (this) { + NONE -> ONE + ONE -> ALL + ALL -> NONE + } + + companion object { + fun fromWire(value: String): RemoteLoopMode = entries.firstOrNull { it.wireValue == value } ?: NONE + } +} + +enum class RemoteVolumeLevel { MUTED, LOW, HIGH } + +data class RemoteQueueItemUi( + val key: String, + val index: Int, + val mediaUrl: String, + val title: String, + val artists: String, + val coverUrl: String?, + val durationLabel: String, + val isCurrent: Boolean) + +data class RemoteControlUiState( + val connection: ConnectionState = ConnectionState.Disconnected, + val deviceName: String = "", + val hasTrack: Boolean = false, + val title: String? = null, + val artists: String? = null, + val album: String? = null, + val coverUrl: String? = null, val isPlaying: Boolean = false, - val positionMs: Long = 0, - val durationMs: Long = 0, - val volume: Float = 1.0f, - val shuffleEnabled: Boolean = false, - val loopMode: String = "none", - val currentTrackId: String? = null, - val currentTrackTitle: String? = null, - val currentTrackArtists: String? = null, - val currentTrackAlbum: String? = null, - val currentTrackCoverUrl: String? = null) + val isShuffling: Boolean = false, + val loopMode: RemoteLoopMode = RemoteLoopMode.NONE, + val canSeek: Boolean = false, + val seekProgress: Float = 0f, + val positionLabel: String = formatPlayerTime(0.milliseconds), + val durationLabel: String = formatPlayerTime(0.milliseconds), + val volume: Float = 1f, + val volumeLevel: RemoteVolumeLevel = RemoteVolumeLevel.HIGH, + val queue: List = emptyList(), + val currentQueueIndex: Int = -1, + val isQueueVisible: Boolean = false) -data class RemoteQueueState( - val entries: List = emptyList(), - val currentIndex: Int = -1) +private data class LocalOverrides( + val seekProgress: Float?, + val volume: Float?, + val isQueueVisible: Boolean) class RemoteControlViewModel : ViewModel(), KoinComponent { - private val logger = Logger.withTag("RemoteControlViewModel") private val remoteControlClient: RemoteControlClient by inject() - private val _playerState = MutableStateFlow(RemotePlayerState()) - val playerState: StateFlow = _playerState.asStateFlow() + // While the user drags a slider these hold the local value, so the host's + // periodic state pushes don't yank the thumb back mid-gesture. + private val seekOverride = MutableStateFlow(null) + private val volumeOverride = MutableStateFlow(null) + private val isQueueVisible = MutableStateFlow(false) - private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) - val connectionState: StateFlow = _connectionState.asStateFlow() + private val overrides = combine(seekOverride, volumeOverride, isQueueVisible) { seek, volume, queueVisible -> + LocalOverrides(seek, volume, queueVisible) + } - private val _queueState = MutableStateFlow(RemoteQueueState()) - val queueState: StateFlow = _queueState.asStateFlow() - - private val _isQueueVisible = MutableStateFlow(false) - val isQueueVisible: StateFlow = _isQueueVisible.asStateFlow() + val uiState: StateFlow = combine( + remoteControlClient.connectionState, + remoteControlClient.latestPlayerState, + remoteControlClient.latestQueue, + overrides + ) { connection, player, queue, local -> + buildUiState(connection, player, queue, local) + }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), RemoteControlUiState()) init { + // Stream volume changes while dragging, throttled so a fast drag doesn't + // flood the socket with one command per frame. viewModelScope.launch { - remoteControlClient.connectionState.collect { state -> - _connectionState.value = state - } - } - - viewModelScope.launch { - remoteControlClient.latestPlayerState.collect { event -> - if (event != null) { - handlePlayerState(event) - } - } - } - - viewModelScope.launch { - remoteControlClient.latestQueue.collect { event -> - if (event != null) { - handleQueueUpdated(event) - } + volumeOverride.filterNotNull().collect { volume -> + remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + delay(VOLUME_THROTTLE_MS) } } } - private fun handlePlayerState(event: RemoteControlEvent.PlayerState) { - _playerState.update { - it.copy( - isPlaying = event.isPlaying, - positionMs = event.positionMs, - durationMs = event.durationMs, - volume = event.volume, - shuffleEnabled = event.shuffleEnabled, - loopMode = event.loopMode, - currentTrackId = event.currentTrackId, - currentTrackTitle = event.currentTrackTitle, - currentTrackArtists = event.currentTrackArtists, - currentTrackAlbum = event.currentTrackAlbum, - currentTrackCoverUrl = event.currentTrackCoverUrl) + 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 handleQueueUpdated(event: RemoteControlEvent.QueueUpdated) { - _queueState.value = RemoteQueueState( - entries = event.entries, - currentIndex = event.currentIndex) - } - - fun togglePlayPause() { + private fun send(command: RemoteControlCommand) { viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.TogglePlayPause) + remoteControlClient.sendCommand(command) } } - fun skipNext() { + /** + * 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, value: Float) { + withTimeoutOrNull(OVERRIDE_RELEASE_TIMEOUT_MS) { + remoteControlClient.latestPlayerState.drop(1).first() + } + override.compareAndSet(value, null) + } + + fun togglePlayPause() = send(RemoteControlCommand.TogglePlayPause) + + fun skipNext() = send(RemoteControlCommand.SkipNext) + + fun skipPrevious() = send(RemoteControlCommand.SkipPrevious) + + fun onSeekChange(progress: Float) { + seekOverride.value = progress.coerceIn(0f, 1f) + } + + fun onSeekFinished() { + val progress = seekOverride.value ?: return + val durationMs = remoteControlClient.latestPlayerState.value?.durationMs ?: 0L + if (durationMs <= 0L) { + seekOverride.value = null + return + } viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.SkipNext) + remoteControlClient.sendCommand(RemoteControlCommand.Seek((durationMs * progress).toLong())) + releaseOverrideAfterEcho(seekOverride, progress) } } - fun skipPrevious() { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.SkipPrevious) - } + fun onVolumeChange(volume: Float) { + volumeOverride.value = volume.coerceIn(0f, 1f) } - fun seek(positionMs: Long) { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.Seek(positionMs)) - } - } - - fun setVolume(volume: Float) { + fun onVolumeChangeFinished() { + val volume = volumeOverride.value ?: return viewModelScope.launch { remoteControlClient.sendCommand(RemoteControlCommand.SetVolume(volume)) + releaseOverrideAfterEcho(volumeOverride, volume) } } fun toggleShuffle() { - viewModelScope.launch { - val newState = !_playerState.value.shuffleEnabled - remoteControlClient.sendCommand(RemoteControlCommand.SetShuffle(newState)) - } + val shuffling = remoteControlClient.latestPlayerState.value?.shuffleEnabled ?: false + send(RemoteControlCommand.SetShuffle(!shuffling)) } fun cycleLoopMode() { - viewModelScope.launch { - val currentMode = _playerState.value.loopMode - val newMode = when (currentMode) { - "none" -> "one" - "one" -> "all" - else -> "none" - } - remoteControlClient.sendCommand(RemoteControlCommand.SetLoopMode(newMode)) - } + val current = RemoteLoopMode.fromWire( + remoteControlClient.latestPlayerState.value?.loopMode ?: RemoteLoopMode.NONE.wireValue) + send(RemoteControlCommand.SetLoopMode(current.next().wireValue)) } fun playQueueItem(index: Int) { if (index < 0) return - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.PlayIndex(index)) - } + send(RemoteControlCommand.PlayIndex(index)) } - fun removeQueueItem(mediaUrl: String) { - viewModelScope.launch { - remoteControlClient.sendCommand(RemoteControlCommand.RemoveFromQueue(mediaUrl)) - } - } + fun removeQueueItem(mediaUrl: String) = send(RemoteControlCommand.RemoveFromQueue(mediaUrl)) fun toggleQueueVisibility() { - _isQueueVisible.update { !it } + isQueueVisible.update { !it } + } + + fun dismissQueue() { + isQueueVisible.value = false } fun disconnect() { @@ -184,4 +264,9 @@ class RemoteControlViewModel : ViewModel(), KoinComponent { remoteControlClient.disconnect() } } + + private companion object { + const val VOLUME_THROTTLE_MS = 100L + const val OVERRIDE_RELEASE_TIMEOUT_MS = 1_500L + } } diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt index 9a50178f..d6da4e16 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/SettingsModels.kt @@ -56,7 +56,6 @@ data class UserSettings( val cacheFolder: String? = null, val cacheSizeLimitMB: Long = -1L, val enableEndlessPlayback: Boolean = true, - val enableConnect: Boolean = false, val playbackProxyServerPort: Int = 14769, // Remote Control (LAN) diff --git a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt index 47d0e51f..7688d0b6 100644 --- a/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt +++ b/composeApp/src/commonMain/kotlin/org/spotube/spotube/modules/settings/sections/PlaybackSection.kt @@ -122,21 +122,6 @@ internal fun LazyListScope.playbackSection( } ) } - add { - SwitchSettingCard( - title = stringResource(Res.string.settings_enable_connect_title), - subtitle = stringResource(Res.string.settings_enable_connect_subtitle), - icon = { - SettingsItemIcon(Iconsax.IconsaxMirroringScreen, stringResource(Res.string.settings_enable_connect_title)) - }, - checked = settings.enableConnect, - onCheckedChange = { enabled -> - settingsViewModel.updateSettings { - copy(enableConnect = enabled) - } - } - ) - } add { SwitchSettingCard( title = stringResource(Res.string.settings_allow_remote_control_title), diff --git a/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.kt new file mode 100644 index 00000000..2f73a824 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.ios.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/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt new file mode 100644 index 00000000..94376d20 --- /dev/null +++ b/composeApp/src/jvmMain/kotlin/org/spotube/spotube/core/discovery/AdvertiseAddress.jvm.kt @@ -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 . + */ + +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()