feat(slint): favorites tracks Play all + Shuffle header actions

Retain the loaded favorite tracks as a play-ready Vec<Track> and add
Play all + Shuffle CircleAction buttons by the title (Tracks tab only),
wired to playback::play_tracks. Mirrors the Mix/Playlist transport
(those expose exactly these two; no batch play-next path exists yet).

cc
This commit is contained in:
vicrodh committed 2026-05-26 22:28:19 -06:00
1 parent 708fd50c87
commit 43b0c28aee
4 files changed
+110 -8

No files matched your search

+36 -3
View File
@@ -8,7 +8,7 @@
//! relevant branch into typed qbz-models items and maps them to the
//! Slint row/card structs.
use std::sync::Arc;
use std::sync::{Arc, LazyLock, Mutex};
use qbz_app::shell::AppRuntime;
use qbz_core::FrontendAdapter;
@@ -32,6 +32,10 @@ pub const PAGE_SIZE: u32 = 500;
/// FAVORITES_PAGE_SIZE * FAVORITES_MAX_PAGES ceiling).
const MAX_ITEMS: usize = 10_000;
/// The loaded favorite tracks as a play-ready queue source (Play all /
/// Shuffle). Set on the UI thread by `apply_favorites`.
static FAV_CURRENT: LazyLock<Mutex<Vec<Track>>> = LazyLock::new(|| Mutex::new(Vec::new()));
/// Which favorites tab to load.
#[derive(Clone, Copy, PartialEq, Eq)]
pub enum FavTab {
@@ -85,7 +89,7 @@ struct FavLabel {
}
pub enum FavData {
Tracks { items: Vec<TrackCard>, total: usize },
Tracks { items: Vec<TrackCard>, play: Vec<Track>, total: usize },
Albums { items: Vec<AlbumCard>, total: usize },
Artists { items: Vec<ArtistCard>, total: usize },
Playlists { items: Vec<PlaylistCard>, total: usize },
@@ -191,8 +195,10 @@ where
Ok(match tab {
FavTab::Tracks => {
let tracks: Vec<Track> = serde_json::from_value(items).unwrap_or_default();
let play = tracks.clone();
FavData::Tracks {
items: tracks.into_iter().map(map_track).collect(),
play,
total,
}
}
@@ -308,7 +314,10 @@ fn map_label(label: FavLabel) -> LabelCard {
pub fn apply_favorites(window: &AppWindow, data: FavData) {
let state = window.global::<FavoritesState>();
match data {
FavData::Tracks { items, total } => {
FavData::Tracks { items, play, total } => {
if let Ok(mut current) = FAV_CURRENT.lock() {
*current = play;
}
let rows: Vec<TrackItem> = items
.into_iter()
.map(|t| TrackItem {
@@ -414,6 +423,30 @@ pub fn derive_tracks(window: &AppWindow) {
state.set_tracks_visible(ModelRc::new(VecModel::from(filtered)));
}
/// The loaded favorite tracks as a play-ready queue (Play all).
pub fn play_tracks() -> Vec<Track> {
FAV_CURRENT.lock().map(|c| c.clone()).unwrap_or_default()
}
/// The favorite tracks in a fresh random order (Shuffle). Mirrors
/// playlist::shuffled_tracks (time-seeded xorshift Fisher-Yates).
pub fn shuffled_tracks() -> Vec<Track> {
let mut tracks = play_tracks();
let mut seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(1)
| 1;
for i in (1..tracks.len()).rev() {
seed ^= seed << 13;
seed ^= seed >> 7;
seed ^= seed << 17;
let j = (seed % (i as u64 + 1)) as usize;
tracks.swap(i, j);
}
tracks
}
pub fn reset_loading(window: &AppWindow) {
window.global::<FavoritesState>().set_loading(true);
}
+34
View File
@@ -4272,6 +4272,40 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
}
{
// Play all favorite tracks as a fresh queue.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FavoritesActions>()
.on_play_all_tracks(move || {
playback::play_tracks(
runtime.clone(),
weak.clone(),
handle.clone(),
favorites::play_tracks(),
0,
);
});
}
{
// Shuffle-play the favorite tracks.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<FavoritesActions>()
.on_shuffle_tracks(move || {
playback::play_tracks(
runtime.clone(),
weak.clone(),
handle.clone(),
favorites::shuffled_tracks(),
0,
);
});
}
// Artwork right-click menu wiring — Open in browser / Save as /
// Add custom / Remove custom. Mirrors the v2_library_* + native
@@ -21,6 +21,7 @@ import {
import { AlbumCard } from "../discover/AlbumCard.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
@@ -275,11 +276,43 @@ export component FavoritesView inherits Rectangle {
// Page title — same size as the album name in AlbumView
// (Typography.section), not the larger Typography.title.
Text {
text: @tr("Favorites");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
HorizontalLayout {
spacing: 12px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Favorites");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
}
}
// Tracks header actions — Play all + Shuffle the favorites
// queue. Shown only when the Tracks tab has rendered rows.
if FavoritesState.active-tab == "tracks"
&& FavoritesState.tracks-visible.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
clicked => {
FavoritesActions.play-all-tracks();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
FavoritesActions.shuffle-tracks();
}
}
}
}
}
Rectangle { height: 16px; }
+2
View File
@@ -1477,6 +1477,8 @@ export global FavoritesActions {
callback play-track(string /* track id */);
callback track-action(string /* track id */, string /* action */);
callback search-tracks(string /* query */);
callback play-all-tracks();
callback shuffle-tracks();
}
// A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened