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:
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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. -------------------------------------------
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user