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 c89a24063..db7e34cff 100644 --- a/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/de/LC_MESSAGES/qbz-ui.po @@ -6684,3 +6684,30 @@ msgstr "{} abgebrochen" msgid "Download finished" msgstr "Download abgeschlossen" + +msgid "Shuffle: On" +msgstr "Mischen: Ein" + +msgid "Shuffle: Off" +msgstr "Mischen: Aus" + +msgid "Repeat: Off" +msgstr "Wiederholen: Aus" + +msgid "Repeat: All" +msgstr "Wiederholen: Alle" + +msgid "Repeat: One" +msgstr "Wiederholen: Eins" + +msgid "Show album artwork in Queue track list" +msgstr "Album-Cover in der Titelliste der Warteschlange anzeigen" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Ersetzt Titelnummern in der Warteschlange durch Cover-Miniaturen. Standardmäßig deaktiviert." + +msgid "Show album artwork in Library track list" +msgstr "Album-Cover in der Titelliste der Bibliothek anzeigen" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Zeigt die Cover-Miniatur zwischen Titelnummer und Titel an. Standardmäßig deaktiviert." 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 3f4ff8b86..d95092060 100644 --- a/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/en/LC_MESSAGES/qbz-ui.po @@ -2271,3 +2271,30 @@ msgstr "Download finished" msgid "Not now" msgstr "Not now" + +msgid "Shuffle: On" +msgstr "Shuffle: On" + +msgid "Shuffle: Off" +msgstr "Shuffle: Off" + +msgid "Repeat: Off" +msgstr "Repeat: Off" + +msgid "Repeat: All" +msgstr "Repeat: All" + +msgid "Repeat: One" +msgstr "Repeat: One" + +msgid "Show album artwork in Queue track list" +msgstr "Show album artwork in Queue track list" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Replace queue track numbers with album cover thumbnails. Off by default." + +msgid "Show album artwork in Library track list" +msgstr "Show album artwork in Library track list" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Display the album cover thumbnail between the track number and title. Off by default." 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 0e3d3684f..2073046da 100644 --- a/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/es/LC_MESSAGES/qbz-ui.po @@ -6690,3 +6690,30 @@ msgstr "{} canceladas" msgid "Download finished" msgstr "Descarga finalizada" + +msgid "Shuffle: On" +msgstr "Aleatorio: activado" + +msgid "Shuffle: Off" +msgstr "Aleatorio: desactivado" + +msgid "Repeat: Off" +msgstr "Repetir: desactivado" + +msgid "Repeat: All" +msgstr "Repetir: todo" + +msgid "Repeat: One" +msgstr "Repetir: una" + +msgid "Show album artwork in Queue track list" +msgstr "Mostrar carátulas de álbum en la lista de pistas de la cola" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Reemplaza los números de pista de la cola con miniaturas de las carátulas. Desactivado por defecto." + +msgid "Show album artwork in Library track list" +msgstr "Mostrar carátulas de álbum en la lista de pistas de la Biblioteca" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Muestra la miniatura de la carátula entre el número y el título de la pista. Desactivado por defecto." 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 ac00d3f65..be0972602 100644 --- a/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/fr/LC_MESSAGES/qbz-ui.po @@ -6682,3 +6682,30 @@ msgstr "{} annulées" msgid "Download finished" msgstr "Téléchargement terminé" + +msgid "Shuffle: On" +msgstr "Lecture aléatoire : activée" + +msgid "Shuffle: Off" +msgstr "Lecture aléatoire : désactivée" + +msgid "Repeat: Off" +msgstr "Répéter : désactivé" + +msgid "Repeat: All" +msgstr "Répéter : tout" + +msgid "Repeat: One" +msgstr "Répéter : un titre" + +msgid "Show album artwork in Queue track list" +msgstr "Afficher les pochettes dans la liste des pistes de la file d’attente" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Remplace les numéros de piste de la file d’attente par des miniatures de pochette. Désactivé par défaut." + +msgid "Show album artwork in Library track list" +msgstr "Afficher les pochettes dans la liste des pistes de la Bibliothèque" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Affiche la miniature de la pochette entre le numéro et le titre de la piste. Désactivé par défaut." 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 c315afd00..71b7223d7 100644 --- a/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ja/LC_MESSAGES/qbz-ui.po @@ -6665,3 +6665,30 @@ msgstr "{} 件キャンセル" msgid "Download finished" msgstr "ダウンロード完了" + +msgid "Shuffle: On" +msgstr "シャッフル:オン" + +msgid "Shuffle: Off" +msgstr "シャッフル:オフ" + +msgid "Repeat: Off" +msgstr "リピート:オフ" + +msgid "Repeat: All" +msgstr "リピート:すべて" + +msgid "Repeat: One" +msgstr "リピート:1曲" + +msgid "Show album artwork in Queue track list" +msgstr "キューのトラックリストにアルバムアートワークを表示" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "キューのトラック番号をアルバムカバーのサムネイルに置き換えます。デフォルトではオフです。" + +msgid "Show album artwork in Library track list" +msgstr "ライブラリのトラックリストにアルバムアートワークを表示" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +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 d367231ce..ffd883b85 100644 --- a/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/nl/LC_MESSAGES/qbz-ui.po @@ -6672,3 +6672,30 @@ msgstr "{} geannuleerd" msgid "Download finished" msgstr "Download voltooid" + +msgid "Shuffle: On" +msgstr "Shuffelen: aan" + +msgid "Shuffle: Off" +msgstr "Shuffelen: uit" + +msgid "Repeat: Off" +msgstr "Herhalen: uit" + +msgid "Repeat: All" +msgstr "Herhalen: alles" + +msgid "Repeat: One" +msgstr "Herhalen: één nummer" + +msgid "Show album artwork in Queue track list" +msgstr "Albumhoezen tonen in de nummerlijst van de wachtrij" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Vervang nummers in de wachtrij door miniaturen van albumhoezen. Standaard uit." + +msgid "Show album artwork in Library track list" +msgstr "Albumhoezen tonen in de nummerlijst van de bibliotheek" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Toon de albumhoesminiatuur tussen het nummer en de titel. Standaard uit." 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 bd4f9df3d..d6bad6194 100644 --- a/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/pt/LC_MESSAGES/qbz-ui.po @@ -6682,3 +6682,30 @@ msgstr "{} canceladas" msgid "Download finished" msgstr "Download concluído" + +msgid "Shuffle: On" +msgstr "Aleatório: ativado" + +msgid "Shuffle: Off" +msgstr "Aleatório: desativado" + +msgid "Repeat: Off" +msgstr "Repetir: desativado" + +msgid "Repeat: All" +msgstr "Repetir: todas" + +msgid "Repeat: One" +msgstr "Repetir: uma" + +msgid "Show album artwork in Queue track list" +msgstr "Mostrar capas de álbum na lista de faixas da fila" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Substitui os números das faixas da fila por miniaturas das capas. Desligado por padrão." + +msgid "Show album artwork in Library track list" +msgstr "Mostrar capas de álbum na lista de faixas da Biblioteca" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Exibe a miniatura da capa entre o número e o título da faixa. Desligado por padrão." 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 50c5e20fc..436b14197 100644 --- a/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po +++ b/crates/qbz-i18n/translations/ru/LC_MESSAGES/qbz-ui.po @@ -6703,3 +6703,30 @@ msgstr "Отменено: {}" msgid "Download finished" msgstr "Загрузка завершена" + +msgid "Shuffle: On" +msgstr "Перемешивание: вкл." + +msgid "Shuffle: Off" +msgstr "Перемешивание: выкл." + +msgid "Repeat: Off" +msgstr "Повтор: выкл." + +msgid "Repeat: All" +msgstr "Повтор: все" + +msgid "Repeat: One" +msgstr "Повтор: один трек" + +msgid "Show album artwork in Queue track list" +msgstr "Показывать обложки альбомов в списке треков очереди" + +msgid "Replace queue track numbers with album cover thumbnails. Off by default." +msgstr "Заменяет номера треков в очереди миниатюрами обложек альбомов. Отключено по умолчанию." + +msgid "Show album artwork in Library track list" +msgstr "Показывать обложки альбомов в списке треков библиотеки" + +msgid "Display the album cover thumbnail between the track number and title. Off by default." +msgstr "Показывает миниатюру обложки между номером и названием трека. Отключено по умолчанию." diff --git a/crates/qbz-player/src/queue.rs b/crates/qbz-player/src/queue.rs index dc3f974e4..09f2c162a 100644 --- a/crates/qbz-player/src/queue.rs +++ b/crates/qbz-player/src/queue.rs @@ -1334,24 +1334,40 @@ impl QueueManager { if state.shuffle == enabled { return; } - state.shuffle = enabled; if enabled { + state.shuffle = true; // The manual block is meaningless once the order is reshuffled (#442). state.manual_next_count = 0; Self::regenerate_shuffle_order_internal(&mut state); - // Enabling shuffle during active playback must keep current track - // as the first item in the shuffled timeline. Otherwise, indices - // before current are interpreted as already played. + // Shuffle only the part of the linear queue that has not been + // traversed yet. The canonical prefix stays behind the cursor, so + // enabling shuffle midway through an album cannot replay tracks + // the user already passed. if let Some(curr_idx) = state.current_index { - if let Some(pos) = state.shuffle_order.iter().position(|&idx| idx == curr_idx) { - if pos != 0 { - state.shuffle_order.swap(0, pos); - } - state.shuffle_position = 0; - } + let mut order = (0..=curr_idx).collect::>(); + order.extend( + state + .shuffle_order + .iter() + .copied() + .filter(|&idx| idx > curr_idx), + ); + state.shuffle_order = order; + state.shuffle_position = curr_idx; } + } else { + // Turning shuffle off is a playback-order transition, not merely + // a flag change. If the shuffled cursor is on canonical track 8, + // deriving the ordinary tail from index 8 would silently discard + // every unheard track whose canonical index is lower. Flatten the + // traversed shuffle prefix + NOW + every still-unheard canonical + // occurrence into the physical queue before returning to linear + // traversal. History is remapped by occurrence index below and + // therefore keeps its chronology and duplicate rows intact. + Self::linearize_after_shuffle_internal(&mut state); + state.shuffle = false; } } @@ -1360,14 +1376,20 @@ impl QueueManager { /// instead of generating a second independent shuffle. pub fn set_shuffle_with_order(&self, enabled: bool, shuffle_order: Option>) { let mut state = self.state.lock().unwrap(); - state.shuffle = enabled; if !enabled { - state.shuffle_order.clear(); - state.shuffle_position = 0; + if state.shuffle { + Self::linearize_after_shuffle_internal(&mut state); + } else { + state.shuffle_order.clear(); + state.shuffle_position = 0; + } + state.shuffle = false; return; } + state.shuffle = true; + if let Some(order) = shuffle_order.filter(|order| Self::is_valid_shuffle_order(order, state.tracks.len())) { @@ -1645,6 +1667,74 @@ impl QueueManager { } } + /// Convert the active shuffled timeline into a linear queue without + /// losing unheard rows whose canonical indices precede the current one. + /// + /// The new physical order is: + /// `traversed shuffle prefix · current · unheard rows in canonical order`. + /// Every coordinate stored by the manager is then remapped from the old + /// occurrence index to the new one. Track ids are deliberately irrelevant: + /// two separately queued copies of the same recording must remain two + /// separately positioned occurrences. + fn linearize_after_shuffle_internal(state: &mut InternalState) { + let track_count = state.tracks.len(); + let Some(current_index) = state.current_index.filter(|&idx| idx < track_count) else { + state.shuffle_order.clear(); + state.shuffle_position = 0; + return; + }; + let Some(current_position) = state + .shuffle_order + .iter() + .position(|&idx| idx == current_index) + else { + // Defensive fallback for a damaged/incomplete order: preserve the + // physical queue rather than guessing which rows were traversed. + state.shuffle_order.clear(); + state.shuffle_position = 0; + return; + }; + + let mut seen = vec![false; track_count]; + let mut linear_order = Vec::with_capacity(track_count); + for &index in state.shuffle_order.iter().take(current_position) { + if index < track_count && !seen[index] { + seen[index] = true; + linear_order.push(index); + } + } + if !seen[current_index] { + seen[current_index] = true; + linear_order.push(current_index); + } + for (index, was_seen) in seen.iter().enumerate() { + if !was_seen { + linear_order.push(index); + } + } + + let mut old_to_new = vec![None; track_count]; + for (new_index, &old_index) in linear_order.iter().enumerate() { + old_to_new[old_index] = Some(new_index); + } + let mut slots = std::mem::take(&mut state.tracks) + .into_iter() + .map(Some) + .collect::>(); + state.tracks = linear_order + .iter() + .filter_map(|&old_index| slots[old_index].take()) + .collect(); + state.current_index = old_to_new[current_index]; + state.history = state + .history + .iter() + .filter_map(|&old_index| old_to_new.get(old_index).copied().flatten()) + .collect(); + state.shuffle_order.clear(); + state.shuffle_position = 0; + } + /// Record one canonical queue occurrence as the newest played entry. /// Replaying that SAME occurrence moves its existing record to the end; /// independently enqueued copies remain distinct because their canonical @@ -2326,6 +2416,123 @@ mod tests { assert_eq!(state.upcoming.len(), 10); } + #[test] + fn disabling_shuffle_keeps_every_unheard_track_upcoming() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=9).map(create_test_track).collect(), + Some(0), + true, + Some(vec![0, 7, 3, 1, 5, 8, 2, 6, 4]), + ); + + assert_eq!(queue.next().expect("shuffled track 8").id, 8); + queue.set_shuffle(false); + + let state = queue.get_state_full(); + assert!(!state.shuffle); + assert_eq!(state.total_tracks, 9); + assert_eq!(state.current_track.expect("current track").id, 8); + assert_eq!( + state + .history + .iter() + .map(|track| track.id) + .collect::>(), + vec![1] + ); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![2, 3, 4, 5, 6, 7, 9] + ); + + let mut played_after_unshuffle = Vec::new(); + while let Some(track) = queue.next() { + played_after_unshuffle.push(track.id); + } + assert_eq!(played_after_unshuffle, vec![2, 3, 4, 5, 6, 7, 9]); + } + + #[test] + fn authoritative_shuffle_disable_keeps_every_unheard_track_upcoming() { + let queue = QueueManager::new(); + queue.set_queue_with_order( + (1..=6).map(create_test_track).collect(), + Some(0), + true, + Some(vec![0, 4, 2, 5, 1, 3]), + ); + + assert_eq!(queue.next().expect("shuffled track 5").id, 5); + queue.set_shuffle_with_order(false, None); + + let state = queue.get_state_full(); + assert!(!state.shuffle); + assert_eq!(state.current_track.expect("current track").id, 5); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![2, 3, 4, 6] + ); + } + + #[test] + fn disabling_shuffle_remaps_duplicate_occurrences_without_merging_them() { + let queue = QueueManager::new(); + let mut first_copy = create_test_track(7); + first_copy.title = "First copy".into(); + let mut second_copy = create_test_track(7); + second_copy.title = "Second copy".into(); + queue.set_queue_with_order( + vec![ + first_copy, + create_test_track(8), + second_copy, + create_test_track(9), + ], + Some(0), + true, + Some(vec![0, 2, 1, 3]), + ); + + assert_eq!(queue.next().expect("second copy").title, "Second copy"); + queue.set_shuffle(false); + + let state = queue.get_state_full(); + assert_eq!(state.total_tracks, 4); + assert_eq!(state.history[0].title, "First copy"); + assert_eq!(state.current_track.expect("current copy").title, "Second copy"); + assert_eq!( + state + .upcoming + .iter() + .map(|track| track.id) + .collect::>(), + vec![8, 9] + ); + } + + #[test] + fn enabling_shuffle_mid_queue_does_not_requeue_the_traversed_prefix() { + let queue = QueueManager::new(); + queue.set_queue((1..=7).map(create_test_track).collect(), Some(3)); + + queue.set_shuffle(true); + + let state = queue.get_state_full(); + assert!(state.shuffle); + assert_eq!(state.current_track.expect("current track").id, 4); + assert_eq!(state.upcoming.len(), 3); + assert!(state.upcoming.iter().all(|track| track.id > 4)); + } + #[test] fn test_play_upcoming_at_without_shuffle_uses_linear_offset() { let queue = QueueManager::new(); diff --git a/crates/qbz-qt/qml/settings/AppearanceSettings.qml b/crates/qbz-qt/qml/settings/AppearanceSettings.qml index 5e6961571..20cc03ed3 100644 --- a/crates/qbz-qt/qml/settings/AppearanceSettings.qml +++ b/crates/qbz-qt/qml/settings/AppearanceSettings.qml @@ -440,6 +440,22 @@ Column { onToggled: function (v) { QbzBridge.settingsBool("sidebar-playlist-collage", v) } } } + SettingRow { + label: QbzSession.tr("Show album artwork in Queue track list", QbzSession.trRev) + description: QbzSession.tr("Replace queue track numbers with album cover thumbnails. Off by default.", QbzSession.trRev) + QbzToggle { + checked: root.doc.queueTrackArtwork === true + onToggled: function (v) { QbzBridge.settingsBool("queue-track-artwork", v) } + } + } + SettingRow { + label: QbzSession.tr("Show album artwork in Library track list", QbzSession.trRev) + description: QbzSession.tr("Display the album cover thumbnail between the track number and title. Off by default.", QbzSession.trRev) + QbzToggle { + checked: root.doc.libraryTrackArtwork === true + onToggled: function (v) { QbzBridge.settingsBool("library-track-artwork", v) } + } + } SettingRow { label: QbzSession.tr("Show album artwork in Local Library track list", QbzSession.trRev) description: QbzSession.tr("Display the album cover thumbnail next to each track in the Tracks and Folders views. Off by default — large libraries pay a per-row image-decode cost.", QbzSession.trRev) diff --git a/crates/qbz-qt/qml/shell/AppShell.qml b/crates/qbz-qt/qml/shell/AppShell.qml index cd541d1bf..639fa49cf 100644 --- a/crates/qbz-qt/qml/shell/AppShell.qml +++ b/crates/qbz-qt/qml/shell/AppShell.qml @@ -242,9 +242,8 @@ Rectangle { // header-tall row; New/Classic/Large keep the full 112px. height: QbzShell.npbMode === 2 ? theme.npbSmallHeight : theme.npbLargeHeight // The shared hover-tooltip overlay (declared further down — id - // references resolve at completion). Only the FULL bar consumes it - // (the Qobuz Connect button's "Qobuz Connect: On/Off" bubble); the - // small bar's button has no tooltip in the reference. + // references resolve at completion). All four modes consume it for + // Shuffle/Repeat state; the full bar also uses it for Qobuz Connect. tooltip: tooltipOverlay } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBar.qml b/crates/qbz-qt/qml/shell/NowPlayingBar.qml index b817cd305..69fc685ec 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBar.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBar.qml @@ -11,9 +11,8 @@ Item { // The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml), // fed in by AppShell (same pattern as Sidebar.tooltip). Forwarded into - // the loaded bar below. Only the FULL bar consumes it — the Qobuz - // Connect button's "Qobuz Connect: On/Off" bubble (contract §8: the - // small bar's button has no tooltip in the reference). + // whichever mode is loaded below: all four modes use it for dynamic + // Shuffle/Repeat state, and the full bar also uses it for Qobuz Connect. property Item tooltip: null Loader { @@ -24,15 +23,13 @@ Item { // Same shape as AppShell's viewLoader "kind" Binding: applies the moment // the item exists, re-applies on a mode switch, RestoreNone because the - // target is DESTROYED on unload. The `when` source guard keeps it off - // NowPlayingBarSmall, which declares no `tooltip` property (a grouped - // assignment against a missing property would fail the load). + // target is DESTROYED on unload. Both bar implementations declare the + // property, so a mode switch keeps the same overlay host. Binding { target: barLoader.item property: "tooltip" value: root.tooltip when: barLoader.item !== null - && barLoader.source.toString().indexOf("PlayerBar.qml") !== -1 restoreMode: Binding.RestoreNone } } diff --git a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml index 99e9248fe..3ffeaa5f4 100644 --- a/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml +++ b/crates/qbz-qt/qml/shell/NowPlayingBarSmall.qml @@ -47,6 +47,8 @@ Rectangle { // Feature parked for a later release. Keep the compact renderer in place // but use the classic seekbar consistently with the other NPB modes. readonly property bool waveformVisible: false + /// AppShell's one shared hover-tooltip overlay. + property Item tooltip: null QbzTheme { id: theme } @@ -412,6 +414,7 @@ Rectangle { playCircle: false favorite: root.npFavorite ephemeral: root.npEphemeral + tooltip: root.tooltip onAddRequested: function (anchorItem) { smallAddMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } diff --git a/crates/qbz-qt/qml/shell/PlayerBar.qml b/crates/qbz-qt/qml/shell/PlayerBar.qml index 03da3fec8..a8427067e 100644 --- a/crates/qbz-qt/qml/shell/PlayerBar.qml +++ b/crates/qbz-qt/qml/shell/PlayerBar.qml @@ -78,10 +78,9 @@ Rectangle { readonly property bool waveformVisible: false // The shell's shared hover-tooltip overlay (controls/QbzTooltip.qml), - // fed in through NowPlayingBar.qml's Binding. Consumed by the Qobuz - // Connect button's "Qobuz Connect: On/Off" bubble — the ONE tooltip the - // reference binds on this bar (PlayerBar.slint:405-407; the small bar's - // button has none, contract §8). + // fed in through NowPlayingBar.qml's Binding. Consumed by the dynamic + // Shuffle/Repeat state bubbles and by the Qobuz Connect button's + // "Qobuz Connect: On/Off" bubble. property Item tooltip: null // Volume lock, both halves (contract §11.3 = PlayerBar.slint:152-158's @@ -462,6 +461,7 @@ Rectangle { classicActions: true favorite: root.npFavorite ephemeral: root.npEphemeral + tooltip: root.tooltip onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } @@ -483,6 +483,7 @@ Rectangle { anchors.centerIn: parent playCircle: true favorite: root.npFavorite + tooltip: root.tooltip onAddRequested: function (anchorItem) { addMenu.openBelowRight(anchorItem) } onTrackInfoRequested: root.openTrackInfo() } diff --git a/crates/qbz-qt/qml/shell/QueuePanel.qml b/crates/qbz-qt/qml/shell/QueuePanel.qml index 514f499d5..262dba1f2 100644 --- a/crates/qbz-qt/qml/shell/QueuePanel.qml +++ b/crates/qbz-qt/qml/shell/QueuePanel.qml @@ -38,6 +38,13 @@ Rectangle { readonly property var currentRow: doc.current || null readonly property var upcoming: doc.upcoming || [] readonly property var historyRows: doc.history || [] + readonly property var settingsDoc: { + try { return JSON.parse(QbzBridge.settingsJson) } + catch (e) { return ({}) } + } + // Opt-in: Up Next swaps its numeral for the same thumbnail History uses. + readonly property bool queueTrackArtwork: + settingsDoc.queueTrackArtwork === true readonly property bool queueEmpty: currentRow === null && (doc.upcomingTotal || 0) === 0 // "Stop after this song": the decimal id of the marked track, "" for none. readonly property string stopAfterId: doc.stopAfterId || "" @@ -218,6 +225,7 @@ Rectangle { } Component.onCompleted: dispatchCovers() onDocChanged: dispatchCovers() + onQueueTrackArtworkChanged: dispatchCovers() // Collect the covers of the document CURRENTLY on the property and ask the // shared pipeline for them. // @@ -243,7 +251,8 @@ Rectangle { var urls = [] if (cur && cur.artUrl) urls.push(cur.artUrl) var i - for (i = 0; i < up.length; i++) if (up[i].artUrl) urls.push(up[i].artUrl) + if (root.queueTrackArtwork) + for (i = 0; i < up.length; i++) if (up[i].artUrl) urls.push(up[i].artUrl) for (i = 0; i < hist.length; i++) if (hist[i].artUrl) urls.push(hist[i].artUrl) if (urls.length > 0) { QbzShell.sidebarArtworkWindow(JSON.stringify(urls)) @@ -314,6 +323,8 @@ Rectangle { property int rowIndex: 0 property bool showNumber: true property bool inQueue: true + readonly property bool artworkLeading: + !qrRoot.showNumber || root.queueTrackArtwork /// Drag-reorder source? UP NEXT rows only — History has no order to /// commit, and it keeps its drag-scroll because of this (see qrArea's /// preventStealing). @@ -366,12 +377,11 @@ Rectangle { anchors.bottomMargin: 4 spacing: 9 - // Leading: track number (UP NEXT) or thumbnail (History). On the - // row carrying the "stop after this song" marker the number is - // REPLACED by the accent CircleStop — same slot, so the list never - // reflows when a marker is placed or cleared. + // Leading: track number (default UP NEXT) or thumbnail (History, + // plus opt-in UP NEXT). On the row carrying the "stop after this + // song" marker, the status still owns this fixed slot. Item { - visible: showNumber + visible: showNumber && !root.queueTrackArtwork width: 22 height: parent.height readonly property bool marked: qrRoot.inQueue @@ -414,7 +424,7 @@ Rectangle { } } Rectangle { - visible: !showNumber + visible: qrRoot.artworkLeading width: 34 height: 34 radius: 4 @@ -437,11 +447,31 @@ Rectangle { phase: root.skelPhase settleMs: root.artSettleMs } + // When artwork replaces the numeral, preserve the two states + // that used to occupy that slot. The scrim makes either glyph + // legible without growing or shifting the 44px row. + Rectangle { + readonly property bool marked: qrRoot.inQueue + && root.stopAfterId !== "" && row.id === root.stopAfterId + visible: qrRoot.showNumber + && (marked || qrRoot.pulledDead) + anchors.fill: parent + radius: 4 + color: "#a6000000" + QbzIcon { + anchors.centerIn: parent + name: parent.marked ? "circle-stop" : "circle-alert" + width: 14 + height: 14 + tintName: parent.marked ? "accent" : "favorite" + } + } } // Title + artist. Column { - width: parent.width - (showNumber ? 22 : 34) - durText.width - 32 - 3 * 9 + width: parent.width - (qrRoot.artworkLeading ? 34 : 22) + - durText.width - 32 - 3 * 9 anchors.verticalCenter: parent.verticalCenter spacing: 2 Row { diff --git a/crates/qbz-qt/qml/shell/TransportControls.qml b/crates/qbz-qt/qml/shell/TransportControls.qml index 6d27ca12a..a8cc8870c 100644 --- a/crates/qbz-qt/qml/shell/TransportControls.qml +++ b/crates/qbz-qt/qml/shell/TransportControls.qml @@ -25,6 +25,8 @@ Row { property bool favorite: false /// The playing track is ephemeral — the heart has nothing to write to. property bool ephemeral: false + /// AppShell's one shared hover-tooltip overlay. + property var tooltip: null /// Emitted by "+" with the button as anchor, so the flyout is owned /// by the bar (one menu, both mount points). @@ -68,6 +70,11 @@ Row { btnEnabled: QbzPlayer.npHasTrack anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.toggleShuffle() + tooltip: tc.tooltip + tooltipKey: "npb-shuffle" + tooltipText: QbzPlayer.npShuffle + ? QbzSession.tr("Shuffle: On", QbzSession.trRev) + : QbzSession.tr("Shuffle: Off", QbzSession.trRev) } // True while the player is resolving/fetching the track that is about to // play. Not gated on `npHasTrack`: the first play of a session has no @@ -165,6 +172,13 @@ Row { btnEnabled: QbzPlayer.npHasTrack anchors.verticalCenter: parent.verticalCenter onClicked: QbzPlayer.cycleRepeat() + tooltip: tc.tooltip + tooltipKey: "npb-repeat" + tooltipText: QbzPlayer.npRepeatMode === 2 + ? QbzSession.tr("Repeat: One", QbzSession.trRev) + : (QbzPlayer.npRepeatMode === 1 + ? QbzSession.tr("Repeat: All", QbzSession.trRev) + : QbzSession.tr("Repeat: Off", QbzSession.trRev)) } // "+" — Tauri's add-to-playlist button, grouped into the shared // "Add to…" flyout in 2.0. diff --git a/crates/qbz-qt/qml/views/LibraryView.qml b/crates/qbz-qt/qml/views/LibraryView.qml index 611fe69fe..b6b88013b 100644 --- a/crates/qbz-qt/qml/views/LibraryView.qml +++ b/crates/qbz-qt/qml/views/LibraryView.qml @@ -77,6 +77,16 @@ Rectangle { property string activeTab: "all" + readonly property var settingsDoc: { + try { return JSON.parse(QbzBridge.settingsJson) } + catch (e) { return ({}) } + } + // Independent opt-in from Queue and Local Library. TrackRow already owns + // the required number → artwork → title geometry, so this only arms it. + readonly property bool libraryTrackArtwork: + settingsDoc.libraryTrackArtwork === true + onLibraryTrackArtworkChanged: Qt.callLater(root.listWindowReport) + readonly property var counts: JSON.parse(QbzLibrary.libraryCountsJson) // Full merged feed (parsed once per publish — timed for the report). readonly property var feed: parseFeed(QbzLibrary.libraryJson) @@ -85,6 +95,12 @@ Rectangle { var f = JSON.parse(json) var totals = { "tracks": 0, "albums": 0, "artists": 0, "playlists": 0, "labels": 0 } + // Tracks / Albums now expose both favourite and purchased sources. + // A purchased entity can also be favourited and therefore appear in + // the merged feed twice; count the entity, not its source rows. + var tabSeen = ({}) + var tabPurchased = ({}) + var tabFavorites = ({}) // Normalize the strings once per Rust publish. Library > All can hold // the complete local library, so repeating toLowerCase() in every // search pass and every sort comparison turns one key press into a @@ -96,10 +112,16 @@ Rectangle { it._artistLc = String(it.artist || "").toLowerCase() it._albumLc = String(it.album || "").toLowerCase() it._genreLc = String(it.genre || "").toLowerCase() - if (it.kind === "track") { - if (it.group === "favorites") totals.tracks++ - } else if (it.kind === "album") { - if (it.group === "favorites") totals.albums++ + if (it.kind === "track" || it.kind === "album") { + if (it.group === "favorites" || it.group === "purchases") { + var tabKey = it.kind + ":" + it.id + if (it.group === "purchases") tabPurchased[tabKey] = true + else tabFavorites[tabKey] = true + if (!tabSeen[tabKey]) { + tabSeen[tabKey] = true + totals[it.kind + "s"]++ + } + } } else if (it.kind === "artist") totals.artists++ else if (it.kind === "playlist") totals.playlists++ else if (it.kind === "label") totals.labels++ @@ -108,6 +130,8 @@ Rectangle { // same feed. Carry the result on the Array itself (outside `length`), // so all consumers keep the exact same row array and model identity. f._tabTotals = totals + f._tabPurchased = tabPurchased + f._tabFavorites = tabFavorites // Was a bare console.log, i.e. printed on every publish for every user // forever. It belongs to the same investigation as the derive timing // right below it, so it now rides the same category and is silent @@ -174,10 +198,17 @@ Rectangle { property string artistsGroup: "off" // "off" | "alpha" property string sortBy: "date" // "date" | "title" | "artist" property bool sortAsc: false // date: false = newest first + // Shared source state for All / Tracks / Albums. The bridge singleton + // survives navigation, so the two new per-tab toggles inherit the same + // session persistence (and default ON) as their All-tab counterparts. readonly property bool showPurchases: QbzLibrary.sessionShowPurchases readonly property bool showFavorites: QbzLibrary.sessionShowFavorites readonly property bool showFollowing: QbzLibrary.sessionShowFollowing property bool showLocal: true + // Only bumped when a live heart change can alter membership in the + // current Tracks / Albums source filter. Kept separate from `feed` so the + // normal card-heart path still avoids rebuilding a large scrolled model. + property int favoriteFilterRev: 0 property string viewMode: "grid" // All tab: "grid" | "list" property string albumsView: "grid" // "grid" | "list" property string playlistsView: "grid" // "grid" | "list" @@ -283,10 +314,15 @@ Rectangle { hidden.push(QbzSession.tr("Favorites", tr)) if (!root.showPurchases) hidden.push(QbzSession.tr("Purchases", tr)) - if (!root.showFollowing) - hidden.push(QbzSession.tr("Following", tr)) - if (!root.showLocal) - hidden.push(QbzSession.tr("Local", tr)) + // Following / Local are All-only sources. Do not claim they filter + // the Albums or Tracks toolbar just because their shared session + // switches happen to be off on All. + if (root.activeTab === "all") { + if (!root.showFollowing) + hidden.push(QbzSession.tr("Following", tr)) + if (!root.showLocal) + hidden.push(QbzSession.tr("Local", tr)) + } var out = [] if (hidden.length > 0) out.push({ group: QbzSession.tr("Hidden", tr), values: hidden }) @@ -396,6 +432,9 @@ Rectangle { // rides the shared route-timing category — it is silent unless // QT_LOGGING_RULES="qbz.nav.timing.info=true". readonly property var visibleRows: { + // Explicit dependency for the narrow live-membership invalidation + // above; the value itself is not part of the derive. + var _favoriteFilterRev = root.favoriteFilterRev var _t = Date.now() var _rows = visibleItems() var _line = "[libtiming] derive tab=" + activeTab + " feed=" + feed.length @@ -511,14 +550,44 @@ Rectangle { || it._titleLc.indexOf(tabNeedle) >= 0 || it._artistLc.indexOf(tabNeedle) >= 0 } - var tabGenres = (activeTab === "tracks" || activeTab === "albums") - ? root.genreNames : [] + var sourceTab = activeTab === "tracks" || activeTab === "albums" + var sourceKind = activeTab === "tracks" ? "track" : "album" + var tabGenres = sourceTab ? root.genreNames : [] + // Mobile-Qobuz semantics (owner video, 2026-09-03): neither switch is + // the unconstrained union, either switch alone selects that source, + // and both switches select their INTERSECTION. Membership comes from + // the actual source rows, not the asynchronously-warmed isFavorite + // cache on a purchase row. Rendering still prefers the purchase row + // so a qualifying favourite retains its purchase-quality metadata. + var purchased = feed._tabPurchased || ({}) + var favorites = feed._tabFavorites || ({}) + var tabSeen = ({}) for (i = 0; i < feed.length; i++) { var x = feed[i] var keep = false - if (activeTab === "tracks") keep = x.kind === "track" && x.group === "favorites" - else if (activeTab === "albums") keep = x.kind === "album" && x.group === "favorites" - else if (activeTab === "artists") keep = x.kind === "artist" + if (sourceTab && x.kind === sourceKind) { + var sourceKey = sourceKind + ":" + x.id + var isPurchased = purchased[sourceKey] === true + var isFavorite = favorites[sourceKey] === true + var included + if (showPurchases && showFavorites) { + included = isPurchased && isFavorite + } else if (showPurchases) { + included = isPurchased + } else if (showFavorites) { + included = isFavorite + } else { + included = isPurchased || isFavorite + } + // One representative row per included entity. A purchase row + // wins even in Favorites-only so its quality badge survives. + keep = included && ((isPurchased && x.group === "purchases") + || (!isPurchased && x.group === "favorites")) + if (keep) { + if (tabSeen[sourceKey]) keep = false + else tabSeen[sourceKey] = true + } + } else if (activeTab === "artists") keep = x.kind === "artist" else if (activeTab === "labels") keep = x.kind === "label" else if (activeTab === "playlists") keep = x.kind === "playlist" && (playlistsSubTab === "following" ? x.group === "following" : x.group === "favorites") @@ -822,21 +891,39 @@ Rectangle { // scroll offset to 0 and rebuilds every delegate. // // Scrolled to row 40 in a 3,000-row feed, one heart click sent the - // user back to row 0. Nothing on screen needed the re-derive: the - // grid cards each listen to this same signal for their own key + // user back to row 0. Ordinarily nothing on screen needs the + // re-derive: the grid cards each listen to this same signal for their + // own key // (cards/AlbumCard.qml, TrackCard.qml, PlaylistCard.qml, // ArtistCard.qml) and the list rows do the same // (library/FeedListRow.qml). // The in-place mutation is for the delegates built LATER — when the // user scrolls, re-filters or switches tab — and it reaches them - // because `visibleRows` pushes the very same row objects. + // because `visibleRows` pushes the very same row objects. The narrow + // exception is a Tracks / Albums combination whose membership + // depends on the heart; `favoriteFilterRev` re-derives only then. // // Same reasoning as onPinChanged below; the two are now symmetric. function onLibraryFavoriteChanged(key, value) { var f = root.feed for (var i = 0; i < f.length; i++) { - if (f[i].artKey === key) { f[i].isFavorite = value; break } + // A purchased favourite has one row per source; patch both. + if (f[i].artKey === key) f[i].isFavorite = value } + // Keep the entity-level source set live even when a newly-added + // favourite has no `favorites` row until the next API refresh. + if (f._tabFavorites) { + if (value) f._tabFavorites[key] = true + else delete f._tabFavorites[key] + } + var sourceTab = root.activeTab === "tracks" + || root.activeTab === "albums" + // Favourite membership affects the model in every combination + // except Purchases-only. Re-derive only in those cases; elsewhere + // the mounted delegates consume this same signal directly and + // retain the existing no-scroll-reset path. + if (sourceTab && (root.showFavorites || !root.showPurchases)) + root.favoriteFilterRev++ } // Pin state settled in the store. The row is patched IN PLACE and the // feed is deliberately NOT re-signalled: `feedChanged()` re-derives @@ -1252,8 +1339,19 @@ Rectangle { Component { id: trackRowComp TrackRow { - item: modelData + item: { + var art = root.libraryTrackArtwork + ? (root.artMap[modelData.artKey] || "") : "" + return art === "" ? modelData + : Object.assign({}, modelData, { "artPath": art }) + } number: modelData._no || (index + 1) + showArtwork: root.libraryTrackArtwork + artPending: root.libraryTrackArtwork + && (modelData.imageUrl || "") !== "" + && (root.artMap[modelData.artKey] || "") === "" + skelPhase: root.skelPhase + artSettleMs: 2500 selectMode: root.tracksMultiSelect checked: root.tracksSelected[modelData.id] === true onToggleSelect: function (mods) { root.toggleTrackSelected(modelData.id, mods) } @@ -1503,6 +1601,12 @@ Rectangle { } function listWindowReport() { if (!list.visible) return + // With the Library track-art opt-in disabled this list renders no + // images; release its window instead of decoding invisible covers. + if (root.activeTab === "tracks" && !root.libraryTrackArtwork) { + root.reportWindow([], 0, -1) + return + } // Variable-height group headers and the All tab's 2px zebra gutter // make arithmetic row pitches approximate. Ask the native ListView // for the real visible indices and keep a four-row artwork runway. diff --git a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml index 86df96f4c..6c642d9c0 100644 --- a/crates/qbz-qt/qml/views/library/LibraryToolbar.qml +++ b/crates/qbz-qt/qml/views/library/LibraryToolbar.qml @@ -238,8 +238,8 @@ Item { activeId: root.view.activeTab tabs: [ { "id": "all", "label": QbzSession.tr("All", QbzSession.trRev), "count": root.view.counts.all || 0 }, - { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.counts.tracks || 0 }, - { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.counts.albums || 0 }, + { "id": "tracks", "label": QbzSession.tr("Tracks", QbzSession.trRev), "count": root.view.tabTotals.tracks || 0 }, + { "id": "albums", "label": QbzSession.tr("Albums", QbzSession.trRev), "count": root.view.tabTotals.albums || 0 }, { "id": "artists", "label": QbzSession.tr("Artists", QbzSession.trRev), "count": root.view.counts.artists || 0 }, { "id": "playlists", "label": QbzSession.tr("Playlists", QbzSession.trRev), "count": root.view.counts.playlists || 0 }, { "id": "labels", "label": QbzSession.tr("Labels", QbzSession.trRev), "count": root.view.counts.labels || 0 }, @@ -511,6 +511,24 @@ Item { onEdited: function (v) { root.view.tabSearch = v } } + // Tracks / Albums source switches. They deliberately sit between the + // fixed 30px search slot and Filter by genre: ExpandableSearch grows + // left, so opening it can never cover or push either toggle. + ToolToggle { + visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums") + && root.view.tabHasItems + name: "shopping-bag" + active: root.view.showPurchases + onClicked: root.view.setShowPurchases(!root.view.showPurchases) + } + ToolToggle { + visible: (root.view.activeTab === "tracks" || root.view.activeTab === "albums") + && root.view.tabHasItems + name: "heart" + active: root.view.showFavorites + onClicked: root.view.setShowFavorites(!root.view.showFavorites) + } + // Filter by genre — the SAME shared popup the All toolbar uses. The // reference draws it on Tracks and Albums too (FavoritesView.slint: // 609, :652); this port had it on All only, so the two biggest tabs diff --git a/crates/qbz-qt/src/playback_qt.rs b/crates/qbz-qt/src/playback_qt.rs index 116b3cf57..fe1050f62 100644 --- a/crates/qbz-qt/src/playback_qt.rs +++ b/crates/qbz-qt/src/playback_qt.rs @@ -3624,7 +3624,7 @@ pub async fn toggle_mute(runtime: &Arc>) { } pub async fn toggle_shuffle(runtime: &Arc>) { - let Some(_owner_action) = begin_owner_action() else { + let Some(owner_action) = begin_owner_action() else { return; }; // QConnect (Slint main.rs:14492-14501 — no cast arm in the reference): @@ -3642,9 +3642,53 @@ pub async fn toggle_shuffle(runtime: &Arc>) { } } } + let event_before_reorder = runtime.core().player().get_playback_event(); let enabled = runtime.core().toggle_shuffle().await; + QUEUE_ORDER_GENERATION.fetch_add(1, Ordering::SeqCst); crate::now_playing::set_shuffle(enabled); publish_queue(runtime).await; + + // A successor may already be appended to the audio engine during the + // final gapless window. Reordering only the QueueManager would then let + // that stale edge play anyway; the next poll would sync the cursor past + // the canonical rows we just preserved. Re-materialize the same current + // occurrence only when the armed successor no longer matches the new + // first upcoming row. Ordinary toggles do not touch playback. + let upcoming_id = runtime + .core() + .peek_upcoming(1) + .await + .first() + .map(|track| track.id); + if gapless_successor_changed(&event_before_reorder, upcoming_id) { + let track_id = event_before_reorder.track_id; + let position = event_before_reorder.position; + let was_playing = event_before_reorder.is_playing; + if let Err(error) = runtime.core().player().stop() { + log::warn!( + "[qbz-qt] shuffle: could not discard stale gapless successor for track {track_id}: {error}" + ); + return; + } + drop(owner_action); + log::info!( + "[qbz-qt] shuffle: re-materializing track {track_id} at {position}s after its armed successor changed" + ); + play_queue_track(runtime, track_id, position).await; + if !was_playing { + if let Err(error) = runtime.core().pause() { + log::warn!( + "[qbz-qt] shuffle: could not restore paused state for track {track_id}: {error}" + ); + } + } + } +} + +fn gapless_successor_changed(event: &PlaybackEvent, upcoming_id: Option) -> bool { + event.track_id != 0 + && event.gapless_next_track_id != 0 + && Some(event.gapless_next_track_id) != upcoming_id } pub async fn cycle_repeat(runtime: &Arc>) { @@ -3970,6 +4014,10 @@ pub async fn publish_queue(runtime: &Arc>) { // --------------------------------------------------------------------------- static POLL_STARTED: AtomicBool = AtomicBool::new(false); +/// Bumped when the local Shuffle control changes the queue timeline. The poll +/// owns the gapless task/one-shot guard, so this is its cheap invalidation +/// channel for an in-flight successor that was derived from the old order. +static QUEUE_ORDER_GENERATION: AtomicU64 = AtomicU64::new(0); /// Wall-clock now in ms — the peer-position extrapolation clock (the Slint /// `now_ms`, playback.rs:5162). @@ -4022,6 +4070,7 @@ pub fn start_poll_loop(runtime: Arc>) { // does not re-request it on every tick while the download is in flight // (playback.rs:5049-5051). let mut gapless_requested_for: u64 = 0; + let mut seen_queue_order_generation = QUEUE_ORDER_GENERATION.load(Ordering::SeqCst); // Own the frontend's gapless fetch so an engine-empty recovery can // abort a segment body that is still monopolizing the link. The // player's incremental successor feeder has its own cancellation slot; @@ -4085,6 +4134,20 @@ pub fn start_poll_loop(runtime: Arc>) { prefetch_track_edge.observe(0); } + let queue_order_generation = QUEUE_ORDER_GENERATION.load(Ordering::SeqCst); + if queue_order_generation != seen_queue_order_generation { + seen_queue_order_generation = queue_order_generation; + gapless_requested_for = 0; + if let Some(task) = gapless_fetch_task.take() { + if !task.is_finished() { + log::info!( + "[qbz-qt] [GAPLESS] aborting successor fetch after queue order changed" + ); + task.abort(); + } + } + } + if gapless_fetch_task .as_ref() .is_some_and(|task| task.is_finished()) @@ -5088,8 +5151,9 @@ pub fn start_poll_loop(runtime: Arc>) { mod tests { use super::{ begin_resolved_play, cancel_owner_playback_tasks, capture_owner_scoped_snapshot, - filter_queue_with, gapless_edge_matches, owner_generation_changed, playback_recovery_cause, - prefetch_track_ids, purchase_quality_badge, quality_badge_from, reconcile_device_cap, + filter_queue_with, gapless_edge_matches, gapless_successor_changed, + owner_generation_changed, playback_recovery_cause, prefetch_track_ids, + purchase_quality_badge, quality_badge_from, reconcile_device_cap, resolved_play_is_current, should_stream_gapless_successor, source_change_targets_current, spawn_owner_playback_task, stream_error_text, xorshift_shuffle_seeded, OwnerActionObservation, OwnerActionToken, OwnerScopedSnapshot, PlaybackRecoveryCause, @@ -5443,6 +5507,20 @@ mod tests { assert!(!gapless_edge_matches(1, 1, 1, 0, None, Some(1))); } + #[test] + fn shuffle_restarts_only_when_an_armed_successor_became_stale() { + let mut event = playback_event(1, true); + assert!(!gapless_successor_changed(&event, Some(2))); + + event.gapless_next_track_id = 2; + assert!(!gapless_successor_changed(&event, Some(2))); + assert!(gapless_successor_changed(&event, Some(3))); + assert!(gapless_successor_changed(&event, None)); + + event.track_id = 0; + assert!(!gapless_successor_changed(&event, Some(3))); + } + // --- #638 fix 3: the request tier reconciles preference against the // detected local device cap. ------------------------------------------- diff --git a/crates/qbz-qt/src/settings_qt.rs b/crates/qbz-qt/src/settings_qt.rs index 6df24f518..6d47704c1 100644 --- a/crates/qbz-qt/src/settings_qt.rs +++ b/crates/qbz-qt/src/settings_qt.rs @@ -2089,6 +2089,10 @@ pub struct SettingsDoc { pub my_qbz_label: String, #[serde(rename = "sidebarPlaylistCollage")] pub sidebar_playlist_collage: bool, + #[serde(rename = "queueTrackArtwork")] + pub queue_track_artwork: bool, + #[serde(rename = "libraryTrackArtwork")] + pub library_track_artwork: bool, #[serde(rename = "localLibraryTrackArtwork")] pub local_library_track_artwork: bool, #[serde(rename = "playIndicatorAnimation")] @@ -2608,6 +2612,8 @@ pub async fn publish_snapshot() { nav_header_compact: nav_header_compact(), my_qbz_label: myqbz_label(), sidebar_playlist_collage: pref_bool("sidebar_playlist_collage", true), + queue_track_artwork: pref_bool("queue_track_artwork", false), + library_track_artwork: pref_bool("library_track_artwork", false), local_library_track_artwork: pref_bool("local_library_track_artwork", false), play_indicator_animation: pref_bool("play_indicator_animation", false), seekbar_waveform: seekbar_waveform(), @@ -3441,6 +3447,17 @@ pub async fn settings_bool(runtime: &Arc>, key: &str, crate::shell_bridge::ui(move |mut b| b.as_mut().set_sidebar_playlist_collage(value)); Ok(Apply::None) } + // These two are read live from the republished `settingsJson` by + // QueuePanel / LibraryView. Separate keys are intentional: the queue + // is tiny, while Library can contain thousands of track rows. + "queue-track-artwork" => { + save_pref("queue_track_artwork", serde_json::json!(value)); + Ok(Apply::None) + } + "library-track-artwork" => { + save_pref("library_track_artwork", serde_json::json!(value)); + Ok(Apply::None) + } "local-library-track-artwork" => { save_pref("local_library_track_artwork", serde_json::json!(value)); // Republish so the local lists repaint live; the bridge property is