Smoke round 2 (owner feedback: content area was an opaque black slab, motion
imperceptible, ambient≈blurred).
- Content-area sweep: 27 content-view roots go transparent when the app
background is active (was opaque Theme.surface-main covering the background) —
Home/Discover/Album/Artist/Search-adjacent/Favorites/Playlist/LocalLibrary/
MyQBZ/managers/etc. Content well (AppShell) transparent too.
- SongCard (NPB classic), search input box, cortinilla panel go translucent so
they stop reading as opaque slabs over the background.
- ambient.wgsl: much faster + bigger blob travel + stronger domain warp +
brighter/more saturated so the flow is obviously moving and clearly distinct
from the blurred-art mode.
- Defaults: dim 0.35, surface-alpha 0.5.
Checkpoint during smoke iteration (owner-requested). Base: 76ea5a82.
cc
322 lines
13 KiB
Plaintext
322 lines
13 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, NavState, BulkAction, ShellState } 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 { MultiSelectBar } from "../primitives/MultiSelectBar.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 <bool> active: 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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: root.primary
|
|
? #ffffff
|
|
: (root.active ? Theme.accent : 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: ShellState.app-background-active ? transparent : Theme.surface-main;
|
|
|
|
flick := Flickable {
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: page.preferred-height;
|
|
// Scroll-position restore on back/forward (see nav::scope_for).
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "mix" && self.viewport-height > self.height) {
|
|
self.viewport-y = NavState.scroll-restore;
|
|
NavState.restore-scope = "";
|
|
}
|
|
}
|
|
init => { self.sr-restore(); }
|
|
changed sr-armed => { self.sr-restore(); }
|
|
changed viewport-height => { self.sr-restore(); }
|
|
changed viewport-y => { NavState.report-scroll(self.viewport-y); }
|
|
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
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;
|
|
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;
|
|
}
|
|
}
|
|
// Multi-select toggle (accent-tinted when active).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: MixState.multi-select;
|
|
enabled: MixState.tracks.length > 0;
|
|
clicked => {
|
|
root.media-action("mix", "", "select-toggle");
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/home-gear.svg");
|
|
clicked => {
|
|
root.refresh();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { height: 28px; }
|
|
|
|
// Bulk action bar — visible in multi-select mode. Scrolls with the
|
|
// page (same as AlbumView). Actions bubble via media-action("mix").
|
|
if MixState.multi-select: MultiSelectBar {
|
|
selected-count: MixState.selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-plus.svg"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
root.media-action("mix", "", id);
|
|
}
|
|
}
|
|
if MixState.multi-select: Rectangle { height: 8px; }
|
|
|
|
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;
|
|
multi-select-mode: MixState.multi-select;
|
|
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;
|
|
}
|
|
}
|