refactor(slint): mix rows reuse SearchTrackRow; For You uses HomeSkeleton

Track row reuse: the mix views had a bespoke MixTrackRow with a dead
favorite/more controls (no context menu), while search / favorites /
artist already share SearchTrackRow (which has the TrackContextMenu).
Consolidated:
  - SearchTrackRow gains optional show-album (album column) +
    show-favorite (standalone heart) — off by default, so search /
    favorites rows are unchanged. SearchTrackItem gains an `album`
    field.
  - MixView drops MixTrackRow/MixTrackItem and renders SearchTrackRow
    with show-album + show-favorite. A row "play" starts the mix from
    that track; the context-menu actions (play next / queue / favorite
    / view album / view artist / ...) now bubble through media-action
    and work in the mix views too.

For You skeleton: the For You tab showed a bare spinner during its
lazy load; it now shows the same pulsing HomeSkeleton as Home (the
static Qobuz Mixes tiles stay visible above it). Editor's Picks is
served from the shared discover-index cache (Tauri parity) and shows
the skeleton during the initial load via the existing HomeView guard.

cc
This commit is contained in:
vicrodh committed 2026-05-21 23:46:13 -06:00
1 parent 61882166f7
commit 469c2495bf
8 files changed
+83 -193

No files matched your search

+1
View File
@@ -315,6 +315,7 @@ pub fn apply_favorites(window: &AppWindow, data: FavData) {
id: t.id.into(),
title: t.title.into(),
artist: t.artist.into(),
album: "".into(),
duration: t.duration.into(),
quality_tier: t.quality_tier.into(),
explicit: t.explicit,
+5 -10
View File
@@ -20,7 +20,7 @@ use qbz_models::Track;
use slint::{ComponentHandle, ModelRc, VecModel};
use crate::artwork::{ArtworkJob, ArtworkTarget};
use crate::{AppWindow, MixState, MixTrackItem};
use crate::{AppWindow, MixState, SearchTrackItem};
/// The currently-loaded mix track list, kept so play-all / per-track
/// play can build the queue without re-fetching.
@@ -124,14 +124,13 @@ fn mmss(secs: u32) -> String {
format!("{}:{:02}", secs / 60, secs % 60)
}
fn to_item(index: usize, track: &Track) -> MixTrackItem {
fn to_item(track: &Track) -> SearchTrackItem {
let mut title = track.title.clone();
if let Some(v) = track.version.as_ref().filter(|v| !v.is_empty()) {
title = format!("{title} ({v})");
}
MixTrackItem {
SearchTrackItem {
id: track.id.to_string().into(),
number: (index + 1).to_string().into(),
title: title.into(),
artist: track
.performer
@@ -176,11 +175,7 @@ fn total_duration(tracks: &[Track]) -> String {
pub fn apply_mix(window: &AppWindow, kind: &str, tracks: Vec<Track>) {
let (title, subtitle) = mix_meta(kind);
let items: Vec<MixTrackItem> = tracks
.iter()
.enumerate()
.map(|(i, t)| to_item(i, t))
.collect();
let items: Vec<SearchTrackItem> = tracks.iter().map(to_item).collect();
let count = tracks.len() as i32;
let duration = total_duration(&tracks);
if let Ok(mut cur) = CURRENT_MIX.lock() {
@@ -202,7 +197,7 @@ 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::<MixTrackItem>::new())));
state.set_tracks(ModelRc::new(VecModel::from(Vec::<SearchTrackItem>::new())));
state.set_track_count(0);
state.set_total_duration("".into());
state.set_loading(true);
+1
View File
@@ -340,6 +340,7 @@ fn track_item(row: TrackRow) -> SearchTrackItem {
id: row.id.into(),
title: row.title.into(),
artist: row.artist.into(),
album: "".into(),
duration: row.duration.into(),
quality_tier: row.quality_tier.into(),
explicit: row.explicit,
+10 -8
View File
@@ -17,7 +17,7 @@ import { ArtistCarousel } from "ArtistCarousel.slint";
import { Spotlight } from "Spotlight.slint";
import { QobuzMixesRow } from "QobuzMixesRow.slint";
import { RadioCard } from "RadioCard.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { HomeSkeleton } from "HomeSkeleton.slint";
export component ForYouView inherits VerticalLayout {
callback open-album(string);
@@ -29,16 +29,16 @@ export component ForYouView inherits VerticalLayout {
callback start-radio-album(string);
callback open-mix(string);
// While the first lazy load is in flight, show the same pulsing
// skeleton Home uses (the Qobuz Mixes tiles stay — they're static).
property <bool> show-skeleton:
ForYouState.loading
&& ForYouState.release-watch.albums.length == 0
&& ForYouState.top-artists.length == 0;
spacing: 40px;
alignment: start;
if ForYouState.loading
&& ForYouState.release-watch.albums.length == 0
&& ForYouState.top-artists.length == 0: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
// 0. Qobuz Mixes (static navigation tiles).
QobuzMixesRow {
open-mix(which) => {
@@ -46,6 +46,8 @@ export component ForYouView inherits VerticalLayout {
}
}
if root.show-skeleton: HomeSkeleton { }
// 1. Release Watch.
if ForYouState.release-watch.albums.length > 0: Carousel {
section: ForYouState.release-watch;
+16 -158
View File
@@ -8,9 +8,9 @@
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { MixState, MixTrackItem } from "../state.slint";
import { MixState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackPlayCell } from "../primitives/TrackPlayCell.slint";
import { SearchTrackRow } from "../search/SearchTrackRow.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { NavButtons } from "../shell/NavButtons.slint";
@@ -47,164 +47,12 @@ component CircleAction inherits Rectangle {
}
}
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 {
callback play-track(string);
callback play-all();
callback shuffle();
callback refresh();
callback media-action(string, string, string);
// Per-mix header gradient, matching the QobuzMixesRow tiles.
property <brush> mix-gradient:
@@ -393,11 +241,21 @@ export component MixView inherits Rectangle {
}
Rectangle { height: 6px; }
for track[i] in MixState.tracks: MixTrackRow {
// Reuses the shared SearchTrackRow (album column + favorite
// + track context menu). A row "play" starts the mix from
// that track; menu actions bubble up as media-action.
for track[i] in MixState.tracks: SearchTrackRow {
track: track;
index: i;
play(id) => {
root.play-track(id);
show-artwork: true;
show-album: true;
show-favorite: true;
media-action(kind, id, action) => {
if (action == "play") {
root.play-track(id);
} else {
root.media-action(kind, id, action);
}
}
}
}
@@ -17,6 +17,11 @@ export component SearchTrackRow inherits Rectangle {
in property <SearchTrackItem> track;
in property <int> index;
in property <bool> show-artwork: true;
// Optional extra columns (mix / playlist-style rows): an album
// column and a standalone favorite button. Off by default so the
// search / favorites rows are unchanged.
in property <bool> show-album: false;
in property <bool> show-favorite: false;
// kind ("track"), track id, action ("play" | "play-next" | "queue" | "favorite").
callback media-action(string, string, string);
@@ -110,6 +115,16 @@ export component SearchTrackRow inherits Rectangle {
}
}
// Optional album column (mix / playlist rows).
if root.show-album: Text {
text: root.track.album;
width: 220px;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
overflow: elide;
}
Text {
text: root.track.duration;
width: 70px;
@@ -129,6 +144,31 @@ export component SearchTrackRow inherits Rectangle {
vertical-alignment: center;
}
// Optional standalone favorite button.
if root.show-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;
clicked => {
root.media-action("track", root.track.id, "favorite");
}
}
}
}
VerticalLayout {
alignment: center;
Rectangle {
+3
View File
@@ -142,6 +142,9 @@ export component AppShell inherits Rectangle {
refresh => {
root.media-action("mix", "", "refresh");
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
}
if NavState.view == ContentView.settings: SettingsView {
settings-bool(k, v) => {
+7 -17
View File
@@ -707,6 +707,9 @@ export struct SearchTrackItem {
id: string,
title: string,
artist: string,
// Album title — shown only by rows with show-album (e.g. the mix
// views); empty elsewhere.
album: string,
duration: string,
quality-tier: string,
explicit: bool,
@@ -791,28 +794,15 @@ 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.
// from the For You Qobuz Mixes tiles. Populated by crate::mix. Rows
// reuse the shared SearchTrackRow (with show-album), so the track
// context menu works here too.
export global MixState {
in property <string> kind; // daily | weekly | fav | top
in property <string> title;
in property <string> subtitle;
in property <[MixTrackItem]> tracks: [];
in property <[SearchTrackItem]> tracks: [];
in property <int> track-count: 0;
in property <string> total-duration;
in property <bool> loading: false;