Files
qbz/crates/qbz-slint/ui/locallibrary/LocalLibraryView.slint
T
vicrodh 460f7659bd fix(locallibrary): align the Tracks scrollbar with the Albums grid (chrome-bezel standard)
The Tracks list scrollbar sat inside the 32px content padding (too far into
the content). Move it to the view's true right edge — the position the Albums
grid already uses, which is the standard for chrome-bezel views: a padded
VerticalLayout holds the ListView while the ListScrollbar floats at the
full-width edge.

cc
2026-05-29 01:24:52 -06:00

561 lines
24 KiB
Plaintext

// Local Library — the user's on-disk + Plex music, browsed inside QBZ.
//
// Greenfield Slint port. This view uses the app's MANDATORY fixed-chrome
// header — the SAME one FavoritesView / Discover use: NavButtons (back/
// forward) + title on row 1 with the tab menu floated right, per-tab action
// buttons + toolbar on row 2, a 1px divider, then a single scrolling content
// area below. No invented header, no pills (ADR-008): the tabs are underline
// tabs (mirroring FavTab), and the settings affordance is a standalone
// icon-only circle (the one circular shape ADR-008 permits).
//
// Folder management, scan, maintenance, and the danger zone do NOT live in
// this view — they belong under Settings > Local Library. The gear routes
// there.
//
// The per-tab bodies are filled slice by slice (Albums first). Until a slice
// lands the content area shows an empty placeholder so the chrome wiring is
// visible end to end.
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { LocalLibraryState, LocalLibraryActions } from "../state.slint";
import { NavButtons } from "../shell/NavButtons.slint";
import { QbzIcon } from "../primitives/QbzIcon.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { TrackRow } from "../primitives/TrackRow.slint";
import { ListView } from "std-widgets.slint";
// One tab in the Local Library tab menu — underline style (ADR-008: NO
// pills). 1:1 with Favorites' FavTab so the chrome reads identically.
component LibTab inherits Rectangle {
in property <string> label;
in property <string> id;
in property <int> count;
callback selected(string);
property <bool> active: LocalLibraryState.active-tab == root.id;
height: 34px;
width: tab-row.preferred-width;
tab-row := HorizontalLayout {
padding-left: 4px;
padding-right: 4px;
spacing: 6px;
Text {
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
}
if root.count > 0: Text {
text: root.count;
color: Theme.text-muted;
font-size: Typography.legal;
vertical-alignment: center;
}
}
// Active underline (NOT a pill background).
Rectangle {
y: parent.height - 2px;
height: 2px;
width: parent.width;
background: root.active ? Theme.accent : transparent;
border-radius: 1px;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.selected(root.id);
}
}
}
// The Local Library tabs as one floating group (row 1, right side). Order
// mirrors the existing HeaderBar "Local Library" menu (Albums / Artists /
// Folders / Tracks). Counts come from LocalLibraryState (0 until seeded).
component LibTabMenu inherits HorizontalLayout {
spacing: 18px;
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Albums");
id: "albums";
count: LocalLibraryState.album-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Artists");
id: "artists";
count: LocalLibraryState.artist-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Folders");
id: "folders";
count: LocalLibraryState.folder-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
VerticalLayout {
alignment: center;
LibTab {
label: @tr("Tracks");
id: "tracks";
count: LocalLibraryState.track-count;
selected(id) => { LocalLibraryActions.select-tab(id); }
}
}
}
// Standalone icon-only circle (ADR-008 permits a full circle for an
// icon-only control that stands alone) — matches NavButtons' history pair.
// Used for the "Local Library settings" gear (management lives in Settings).
component CircleIconBtn inherits Rectangle {
in property <image> icon;
callback clicked();
width: 28px;
height: 28px;
border-radius: 14px;
background: ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
QbzIcon {
source: root.icon;
width: 15px;
height: 15px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: Theme.text-primary;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked();
}
}
}
export component LocalLibraryView inherits Rectangle {
background: Theme.surface-main;
VerticalLayout {
spacing: 0px;
// ===================== FIXED CHROME =====================
// Row 1 — back/forward + title (left), tab menu + settings gear
// (right). Same structure as FavoritesView row 1.
HorizontalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 11px;
spacing: 16px;
VerticalLayout {
alignment: center;
NavButtons { }
}
VerticalLayout {
alignment: center;
Text {
text: @tr("Local Library");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
}
}
Rectangle { horizontal-stretch: 1; }
VerticalLayout {
alignment: center;
LibTabMenu { }
}
VerticalLayout {
alignment: center;
CircleIconBtn {
icon: @image-url("../assets/icons/settings.svg");
clicked => {
LocalLibraryActions.open-settings();
}
}
}
}
Rectangle { height: 12px; }
// Row 2 — per-tab action buttons (left) + toolbar (right). Filled as
// each tab slice lands. vertical-stretch: 0 is load-bearing (see
// FavoritesView): without it an empty row grabs leftover height and
// inflates the sub-header.
HorizontalLayout {
vertical-stretch: 0;
padding-left: 32px;
padding-right: 32px;
spacing: 8px;
Rectangle { horizontal-stretch: 1; }
// Albums toolbar — search + sort (both server-side). The
// grid/list toggle, group-by and filters land with the next slice.
if LocalLibraryState.active-tab == "albums"
&& (LocalLibraryState.albums.length > 0
|| LocalLibraryState.albums-search != ""): HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> LocalLibraryState.albums-search;
edited(q) => {
LocalLibraryActions.albums-search(q);
}
}
}
VerticalLayout {
alignment: center;
QbzSelect {
menu-width: 180px;
options: [
@tr("Artist A-Z"),
@tr("Artist Z-A"),
@tr("Title A-Z"),
@tr("Title Z-A"),
@tr("Year (newest)"),
@tr("Year (oldest)"),
];
sm: true;
current-index: LocalLibraryState.albums-sort == "artist-asc" ? 0
: LocalLibraryState.albums-sort == "artist-desc" ? 1
: LocalLibraryState.albums-sort == "title-asc" ? 2
: LocalLibraryState.albums-sort == "title-desc" ? 3
: LocalLibraryState.albums-sort == "year-desc" ? 4
: 5;
selected(i) => {
LocalLibraryActions.albums-set-sort(
i == 0 ? "artist-asc"
: i == 1 ? "artist-desc"
: i == 2 ? "title-asc"
: i == 3 ? "title-desc"
: i == 4 ? "year-desc"
: "year-asc");
}
}
}
}
// Tracks toolbar — search.
if LocalLibraryState.active-tab == "tracks"
&& (LocalLibraryState.tracks.length > 0
|| LocalLibraryState.tracks-search != ""): HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
sm: true;
query <=> LocalLibraryState.tracks-search;
edited(q) => {
LocalLibraryActions.tracks-search(q);
}
}
}
}
}
Rectangle { height: 14px; }
Rectangle {
height: 1px;
background: Theme.border-subtle;
}
// ===================== SCROLLING CONTENT =====================
Rectangle {
vertical-stretch: 1;
clip: true;
// -------- Albums tab — chunked grid --------------------------
if LocalLibraryState.active-tab == "albums": Rectangle {
// Initial page load.
if LocalLibraryState.albums-loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
}
// Load error + retry.
if !LocalLibraryState.albums-loading
&& LocalLibraryState.albums-load-failed: VerticalLayout {
alignment: center;
spacing: 10px;
Text {
text: @tr("Couldn't load your local albums.");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
Rectangle {
width: retry-label.preferred-width + 28px;
height: 32px;
border-radius: 6px;
background: retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
retry-label := Text {
text: @tr("Retry");
color: Theme.text-primary;
font-size: Typography.legal;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
retry-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LocalLibraryActions.albums-retry();
}
}
}
}
}
// Empty — no albums indexed yet.
if !LocalLibraryState.albums-loading
&& !LocalLibraryState.albums-load-failed
&& LocalLibraryState.albums.length == 0
&& LocalLibraryState.albums-search == "": VerticalLayout {
alignment: center;
Text {
text: @tr("No albums in your local library yet.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
// Empty — search matched nothing.
if !LocalLibraryState.albums-loading
&& !LocalLibraryState.albums-load-failed
&& LocalLibraryState.albums.length == 0
&& LocalLibraryState.albums-search != "": VerticalLayout {
alignment: center;
Text {
text: @tr("No albums match your search.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
// The grid — self-scrolling, paged on scroll-near-bottom.
if !LocalLibraryState.albums-loading
&& !LocalLibraryState.albums-load-failed
&& LocalLibraryState.albums.length > 0: Rectangle {
albums-flick := Flickable {
viewport-width: self.width;
viewport-height: albums-page.preferred-height;
changed viewport-y => {
if (-self.viewport-y + self.height >= self.viewport-height - 600px
&& LocalLibraryState.albums-has-more
&& !LocalLibraryState.albums-loading-more
&& !LocalLibraryState.albums-loading) {
LocalLibraryActions.albums-load-more();
}
}
albums-page := VerticalLayout {
width: parent.width;
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
padding-bottom: 100px;
spacing: 0px;
alignment: start;
AlbumCollectionView {
albums: LocalLibraryState.albums;
is-grouped: false;
view-mode: "grid";
show-source: false;
// Local albums aren't Qobuz-favoritable — hide the heart.
show-favorite: false;
// Always-visible source badge (local | qobuz-offline | plex).
show-source-badge: true;
open-album(id) => {
LocalLibraryActions.open-album(id);
}
open-artist(id) => {
LocalLibraryActions.open-artist(id);
}
media-action(kind, id, action) => {
LocalLibraryActions.album-action(id, action);
}
}
if LocalLibraryState.albums-loading-more: HorizontalLayout {
alignment: center;
padding-top: 16px;
LoadingSpinner { size: 24px; }
}
}
}
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: albums-flick.viewport-height;
visible-height: albums-flick.height;
viewport-y <=> albums-flick.viewport-y;
}
}
}
// -------- Tracks tab — server-paginated flat list ------------
if LocalLibraryState.active-tab == "tracks": Rectangle {
if LocalLibraryState.tracks-loading: VerticalLayout {
alignment: center;
HorizontalLayout {
alignment: center;
LoadingSpinner { size: 36px; }
}
}
if !LocalLibraryState.tracks-loading
&& LocalLibraryState.tracks-load-failed: VerticalLayout {
alignment: center;
spacing: 10px;
Text {
text: @tr("Couldn't load your local tracks.");
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.semibold;
horizontal-alignment: center;
}
HorizontalLayout {
alignment: center;
Rectangle {
width: tretry-label.preferred-width + 28px;
height: 32px;
border-radius: 6px;
background: tretry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
border-width: 1px;
border-color: Theme.border-subtle;
tretry-label := Text {
text: @tr("Retry");
color: Theme.text-primary;
font-size: Typography.legal;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
tretry-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
LocalLibraryActions.tracks-retry();
}
}
}
}
}
if !LocalLibraryState.tracks-loading
&& !LocalLibraryState.tracks-load-failed
&& LocalLibraryState.tracks.length == 0
&& LocalLibraryState.tracks-search == "": VerticalLayout {
alignment: center;
Text {
text: @tr("No tracks in your local library yet.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
if !LocalLibraryState.tracks-loading
&& !LocalLibraryState.tracks-load-failed
&& LocalLibraryState.tracks.length == 0
&& LocalLibraryState.tracks-search != "": VerticalLayout {
alignment: center;
Text {
text: @tr("No tracks match your search.");
color: Theme.text-muted;
font-size: Typography.body;
horizontal-alignment: center;
}
}
if !LocalLibraryState.tracks-loading
&& !LocalLibraryState.tracks-load-failed
&& LocalLibraryState.tracks.length > 0: Rectangle {
// Padded content, but the scrollbar floats at the view's
// TRUE right edge — matching the Albums grid, which is the
// chrome-bezel standard. (Previously it was inset by the
// 32px content padding, sitting too far into the content.)
VerticalLayout {
padding-left: 32px;
padding-right: 32px;
padding-top: 16px;
tracks-list := ListView {
width: 100%;
height: 100%;
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
changed viewport-y => {
if (-self.viewport-y + self.visible-height >= self.viewport-height - 600px
&& LocalLibraryState.tracks-has-more
&& !LocalLibraryState.tracks-loading-more
&& !LocalLibraryState.tracks-loading) {
LocalLibraryActions.tracks-load-more();
}
}
for track[i] in LocalLibraryState.tracks: TrackRow {
track: track;
index: i;
show-artwork: false;
show-album: true;
show-favorite: false;
show-download: false;
media-action(kind, id, action) => {
if (kind == "album" && action == "open") {
LocalLibraryActions.open-album(id);
} else if (kind == "artist" && action == "open") {
LocalLibraryActions.open-artist(id);
} else {
LocalLibraryActions.track-action(id, action);
}
}
}
}
}
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;
}
}
}
// -------- Other tabs — placeholder until their slices land ----
if LocalLibraryState.active-tab != "albums"
&& LocalLibraryState.active-tab != "tracks": VerticalLayout {
alignment: center;
spacing: 6px;
Text {
text: @tr("Local Library port in progress");
horizontal-alignment: center;
color: Theme.text-secondary;
font-size: Typography.body;
font-weight: Typography.semibold;
}
Text {
text: @tr("This tab is coming next.");
horizontal-alignment: center;
color: Theme.text-muted;
font-size: Typography.legal;
}
}
}
}
}