feat(slint): rebuild mix views to AlbumView-level rendering

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
This commit is contained in:
vicrodh committed 2026-05-21 22:48:49 -06:00
1 parent 3b8f0a4597
commit 61882166f7
5 files changed
+424 -64

No files matched your search

+38 -14
View File
@@ -1311,6 +1311,20 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
} }
} }
// 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") => { ("mix", "play-all") => {
let runtime = runtime.clone(); let runtime = runtime.clone();
let weak = weak.clone(); let weak = weak.clone();
@@ -1320,6 +1334,30 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
playback::play_tracks(runtime, weak, handle, tracks, 0); 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::<MixState>().get_kind().to_string();
if !kind.is_empty() {
navigate_mix(
runtime.clone(),
weak.clone(),
&handle,
image_cache.clone(),
kind,
);
}
}
}
("mix-track", track_id) => { ("mix-track", track_id) => {
let idx = mix::index_of(track_id); let idx = mix::index_of(track_id);
let runtime = runtime.clone(); let runtime = runtime.clone();
@@ -1330,20 +1368,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
playback::play_tracks(runtime, weak, handle, tracks, idx); 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);
}
}
_ => {} _ => {}
} }
}); });
+41 -5
View File
@@ -20,7 +20,7 @@ use qbz_models::Track;
use slint::{ComponentHandle, ModelRc, VecModel}; use slint::{ComponentHandle, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget}; 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 /// The currently-loaded mix track list, kept so play-all / per-track
/// play can build the queue without re-fetching. /// play can build the queue without re-fetching.
@@ -124,13 +124,14 @@ fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60) 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(); let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) { if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({v})"); title = format!("{title} ({v})");
} }
SearchTrackItem { MixTrackItem {
id: track.id.to_string().into(), id: track.id.to_string().into(),
number: (index + 1).to_string().into(),
title: title.into(), title: title.into(),
artist: track artist: track
.performer .performer
@@ -138,6 +139,12 @@ fn to_item(track: &Track) -> SearchTrackItem {
.map(|p| p.name.clone()) .map(|p| p.name.clone())
.unwrap_or_default() .unwrap_or_default()
.into(), .into(),
album: track
.album
.as_ref()
.map(|a| a.title.clone())
.unwrap_or_default()
.into(),
duration: mmss(track.duration).into(), duration: mmss(track.duration).into(),
quality_tier: match track.maximum_bit_depth { quality_tier: match track.maximum_bit_depth {
Some(d) if d >= 24 => "hires", 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<Track>) { pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
let (title, subtitle) = mix_meta(kind); let (title, subtitle) = mix_meta(kind);
let items: Vec<SearchTrackItem> = tracks.iter().map(to_item).collect(); let items: Vec<MixTrackItem> = 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() { if let Ok(mut cur) = CURRENT_MIX.lock() {
*cur = tracks; *cur = tracks;
} }
@@ -167,6 +191,8 @@ pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
state.set_title(title.into()); state.set_title(title.into());
state.set_subtitle(subtitle.into()); state.set_subtitle(subtitle.into());
state.set_tracks(ModelRc::new(VecModel::from(items))); state.set_tracks(ModelRc::new(VecModel::from(items)));
state.set_track_count(count);
state.set_total_duration(duration.into());
state.set_loading(false); state.set_loading(false);
} }
@@ -176,7 +202,9 @@ pub fn reset_mix(window: &AppWindow, kind: &str) {
state.set_kind(kind.into()); state.set_kind(kind.into());
state.set_title(title.into()); state.set_title(title.into());
state.set_subtitle(subtitle.into()); state.set_subtitle(subtitle.into());
state.set_tracks(ModelRc::new(VecModel::from(Vec::<SearchTrackItem>::new()))); state.set_tracks(ModelRc::new(VecModel::from(Vec::<MixTrackItem>::new())));
state.set_track_count(0);
state.set_total_duration("".into());
state.set_loading(true); state.set_loading(true);
} }
@@ -185,6 +213,14 @@ pub fn current_tracks() -> Vec<Track> {
CURRENT_MIX.lock().map(|c| c.clone()).unwrap_or_default() 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<Track> {
let mut tracks = current_tracks();
shuffle(&mut tracks);
tracks
}
/// Index of `track_id` within the current mix (for play-from-here). /// Index of `track_id` within the current mix (for play-from-here).
pub fn index_of(track_id: &str) -> usize { pub fn index_of(track_id: &str) -> usize {
CURRENT_MIX CURRENT_MIX
+321 -44
View File
@@ -1,18 +1,220 @@
// Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Header with // Qobuz mix detail view (DailyQ / WeeklyQ / FavQ / TopQ). Ported to
// the mix name + description + a Play all button, over the mix's // the AlbumView level: a header (gradient artwork + "Qobuz Mixes"
// track list (reusing the shared SearchTrackRow). // 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 { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint"; import { Typography } from "../foundation/typography.slint";
import { MixState } from "../state.slint"; import { Radius } from "../foundation/radius.slint";
import { SearchTrackRow } from "../search/SearchTrackRow.slint"; import { MixState, MixTrackItem } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint"; import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint"; import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint"; import { NavButtons } from "../shell/NavButtons.slint";
// Circular header action button (Play / Shuffle / Save / Refresh).
component CircleAction inherits Rectangle {
in property <image> icon;
in property <bool> primary: false;
in property <length> diameter: 38px;
in property <bool> 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 <MixTrackItem> track;
in property <int> index;
callback play(string);
property <bool> 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 { export component MixView inherits Rectangle {
callback play-track(string); callback play-track(string);
callback play-all(); callback play-all();
callback shuffle();
callback refresh();
// Per-mix header gradient, matching the QobuzMixesRow tiles.
property <brush> 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; background: Theme.surface-main;
@@ -38,67 +240,96 @@ export component MixView inherits Rectangle {
// --- Header ------------------------------------------------ // --- Header ------------------------------------------------
HorizontalLayout { HorizontalLayout {
spacing: 20px; spacing: 32px;
alignment: start; alignment: start;
// Gradient artwork.
Rectangle { Rectangle {
width: 120px; width: 224px;
height: 120px; height: 224px;
border-radius: 8px; border-radius: Radius.md;
background: @linear-gradient(135deg, #1e3a8a 0%, #6366f1 50%, #c084fc 100%); background: root.mix-gradient;
Text { Text {
text: MixState.title; text: MixState.title;
x: (parent.width - self.width) / 2; x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2; y: (parent.height - self.height) / 2;
color: #ffffff; color: #ffffff;
font-size: 18px; font-size: 34px;
font-weight: Typography.bold; font-weight: Typography.bold;
} }
} }
// Metadata.
VerticalLayout { VerticalLayout {
alignment: center; alignment: start;
spacing: 8px; 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 {
text: MixState.title; text: MixState.title;
color: Theme.text-primary; color: Theme.text-primary;
font-size: Typography.title; font-size: Typography.title;
font-weight: Typography.bold; font-weight: Typography.bold;
} }
Rectangle { height: 8px; }
Text { Text {
width: root.width - 320px;
text: MixState.subtitle; text: MixState.subtitle;
color: Theme.text-muted; color: Theme.text-secondary;
font-size: Typography.body; font-size: Typography.body;
wrap: word-wrap;
} }
if MixState.tracks.length > 0: Rectangle { Rectangle { height: 10px; }
width: play-row.preferred-width + 28px; if MixState.track-count > 0: Text {
height: 36px; text: MixState.track-count + " " + @tr("tracks") + " • " + MixState.total-duration;
border-radius: 18px; color: Theme.text-secondary;
background: play-ta.has-hover ? #ffffffd6 : Theme.accent; font-size: Typography.legal;
play-row := HorizontalLayout { }
x: (parent.width - self.preferred-width) / 2; Rectangle { height: 20px; }
spacing: 8px; // Action buttons.
VerticalLayout { HorizontalLayout {
alignment: center; alignment: start;
QbzIcon { spacing: 12px;
source: @image-url("../assets/icons/play-fill.svg"); CircleAction {
width: 14px; icon: @image-url("../assets/icons/play-fill.svg");
height: 14px; primary: true;
tint: #ffffff; diameter: 44px;
} enabled: MixState.tracks.length > 0;
}
Text {
text: @tr("Play all");
color: #ffffff;
font-size: 14px;
font-weight: Typography.semibold;
vertical-alignment: center;
}
}
play-ta := TouchArea {
mouse-cursor: pointer;
clicked => { clicked => {
root.play-all(); 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; 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; track: track;
index: i; index: i;
show-artwork: true; play(id) => {
media-action(kind, id, action) => {
root.play-track(id); root.play-track(id);
} }
} }
+6
View File
@@ -136,6 +136,12 @@ export component AppShell inherits Rectangle {
play-all => { play-all => {
root.media-action("mix", "", "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 { if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => { settings-bool(k, v) => {
+18 -1
View File
@@ -791,13 +791,30 @@ export global FavoritesActions {
callback track-action(string /* track id */, string /* action */); 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 // A Qobuz mix detail page (DailyQ / WeeklyQ / FavQ / TopQ), opened
// from the For You Qobuz Mixes tiles. Populated by crate::mix. // from the For You Qobuz Mixes tiles. Populated by crate::mix.
export global MixState { export global MixState {
in property <string> kind; // daily | weekly | fav | top in property <string> kind; // daily | weekly | fav | top
in property <string> title; in property <string> title;
in property <string> subtitle; in property <string> subtitle;
in property <[SearchTrackItem]> tracks: []; in property <[MixTrackItem]> tracks: [];
in property <int> track-count: 0;
in property <string> total-duration;
in property <bool> loading: false; in property <bool> loading: false;
} }