Files
qbz/crates/qbz-slint/ui/mix/MixView.slint
T
vicrodh 61882166f7 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
2026-05-21 22:48:49 -06:00

406 lines
14 KiB
Plaintext

// 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 { 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 <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 {
callback play-track(string);
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;
Flickable {
viewport-width: self.width;
viewport-height: page.preferred-height;
page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
// --- Header ------------------------------------------------
HorizontalLayout {
spacing: 32px;
alignment: start;
// Gradient artwork.
Rectangle {
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: 34px;
font-weight: Typography.bold;
}
}
// Metadata.
VerticalLayout {
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-secondary;
font-size: Typography.body;
wrap: word-wrap;
}
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();
}
}
}
}
}
}
Rectangle { height: 28px; }
if MixState.loading: HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
if !MixState.loading && MixState.tracks.length == 0: Text {
text: @tr("No tracks for this mix.");
color: Theme.text-muted;
font-size: Typography.body;
}
// --- 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;
play(id) => {
root.play-track(id);
}
}
}
}
}