Files
qbz/crates/qbz-slint/ui/album/LocalAlbumView.slint
T
vicrodh 41c5261613 feat(slint): local album homologation, Recently Played fixes, local artist nav
Album view (local/Plex):
- Rebuild LocalAlbumView as a near copy of the Qobuz AlbumPageView (whole-page
  scroll, 224px cover, action row, toolbar with quality badge + track search,
  column header, source-aware rows) minus the label/awards sidebar and Qobuz
  context menus. Add a per-album client-side track search.

Recently Played (Home) — D:
- Carry the track source (qobuz|plex|local) through the recently-played store so
  the cards resolve source-aware artwork (PlexThumb / local file), the album-card
  play routes to local/Plex playback instead of the empty Qobuz path, and the
  card click opens the right album view.
- Fix a regression from the Plex version split: local_queue_track now derives the
  content-hash plex_album_key for album_id (navigation) while album_group_key keeps
  the per-edition split key, so "go to album" and recent Plex albums open correctly.

Local artist navigation — B:
- Implement open_artist (was a TODO): latch the requested artist and select it once
  the Artists tab loads. The shared open-artist is now source-aware (numeric id ->
  Qobuz artist; name -> LocalLibrary Artists tab). Wire the LocalAlbum header artist
  link and the now-playing "Go to artist" for Plex/local.

Folder delete -> Recently Played cascade:
- Removing a Local Library folder now also prunes its albums/tracks from the
  recently-played store (album_keys_in_folder + recently::prune_albums).

Covers — C:
- Robust on-disk folder cover lookup (cover/folder/front/art/<album>.jpg, any image
  as a last resort), shared by the Folders subcards and now-playing/album rows.

cc
2026-06-09 01:44:39 -06:00

498 lines
20 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 } 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 { 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"
? @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"));
colorize: (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: 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;
diameter: 44px;
loading: NowPlayingState.loading
&& NowPlayingState.album-id == LocalAlbumState.id;
clicked => {
LocalAlbumActions.play-all();
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/shuffle.svg");
clicked => {
LocalAlbumActions.shuffle();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pencil.svg");
clicked => {
LocalAlbumActions.edit-tags();
}
}
}
VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/list-plus.svg");
clicked => {
LocalAlbumActions.add-to-playlist();
}
}
}
}
// 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;
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 {
text: @tr("TITLE");
horizontal-stretch: 1;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
vertical-alignment: center;
}
Text {
text: @tr("DURATION");
width: 80px;
color: Theme.text-muted;
font-size: Typography.legal;
letter-spacing: 0.5px;
horizontal-alignment: center;
vertical-alignment: center;
}
Text {
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; }
}
for track[index] in LocalAlbumState.tracks: 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;
media-action(kind, id, action) => {
// Local-only: ignore album/artist link clicks; any other
// row action plays the track.
if (kind != "album" && kind != "artist") {
LocalAlbumActions.play-track(id);
}
}
}
}
}
}
// 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;
}
}