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
569 lines
24 KiB
Plaintext
569 lines
24 KiB
Plaintext
// Dedicated Local Library album detail — SEPARATE from the Qobuz AlbumView.
|
|
// Reuses the internal components (NavButtons, CircleAction, TrackRow,
|
|
// QualityBadgeFull) but exposes ONLY local actions (play all / shuffle / edit
|
|
// tags / add to playlist). When the album has multiple physical versions
|
|
// (distinct source folders), a version picker switches the shown copy — so two
|
|
// copies of the same album never merge into a duplicated track list.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState, NavState, DragState, UiFocusState, ShellState } from "../state.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { CircleAction } from "../primitives/CircleAction.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { TrackRow } from "../primitives/TrackRow.slint";
|
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { DiscHeaderMenu } from "../primitives/DiscHeaderMenu.slint";
|
|
import { ListView } from "std-widgets.slint";
|
|
|
|
// Source glyph for a version: hard-drive (local), Qobuz logo (offline cache),
|
|
// Plex logo. The logos keep their own colors; the local glyph is tinted.
|
|
component SourceIcon inherits Image {
|
|
in property <string> kind;
|
|
width: 14px;
|
|
height: 14px;
|
|
image-fit: contain;
|
|
source: (root.kind == "qobuz_download" || root.kind == "qobuz_purchase")
|
|
? @image-url("../assets/icons/qobuz-logo-filled.svg")
|
|
: (root.kind == "plex"
|
|
? @image-url("../assets/icons/plex-logo.svg")
|
|
: @image-url("../assets/icons/hard-drive.svg"));
|
|
// `qobuz_purchase` → GOLD-tinted Qobuz mark (§8.7 purchase badge).
|
|
colorize: root.kind == "qobuz_purchase" ? #eab308
|
|
: ((root.kind == "qobuz_download" || root.kind == "plex")
|
|
? transparent
|
|
: Theme.text-secondary);
|
|
}
|
|
|
|
// Version picker — selected display + dropdown, both with the source icon.
|
|
component VersionPicker inherits Rectangle {
|
|
in property <[LocalAlbumVersion]> versions;
|
|
in property <int> current;
|
|
callback select(int);
|
|
// Matches QbzSelect `sm` (height 30, radius 6, no outline, 10px left pad)
|
|
// so it reads like every other small select instead of towering over them.
|
|
height: 30px;
|
|
width: pick-row.preferred-width;
|
|
border-radius: 6px;
|
|
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
|
|
pick-row := HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 8px;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
SourceIcon { kind: root.versions[root.current].source; }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: root.versions[root.current].label;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/chevron-down.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
pick-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
menu.show();
|
|
}
|
|
}
|
|
menu := PopupWindow {
|
|
x: 0;
|
|
y: parent.height + 4px;
|
|
width: 280px;
|
|
close-policy: close-on-click;
|
|
Rectangle {
|
|
background: Theme.surface-card;
|
|
border-radius: 8px;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
drop-shadow-blur: 22px;
|
|
drop-shadow-color: #00000080;
|
|
VerticalLayout {
|
|
padding: 6px;
|
|
spacing: 1px;
|
|
for v[i] in root.versions: Rectangle {
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
background: i == root.current
|
|
? Theme.surface-elevated
|
|
: (row-ta.has-hover ? Theme.surface-hover : transparent);
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
SourceIcon { kind: v.source; }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: v.label;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.legal;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
if i == root.current: VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/check.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
}
|
|
row-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.select(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component LocalAlbumView inherits Rectangle {
|
|
// Navigate to the artist (the NAME — local/Plex artists have no id; the
|
|
// source-aware open-artist routes a name to the LocalLibrary Artists tab).
|
|
callback open-artist(string);
|
|
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
|
|
|
// Homologated 1:1 with the Qobuz AlbumPageView: whole-page Flickable, the
|
|
// same header proportions + action row, the toolbar (quality badge + track
|
|
// search), the column header, and source-aware TrackRows. The only
|
|
// intentional differences vs AlbumPageView: no label/awards right sidebar,
|
|
// no Qobuz context menus, and the local-only version picker.
|
|
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 == "local-album" && 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); }
|
|
|
|
// Header band (neutral — local albums have no artwork-derived tint yet).
|
|
Rectangle {
|
|
x: 0;
|
|
y: 0;
|
|
width: parent.width;
|
|
height: 340px;
|
|
background: @linear-gradient(180deg,
|
|
#181820 0%,
|
|
#181820 16%,
|
|
transparent 100%);
|
|
}
|
|
|
|
page := VerticalLayout {
|
|
width: parent.width;
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 11px;
|
|
padding-bottom: 100px;
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// --- History navigation -------------------------------------
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
NavButtons { }
|
|
}
|
|
|
|
Rectangle { height: 22px; }
|
|
|
|
// --- Album header -------------------------------------------
|
|
HorizontalLayout {
|
|
spacing: 32px;
|
|
|
|
Rectangle {
|
|
width: 224px;
|
|
height: 224px;
|
|
border-radius: 12px;
|
|
background: Theme.surface-elevated;
|
|
drop-shadow-blur: 32px;
|
|
drop-shadow-color: #00000080;
|
|
drop-shadow-offset-y: 8px;
|
|
clip: true;
|
|
Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: LocalAlbumState.cover;
|
|
image-fit: cover;
|
|
}
|
|
}
|
|
|
|
VerticalLayout {
|
|
alignment: start;
|
|
padding-top: 4px;
|
|
spacing: 0px;
|
|
horizontal-stretch: 1;
|
|
|
|
Text {
|
|
text: LocalAlbumState.title;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
overflow: elide;
|
|
}
|
|
Rectangle { height: 4px; }
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
la-artist := TouchArea {
|
|
width: la-artist-text.preferred-width;
|
|
height: la-artist-text.preferred-height;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open-artist(LocalAlbumState.artist);
|
|
}
|
|
la-artist-text := Text {
|
|
text: LocalAlbumState.artist;
|
|
color: la-artist.has-hover
|
|
? Theme.text-primary
|
|
: Theme.text-secondary;
|
|
font-size: Typography.heading;
|
|
font-weight: Typography.bold;
|
|
}
|
|
}
|
|
}
|
|
Rectangle { height: 10px; }
|
|
Text {
|
|
text: LocalAlbumState.info-line;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
overflow: elide;
|
|
}
|
|
|
|
Rectangle { height: 20px; }
|
|
|
|
// Local action row — same chrome as AlbumPageView, only the
|
|
// actions that apply to a local/Plex album.
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
spacing: 12px;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/play-fill.svg");
|
|
primary: true;
|
|
// The neutral header band has largely faded out at
|
|
// the action row's height — on light themes these
|
|
// sit on a mostly-plain surface, so use the
|
|
// theme-aware variant (reads fine over the faded
|
|
// band on dark themes too).
|
|
on-surface: true;
|
|
loading: NowPlayingState.loading
|
|
&& NowPlayingState.album-id == LocalAlbumState.id;
|
|
clicked => {
|
|
LocalAlbumActions.play-all();
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
LocalAlbumActions.shuffle();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/pencil.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
LocalAlbumActions.edit-tags();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/list-plus.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
LocalAlbumActions.add-to-playlist();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/cassette-tape.svg");
|
|
on-surface: true;
|
|
clicked => {
|
|
LocalAlbumActions.add-to-mixtape();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Version picker (only with multiple physical copies).
|
|
if LocalAlbumState.versions.length > 1: Rectangle { height: 16px; }
|
|
if LocalAlbumState.versions.length > 1: HorizontalLayout {
|
|
alignment: start;
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: "\{LocalAlbumState.versions.length} " + @tr("versions") + ":";
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
VersionPicker {
|
|
versions: LocalAlbumState.versions;
|
|
current: LocalAlbumState.version-index;
|
|
select(i) => {
|
|
LocalAlbumActions.select-version(i);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { height: 20px; }
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
Rectangle { height: 8px; }
|
|
|
|
// --- Track list (no sidebar) --------------------------------
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
if LocalAlbumState.loading && LocalAlbumState.tracks.length == 0: Rectangle {
|
|
height: 280px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
}
|
|
}
|
|
|
|
// Toolbar — album quality badge + track search.
|
|
HorizontalLayout {
|
|
height: 52px;
|
|
spacing: 16px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QualityBadgeFull {
|
|
tier: LocalAlbumState.quality-tier;
|
|
detail: LocalAlbumState.quality-detail;
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 280px;
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
background: Theme.surface-elevated;
|
|
HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/search.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
track-search := TextInput {
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-primary;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
single-line: true;
|
|
changed has-focus => { UiFocusState.text-input-focused = self.has-focus; }
|
|
edited => {
|
|
LocalAlbumActions.search(self.text);
|
|
}
|
|
}
|
|
Text {
|
|
visible: track-search.text == "";
|
|
text: @tr("Search tracks...");
|
|
width: 100%;
|
|
height: 100%;
|
|
color: Theme.text-muted;
|
|
font-size: 13px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Column header.
|
|
HorizontalLayout {
|
|
height: 40px;
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 16px;
|
|
Text {
|
|
text: "#";
|
|
width: 32px;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
letter-spacing: 0.5px;
|
|
vertical-alignment: center;
|
|
}
|
|
Text {
|
|
// Capital-case to match PlaylistView's column headers.
|
|
text: @tr("Title");
|
|
horizontal-stretch: 1;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
letter-spacing: 0.5px;
|
|
vertical-alignment: center;
|
|
}
|
|
Text {
|
|
// Capital-case to match PlaylistView's column headers.
|
|
text: @tr("Duration");
|
|
width: 80px;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
letter-spacing: 0.5px;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
Text {
|
|
// Capital-case to match PlaylistView's column headers.
|
|
text: @tr("Quality");
|
|
width: 80px;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
letter-spacing: 0.5px;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
// Source column (local/Plex/qobuz indicator on each row).
|
|
Rectangle { width: 28px; }
|
|
// Aligns with the per-row more-button slot.
|
|
Rectangle { width: 32px; }
|
|
}
|
|
|
|
// Disc divider before the first row of each disc on a
|
|
// multi-disc local album (disc-header-number is stamped in
|
|
// apply_album_version; 0 for single-disc → flat list, as
|
|
// before). Same pattern as the Qobuz AlbumPageView.
|
|
for track[index] in LocalAlbumState.tracks: VerticalLayout {
|
|
spacing: 0px;
|
|
if track.disc-header-number > 0: Rectangle {
|
|
height: 40px;
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 16px;
|
|
Text {
|
|
text: @tr("Disc") + " \{track.disc-header-number}";
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
font-weight: 600;
|
|
letter-spacing: 0.5px;
|
|
vertical-alignment: center;
|
|
horizontal-alignment: left;
|
|
horizontal-stretch: 1;
|
|
}
|
|
// Per-disc ⋯ menu — actions scoped to this disc's
|
|
// tracks only (aligns with the column header's 32px
|
|
// right-edge more-button slot).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
DiscHeaderMenu {
|
|
disc: track.disc-header-number;
|
|
disc-action(d, a) => {
|
|
LocalAlbumActions.disc-action(d, a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
TrackRow {
|
|
track: track;
|
|
index: index;
|
|
show-artwork: false;
|
|
show-album: false;
|
|
show-favorite: false;
|
|
show-download: false;
|
|
// Show the per-row source glyph (local / qobuz-offline / Plex).
|
|
show-source: true;
|
|
// Library surface: local menu even for qobuz_download
|
|
// rows (spec §3.4).
|
|
force-local-menu: true;
|
|
media-action(kind, id, action) => {
|
|
// Local-only: ignore album/artist link clicks. "play"
|
|
// (row body / play cell) plays; the context-menu
|
|
// actions (play-next / queue / add-to-playlist) route
|
|
// to their own handler instead of collapsing to play.
|
|
if (kind != "album" && kind != "artist") {
|
|
if (action == "play") {
|
|
LocalAlbumActions.play-track(id);
|
|
} else {
|
|
LocalAlbumActions.track-menu-action(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;
|
|
}
|
|
}
|