From 61882166f7da05a6e0890a07fa640875cb5cde89 Mon Sep 17 00:00:00 2001 From: vicrodh <103399283+vicrodh@users.noreply.github.com> Date: Thu, 21 May 2026 22:48:49 -0600 Subject: [PATCH] feat(slint): rebuild mix views to AlbumView-level rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mix detail pages were a thin header + flat track list. Rebuilt to mirror Tauri's playlist-detail layout at the AlbumView port quality. - Header: a per-mix gradient artwork (224px, same gradients as the QobuzMixesRow tiles) + "Qobuz Mixes" label + title + description + "N tracks • duration" info line + circular action buttons (Play all, Shuffle, Save-as-playlist, Refresh) — matching AlbumView's header anatomy. - Track list with a real column header (#, Title, Album, Duration, Quality) over rich MixTrackRows: artwork thumbnail + number/play cell (TrackPlayCell, artwork-on), title + explicit badge, artist, album column, duration, quality, favorite + more controls. - MixTrackItem gains an album column; MixState carries track-count + total-duration for the info line. - Play all / per-track play (from that track), Shuffle (plays a random order without mutating the list), and Refresh (re-fetches) wired through media-action. Fixes a wiring bug: the tile click sends id=kind/action="open", but the handler matched on the action and passed "open" as the mix kind — now it reads the kind from id. cc --- crates/qbz-slint/src/main.rs | 52 +++- crates/qbz-slint/src/mix.rs | 46 ++- crates/qbz-slint/ui/mix/MixView.slint | 365 ++++++++++++++++++++--- crates/qbz-slint/ui/shell/AppShell.slint | 6 + crates/qbz-slint/ui/state.slint | 19 +- 5 files changed, 424 insertions(+), 64 deletions(-) diff --git a/crates/qbz-slint/src/main.rs b/crates/qbz-slint/src/main.rs index 2b423f38c..987dad49a 100644 --- a/crates/qbz-slint/src/main.rs +++ b/crates/qbz-slint/src/main.rs @@ -1311,6 +1311,20 @@ fn main() -> Result<(), Box> { } } } + // The mix tile sends id = mix kind, action = "open". + ("mix", "open") => { + nav::record(nav::NavEntry::Mix { kind: id.clone() }); + navigate_mix( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + id.clone(), + ); + if let Some(w) = weak.upgrade() { + update_nav_flags(&w); + } + } ("mix", "play-all") => { let runtime = runtime.clone(); let weak = weak.clone(); @@ -1320,6 +1334,30 @@ fn main() -> Result<(), Box> { playback::play_tracks(runtime, weak, handle, tracks, 0); }); } + ("mix", "shuffle") => { + let runtime = runtime.clone(); + let weak = weak.clone(); + let handle = handle.clone(); + handle.clone().spawn(async move { + let tracks = mix::shuffled_tracks(); + playback::play_tracks(runtime, weak, handle, tracks, 0); + }); + } + ("mix", "refresh") => { + // Re-load the current mix (re-fetch its tracks). + if let Some(w) = weak.upgrade() { + let kind = w.global::().get_kind().to_string(); + if !kind.is_empty() { + navigate_mix( + runtime.clone(), + weak.clone(), + &handle, + image_cache.clone(), + kind, + ); + } + } + } ("mix-track", track_id) => { let idx = mix::index_of(track_id); let runtime = runtime.clone(); @@ -1330,20 +1368,6 @@ fn main() -> Result<(), Box> { playback::play_tracks(runtime, weak, handle, tracks, idx); }); } - ("mix", which) => { - // A Qobuz Mixes tile — open its detail view. - nav::record(nav::NavEntry::Mix { kind: which.to_string() }); - navigate_mix( - runtime.clone(), - weak.clone(), - &handle, - image_cache.clone(), - which.to_string(), - ); - if let Some(w) = weak.upgrade() { - update_nav_flags(&w); - } - } _ => {} } }); diff --git a/crates/qbz-slint/src/mix.rs b/crates/qbz-slint/src/mix.rs index f13402fda..e04e6ab97 100644 --- a/crates/qbz-slint/src/mix.rs +++ b/crates/qbz-slint/src/mix.rs @@ -20,7 +20,7 @@ use qbz_models::Track; use slint::{ComponentHandle, ModelRc, VecModel}; use crate::artwork::{ArtworkJob, ArtworkTarget}; -use crate::{AppWindow, MixState, SearchTrackItem}; +use crate::{AppWindow, MixState, MixTrackItem}; /// The currently-loaded mix track list, kept so play-all / per-track /// play can build the queue without re-fetching. @@ -124,13 +124,14 @@ fn mmss(secs: u32) -> String { format!("{}:{:02}", secs / 60, secs % 60) } -fn to_item(track: &Track) -> SearchTrackItem { +fn to_item(index: usize, track: &Track) -> MixTrackItem { let mut title = track.title.clone(); if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) { title = format!("{title} ({v})"); } - SearchTrackItem { + MixTrackItem { id: track.id.to_string().into(), + number: (index + 1).to_string().into(), title: title.into(), artist: track .performer @@ -138,6 +139,12 @@ fn to_item(track: &Track) -> SearchTrackItem { .map(|p| p.name.clone()) .unwrap_or_default() .into(), + album: track + .album + .as_ref() + .map(|a| a.title.clone()) + .unwrap_or_default() + .into(), duration: mmss(track.duration).into(), quality_tier: match track.maximum_bit_depth { Some(d) if d >= 24 => "hires", @@ -156,9 +163,26 @@ fn to_item(track: &Track) -> SearchTrackItem { } } +/// Human total duration: "1 h 23 min" or "23 min". +fn total_duration(tracks: &[Track]) -> String { + let secs: u64 = tracks.iter().map(|t| t.duration as u64).sum(); + let mins = secs / 60; + if mins >= 60 { + format!("{} h {} min", mins / 60, mins % 60) + } else { + format!("{} min", mins) + } +} + pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec) { let (title, subtitle) = mix_meta(kind); - let items: Vec = tracks.iter().map(to_item).collect(); + let items: Vec = tracks + .iter() + .enumerate() + .map(|(i, t)| to_item(i, t)) + .collect(); + let count = tracks.len() as i32; + let duration = total_duration(&tracks); if let Ok(mut cur) = CURRENT_MIX.lock() { *cur = tracks; } @@ -167,6 +191,8 @@ pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec) { state.set_title(title.into()); state.set_subtitle(subtitle.into()); state.set_tracks(ModelRc::new(VecModel::from(items))); + state.set_track_count(count); + state.set_total_duration(duration.into()); state.set_loading(false); } @@ -176,7 +202,9 @@ pub fn reset_mix(window: &AppWindow, kind: &str) { state.set_kind(kind.into()); state.set_title(title.into()); state.set_subtitle(subtitle.into()); - state.set_tracks(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_tracks(ModelRc::new(VecModel::from(Vec::::new()))); + state.set_track_count(0); + state.set_total_duration("".into()); state.set_loading(true); } @@ -185,6 +213,14 @@ pub fn current_tracks() -> Vec { CURRENT_MIX.lock().map(|c| c.clone()).unwrap_or_default() } +/// The current mix tracks in a fresh random order (for the Shuffle +/// action) — does not mutate the displayed list. +pub fn shuffled_tracks() -> Vec { + let mut tracks = current_tracks(); + shuffle(&mut tracks); + tracks +} + /// Index of `track_id` within the current mix (for play-from-here). pub fn index_of(track_id: &str) -> usize { CURRENT_MIX diff --git a/crates/qbz-slint/ui/mix/MixView.slint b/crates/qbz-slint/ui/mix/MixView.slint index 0a3166bf4..5e21aff16 100644 --- a/crates/qbz-slint/ui/mix/MixView.slint +++ b/crates/qbz-slint/ui/mix/MixView.slint @@ -1,18 +1,220 @@ -// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Header with -// the mix name + description + a Play all button, over the mix's -// track list (reusing the shared SearchTrackRow). +// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Ported to +// the AlbumView level: a header (gradient artwork + "Qobuz Mixes" +// label + title + description + "N tracks • duration" info line + +// circular action buttons) over a track list with a column header +// and rich rows (artwork + title/artist + album + duration + quality +// + favorite + more). import { Theme } from "../foundation/semantic-colors.slint"; import { Typography } from "../foundation/typography.slint"; -import { MixState } from "../state.slint"; -import { SearchTrackRow } from "../search/SearchTrackRow.slint"; +import { Radius } from "../foundation/radius.slint"; +import { MixState, MixTrackItem } from "../state.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint"; +import { TrackPlayCell } from "../primitives/TrackPlayCell.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { NavButtons } from "../shell/NavButtons.slint"; +// Circular header action button (Play / Shuffle / Save / Refresh). +component CircleAction inherits Rectangle { + in property icon; + in property primary: false; + in property diameter: 38px; + in property enabled: true; + callback clicked(); + + width: root.diameter; + height: root.diameter; + border-radius: root.diameter / 2; + opacity: root.enabled ? 1.0 : 0.4; + background: root.primary + ? (ta.has-hover ? #ffffffd6 : Theme.accent) + : (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated); + QbzIcon { + source: root.icon; + width: root.primary ? 18px : 16px; + height: root.primary ? 18px : 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: root.primary ? #ffffff : Theme.text-secondary; + } + ta := TouchArea { + mouse-cursor: root.enabled ? pointer : default; + clicked => { + if (root.enabled) { + root.clicked(); + } + } + } +} + +component MixTrackRow inherits Rectangle { + in property track; + in property index; + callback play(string); + + property row-hovered: ta.has-hover || play-cell.hovered || fav-ta.has-hover || more-ta.has-hover; + + height: 56px; + border-radius: 8px; + background: root.row-hovered + ? #ffffff16 + : (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent); + + ta := TouchArea { + mouse-cursor: default; + } + + HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 16px; + + VerticalLayout { + alignment: center; + play-cell := TrackPlayCell { + track-id: root.track.id; + number: root.track.number; + artwork: root.track.artwork; + show-artwork: true; + row-hovered: root.row-hovered; + play => { + root.play(root.track.id); + } + } + } + + // Title + artist. + VerticalLayout { + alignment: center; + horizontal-stretch: 1; + spacing: 2px; + HorizontalLayout { + alignment: start; + spacing: 6px; + Text { + text: root.track.title; + color: Theme.text-primary; + font-size: Typography.body; + font-weight: Typography.medium; + vertical-alignment: center; + overflow: elide; + } + if root.track.explicit: VerticalLayout { + alignment: center; + Rectangle { + width: 16px; + height: 16px; + border-radius: 3px; + background: Theme.surface-elevated; + Text { + text: "E"; + color: Theme.text-muted; + font-size: 9px; + font-weight: Typography.semibold; + horizontal-alignment: center; + vertical-alignment: center; + } + } + } + } + Text { + text: root.track.artist; + visible: root.track.artist != ""; + color: Theme.text-muted; + font-size: Typography.link; + overflow: elide; + } + } + + // Album column. + Text { + text: root.track.album; + width: 220px; + color: Theme.text-muted; + font-size: Typography.legal; + vertical-alignment: center; + overflow: elide; + } + // Duration. + Text { + text: root.track.duration; + width: 64px; + color: Theme.text-muted; + font-size: Typography.body; + horizontal-alignment: center; + vertical-alignment: center; + } + // Quality. + Text { + text: root.track.quality-tier == "hires" + ? "Hi-Res" + : (root.track.quality-tier == "cd" ? "CD" : ""); + width: 64px; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + vertical-alignment: center; + } + // Favorite. + VerticalLayout { + alignment: center; + Rectangle { + width: 28px; + height: 28px; + border-radius: Radius.sm; + background: fav-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/heart.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: fav-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + fav-ta := TouchArea { + mouse-cursor: pointer; + } + } + } + // More. + VerticalLayout { + alignment: center; + Rectangle { + width: 32px; + height: 32px; + border-radius: Radius.sm; + background: more-ta.has-hover ? Theme.surface-elevated : transparent; + QbzIcon { + source: @image-url("../assets/icons/ellipsis.svg"); + width: 16px; + height: 16px; + x: (parent.width - self.width) / 2; + y: (parent.height - self.height) / 2; + tint: more-ta.has-hover ? Theme.text-primary : Theme.text-muted; + } + more-ta := TouchArea { + mouse-cursor: pointer; + } + } + } + } +} + export component MixView inherits Rectangle { callback play-track(string); callback play-all(); + callback shuffle(); + callback refresh(); + + // Per-mix header gradient, matching the QobuzMixesRow tiles. + property mix-gradient: + MixState.kind == "daily" + ? @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%) + : MixState.kind == "weekly" + ? @linear-gradient(135deg, #065f46 0%, #10b981 50%, #fbbf24 100%) + : MixState.kind == "fav" + ? @linear-gradient(135deg, #7f1d1d 0%, #ef4444 50%, #fb923c 100%) + : @linear-gradient(135deg, #1f2937 0%, #4b5563 50%, #fbbf24 100%); background: Theme.surface-main; @@ -38,67 +240,96 @@ export component MixView inherits Rectangle { // --- Header ------------------------------------------------ HorizontalLayout { - spacing: 20px; + spacing: 32px; alignment: start; + // Gradient artwork. Rectangle { - width: 120px; - height: 120px; - border-radius: 8px; - background: @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%); + width: 224px; + height: 224px; + border-radius: Radius.md; + background: root.mix-gradient; Text { text: MixState.title; x: (parent.width - self.width) / 2; y: (parent.height - self.height) / 2; color: #ffffff; - font-size: 18px; + font-size: 34px; font-weight: Typography.bold; } } + // Metadata. VerticalLayout { - alignment: center; - spacing: 8px; + alignment: start; + padding-top: 4px; + horizontal-stretch: 1; + Text { + text: @tr("Qobuz Mixes"); + color: Theme.text-muted; + font-size: 11px; + font-weight: Typography.semibold; + letter-spacing: 1.5px; + } + Rectangle { height: 6px; } Text { text: MixState.title; color: Theme.text-primary; font-size: Typography.title; font-weight: Typography.bold; } + Rectangle { height: 8px; } Text { + width: root.width - 320px; text: MixState.subtitle; - color: Theme.text-muted; + color: Theme.text-secondary; font-size: Typography.body; + wrap: word-wrap; } - if MixState.tracks.length > 0: Rectangle { - width: play-row.preferred-width + 28px; - height: 36px; - border-radius: 18px; - background: play-ta.has-hover ? #ffffffd6 : Theme.accent; - play-row := HorizontalLayout { - x: (parent.width - self.preferred-width) / 2; - spacing: 8px; - VerticalLayout { - alignment: center; - QbzIcon { - source: @image-url("../assets/icons/play-fill.svg"); - width: 14px; - height: 14px; - tint: #ffffff; - } - } - Text { - text: @tr("Play all"); - color: #ffffff; - font-size: 14px; - font-weight: Typography.semibold; - vertical-alignment: center; - } - } - play-ta := TouchArea { - mouse-cursor: pointer; + Rectangle { height: 10px; } + if MixState.track-count > 0: Text { + text: MixState.track-count + " " + @tr("tracks") + " • " + MixState.total-duration; + color: Theme.text-secondary; + font-size: Typography.legal; + } + Rectangle { height: 20px; } + // Action buttons. + HorizontalLayout { + alignment: start; + spacing: 12px; + CircleAction { + icon: @image-url("../assets/icons/play-fill.svg"); + primary: true; + diameter: 44px; + enabled: MixState.tracks.length > 0; clicked => { root.play-all(); } } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/shuffle.svg"); + enabled: MixState.tracks.length > 0; + clicked => { + root.shuffle(); + } + } + } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/list-plus.svg"); + enabled: MixState.tracks.length > 0; + } + } + VerticalLayout { + alignment: center; + CircleAction { + icon: @image-url("../assets/icons/home-gear.svg"); + clicked => { + root.refresh(); + } + } + } } } } @@ -115,11 +346,57 @@ export component MixView inherits Rectangle { font-size: Typography.body; } - for track[i] in MixState.tracks: SearchTrackRow { + // --- Track list column header ------------------------------ + if MixState.tracks.length > 0: HorizontalLayout { + padding-left: 12px; + padding-right: 12px; + spacing: 16px; + Text { + text: "#"; + width: 40px; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + } + Text { + text: @tr("Title"); + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-stretch: 1; + } + Text { + text: @tr("Album"); + width: 220px; + color: Theme.text-muted; + font-size: Typography.legal; + } + Text { + text: @tr("Duration"); + width: 64px; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + } + Text { + text: @tr("Quality"); + width: 64px; + color: Theme.text-muted; + font-size: Typography.legal; + horizontal-alignment: center; + } + Rectangle { width: 28px; } + Rectangle { width: 32px; } + } + if MixState.tracks.length > 0: Rectangle { + height: 1px; + background: Theme.border-subtle; + } + Rectangle { height: 6px; } + + for track[i] in MixState.tracks: MixTrackRow { track: track; index: i; - show-artwork: true; - media-action(kind, id, action) => { + play(id) => { root.play-track(id); } } diff --git a/crates/qbz-slint/ui/shell/AppShell.slint b/crates/qbz-slint/ui/shell/AppShell.slint index da26ed751..a6613928b 100644 --- a/crates/qbz-slint/ui/shell/AppShell.slint +++ b/crates/qbz-slint/ui/shell/AppShell.slint @@ -136,6 +136,12 @@ export component AppShell inherits Rectangle { play-all => { root.media-action("mix", "", "play-all"); } + shuffle => { + root.media-action("mix", "", "shuffle"); + } + refresh => { + root.media-action("mix", "", "refresh"); + } } if NavState.view == ContentView.settings: SettingsView { settings-bool(k, v) => { diff --git a/crates/qbz-slint/ui/state.slint b/crates/qbz-slint/ui/state.slint index e26362c04..4f1dccbe2 100644 --- a/crates/qbz-slint/ui/state.slint +++ b/crates/qbz-slint/ui/state.slint @@ -791,13 +791,30 @@ export global FavoritesActions { callback track-action(string /* track id */, string /* action */); } +// One track row in a Qobuz mix detail page — carries an album column +// (mix tracks span many albums, unlike an album view). +export struct MixTrackItem { + id: string, + number: string, + title: string, + artist: string, + album: string, + duration: string, + quality-tier: string, + explicit: bool, + artwork-url: string, + artwork: image, +} + // A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened // from the For You Qobuz Mixes tiles. Populated by crate::mix. export global MixState { in property kind; // daily | weekly | fav | top in property title; in property subtitle; - in property <[SearchTrackItem]> tracks: []; + in property <[MixTrackItem]> tracks: []; + in property track-count: 0; + in property total-duration; in property loading: false; }