Files
qbz/crates/qbz-slint/ui/mix/MixView.slint
T
vicrodh 34eaebd8ef feat(slint): play-from-clicked-track, track-row quality badge, album link, playlist layout
Committed together because these changes share main.rs, playlist.rs and
PlaylistView.slint, and the new quality field is atomic across every
TrackItem builder, so they cannot compile as separate whole-file commits.

- Universal play-from-here: a single play_track_in_context dispatcher builds
  the queue from the current view's VISIBLE list and starts at the clicked
  track (playlist, album, artist, favorites, label, mix, search). Cached
  views order by the visible model; re-fetch views (album / artist top
  tracks) reorder to the in-page filter; the search "most popular" hero
  prepends to the results queue. Drops the playlist-track / mix-track arms
  that always started at track 1 (the clicked id was read from the wrong
  media-action slot).

- Album-name links in track rows open the album page (the ("album","open")
  media-action arm was missing and fell through to a no-op).

- Unified track-row quality badge: a new quality-detail field on TrackItem,
  populated from bit-depth / sample-rate in every builder through a shared
  crate::quality::detail, rendered with a bare (no chip background/border,
  centered text) QualityBadgeFull. Restores the exact bit-depth / sample-rate
  line that had regressed to a plain CD / Hi-Res tier.

- Playlist track list: reserve the favorite / download / context-menu columns
  in the column header, match the row's 36px cover cell, and anchor the
  scrollbar to the view edge (matches Local Library > Tracks).

cc
2026-05-31 21:50:51 -06:00

277 lines
10 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, DragState } from "../state.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.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();
}
}
}
}
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:
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;
flick := Flickable {
interactive: !DragState.active;
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.section;
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: 92px;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
}
Rectangle { width: 28px; }
Rectangle { width: 32px; }
}
if MixState.tracks.length > 0: Rectangle { height: 3px; }
if MixState.tracks.length > 0: Rectangle {
height: 1px;
background: Theme.border-subtle;
}
Rectangle { height: 6px; }
// Reuses the shared TrackRow (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: TrackRow {
track: track;
index: i;
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);
}
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}