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:
8 files changed
+83
-193
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user