Files
qbz/crates/qbz-slint/ui/album/LocalAlbumView.slint
T
vicrodh 201082d4a1 feat(locallibrary): complete Albums 1:1 + dedicated local album view with versions
Albums tab — full 1:1 with Tauri in one pass:
- Switch from the chunked-paginated grid to a full-load + in-memory derive so
  grouping/filtering span the whole set. derive_albums = search + sort + group
  (off / by letter with A-Z jump / by artist, both with section headers) + the
  quality/format/source filter (1:1 with matchesQualityFilters: OR within a
  group, AND between groups; empty group passes).
- Grid/list view toggle; the list view shows a SOURCE column (Local/Offline/
  Plex), placed after TYPE. Filter popup now FLOATS (sibling of the content
  layout, not a layout slot) with an active-count badge. Fixed the year sort
  (sort_album_items maps year-asc/desc — was also silently broken in Folders).
- Album covers dual-set by id across the full set + visible + grouped sections.

Dedicated local album view — SEPARATE from the Qobuz AlbumView:
- New ContentView::local-album + NavEntry::LocalAlbum + ui/album/LocalAlbumView.slint,
  reusing TrackRow/NavButtons/CircleAction/QualityBadge internals but ONLY local
  actions (play all / shuffle / edit tags / add to playlist) — no Qobuz chrome.
- Versions: an album's tracks are split by source directory into versions, so
  multiple copies of the same album no longer merge into a duplicated track
  list. Best-quality version is the default; a source-icon version picker
  (hard-drive / Qobuz / Plex glyph in both the selected display and the options)
  switches the shown copy in place. The album cover is the best-quality version
  that has one, falling through versions, else a placeholder (covers backfilled
  from disk via fill_missing_covers). Header padding-top 11px matches the app.

Fix: Artists tab deadlock — fetch_missing_artist_images snapshotted names with a
blocking rx.recv() called from inside an event-loop closure, freezing the UI
(static spinner, no error). Names are now snapshotted on the UI thread and
passed in; the worker never blocks the event loop.

cc
2026-05-29 19:16:09 -06:00

349 lines
13 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 } 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);
height: 32px;
width: pick-row.preferred-width + 22px;
border-radius: 6px;
border-width: 1px;
border-color: Theme.border-subtle;
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
pick-row := HorizontalLayout {
x: (parent.width - self.width) / 2;
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: 34px;
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 {
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// --- History nav + header ---------------------------------------
// padding-top 11px matches Discover/Album so the [<] [>] pair sits at
// the same top margin across every view. vertical-stretch: 0 is
// load-bearing (FavoritesView lesson): without it this nested layout
// grabs the leftover height and the header balloons.
VerticalLayout {
vertical-stretch: 0;
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
spacing: 0px;
HorizontalLayout {
alignment: start;
NavButtons { }
}
Rectangle { height: 22px; }
HorizontalLayout {
spacing: 28px;
// Cover.
Rectangle {
width: 200px;
height: 200px;
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: 6px;
spacing: 6px;
horizontal-stretch: 1;
Text {
text: LocalAlbumState.title;
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
overflow: elide;
}
Text {
text: LocalAlbumState.artist;
color: Theme.text-secondary;
font-size: Typography.heading;
font-weight: Typography.bold;
overflow: elide;
}
Text {
text: LocalAlbumState.info-line;
color: Theme.text-muted;
font-size: Typography.body;
}
if LocalAlbumState.quality-tier != "": HorizontalLayout {
alignment: start;
QualityBadgeFull {
tier: LocalAlbumState.quality-tier;
detail: LocalAlbumState.quality-detail;
}
}
Rectangle { height: 6px; }
// Local-only action row.
HorizontalLayout {
alignment: start;
spacing: 12px;
CircleAction {
icon: @image-url("../assets/icons/play-fill.svg");
primary: true;
diameter: 44px;
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 when there are multiple copies).
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: 16px; }
}
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// --- Track list -------------------------------------------------
Rectangle {
vertical-stretch: 1;
clip: true;
if LocalAlbumState.loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 32px; }
}
}
if !LocalAlbumState.loading && LocalAlbumState.tracks.length > 0: Rectangle {
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 12px;
tracks-list := ListView {
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
for track[i] in LocalAlbumState.tracks: TrackRow {
track: track;
index: i;
show-artwork: false;
show-album: false;
show-favorite: false;
show-download: false;
show-source: false;
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);
}
}
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: tracks-list.viewport-height;
visible-height: tracks-list.visible-height;
viewport-y <=> tracks-list.viewport-y;
}
}
}
}
}