From f6e1c4e0235f44a3d59e3bc8f1854ce85d8bdbdb Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:23:14 -0600 Subject: [PATCH] fix(qconnect): harden queue ownership handoffs Preserve local playback when it is authoritative, make renderer conflicts explicit and persist the selected policy. Filter unsupported queue rows, restore portable Qobuz identities after disconnect, and fence takeover commands to avoid stale queue and playback loops. cx --- .../translations/de/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/en/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/es/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/fr/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/ja/LC_MESSAGES/qbz-ui.po | 55 ++ .../translations/nl/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/pt/LC_MESSAGES/qbz-ui.po | 56 ++ .../translations/ru/LC_MESSAGES/qbz-ui.po | 57 ++ crates/qbz-qt/build.rs | 1 + crates/qbz-qt/qml/controls/QbzSelect.qml | 18 +- .../qbz-qt/qml/settings/PlaybackSettings.qml | 11 + crates/qbz-qt/qml/shell/AppShell.qml | 4 + crates/qbz-qt/qml/shell/KioskShell.qml | 4 + .../qbz-qt/qml/shell/NowPlayingBarSmall.qml | 6 +- crates/qbz-qt/qml/shell/PlayerBar.qml | 4 +- crates/qbz-qt/qml/shell/QconnectFlyout.qml | 61 +- .../shell/QconnectPlaybackConflictModal.qml | 190 ++++ crates/qbz-qt/src/album_qt.rs | 3 + crates/qbz-qt/src/bulk_tracks_qt.rs | 3 + crates/qbz-qt/src/cast_qt.rs | 50 +- crates/qbz-qt/src/local_album_actions.rs | 7 +- crates/qbz-qt/src/local_bulk.rs | 10 +- crates/qbz-qt/src/local_playback.rs | 11 +- crates/qbz-qt/src/local_playlist_qt.rs | 3 + crates/qbz-qt/src/myqbz_play_qt.rs | 55 +- crates/qbz-qt/src/playback_qt.rs | 141 ++- crates/qbz-qt/src/playlist_qt.rs | 3 + crates/qbz-qt/src/qconnect_bridge.rs | 29 +- crates/qbz-qt/src/qconnect_event_sink_qt.rs | 69 +- crates/qbz-qt/src/qconnect_qt.rs | 562 ++++++++--- crates/qbz-qt/src/qconnect_transport_qt.rs | 29 + crates/qbz-qt/src/queue_qt.rs | 12 +- crates/qbz-qt/src/settings_qt.rs | 29 + crates/qbz-source/src/id.rs | 10 +- crates/qbz-source/src/meta.rs | 2 +- crates/qbzd/src/qconnect/publish.rs | 166 ++-- crates/qbzd/src/qconnect/report.rs | 23 +- crates/qbzd/src/qconnect/session.rs | 23 + crates/qbzd/src/qconnect/sink.rs | 62 +- crates/qconnect-app/src/app.rs | 869 +++++++++++++++++- crates/qconnect-app/src/lib.rs | 30 +- crates/qconnect-app/src/playback_conflict.rs | 128 +++ crates/qconnect-app/src/renderer.rs | 269 +++++- crates/qconnect-app/src/session.rs | 44 +- crates/qconnect-app/src/sync_state.rs | 314 ++++++- crates/qconnect-core/src/pending.rs | 2 + 46 files changed, 3302 insertions(+), 403 deletions(-) create mode 100644 crates/qbz-qt/qml/shell/QconnectPlaybackConflictModal.qml create mode 100644 crates/qconnect-app/src/playback_conflict.rs 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,