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
This commit is contained in:
12 files changed
+645
-162
No files matched your search
@@ -7,7 +7,7 @@
|
||||
|
||||
import { Theme } from "../foundation/semantic-colors.slint";
|
||||
import { Typography } from "../foundation/typography.slint";
|
||||
import { LocalAlbumState, LocalAlbumActions, LocalAlbumVersion, NowPlayingState, NavState } from "../state.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";
|
||||
@@ -39,15 +39,16 @@ component VersionPicker inherits Rectangle {
|
||||
in property <[LocalAlbumVersion]> versions;
|
||||
in property <int> current;
|
||||
callback select(int);
|
||||
height: 32px;
|
||||
width: pick-row.preferred-width + 22px;
|
||||
// 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;
|
||||
border-width: 1px;
|
||||
border-color: Theme.border-subtle;
|
||||
background: pick-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
||||
|
||||
pick-row := HorizontalLayout {
|
||||
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
||||
padding-left: 10px;
|
||||
padding-right: 8px;
|
||||
spacing: 7px;
|
||||
VerticalLayout {
|
||||
alignment: center;
|
||||
@@ -94,7 +95,7 @@ component VersionPicker inherits Rectangle {
|
||||
padding: 6px;
|
||||
spacing: 1px;
|
||||
for v[i] in root.versions: Rectangle {
|
||||
height: 34px;
|
||||
height: 30px;
|
||||
border-radius: 6px;
|
||||
background: i == root.current
|
||||
? Theme.surface-elevated
|
||||
@@ -141,23 +142,55 @@ component VersionPicker inherits Rectangle {
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
VerticalLayout {
|
||||
spacing: 0px;
|
||||
// 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); }
|
||||
|
||||
// --- 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;
|
||||
// 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 { }
|
||||
@@ -165,13 +198,13 @@ export component LocalAlbumView inherits Rectangle {
|
||||
|
||||
Rectangle { height: 22px; }
|
||||
|
||||
// --- Album header -------------------------------------------
|
||||
HorizontalLayout {
|
||||
spacing: 28px;
|
||||
spacing: 32px;
|
||||
|
||||
// Cover.
|
||||
Rectangle {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
width: 224px;
|
||||
height: 224px;
|
||||
border-radius: 12px;
|
||||
background: Theme.surface-elevated;
|
||||
drop-shadow-blur: 32px;
|
||||
@@ -188,8 +221,8 @@ export component LocalAlbumView inherits Rectangle {
|
||||
|
||||
VerticalLayout {
|
||||
alignment: start;
|
||||
padding-top: 6px;
|
||||
spacing: 6px;
|
||||
padding-top: 4px;
|
||||
spacing: 0px;
|
||||
horizontal-stretch: 1;
|
||||
|
||||
Text {
|
||||
@@ -199,29 +232,38 @@ export component LocalAlbumView inherits Rectangle {
|
||||
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 {
|
||||
Rectangle { height: 4px; }
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
QualityBadgeFull {
|
||||
tier: LocalAlbumState.quality-tier;
|
||||
detail: LocalAlbumState.quality-detail;
|
||||
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: 6px; }
|
||||
Rectangle { height: 20px; }
|
||||
|
||||
// Local-only action row.
|
||||
// Local action row — same chrome as AlbumPageView, only the
|
||||
// actions that apply to a local/Plex album.
|
||||
HorizontalLayout {
|
||||
alignment: start;
|
||||
spacing: 12px;
|
||||
@@ -229,8 +271,6 @@ export component LocalAlbumView inherits Rectangle {
|
||||
icon: @image-url("../assets/icons/play-fill.svg");
|
||||
primary: true;
|
||||
diameter: 44px;
|
||||
// Spin only while THIS album's first track is fetching
|
||||
// (local album-id == the now-playing track's album_id).
|
||||
loading: NowPlayingState.loading
|
||||
&& NowPlayingState.album-id == LocalAlbumState.id;
|
||||
clicked => {
|
||||
@@ -266,7 +306,8 @@ export component LocalAlbumView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// Version picker (only when there are multiple copies).
|
||||
// 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;
|
||||
@@ -292,73 +333,165 @@ export component LocalAlbumView inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
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; }
|
||||
}
|
||||
Rectangle { height: 20px; }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.border-subtle;
|
||||
}
|
||||
Rectangle { height: 8px; }
|
||||
|
||||
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;
|
||||
// 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 = "";
|
||||
}
|
||||
// --- 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; }
|
||||
}
|
||||
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); }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,11 @@ export component AppShell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
// Dedicated Local Library album detail (separate from AlbumView).
|
||||
if NavState.view == ContentView.local-album: LocalAlbumView { }
|
||||
if NavState.view == ContentView.local-album: LocalAlbumView {
|
||||
open-artist(name) => {
|
||||
root.open-artist(name);
|
||||
}
|
||||
}
|
||||
if NavState.view == ContentView.artist: ArtistPageView {
|
||||
open-album(id) => {
|
||||
root.open-album(id);
|
||||
|
||||
@@ -328,8 +328,14 @@ export component SongCard inherits Rectangle {
|
||||
label: NowPlayingState.artist;
|
||||
stretch: 2.0;
|
||||
activated => {
|
||||
if (NowPlayingState.artist-id != "" && !NowPlayingState.is-ephemeral) {
|
||||
root.open-artist(NowPlayingState.artist-id);
|
||||
if (!NowPlayingState.is-ephemeral) {
|
||||
// Qobuz: numeric artist-id → Qobuz artist page. Plex/local:
|
||||
// no id, so pass the NAME → LocalLibrary Artists tab.
|
||||
if (NowPlayingState.artist-id != "") {
|
||||
root.open-artist(NowPlayingState.artist-id);
|
||||
} else if (NowPlayingState.artist != "") {
|
||||
root.open-artist(NowPlayingState.artist);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,6 +699,9 @@ export global LocalAlbumActions {
|
||||
callback add-to-playlist();
|
||||
callback play-track(string /* track id */);
|
||||
callback select-version(int);
|
||||
// Client-side track filter (mirrors AlbumActions.search), applied over the
|
||||
// current version's tracks.
|
||||
callback search(string /* query */);
|
||||
}
|
||||
|
||||
// ============================ Local Library management ====================
|
||||
@@ -767,6 +770,7 @@ export global LibraryManageActions {
|
||||
callback load(); // (re)load the folder list (panel init)
|
||||
callback add-folder(); // native dir picker (Rust)
|
||||
callback remove-folders(); // bulk-remove selected (confirm)
|
||||
callback remove-folder(int /* id */); // remove ONE folder directly (confirm)
|
||||
callback toggle-folder-select(int /* id */);
|
||||
callback edit-folder(int /* id */); // open the settings modal
|
||||
callback save-folder-settings(int /* id */, string /* alias */, bool /* enabled */,
|
||||
|
||||
Reference in new issue
Block a user