diff --git a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po index 6c701ff56..f1b35ddfc 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6299,3 +6299,59 @@ msgstr "Sponsoren" msgid "And all those anonymous sponsors..." msgstr "Und all die anonymen Sponsoren..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect kann lokale Tracks nicht auflösen. {} Track wurde übersprungen und nicht zur Warteschlange hinzugefügt. Schalte Qobuz Connect aus, um ihn abzuspielen." +msgstr[1] "Qobuz Connect kann lokale Tracks nicht auflösen. {} Tracks wurden übersprungen und nicht zur Warteschlange hinzugefügt. Schalte Qobuz Connect aus, um sie abzuspielen." + +msgid "Qobuz Connect device" +msgstr "Qobuz-Connect-Gerät" + +msgid "Continue playback on %1" +msgstr "Wiedergabe auf %1 fortsetzen" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Die Qobuz-Connect-Warteschlange verwenden und dieses Gerät aktiv lassen." + +msgid "Continue playback on this device" +msgstr "Wiedergabe auf diesem Gerät fortsetzen" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Die Qobuz-Connect-Warteschlange verwenden und die lokale Warteschlange ersetzen." + +msgid "Continue this device's current playback" +msgstr "Aktuelle Wiedergabe dieses Geräts fortsetzen" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Die Qobuz-Connect-Warteschlange und den Wiedergabestatus durch die aktuelle Warteschlange und Position dieses Geräts ersetzen." + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect abbrechen" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Beide Warteschlangen unverändert lassen und die aktuelle lokale Wiedergabe fortsetzen." + +msgid "Choose how to continue" +msgstr "Auswählen, wie fortgefahren werden soll" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Auf diesem Gerät und einem anderen Qobuz-Connect-Wiedergabegerät läuft eine Wiedergabe. Wähle aus, welche Warteschlange und welches Gerät fortfahren sollen." + +msgid "Ask every time" +msgstr "Jedes Mal nachfragen" + +msgid "Continue on the active renderer" +msgstr "Auf dem aktiven Wiedergabegerät fortsetzen" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Hier mit der Qobuz-Connect-Warteschlange fortsetzen" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Lokale Wiedergabe fortsetzen und Qobuz-Connect-Warteschlange ersetzen" + +msgid "When playback conflicts" +msgstr "Bei Wiedergabekonflikten" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Jedes Mal nachfragen oder automatisch auswählen, welche Warteschlange und welches Gerät fortfahren sollen." diff --git a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po index 94ef60183..31c32bc23 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -1889,3 +1889,59 @@ msgstr "{} of {}" msgid "Last playlist" msgstr "Last playlist" + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgstr[1] "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect device" + +msgid "Continue playback on %1" +msgstr "Continue playback on %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Use the Qobuz Connect queue and keep that device active." + +msgid "Continue playback on this device" +msgstr "Continue playback on this device" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Use the Qobuz Connect queue and replace the local queue." + +msgid "Continue this device's current playback" +msgstr "Continue this device's current playback" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Replace the Qobuz Connect queue and playback state with this device's current queue and position." + +msgid "Cancel Qobuz Connect" +msgstr "Cancel Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Keep both queues unchanged and continue the current local playback." + +msgid "Choose how to continue" +msgstr "Choose how to continue" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." + +msgid "Ask every time" +msgstr "Ask every time" + +msgid "Continue on the active renderer" +msgstr "Continue on the active renderer" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continue here with the Qobuz Connect queue" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continue local playback and replace the Qobuz Connect queue" + +msgid "When playback conflicts" +msgstr "When playback conflicts" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Ask each time or automatically choose which queue and device should continue." diff --git a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po index 60d217132..1e5ce0504 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6305,3 +6305,59 @@ msgstr "Patrocinadores" msgid "And all those anonymous sponsors..." msgstr "Y todos esos patrocinadores anónimos..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect no puede resolver tracks locales. Se ignoró {} track y no se incluyó en la lista. Si deseas reproducirlo, apaga Qobuz Connect." +msgstr[1] "Qobuz Connect no puede resolver tracks locales. Se ignoraron {} tracks y no se incluyeron en la lista. Si deseas reproducirlos, apaga Qobuz Connect." + +msgid "Qobuz Connect device" +msgstr "Dispositivo Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuar la reproducción en %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Usar la cola de Qobuz Connect y mantener activo ese dispositivo." + +msgid "Continue playback on this device" +msgstr "Continuar la reproducción en este dispositivo" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Usar la cola de Qobuz Connect y reemplazar la cola local." + +msgid "Continue this device's current playback" +msgstr "Continuar la reproducción actual de este dispositivo" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Reemplazar la cola y el estado de reproducción de Qobuz Connect con la cola y la posición actuales de este dispositivo." + +msgid "Cancel Qobuz Connect" +msgstr "Cancelar Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Conservar ambas colas sin cambios y continuar la reproducción local actual." + +msgid "Choose how to continue" +msgstr "Elegir cómo continuar" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Este dispositivo y otro reproductor de Qobuz Connect tienen una reproducción en curso. Elige qué cola y dispositivo deben continuar." + +msgid "Ask every time" +msgstr "Preguntar siempre" + +msgid "Continue on the active renderer" +msgstr "Continuar en el renderer activo" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuar aquí con la cola de Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuar la reproducción local y reemplazar la cola de Qobuz Connect" + +msgid "When playback conflicts" +msgstr "Cuando la reproducción entre en conflicto" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Preguntar cada vez o elegir automáticamente qué cola y dispositivo deben continuar." diff --git a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po index 906aeaced..ee87cd799 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6297,3 +6297,59 @@ msgstr "Sponsors" msgid "And all those anonymous sponsors..." msgstr "Et tous ces sponsors anonymes..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect ne peut pas résoudre les morceaux locaux. {} morceau a été ignoré et n’a pas été ajouté à la file d’attente. Désactivez Qobuz Connect pour le lire." +msgstr[1] "Qobuz Connect ne peut pas résoudre les morceaux locaux. {} morceaux ont été ignorés et n’ont pas été ajoutés à la file d’attente. Désactivez Qobuz Connect pour les lire." + +msgid "Qobuz Connect device" +msgstr "Appareil Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuer la lecture sur %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Utiliser la file Qobuz Connect et garder cet appareil actif." + +msgid "Continue playback on this device" +msgstr "Continuer la lecture sur cet appareil" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Utiliser la file Qobuz Connect et remplacer la file locale." + +msgid "Continue this device's current playback" +msgstr "Continuer la lecture actuelle de cet appareil" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Remplacer la file et l’état de lecture de Qobuz Connect par la file et la position actuelles de cet appareil." + +msgid "Cancel Qobuz Connect" +msgstr "Annuler Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Conserver les deux files inchangées et continuer la lecture locale actuelle." + +msgid "Choose how to continue" +msgstr "Choisir comment continuer" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Cet appareil et un autre lecteur Qobuz Connect sont tous deux en cours de lecture. Choisissez la file et l’appareil qui doivent continuer." + +msgid "Ask every time" +msgstr "Toujours demander" + +msgid "Continue on the active renderer" +msgstr "Continuer sur le lecteur actif" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuer ici avec la file d’attente Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuer la lecture locale et remplacer la file d’attente Qobuz Connect" + +msgid "When playback conflicts" +msgstr "En cas de conflit de lecture" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Demander à chaque fois ou choisir automatiquement la file d’attente et l’appareil qui doivent continuer." diff --git a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po index 4079d8134..8926fe1d0 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -6281,3 +6281,58 @@ msgstr "スポンサー" msgid "And all those anonymous sponsors..." msgstr "そして、すべての匿名スポンサーの皆さん…" + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect はローカルトラックを解決できません。{} 曲をスキップし、キューに追加しませんでした。再生するには Qobuz Connect をオフにしてください。" + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect デバイス" + +msgid "Continue playback on %1" +msgstr "%1 で再生を続ける" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Qobuz Connect のキューを使用し、そのデバイスをアクティブのままにします。" + +msgid "Continue playback on this device" +msgstr "このデバイスで再生を続ける" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Qobuz Connect のキューを使用し、ローカルキューを置き換えます。" + +msgid "Continue this device's current playback" +msgstr "このデバイスの現在の再生を続ける" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Qobuz Connect のキューと再生状態を、このデバイスの現在のキューと再生位置に置き換えます。" + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect をキャンセル" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "両方のキューを変更せず、現在のローカル再生を続けます。" + +msgid "Choose how to continue" +msgstr "続行方法を選択" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "このデバイスと別の Qobuz Connect レンダラーの両方で再生中です。続行するキューとデバイスを選択してください。" + +msgid "Ask every time" +msgstr "毎回確認する" + +msgid "Continue on the active renderer" +msgstr "アクティブなレンダラーで再生を続ける" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Qobuz Connect のキューを使用してこのデバイスで続ける" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "ローカル再生を続け、Qobuz Connect のキューを置き換える" + +msgid "When playback conflicts" +msgstr "再生が競合した場合" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "毎回確認するか、続行するキューとデバイスを自動的に選択します。" diff --git a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po index f0ba44118..1549d19a1 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6287,3 +6287,59 @@ msgstr "Sponsors" msgid "And all those anonymous sponsors..." msgstr "En al die anonieme sponsors..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect kan lokale tracks niet vinden. {} track is overgeslagen en niet aan de wachtrij toegevoegd. Schakel Qobuz Connect uit om deze af te spelen." +msgstr[1] "Qobuz Connect kan lokale tracks niet vinden. {} tracks zijn overgeslagen en niet aan de wachtrij toegevoegd. Schakel Qobuz Connect uit om ze af te spelen." + +msgid "Qobuz Connect device" +msgstr "Qobuz Connect-apparaat" + +msgid "Continue playback on %1" +msgstr "Afspelen voortzetten op %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "De Qobuz Connect-wachtrij gebruiken en dat apparaat actief houden." + +msgid "Continue playback on this device" +msgstr "Afspelen voortzetten op dit apparaat" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "De Qobuz Connect-wachtrij gebruiken en de lokale wachtrij vervangen." + +msgid "Continue this device's current playback" +msgstr "Huidige weergave op dit apparaat voortzetten" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "De Qobuz Connect-wachtrij en afspeelstatus vervangen door de huidige wachtrij en positie van dit apparaat." + +msgid "Cancel Qobuz Connect" +msgstr "Qobuz Connect annuleren" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Beide wachtrijen ongewijzigd laten en de huidige lokale weergave voortzetten." + +msgid "Choose how to continue" +msgstr "Kies hoe je wilt doorgaan" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Op dit apparaat en een andere Qobuz Connect-speler wordt afgespeeld. Kies welke wachtrij en welk apparaat moeten doorgaan." + +msgid "Ask every time" +msgstr "Altijd vragen" + +msgid "Continue on the active renderer" +msgstr "Doorgaan op de actieve speler" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Hier doorgaan met de Qobuz Connect-wachtrij" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Lokale weergave voortzetten en de Qobuz Connect-wachtrij vervangen" + +msgid "When playback conflicts" +msgstr "Bij een afspeelconflict" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Vraag het elke keer of kies automatisch welke wachtrij en welk apparaat doorgaan." diff --git a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po index 7437983b0..be44288d2 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6297,3 +6297,59 @@ msgstr "Patrocinadores" msgid "And all those anonymous sponsors..." msgstr "E todos esses patrocinadores anônimos..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "O Qobuz Connect não consegue resolver faixas locais. {} faixa foi ignorada e não foi adicionada à fila. Desative o Qobuz Connect para reproduzi-la." +msgstr[1] "O Qobuz Connect não consegue resolver faixas locais. {} faixas foram ignoradas e não foram adicionadas à fila. Desative o Qobuz Connect para reproduzi-las." + +msgid "Qobuz Connect device" +msgstr "Dispositivo Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Continuar a reprodução em %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Usar a fila do Qobuz Connect e manter esse dispositivo ativo." + +msgid "Continue playback on this device" +msgstr "Continuar a reprodução neste dispositivo" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Usar a fila do Qobuz Connect e substituir a fila local." + +msgid "Continue this device's current playback" +msgstr "Continuar a reprodução atual deste dispositivo" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Substituir a fila e o estado de reprodução do Qobuz Connect pela fila e posição atuais deste dispositivo." + +msgid "Cancel Qobuz Connect" +msgstr "Cancelar o Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Manter as duas filas inalteradas e continuar a reprodução local atual." + +msgid "Choose how to continue" +msgstr "Escolher como continuar" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "Este dispositivo e outro reprodutor do Qobuz Connect estão reproduzindo. Escolha qual fila e dispositivo devem continuar." + +msgid "Ask every time" +msgstr "Perguntar sempre" + +msgid "Continue on the active renderer" +msgstr "Continuar no reprodutor ativo" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Continuar aqui com a fila do Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Continuar a reprodução local e substituir a fila do Qobuz Connect" + +msgid "When playback conflicts" +msgstr "Quando houver conflito de reprodução" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Perguntar sempre ou escolher automaticamente qual fila e dispositivo devem continuar." diff --git a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po index 5780ad0ca..d72ee57a3 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6317,3 +6317,60 @@ msgstr "Спонсоры" msgid "And all those anonymous sponsors..." msgstr "И все те анонимные спонсоры..." + +msgid "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it." +msgid_plural "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them." +msgstr[0] "Qobuz Connect не может распознать локальные треки. Пропущен {} локальный трек; он не добавлен в очередь. Отключите Qobuz Connect, чтобы воспроизвести его." +msgstr[1] "Qobuz Connect не может распознать локальные треки. Пропущено {} локальных трека; они не добавлены в очередь. Отключите Qobuz Connect, чтобы воспроизвести их." +msgstr[2] "Qobuz Connect не может распознать локальные треки. Пропущено {} локальных треков; они не добавлены в очередь. Отключите Qobuz Connect, чтобы воспроизвести их." + +msgid "Qobuz Connect device" +msgstr "Устройство Qobuz Connect" + +msgid "Continue playback on %1" +msgstr "Продолжить воспроизведение на %1" + +msgid "Use the Qobuz Connect queue and keep that device active." +msgstr "Использовать очередь Qobuz Connect и оставить это устройство активным." + +msgid "Continue playback on this device" +msgstr "Продолжить воспроизведение на этом устройстве" + +msgid "Use the Qobuz Connect queue and replace the local queue." +msgstr "Использовать очередь Qobuz Connect и заменить локальную очередь." + +msgid "Continue this device's current playback" +msgstr "Продолжить текущее воспроизведение на этом устройстве" + +msgid "Replace the Qobuz Connect queue and playback state with this device's current queue and position." +msgstr "Заменить очередь и состояние воспроизведения Qobuz Connect текущей очередью и позицией этого устройства." + +msgid "Cancel Qobuz Connect" +msgstr "Отменить подключение Qobuz Connect" + +msgid "Keep both queues unchanged and continue the current local playback." +msgstr "Оставить обе очереди без изменений и продолжить текущее локальное воспроизведение." + +msgid "Choose how to continue" +msgstr "Выберите, как продолжить" + +msgid "This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue." +msgstr "На этом устройстве и другом проигрывателе Qobuz Connect уже идёт воспроизведение. Выберите очередь и устройство, которые должны продолжить." + +msgid "Ask every time" +msgstr "Спрашивать каждый раз" + +msgid "Continue on the active renderer" +msgstr "Продолжить на активном устройстве" + +msgid "Continue here with the Qobuz Connect queue" +msgstr "Продолжить здесь с очередью Qobuz Connect" + +msgid "Continue local playback and replace the Qobuz Connect queue" +msgstr "Продолжить локальное воспроизведение и заменить очередь Qobuz Connect" + +msgid "When playback conflicts" +msgstr "При конфликте воспроизведения" + +msgid "Ask each time or automatically choose which queue and device should continue." +msgstr "Спрашивать каждый раз или автоматически выбирать, какая очередь и устройство продолжат воспроизведение." diff --git a/crates/qbz-qt/build.rs b/crates/qbz-qt/build.rs index 1938f7c4c..c4ba662f9 100644 --- a/crates/qbz-qt/build.rs +++ b/crates/qbz-qt/build.rs @@ -669,6 +669,7 @@ fn main() { // device flyout both bars mount + the diagnostics modal // AppShell mounts last. "qml/shell/QconnectFlyout.qml", + "qml/shell/QconnectPlaybackConflictModal.qml", "qml/shell/LogViewerModal.qml", "qml/shell/ReportIssueModal.qml", "qml/shell/QconnectDevModal.qml", diff --git a/crates/qbz-qt/qml/controls/QbzSelect.qml b/crates/qbz-qt/qml/controls/QbzSelect.qml index fba0e1f0c..a1df927fc 100644 --- a/crates/qbz-qt/qml/controls/QbzSelect.qml +++ b/crates/qbz-qt/qml/controls/QbzSelect.qml @@ -18,6 +18,10 @@ Rectangle { property int currentIndex: 0 property int menuWidth: 240 property int popupWidth: 0 + // The default keeps the standard dropdown below the field. Flyouts near a + // window edge may opt into a lateral list without changing every other + // QbzSelect instance. Supported values: "below" and "left". + property string popupPlacement: "below" property bool enabled: true property bool searchable: false // Bootstrap-style small variant (QbzSelect.slint:74-76). The default is the @@ -131,7 +135,7 @@ Rectangle { } } QbzIcon { - name: "chevron-down" + name: selectRoot.popupPlacement === "left" ? "chevron-left" : "chevron-down" width: selectRoot.sm ? 14 : 16 height: selectRoot.sm ? 14 : 16 anchors.verticalCenter: parent.verticalCenter @@ -153,9 +157,15 @@ Rectangle { Popup { id: popup parent: selectRoot - // Right-anchored: a list wider than the control grows leftward. - x: selectRoot.width - selectRoot.listWidth - y: selectRoot.height + 4 + // Standard selects grow down and left-align their right edge. A + // right-edge flyout may place the list beside the field instead; its + // bottom edge stays aligned so it cannot fall into the player bar. + x: selectRoot.popupPlacement === "left" + ? -selectRoot.listWidth - 4 + : selectRoot.width - selectRoot.listWidth + y: selectRoot.popupPlacement === "left" + ? selectRoot.height - popup.height + : selectRoot.height + 4 width: selectRoot.listWidth height: selectRoot.searchHeight + Math.min(listContent.contentHeight, selectRoot.maxListHeight) + 10 padding: 0 diff --git a/crates/qbz-qt/qml/settings/PlaybackSettings.qml b/crates/qbz-qt/qml/settings/PlaybackSettings.qml index 735efa2bc..67c59206c 100644 --- a/crates/qbz-qt/qml/settings/PlaybackSettings.qml +++ b/crates/qbz-qt/qml/settings/PlaybackSettings.qml @@ -80,6 +80,17 @@ Column { onSelected: function (i) { QbzBridge.settingsSelect("qconnect-startup", i) } } } + SettingRow { + label: QbzSession.tr("When playback conflicts", QbzSession.trRev) + description: QbzSession.tr("Ask each time or automatically choose which queue and device should continue.", QbzSession.trRev) + QbzSelect { + menuWidth: 300 + popupWidth: 420 + options: root.doc.qconnectConflictPolicies || [] + currentIndex: root.doc.qconnectConflictPolicyIndex || 0 + onSelected: function (i) { QbzBridge.settingsSelect("qconnect-conflict-policy", i) } + } + } SettingRow { label: QbzSession.tr("Qobuz Connect device name", QbzSession.trRev) description: QbzSession.tr("The name other Qobuz Connect apps see for this device. Applies on the next connection.", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index 16c050c3a..471b59c2a 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -934,6 +934,10 @@ Rectangle { } } + // Qobuz Connect bootstrap conflict: mounted globally because it fences the + // session loop until the user chooses which queue/renderer wins. + QconnectPlaybackConflictModal { } + // Qobuz Connect diagnostics modal (DeveloperSettings > QOBUZ CONNECT) — // mounted LAST, mirroring QconnectDevModal.slint's topmost mount in // AppShell.slint. Ordering against the tooltip above is a non-issue: this diff --git a/crates/qbz-qt/qml/shell/KioskShell.qml b/crates/qbz-qt/qml/shell/KioskShell.qml index b18e0a1c4..f3f778533 100644 --- a/crates/qbz-qt/qml/shell/KioskShell.qml +++ b/crates/qbz-qt/qml/shell/KioskShell.qml @@ -613,6 +613,10 @@ Rectangle { onTriggered: root.forceActiveFocus() } + // QConnect bootstrap conflicts are global and can also originate from the + // compact player bar used by kiosk mode. + QconnectPlaybackConflictModal { } + // ===================================================================== // 4. The kiosk's OWN Immersive mount (KioskShell.slint:737-747) // ===================================================================== diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index ed2d2add3..99e9248fe 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -502,7 +502,7 @@ Rectangle { anchors.fill: parent hoverEnabled: true cursorShape: Qt.PointingHandCursor - onClicked: smallQcFlyout.openBelowRight(smallQconnectBtn) + onClicked: smallQcFlyout.openAboveRight(smallQconnectBtn) } } // Cast (Chromecast / DLNA) — opens the picker modal and, @@ -628,8 +628,8 @@ Rectangle { AudioSettingsMenu { id: smallAudioMenu; doc: root.settingsDoc } // Qobuz Connect device flyout — the ONE shared component both bars mount - // (contract §8; the Slint `qconnect-menu` PopupWindow). Opened below- - // right of the Connect button. + // (contract §8; the Slint `qconnect-menu` PopupWindow). Opened above- + // right of the Connect button so it never covers its own trigger. QconnectFlyout { id: smallQcFlyout } // "Add to…" flyout behind the transport "+" (TransportControls.slint's diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 7353a8dc3..03da3fec8 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -556,7 +556,7 @@ Rectangle { onClicked: { if (root.tooltip) root.tooltip.hide("qconnect") - qcFlyout.openBelowRight(qconnectBtn) + qcFlyout.openAboveRight(qconnectBtn) } // The ONE bar tooltip the reference binds (always // shows on hover, even while connected — Slint @@ -703,7 +703,7 @@ Rectangle { // Qobuz Connect device flyout — the ONE shared component both bars mount // (contract §8; the Slint `qconnect-menu` PopupWindow, PlayerBar.slint: - // 412-642). Opened below-right of the Connect button. + // 412-642). Opened above-right of the Connect button. QconnectFlyout { id: qcFlyout } // "Add to…" flyout behind the transport "+" (TransportControls.slint's diff --git a/crates/qbz-qt/qml/shell/QconnectFlyout.qml b/crates/qbz-qt/qml/shell/QconnectFlyout.qml index 170c47cff..bb09335ee 100644 --- a/crates/qbz-qt/qml/shell/QconnectFlyout.qml +++ b/crates/qbz-qt/qml/shell/QconnectFlyout.qml @@ -7,10 +7,11 @@ // // STRUCTURE + BEHAVIOR are 1:1 with the Slint; geometry follows this port's // own popup conventions (controls/QbzContextMenu.qml): a Popup parented to -// the Overlay, opened below-right of the trigger and clamped into the window -// with an 8px margin — the same placement ViewModeMenu / AudioSettingsMenu -// use from the same bars. Slint's fixed 280x244 panel, 14px padding and 12px -// block spacing are kept. +// the Overlay, opened above the bottom-bar trigger with right edges aligned +// and clamped into the window with an 8px margin. The original 280x244 panel +// grows vertically only +// for the persisted playback-conflict policy added to this live Qt surface; +// its 14px padding and 12px block spacing are kept. // // STATE: all session data is push-driven off the QbzQConnect singleton // (src/qconnect_bridge.rs) — `devicesJson` is parsed here exactly like @@ -36,7 +37,7 @@ Popup { parent: Overlay.overlay width: 280 - height: 244 + height: 310 padding: 14 closePolicy: Popup.CloseOnPressOutside | Popup.CloseOnEscape @@ -58,6 +59,13 @@ Popup { } } + readonly property var conflictPolicyOptions: [ + QbzSession.tr("Ask every time", QbzSession.trRev), + QbzSession.tr("Continue on the active renderer", QbzSession.trRev), + QbzSession.tr("Continue here with the Qobuz Connect queue", QbzSession.trRev), + QbzSession.tr("Continue local playback and replace the Qobuz Connect queue", QbzSession.trRev) + ] + // Discovery timer — bounds the spinner so the "no devices" empty state // shows if none appear within ~8s (Slint :486-493). Started on a Connect // click; stopped on disconnect. Single-shot, so triggering self-stops. @@ -67,9 +75,9 @@ Popup { onTriggered: root.qcConnecting = false } - // Placement — byte-for-byte the QbzContextMenu convention: below the - // trigger, right edges aligned, clamped 8px into the window. From the - // bottom bar the y clamp is what lifts the panel into view. + // Bottom-bar placement: explicitly above the trigger, right edges + // aligned, then clamped 8px into the window. Computing the upward anchor + // before clamping prevents the panel from covering its own button. function _place(gx, gy, win) { if (win) { x = Math.max(8, Math.min(gx, win.width - width - 8)) @@ -81,8 +89,8 @@ Popup { open() } - function openBelowRight(sourceItem) { - var g = sourceItem.mapToItem(null, sourceItem.width - width, sourceItem.height + 4) + function openAboveRight(sourceItem) { + var g = sourceItem.mapToItem(null, sourceItem.width - width, -height - 4) _place(g.x, g.y, sourceItem.Window.window) } @@ -211,13 +219,44 @@ Popup { } } + Column { + id: conflictPolicy + anchors.left: parent.left + anchors.right: parent.right + anchors.bottom: connectBtn.top + anchors.bottomMargin: 12 + spacing: 5 + + Text { + width: parent.width + text: QbzSession.tr("When playback conflicts", QbzSession.trRev) + color: theme.textSecondary + font.pixelSize: 11 + font.weight: theme.weightMedium + elide: Text.ElideRight + } + + QbzSelect { + width: parent.width + menuWidth: parent.width + popupWidth: 420 + sm: true + popupPlacement: "left" + options: root.conflictPolicyOptions + currentIndex: QbzQConnect.playbackConflictPolicyIndex + onSelected: function (i) { + QbzBridge.settingsSelect("qconnect-conflict-policy", i) + } + } + } + // Device list / empty state — the Slint's vertical-stretch block // (:495-602): surface-elevated, Radius.sm, clipped. Rectangle { id: listBox anchors.top: topCol.bottom anchors.topMargin: 12 - anchors.bottom: connectBtn.top + anchors.bottom: conflictPolicy.top anchors.bottomMargin: 12 anchors.left: parent.left anchors.right: parent.right diff --git a/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml new file mode 100644 index 000000000..dbe44886d --- /dev/null +++ b/crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml @@ -0,0 +1,190 @@ +import QtQuick +import QtQuick.Controls +import com.blitzfc.qbz +import "../theme" + +Popup { + id: root + + parent: Overlay.overlay + x: 0 + y: 0 + width: parent ? parent.width : 0 + height: parent ? parent.height : 0 + padding: 0 + z: 3200 + modal: true + dim: false + closePolicy: Popup.NoAutoClose + + QbzTheme { id: theme } + + function t(text) { return QbzSession.tr(text, QbzSession.trRev) } + function choose(choice) { QbzQConnect.resolvePlaybackConflict(choice) } + + readonly property string rendererName: QbzQConnect.playbackConflictRendererName !== "" + ? QbzQConnect.playbackConflictRendererName + : root.t("Qobuz Connect device") + readonly property var choices: [ + { + "number": 1, + "title": root.t("Continue playback on %1").arg(root.rendererName), + "description": root.t("Use the Qobuz Connect queue and keep that device active.") + }, + { + "number": 2, + "title": root.t("Continue playback on this device"), + "description": root.t("Use the Qobuz Connect queue and replace the local queue.") + }, + { + "number": 3, + "title": root.t("Continue this device's current playback"), + "description": root.t("Replace the Qobuz Connect queue and playback state with this device's current queue and position.") + }, + { + "number": 4, + "title": root.t("Cancel Qobuz Connect"), + "description": root.t("Keep both queues unchanged and continue the current local playback.") + } + ] + + Connections { + target: QbzQConnect + function onPlaybackConflictOpenChanged() { + if (QbzQConnect.playbackConflictOpen) + root.open() + else + root.close() + } + } + + onOpened: keyScope.forceActiveFocus() + + background: Rectangle { color: "#bf000000" } + + contentItem: FocusScope { + id: keyScope + + Keys.onEscapePressed: function (event) { + root.choose(4) + event.accepted = true + } + + MouseArea { + anchors.fill: parent + onClicked: root.choose(4) + onWheel: function (wheel) { wheel.accepted = true } + } + + Rectangle { + id: card + width: Math.min(root.width - 48, 720) + height: Math.min(root.height - 48, 570) + x: Math.round((parent.width - width) / 2) + y: Math.round((parent.height - height) / 2) + radius: theme.radiusMd + color: theme.surfaceCard + border.width: 1 + border.color: theme.borderSubtle + clip: true + + MouseArea { + anchors.fill: parent + onWheel: function (wheel) { wheel.accepted = true } + } + + Column { + anchors.fill: parent + anchors.margins: 22 + spacing: 12 + + Text { + width: parent.width + text: root.t("Choose how to continue") + color: theme.textPrimary + font.pixelSize: theme.fontHeading + font.weight: theme.weightSemibold + wrapMode: Text.Wrap + } + + Text { + width: parent.width + text: root.t("This device and another Qobuz Connect renderer both have playback in progress. Choose which queue and device should continue.") + color: theme.textSecondary + font.pixelSize: theme.fontBody + wrapMode: Text.Wrap + } + + Item { width: 1; height: 2 } + + Repeater { + model: root.choices + + delegate: Rectangle { + id: option + required property var modelData + width: parent.width + height: 92 + radius: theme.radiusSm + color: optionArea.containsMouse ? theme.surfaceHover : theme.surfaceElevated + border.width: 1 + border.color: optionArea.containsMouse ? theme.accent : theme.borderSubtle + + Rectangle { + id: numberBadge + anchors.left: parent.left + anchors.leftMargin: 14 + anchors.verticalCenter: parent.verticalCenter + width: 32 + height: 32 + radius: 16 + color: theme.accent + + Text { + anchors.centerIn: parent + text: option.modelData.number + color: theme.accentText + font.pixelSize: theme.fontBody + font.weight: theme.weightBold + } + } + + Column { + anchors.left: numberBadge.right + anchors.leftMargin: 14 + anchors.right: parent.right + anchors.rightMargin: 14 + anchors.verticalCenter: parent.verticalCenter + spacing: 5 + + Text { + width: parent.width + text: option.modelData.title + color: theme.textPrimary + font.pixelSize: theme.fontBody + font.weight: theme.weightSemibold + elide: Text.ElideRight + } + + Text { + width: parent.width + text: option.modelData.description + color: theme.textSecondary + font.pixelSize: theme.fontLegal + wrapMode: Text.Wrap + } + } + + MouseArea { + id: optionArea + anchors.fill: parent + hoverEnabled: true + cursorShape: Qt.PointingHandCursor + onClicked: root.choose(option.modelData.number) + } + } + } + } + } + } +} diff --git a/crates/qbz-qt/src/album_qt.rs b/crates/qbz-qt/src/album_qt.rs index bd0de1f70..4f2b74646 100644 --- a/crates/qbz-qt/src/album_qt.rs +++ b/crates/qbz-qt/src/album_qt.rs @@ -773,6 +773,9 @@ pub fn bulk_action(album_id: String, ids_json: String, action: String) { picked, crate::playback_qt::PlayContext::album(&album_id), ); + if picked.is_empty() { + return; + } if let Err(e) = crate::playback_qt::enqueue_track_list_mode(&runtime, picked, mode).await { diff --git a/crates/qbz-qt/src/bulk_tracks_qt.rs b/crates/qbz-qt/src/bulk_tracks_qt.rs index 6c085a0ab..849f2f311 100644 --- a/crates/qbz-qt/src/bulk_tracks_qt.rs +++ b/crates/qbz-qt/src/bulk_tracks_qt.rs @@ -123,6 +123,9 @@ pub fn run(ids_json: String, action: String, context_kind: String, context_id: S queue, crate::playback_qt::PlayContext::new(&context_kind, &context_id), ); + if queue.is_empty() { + return; + } let runtime = crate::app(); if let Err(e) = crate::playback_qt::enqueue_track_list_mode(&runtime, queue, mode).await diff --git a/crates/qbz-qt/src/cast_qt.rs b/crates/qbz-qt/src/cast_qt.rs index cc183b053..ffea64c4f 100644 --- a/crates/qbz-qt/src/cast_qt.rs +++ b/crates/qbz-qt/src/cast_qt.rs @@ -2005,27 +2005,22 @@ impl CastService { return Ok(None); } - let source = if track.is_local { - "local" - } else { - track.source.as_deref().unwrap_or("qobuz") - }; - // Resolve + register per source. The fetch happens OUTSIDE the lock. // Qobuz has its own tiers (cache / progressive download); every // other source goes through the registry's playback ticket: a file on // disk is served from disk, a server-streamed item (Plex / Jellyfin / // Subsonic) is PROXIED to the renderer with the source's own request // contract — the media-server arm the Slint service left as a TODO. - let info_result = match source { - "qobuz" | "qobuz_download" => self.register_qobuz(media_stamp).await, - _ => match resolve_castable(track).await { + let info_result = if uses_qobuz_cast_pipeline(track) { + self.register_qobuz(media_stamp).await + } else { + match resolve_castable(track).await { Ok(Castable::File(path)) => self.register_local(media_stamp, &path).await, Ok(Castable::Stream { url, headers }) => { self.register_proxy(media_stamp, url, headers).await } Err(error) => Err(error), - }, + } }; let mut info = match info_result { Ok(info) => info, @@ -3845,6 +3840,20 @@ enum Castable { }, } +/// Whether this row names a Qobuz catalog track that Cast must fetch through +/// the core's cache/progressive-stream path. +/// +/// New QConnect materializations are stamped `qobuz`. The legacy transport tag +/// remains accepted so queues persisted by an older build — or materialized +/// before a manual disconnect in the same process — can still hand off to Cast. +fn uses_qobuz_cast_pipeline(track: &QueueTrack) -> bool { + !track.is_local + && matches!( + track.source.as_deref(), + None | Some("qobuz") | Some("qobuz_download") | Some("qobuz_connect_remote") + ) +} + async fn resolve_castable(track: &QueueTrack) -> Result { let ticket = qbz_source::registry() .playback(track) @@ -4661,4 +4670,25 @@ mod tests { assert!(body.contains("qc.disconnect_for_cast(transition_epoch).await")); assert!(!body.contains("qconnect_restore_token = None")); } + + #[test] + fn qconnect_queue_rows_remain_castable_after_disconnect() { + for source in ["qobuz", "qobuz_connect_remote"] { + let track: QueueTrack = serde_json::from_value(serde_json::json!({ + "id": 128659874_u64, + "title": "Burning Ambition", + "artist": "Iron Maiden", + "album": "Best Of The B-Sides", + "duration_secs": 162_u64, + "artwork_url": null, + "bit_depth": 16_u32, + "sample_rate": 44100.0, + "album_id": null, + "artist_id": null, + "source": source + })) + .expect("queue track"); + assert!(uses_qobuz_cast_pipeline(&track), "{source}"); + } + } } diff --git a/crates/qbz-qt/src/local_album_actions.rs b/crates/qbz-qt/src/local_album_actions.rs index 9ff8d69db..2a2140764 100644 --- a/crates/qbz-qt/src/local_album_actions.rs +++ b/crates/qbz-qt/src/local_album_actions.rs @@ -964,9 +964,9 @@ fn publish_doc(doc: Option) { /// "Play next" / "Play later" / "Add to queue" over a track SUBSET — the same /// core helpers `local_playback::enqueue` uses, just without the id round-trip. /// -/// QConnect EXEMPT (contract §6.3): no routed arm / sync-on-add tail — a -/// local-only batch is never Qobuz-castable, so the predicate is always false -/// (Slint local_album_actions.rs:486-494 is likewise unhooked). +/// The shared enqueue seam runs before any core mutation. While QConnect is +/// enabled it drops this local-only batch, raises the single counted notice, +/// and leaves the existing queue untouched. async fn enqueue_rows(runtime: &Runtime, tracks: Vec, mode: &str) { let tracks = tokio::task::spawn_blocking(move || { let mut tracks = tracks; @@ -976,6 +976,7 @@ async fn enqueue_rows(runtime: &Runtime, tracks: Vec, mode: &str) { .await .unwrap_or_default(); let queue: Vec = tracks.iter().map(local_queue_track).collect(); + let queue = crate::playback_qt::stamped(queue, None); if queue.is_empty() { return; } diff --git a/crates/qbz-qt/src/local_bulk.rs b/crates/qbz-qt/src/local_bulk.rs index 6dc13e133..b76f09c32 100644 --- a/crates/qbz-qt/src/local_bulk.rs +++ b/crates/qbz-qt/src/local_bulk.rs @@ -296,9 +296,9 @@ pub(crate) async fn apply(rows: Vec, action: &str) -> bool { /// a multi-row insert keeps its order, "play-later" appends to the manual /// block's tail, anything else appends. /// -/// QConnect EXEMPT (contract §6.3): no routed arm / sync-on-add tail — a -/// local-only batch is never Qobuz-castable, so the predicate is always false -/// (Slint local_bulk.rs:261-269 is likewise unhooked). +/// The shared enqueue seam runs before any core mutation. While QConnect is +/// enabled it drops this local-only batch, raises the single counted notice, +/// and leaves the existing queue untouched. async fn enqueue_rows(rows: Vec, mode: &str) { let runtime = crate::app(); // Same folder-cover backfill the single-row path runs @@ -313,6 +313,10 @@ async fn enqueue_rows(rows: Vec, mode: &str) { .await .unwrap_or_default(); let queue: Vec = rows.iter().map(local_queue_track).collect(); + let queue = crate::playback_qt::stamped(queue, None); + if queue.is_empty() { + return; + } log::info!("[qbz-qt] local bulk {mode}: {} track(s)", queue.len()); let Some(_owner_action) = crate::playback_qt::begin_owner_action() else { return; diff --git a/crates/qbz-qt/src/local_playback.rs b/crates/qbz-qt/src/local_playback.rs index 2a496a06c..9d698d3e8 100644 --- a/crates/qbz-qt/src/local_playback.rs +++ b/crates/qbz-qt/src/local_playback.rs @@ -816,6 +816,9 @@ pub(crate) async fn enqueue_rows(runtime: &Runtime, tracks: Vec, mod // Same stamping seam the Qobuz enqueue paths use, so an appended local // block carries its own origin instead of inheriting whatever is playing. let queue = crate::playback_qt::stamped(tracks.iter().map(local_queue_track).collect(), None); + if queue.is_empty() { + return; + } let Some(_owner_action) = crate::playback_qt::begin_owner_action() else { return; }; @@ -823,11 +826,9 @@ pub(crate) async fn enqueue_rows(runtime: &Runtime, tracks: Vec, mod // (reversed so a multi-track insert keeps its order), "later" appends to // the block tail, anything else appends. // - // QConnect EXEMPT (contract §6.3): no routed arm and no sync-on-add tail - // here — this is a LOCAL-only path, so `batch_all_qconnect_castable` is - // always false and the push would never fire; hooking the arm would only - // risk the refusal toast on every click (Slint local_playback.rs:437-450 - // is likewise unhooked). + // This remains a local-only execution path. While QConnect is enabled the + // shared seam above returns an empty batch after raising the counted + // notice, so no local row can enter the connected queue here. match mode.as_str() { "next" => { for t in queue.into_iter().rev() { diff --git a/crates/qbz-qt/src/local_playlist_qt.rs b/crates/qbz-qt/src/local_playlist_qt.rs index dfcae72ee..42741a3aa 100644 --- a/crates/qbz-qt/src/local_playlist_qt.rs +++ b/crates/qbz-qt/src/local_playlist_qt.rs @@ -1437,6 +1437,9 @@ pub async fn enqueue_all(runtime: &Runtime, mode: &str) -> Result<(), String> { queue, crate::playback_qt::PlayContext::playlist(&playlist_id), ); + if queue.is_empty() { + return Ok(()); + } crate::playback_qt::enqueue_track_list_mode(runtime, queue, mode).await } diff --git a/crates/qbz-qt/src/myqbz_play_qt.rs b/crates/qbz-qt/src/myqbz_play_qt.rs index f5c0f7319..3fb6f42c0 100644 --- a/crates/qbz-qt/src/myqbz_play_qt.rs +++ b/crates/qbz-qt/src/myqbz_play_qt.rs @@ -779,41 +779,38 @@ pub(crate) fn play_inline_track(item_source_item_id: String, track_id: String, a crate::queue_qt::publish(&runtime).await; } InlineTrackMode::PlayNext => { - let mut stamped = crate::playback_qt::stamped(vec![track], None); - if let Some(track) = stamped.pop() { - // QConnect CONTROLLER mode (§7): route the insert to the - // peer's queue — early-returns when handled (stamped - // first: a blacklisted track must not reach the peer when - // the local path would drop it). - if crate::playback_qt::route_track_to_peer(&track, "next").await { - return; - } - let added_castable = crate::playback_qt::batch_all_qconnect_castable( - std::slice::from_ref(&track), - ); - runtime.core().add_track_next(track).await; - crate::playback_qt::sync_qconnect_after_add(added_castable).await; + let Some(track) = crate::playback_qt::stamped(vec![track], None).pop() else { + return; + }; + // QConnect CONTROLLER mode (§7): route the insert to the + // peer's queue — early-returns when handled (stamped first: a + // blocked track must not reach the peer when the local path + // would drop it). + if crate::playback_qt::route_track_to_peer(&track, "next").await { + return; } + let added_castable = + crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track)); + runtime.core().add_track_next(track).await; + crate::playback_qt::sync_qconnect_after_add(added_castable).await; crate::queue_qt::publish(&runtime).await; crate::toast_qt::success(qbz_i18n::t("Playing next")); } InlineTrackMode::PlayLater => { - let mut stamped = crate::playback_qt::stamped(vec![track], None); - if let Some(track) = stamped.pop() { - // QConnect CONTROLLER mode (§7): route the add to the - // peer's queue. Mode "queue", NOT "later": the local op - // here appends via `add_tracks` (the documented inline/row - // divergence above), so the route matches what the LOCAL - // path does. - if crate::playback_qt::route_track_to_peer(&track, "queue").await { - return; - } - let added_castable = crate::playback_qt::batch_all_qconnect_castable( - std::slice::from_ref(&track), - ); - runtime.core().add_tracks(vec![track]).await; - crate::playback_qt::sync_qconnect_after_add(added_castable).await; + let Some(track) = crate::playback_qt::stamped(vec![track], None).pop() else { + return; + }; + // QConnect CONTROLLER mode (§7): route the add to the peer's + // queue. Mode "queue", NOT "later": the local op here appends + // via `add_tracks` (the documented inline/row divergence + // above), so the route matches what the LOCAL path does. + if crate::playback_qt::route_track_to_peer(&track, "queue").await { + return; } + let added_castable = + crate::playback_qt::batch_all_qconnect_castable(std::slice::from_ref(&track)); + runtime.core().add_tracks(vec![track]).await; + crate::playback_qt::sync_qconnect_after_add(added_castable).await; crate::queue_qt::publish(&runtime).await; crate::toast_qt::success(qbz_i18n::t("Added to queue")); } diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 69ce69b15..64ef4bf12 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -779,7 +779,7 @@ pub(crate) fn queue_track_unavailable(track: &QueueTrack) -> bool { dead && !crate::offline_qt::is_cached_id(track.id) } -/// Why rows were dropped, counted per reason. The two are NOT interchangeable +/// Why rows were dropped, counted per reason. The reasons are NOT interchangeable /// and must not collapse into one number: a blacklist drop is what the user /// asked for and stays SILENT, while an unavailability drop is news they never /// asked for and owes them a toast (D4 — "el usuario siempre debe saber"). One @@ -790,6 +790,9 @@ pub(crate) struct QueueDrops { pub blacklisted: usize, /// Dropped because Qobuz pulled the track AND we hold no offline copy. pub unavailable: usize, + /// Dropped while QConnect is enabled because its source is not resolvable + /// by Qobuz. These rows never enter the local queue in that mode. + pub qconnect_unresolvable: usize, } /// The ONE toast a queue build owes when it silently shortened itself @@ -829,17 +832,19 @@ fn toast_unavailable_dropped(count: usize) { /// itself dropped, the walk lands on the next survivor, which is what the user /// asking for "play from here" means once "here" is gone. /// -/// Renamed from `filter_blacklisted_queue`: it now drops for two reasons and +/// Renamed from `filter_blacklisted_queue`: it now drops for three reasons and /// the old name named only one of them. fn filter_unplayable_queue( tracks: Vec, start: Option, ) -> (Vec, Option, QueueDrops) { + let qconnect_enabled = crate::qconnect_qt::queue_admission_enabled(); let (kept, start, dropped) = filter_queue_with( tracks, start, queue_track_blacklisted, queue_track_unavailable, + |track| qconnect_enabled && !crate::qconnect_qt::is_qconnect_queue_track(track), ); if dropped.blacklisted > 0 { log::info!( @@ -853,26 +858,33 @@ fn filter_unplayable_queue( dropped.unavailable ); } + if dropped.qconnect_unresolvable > 0 { + log::info!( + "[qbz-qt] qconnect admission: dropped {} unresolvable track(s) from the queue", + dropped.qconnect_unresolvable + ); + } (kept, start, dropped) } /// The pure half, split out so the index remap is unit-testable without a bound -/// session (the blacklist store needs one). The two predicates decide; +/// session (the blacklist store needs one). The three predicates decide; /// everything else is arithmetic, and that arithmetic is unchanged. /// -/// TWO predicates, not one OR'd closure, because the caller has to know WHICH +/// Separate predicates, not one OR'd closure, because the caller has to know WHICH /// one claimed each row (see [`QueueDrops`]). `blacklisted` is tested FIRST and /// wins ties: a pulled track by an artist the user blocked is a row they /// already chose not to see, and toasting "1 track skipped" for it would /// announce exactly the content the blacklist exists to hide. /// /// The log line that used to live here moved to `filter_unplayable_queue`: it -/// said "blacklist", which a second reason turns into a lie. +/// said "blacklist", which any additional reason turns into a lie. fn filter_queue_with( tracks: Vec, start: Option, blacklisted: impl Fn(&T) -> bool, unavailable: impl Fn(&T) -> bool, + qconnect_unresolvable: impl Fn(&T) -> bool, ) -> (Vec, Option, QueueDrops) { let clicked = start.unwrap_or(0); let mut kept = Vec::with_capacity(tracks.len()); @@ -888,6 +900,10 @@ fn filter_queue_with( dropped.unavailable += 1; continue; } + if qconnect_unresolvable(&track) { + dropped.qconnect_unresolvable += 1; + continue; + } if new_start.is_none() && i >= clicked { new_start = Some(kept.len()); } @@ -938,6 +954,7 @@ pub(crate) struct PreparedQueue { start: Option, anchor: Option, unavailable_dropped: usize, + qconnect_unresolvable_dropped: usize, } impl PreparedQueue { @@ -959,12 +976,14 @@ pub(crate) fn prepare_queue_stamped( let (mut tracks, start, dropped) = filter_unplayable_queue(tracks, start); if tracks.is_empty() && asked > 0 { log::info!( - "[qbz-qt] set_queue: all {asked} track(s) filtered ({} blacklisted, {} unavailable) \ + "[qbz-qt] set_queue: all {asked} track(s) filtered ({} blacklisted, {} unavailable, {} qconnect-unresolvable) \ — keeping the previous queue", dropped.blacklisted, - dropped.unavailable + dropped.unavailable, + dropped.qconnect_unresolvable ); toast_unavailable_dropped(dropped.unavailable); + crate::qconnect_qt::toast_unresolvable_tracks(dropped.qconnect_unresolvable); return None; } stamp_context(&mut tracks, context); @@ -980,6 +999,7 @@ pub(crate) fn prepare_queue_stamped( start, anchor, unavailable_dropped: dropped.unavailable, + qconnect_unresolvable_dropped: dropped.qconnect_unresolvable, }) } @@ -1006,12 +1026,14 @@ async fn commit_prepared_queue_with_offline_only( start, anchor, unavailable_dropped, + qconnect_unresolvable_dropped, } = prepared; runtime .core() .set_queue_with_offline_only(tracks, start, offline_only) .await; toast_unavailable_dropped(unavailable_dropped); + crate::qconnect_qt::toast_unresolvable_tracks(qconnect_unresolvable_dropped); if let Some(svc) = crate::qconnect_qt::service() { svc.sync_local_queue_if_changed().await; } @@ -1067,6 +1089,7 @@ pub(crate) fn stamped(tracks: Vec, context: Option) -> // in the list must not be reachable by dragging it into the queue either. // No start index to remap here — these APPEND. let mut dropped = QueueDrops::default(); + let qconnect_enabled = crate::qconnect_qt::queue_admission_enabled(); let mut tracks: Vec = tracks .into_iter() .filter(|t| { @@ -1078,6 +1101,10 @@ pub(crate) fn stamped(tracks: Vec, context: Option) -> dropped.unavailable += 1; return false; } + if qconnect_enabled && !crate::qconnect_qt::is_qconnect_queue_track(t) { + dropped.qconnect_unresolvable += 1; + return false; + } true }) .collect(); @@ -1093,11 +1120,18 @@ pub(crate) fn stamped(tracks: Vec, context: Option) -> dropped.unavailable ); } + if dropped.qconnect_unresolvable > 0 { + log::info!( + "[qbz-qt] qconnect admission: dropped {} unresolvable track(s) from an enqueue", + dropped.qconnect_unresolvable + ); + } // Idempotent with the seam below it: `myqbz_play_qt` stamps and THEN calls // `set_queue_stamped`, whose second pass finds nothing left to drop and so // raises no second toast. That module's existing "both passes are // idempotent" comment stays true. toast_unavailable_dropped(dropped.unavailable); + crate::qconnect_qt::toast_unresolvable_tracks(dropped.qconnect_unresolvable); stamp_context(&mut tracks, context); warn_dead_context(&tracks, "enqueue"); tracks @@ -1109,22 +1143,13 @@ pub(crate) fn stamped(tracks: Vec, context: Option) -> // --------------------------------------------------------------------------- /// QConnect sync-on-add predicate (#442, playback.rs:4082-4096): true when -/// EVERY track of an added batch is Qobuz-castable (mirrors -/// `sync_local_queue_if_changed`'s all-or-nothing admission — `local` / -/// `plex` refused, offline `qobuz_download` eligible). A non-castable batch -/// skips the post-add push so a local/plex add never trips the refusal toast -/// on every click; the next track tick already surfaces that state. +/// every surviving row uses a Qobuz-resolvable provenance. The shared enqueue +/// seam normally guarantees this while QConnect is enabled; this remains a +/// defensive gate for callers running while it is disabled or starting up. pub(crate) fn batch_all_qconnect_castable(tracks: &[QueueTrack]) -> bool { - tracks.iter().all(|qt| { - qt.id > 0 - && !matches!( - qt.source - .as_deref() - .map(|s| s.to_ascii_lowercase()) - .as_deref(), - Some("local") | Some("plex") - ) - }) + tracks + .iter() + .all(crate::qconnect_qt::is_qconnect_queue_track) } /// QConnect sync-on-add (#442, playback.rs:4098-4111): after a local queue @@ -1979,8 +2004,8 @@ pub async fn enqueue_album( _ => runtime.core().add_tracks(tracks).await, } // QConnect sync-on-add (#442): push the updated queue to the session so a - // connected controller sees the new items; skipped silently for a - // non-castable batch (self-gating when a peer owns playback). + // connected controller sees the surviving items (self-gating when a peer + // owns playback). sync_qconnect_after_add(added_castable).await; publish_queue(runtime).await; Ok(()) @@ -2369,8 +2394,8 @@ pub async fn enqueue_track_list_mode( } _ => runtime.core().add_tracks(tracks).await, } - // QConnect sync-on-add (#442): push the updated queue to the session; - // skipped silently for a non-castable batch. + // QConnect sync-on-add (#442): push the updated surviving queue to the + // session. sync_qconnect_after_add(added_castable).await; publish_queue(runtime).await; Ok(()) @@ -5170,8 +5195,13 @@ mod tests { /// the one the user clicked — silently, and only when something was blocked. #[test] fn start_index_follows_the_clicked_track() { - let (kept, start, _) = - filter_queue_with(vec![0, 1, 2, 3, 4], Some(2), |n| n % 2 == 1, |_| false); + let (kept, start, _) = filter_queue_with( + vec![0, 1, 2, 3, 4], + Some(2), + |n| n % 2 == 1, + |_| false, + |_| false, + ); assert_eq!(kept, vec![0, 2, 4]); assert_eq!(start, Some(1)); // the 2 moved from index 2 to index 1 } @@ -5179,8 +5209,13 @@ mod tests { /// Clicking a row that is itself blocked lands on the next survivor. #[test] fn a_blocked_clicked_row_falls_forward() { - let (kept, start, _) = - filter_queue_with(vec![0, 1, 2, 3], Some(1), |n| n % 2 == 1, |_| false); + let (kept, start, _) = filter_queue_with( + vec![0, 1, 2, 3], + Some(1), + |n| n % 2 == 1, + |_| false, + |_| false, + ); assert_eq!(kept, vec![0, 2]); assert_eq!(start, Some(1)); // the 1 was dropped, so start on the 2 } @@ -5189,8 +5224,13 @@ mod tests { /// (an out-of-range index is what makes the core start nothing at all). #[test] fn a_fully_blocked_tail_clamps_to_the_last_survivor() { - let (kept, start, _) = - filter_queue_with(vec![0, 1, 3, 5], Some(2), |n| n % 2 == 1, |_| false); + let (kept, start, _) = filter_queue_with( + vec![0, 1, 3, 5], + Some(2), + |n| n % 2 == 1, + |_| false, + |_| false, + ); assert_eq!(kept, vec![0]); assert_eq!(start, Some(0)); } @@ -5198,7 +5238,8 @@ mod tests { /// Everything blocked: an empty queue carries NO start index. #[test] fn everything_blocked_yields_no_start() { - let (kept, start, _) = filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false); + let (kept, start, _) = + filter_queue_with(vec![1, 3, 5], Some(1), |n| n % 2 == 1, |_| false, |_| false); assert!(kept.is_empty()); assert_eq!(start, None); } @@ -5207,7 +5248,8 @@ mod tests { /// user without a blacklist takes. #[test] fn nothing_blocked_is_identity() { - let (kept, start, _) = filter_queue_with(vec![0, 2, 4], Some(2), |_| false, |_| false); + let (kept, start, _) = + filter_queue_with(vec![0, 2, 4], Some(2), |_| false, |_| false, |_| false); assert_eq!(kept, vec![0, 2, 4]); assert_eq!(start, Some(2)); } @@ -5215,7 +5257,7 @@ mod tests { /// Acceptance §8-5: a build containing dead tracks drops them AND re-maps /// the start index to the track the user actually clicked. Same arithmetic /// as the blacklist, different predicate — which is why the helper took a - /// second closure instead of a rewrite. + /// dedicated closure instead of a rewrite. #[test] fn unavailable_rows_drop_and_remap_the_start_index() { // Rows 1 and 3 are "pulled"; the user clicked row 4. @@ -5224,6 +5266,7 @@ mod tests { Some(4), |_| false, |n| *n == 1 || *n == 3, + |_| false, ); assert_eq!(kept, vec![0, 2, 4]); assert_eq!(start, Some(2)); // the 4 moved from index 4 to index 2 @@ -5236,8 +5279,13 @@ mod tests { /// news. A single counter would force one policy on both. #[test] fn the_two_drop_reasons_are_counted_separately() { - let (kept, _, dropped) = - filter_queue_with(vec![0, 1, 2, 3], Some(0), |n| *n == 1, |n| *n == 2); + let (kept, _, dropped) = filter_queue_with( + vec![0, 1, 2, 3], + Some(0), + |n| *n == 1, + |n| *n == 2, + |_| false, + ); assert_eq!(kept, vec![0, 3]); assert_eq!(dropped.blacklisted, 1); assert_eq!(dropped.unavailable, 1); @@ -5248,12 +5296,29 @@ mod tests { /// content the blacklist exists to keep off their screen. #[test] fn blacklist_wins_the_tie_and_stays_silent() { - let (kept, _, dropped) = filter_queue_with(vec![0, 1], Some(0), |n| *n == 1, |n| *n == 1); + let (kept, _, dropped) = + filter_queue_with(vec![0, 1], Some(0), |n| *n == 1, |n| *n == 1, |_| false); assert_eq!(kept, vec![0]); assert_eq!(dropped.blacklisted, 1); assert_eq!(dropped.unavailable, 0); } + #[test] + fn qconnect_unresolvable_rows_drop_and_remap_the_start_index() { + let (kept, start, dropped) = filter_queue_with( + vec![0, 1, 2, 3, 4], + Some(3), + |_| false, + |_| false, + |n| *n == 1 || *n == 3, + ); + assert_eq!(kept, vec![0, 2, 4]); + assert_eq!(start, Some(2)); + assert_eq!(dropped.qconnect_unresolvable, 2); + assert_eq!(dropped.blacklisted, 0); + assert_eq!(dropped.unavailable, 0); + } + #[test] fn song_card_near_fit_keeps_the_final_glyphs_instead_of_an_ellipsis() { // TextMetrics.width is a painted bounding box, while the line layout diff --git a/crates/qbz-qt/src/playlist_qt.rs b/crates/qbz-qt/src/playlist_qt.rs index ae67aa68a..5ad76f3dd 100644 --- a/crates/qbz-qt/src/playlist_qt.rs +++ b/crates/qbz-qt/src/playlist_qt.rs @@ -2225,6 +2225,9 @@ pub async fn enqueue_all( mode: &str, ) -> Result<(), String> { let tracks = crate::playback_qt::stamped(current_queue(), open_context()); + if tracks.is_empty() { + return Ok(()); + } crate::playback_qt::enqueue_track_list_mode(runtime, tracks, mode).await } diff --git a/crates/qbz-qt/src/qconnect_bridge.rs b/crates/qbz-qt/src/qconnect_bridge.rs index d2191c48e..ce44d396d 100644 --- a/crates/qbz-qt/src/qconnect_bridge.rs +++ b/crates/qbz-qt/src/qconnect_bridge.rs @@ -57,6 +57,15 @@ pub mod qbz_qconnect { #[qproperty(QString, devices_json)] // -1 = none. #[qproperty(i32, active_renderer_id)] + // --- Playback-conflict modal -------------------------------------- + // Raised when local audio is already playing and SESSION_STATE names a + // peer renderer. No remote queue/renderer command is applied until QML + // resolves one of the four explicit choices. + #[qproperty(bool, playback_conflict_open)] + #[qproperty(QString, playback_conflict_renderer_name)] + // 0=ask every time, 1..=3 mirror the modal's non-cancel choices. + // Both the flyout and Settings bind this same persisted value. + #[qproperty(i32, playback_conflict_policy_index)] // --- Diagnostics modal (DeveloperSettings > QOBUZ CONNECT) --------- // QML-driven open/close (DeveloperSettings sets it true; the modal's // own close sets it false). @@ -82,6 +91,11 @@ pub mod qbz_qconnect { #[qinvokable] fn set_active(self: Pin<&mut QbzQConnect>, renderer_id: i32); + /// Resolve the playback-conflict modal. Values 1..=4 match the visual + /// order and are validated again by the Rust facade. + #[qinvokable] + fn resolve_playback_conflict(self: Pin<&mut QbzQConnect>, choice: i32); + /// Diagnostics modal footer: clear the rolling event log. #[qinvokable] fn diag_clear(self: Pin<&mut QbzQConnect>); @@ -105,6 +119,9 @@ pub struct QbzQConnectRust { qconnect_connected: bool, devices_json: QString, active_renderer_id: i32, + playback_conflict_open: bool, + playback_conflict_renderer_name: QString, + playback_conflict_policy_index: i32, diag_open: bool, diag_status: QString, diag_log_text: QString, @@ -118,6 +135,9 @@ impl Default for QbzQConnectRust { // frame (the home_bridge / cast_bridge convention). devices_json: QString::from("[]"), active_renderer_id: -1, + playback_conflict_open: false, + playback_conflict_renderer_name: QString::default(), + playback_conflict_policy_index: 0, diag_open: false, diag_status: QString::default(), diag_log_text: QString::default(), @@ -140,10 +160,13 @@ pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzQConnect>) + Send + 'static) { } impl qbz_qconnect::QbzQConnect { - pub fn boot(self: Pin<&mut Self>) { + pub fn boot(mut self: Pin<&mut Self>) { if QT_THREAD.set(self.qt_thread()).is_err() { log::warn!("[qbz-qt] qconnect Qt thread already registered"); } + let policy_index = crate::qconnect_transport_qt::load_playback_conflict_policy().index(); + self.as_mut() + .set_playback_conflict_policy_index(policy_index); } pub fn connect_toggle(self: Pin<&mut Self>) { @@ -229,6 +252,10 @@ impl qbz_qconnect::QbzQConnect { }); } + pub fn resolve_playback_conflict(self: Pin<&mut Self>, choice: i32) { + crate::qconnect_qt::resolve_playback_conflict_choice(choice); + } + pub fn diag_clear(self: Pin<&mut Self>) { crate::qconnect_qt::dev_clear(); } diff --git a/crates/qbz-qt/src/qconnect_event_sink_qt.rs b/crates/qbz-qt/src/qconnect_event_sink_qt.rs index 89381503a..7c3bb78ac 100644 --- a/crates/qbz-qt/src/qconnect_event_sink_qt.rs +++ b/crates/qbz-qt/src/qconnect_event_sink_qt.rs @@ -29,9 +29,11 @@ use qbz_app::shell::AppRuntime; use qbz_core::LoggingAdapter; use qconnect_app::{ build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active, - renderer_allows_remote_volume, AuthorityCell, AuthorityStamp, QconnectApp, QconnectAppEvent, - QconnectEventSink, QconnectRemoteSyncState, QconnectRendererEngine, RendererBufferState, - RendererCommand, RendererReport, RendererReportType, + local_playback_should_yield_to_active_peer, remote_renderer_commands_are_fenced, + renderer_allows_remote_volume, should_materialize_remote_queue, AuthorityCell, AuthorityStamp, + QconnectApp, QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState, + QconnectRendererEngine, RendererBufferState, RendererCommand, RendererReport, + RendererReportType, }; use qconnect_transport_ws::NativeWsTransport; use serde_json::Value; @@ -487,7 +489,11 @@ impl QtQconnectEventSink { let was_peer_active = self .last_peer_active .swap(peer_active_now, std::sync::atomic::Ordering::Relaxed); - if peer_active_now && !was_peer_active { + let conflict_pending = { + let state = self.sync_state.lock().await; + state.local_playback_conflict_pending + }; + if peer_active_now && !was_peer_active && !conflict_pending { let result = app.ask_for_active_renderer_state().await; if !self.is_current() { return; @@ -500,21 +506,20 @@ impl QtQconnectEventSink { } } - /// When an active PEER renderer now owns playback, stop our local playback so - /// the two don't double-play. Mirrors the Tauri helper, with the engine seam - /// in place of `CoreBridge`. + /// When the active PEER renderer is actually playing, stop our local playback + /// so the two don't double-play. A paused/stale peer must not interrupt QBZ. async fn sync_local_playback_for_renderer_ownership(&self) { if !self.is_current() { return; } - let peer_renderer_active = { + let peer_renderer_playing = { let state = self.sync_state.lock().await; if !self.is_current() { return; } - is_peer_renderer_active(&state.session) + local_playback_should_yield_to_active_peer(&state) }; - if !peer_renderer_active { + if !peer_renderer_playing { return; } @@ -529,7 +534,7 @@ impl QtQconnectEventSink { } log::info!( - "[QConnect] Stopping local playback because active renderer is a peer (track_id={})", + "[QConnect] Stopping local playback because the active peer is playing (track_id={})", playback_state.track_id ); if let Err(err) = self.engine.stop() { @@ -652,12 +657,31 @@ impl QconnectEventSink for QtQconnectEventSink { queue_state.version.major, queue_state.version.minor, ); - { + let (should_materialize, accepted_local_echo) = { let mut sync_state = self.sync_state.lock().await; if !self.is_current() { return; } sync_state.last_remote_queue_state = Some(queue_state.clone()); + let had_local_takeover = sync_state.pending_local_queue_takeover.is_some(); + let should_materialize = + should_materialize_remote_queue(&mut sync_state, queue_state); + let accepted_local_echo = had_local_takeover + && sync_state.pending_local_queue_takeover.is_none() + && sync_state.local_playback_state_assertion_pending; + (should_materialize, accepted_local_echo) + }; + if !should_materialize { + if accepted_local_echo { + log::info!( + "[QConnect] Local queue accepted by Connect; preserving live player cursor" + ); + } else { + log::debug!( + "[QConnect] Ignoring stale remote queue while local-playing takeover settles" + ); + } + return; } let result = qconnect_app::renderer::materialize_remote_queue( &self.engine, @@ -668,8 +692,15 @@ impl QconnectEventSink for QtQconnectEventSink { if !self.is_current() { return; } - if let Err(err) = result { - log::warn!("[QConnect] Failed to materialize remote queue: {err}"); + let materialized = match result { + Ok(materialized) => materialized, + Err(err) => { + log::warn!("[QConnect] Failed to materialize remote queue: {err}"); + false + } + }; + if !materialized { + return; } // Reflect the remote queue change in the QBZ UI (queue panel + // now-playing card). materialize already set the core queue + @@ -680,6 +711,16 @@ impl QconnectEventSink for QtQconnectEventSink { } } QconnectAppEvent::RendererCommandApplied { command, state } => { + let fenced = { + let sync_state = self.sync_state.lock().await; + remote_renderer_commands_are_fenced(&sync_state) + }; + if fenced { + log::info!( + "[QConnect] Ignoring stale renderer command while local queue authority settles" + ); + return; + } // SetState is the routine playback/position command and may be // republished. Lifecycle commands remain visible at info. if matches!(command, RendererCommand::SetState { .. }) { diff --git a/crates/qbz-qt/src/qconnect_qt.rs b/crates/qbz-qt/src/qconnect_qt.rs index 485142c8a..ca6f5f274 100644 --- a/crates/qbz-qt/src/qconnect_qt.rs +++ b/crates/qbz-qt/src/qconnect_qt.rs @@ -54,11 +54,13 @@ use qconnect_app::queue_resolution::{ }; use qconnect_app::renderer::{PLAYING_STATE_PAUSED, PLAYING_STATE_PLAYING, PLAYING_STATE_STOPPED}; use qconnect_app::{ - build_effective_renderer_snapshot, build_renderer_playback_report, + active_peer_renderer_is_playing, arm_local_queue_takeover, build_effective_renderer_snapshot, + build_renderer_playback_report, confirm_local_playback_state_asserted, ensure_session_renderer_state, is_local_renderer_active, is_peer_renderer_active, - lan_callback_is_current, queue_item_snapshot_for_cursor, renderer_allows_remote_volume, - AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp, - DelegationCoordinatorConfig, DelegationHost, LanRuntimeLifecycle, OwnerAuthorityObservation, + lan_callback_is_current, local_queue_takeover_needs_retry, queue_item_snapshot_for_cursor, + renderer_allows_remote_volume, set_local_playback_conflict_pending, AuthorityActionPermit, + AuthorityCell, AuthorityOrigin, AuthorityStamp, DelegationCoordinatorConfig, DelegationHost, + LanRuntimeLifecycle, LocalPlaybackConflictChoice, OwnerAuthorityObservation, OwnerAuthorityToken, QConnectQueueState, QConnectRendererState, QconnectApp, QconnectAppEvent, QconnectDisabledToken, QconnectEnableIntent, QconnectEnableToken, QconnectEventSink, QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRemoteSyncState, @@ -103,22 +105,144 @@ type Runtime = Arc>; /// download is still the same catalog id and is eligible; every server/file /// source is not, even when its numeric id happens to look like a Qobuz id. pub(crate) fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool { - if track.id == 0 { + qconnect_app::qconnect_queue_track_is_resolvable(track) +} + +/// Queue admission follows the user's enabled intent, not the transient +/// transport badge. Otherwise a reconnect window can admit local rows that +/// poison the next cloud sync even though QConnect was never turned off. +pub(crate) fn queue_admission_enabled() -> bool { + service().is_some_and(|service| service.has_enabled_intent()) +} + +/// One user-facing notice per queue action, pluralized across all locales. +/// The caller owns batching; this helper never emits once per row. +pub(crate) fn toast_unresolvable_tracks(count: usize) { + if count == 0 { + return; + } + crate::toast_qt::info(qbz_i18n::tf( + "Qobuz Connect can't resolve local tracks. {} track was skipped and wasn't added to the queue. Turn off Qobuz Connect to play it.", + "Qobuz Connect can't resolve local tracks. {} tracks were skipped and weren't added to the queue. Turn off Qobuz Connect to play them.", + count as i64, + &[&count.to_string()], + )); +} + +fn resolvable_track_ids(tracks: &[(u64, Option)]) -> (Vec, usize) { + let mut kept = Vec::with_capacity(tracks.len()); + let mut dropped = 0; + for (track_id, source) in tracks { + if qconnect_app::qconnect_source_is_resolvable(*track_id, source.as_deref()) { + kept.push(*track_id); + } else { + dropped += 1; + } + } + (kept, dropped) +} + +/// Defensive projection for queues that predate the admission seam (session +/// restore, an older build, or an internal caller). Keeps catalog order and +/// maps the old cursor onto the first surviving row at/after it, falling back +/// to the last survivor when the remainder of the queue was local-only. +fn resolvable_queue_projection( + tracks: &[qbz_models::QueueTrack], + start: Option, +) -> (Vec, Option, usize) { + let clicked = start.unwrap_or(0); + let mut kept = Vec::with_capacity(tracks.len()); + let mut new_start = None; + let mut dropped = 0; + for (index, track) in tracks.iter().enumerate() { + if !is_qconnect_queue_track(track) { + dropped += 1; + continue; + } + if new_start.is_none() && index >= clicked { + new_start = Some(kept.len()); + } + kept.push(track.id); + } + if new_start.is_none() && !kept.is_empty() { + new_start = Some(kept.len() - 1); + } + (kept, new_start, dropped) +} + +/// Publish the local queue while QBZ takes renderer ownership because it is +/// already playing and no peer is. This deliberately bypasses the ordinary +/// `is_local_renderer_active` gate: the SET_ACTIVE command was just sent, but +/// its cloud echo has not landed yet. The upload is still authority-stamped and +/// only contains Qobuz-resolvable rows. +async fn publish_local_queue_for_takeover( + app: &Arc, + sync_state: &Arc>, + inner: &Arc>, + runtime: &Runtime, + authority: &AuthorityCell, + stamp: AuthorityStamp, +) -> bool { + if !authority.is_current(stamp) || runtime.core().queue_is_offline_only() { return false; } - match track - .source - .as_deref() - .map(str::trim) - .filter(|source| !source.is_empty()) - { - Some(source) => matches!( - source.to_ascii_lowercase().as_str(), - "qobuz" | "qobuz_download" | "qobuz_purchase" | "offline" - ), - // Legacy catalog rows predate the source stamp. Their explicit - // non-local bit is the compatibility discriminator. - None => !track.is_local, + let (tracks, current_index) = runtime.core().get_all_queue_tracks().await; + if !authority.is_current(stamp) || tracks.is_empty() { + return false; + } + let source_ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let (ordered_ids, projected_start, dropped) = + resolvable_queue_projection(&tracks, current_index); + if dropped > 0 { + log::info!("[QConnect] Local-playing takeover skipped {dropped} non-Qobuz track(s)"); + toast_unresolvable_tracks(dropped); + } + if ordered_ids.is_empty() || !authority.is_current(stamp) { + lock_inner(inner).last_pushed_queue_ids = Some(source_ordered_ids); + return false; + } + + let start_index = projected_start.unwrap_or(0); + let count = ordered_ids.len(); + let payload = json!({ + "track_ids": ordered_ids.iter().map(|id| *id as i64).collect::>(), + "queue_position": start_index, + "shuffle_mode": false, + "shuffle_pivot_index": start_index, + "context_uuid": Uuid::new_v4().to_string(), + "autoplay_reset": true, + "autoplay_loading": false, + }); + let command = app + .build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload) + .await; + if !authority.is_current(stamp) { + return false; + } + match app.send_queue_command(command).await { + Ok(action_uuid) if authority.is_current(stamp) => { + { + let mut state = sync_state.lock().await; + if !authority.is_current(stamp) { + return false; + } + arm_local_queue_takeover(&mut state, ordered_ids, action_uuid); + set_local_playback_conflict_pending(&mut state, false); + } + lock_inner(inner).last_pushed_queue_ids = Some(source_ordered_ids); + log::info!( + "[QConnect] Local playback kept; published filtered queue ({count} tracks, start={start_index})" + ); + dev_push_event(format!( + "-> local-playing takeover QueueLoadTracks {count} start={start_index}" + )); + true + } + Ok(_) => false, + Err(error) => { + log::warn!("[QConnect] Local-playing queue publication failed: {error}"); + false + } } } @@ -138,17 +262,6 @@ pub(crate) fn is_qconnect_queue_track(track: &qbz_models::QueueTrack) -> bool { // --------------------------------------------------------------------------- pub(crate) mod publish { use cxx_qt_lib::QString; - use std::sync::atomic::{AtomicBool, Ordering}; - - /// Rust-side mirror of the bridge bit. Queue projections are assembled off - /// the Qt thread, so they cannot read `QbzQConnect.qconnectConnected` - /// directly. Keeping the mirror at the one publish funnel makes the QML - /// greyout and the Rust action guards agree on the same session edge. - static CONNECTED: AtomicBool = AtomicBool::new(false); - - pub(crate) fn is_connected() -> bool { - CONNECTED.load(Ordering::SeqCst) - } /// One device-picker row (the Slint `QconnectDevice` struct), serialized /// onto `QbzQConnect.devices_json` by [`devices`] (same JSON precedent as @@ -167,7 +280,6 @@ pub(crate) mod publish { /// `NowPlayingState.qconnect-connected` -> `QbzQConnect.qconnect_connected` /// (the golden ConnectButton badge). pub(crate) fn connected(connected: bool) { - CONNECTED.store(connected, Ordering::SeqCst); crate::qconnect_bridge::ui(move |mut b| { b.as_mut().set_qconnect_connected(connected); }); @@ -206,6 +318,24 @@ pub(crate) mod publish { }); } + /// Open/close the global playback-conflict modal. The session loop remains + /// fenced while it is open, so this publish is presentation only. + pub(crate) fn playback_conflict(open: bool, renderer_name: String) { + crate::qconnect_bridge::ui(move |mut b| { + b.as_mut() + .set_playback_conflict_renderer_name(QString::from(renderer_name.as_str())); + b.as_mut().set_playback_conflict_open(open); + }); + } + + /// Shared persisted conflict-policy selection. The flyout binds this + /// property directly; Settings receives the same index in its snapshot. + pub(crate) fn playback_conflict_policy_index(index: i32) { + crate::qconnect_bridge::ui(move |mut b| { + b.as_mut().set_playback_conflict_policy_index(index); + }); + } + /// `QconnectDevState.status` -> `QbzQConnect.diag_status` (the diagnostics /// modal's live status block). pub(crate) fn diag_status(status: String) { @@ -262,6 +392,49 @@ pub struct RemoteNowPlaying { /// future `*_if_remote` transport routing reach it through `service()`. static SERVICE: std::sync::OnceLock> = std::sync::OnceLock::new(); +type PlaybackConflictSender = tokio::sync::oneshot::Sender; +static PLAYBACK_CONFLICT_SENDER: std::sync::OnceLock>> = + std::sync::OnceLock::new(); + +fn playback_conflict_sender() -> &'static StdMutex> { + PLAYBACK_CONFLICT_SENDER.get_or_init(|| StdMutex::new(None)) +} + +async fn request_playback_conflict_choice(renderer_name: String) -> LocalPlaybackConflictChoice { + let (sender, receiver) = tokio::sync::oneshot::channel(); + let previous = playback_conflict_sender() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .replace(sender); + drop(previous); + publish::playback_conflict(true, renderer_name); + receiver + .await + .unwrap_or(LocalPlaybackConflictChoice::CancelConnection) +} + +/// QML result for the conflict modal. Invalid values fail closed as Cancel. +pub(crate) fn resolve_playback_conflict_choice(choice: i32) { + let choice = match choice { + 1 => LocalPlaybackConflictChoice::ContinueOnActiveRenderer, + 2 => LocalPlaybackConflictChoice::ContinueOnThisDevice, + 3 => LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue, + 4 => LocalPlaybackConflictChoice::CancelConnection, + value => { + log::warn!("[QConnect] invalid playback-conflict choice: {value}"); + LocalPlaybackConflictChoice::CancelConnection + } + }; + publish::playback_conflict(false, String::new()); + let sender = playback_conflict_sender() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .take(); + if let Some(sender) = sender { + let _ = sender.send(choice); + } +} + /// Initialize the QConnect service singleton (idempotent — a second call returns /// the existing instance, ignoring the new args). pub fn init_service(runtime: Runtime) -> Arc { @@ -1481,8 +1654,12 @@ impl QtQconnectService { next_queue_item_id: next_qid, }, ); - if let Err(err) = app.send_renderer_report_command(report).await { - log::warn!("[QConnect] Failed to report playback state: {err}"); + match app.send_renderer_report_command(report).await { + Ok(()) => { + let mut state = sync_state.lock().await; + confirm_local_playback_state_asserted(&mut state); + } + Err(err) => log::warn!("[QConnect] Failed to report playback state: {err}"), } if position_ms >= 0 { @@ -2058,10 +2235,10 @@ impl QtQconnectService { /// /// Echo-safe by construction: the inbound materialize path never calls this, /// and we skip when the local queue already equals the cloud's last-applied - /// queue OR the last queue we pushed. Admission mirrors the webplayer's - /// `assessQconnectQueueSync` — all-or-nothing: a queue containing any local / - /// Plex track is refused whole (a renderer can only play Qobuz catalog ids; - /// offline qobuz_download IS eligible — its id is the real Qobuz id). + /// queue OR the last queue we pushed. Admission normally happened before + /// the core queue mutation. A defensive + /// projection here also repairs restored/legacy mixed queues instead of + /// refusing the whole cloud update. pub async fn sync_local_queue_if_changed(&self) { let Ok(Some(_runtime_action)) = self.begin_runtime_action_if_running() else { return; @@ -2094,11 +2271,15 @@ impl QtQconnectService { if tracks.is_empty() { return; } - let ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let source_ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let takeover_retry = { + let state = sync_state.lock().await; + local_queue_takeover_needs_retry(&state) + }; // Echo-suppress: skip when this is the cloud's current queue (materialized // inbound) so our own adoption / a remote queue change never bounces back. - { + if !takeover_retry { let state = sync_state.lock().await; if let Some(applied) = &state.last_applied_queue_state { let applied_ids: Vec = applied @@ -2106,33 +2287,38 @@ impl QtQconnectService { .iter() .map(|item| item.track_id) .collect(); - if applied_ids == ordered_ids { + if applied_ids == source_ordered_ids { return; } } } // ...and skip when we already pushed this exact queue (cloud echo pending). - { + if !takeover_retry { let guard = lock_inner(&self.inner); - if guard.last_pushed_queue_ids.as_deref() == Some(ordered_ids.as_slice()) { + if guard.last_pushed_queue_ids.as_deref() == Some(source_ordered_ids.as_slice()) { return; } } - // Admission: refuse the whole push if any track isn't Qobuz-castable. - let all_eligible = tracks.iter().all(is_qconnect_queue_track); - if !all_eligible { - log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect"); - crate::toast_qt::error(qbz_i18n::t("Mixed queue — not cast to Qobuz Connect")); - dev_push_event("-> queue push REFUSED (mixed/non-Qobuz)".to_string()); - // Remember it so we don't re-toast on every track tick within this queue. - lock_inner(&self.inner).last_pushed_queue_ids = Some(ordered_ids); + let (ordered_ids, projected_start, dropped) = + resolvable_queue_projection(&tracks, current_index); + if dropped > 0 { + log::info!( + "[QConnect] Queue projection skipped {dropped} non-Qobuz track(s) before cloud sync" + ); + toast_unresolvable_tracks(dropped); + dev_push_event(format!( + "-> queue projection skipped {dropped} non-Qobuz track(s)" + )); + } + if ordered_ids.is_empty() { + lock_inner(&self.inner).last_pushed_queue_ids = Some(source_ordered_ids); return; } let count = ordered_ids.len(); let track_ids: Vec = ordered_ids.iter().map(|id| *id as i64).collect(); - let start_index = current_index.unwrap_or(0); + let start_index = projected_start.unwrap_or(0); let payload = json!({ "track_ids": track_ids, "queue_position": start_index, @@ -2146,14 +2332,18 @@ impl QtQconnectService { .build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload) .await; match app.send_queue_command(command).await { - Ok(_) => { + Ok(action_uuid) => { + if takeover_retry { + let mut state = sync_state.lock().await; + arm_local_queue_takeover(&mut state, ordered_ids, action_uuid); + } log::info!( "[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})" ); dev_push_event(format!( "-> QueueLoadTracks {count} tracks start={start_index}" )); - lock_inner(&self.inner).last_pushed_queue_ids = Some(ordered_ids); + lock_inner(&self.inner).last_pushed_queue_ids = Some(source_ordered_ids); } Err(err) => log::warn!("[QConnect] Failed to push local queue: {err}"), } @@ -2168,13 +2358,9 @@ impl QtQconnectService { /// Unlike `sync_local_queue_if_changed`, the queue push here is /// UNCONDITIONAL (no echo-gate, no is_local_renderer_active gate) — the user /// just issued a fresh play, so the current core queue IS what should run on - /// the peer. Admission is the SAME all-or-nothing rule: a queue containing - /// any local / Plex track is refused whole (a renderer can only play Qobuz - /// catalog ids; offline qobuz_download IS eligible). On refusal it toasts and - /// returns `true` (handled — do NOT fall back to playing a mixed queue - /// locally that we just declined to cast). On any send error it logs and - /// still returns `true` (a peer owns playback; falling back to local audio - /// would double-play). + /// the peer. Any legacy non-Qobuz rows are projected out while preserving + /// order and cursor. On any send error it logs and still returns `true` (a + /// peer owns playback; falling back to local audio would double-play). pub async fn play_on_peer_if_active(&self, track_id: u64) -> bool { let (app, sync_state) = { let guard = lock_inner(&self.inner); @@ -2192,9 +2378,8 @@ impl QtQconnectService { } // D8 guard: an offline-only-playlist queue is never pushed to the - // cloud. Returning false lets the play proceed LOCALLY (unlike the - // mixed-queue refusal below, local playback of these tracks is fine — - // the prohibition is only on the cloud push). + // cloud. Returning false lets the play proceed LOCALLY; playback of + // these tracks is fine, the prohibition is only on the cloud push. if self.runtime.core().queue_is_offline_only() { log::info!( "[QConnect] queue is from an offline-only playlist; not routing to peer renderer" @@ -2207,23 +2392,24 @@ impl QtQconnectService { if tracks.is_empty() { return false; } - let ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); - - // Admission: refuse the whole push if any track isn't Qobuz-castable. - let all_eligible = tracks.iter().all(is_qconnect_queue_track); - if !all_eligible { - log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect"); - crate::toast_qt::error(qbz_i18n::t("Mixed queue — not cast to Qobuz Connect")); - dev_push_event("-> queue push REFUSED (mixed/non-Qobuz)".to_string()); - // Remember it so the poll loop's sync doesn't re-toast this queue. - lock_inner(&self.inner).last_pushed_queue_ids = Some(ordered_ids); - // Handled: do NOT play a refused queue locally. + let source_ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let (ordered_ids, projected_start, dropped) = + resolvable_queue_projection(&tracks, current_index); + if dropped > 0 { + log::info!("[QConnect] play_on_peer projected out {dropped} non-Qobuz track(s)"); + toast_unresolvable_tracks(dropped); + dev_push_event(format!( + "-> play_on_peer skipped {dropped} non-Qobuz track(s)" + )); + } + if ordered_ids.is_empty() || !ordered_ids.contains(&track_id) { + lock_inner(&self.inner).last_pushed_queue_ids = Some(source_ordered_ids); return true; } let count = ordered_ids.len(); let track_ids: Vec = ordered_ids.iter().map(|id| *id as i64).collect(); - let start_index = current_index.unwrap_or(0); + let start_index = projected_start.unwrap_or(0); let payload = json!({ "track_ids": track_ids, "queue_position": start_index, @@ -2244,7 +2430,7 @@ impl QtQconnectService { dev_push_event(format!( "-> play_on_peer QueueLoadTracks {count} start={start_index}" )); - lock_inner(&self.inner).last_pushed_queue_ids = Some(ordered_ids); + lock_inner(&self.inner).last_pushed_queue_ids = Some(source_ordered_ids); } Err(err) => { log::warn!("[QConnect] play_on_peer: queue push failed: {err}"); @@ -2290,7 +2476,7 @@ impl QtQconnectService { log::info!( "[QConnect] play_next_on_peer: track {track_id} not Qobuz-castable; refusing" ); - crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect")); + toast_unresolvable_tracks(1); dev_push_event(format!("-> play_next REFUSED (non-Qobuz track {track_id})")); // Handled: do NOT add a non-castable track to the local queue while a // peer owns playback. @@ -2363,7 +2549,7 @@ impl QtQconnectService { log::info!( "[QConnect] add_to_queue_on_peer: track {track_id} not Qobuz-castable; refusing" ); - crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect")); + toast_unresolvable_tracks(1); dev_push_event(format!( "-> add_to_queue REFUSED (non-Qobuz track {track_id})" )); @@ -2394,10 +2580,8 @@ impl QtQconnectService { /// Controller add-to-queue routing for a MULTI-track batch (album / playlist / /// favorites bulk). Same contract as the single-track - /// `add_to_queue_on_peer_if_active`, but admission is ALL-OR-NOTHING: if ANY - /// track in the batch is non-castable (`local` / `plex`), the WHOLE batch is - /// refused (toast + `return true`, nothing routed and nothing added locally) — - /// mirroring the queue-sync's all-or-nothing rule. A single + /// `add_to_queue_on_peer_if_active`. Admission is partial: non-Qobuz rows + /// are counted once and omitted while every resolvable row keeps its order. A single /// `CtrlSrvrQueueAddTracks` carries every id (the protocol `track_ids` is a /// full `Vec`), and the cloud echoes a `QueueUpdated` that /// `materialize_remote_queue` applies locally, so we never mutate the local @@ -2414,22 +2598,22 @@ impl QtQconnectService { return false; } - if let Some((bad_id, _)) = tracks - .iter() - .find(|(id, source)| !self.is_track_castable(*id, source.as_deref())) - { + let (ids_u64, dropped) = resolvable_track_ids(tracks); + if dropped > 0 { log::info!( - "[QConnect] add_to_queue_batch_on_peer: track {bad_id} not Qobuz-castable; refusing whole batch" + "[QConnect] add_to_queue_batch_on_peer: skipped {dropped} non-Qobuz track(s)" ); - crate::toast_qt::error(qbz_i18n::t("Some tracks can't be cast to Qobuz Connect")); + toast_unresolvable_tracks(dropped); dev_push_event(format!( - "-> add_to_queue REFUSED (non-Qobuz track {bad_id} in batch of {})", - tracks.len() + "-> add_to_queue skipped {dropped} non-Qobuz track(s) from batch of {}", + tracks.len(), )); + } + if ids_u64.is_empty() { return true; } - let ids: Vec = tracks.iter().map(|(id, _)| *id as i64).collect(); + let ids: Vec = ids_u64.iter().map(|id| *id as i64).collect(); let count = ids.len(); let payload = json!({ "track_ids": ids, @@ -2453,7 +2637,7 @@ impl QtQconnectService { true } - /// Controller play-next routing for a MULTI-track batch. Same all-or-nothing + /// Controller play-next routing for a MULTI-track batch. Same partial /// admission as `add_to_queue_batch_on_peer_if_active`. The server /// `CtrlSrvrQueueInsertTracks` inserts the whole `track_ids` block right after /// `insert_after` and PRESERVES the list order, so the ids are passed in @@ -2470,18 +2654,16 @@ impl QtQconnectService { return false; } - if let Some((bad_id, _)) = tracks - .iter() - .find(|(id, source)| !self.is_track_castable(*id, source.as_deref())) - { - log::info!( - "[QConnect] play_next_batch_on_peer: track {bad_id} not Qobuz-castable; refusing whole batch" - ); - crate::toast_qt::error(qbz_i18n::t("Some tracks can't be cast to Qobuz Connect")); + let (ids_u64, dropped) = resolvable_track_ids(tracks); + if dropped > 0 { + log::info!("[QConnect] play_next_batch_on_peer: skipped {dropped} non-Qobuz track(s)"); + toast_unresolvable_tracks(dropped); dev_push_event(format!( - "-> play_next REFUSED (non-Qobuz track {bad_id} in batch of {})", - tracks.len() + "-> play_next skipped {dropped} non-Qobuz track(s) from batch of {}", + tracks.len(), )); + } + if ids_u64.is_empty() { return true; } @@ -2498,7 +2680,6 @@ impl QtQconnectService { .and_then(|item| i64::try_from(item.queue_item_id).ok()) }); - let ids_u64: Vec = tracks.iter().map(|(id, _)| *id).collect(); let ids: Vec = ids_u64.iter().map(|id| *id as i64).collect(); let count = ids.len(); let mut payload = json!({ @@ -2576,7 +2757,7 @@ impl QtQconnectService { log::info!( "[QConnect] play_later_on_peer: track {track_id} not Qobuz-castable; refusing" ); - crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect")); + toast_unresolvable_tracks(1); dev_push_event(format!( "-> play_later REFUSED (non-Qobuz track {track_id})" )); @@ -2661,7 +2842,7 @@ impl QtQconnectService { } if !self.is_track_castable(track_id, source) { log::info!("[QConnect] insert_at_slot: track {track_id} not Qobuz-castable; refusing"); - crate::toast_qt::error(qbz_i18n::t("Track not castable to Qobuz Connect")); + toast_unresolvable_tracks(1); dev_push_event(format!( "-> insert_at_slot REFUSED (non-Qobuz track {track_id})" )); @@ -2719,8 +2900,8 @@ impl QtQconnectService { } /// Controller play-LATER routing for a MULTI-track batch (#442). Same - /// all-or-nothing admission as `play_next_batch_on_peer_if_active`; the - /// whole block lands after the manual-block tail in NATURAL order (the + /// partial admission as `play_next_batch_on_peer_if_active`; the surviving + /// block lands after the manual-block tail in NATURAL order (the /// server preserves list order), mirroring the local /// `enqueue_queue_tracks_later` semantics. pub async fn play_later_batch_on_peer_if_active( @@ -2734,24 +2915,21 @@ impl QtQconnectService { return false; } - if let Some((bad_id, _)) = tracks - .iter() - .find(|(id, source)| !self.is_track_castable(*id, source.as_deref())) - { - log::info!( - "[QConnect] play_later_batch_on_peer: track {bad_id} not Qobuz-castable; refusing whole batch" - ); - crate::toast_qt::error(qbz_i18n::t("Some tracks can't be cast to Qobuz Connect")); + let (ids_u64, dropped) = resolvable_track_ids(tracks); + if dropped > 0 { + log::info!("[QConnect] play_later_batch_on_peer: skipped {dropped} non-Qobuz track(s)"); + toast_unresolvable_tracks(dropped); dev_push_event(format!( - "-> play_later REFUSED (non-Qobuz track {bad_id} in batch of {})", - tracks.len() + "-> play_later skipped {dropped} non-Qobuz track(s) from batch of {}", + tracks.len(), )); + } + if ids_u64.is_empty() { return true; } let insert_after = self.controller_later_anchor().await; - let ids_u64: Vec = tracks.iter().map(|(id, _)| *id).collect(); let ids: Vec = ids_u64.iter().map(|id| *id as i64).collect(); let count = ids.len(); let mut payload = json!({ @@ -2799,13 +2977,9 @@ impl QtQconnectService { is_peer_renderer_active(&state.session) } - /// Single-track castability check, mirroring `sync_local_queue_if_changed`'s - /// all-or-nothing rule: castable = a positive id whose `source` is not - /// `local` / `plex` (offline `qobuz_download` IS eligible; the default/None - /// is treated as `qobuz`). + /// Single-track form of the shared source-aware admission contract. fn is_track_castable(&self, track_id: u64, source: Option<&str>) -> bool { - let source = source.unwrap_or("qobuz").to_ascii_lowercase(); - track_id > 0 && source != "local" && source != "plex" + qconnect_app::qconnect_source_is_resolvable(track_id, source) } // ----------------------------------------------------------------------- @@ -3879,6 +4053,111 @@ pub(crate) struct QtSessionLoopHost { #[async_trait::async_trait] impl SessionLoopHost for QtSessionLoopHost { + fn local_playback_is_playing(&self) -> bool { + self.authority.is_current(self.stamp) && self.runtime.core().get_playback_state().is_playing + } + + async fn publish_local_queue_for_takeover(&self) -> bool { + publish_local_queue_for_takeover( + &self.app, + &self.sync_state, + &self.inner, + &self.runtime, + &self.authority, + self.stamp, + ) + .await + } + + async fn resolve_local_playback_conflict( + &self, + active_renderer_id: i32, + _peer_was_playing: bool, + ) -> LocalPlaybackConflictChoice { + if !self.authority.is_current(self.stamp) { + return LocalPlaybackConflictChoice::CancelConnection; + } + let policy = crate::qconnect_transport_qt::load_playback_conflict_policy(); + if let Some(choice) = policy.automatic_choice() { + log::info!( + "[QConnect] Local playback conflicts with active renderer {active_renderer_id}; applying persisted policy {}", + policy.as_str() + ); + return choice; + } + let renderer_name = { + let state = self.sync_state.lock().await; + state + .session + .renderers + .iter() + .find(|renderer| renderer.renderer_id == active_renderer_id) + .and_then(|renderer| renderer.friendly_name.clone()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or_else(|| "Qobuz Connect".to_string()) + }; + log::info!( + "[QConnect] Local playback conflicts with active renderer {active_renderer_id}; awaiting user choice" + ); + request_playback_conflict_choice(renderer_name).await + } + + async fn stop_local_playback_for_remote_queue(&self) { + if !self.authority.is_current(self.stamp) { + return; + } + if let Err(error) = self.runtime.core().stop() { + log::warn!("[QConnect] Failed to stop local playback for remote queue: {error}"); + } + } + + async fn continue_active_renderer_playback(&self) { + if !self.authority.is_current(self.stamp) { + return; + } + let peer_is_playing = { + let state = self.sync_state.lock().await; + active_peer_renderer_is_playing(&state) + }; + if peer_is_playing { + return; + } + let Some(service) = service() else { + return; + }; + if let Err(error) = service.toggle_remote_renderer_playback_if_active().await { + log::warn!("[QConnect] Failed to continue active renderer: {error}"); + } + } + + async fn cancel_connection_for_playback_conflict(&self) { + publish::playback_conflict(false, String::new()); + let Some(service) = service() else { + return; + }; + crate::spawn(async move { + match service.disconnect_safely().await { + Ok(outcome) if outcome.authority_safe => { + publish::connected(false); + let _ = tokio::task::spawn_blocking(|| { + if crate::qconnect_transport_qt::load_startup_mode() + == qconnect_app::QconnectStartupMode::RememberLast + { + crate::qconnect_transport_qt::save_last_known_state(false); + } + }) + .await; + } + Ok(_) => log::warn!( + "[QConnect] Playback-conflict cancellation left authority teardown incomplete" + ), + Err(error) => { + log::warn!("[QConnect] Playback-conflict cancellation failed: {error}") + } + } + }); + } + async fn update_lifecycle(&self, state: QconnectLifecycleState) { update_lifecycle_state_if_running( &self.inner, @@ -4435,7 +4714,7 @@ async fn deferred_renderer_join( #[cfg(test)] mod tests { - use super::is_qconnect_queue_track; + use super::{is_qconnect_queue_track, resolvable_queue_projection, resolvable_track_ids}; use qbz_models::QueueTrack; fn track(source: Option<&str>, is_local: bool) -> QueueTrack { @@ -4467,7 +4746,13 @@ mod tests { #[test] fn qconnect_admits_catalog_and_offline_catalog_rows() { - for source in ["qobuz", "qobuz_download", "qobuz_purchase", "offline"] { + for source in [ + "qobuz", + "qobuz_download", + "qobuz_purchase", + "offline", + "qobuz_connect_remote", + ] { assert!( is_qconnect_queue_track(&track(Some(source), true)), "{source}" @@ -4494,6 +4779,33 @@ mod tests { assert!(!is_qconnect_queue_track(&row)); } + #[test] + fn controller_batches_keep_resolvable_rows_in_original_order() { + let tracks = vec![ + (11, Some("qobuz".to_string())), + (12, Some("local".to_string())), + (13, Some("qobuz_connect_remote".to_string())), + (14, Some("jellyfin".to_string())), + (15, Some("qobuz_download".to_string())), + ]; + let (kept, dropped) = resolvable_track_ids(&tracks); + assert_eq!(kept, vec![11, 13, 15]); + assert_eq!(dropped, 2); + } + + #[test] + fn defensive_queue_projection_remaps_a_dropped_cursor_forward() { + let tracks = vec![ + track(Some("qobuz"), false), + track(Some("local"), true), + track(Some("qobuz_connect_remote"), false), + ]; + let (kept, start, dropped) = resolvable_queue_projection(&tracks, Some(1)); + assert_eq!(kept, vec![42, 42]); + assert_eq!(start, Some(1)); + assert_eq!(dropped, 1); + } + #[test] fn cast_teardown_replaces_only_the_exact_enabled_intent() { let source = include_str!("qconnect_qt.rs"); diff --git a/crates/qbz-qt/src/qconnect_transport_qt.rs b/crates/qbz-qt/src/qconnect_transport_qt.rs index 7e58a6155..78b6e74fa 100644 --- a/crates/qbz-qt/src/qconnect_transport_qt.rs +++ b/crates/qbz-qt/src/qconnect_transport_qt.rs @@ -427,6 +427,35 @@ pub fn save_startup_mode(mode: qconnect_app::QconnectStartupMode) { ); } +/// Load the persisted ownership policy used only when local audio conflicts +/// with an already-playing Qobuz Connect renderer. Missing or invalid values +/// fail open to AskEveryTime. +pub fn load_playback_conflict_policy() -> qconnect_app::QconnectPlaybackConflictPolicy { + let Some(conn) = open_qconnect_settings_conn() else { + return qconnect_app::QconnectPlaybackConflictPolicy::default(); + }; + conn.query_row( + "SELECT value FROM settings WHERE key = 'playback_conflict_policy'", + [], + |row| row.get::<_, String>(0), + ) + .ok() + .as_deref() + .and_then(qconnect_app::QconnectPlaybackConflictPolicy::from_str) + .unwrap_or_default() +} + +/// Persist the ownership policy shared by the flyout and Settings dropdowns. +pub fn save_playback_conflict_policy(policy: qconnect_app::QconnectPlaybackConflictPolicy) { + let Some(conn) = open_qconnect_settings_conn() else { + return; + }; + let _ = conn.execute( + "INSERT OR REPLACE INTO settings (key, value) VALUES ('playback_conflict_policy', ?1)", + rusqlite::params![policy.as_str()], + ); +} + /// Load the last-known QConnect on/off state, if recorded. Same key/values as /// Tauri (`last_known_state` = "on" | "off"). pub fn load_last_known_state() -> Option { diff --git a/crates/qbz-qt/src/queue_qt.rs b/crates/qbz-qt/src/queue_qt.rs index 53e4af363..3e8ebb48b 100644 --- a/crates/qbz-qt/src/queue_qt.rs +++ b/crates/qbz-qt/src/queue_qt.rs @@ -830,13 +830,14 @@ async fn play_extended_upcoming( log::warn!("[qbz-qt] queue: upcoming changed during extended activation"); return; } - if crate::qconnect_qt::publish::is_connected() + if crate::qconnect_qt::queue_admission_enabled() && !crate::qconnect_qt::is_qconnect_queue_track(&track) { log::info!( "[qbz-qt] queue: skipped QConnect-incompatible upcoming row {}", track.id ); + crate::qconnect_qt::toast_unresolvable_tracks(1); return; } if let Some(service) = crate::qconnect_qt::service() { @@ -892,13 +893,14 @@ pub async fn play_extended( log::warn!("[qbz-qt] queue: history changed during activation"); return; } - if crate::qconnect_qt::publish::is_connected() + if crate::qconnect_qt::queue_admission_enabled() && !crate::qconnect_qt::is_qconnect_queue_track(&track) { log::info!( "[qbz-qt] queue: skipped QConnect-incompatible history row {}", track.id ); + crate::qconnect_qt::toast_unresolvable_tracks(1); return; } if let Some(service) = crate::qconnect_qt::service() { @@ -1224,13 +1226,17 @@ pub async fn drop_extended( log::warn!("[qbz-qt] queue: history changed during extended drag"); return; } - if crate::qconnect_qt::publish::is_connected() + // Admission follows enabled intent, including reconnect windows; + // otherwise a local history row can enter the queue while the + // transport is down and poison the next cloud sync. + if crate::qconnect_qt::queue_admission_enabled() && !crate::qconnect_qt::is_qconnect_queue_track(&track) { log::info!( "[qbz-qt] queue: skipped QConnect-incompatible history drag {}", track.id ); + crate::qconnect_qt::toast_unresolvable_tracks(1); return; } if let Some(service) = crate::qconnect_qt::service() { diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 27f12e130..4bf78eca8 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -1524,6 +1524,12 @@ const RETRY_BEHAVIOR_LABELS: &[&str] = const RETRY_BEHAVIOR_VALUES: &[&str] = &["ask", "always_fallback", "always_skip"]; const QCONNECT_STARTUP_LABELS: &[&str] = &["Remember state", "On by default", "Off by default"]; const QCONNECT_STARTUP_VALUES: &[&str] = &["remember_last", "on", "off"]; +const QCONNECT_CONFLICT_POLICY_LABELS: &[&str] = &[ + "Ask every time", + "Continue on the active renderer", + "Continue here with the Qobuz Connect queue", + "Continue local playback and replace the Qobuz Connect queue", +]; // Appearance option tables (AppearanceSettings.slint / ui_prefs.rs). const APP_BACKGROUND_LABELS: &[&str] = &["Off", "Ambient", "Blurred art"]; const APP_BACKGROUND_VALUES: &[&str] = &["off", "ambient", "blurred"]; @@ -1993,6 +1999,10 @@ pub struct SettingsDoc { pub qconnect_startup_modes: Vec, #[serde(rename = "qconnectStartupIndex")] pub qconnect_startup_index: i32, + #[serde(rename = "qconnectConflictPolicies")] + pub qconnect_conflict_policies: Vec, + #[serde(rename = "qconnectConflictPolicyIndex")] + pub qconnect_conflict_policy_index: i32, #[serde(rename = "qconnectDeviceName")] pub qconnect_device_name: String, #[serde(rename = "qconnectDeviceNameDefault")] @@ -2407,6 +2417,8 @@ pub async fn publish_snapshot() { let qconnect_startup = crate::qconnect_transport_qt::load_startup_mode() .as_str() .to_string(); + let qconnect_conflict_policy = + crate::qconnect_transport_qt::load_playback_conflict_policy(); let scrobble_snap = crate::integrations_qt::scrobble_settings(); let integ_ui = crate::integrations_qt::ui_snapshot(); let qconnect_startup_index = QCONNECT_STARTUP_VALUES @@ -2485,6 +2497,11 @@ pub async fn publish_snapshot() { .map(|l| qbz_i18n::t(l)) .collect(), qconnect_startup_index: qconnect_startup_index as i32, + qconnect_conflict_policies: QCONNECT_CONFLICT_POLICY_LABELS + .iter() + .map(|label| qbz_i18n::t(label)) + .collect(), + qconnect_conflict_policy_index: qconnect_conflict_policy.index(), qconnect_device_name: crate::qconnect_transport_qt::load_persisted_device_name() .unwrap_or_default(), qconnect_device_name_default: @@ -3888,6 +3905,18 @@ pub async fn settings_select(runtime: &Arc>, key: &st crate::qconnect_transport_qt::save_startup_mode(mode); } } + "qconnect-conflict-policy" => { + let Some(policy) = qconnect_app::QconnectPlaybackConflictPolicy::from_index(index) + else { + return; + }; + crate::qconnect_transport_qt::save_playback_conflict_policy(policy); + crate::qconnect_qt::publish::playback_conflict_policy_index(policy.index()); + log::info!( + "[QConnect] playback conflict policy changed to {}", + policy.as_str() + ); + } // --- Appearance (phase 19) ---------------------------------------- "app-background" => { let Some(mode) = APP_BACKGROUND_VALUES.get(index) else { diff --git a/crates/qbz-source/src/id.rs b/crates/qbz-source/src/id.rs index 9c54a5860..dce7611ed 100644 --- a/crates/qbz-source/src/id.rs +++ b/crates/qbz-source/src/id.rs @@ -51,6 +51,7 @@ impl SourceId { /// | word | where | → | /// |---|---|---| /// | `"qobuz"` | `QueueTrack.source`, `AlbumSource::Qobuz` | `QOBUZ` | + /// | `"qobuz_connect_remote"` | legacy persisted QConnect queue rows | `QOBUZ` | /// | `""` / absent | `local_tracks.source`, `HomeCard` (no field at all) | `None` | /// | `"local"`, `"user"` | `QueueTrack.source`, `LocalTrack.source` | `LOCAL` | /// | `"qobuz_download"`, `"qobuz_purchase"`, `"offline"` | `LocalTrack.source`, badges | `LOCAL` (an offline copy is a FILE; the distinction is a [`SourceBadge`]) | @@ -64,7 +65,7 @@ impl SourceId { /// is `myqbz_add_qt::source_from_str`'s bug (survey IC-6). pub fn from_word(w: &str) -> Option { match w.trim().to_ascii_lowercase().as_str() { - "qobuz" => Some(SourceId::QOBUZ), + "qobuz" | "qobuz_connect_remote" => Some(SourceId::QOBUZ), "plex" => Some(SourceId::PLEX), "jellyfin" => Some(SourceId::JELLYFIN), // The Subsonic FLAVOURS fold into one source. They speak one API @@ -346,6 +347,13 @@ mod tests { assert_eq!(r.badge, SourceBadge::Offline); } + #[test] + fn legacy_qconnect_queue_source_is_qobuz() { + let r = RawRef::new("qobuz_connect_remote", ItemKind::Track, "128659874"); + assert_eq!(r.source, Some(SourceId::QOBUZ)); + assert_eq!(r.badge, SourceBadge::Qobuz); + } + #[test] fn numeric_reads_the_id_on_both_paths() { // survey IC-3: the queue path holds a u64, the mixtape path holds the diff --git a/crates/qbz-source/src/meta.rs b/crates/qbz-source/src/meta.rs index d216eda20..1db082415 100644 --- a/crates/qbz-source/src/meta.rs +++ b/crates/qbz-source/src/meta.rs @@ -113,7 +113,7 @@ impl SourceBadge { Some("qobuz_download") | Some("qobuz_purchase") | Some("offline") => { SourceBadge::Offline } - Some("qobuz") => SourceBadge::Qobuz, + Some("qobuz") | Some("qobuz_connect_remote") => SourceBadge::Qobuz, _ => SourceBadge::Local, } } diff --git a/crates/qbzd/src/qconnect/publish.rs b/crates/qbzd/src/qconnect/publish.rs index 8dd474b71..4d8a6e790 100644 --- a/crates/qbzd/src/qconnect/publish.rs +++ b/crates/qbzd/src/qconnect/publish.rs @@ -13,10 +13,10 @@ //! The gates are the desktop's EXACT set, in the same order: live runtime -> //! `is_local_renderer_active` (a peer owns playback -> the peer publishes) -> //! offline-only skip -> non-empty -> echo-suppress vs the cloud's last-applied -//! queue -> per-session `last_pushed_queue_ids` latch -> all-or-nothing -//! admission (any local/Plex track refuses the WHOLE push; offline -//! qobuz_download stays eligible — its id is the real Qobuz id). The desktop -//! toasts on refusal; the daemon logs. +//! queue -> per-session `last_pushed_queue_ids` latch -> resolvable projection +//! (local/Plex tracks are dropped while Qobuz and offline `qobuz_download` +//! tracks stay eligible — the latter carry their real Qobuz id). The desktop +//! toasts when rows are dropped; the daemon logs. //! //! Trigger: the desktop calls it on every track transition from its poll loop. //! The daemon instead runs a debounced `CoreEvent::QueueUpdated` subscriber @@ -26,8 +26,11 @@ use std::sync::{Arc, Mutex as StdMutex}; use qbz_app::shell::AppRuntime; -use qbz_models::CoreEvent; -use qconnect_app::{is_local_renderer_active, QueueCommandType}; +use qbz_models::{CoreEvent, QueueTrack}; +use qconnect_app::{ + arm_local_queue_takeover, is_local_renderer_active, local_queue_takeover_needs_retry, + qconnect_queue_track_is_resolvable, set_local_playback_conflict_pending, QueueCommandType, +}; use serde_json::json; use tokio::sync::broadcast; use tokio::task::JoinHandle; @@ -48,6 +51,52 @@ pub async fn publish_local_queue_if_changed( authority: &AuthorityCell, expected_stamp: AuthorityStamp, ) { + let _ = publish_local_queue(inner, runtime, authority, expected_stamp, false).await; +} + +/// Takeover variant used when the daemon is already playing and no peer is. +/// It bypasses only the active-renderer echo gate; all authority, offline, and +/// provenance checks remain in force. +pub async fn publish_local_queue_for_takeover( + inner: &Arc>, + runtime: &Arc>, + authority: &AuthorityCell, + expected_stamp: AuthorityStamp, +) -> bool { + publish_local_queue(inner, runtime, authority, expected_stamp, true).await +} + +fn resolvable_queue_projection( + tracks: &[QueueTrack], + current_index: Option, +) -> (Vec, Option, usize) { + let clicked = current_index.unwrap_or(0); + let mut kept = Vec::with_capacity(tracks.len()); + let mut projected_start = None; + let mut dropped = 0; + for (index, track) in tracks.iter().enumerate() { + if !qconnect_queue_track_is_resolvable(track) { + dropped += 1; + continue; + } + if projected_start.is_none() && index >= clicked { + projected_start = Some(kept.len()); + } + kept.push(track.id); + } + if projected_start.is_none() && !kept.is_empty() { + projected_start = Some(kept.len() - 1); + } + (kept, projected_start, dropped) +} + +async fn publish_local_queue( + inner: &Arc>, + runtime: &Arc>, + authority: &AuthorityCell, + expected_stamp: AuthorityStamp, + force_takeover: bool, +) -> bool { let (app, sync_state, stamp) = { let guard = inner .lock() @@ -56,12 +105,12 @@ pub async fn publish_local_queue_if_changed( Some(rt) if rt.stamp == expected_stamp => { (Arc::clone(&rt.app), Arc::clone(&rt.sync_state), rt.stamp) } - None => return, - Some(_) => return, + None => return false, + Some(_) => return false, } }; if !authority.is_current(stamp) { - return; + return false; } // Only push while WE are the active renderer (the user is driving the @@ -69,10 +118,10 @@ pub async fn publish_local_queue_if_changed( { let state = sync_state.lock().await; if !authority.is_current(stamp) { - return; + return false; } - if !is_local_renderer_active(&state.session) { - return; + if !force_takeover && !is_local_renderer_active(&state.session) { + return false; } } @@ -81,24 +130,31 @@ pub async fn publish_local_queue_if_changed( // must not spam the log. if runtime.core().queue_is_offline_only() { log::debug!("[QConnect] queue is from an offline-only playlist; skipping cloud push"); - return; + return false; } let (tracks, current_index) = runtime.core().get_all_queue_tracks().await; if !authority.is_current(stamp) { - return; + return false; } if tracks.is_empty() { - return; + return false; } - let ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let source_ordered_ids: Vec = tracks.iter().map(|track| track.id).collect(); + let takeover_retry = { + let state = sync_state.lock().await; + if !authority.is_current(stamp) { + return false; + } + local_queue_takeover_needs_retry(&state) + }; // Echo-suppress: skip when this is the cloud's current queue (materialized // inbound) so our own adoption / a remote queue change never bounces back. - { + if !force_takeover && !takeover_retry { let state = sync_state.lock().await; if !authority.is_current(stamp) { - return; + return false; } if let Some(applied) = &state.last_applied_queue_state { let applied_ids: Vec = applied @@ -106,58 +162,48 @@ pub async fn publish_local_queue_if_changed( .iter() .map(|item| item.track_id) .collect(); - if applied_ids == ordered_ids { - return; + if applied_ids == source_ordered_ids { + return false; } } } // ...and skip when we already pushed this exact queue (cloud echo pending). if !authority.is_current(stamp) { - return; + return false; } - { + if !force_takeover && !takeover_retry { let guard = inner .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if !installed_runtime_matches(&guard, stamp) { - return; + return false; } - if guard.last_pushed_queue_ids.as_deref() == Some(ordered_ids.as_slice()) { - return; + if guard.last_pushed_queue_ids.as_deref() == Some(source_ordered_ids.as_slice()) { + return false; } } - // Admission: refuse the whole push if any track isn't Qobuz-castable. - let all_eligible = tracks.iter().all(|track| { - let source = track - .source - .as_deref() - .unwrap_or("qobuz") - .to_ascii_lowercase(); - source != "local" && source != "plex" && track.id > 0 - }); - if !all_eligible { - if !authority.is_current(stamp) { - return; - } - log::info!("[QConnect] Local queue has non-Qobuz tracks; not casting to Connect"); - // Remember it so we don't re-log on every queue event within this queue. - if !authority.is_current(stamp) { - return; - } + let (ordered_ids, projected_start, dropped) = + resolvable_queue_projection(&tracks, current_index); + if dropped > 0 { + log::info!( + "[QConnect] Queue projection skipped {dropped} non-Qobuz track(s) before cloud sync" + ); + } + if ordered_ids.is_empty() { let mut guard = inner .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if !installed_runtime_matches(&guard, stamp) { - return; + return false; } - guard.last_pushed_queue_ids = Some(ordered_ids); - return; + guard.last_pushed_queue_ids = Some(source_ordered_ids); + return false; } let count = ordered_ids.len(); let track_ids: Vec = ordered_ids.iter().map(|id| *id as i64).collect(); - let start_index = current_index.unwrap_or(0); + let start_index = projected_start.unwrap_or(0); let payload = json!({ "track_ids": track_ids, "queue_position": start_index, @@ -171,31 +217,41 @@ pub async fn publish_local_queue_if_changed( .build_queue_command(QueueCommandType::CtrlSrvrQueueLoadTracks, payload) .await; if !authority.is_current(stamp) { - return; + return false; } match app.send_queue_command(command).await { - Ok(_) => { + Ok(action_uuid) => { if !authority.is_current(stamp) { - return; + return false; + } + if force_takeover || takeover_retry { + let mut state = sync_state.lock().await; + if !authority.is_current(stamp) { + return false; + } + arm_local_queue_takeover(&mut state, ordered_ids, action_uuid); + set_local_playback_conflict_pending(&mut state, false); } log::info!( "[QConnect] Pushed local queue to Connect ({count} tracks, start={start_index})" ); if !authority.is_current(stamp) { - return; + return false; } let mut guard = inner .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if !installed_runtime_matches(&guard, stamp) { - return; + return false; } - guard.last_pushed_queue_ids = Some(ordered_ids); + guard.last_pushed_queue_ids = Some(source_ordered_ids); + true } Err(err) if authority.is_current(stamp) => { - log::warn!("[QConnect] Failed to push local queue: {err}") + log::warn!("[QConnect] Failed to push local queue: {err}"); + false } - Err(_) => {} + Err(_) => false, } } diff --git a/crates/qbzd/src/qconnect/report.rs b/crates/qbzd/src/qconnect/report.rs index c1d6ce78e..6e61f1e6d 100644 --- a/crates/qbzd/src/qconnect/report.rs +++ b/crates/qbzd/src/qconnect/report.rs @@ -19,9 +19,9 @@ use std::sync::{Arc, Mutex as StdMutex}; use qbz_app::shell::AppRuntime; use qbz_player::player::PlaybackBufferState; use qconnect_app::{ - build_renderer_playback_report, is_local_renderer_active, qconnect_report_track_id, - renderer_playing_state, QconnectFileAudioQualitySnapshot, QconnectRemoteSyncState, - RendererPlaybackSnapshot, + build_renderer_playback_report, confirm_local_playback_state_asserted, + is_local_renderer_active, qconnect_report_track_id, renderer_playing_state, + QconnectFileAudioQualitySnapshot, QconnectRemoteSyncState, RendererPlaybackSnapshot, }; use tokio::sync::Mutex; use uuid::Uuid; @@ -96,11 +96,20 @@ pub async fn report_playback_state( if !authority.is_current(stamp) { return; } - if let Err(err) = app.send_renderer_report_command(report).await { - if !authority.is_current(stamp) { - return; + match app.send_renderer_report_command(report).await { + Ok(()) if authority.is_current(stamp) => { + let mut state = sync_state.lock().await; + if authority.is_current(stamp) { + confirm_local_playback_state_asserted(&mut state); + } + } + Ok(()) => return, + Err(err) => { + if !authority.is_current(stamp) { + return; + } + log::warn!("[QConnect] Failed to report playback state: {err}"); } - log::warn!("[QConnect] Failed to report playback state: {err}"); } if position_ms >= 0 && authority.is_current(stamp) { diff --git a/crates/qbzd/src/qconnect/session.rs b/crates/qbzd/src/qconnect/session.rs index 3dcc48bf0..e83763d25 100644 --- a/crates/qbzd/src/qconnect/session.rs +++ b/crates/qbzd/src/qconnect/session.rs @@ -68,6 +68,29 @@ pub struct DaemonSessionLoopHost { #[async_trait::async_trait] impl SessionLoopHost for DaemonSessionLoopHost { + fn local_playback_is_playing(&self) -> bool { + self.authority.is_current(self.stamp) && self.runtime.core().get_playback_state().is_playing + } + + async fn publish_local_queue_for_takeover(&self) -> bool { + super::publish::publish_local_queue_for_takeover( + &self.inner, + &self.runtime, + &self.authority, + self.stamp, + ) + .await + } + + async fn stop_local_playback_for_remote_queue(&self) { + if !self.authority.is_current(self.stamp) { + return; + } + if let Err(error) = self.runtime.core().stop() { + log::warn!("[QConnect] Failed to stop daemon playback for remote queue: {error}"); + } + } + async fn update_lifecycle(&self, state: QconnectLifecycleState) { if !self.authority.is_current(self.stamp) { return; diff --git a/crates/qbzd/src/qconnect/sink.rs b/crates/qbzd/src/qconnect/sink.rs index 9cd4747f0..ea8a3d3ee 100644 --- a/crates/qbzd/src/qconnect/sink.rs +++ b/crates/qbzd/src/qconnect/sink.rs @@ -21,9 +21,11 @@ use std::sync::{Arc, OnceLock, Weak}; use async_trait::async_trait; use qconnect_app::{ - build_session_renderer_snapshot, cache_renderer_snapshot, is_peer_renderer_active, QconnectApp, - QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState, QconnectRendererEngine, - RendererBufferState, RendererCommand, RendererReport, RendererReportType, + active_peer_renderer_is_playing, build_session_renderer_snapshot, cache_renderer_snapshot, + is_peer_renderer_active, remote_renderer_commands_are_fenced, should_materialize_remote_queue, + QconnectApp, QconnectAppEvent, QconnectEventSink, QconnectRemoteSyncState, + QconnectRendererEngine, RendererBufferState, RendererCommand, RendererReport, + RendererReportType, }; use qconnect_transport_ws::NativeWsTransport; use serde_json::Value; @@ -224,7 +226,11 @@ impl DaemonEventSink { let was_peer_active = self .last_peer_active .swap(peer_active_now, std::sync::atomic::Ordering::Relaxed); - if peer_active_now && !was_peer_active { + let conflict_pending = { + let state = self.sync_state.lock().await; + state.local_playback_conflict_pending + }; + if peer_active_now && !was_peer_active && !conflict_pending { if !self.is_current() { return; } @@ -239,21 +245,24 @@ impl DaemonEventSink { } } - /// When an active PEER renderer now owns playback, stop our local playback so - /// the two don't double-play. Mirrors the Tauri helper, with the engine seam - /// in place of `CoreBridge`. + /// When the active PEER renderer is actually playing, stop our local playback + /// so the two don't double-play. A paused/stale peer must not interrupt QBZ. async fn sync_local_playback_for_renderer_ownership(&self) { if !self.is_current() { return; } - let peer_renderer_active = { + let peer_renderer_playing = { let state = self.sync_state.lock().await; if !self.is_current() { return; } - is_peer_renderer_active(&state.session) + if state.local_playback_conflict_pending { + false + } else { + active_peer_renderer_is_playing(&state) + } }; - if !peer_renderer_active { + if !peer_renderer_playing { return; } @@ -268,7 +277,7 @@ impl DaemonEventSink { } log::info!( - "[QConnect] Stopping local playback because active renderer is a peer (track_id={})", + "[QConnect] Stopping local playback because the active peer is playing (track_id={})", playback_state.track_id ); if let Err(err) = self.engine.stop() { @@ -395,12 +404,31 @@ impl QconnectEventSink for DaemonEventSink { queue_state.version.major, queue_state.version.minor, ); - { + let (should_materialize, accepted_local_echo) = { let mut sync_state = self.sync_state.lock().await; if !self.is_current() { return; } sync_state.last_remote_queue_state = Some(queue_state.clone()); + let had_local_takeover = sync_state.pending_local_queue_takeover.is_some(); + let should_materialize = + should_materialize_remote_queue(&mut sync_state, queue_state); + let accepted_local_echo = had_local_takeover + && sync_state.pending_local_queue_takeover.is_none() + && sync_state.local_playback_state_assertion_pending; + (should_materialize, accepted_local_echo) + }; + if !should_materialize { + if accepted_local_echo { + log::info!( + "[QConnect] Local queue accepted by Connect; preserving live player cursor" + ); + } else { + log::debug!( + "[QConnect] Ignoring stale remote queue while local-playing takeover settles" + ); + } + return; } if !self.is_current() { return; @@ -419,6 +447,16 @@ impl QconnectEventSink for DaemonEventSink { } } QconnectAppEvent::RendererCommandApplied { command, state } => { + let fenced = { + let sync_state = self.sync_state.lock().await; + remote_renderer_commands_are_fenced(&sync_state) + }; + if fenced { + log::info!( + "[QConnect] Ignoring stale renderer command while local queue authority settles" + ); + return; + } log::info!( "[QConnect] Renderer command applied: {}", renderer_command_name(command) diff --git a/crates/qconnect-app/src/app.rs b/crates/qconnect-app/src/app.rs index e916d8a8a..130f52b2c 100644 --- a/crates/qconnect-app/src/app.rs +++ b/crates/qconnect-app/src/app.rs @@ -180,6 +180,10 @@ where command.command_type, QueueCommandType::CtrlSrvrSetActiveRenderer ); + let is_queue_load_tracks_action = matches!( + command.command_type, + QueueCommandType::CtrlSrvrQueueLoadTracks + ); let pending = PendingQueueAction { uuid: action_uuid.clone(), queue_version_ref: command.queue_version_ref, @@ -201,6 +205,7 @@ where QueueCommandType::CtrlSrvrSetVolume ), is_set_active_renderer_action, + is_queue_load_tracks_action, expected_active_renderer_id: if is_set_active_renderer_action { pending_active_renderer_id_from_payload(&command.payload) } else { @@ -458,6 +463,10 @@ where }; if let Some(uuid) = completed_uuid { + { + let mut sync = self.sync.lock().await; + crate::confirm_local_queue_takeover_action(&mut sync, &uuid); + } self.sink .on_event(QconnectAppEvent::PendingActionCompleted { uuid }) .await; @@ -492,6 +501,21 @@ where state.concurrency_canceled_action_uuid = canceled_uuid.clone(); should_trigger_resync = canceled_uuid.is_some(); } + PendingCorrelation::EventWithoutActionUuid + if matches!(event.event_type, QueueEventType::SrvrCtrlQueueErrorMessage) + && state + .pending + .current() + .map(|pending| pending.is_queue_load_tracks_action) + .unwrap_or(false) => + { + // Queue-load rejections are emitted without the action UUID + // on some server paths (notably version mismatch). Keeping + // the slot occupied until the generic timeout prevents the + // authoritative local queue from being retried. + canceled_uuid = state.pending.clear().map(|pending| pending.uuid); + should_trigger_resync = canceled_uuid.is_some(); + } PendingCorrelation::NoPending | PendingCorrelation::EventWithoutActionUuid => {} } @@ -531,12 +555,20 @@ where } if let Some(uuid) = completed_uuid { + { + let mut sync = self.sync.lock().await; + crate::confirm_local_queue_takeover_action(&mut sync, &uuid); + } self.sink .on_event(QconnectAppEvent::PendingActionCompleted { uuid }) .await; } if let Some(pending_uuid) = canceled_uuid { + { + let mut sync = self.sync.lock().await; + crate::reject_local_queue_takeover_action(&mut sync, &pending_uuid); + } self.sink .on_event( QconnectAppEvent::PendingActionCanceledByConcurrentRemoteEvent { @@ -573,6 +605,22 @@ where return Ok(()); }; + // Authority fencing belongs before the app's renderer reducer. The Qt + // sink also guards the audio engine, but by then a stale command has + // already mutated this snapshot and `send_renderer_reports` can echo it + // back to the cloud, creating a feedback storm. During local takeover + // or conflict resolution the remote renderer state has no authority at + // either layer. + { + let sync = self.sync.lock().await; + if crate::remote_renderer_commands_are_fenced(&sync) { + log::debug!( + "[QConnect] Ignoring renderer command before reducer while local authority settles" + ); + return Ok(()); + } + } + // Detect echo SET_STATE commands: the server echoes every state report // as a SET_STATE with only next_track (playing_state=None, // current_track=None, current_position_ms=None). These echoes must @@ -662,6 +710,11 @@ where return; } + { + let mut sync = self.sync.lock().await; + crate::reject_local_queue_takeover_action(&mut sync, &action_uuid); + } + self.sink .on_event(QconnectAppEvent::PendingActionTimedOut { uuid: action_uuid, @@ -1189,7 +1242,7 @@ fn session_management_event_completes_pending_action( pub struct SessionApplyOutcome { /// Active renderer whose remote projection should be re-aligned into CoreBridge. pub remote_projection_renderer_id: Option, - /// Stop local playback because an active peer renderer now owns playback. + /// Re-evaluate local playback because peer renderer state or ownership changed. pub sync_local_playback: bool, /// Apply this remote loop mode to CoreBridge. pub apply_loop_mode: Option, @@ -1246,13 +1299,32 @@ where state.session.active_renderer_id = normalize_active_renderer_id( payload.get("active_renderer_id").and_then(Value::as_i64), ); + // SESSION_STATE carries the authoritative playing state of its + // active renderer at the top level. Cache it before the event + // sink decides whether local playback must yield. Without this, + // a fresh connection treated a paused peer as unknown and the + // takeover classifier reused an unrelated renderer snapshot. + if let (Some(active_renderer_id), Some(playing_state)) = ( + state.session.active_renderer_id, + payload + .get("playing_state") + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()), + ) { + let renderer_state = + ensure_session_renderer_state(&mut state, active_renderer_id); + renderer_state.playing_state = Some(playing_state); + renderer_state.updated_at_ms = now_ms(); + } if let Some(loop_mode) = payload .get("loop_mode") .and_then(Value::as_i64) .and_then(|value| i32::try_from(value).ok()) { - state.session_loop_mode = Some(loop_mode); - apply_loop_mode = Some(loop_mode); + if state.session_loop_mode != Some(loop_mode) { + state.session_loop_mode = Some(loop_mode); + apply_loop_mode = Some(loop_mode); + } } if let (Some(active_renderer_id), Some(loop_mode)) = (state.session.active_renderer_id, state.session_loop_mode) @@ -1376,9 +1448,17 @@ where } } "MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED" => { + let local_was_active = is_local_renderer_active(&state.session); state.session.active_renderer_id = normalize_active_renderer_id( payload.get("active_renderer_id").and_then(Value::as_i64), ); + if local_was_active && is_peer_renderer_active(&state.session) { + // A real local -> peer handoff supersedes any unfinished + // local queue publication. Never carry its ids/action uuid + // into a later takeback in the same runtime. + crate::clear_local_queue_takeover(&mut state); + crate::set_local_playback_conflict_pending(&mut state, false); + } if let (Some(active_renderer_id), Some(loop_mode)) = (state.session.active_renderer_id, state.session_loop_mode) { @@ -1506,8 +1586,10 @@ where else { return SessionApplyOutcome::default(); }; - state.session_loop_mode = Some(loop_mode); - apply_loop_mode = Some(loop_mode); + if state.session_loop_mode != Some(loop_mode) { + state.session_loop_mode = Some(loop_mode); + apply_loop_mode = Some(loop_mode); + } if let Some(active_renderer_id) = state.session.active_renderer_id { let renderer_state = ensure_session_renderer_state(&mut state, active_renderer_id); @@ -1637,6 +1719,18 @@ pub struct SessionStateTakeoverInput { pub was_active: bool, pub was_playing: bool, pub server: ServerActiveState, + pub active_renderer_id: Option, +} + +/// Explicit user decision when local audio and a peer renderer are both live +/// during QConnect bootstrap. The numeric ordering belongs to the Qt modal; +/// this enum keeps the control flow frontend-independent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LocalPlaybackConflictChoice { + ContinueOnActiveRenderer, + ContinueOnThisDevice, + ContinueLocalPlaybackAndReplaceQueue, + CancelConnection, } /// Preview the first 8 `track_id`s under `payload[key]` for diagnostic emits. @@ -1660,19 +1754,19 @@ where TTransport: WsTransport + 'static, TSink: QconnectEventSink + 'static, { - /// Capture `was_active`/`was_playing` from the current (pre-apply) sync state - /// and classify the server's active-renderer state from the incoming - /// SESSION_STATE payload, relative to our local renderer id. Pure-ish read; - /// takes no decision. Relocated from the Tauri adapter (slice 5); locks - /// `self.sync` (the same accumulator the apply path mutates). + /// Capture prior renderer ownership and classify the incoming server state. + /// `local_playback_is_playing` comes from the adapter's real player, not a + /// remote-renderer cache; that distinction is the local-playing takeover + /// rule's source of truth. pub async fn capture_session_state_takeover_input( &self, payload: &Value, + local_playback_is_playing: bool, ) -> SessionStateTakeoverInput { let st = self.sync.lock().await; let local_id = st.session.local_renderer_id; let was_active = is_local_renderer_active(&st.session); - let was_playing = st.last_renderer_playing_state == Some(PLAYING_STATE_PLAYING); + let was_playing = local_playback_is_playing; // Normalize identically to the apply path (`normalize_active_renderer_id`): // the cloud encodes "no active renderer" as `active_renderer_id: -1`, so a // raw parse would classify -1 as `Some(-1)` and fall into the `Some(_)` @@ -1680,13 +1774,25 @@ where // -1 (and any negative sentinel) to None, the genuine "session idle" state. let incoming_active = normalize_active_renderer_id(payload.get("active_renderer_id").and_then(Value::as_i64)); + let incoming_playing_state = payload + .get("playing_state") + .and_then(Value::as_i64) + .and_then(|value| i32::try_from(value).ok()) + .or_else(|| { + incoming_active.and_then(|renderer_id| { + st.session_renderer_states + .get(&renderer_id) + .and_then(|renderer| renderer.playing_state) + }) + }); let server = match incoming_active { None => ServerActiveState::None, Some(id) if Some(id) == local_id => ServerActiveState::Me, Some(_) => { - // Another renderer owns the slot. Classify playing vs paused from - // the last observed peer playing_state; default OtherPaused when unknown. - if st.last_renderer_playing_state == Some(PLAYING_STATE_PLAYING) { + // The incoming active renderer's own state is authoritative. + // Unknown is conservative for continuity: it is not evidence + // that another renderer is actually playing. + if incoming_playing_state == Some(PLAYING_STATE_PLAYING) { ServerActiveState::OtherPlaying } else { ServerActiveState::OtherPaused @@ -1697,9 +1803,18 @@ where was_active, was_playing, server, + active_renderer_id: incoming_active, } } + /// Runtime-only authority fences must not survive an explicit cancel or a + /// failed conflict resolution into the next connection attempt. + async fn clear_local_playback_authority_fences(&self) { + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending(&mut sync, false); + } + /// Clear the pending action slot iff it still matches `action_uuid`. Used for /// replies that are not reducer-correlated (set-active, ask-for-state). Locks /// `self.state` (NEVER co-locked with `self.sync`). Relocated from the Tauri @@ -1732,6 +1847,17 @@ where return Ok(false); } } + { + let mut state = self.state.lock().await; + let superseded_transport = state + .pending + .current() + .map(|pending| pending.is_transport_control_action) + .unwrap_or(false); + if superseded_transport { + state.pending.clear(); + } + } let payload = serde_json::json!({ "renderer_id": target_renderer_id }); let command = self .build_queue_command(QueueCommandType::CtrlSrvrSetActiveRenderer, payload) @@ -1799,6 +1925,39 @@ const QCONNECT_REASK_QUEUE_STATE_MAX_ATTEMPTS: u32 = 5; /// surface. Both the Tauri adapter and a future Slint adapter implement this. #[async_trait] pub trait SessionLoopHost: Send + Sync { + /// Synchronous snapshot of the adapter's actual local player. + fn local_playback_is_playing(&self) -> bool { + false + } + /// Publish the resolvable projection of the local queue during a + /// local-playing takeover. Returns true only when a non-empty queue command + /// was accepted by the transport. + async fn publish_local_queue_for_takeover(&self) -> bool { + false + } + /// Ask the frontend which side wins when a peer renderer and local audio + /// are both active during bootstrap. Headless adapters retain the previous + /// server-authoritative behavior by default. + async fn resolve_local_playback_conflict( + &self, + _active_renderer_id: i32, + peer_was_playing: bool, + ) -> LocalPlaybackConflictChoice { + if peer_was_playing { + LocalPlaybackConflictChoice::ContinueOnActiveRenderer + } else { + // Preserve the pre-modal headless policy: a paused/stale peer does + // not interrupt real local playback. + LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue + } + } + /// Stop local audio before deliberately adopting the remote queue. + async fn stop_local_playback_for_remote_queue(&self) {} + /// Resume a paused active peer when option 1 explicitly asks to continue it. + async fn continue_active_renderer_playback(&self) {} + /// Tear down QConnect without applying the remote queue or disturbing the + /// current local queue/playback. + async fn cancel_connection_for_playback_conflict(&self) {} /// Surface a lifecycle transition. The Tauri adapter gates + dedups this /// (emits `qconnect:status_changed` via the sink only while a runtime is /// still alive); a Slint adapter does its own. @@ -1869,6 +2028,18 @@ where // broadcast has no replay — subscribing here, inside the spawned task, // would race those events and silently drop them. log::info!("[QConnect/EventLoop] Started listening for transport events"); + { + // A previous runtime may have ended through the modal's Cancel + // path before its queued Disconnected event was observed. Runtime + // fences never carry authority into a new connection. If real + // local audio is already playing, however, fence bootstrap traffic + // until SESSION_STATE + the authoritative QueueState establish + // which side may mutate it. + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending(&mut sync, host.local_playback_is_playing()); + } + let mut authoritative_queue_state_received = false; let mut renderer_joined = false; let mut has_disconnected = false; // One-shot latch: auto-take the render at most ONCE per fresh connect, on @@ -1883,6 +2054,19 @@ where // the SET_ACTIVE as soon as the id arrives — and ONLY if nobody else has // become active meanwhile (anti-steal). Reset on disconnect. let mut pending_auto_take = false; + // A local-playing takeover may be decided before the renderer ADD echo + // gives us our local id. Keep that decision until the id arrives, but + // cancel it immediately if local playback stops or the active peer + // reports PLAYING in the meantime. + let mut pending_local_takeover = false; + let mut pending_local_takeover_is_explicit = false; + // Option 2 can be chosen before ADD_RENDERER assigns our local id. + // Keep remote-queue authority fenced until that id arrives and we can + // claim this device intentionally. + let mut pending_remote_queue_takeover = false; + // One explicit choice per runtime. Reconnect frames in the same runtime + // must not reopen the modal after the user already chose an authority. + let mut playback_conflict_resolved = false; // P1-8: budget for re-AskForQueueState after Lagged broadcast drops, // until the session_uuid is confirmed. Reset on disconnect. let mut lagged_reask_attempts: u32 = 0; @@ -1893,6 +2077,11 @@ where }; match next_event { Ok(event) => { + let received_authoritative_queue_state = matches!( + &event, + TransportEvent::InboundQueueServerEvent(event) + if event.event_type == QueueEventType::SrvrCtrlQueueState + ); // P1-3: on every SESSION_STATE, capture our prior active/playing // state + classify the server's active renderer BEFORE the sink // applies the new state, so takeover arbitration runs on the @@ -1900,10 +2089,24 @@ where let mut takeover_input: Option = None; if let TransportEvent::InboundQueueServerEvent(ref evt) = event { if evt.message_type() == "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE" { - takeover_input = Some( - self.capture_session_state_takeover_input(&evt.payload) - .await, - ); + let input = self + .capture_session_state_takeover_input( + &evt.payload, + host.local_playback_is_playing(), + ) + .await; + if !playback_conflict_resolved + && input.was_playing + && matches!( + input.server, + ServerActiveState::OtherPlaying + | ServerActiveState::OtherPaused + ) + { + let mut sync = self.sync.lock().await; + crate::set_local_playback_conflict_pending(&mut sync, true); + } + takeover_input = Some(input); if !renderer_joined { if let Some(session_uuid) = evt.payload.get("session_uuid").and_then(|v| v.as_str()) @@ -1932,7 +2135,19 @@ where has_disconnected = true; auto_take_attempted = false; pending_auto_take = false; + pending_local_takeover = false; + pending_local_takeover_is_explicit = false; + pending_remote_queue_takeover = false; + authoritative_queue_state_received = false; lagged_reask_attempts = 0; + { + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending( + &mut sync, + host.local_playback_is_playing(), + ); + } // Surface "Reconnecting" to the UI, but only if we're // not in a teardown path (Off/Exhausted). The host gates // this on a live runtime. @@ -2108,10 +2323,108 @@ where log::error!("{message}"); host.on_loop_error(message).await; } + if received_authoritative_queue_state { + authoritative_queue_state_received = true; + } + if let Some(input) = takeover_input { + let has_playback_conflict = !playback_conflict_resolved + && input.was_playing + && matches!( + input.server, + ServerActiveState::OtherPlaying | ServerActiveState::OtherPaused + ); + if has_playback_conflict { + // SESSION_STATE itself proves the session is ready. + // Surface Connected now so a user may consider the + // modal without racing the adapter's bootstrap timeout; + // all destructive follow-up events remain queued. + host.update_lifecycle(QconnectLifecycleState::Connected) + .await; + let choice = host + .resolve_local_playback_conflict( + input.active_renderer_id.unwrap_or(-1), + matches!(input.server, ServerActiveState::OtherPlaying), + ) + .await; + playback_conflict_resolved = true; + match choice { + LocalPlaybackConflictChoice::ContinueOnActiveRenderer => { + { + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending( + &mut sync, false, + ); + } + // The initial QueueState may already have + // been consumed while local playback was + // fenced. Pull it again now that the user + // explicitly chose remote authority. + if let Err(error) = self.ask_for_queue_state().await { + log::warn!( + "[QConnect] remote queue refresh after peer choice failed: {error}" + ); + } + host.continue_active_renderer_playback().await; + host.stop_local_playback_for_remote_queue().await; + } + LocalPlaybackConflictChoice::ContinueOnThisDevice => { + let local_id = { + let sync = self.sync.lock().await; + sync.session.local_renderer_id + }; + if authoritative_queue_state_received && local_id.is_some() { + let local_id = local_id.expect("checked local renderer id"); + if let Err(error) = + self.send_set_active_renderer(local_id).await + { + log::warn!( + "[QConnect] remote-queue local takeover failed: {error}" + ); + self.clear_local_playback_authority_fences().await; + host.cancel_connection_for_playback_conflict().await; + break; + } + host.stop_local_playback_for_remote_queue().await; + { + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending( + &mut sync, false, + ); + } + if let Err(error) = self.ask_for_queue_state().await { + log::warn!( + "[QConnect] remote queue refresh after local claim failed: {error}" + ); + } + } else { + pending_remote_queue_takeover = true; + } + } + LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue => { + // SET_ACTIVE_RENDERER is the handoff: the + // previously-active peer is paused by the + // session when QBZ becomes active. Sending a + // separate STOP first occupies the pending + // action slot and races this takeover. + pending_local_takeover = true; + pending_local_takeover_is_explicit = true; + } + LocalPlaybackConflictChoice::CancelConnection => { + self.clear_local_playback_authority_fences().await; + host.cancel_connection_for_playback_conflict().await; + break; + } + } + // The explicit choice supersedes the generic matrix. + takeover_input = None; + } + } // P1-3: now that the sink has applied the SESSION_STATE, run // takeover arbitration on the prior snapshot captured above. - // Only the claim-active path is wired here; never resets - // reconnect counters (#358 latch untouched). + // Claim-active and local-queue publication are wired here; + // neither resets reconnect counters (#358 latch untouched). if let Some(input) = takeover_input { // Auto-take the render only on a FRESH connect (never a // reconnect — that rejoins active via the RECONNECTION join @@ -2133,22 +2446,37 @@ where false, auto_take_when_idle, ); - if decision.should_set_active_renderer { + if decision.should_set_queue { + // QueueLoadTracks is versioned. Do not publish from + // SESSION_STATE before the initial QueueState lands: + // that races with a stale version and the server + // rejects the action. Keep all remote queue/renderer + // effects fenced while the baseline is pending. + { + let mut sync = self.sync.lock().await; + crate::set_local_playback_conflict_pending(&mut sync, true); + } + pending_local_takeover = true; + pending_local_takeover_is_explicit = false; + } + if decision.should_set_active_renderer && !decision.should_set_queue { let local_id = { let st = self.sync.lock().await; st.session.local_renderer_id }; match local_id { Some(local_id) => { - // Latch ONLY on an actual send (reconnect - // re-claim, or the idle auto-take when our - // ADD_RENDERER already landed). - auto_take_attempted = true; - if let Err(err) = self.send_set_active_renderer(local_id).await - { - log::warn!( - "[QConnect] takeover set_active_renderer failed: {err}" - ); + match self.send_set_active_renderer(local_id).await { + Ok(_) => { + // A no-op means the cloud already + // agrees that we are active. + auto_take_attempted = true; + } + Err(err) => { + log::warn!( + "[QConnect] takeover set_active_renderer failed: {err}" + ); + } } } None if auto_take_when_idle => { @@ -2163,6 +2491,77 @@ where } } + if pending_local_takeover { + let (local_id, active_peer_playing) = { + let st = self.sync.lock().await; + ( + st.session.local_renderer_id, + crate::active_peer_renderer_is_playing(&st), + ) + }; + if !host.local_playback_is_playing() + || (active_peer_playing && !pending_local_takeover_is_explicit) + { + pending_local_takeover = false; + pending_local_takeover_is_explicit = false; + self.clear_local_playback_authority_fences().await; + } else if authoritative_queue_state_received && local_id.is_some() { + let local_id = local_id.expect("checked local renderer id"); + let explicit = pending_local_takeover_is_explicit; + log::info!( + "[QConnect] local playback is authoritative; claiming renderer and publishing queue (local_id={local_id})" + ); + if let Err(err) = self.send_set_active_renderer(local_id).await { + log::warn!( + "[QConnect] local-playing takeover is waiting for the pending action to clear: {err}" + ); + } else if !host.publish_local_queue_for_takeover().await { + log::warn!( + "[QConnect] deferred local-playing takeover had no resolvable queue to publish" + ); + pending_local_takeover = false; + pending_local_takeover_is_explicit = false; + if explicit { + self.clear_local_playback_authority_fences().await; + } + } else { + pending_local_takeover = false; + pending_local_takeover_is_explicit = false; + auto_take_attempted = true; + } + } + } + + if pending_remote_queue_takeover { + let local_id = { + let state = self.sync.lock().await; + state.session.local_renderer_id + }; + if authoritative_queue_state_received && local_id.is_some() { + let local_id = local_id.expect("checked local renderer id"); + pending_remote_queue_takeover = false; + if let Err(error) = self.send_set_active_renderer(local_id).await { + log::warn!( + "[QConnect] deferred remote-queue local takeover failed: {error}" + ); + self.clear_local_playback_authority_fences().await; + host.cancel_connection_for_playback_conflict().await; + break; + } + host.stop_local_playback_for_remote_queue().await; + { + let mut sync = self.sync.lock().await; + crate::clear_local_queue_takeover(&mut sync); + crate::set_local_playback_conflict_pending(&mut sync, false); + } + if let Err(error) = self.ask_for_queue_state().await { + log::warn!( + "[QConnect] deferred remote queue refresh failed: {error}" + ); + } + } + } + // Fire a deferred idle auto-take as soon as our // local_renderer_id lands — but ONLY while the session is still // idle (no active renderer), so we never steal a peer that @@ -2223,12 +2622,16 @@ where #[cfg(test)] mod tests { use std::collections::VecDeque; - use std::sync::Arc; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; use async_trait::async_trait; use qconnect_core::{QueueEvent, QueueVersion}; - use qconnect_transport_ws::InMemoryWsTransport; + use qconnect_transport_ws::{InMemoryWsTransport, TransportEvent}; + use crate::renderer::PLAYING_STATE_PLAYING; use crate::session::{ LocalIdentity, QconnectLifecycleState, QconnectRendererInfo, ServerActiveState, }; @@ -2267,10 +2670,39 @@ mod tests { #[derive(Default)] struct TestSessionLoopHost { lifecycles: Mutex>, + local_playing: bool, + published_local_queues: AtomicUsize, + conflict_choice: Option, + cancelled_connections: AtomicUsize, } #[async_trait] impl SessionLoopHost for TestSessionLoopHost { + fn local_playback_is_playing(&self) -> bool { + self.local_playing + } + + async fn publish_local_queue_for_takeover(&self) -> bool { + self.published_local_queues.fetch_add(1, Ordering::SeqCst); + true + } + + async fn resolve_local_playback_conflict( + &self, + _active_renderer_id: i32, + peer_was_playing: bool, + ) -> super::LocalPlaybackConflictChoice { + self.conflict_choice.unwrap_or(if peer_was_playing { + super::LocalPlaybackConflictChoice::ContinueOnActiveRenderer + } else { + super::LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue + }) + } + + async fn cancel_connection_for_playback_conflict(&self) { + self.cancelled_connections.fetch_add(1, Ordering::SeqCst); + } + async fn update_lifecycle(&self, state: QconnectLifecycleState) { self.lifecycles.lock().await.push(state); } @@ -2416,6 +2848,169 @@ mod tests { ); } + #[tokio::test] + async fn local_playing_takeover_waits_for_authoritative_queue_version() { + let transport = Arc::new(InMemoryWsTransport::new()); + let sink = TestSink::default(); + let app = QconnectApp::new( + transport, + Arc::new(sink), + Arc::new(Mutex::new(QconnectRemoteSyncState::default())), + ); + { + let sync = app.sync_handle(); + let mut state = sync.lock().await; + state.session.local_renderer_id = Some(1); + state.session.active_renderer_id = Some(1); + } + + let session_state = TransportEvent::InboundQueueServerEvent(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlSessionState, + action_uuid: None, + queue_version: Some(QueueVersion::new(4, 1)), + payload: json!({ + "session_uuid": "session-1", + "active_renderer_id": 1, + "playing_state": PLAYING_STATE_PLAYING, + }), + }); + + // SESSION_STATE alone must not publish with the default/stale version. + let (events_tx, events_rx) = tokio::sync::broadcast::channel(4); + drop(events_tx); + let host = Arc::new(TestSessionLoopHost { + local_playing: true, + ..Default::default() + }); + app.run_session_loop_with_prefetched( + host.clone(), + events_rx, + false, + VecDeque::from([session_state.clone()]), + ) + .await; + assert_eq!(host.published_local_queues.load(Ordering::SeqCst), 0); + + // Once QueueState has updated qconnect-app's version, the deferred + // takeover may publish exactly once. + let (events_tx, events_rx) = tokio::sync::broadcast::channel(4); + drop(events_tx); + let host = Arc::new(TestSessionLoopHost { + local_playing: true, + ..Default::default() + }); + let queue_state = TransportEvent::InboundQueueServerEvent(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlQueueState, + action_uuid: None, + queue_version: Some(QueueVersion::new(4, 1)), + payload: json!({ "tracks": [], "shuffle_mode": false }), + }); + app.run_session_loop_with_prefetched( + host.clone(), + events_rx, + false, + VecDeque::from([session_state, queue_state]), + ) + .await; + assert_eq!(host.published_local_queues.load(Ordering::SeqCst), 1); + assert_eq!( + app.queue_state_snapshot().await.version, + QueueVersion::new(4, 1) + ); + } + + #[tokio::test] + async fn explicit_local_queue_takeover_retries_without_disconnecting() { + let transport = Arc::new(InMemoryWsTransport::new()); + let sink = TestSink::default(); + let app = QconnectApp::new( + Arc::clone(&transport), + Arc::new(sink), + Arc::new(Mutex::new(QconnectRemoteSyncState::default())), + ); + let _transport_events = app.subscribe_transport_events(); + app.connect(test_config()).await.expect("connect transport"); + { + let sync = app.sync_handle(); + let mut state = sync.lock().await; + state.session.local_renderer_id = Some(3); + state.session.active_renderer_id = Some(7); + } + + // Reproduce the field failure: another queue action occupies the slot + // when option 3 first tries to claim this renderer. + let existing = app + .build_queue_command( + QueueCommandType::CtrlSrvrQueueAddTracks, + json!({ "track_ids": [99] }), + ) + .await; + let existing_uuid = app + .send_queue_command(existing) + .await + .expect("seed pending queue action"); + + let session_state = TransportEvent::InboundQueueServerEvent(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlSessionState, + action_uuid: None, + queue_version: Some(QueueVersion::new(11, 1)), + payload: json!({ + "session_uuid": "session-option-3", + "active_renderer_id": 7, + "playing_state": PLAYING_STATE_PLAYING, + }), + }); + let queue_state = TransportEvent::InboundQueueServerEvent(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlQueueState, + action_uuid: None, + queue_version: Some(QueueVersion::new(11, 1)), + payload: json!({ "tracks": [], "shuffle_mode": false }), + }); + let pending_completed = TransportEvent::InboundQueueServerEvent(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlQueueTracksAdded, + action_uuid: Some(existing_uuid), + queue_version: Some(QueueVersion::new(11, 2)), + payload: json!({ "tracks": [] }), + }); + + let (events_tx, events_rx) = tokio::sync::broadcast::channel(4); + drop(events_tx); + let host = Arc::new(TestSessionLoopHost { + local_playing: true, + conflict_choice: Some( + super::LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue, + ), + ..Default::default() + }); + app.run_session_loop_with_prefetched( + host.clone(), + events_rx, + false, + VecDeque::from([session_state, queue_state, pending_completed]), + ) + .await; + + assert_eq!(host.published_local_queues.load(Ordering::SeqCst), 1); + assert_eq!(host.cancelled_connections.load(Ordering::SeqCst), 0); + let sent = transport.sent_messages().await; + assert_eq!( + sent.iter() + .filter(|message| { + message.message_type == "MESSAGE_TYPE_CTRL_SRVR_SET_ACTIVE_RENDERER" + }) + .count(), + 1 + ); + let set_active = sent + .iter() + .find(|message| message.message_type == "MESSAGE_TYPE_CTRL_SRVR_SET_ACTIVE_RENDERER") + .expect("option 3 must claim the local renderer"); + assert_eq!(set_active.payload["renderer_id"], 3); + assert!(sent + .iter() + .all(|message| { message.message_type != "MESSAGE_TYPE_CTRL_SRVR_SET_PLAYER_STATE" })); + } + fn renderer_info(renderer_id: i32, device_uuid: &str) -> QconnectRendererInfo { QconnectRendererInfo { renderer_id, @@ -2455,7 +3050,12 @@ mod tests { let outcome = app .apply_session_management_event( "MESSAGE_TYPE_SRVR_CTRL_SESSION_STATE", - &json!({ "session_uuid": "sess-1", "active_renderer_id": 2, "loop_mode": 3 }), + &json!({ + "session_uuid": "sess-1", + "active_renderer_id": 2, + "playing_state": PLAYING_STATE_PLAYING, + "loop_mode": 3 + }), &local_identity("local-uuid"), ) .await; @@ -2466,6 +3066,68 @@ mod tests { assert_eq!(state.session.session_uuid.as_deref(), Some("sess-1")); assert_eq!(state.session.active_renderer_id, Some(2)); assert_eq!(state.session_loop_mode, Some(3)); + assert_eq!( + state + .session_renderer_states + .get(&2) + .and_then(|renderer| renderer.playing_state), + Some(PLAYING_STATE_PLAYING) + ); + } + + #[tokio::test] + async fn local_to_peer_handoff_clears_unfinished_local_takeover() { + let (app, _sink, _transport, _rx) = build_connected_app().await; + { + let handle = app.sync_handle(); + let mut state = handle.lock().await; + state.session.local_renderer_id = Some(1); + state.session.active_renderer_id = Some(1); + crate::arm_local_queue_takeover( + &mut state, + vec![10, 20], + "load-before-handoff".to_string(), + ); + } + + app.apply_session_management_event( + "MESSAGE_TYPE_SRVR_CTRL_ACTIVE_RENDERER_CHANGED", + &json!({ "active_renderer_id": 2 }), + &local_identity("local-uuid"), + ) + .await; + + let handle = app.sync_handle(); + let state = handle.lock().await; + assert_eq!(state.session.active_renderer_id, Some(2)); + assert!(state.pending_local_queue_takeover.is_none()); + } + + /// Repeated server snapshots are common during bootstrap. Applying the + /// same loop mode twice would echo another renderer command and was one of + /// the ingredients in the SET_LOOP_MODE feedback storm seen in the field. + #[tokio::test] + async fn repeated_loop_mode_is_not_reapplied() { + let (app, _sink, _transport, _rx) = build_connected_app().await; + let identity = local_identity("local-uuid"); + + let first = app + .apply_session_management_event( + "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET", + &json!({ "loop_mode": 3 }), + &identity, + ) + .await; + let repeated = app + .apply_session_management_event( + "MESSAGE_TYPE_SRVR_CTRL_LOOP_MODE_SET", + &json!({ "loop_mode": 3 }), + &identity, + ) + .await; + + assert_eq!(first.apply_loop_mode, Some(3)); + assert_eq!(repeated.apply_loop_mode, None); } /// Regression (take-renderer-when-idle): the cloud encodes "no active @@ -2483,7 +3145,7 @@ mod tests { state.session.local_renderer_id = Some(8); } let input = app - .capture_session_state_takeover_input(&json!({ "active_renderer_id": -1 })) + .capture_session_state_takeover_input(&json!({ "active_renderer_id": -1 }), false) .await; assert_eq!(input.server, ServerActiveState::None); } @@ -2499,14 +3161,62 @@ mod tests { state.session.local_renderer_id = Some(8); } let input = app - .capture_session_state_takeover_input(&json!({ "active_renderer_id": 5 })) + .capture_session_state_takeover_input(&json!({ "active_renderer_id": 5 }), false) .await; + assert_eq!(input.active_renderer_id, Some(5)); assert!(matches!( input.server, ServerActiveState::OtherPlaying | ServerActiveState::OtherPaused )); } + #[tokio::test] + async fn conflict_fences_are_cleared_before_a_later_connection() { + let (app, _sink, _transport, _rx) = build_connected_app().await; + { + let handle = app.sync_handle(); + let mut state = handle.lock().await; + state.local_playback_conflict_pending = true; + crate::arm_local_queue_takeover(&mut state, vec![101, 102], "load-1".to_string()); + } + + app.clear_local_playback_authority_fences().await; + + let handle = app.sync_handle(); + let state = handle.lock().await; + assert!(!state.local_playback_conflict_pending); + assert!(state.pending_local_queue_takeover.is_none()); + } + + #[tokio::test] + async fn capture_takeover_uses_real_local_playback_and_incoming_peer_state() { + let (app, _sink, _transport, _rx) = build_connected_app().await; + { + let handle = app.sync_handle(); + let mut state = handle.lock().await; + state.session.local_renderer_id = Some(8); + // This unrelated legacy snapshot must not classify renderer 5. + state.last_renderer_playing_state = Some(PLAYING_STATE_PLAYING); + } + + let paused = app + .capture_session_state_takeover_input( + &json!({ "active_renderer_id": 5, "playing_state": 3 }), + true, + ) + .await; + assert!(paused.was_playing); + assert_eq!(paused.server, ServerActiveState::OtherPaused); + + let playing = app + .capture_session_state_takeover_input( + &json!({ "active_renderer_id": 5, "playing_state": 2 }), + true, + ) + .await; + assert_eq!(playing.server, ServerActiveState::OtherPlaying); + } + /// A RENDERER_STATE_UPDATED for a PLAYING active peer arms the watchdog and /// reports the renderer for projection. #[tokio::test] @@ -3058,6 +3768,61 @@ mod tests { ); } + /// The server can reject QUEUE_LOAD_TRACKS with no action UUID. In that + /// form correlation cannot match by UUID, but retaining the rejected load + /// until the generic timeout blocks the authoritative local-queue retry. + #[tokio::test] + async fn queue_load_error_without_action_uuid_releases_rejected_pending_action() { + let (app, sink, _transport, _events_rx) = build_connected_app().await; + let command = app + .build_queue_command( + QueueCommandType::CtrlSrvrQueueLoadTracks, + json!({ "track_ids": [101, 102] }), + ) + .await; + let rejected_uuid = app + .send_queue_command(command) + .await + .expect("send queue-load command"); + + app.apply_server_event(QueueServerEvent { + event_type: QueueEventType::SrvrCtrlQueueErrorMessage, + action_uuid: None, + queue_version: Some(QueueVersion::new(2, 1)), + payload: json!({ + "error_code": "ERROR_QUEUE_LOAD_TRACKS", + "error_message": "Queue version mismatch" + }), + }) + .await + .expect("apply queue-load rejection without action UUID"); + + let state = app.state_handle(); + let guard = state.lock().await; + let pending = guard + .pending + .current() + .expect("queue-state resync should replace the rejected action"); + assert_ne!(pending.uuid, rejected_uuid); + assert!(pending.is_ask_for_state_action); + assert!(!pending.is_queue_load_tracks_action); + drop(guard); + + let events = sink.snapshot().await; + assert!(events.iter().any(|event| { + matches!( + event, + QconnectAppEvent::PendingActionCanceledByConcurrentRemoteEvent { + pending_uuid, + remote_action_uuid, + } if pending_uuid == &rejected_uuid && remote_action_uuid.is_empty() + ) + })); + assert!(events + .iter() + .any(|event| matches!(event, QconnectAppEvent::QueueResyncTriggered))); + } + #[tokio::test] async fn matching_session_management_event_completes_pending_action() { let (app, sink, _transport, _events_rx) = build_connected_app().await; @@ -3345,4 +4110,38 @@ mod tests { .expect("next_queue_item_id field") .is_null()); } + + #[tokio::test] + async fn local_authority_fence_blocks_renderer_reducer_and_reports() { + let (app, sink, transport, _events_rx) = build_connected_app().await; + { + let handle = app.sync_handle(); + let mut state = handle.lock().await; + crate::arm_local_queue_takeover(&mut state, vec![10, 20], "load-local".to_string()); + } + + app.apply_renderer_server_command(RendererServerCommand { + command_type: RendererCommandType::SrvrRndrSetState, + payload: json!({ + "playing_state": 3, + "current_position": 46_870, + "current_track": { + "track_context_uuid": "stale", + "track_id": 439_248_309_u64, + "queue_item_id": 0, + } + }), + }) + .await + .expect("fenced renderer command"); + + let renderer = app.renderer_state_snapshot().await; + assert_eq!(renderer.playing_state, None); + assert_eq!(renderer.current_position_ms, None); + assert!(sink.snapshot().await.iter().all(|event| !matches!( + event, + QconnectAppEvent::RendererUpdated(_) | QconnectAppEvent::RendererCommandApplied { .. } + ))); + assert!(transport.sent_messages().await.is_empty()); + } } diff --git a/crates/qconnect-app/src/lib.rs b/crates/qconnect-app/src/lib.rs index 0ec9646b9..44f8c4100 100644 --- a/crates/qconnect-app/src/lib.rs +++ b/crates/qconnect-app/src/lib.rs @@ -5,14 +5,15 @@ mod app; mod authority; mod authority_transition; +mod delegated_rejoin; mod delegation; mod delegation_preflight; -mod delegated_rejoin; mod error; mod events; mod feature_flags; mod lan_projection; mod lan_runtime; +mod playback_conflict; pub mod queue_resolution; pub mod renderer; mod renderer_engine; @@ -23,8 +24,8 @@ mod state; mod sync_state; pub use app::{ - queue_payload_track_preview, QconnectApp, SessionApplyOutcome, SessionLoopHost, - SessionStateTakeoverInput, + queue_payload_track_preview, LocalPlaybackConflictChoice, QconnectApp, SessionApplyOutcome, + SessionLoopHost, SessionStateTakeoverInput, }; pub use authority::{ AuthorityActionPermit, AuthorityCell, AuthorityOrigin, AuthorityStamp, @@ -34,15 +35,15 @@ pub use authority::{ pub use authority_transition::{ acquire_transition_guard_and_fence, DeferredActivationRelease, OwnerActionFence, }; +pub use delegated_rejoin::{ + DelegatedRejoinWatchdog, DelegatedRuntimeEventDirective, DelegatedRuntimeEventState, +}; pub use delegation::{ CommitRejected, CredentialOrigin, DelegationCancellation, DelegationCandidate, DelegationCoordinator, DelegationCoordinatorConfig, DelegationCoordinatorError, DelegationErrorCode, DelegationHost, DelegationPhase, DelegationSnapshot, RestoreReason, }; pub use delegation_preflight::DelegationPreflight; -pub use delegated_rejoin::{ - DelegatedRejoinWatchdog, DelegatedRuntimeEventDirective, DelegatedRuntimeEventState, -}; pub use error::{QconnectAppError, QconnectOwnerFailure}; pub use events::{NoOpEventSink, QconnectAppEvent, QconnectEventSink}; pub use feature_flags::{ @@ -51,6 +52,7 @@ pub use feature_flags::{ }; pub use lan_projection::LanProjectionSlot; pub use lan_runtime::{lan_callback_is_current, LanRuntimeError, LanRuntimeLifecycle}; +pub use playback_conflict::QconnectPlaybackConflictPolicy; pub use qconnect_core::{ evaluate_remote_queue_admission, resolve_handoff_intent, validate_track_origins_for_admission, AdmissionDecision, HandoffIntent, QConnectQueueState, QConnectRendererState, QueueVersion, @@ -59,6 +61,7 @@ pub use qconnect_core::{ pub use qconnect_protocol::{ QueueCommandType, RendererBufferState, RendererReport, RendererReportType, }; +pub use renderer::{qconnect_queue_track_is_resolvable, qconnect_source_is_resolvable}; pub use renderer_engine::QconnectRendererEngine; pub use reporting::{ build_renderer_playback_report, qconnect_report_track_id, renderer_buffer_state, @@ -70,15 +73,20 @@ pub use session::{ is_peer_renderer_active, max_audio_quality_from_quality, normalize_active_renderer_id, qconnect_millis_from_secs, quality_from_max_audio_quality, queue_item_snapshot_for_cursor, refresh_local_renderer_id, renderer_allows_remote_volume, should_arm_renderer_watchdog, - should_reask_queue_state, ConnectionDecision, LocalIdentity, - QconnectFileAudioQualitySnapshot, QconnectLifecycleState, QconnectRendererInfo, - QconnectSessionRendererState, QconnectSessionState, RendererStatus, ServerActiveState, + should_reask_queue_state, ConnectionDecision, LocalIdentity, QconnectFileAudioQualitySnapshot, + QconnectLifecycleState, QconnectRendererInfo, QconnectSessionRendererState, + QconnectSessionState, RendererStatus, ServerActiveState, JOIN_SESSION_REASON_CONTROLLER_REQUEST, JOIN_SESSION_REASON_RECONNECTION, QCONNECT_RENDERER_LOST_TIMEOUT_MS, }; pub use startup::{compute_effective_startup, QconnectStartupMode}; pub use state::QconnectRuntimeState; pub use sync_state::{ - cache_renderer_snapshot, ensure_session_renderer_state, sync_session_renderer_active_flags, - QconnectRemoteSyncState, + active_peer_renderer_is_playing, arm_local_queue_takeover, cache_renderer_snapshot, + clear_local_queue_takeover, confirm_local_playback_state_asserted, + confirm_local_queue_takeover_action, ensure_session_renderer_state, + local_playback_should_yield_to_active_peer, local_queue_takeover_needs_retry, + reject_local_queue_takeover_action, remote_renderer_commands_are_fenced, + set_local_playback_conflict_pending, should_materialize_remote_queue, + sync_session_renderer_active_flags, QconnectRemoteSyncState, }; diff --git a/crates/qconnect-app/src/playback_conflict.rs b/crates/qconnect-app/src/playback_conflict.rs new file mode 100644 index 000000000..8a4566bb9 --- /dev/null +++ b/crates/qconnect-app/src/playback_conflict.rs @@ -0,0 +1,128 @@ +//! Persistable policy for resolving the bootstrap conflict between local +//! playback and an already-active Qobuz Connect renderer. + +use serde::{Deserialize, Serialize}; + +use crate::app::LocalPlaybackConflictChoice; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum QconnectPlaybackConflictPolicy { + /// Open the four-choice modal for every conflict. This is the fail-open + /// default because it never makes an ownership decision on the user's + /// behalf. + #[default] + AskEveryTime, + ContinueOnActiveRenderer, + ContinueOnThisDevice, + ContinueLocalPlaybackAndReplaceQueue, +} + +impl QconnectPlaybackConflictPolicy { + pub fn as_str(self) -> &'static str { + match self { + Self::AskEveryTime => "ask_every_time", + Self::ContinueOnActiveRenderer => "continue_on_active_renderer", + Self::ContinueOnThisDevice => "continue_on_this_device", + Self::ContinueLocalPlaybackAndReplaceQueue => { + "continue_local_playback_and_replace_queue" + } + } + } + + pub fn from_str(value: &str) -> Option { + match value { + "ask_every_time" => Some(Self::AskEveryTime), + "continue_on_active_renderer" => Some(Self::ContinueOnActiveRenderer), + "continue_on_this_device" => Some(Self::ContinueOnThisDevice), + "continue_local_playback_and_replace_queue" => { + Some(Self::ContinueLocalPlaybackAndReplaceQueue) + } + _ => None, + } + } + + pub const fn index(self) -> i32 { + match self { + Self::AskEveryTime => 0, + Self::ContinueOnActiveRenderer => 1, + Self::ContinueOnThisDevice => 2, + Self::ContinueLocalPlaybackAndReplaceQueue => 3, + } + } + + pub fn from_index(index: usize) -> Option { + match index { + 0 => Some(Self::AskEveryTime), + 1 => Some(Self::ContinueOnActiveRenderer), + 2 => Some(Self::ContinueOnThisDevice), + 3 => Some(Self::ContinueLocalPlaybackAndReplaceQueue), + _ => None, + } + } + + /// `None` means the frontend must ask. CancelConnection is deliberately + /// absent: cancelling a connection is a one-shot modal action, not a + /// sensible automatic policy. + pub const fn automatic_choice(self) -> Option { + match self { + Self::AskEveryTime => None, + Self::ContinueOnActiveRenderer => { + Some(LocalPlaybackConflictChoice::ContinueOnActiveRenderer) + } + Self::ContinueOnThisDevice => Some(LocalPlaybackConflictChoice::ContinueOnThisDevice), + Self::ContinueLocalPlaybackAndReplaceQueue => { + Some(LocalPlaybackConflictChoice::ContinueLocalPlaybackAndReplaceQueue) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_asks_every_time() { + assert_eq!( + QconnectPlaybackConflictPolicy::default(), + QconnectPlaybackConflictPolicy::AskEveryTime + ); + assert_eq!( + QconnectPlaybackConflictPolicy::default().automatic_choice(), + None + ); + } + + #[test] + fn values_round_trip_through_storage_and_ui_index() { + let policies = [ + QconnectPlaybackConflictPolicy::AskEveryTime, + QconnectPlaybackConflictPolicy::ContinueOnActiveRenderer, + QconnectPlaybackConflictPolicy::ContinueOnThisDevice, + QconnectPlaybackConflictPolicy::ContinueLocalPlaybackAndReplaceQueue, + ]; + for policy in policies { + assert_eq!( + QconnectPlaybackConflictPolicy::from_str(policy.as_str()), + Some(policy) + ); + assert_eq!( + QconnectPlaybackConflictPolicy::from_index(policy.index() as usize), + Some(policy) + ); + } + } + + #[test] + fn cancel_is_not_an_automatic_policy() { + for index in 1..=3 { + assert_ne!( + QconnectPlaybackConflictPolicy::from_index(index) + .and_then(QconnectPlaybackConflictPolicy::automatic_choice), + Some(LocalPlaybackConflictChoice::CancelConnection) + ); + } + assert_eq!(QconnectPlaybackConflictPolicy::from_index(4), None); + } +} diff --git a/crates/qconnect-app/src/renderer.rs b/crates/qconnect-app/src/renderer.rs index e50312aa2..cdb1786bd 100644 --- a/crates/qconnect-app/src/renderer.rs +++ b/crates/qconnect-app/src/renderer.rs @@ -35,9 +35,59 @@ pub const PLAYING_STATE_PAUSED: i32 = 3; /// `track_id` comparison would re-fire during that buffer/decode gap. const LOAD_ATTEMPT_DEDUP_WINDOW: Duration = Duration::from_secs(5); -/// Source tag stamped on remote queue tracks materialized from a QConnect cloud -/// queue. Matches the Tauri adapter's prior `QCONNECT_REMOTE_QUEUE_SOURCE`. -pub const QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote"; +/// Canonical source stamped on tracks materialized from a QConnect cloud queue. +/// +/// The wire supplies Qobuz catalog ids. Keeping the old transport-only +/// `qobuz_connect_remote` tag in the core queue leaked QConnect lifecycle state +/// into downstream renderers: after a manual disconnect, Cast no longer chose +/// its Qobuz progressive-stream path. Materialized rows therefore use the same +/// portable provenance as every other Qobuz catalog row. +pub const QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz"; + +/// Backward-compatible spelling found in persisted queues and in queues +/// materialized by an older runtime before an in-process upgrade/reconnect. +pub const LEGACY_QCONNECT_REMOTE_QUEUE_SOURCE: &str = "qobuz_connect_remote"; + +/// Whether a queue row can be named by Qobuz Connect without looking the id up. +/// +/// Source provenance is the authority: local-library and media-server ids can +/// overlap the numeric Qobuz id space, while offline Qobuz downloads retain the +/// real catalog id and remain resolvable. A missing source is accepted only by +/// the [`QueueTrack`] wrapper below, where the legacy `is_local` discriminator +/// is still available. +pub fn qconnect_source_is_resolvable(track_id: u64, source: Option<&str>) -> bool { + if track_id == 0 { + return false; + } + match source.map(str::trim).filter(|source| !source.is_empty()) { + Some(source) => matches!( + source.to_ascii_lowercase().as_str(), + QCONNECT_REMOTE_QUEUE_SOURCE + | "qobuz_download" + | "qobuz_purchase" + | "offline" + | LEGACY_QCONNECT_REMOTE_QUEUE_SOURCE + ), + // Callers with only the wire-shaped `(id, source)` pair have no local + // bit. Every current local producer stamps an explicit source, so the + // source-less shape is the legacy catalog case. + None => true, + } +} + +/// Queue-row form of [`qconnect_source_is_resolvable`]. This is the canonical +/// admission predicate for desktop queue filtering and daemon publication. +pub fn qconnect_queue_track_is_resolvable(track: &QueueTrack) -> bool { + match track + .source + .as_deref() + .map(str::trim) + .filter(|source| !source.is_empty()) + { + Some(source) => qconnect_source_is_resolvable(track.id, Some(source)), + None => track.id > 0 && !track.is_local, + } +} pub fn qconnect_repeat_mode_from_loop_mode(loop_mode: i32) -> Option { // QConnect protocol loop mode values: 1 = off, 2 = repeat one, 3 = repeat all. @@ -96,7 +146,9 @@ pub fn model_track_to_core_queue_track(track: &Track) -> QueueTrack { // A queue pushed by a QConnect peer carries real Qobuz tracks, so the // flag is worth keeping — but resolved through `is_streamable()`, since // a peer's payload is one more endpoint we have never captured and a - // terse one must not arrive here marked dead. + // terse one must not arrive here marked dead. Its source is canonical + // Qobuz provenance, not a transport-lifecycle tag: this queue remains + // playable by local and Cast renderers after QConnect disconnects. streamable: track.is_streamable(), source: Some(QCONNECT_REMOTE_QUEUE_SOURCE.to_string()), parental_warning: track.parental_warning, @@ -322,6 +374,41 @@ pub async fn force_remote_track_stream( Ok(true) } +/// Resolve the track for a state-only PLAYING command on a cold engine. +/// Official clients can omit `current_track`; the synchronized queue cursor is +/// then the next authority. The id that `stop()` deliberately preserved is +/// accepted only when it still names a resolvable row in that queue; a bare +/// local-library id is never guessed to be Qobuz. +async fn takeback_track_id( + engine: &impl QconnectRendererEngine, + renderer_state: &QConnectRendererState, +) -> Option { + if let Some(track_id) = renderer_state + .current_track + .as_ref() + .map(|track| track.track_id) + .filter(|track_id| *track_id > 0) + { + return Some(track_id); + } + + let (tracks, current_index) = engine.get_all_queue_tracks().await; + if let Some(track_id) = current_index + .and_then(|index| tracks.get(index)) + .filter(|track| qconnect_queue_track_is_resolvable(track)) + .map(|track| track.id) + .filter(|track_id| *track_id > 0) + { + return Some(track_id); + } + + let track_id = engine.get_playback_state().track_id; + tracks + .iter() + .find(|track| track.id == track_id && qconnect_queue_track_is_resolvable(track)) + .map(|track| track.id) +} + pub async fn apply_remote_loop_mode( engine: &impl QconnectRendererEngine, loop_mode: i32, @@ -454,14 +541,20 @@ pub async fn apply_renderer_command( // SetActive takeback path; the has_loaded_audio gate // keeps echoes and live playback on the plain resume. let cold_engine = !engine.has_loaded_audio(); - let cold_track = projection_renderer_state.current_track.as_ref(); - if cold_engine && cold_track.is_some() { - let track_id = cold_track.map(|t| t.track_id).unwrap_or(0); + if cold_engine { + let Some(track_id) = + takeback_track_id(engine, &projection_renderer_state).await + else { + return Err( + "cold-start resume has no projected, queued, or preserved track" + .to_string(), + ); + }; let start_position_secs = renderer_state .current_position_ms .or(*current_position_ms) .map(|ms| ms / 1000) - .unwrap_or(0); + .unwrap_or_else(|| engine.get_playback_state().position); match force_remote_track_stream( engine, sync_state, @@ -556,6 +649,20 @@ pub async fn apply_renderer_command( } RendererCommand::SetActive { active } => { if *active { + let local_playback = engine.get_playback_state(); + if engine.has_loaded_audio() && local_playback.is_playing { + // SESSION_STATE arbitration may have chosen the live local + // queue while a stale SetActive from the old cloud snapshot + // was already in flight. Activation acknowledges ownership; + // it is not authority to replace audible local playback with + // an unrelated renderer cursor. An intentional remote-queue + // takeover stops local audio before claiming this renderer. + log::info!( + "[QConnect] SetActive(true) preserved active local playback (track_id={})", + local_playback.track_id + ); + return Ok(()); + } // Becoming the active renderer (takeback). FORCE a stream of the // current track instead of a plain ensure-loaded: a prior // controller->renderer transition tore the local stream down via @@ -677,7 +784,7 @@ pub async fn materialize_remote_queue( engine: &impl QconnectRendererEngine, sync_state: &Arc>, queue_state: &QConnectQueueState, -) -> Result<(), String> { +) -> Result { let ( renderer_queue_item_id, renderer_track_id, @@ -716,7 +823,7 @@ pub async fn materialize_remote_queue( queue_state.version.major, queue_state.version.minor ); - return Ok(()); + return Ok(false); } log::info!( @@ -738,7 +845,7 @@ pub async fn materialize_remote_queue( let mut state = sync_state.lock().await; state.last_materialized_start_index = None; state.last_materialized_core_shuffle_order = None; - return Ok(()); + return Ok(true); } // Preserve the richer QueueTrack built by album/playlist entry points @@ -888,7 +995,7 @@ pub async fn materialize_remote_queue( let _ = engine.stop(); } - Ok(()) + Ok(true) } pub async fn align_queue_cursor( @@ -1093,6 +1200,43 @@ mod tests { model_track_to_core_queue_track(&mock_track(id)) } + #[test] + fn qconnect_admission_uses_provenance_and_accepts_materialized_rows() { + for source in [ + "qobuz", + "qobuz_download", + "qobuz_purchase", + "offline", + LEGACY_QCONNECT_REMOTE_QUEUE_SOURCE, + ] { + let mut track = mock_queue_track(7); + track.source = Some(source.to_string()); + track.is_local = source == "qobuz_download"; + assert!(qconnect_queue_track_is_resolvable(&track), "{source}"); + } + + for source in ["local", "plex", "jellyfin", "subsonic", "navidrome"] { + let mut track = mock_queue_track(7); + track.source = Some(source.to_string()); + track.is_local = true; + assert!(!qconnect_queue_track_is_resolvable(&track), "{source}"); + } + } + + #[test] + fn qconnect_admission_legacy_rows_require_non_local_positive_ids() { + let mut track = mock_queue_track(7); + track.source = None; + track.is_local = false; + assert!(qconnect_queue_track_is_resolvable(&track)); + + track.is_local = true; + assert!(!qconnect_queue_track_is_resolvable(&track)); + track.id = 0; + track.is_local = false; + assert!(!qconnect_queue_track_is_resolvable(&track)); + } + fn queue_state( version: QueueVersion, items: Vec, @@ -1166,7 +1310,7 @@ mod tests { assert_eq!(track.bit_depth, Some(24)); assert_eq!(track.sample_rate, Some(96_000.0)); assert!(track.hires); - assert_eq!(track.source.as_deref(), Some(QCONNECT_REMOTE_QUEUE_SOURCE)); + assert_eq!(track.source.as_deref(), Some("qobuz")); assert!( track.context_kind.is_none(), "remote context remains authoritative" @@ -1308,6 +1452,70 @@ mod tests { assert_eq!(calls.resumes, 0, "no bare resume on a cold engine"); } + /// The field shape from the owner's 2026-08-31 regression: both the + /// command and renderer projection omit current_track, but the synchronized + /// queue still has the authoritative cursor. This must cold-load that row, + /// never fall through to `resume()` on an empty engine. + #[tokio::test] + async fn cold_resume_falls_back_to_queue_cursor_when_projection_omits_track() { + let mut engine = MockEngine::new(); + engine.playback = PlaybackState { + track_id: 7, + position: 38, + ..Default::default() + }; + engine.queue_tracks = vec![mock_queue_track(6), mock_queue_track(7)]; + engine.queue_index = Some(1); + engine.loaded_audio = false; + let sync = sync(); + let cmd = RendererCommand::SetState { + playing_state: Some(PLAYING_STATE_PLAYING), + current_position_ms: None, + current_track: None, + next_track: None, + }; + + apply_renderer_command(&engine, &sync, &cmd, &QConnectRendererState::default()) + .await + .unwrap(); + + let calls = engine.calls(); + assert_eq!(calls.start_track_streams, vec![7]); + assert_eq!(calls.start_positions, vec![38]); + assert_eq!(calls.resumes, 0); + } + + #[tokio::test] + async fn cold_resume_never_guesses_a_local_queue_id_is_qobuz() { + let mut engine = MockEngine::new(); + engine.playback = PlaybackState { + track_id: 7, + ..Default::default() + }; + let mut local = mock_queue_track(7); + local.source = Some("local".to_string()); + local.is_local = true; + engine.queue_tracks = vec![local]; + engine.queue_index = Some(0); + engine.loaded_audio = false; + let cmd = RendererCommand::SetState { + playing_state: Some(PLAYING_STATE_PLAYING), + current_position_ms: None, + current_track: None, + next_track: None, + }; + + let error = + apply_renderer_command(&engine, &sync(), &cmd, &QConnectRendererState::default()) + .await + .expect_err("local ids must not be sent to Qobuz"); + + assert!(error.contains("no projected, queued, or preserved track")); + let calls = engine.calls(); + assert!(calls.start_track_streams.is_empty()); + assert_eq!(calls.resumes, 0); + } + /// The cold-start load never fires while audio is loaded: a resume during /// live playback (or a cloud echo) stays a plain resume, no re-stream. #[tokio::test] @@ -1532,6 +1740,41 @@ mod tests { ); } + /// Regression (2026-09-01): local track 7 was actively playing when a + /// stale SetActive carried remote track 99. The command created a one-track + /// queue and destroyed the local session before its queue upload settled. + #[tokio::test] + async fn set_active_does_not_replace_different_active_local_track() { + let mut engine = MockEngine::new(); + engine.playback = PlaybackState { + track_id: 7, + is_playing: true, + ..Default::default() + }; + engine.loaded_audio = true; + engine.queue_tracks = vec![mock_queue_track(7), mock_queue_track(8)]; + engine.queue_index = Some(0); + + let renderer_state = QConnectRendererState { + current_track: Some(qi(99, 9)), + current_position_ms: Some(71_000), + ..Default::default() + }; + apply_renderer_command( + &engine, + &sync(), + &RendererCommand::SetActive { active: true }, + &renderer_state, + ) + .await + .unwrap(); + + let calls = engine.calls(); + assert!(calls.start_track_streams.is_empty()); + assert_eq!(calls.set_queues, 0); + assert!(calls.play_indexes.is_empty()); + } + /// #1 (takeback first-load via SetState) — when the FIRST load on a takeback /// lands in the SetState path (SetActive arrived before current_track was /// known, so the force-stream couldn't fire), the load must stream at the diff --git a/crates/qconnect-app/src/session.rs b/crates/qconnect-app/src/session.rs index 78caa9593..fac731f83 100644 --- a/crates/qconnect-app/src/session.rs +++ b/crates/qconnect-app/src/session.rs @@ -105,13 +105,12 @@ pub struct ConnectionDecision { } /// Pure takeover arbitration ported from the web controller's -/// `computeConnectionState`. This is a REDUCED matrix: it captures the -/// shape and named outputs faithfully from the appendix prose, but the -/// exact web truth-table cell values were not dumped verbatim. The -/// `OtherPaused` vs `OtherPlaying` divergence and repeat-mode -/// reconciliation are pending a decompiled-bundle cross-check; repeat-mode -/// reconciliation is intentionally OUT of P1-3 scope (it overlaps the -/// existing `session_loop_mode` handling in `event_sink.rs`). +/// `computeConnectionState` and completed with QBZ's local-playback rule. A +/// playing peer remains authoritative. When QBZ itself is already playing and +/// the active peer is paused/stopped (or the session is idle), QBZ keeps +/// playback, claims the renderer slot, and publishes its resolvable queue. +/// Repeat-mode reconciliation remains outside this function; it overlaps the +/// existing `session_loop_mode` handling in the event sinks. pub fn compute_connection_state( was_active: bool, was_playing: bool, @@ -128,9 +127,9 @@ pub fn compute_connection_state( // "Play here" click when QBZ is the only candidate. This NEVER steals // from an active peer: an active peer is server==Other*/Me, not None, // and those branches keep should_set_active_renderer=false. - should_be_active: was_active || auto_take_when_idle, - should_set_active_renderer: was_active || auto_take_when_idle, - should_set_queue: was_active, + should_be_active: was_active || was_playing || auto_take_when_idle, + should_set_active_renderer: was_active || was_playing || auto_take_when_idle, + should_set_queue: was_active || was_playing, should_ask_queue: false, }, Me => ConnectionDecision { @@ -139,7 +138,19 @@ pub fn compute_connection_state( should_set_queue: !queue_equal && (was_active || was_playing), should_ask_queue: queue_equal, }, - OtherPlaying | OtherPaused => ConnectionDecision { + OtherPlaying => ConnectionDecision { + should_be_active: false, + should_set_active_renderer: false, + should_set_queue: false, + should_ask_queue: true, + }, + OtherPaused if was_playing => ConnectionDecision { + should_be_active: true, + should_set_active_renderer: true, + should_set_queue: true, + should_ask_queue: false, + }, + OtherPaused => ConnectionDecision { should_be_active: false, should_set_active_renderer: false, should_set_queue: false, @@ -552,6 +563,13 @@ mod tests { assert!(d.should_ask_queue && !d.should_set_queue && !d.should_set_active_renderer); let d = compute_connection_state(false, false, OtherPlaying, false, false); assert!(!d.should_be_active && !d.should_set_active_renderer && d.should_ask_queue); + // A paused/stopped peer does not interrupt real local playback: QBZ + // claims the slot and publishes its filtered queue. + let d = compute_connection_state(false, true, OtherPaused, false, false); + assert!(d.should_be_active && d.should_set_active_renderer && d.should_set_queue); + // A genuinely playing peer remains authoritative. + let d = compute_connection_state(false, true, OtherPlaying, false, false); + assert!(!d.should_be_active && !d.should_set_active_renderer && !d.should_set_queue); let d = compute_connection_state(false, false, None, false, false); assert!( !d.should_be_active @@ -563,6 +581,10 @@ mod tests { // QBZ claims the render (no extra "Play here" click). let d = compute_connection_state(false, false, None, false, true); assert!(d.should_be_active && d.should_set_active_renderer); + // Local playback itself is sufficient to claim an idle session and + // upload its queue, even without the generic idle auto-take flag. + let d = compute_connection_state(false, true, None, false, false); + assert!(d.should_be_active && d.should_set_active_renderer && d.should_set_queue); // An active peer is server==OtherPlaying/Other Paused, never None, so // auto_take_when_idle can never steal it (guard at the call site too). let d = compute_connection_state(false, false, OtherPlaying, false, true); diff --git a/crates/qconnect-app/src/sync_state.rs b/crates/qconnect-app/src/sync_state.rs index 9108b7e63..deba2d17b 100644 --- a/crates/qconnect-app/src/sync_state.rs +++ b/crates/qconnect-app/src/sync_state.rs @@ -6,12 +6,12 @@ //! //! This is held behind a SINGLE Mutex shared by the session/liveness path and //! the renderer-materialize path. That single-lock sharing is load-bearing: -//! `capture_session_state_takeover_input` reads `session` together with -//! `last_renderer_playing_state`, and the materialize path reads `last_renderer_*` -//! together with `last_applied_queue_state`, all atomically. Splitting these -//! across two locks would tear the takeover decision. So it stays ONE struct -//! under ONE lock. Relocated here (slice 2+4) so both the Tauri and Slint -//! adapters share it; the owning `Mutex` is held by the adapter / `QconnectApp`. +//! `capture_session_state_takeover_input` reads session topology together with +//! per-renderer state, and the materialize path reads `last_renderer_*` together +//! with `last_applied_queue_state`, all atomically. Splitting these across two +//! locks would tear the takeover decision. So it stays ONE struct under ONE +//! lock. Relocated here (slice 2+4) so all adapters share it; the owning `Mutex` +//! is held by the adapter / `QconnectApp`. //! //! Mutated by the event sink (on inbound events), the renderer engine (on //! materialize/apply), track loading (on load attempt), and the service loop @@ -26,6 +26,14 @@ use crate::session::{ QconnectFileAudioQualitySnapshot, QconnectSessionRendererState, QconnectSessionState, }; +#[derive(Debug)] +pub struct PendingLocalQueueTakeover { + expected_track_ids: Vec, + action_uuid: String, + action_confirmed: bool, + retry_needed: bool, +} + #[derive(Debug, Default)] pub struct QconnectRemoteSyncState { pub last_renderer_queue_item_id: Option, @@ -41,6 +49,19 @@ pub struct QconnectRemoteSyncState { pub last_reported_device_audio_quality: Option<(i32, i32, i32)>, pub last_applied_queue_state: Option, pub last_remote_queue_state: Option, + /// Queue uploaded because QBZ was already playing while no peer was. A + /// stale bootstrap queue reply must not overwrite local playback before the + /// queue-load echo lands. + pub pending_local_queue_takeover: Option, + /// The exact local QueueLoadTracks echo landed, but QBZ has not yet + /// published its real playing position/state against that queue version. + /// Keep renderer commands fenced through that final assertion so the + /// server cannot seek/pause local audio with an older cursor in between. + pub local_playback_state_assertion_pending: bool, + /// A SESSION_STATE reported a peer renderer while local audio was already + /// playing. The frontend is asking which queue/renderer should win. While + /// this is true, no remote queue or renderer command may mutate local audio. + pub local_playback_conflict_pending: bool, pub session_loop_mode: Option, /// Session topology — stored from session management events (types 81-87). pub session: QconnectSessionState, @@ -62,6 +83,157 @@ pub struct QconnectRemoteSyncState { pub watchdog_generation: u64, } +/// Arm local queue authority after a takeover upload is accepted by the +/// transport. It remains armed until the matching cloud echo arrives, a peer +/// explicitly wins, or the runtime disconnects. A wall-clock timeout is unsafe: +/// a slow or rejected queue load can arrive after it and overwrite live audio. +pub fn arm_local_queue_takeover( + state: &mut QconnectRemoteSyncState, + track_ids: Vec, + action_uuid: String, +) { + if track_ids.is_empty() { + state.pending_local_queue_takeover = None; + return; + } + state.pending_local_queue_takeover = Some(PendingLocalQueueTakeover { + expected_track_ids: track_ids, + action_uuid, + action_confirmed: false, + retry_needed: false, + }); + state.local_playback_state_assertion_pending = false; +} + +/// Mark the exact QueueLoadTracks action that established local authority as +/// accepted. Track ids alone are not an acknowledgement: a stale bootstrap +/// queue can contain the same album while carrying an older cursor/position. +pub fn confirm_local_queue_takeover_action( + state: &mut QconnectRemoteSyncState, + action_uuid: &str, +) -> bool { + let Some(pending) = state.pending_local_queue_takeover.as_mut() else { + return false; + }; + if pending.action_uuid != action_uuid { + return false; + } + pending.action_confirmed = true; + true +} + +/// Keep local authority fenced after the matching QueueLoadTracks action was +/// rejected, canceled by concurrency, or timed out. The next sync uses the +/// refreshed queue version and replaces this action uuid. +pub fn reject_local_queue_takeover_action( + state: &mut QconnectRemoteSyncState, + action_uuid: &str, +) -> bool { + let Some(pending) = state.pending_local_queue_takeover.as_mut() else { + return false; + }; + if pending.action_uuid != action_uuid { + return false; + } + pending.action_confirmed = false; + pending.retry_needed = true; + true +} + +/// True when a rejected/mismatching cloud queue requires the local projection +/// to be sent again even if the source queue itself has not changed. +pub fn local_queue_takeover_needs_retry(state: &QconnectRemoteSyncState) -> bool { + state + .pending_local_queue_takeover + .as_ref() + .map(|pending| pending.retry_needed) + .unwrap_or(false) +} + +/// Renderer commands carry the cloud's prior cursor independently of queue +/// snapshots. Fence them alongside queue materialization during an unresolved +/// conflict or a local-queue takeover, otherwise SetActive/SetState can create +/// a stale single-track queue before the matching queue echo arrives. +pub fn remote_renderer_commands_are_fenced(state: &QconnectRemoteSyncState) -> bool { + state.local_playback_conflict_pending + || state.pending_local_queue_takeover.is_some() + || state.local_playback_state_assertion_pending +} + +/// Release the final renderer-command fence only after QBZ has successfully +/// reported the actual local position/state for the accepted queue version. +pub fn confirm_local_playback_state_asserted(state: &mut QconnectRemoteSyncState) { + state.local_playback_state_assertion_pending = false; +} + +/// Fence renderer/queue side effects while the frontend resolves a playback +/// conflict. This is separate from queue takeover authority because choices 1 +/// and 2 deliberately let the remote queue win. +pub fn set_local_playback_conflict_pending(state: &mut QconnectRemoteSyncState, pending: bool) { + state.local_playback_conflict_pending = pending; +} + +/// Clear takeover protection on disconnect/retirement. +pub fn clear_local_queue_takeover(state: &mut QconnectRemoteSyncState) { + state.pending_local_queue_takeover = None; + state.local_playback_state_assertion_pending = false; +} + +/// Decide whether an inbound remote queue may mutate the local core queue. +/// A matching, action-confirmed local echo advances the fence but is not +/// materialized: the live core queue/cursor already owns playback. Mismatching +/// bootstrap replies stay suppressed; they never gain authority just because +/// an arbitrary timer elapsed. +pub fn should_materialize_remote_queue( + state: &mut QconnectRemoteSyncState, + queue: &QConnectQueueState, +) -> bool { + if state.local_playback_conflict_pending { + return false; + } + let Some(pending) = state.pending_local_queue_takeover.as_mut() else { + return true; + }; + let incoming_ids: Vec = queue.queue_items.iter().map(|item| item.track_id).collect(); + if incoming_ids == pending.expected_track_ids && pending.action_confirmed { + state.pending_local_queue_takeover = None; + state.last_applied_queue_state = Some(queue.clone()); + state.local_playback_state_assertion_pending = true; + // The core already owns this exact queue and cursor. This is an + // acknowledgement, not an instruction to materialize/seek it again. + return false; + } + if pending.action_confirmed { + pending.retry_needed = true; + } + false +} + +/// True only when the session's active renderer is a peer and that exact peer +/// has reported the QConnect PLAYING wire state. A merely active but +/// paused/stopped/unknown peer must not stop real local playback. +pub fn active_peer_renderer_is_playing(state: &QconnectRemoteSyncState) -> bool { + let Some(active_renderer_id) = state.session.active_renderer_id else { + return false; + }; + if state.session.local_renderer_id == Some(active_renderer_id) { + return false; + } + state + .session_renderer_states + .get(&active_renderer_id) + .and_then(|renderer| renderer.playing_state) + == Some(crate::renderer::PLAYING_STATE_PLAYING) +} + +/// Local audio yields only after remote authority is fully settled. During a +/// local-queue takeover the old active peer can keep reporting PLAYING until +/// the cloud applies SET_ACTIVE_RENDERER; treating that stale report as current +/// authority would stop the very playback the user chose to preserve. +pub fn local_playback_should_yield_to_active_peer(state: &QconnectRemoteSyncState) -> bool { + !remote_renderer_commands_are_fenced(state) && active_peer_renderer_is_playing(state) +} + /// Set each cached renderer's `active` flag to match the session's current /// active renderer id. Pure mutation over the relocated accumulator; relocated /// from the Tauri adapter (slice 2+4) so the shared session-apply logic in @@ -122,3 +294,133 @@ pub fn cache_renderer_snapshot( .map(|item| item.track_id); state.last_renderer_playing_state = renderer_snapshot.playing_state; } + +#[cfg(test)] +mod tests { + use qconnect_core::{QConnectQueueState, QueueItem}; + + use super::*; + + fn queue(track_ids: &[u64]) -> QConnectQueueState { + QConnectQueueState { + queue_items: track_ids + .iter() + .enumerate() + .map(|(index, track_id)| QueueItem { + track_context_uuid: String::new(), + track_id: *track_id, + queue_item_id: index as u64 + 1, + }) + .collect(), + ..Default::default() + } + } + + #[test] + fn local_takeover_suppresses_stale_queue_but_accepts_its_echo() { + let mut state = QconnectRemoteSyncState::default(); + arm_local_queue_takeover(&mut state, vec![10, 20], "load-1".to_string()); + + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[90, 91]) + )); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[10, 20]) + )); + assert!(confirm_local_queue_takeover_action(&mut state, "load-1")); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[10, 20]) + )); + assert!(state.pending_local_queue_takeover.is_none()); + assert!(state.local_playback_state_assertion_pending); + assert!(remote_renderer_commands_are_fenced(&state)); + confirm_local_playback_state_asserted(&mut state); + assert!(!remote_renderer_commands_are_fenced(&state)); + } + + #[test] + fn stale_queue_arms_retry_without_expiring_local_authority() { + let mut state = QconnectRemoteSyncState::default(); + arm_local_queue_takeover(&mut state, vec![10, 20], "load-1".to_string()); + + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[90, 91]) + )); + assert!(!local_queue_takeover_needs_retry(&state)); + assert!(reject_local_queue_takeover_action(&mut state, "load-1")); + assert!(local_queue_takeover_needs_retry(&state)); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[90, 91]) + )); + } + + #[test] + fn same_tracks_do_not_complete_takeover_without_matching_action_ack() { + let mut state = QconnectRemoteSyncState::default(); + arm_local_queue_takeover(&mut state, vec![10, 20], "load-new".to_string()); + + assert!(!confirm_local_queue_takeover_action(&mut state, "load-old")); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[10, 20]) + )); + assert!(state.pending_local_queue_takeover.is_some()); + } + + #[test] + fn unresolved_playback_conflict_blocks_every_remote_queue() { + let mut state = QconnectRemoteSyncState::default(); + set_local_playback_conflict_pending(&mut state, true); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[90, 91]) + )); + } + + #[test] + fn only_a_playing_active_peer_requires_local_playback_to_yield() { + let mut state = QconnectRemoteSyncState::default(); + state.session.local_renderer_id = Some(1); + state.session.active_renderer_id = Some(2); + state.session_renderer_states.insert( + 2, + QconnectSessionRendererState { + playing_state: Some(crate::renderer::PLAYING_STATE_PAUSED), + ..Default::default() + }, + ); + assert!(!active_peer_renderer_is_playing(&state)); + + state + .session_renderer_states + .get_mut(&2) + .expect("peer state") + .playing_state = Some(crate::renderer::PLAYING_STATE_PLAYING); + assert!(active_peer_renderer_is_playing(&state)); + assert!(local_playback_should_yield_to_active_peer(&state)); + + // Option 3 has chosen local authority, but the old peer can continue + // reporting PLAYING until SET_ACTIVE_RENDERER reaches every client. + // That stale window must never stop the live local player. + arm_local_queue_takeover(&mut state, vec![10, 20], "load-local".to_string()); + assert!(!local_playback_should_yield_to_active_peer(&state)); + assert!(confirm_local_queue_takeover_action( + &mut state, + "load-local" + )); + assert!(!should_materialize_remote_queue( + &mut state, + &queue(&[10, 20]) + )); + assert!(state.local_playback_state_assertion_pending); + assert!(!local_playback_should_yield_to_active_peer(&state)); + + state.session.active_renderer_id = Some(1); + assert!(!active_peer_renderer_is_playing(&state)); + } +} diff --git a/crates/qconnect-core/src/pending.rs b/crates/qconnect-core/src/pending.rs index c7a2c6b45..d89e52346 100644 --- a/crates/qconnect-core/src/pending.rs +++ b/crates/qconnect-core/src/pending.rs @@ -17,6 +17,8 @@ pub struct PendingQueueAction { #[serde(default)] pub is_set_active_renderer_action: bool, #[serde(default)] + pub is_queue_load_tracks_action: bool, + #[serde(default)] pub expected_active_renderer_id: Option, pub concurrency_error: bool, pub sent_at_ms: u64,