feat(library): refine track source controls

Preserve the remaining queue when shuffle is disabled, expose clear transport state tooltips, retain Library source filters for the session, and add independent track-artwork preferences for Queue and Library.

cx
This commit is contained in:
vicrodh committed 2026-09-03 14:53:22 -06:00
1 parent f833a5a401
commit 30912962d3
20 files changed
+758 -58

No files matched your search

@@ -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."
@@ -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."
@@ -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."
@@ -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."
@@ -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 "トラック番号とタイトルの間にアルバムカバーのサムネイルを表示します。デフォルトではオフです。"
@@ -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."
@@ -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."
@@ -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 "Показывает миниатюру обложки между номером и названием трека. Отключено по умолчанию."
+220 -13
View File
@@ -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::<Vec<_>>();
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<Vec<usize>>) {
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::<Vec<_>>();
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<_>>(),
vec![1]
);
assert_eq!(
state
.upcoming
.iter()
.map(|track| track.id)
.collect::<Vec<_>>(),
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<_>>(),
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<_>>(),
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();
@@ -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)
+2 -3
View File
@@ -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
}
+4 -7
View File
@@ -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
}
}
@@ -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()
}
+5 -4
View File
@@ -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()
}
+38 -8
View File
@@ -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 {
@@ -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.
+122 -18
View File
@@ -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.
@@ -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
+81 -3
View File
@@ -3624,7 +3624,7 @@ pub async fn toggle_mute(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
}
pub async fn toggle_shuffle(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
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<AppRuntime<LoggingAdapter>>) {
}
}
}
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<u64>) -> 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<AppRuntime<LoggingAdapter>>) {
@@ -3970,6 +4014,10 @@ pub async fn publish_queue(runtime: &Arc<AppRuntime<LoggingAdapter>>) {
// ---------------------------------------------------------------------------
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<AppRuntime<LoggingAdapter>>) {
// 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<AppRuntime<LoggingAdapter>>) {
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<AppRuntime<LoggingAdapter>>) {
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. -------------------------------------------
+17
View File
@@ -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<AppRuntime<LoggingAdapter>>, 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