Replaces the trash can (Favorites bulk bars, tracks + albums) and the plain heart (Local Library bulk bar) with Lucide heart-crack — trash reads as file deletion, not as un-favorite. NOTE: touches .slint, so the next build recompiles qbz-ui (long path). km
2194 lines
114 KiB
Plaintext
2194 lines
114 KiB
Plaintext
// Library > Favorites — the user's saved tracks / albums / artists /
|
|
// playlists / labels. A fixed chrome block (back/forward + title + the
|
|
// tab menu on row 1; per-view action buttons + toolbar on row 2) sits
|
|
// over a single scrolling content area. Tracks reuse the shared TrackRow,
|
|
// albums the shared AlbumCollectionView, artists/playlists round/tile
|
|
// grids, labels a row list.
|
|
|
|
import { Theme } from "../foundation/semantic-colors.slint";
|
|
import { Typography } from "../foundation/typography.slint";
|
|
import { Radius } from "../foundation/radius.slint";
|
|
import {
|
|
FavoritesState,
|
|
FavoritesActions,
|
|
LibraryAllState,
|
|
LibraryAllActions,
|
|
LibraryFeedItem,
|
|
AlbumCardItem,
|
|
TrackItem,
|
|
FavoriteArtistItem,
|
|
FavoriteLabelItem,
|
|
DragState,
|
|
GenreFilterState,
|
|
GenreFilterActions,
|
|
AlphaJump,
|
|
NavState,
|
|
ShellState,
|
|
} from "../state.slint";
|
|
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
|
|
import { AlbumCard } from "../discover/AlbumCard.slint";
|
|
import { TrackCard } from "../discover/TrackCard.slint";
|
|
import { ArtistGridCard } from "../discover/ArtistGridCard.slint";
|
|
import { PlaylistCard } from "../discover/PlaylistCard.slint";
|
|
import { LabelCard } from "../discover/LabelCard.slint";
|
|
import { GenreFilterPopup } from "../discover/GenreFilterPopup.slint";
|
|
import { TrackRow } from "../primitives/TrackRow.slint";
|
|
import { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
|
|
import { CircleAction } from "../primitives/CircleAction.slint";
|
|
import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
|
|
import { ToggleButton } from "../primitives/ToggleButton.slint";
|
|
import { QbzSelect } from "../primitives/QbzSelect.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { QualityBadge } from "../primitives/QualityBadge.slint";
|
|
import { QualityBadgeFull } from "../primitives/QualityBadgeFull.slint";
|
|
import { SourceGlyph } from "../primitives/SourceGlyph.slint";
|
|
import { ContextMenu } from "../primitives/ContextMenu.slint";
|
|
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { PlaylistListRow } from "../primitives/PlaylistListRow.slint";
|
|
import { SegmentedTabBar, SegTab } from "../primitives/SegmentedTabBar.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { ListView } from "std-widgets.slint";
|
|
|
|
// One tab in the favorites tab menu.
|
|
// The 5 favorites tabs as one floating group (row 1, right side) — the
|
|
// shared Discovery-style SegmentedTabBar with count badges. Counts stay
|
|
// live (FavoritesState.*-total are seeded up front).
|
|
component FavTabMenu inherits SegmentedTabBar {
|
|
active-id: FavoritesState.active-tab;
|
|
tabs: [
|
|
{ id: "all", label: @tr("All"), count: LibraryAllState.total },
|
|
{ id: "tracks", label: @tr("Tracks"), count: FavoritesState.tracks-total },
|
|
{ id: "albums", label: @tr("Albums"), count: FavoritesState.albums-total },
|
|
{ id: "artists", label: @tr("Artists"), count: FavoritesState.artists-total },
|
|
{ id: "playlists", label: @tr("Playlists"), count: FavoritesState.playlists-total },
|
|
{ id: "labels", label: @tr("Labels"), count: FavoritesState.labels-total },
|
|
];
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Viewport windowing engine for the Library "All" grid + list — POST-LAYOUT
|
|
// SNAPSHOT SAMPLING, the SAME class-kill as AlbumGrid's (see the long comment
|
|
// in AlbumCollectionView.slint; the "Recursion detected" panic class).
|
|
//
|
|
// Rules copied from there, do not break them:
|
|
// - `visible-top` / `visible-height` / `content-offset` are POLLED, never
|
|
// observed: read ONLY from the sampler Timer below. Wiring them to live
|
|
// layout expressions (-flick.viewport-y / flick.height) is safe BECAUSE
|
|
// nothing here evaluates them from a binding or `changed` handler.
|
|
// - The band state is PLAIN properties written ONLY by the Timer. No
|
|
// `changed` handlers, no init-time reads of the inputs.
|
|
// Consumers gate each slot on `first-item`/`last-item` (plain, Timer-written)
|
|
// and report through `window-changed` so Rust dispatches covers for the band.
|
|
// ---------------------------------------------------------------------------
|
|
component ViewportWindow inherits Rectangle {
|
|
in property <length> visible-top: 0px;
|
|
in property <length> visible-height: 0px;
|
|
// This content's approximate y inside the Flickable's CONTENT coordinates;
|
|
// small errors are absorbed by the one-viewport margin.
|
|
in property <length> content-offset: 0px;
|
|
in property <length> row-stride: 1px;
|
|
in property <int> columns: 1;
|
|
callback window-changed(int /* first item index */, int /* last item index */);
|
|
|
|
// Snapshot band — plain values, sampler-written. The pre-seed [0, 3]
|
|
// keeps roughly the first viewport of slots mounted for the <=80ms
|
|
// between mount and the first tick (placeholder tiles cover the rest).
|
|
property <int> target-first-row: 0;
|
|
property <int> target-last-row: 3;
|
|
// Mounted band = target band + contract hysteresis. Expansion follows the
|
|
// target immediately; contraction lags by `hysteresis-rows` so a few-px
|
|
// oscillation across a row boundary cannot mount/unmount a whole row on
|
|
// every direction change.
|
|
property <int> hysteresis-rows: 1;
|
|
property <int> first-window-row: 0;
|
|
property <int> last-window-row: 3;
|
|
// Item-index form of the mounted band — the slot gates read these.
|
|
out property <int> first-item: 0;
|
|
out property <int> last-item: 3 * Math.max(1, root.columns) - 1;
|
|
// Last column count reported through window-changed (columns shift on
|
|
// resize, which re-keys the band's ITEM indices).
|
|
property <int> notified-columns: 0;
|
|
pure function desired-first-row() -> int {
|
|
if (root.target-first-row < root.first-window-row) {
|
|
return root.target-first-row;
|
|
}
|
|
if (root.target-first-row > root.first-window-row + root.hysteresis-rows) {
|
|
return root.target-first-row - root.hysteresis-rows;
|
|
}
|
|
return root.first-window-row;
|
|
}
|
|
pure function desired-last-row() -> int {
|
|
if (root.target-last-row > root.last-window-row) {
|
|
return root.target-last-row;
|
|
}
|
|
if (root.target-last-row < root.last-window-row - root.hysteresis-rows) {
|
|
return root.target-last-row + root.hysteresis-rows;
|
|
}
|
|
return root.last-window-row;
|
|
}
|
|
function apply-window() {
|
|
if (root.desired-first-row() != root.first-window-row
|
|
|| root.desired-last-row() != root.last-window-row) {
|
|
root.first-window-row = root.desired-first-row();
|
|
root.last-window-row = root.desired-last-row();
|
|
root.publish();
|
|
}
|
|
}
|
|
// The sampler: reads the live inputs (safe here — event-loop context),
|
|
// recomputes the target band, applies hysteresis, and notifies on change.
|
|
// ~12.5 Hz is enough: the band carries a full-viewport margin, and the
|
|
// placeholder tiles are the designed fast-scroll fallback.
|
|
function sample-window() {
|
|
root.target-first-row = Math.max(0, Math.floor(
|
|
((root.visible-top - root.content-offset) - root.visible-height)
|
|
/ root.row-stride));
|
|
root.target-last-row = Math.max(0, Math.floor(
|
|
((root.visible-top - root.content-offset) + 2 * root.visible-height)
|
|
/ root.row-stride));
|
|
root.apply-window();
|
|
if (root.columns != root.notified-columns) {
|
|
root.notified-columns = root.columns;
|
|
root.publish();
|
|
}
|
|
}
|
|
Timer {
|
|
interval: 80ms;
|
|
running: true;
|
|
triggered => { root.sample-window(); }
|
|
}
|
|
function publish() {
|
|
root.first-item = root.first-window-row * Math.max(1, root.columns);
|
|
root.last-item = (root.last-window-row + 1) * Math.max(1, root.columns) - 1;
|
|
root.window-changed(root.first-item, root.last-item);
|
|
}
|
|
}
|
|
|
|
// One underline sub-tab for the Playlists tab (Library / Following).
|
|
// Underline style, NOT a pill (ADR-008). Mirrors the FavTab pattern.
|
|
component PlaylistSubTab inherits Rectangle {
|
|
in property <string> label;
|
|
in property <string> tab-id; // "favorites" | "following"
|
|
in property <int> count: 0;
|
|
property <bool> active: FavoritesState.playlists-sub-tab == root.tab-id;
|
|
height: 36px;
|
|
width: row.preferred-width;
|
|
|
|
row := HorizontalLayout {
|
|
padding-left: 2px;
|
|
padding-right: 2px;
|
|
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.
|
|
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 => {
|
|
FavoritesActions.playlists-set-sub-tab(root.tab-id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// (FavLabelCard + FavArtistCard removed — the Favorites Labels + Artists grids
|
|
// now use the card-sized LabelCard / ArtistGridCard, matching the mixed feed.)
|
|
|
|
// One row in the Favorites Artists sidepanel left list — a 40px round thumb
|
|
// + name; the selected row is highlighted.
|
|
component SidepanelArtistRow inherits Rectangle {
|
|
in property <FavoriteArtistItem> item;
|
|
in property <bool> selected;
|
|
callback clicked(string, string);
|
|
height: 56px;
|
|
border-radius: 8px;
|
|
background: root.selected
|
|
? Theme.surface-elevated
|
|
: (ta.has-hover ? #ffffff10 : transparent);
|
|
HorizontalLayout {
|
|
padding-left: 8px;
|
|
padding-right: 8px;
|
|
spacing: 10px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 40px;
|
|
height: 40px;
|
|
border-radius: 20px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
image-fit: cover;
|
|
source: root.item.image;
|
|
}
|
|
if root.item.image-url == "": QbzIcon {
|
|
source: @image-url("../assets/icons/user.svg");
|
|
width: 18px;
|
|
height: 18px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.item.name;
|
|
color: root.selected ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: Typography.body;
|
|
font-weight: root.selected ? Typography.semibold : Typography.regular;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked(root.item.id, root.item.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Genre-filter trigger for the favorites toolbars — opens the shared
|
|
// popup in the "favorites" context. Accent fill + "N genres" when active.
|
|
component FavGenreButton inherits Rectangle {
|
|
// Which shared genre-filter context this button edits ("favorites" for the
|
|
// favorites tabs, "library-all" for the mixed Library feed). The popup is
|
|
// shared; set-context switches whose selection it shows.
|
|
in property <string> context: "favorites";
|
|
property <bool> active: GenreFilterState.selected-count > 0;
|
|
width: genre-row.preferred-width;
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
background: root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
|
|
|
genre-row := HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 12px;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/list-filter.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: root.active ? #ffffff : Theme.text-secondary;
|
|
}
|
|
}
|
|
Text {
|
|
text: root.active
|
|
? (GenreFilterState.selected-count == 1
|
|
? @tr("1 genre")
|
|
: @tr("{} genres", GenreFilterState.selected-count))
|
|
: @tr("Filter by genre");
|
|
color: root.active ? #ffffff : Theme.text-secondary;
|
|
font-size: 12px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
GenreFilterActions.set-context(root.context);
|
|
GenreFilterState.open = !GenreFilterState.open;
|
|
}
|
|
}
|
|
}
|
|
|
|
// One row of the Library "All" sort menu — mirrors PlaylistView's SortOption:
|
|
// the active field shows its direction chevron (asc/desc) and re-selecting it
|
|
// toggles direction.
|
|
component LibrarySortOption inherits Rectangle {
|
|
in property <string> label;
|
|
in property <bool> active: false;
|
|
in property <bool> asc: true;
|
|
callback chosen();
|
|
height: 30px;
|
|
border-radius: 5px;
|
|
background: lso-ta.has-hover ? Theme.surface-hover : transparent;
|
|
HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 8px;
|
|
spacing: 6px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.active ? Theme.text-primary : Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
font-weight: root.active ? Typography.semibold : Typography.regular;
|
|
vertical-alignment: center;
|
|
horizontal-stretch: 1;
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
if root.active: QbzIcon {
|
|
source: root.asc
|
|
? @image-url("../assets/icons/chevron-up.svg")
|
|
: @image-url("../assets/icons/chevron-down.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
}
|
|
lso-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.chosen();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Grid/list segmented toggle. Parameterized so albums + playlists can both
|
|
// drive it with their own view-mode state.
|
|
component ViewToggle inherits Rectangle {
|
|
in property <string> view-mode; // current "grid" | "list"
|
|
callback set-view(string);
|
|
width: 60px;
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
HorizontalLayout {
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/layout-grid.svg");
|
|
active: root.view-mode == "grid";
|
|
sm: true;
|
|
clicked => {
|
|
root.set-view("grid");
|
|
}
|
|
}
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/list.svg");
|
|
active: root.view-mode == "list";
|
|
sm: true;
|
|
clicked => {
|
|
root.set-view("list");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Vertical A-Z jump strip overlaid on the right edge of a grouped list /
|
|
// grid. Emits jump(ordinal, index); the host scrolls its list/flickable.
|
|
component AlphaStrip inherits Rectangle {
|
|
in property <[AlphaJump]> jumps;
|
|
callback jump(int /* ordinal */, int /* index */);
|
|
width: 18px;
|
|
|
|
VerticalLayout {
|
|
alignment: center;
|
|
spacing: 0px;
|
|
for j[k] in root.jumps: Rectangle {
|
|
height: 15px;
|
|
width: 18px;
|
|
Text {
|
|
text: j.letter;
|
|
color: la.has-hover ? Theme.accent : Theme.text-muted;
|
|
font-size: 9px;
|
|
font-weight: Typography.semibold;
|
|
horizontal-alignment: center;
|
|
vertical-alignment: center;
|
|
}
|
|
la := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.jump(k, j.index);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
export component FavoritesView inherits Rectangle {
|
|
callback open-album(string);
|
|
callback open-artist(string);
|
|
|
|
property <length> artist-w: 160px;
|
|
property <length> artist-h: 200px;
|
|
// Artists grid now uses the 200x246 ArtistGridCard (labels still use the
|
|
// round 160x200 FavLabelCard via artist-w/artist-h above).
|
|
property <length> artist-card-w: 200px;
|
|
property <length> artist-card-h: 246px;
|
|
property <length> gap: 24px;
|
|
|
|
background: ShellState.app-background-active ? transparent : Theme.surface-main;
|
|
|
|
VerticalLayout {
|
|
spacing: 0px;
|
|
|
|
// ===================== FIXED CHROME =====================
|
|
|
|
// Row 1 — back/forward + title (left), tab menu (right).
|
|
HorizontalLayout {
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 11px;
|
|
spacing: 16px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Library");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
}
|
|
}
|
|
// Reserve the action-button height from the start so the header
|
|
// doesn't jump when the (conditional) buttons appear after load.
|
|
Rectangle { width: 0px; height: 40px; }
|
|
// Per-tab action buttons — inline, to the right of the title.
|
|
if FavoritesState.active-tab == "tracks"
|
|
&& FavoritesState.tracks-visible.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/play-fill.svg");
|
|
primary: true;
|
|
// Fixed chrome on the plain page background — theme-
|
|
// aware variant so these read on light themes.
|
|
on-surface: true;
|
|
tooltip: @tr("Play all tracks");
|
|
clicked => { FavoritesActions.play-all-tracks(); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
tooltip: @tr("Shuffle all tracks");
|
|
clicked => { FavoritesActions.shuffle-tracks(); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: FavoritesState.tracks-multi-select;
|
|
on-surface: true;
|
|
tooltip: @tr("Select multiple tracks");
|
|
clicked => { FavoritesActions.toggle-multi-select(); }
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "albums"
|
|
&& FavoritesState.albums-visible.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
tooltip: @tr("Play a random album");
|
|
clicked => { FavoritesActions.albums-shuffle(); }
|
|
}
|
|
}
|
|
// Multi-select — LIST mode only (owner 2026-07-24): bulk
|
|
// un-favorite / play-next / queue on the checked rows.
|
|
if FavoritesState.albums-view-mode == "list": VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: FavoritesState.albums-multi-select;
|
|
on-surface: true;
|
|
tooltip: @tr("Select multiple albums");
|
|
clicked => { FavoritesActions.albums-toggle-multi-select(); }
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "artists"
|
|
&& FavoritesState.artists-visible.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
tooltip: @tr("Open a random artist");
|
|
clicked => { FavoritesActions.artists-shuffle(); }
|
|
}
|
|
}
|
|
}
|
|
// Playlists — play a random playlist.
|
|
if FavoritesState.active-tab == "playlists"
|
|
&& FavoritesState.playlists-visible.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
tooltip: @tr("Play a random playlist");
|
|
clicked => { FavoritesActions.playlists-random(); }
|
|
}
|
|
}
|
|
}
|
|
// Labels — open a random label's landing.
|
|
if FavoritesState.active-tab == "labels"
|
|
&& FavoritesState.labels-visible.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
on-surface: true;
|
|
tooltip: @tr("Open a random label");
|
|
clicked => { FavoritesActions.labels-random(); }
|
|
}
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
}
|
|
|
|
Rectangle { height: 12px; }
|
|
|
|
// Row 2 — per-view action buttons (left), toolbar (right).
|
|
// vertical-stretch: 0 is load-bearing: without it, when this row's
|
|
// sized children are absent (tabs whose toolbar is conditional) its
|
|
// auto stretch becomes 1 and it splits the leftover height with the
|
|
// scrolling content (which, for the Flickable-based tabs, has a small
|
|
// preferred height) — inflating the sub-header. Tracks/Albums looked
|
|
// fine only because their content fills, leaving no slack to grab.
|
|
HorizontalLayout {
|
|
vertical-stretch: 0;
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
spacing: 8px;
|
|
|
|
// --- Left: the section tab menu, sitting under the title ---
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTabMenu { }
|
|
}
|
|
|
|
Rectangle { horizontal-stretch: 1; }
|
|
|
|
// --- Right: per-view toolbar ---
|
|
if FavoritesState.active-tab == "tracks"
|
|
&& FavoritesState.tracks.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search");
|
|
sm: true;
|
|
query <=> FavoritesState.tracks-search;
|
|
edited(q) => {
|
|
FavoritesActions.search-tracks(q);
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavGenreButton { }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzSelect {
|
|
menu-width: 150px;
|
|
options: [
|
|
@tr("Group: Off"),
|
|
@tr("Group: Album"),
|
|
@tr("Group: Artist"),
|
|
@tr("Group: Name"),
|
|
];
|
|
sm: true;
|
|
current-index: FavoritesState.tracks-group-mode == "off" ? 0
|
|
: FavoritesState.tracks-group-mode == "album" ? 1
|
|
: FavoritesState.tracks-group-mode == "artist" ? 2
|
|
: 3;
|
|
selected(i) => {
|
|
FavoritesActions.tracks-set-group(
|
|
i == 0 ? "off"
|
|
: i == 1 ? "album"
|
|
: i == 2 ? "artist"
|
|
: "name");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "albums"
|
|
&& FavoritesState.albums.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search");
|
|
sm: true;
|
|
query <=> FavoritesState.albums-search;
|
|
edited(q) => {
|
|
FavoritesActions.albums-search(q);
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavGenreButton { }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzSelect {
|
|
menu-width: 150px;
|
|
options: [
|
|
@tr("Group: Off"),
|
|
@tr("Group: A-Z"),
|
|
@tr("Group: Artist"),
|
|
];
|
|
sm: true;
|
|
current-index: FavoritesState.albums-group-mode == "off" ? 0
|
|
: FavoritesState.albums-group-mode == "alpha" ? 1
|
|
: 2;
|
|
selected(i) => {
|
|
FavoritesActions.albums-set-group(
|
|
i == 0 ? "off" : i == 1 ? "alpha" : "artist");
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzSelect {
|
|
menu-width: 168px;
|
|
options: [
|
|
@tr("Default"),
|
|
@tr("Title A-Z"),
|
|
@tr("Title Z-A"),
|
|
@tr("Artist A-Z"),
|
|
];
|
|
sm: true;
|
|
current-index: FavoritesState.albums-sort-by == "default" ? 0
|
|
: FavoritesState.albums-sort-by == "title-asc" ? 1
|
|
: FavoritesState.albums-sort-by == "title-desc" ? 2
|
|
: 3;
|
|
selected(i) => {
|
|
FavoritesActions.albums-set-sort(
|
|
i == 0 ? "default"
|
|
: i == 1 ? "title-asc"
|
|
: i == 2 ? "title-desc"
|
|
: "artist-asc");
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ViewToggle {
|
|
view-mode: FavoritesState.albums-view-mode;
|
|
set-view(v) => {
|
|
FavoritesActions.albums-set-view(v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "labels"
|
|
&& FavoritesState.labels.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search");
|
|
sm: true;
|
|
query <=> FavoritesState.labels-search;
|
|
edited(q) => {
|
|
FavoritesActions.search-labels(q);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "playlists"
|
|
&& (FavoritesState.playlists-favorites.length > 0
|
|
|| FavoritesState.playlists-following.length > 0): HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search");
|
|
sm: true;
|
|
query <=> FavoritesState.playlists-search;
|
|
edited(q) => {
|
|
FavoritesActions.search-playlists(q);
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ViewToggle {
|
|
view-mode: FavoritesState.playlists-view-mode;
|
|
set-view(v) => {
|
|
FavoritesActions.playlists-set-view(v);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.active-tab == "artists"
|
|
&& FavoritesState.artists.length > 0: HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search");
|
|
sm: true;
|
|
query <=> FavoritesState.artists-search;
|
|
edited(q) => {
|
|
FavoritesActions.search-artists(q);
|
|
}
|
|
}
|
|
}
|
|
// Group (A-Z) — grid mode only (Tauri hides it in sidepanel).
|
|
// Sits between the search field and the view toggle.
|
|
if FavoritesState.artists-view-mode == "grid": VerticalLayout {
|
|
alignment: center;
|
|
QbzSelect {
|
|
menu-width: 150px;
|
|
options: [
|
|
@tr("Group: Off"),
|
|
@tr("Group: A-Z"),
|
|
];
|
|
sm: true;
|
|
current-index: FavoritesState.artists-group-enabled ? 1 : 0;
|
|
selected(i) => {
|
|
FavoritesActions.artists-set-group(i == 0 ? "off" : "alpha");
|
|
}
|
|
}
|
|
}
|
|
// Grid <-> sidepanel view toggle. active:false keeps the two
|
|
// icon states visually identical (homologated background).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ToggleButton {
|
|
icon: FavoritesState.artists-view-mode == "sidepanel"
|
|
? @image-url("../assets/icons/layout-grid.svg")
|
|
: @image-url("../assets/icons/list.svg");
|
|
active: false;
|
|
sm: true;
|
|
clicked => {
|
|
FavoritesActions.artists-set-view(
|
|
FavoritesState.artists-view-mode == "grid" ? "sidepanel" : "grid");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// --- Library "All" mixed feed toolbar (search + source switches +
|
|
// genre + sort). Mirrors the webplayer /user-library/all header. ---
|
|
if FavoritesState.active-tab == "all": HorizontalLayout {
|
|
spacing: 8px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ExpandableSearch {
|
|
placeholder: @tr("Search your library");
|
|
sm: true;
|
|
query <=> LibraryAllState.search;
|
|
edited(q) => {
|
|
all-search-debounce.restart();
|
|
}
|
|
}
|
|
// Derive once per typing pause, not per keystroke — with
|
|
// thousands of items each derive swaps the visible model.
|
|
all-search-debounce := Timer {
|
|
interval: 250ms;
|
|
triggered => { LibraryAllActions.search(LibraryAllState.search); }
|
|
}
|
|
}
|
|
// Source switches — Purchases / Favorites / Following. Tooltips
|
|
// spell out each cryptic icon (owner request).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/shopping-bag.svg");
|
|
active: LibraryAllState.show-purchases;
|
|
sm: true;
|
|
tooltip: @tr("Show purchases");
|
|
clicked => { LibraryAllActions.toggle-source("purchases"); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/heart.svg");
|
|
active: LibraryAllState.show-favorites;
|
|
sm: true;
|
|
tooltip: @tr("Show favorites");
|
|
clicked => { LibraryAllActions.toggle-source("favorites"); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/user-plus.svg");
|
|
active: LibraryAllState.show-following;
|
|
sm: true;
|
|
tooltip: @tr("Show followed artists & playlists");
|
|
clicked => { LibraryAllActions.toggle-source("following"); }
|
|
}
|
|
}
|
|
// Show local files + Plex (filter lives IN the view; the
|
|
// Settings row only picks the SCOPE: favorites vs whole
|
|
// local library).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/hard-drive.svg");
|
|
active: LibraryAllState.show-local;
|
|
sm: true;
|
|
tooltip: @tr("Show local files & Plex");
|
|
clicked => { LibraryAllActions.toggle-source("local"); }
|
|
}
|
|
}
|
|
// Filter by genre (shared popup, own "library-all" context).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavGenreButton { context: "library-all"; }
|
|
}
|
|
// Grid / list view toggle.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
ViewToggle {
|
|
view-mode: LibraryAllState.view-mode;
|
|
set-view(v) => { LibraryAllActions.set-view(v); }
|
|
}
|
|
}
|
|
// Sort — PlaylistView-style menu: pick a field, re-pick to
|
|
// flip direction (chevron shows asc/desc on the active row).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
all-sort-anchor := Rectangle {
|
|
property <string> sort-label:
|
|
LibraryAllState.sort-by == "title" ? @tr("Title")
|
|
: LibraryAllState.sort-by == "artist" ? @tr("Artist")
|
|
: @tr("Date added");
|
|
width: all-sort-row.preferred-width;
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
background: all-sort-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
all-sort-row := HorizontalLayout {
|
|
padding-left: 10px;
|
|
padding-right: 10px;
|
|
spacing: 6px;
|
|
Text {
|
|
text: @tr("Sort") + ": " + all-sort-anchor.sort-label;
|
|
color: Theme.text-secondary;
|
|
font-size: 12px;
|
|
vertical-alignment: center;
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: LibraryAllState.sort-asc
|
|
? @image-url("../assets/icons/chevron-up.svg")
|
|
: @image-url("../assets/icons/chevron-down.svg");
|
|
width: 12px;
|
|
height: 12px;
|
|
tint: Theme.text-secondary;
|
|
}
|
|
}
|
|
}
|
|
all-sort-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { all-sort-menu.show(); }
|
|
}
|
|
all-sort-menu := PopupWindow {
|
|
x: all-sort-anchor.width - 172px;
|
|
y: all-sort-anchor.height + 4px;
|
|
width: 172px;
|
|
height: 110px;
|
|
ContextMenu {
|
|
LibrarySortOption {
|
|
label: @tr("Date added");
|
|
active: LibraryAllState.sort-by == "date";
|
|
asc: LibraryAllState.sort-asc;
|
|
chosen => { LibraryAllActions.set-sort("date"); }
|
|
}
|
|
LibrarySortOption {
|
|
label: @tr("Title");
|
|
active: LibraryAllState.sort-by == "title";
|
|
asc: LibraryAllState.sort-asc;
|
|
chosen => { LibraryAllActions.set-sort("title"); }
|
|
}
|
|
LibrarySortOption {
|
|
label: @tr("Artist");
|
|
active: LibraryAllState.sort-by == "artist";
|
|
asc: LibraryAllState.sort-asc;
|
|
chosen => { LibraryAllActions.set-sort("artist"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { height: 14px; }
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// ===================== SCROLLING CONTENT =====================
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
clip: true;
|
|
|
|
// Loading (any tab).
|
|
if FavoritesState.loading: VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
}
|
|
|
|
// Load error + retry (any tab).
|
|
if !FavoritesState.loading && FavoritesState.load-error != "": VerticalLayout {
|
|
alignment: center;
|
|
spacing: 10px;
|
|
Text {
|
|
text: @tr("Couldn't load your Library.");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
horizontal-alignment: center;
|
|
}
|
|
Text {
|
|
text: FavoritesState.load-error;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
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: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
retry-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
FavoritesActions.retry-load();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Library "All" — the mixed feed grid (webplayer parity) ----
|
|
if FavoritesState.active-tab == "all": Rectangle {
|
|
vertical-stretch: 1;
|
|
clip: true;
|
|
// Loading.
|
|
if LibraryAllState.loading: VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
}
|
|
// Error + retry.
|
|
if !LibraryAllState.loading && LibraryAllState.load-error != "": VerticalLayout {
|
|
alignment: center;
|
|
spacing: 10px;
|
|
Text {
|
|
text: @tr("Couldn't load your Library.");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
horizontal-alignment: center;
|
|
}
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: all-retry-label.preferred-width + 28px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: all-retry-ta.has-hover ? Theme.surface-hover : Theme.surface-elevated;
|
|
border-width: 1px;
|
|
border-color: Theme.border-subtle;
|
|
all-retry-label := Text {
|
|
text: @tr("Retry");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.legal;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
all-retry-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => { LibraryAllActions.retry(); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Grid — WINDOWED flow (columns from width), one card-slot per
|
|
// item keyed off `kind`. Only slots near the viewport mount a
|
|
// real card; the rest are same-footprint placeholder tiles
|
|
// (ViewportWindow engine — the AlbumGrid class-kill pattern).
|
|
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
|
&& LibraryAllState.view-mode != "list": all-grid-flick := Flickable {
|
|
viewport-height: all-grid.height + 32px;
|
|
all-grid := Rectangle {
|
|
property <length> cw: 200px;
|
|
property <length> ch: 246px;
|
|
property <length> gap: 20px;
|
|
property <int> columns: Math.max(1,
|
|
Math.floor((self.width + self.gap) / (self.cw + self.gap)));
|
|
property <int> rows: Math.ceil(
|
|
LibraryAllState.items-visible.length / self.columns);
|
|
x: 32px;
|
|
y: 16px;
|
|
width: parent.width - 64px;
|
|
height: self.rows > 0 ? self.rows * (self.ch + self.gap) : 0px;
|
|
|
|
all-grid-win := ViewportWindow {
|
|
visible-top: -all-grid-flick.viewport-y;
|
|
visible-height: all-grid-flick.height;
|
|
content-offset: 16px;
|
|
row-stride: all-grid.ch + all-grid.gap;
|
|
columns: all-grid.columns;
|
|
window-changed(first, last) => {
|
|
LibraryAllActions.window-changed(first, last);
|
|
}
|
|
}
|
|
|
|
for item[i] in LibraryAllState.items-visible: card-slot := Rectangle {
|
|
x: Math.mod(i, all-grid.columns) * (all-grid.cw + all-grid.gap);
|
|
y: Math.floor(i / all-grid.columns) * (all-grid.ch + all-grid.gap);
|
|
width: all-grid.cw;
|
|
height: all-grid.ch;
|
|
property <bool> in-window: i >= all-grid-win.first-item
|
|
&& i <= all-grid-win.last-item;
|
|
// Placeholder tile while the real card is unmounted
|
|
// (fast scroll).
|
|
border-radius: 8px;
|
|
background: card-slot.in-window ? transparent : Theme.surface-elevated;
|
|
|
|
if card-slot.in-window && item.kind == "track": TrackCard {
|
|
show-source-badge: LibraryAllState.show-local;
|
|
track: {
|
|
id: item.id,
|
|
title: item.title,
|
|
artist: item.artist,
|
|
artist-id: item.artist-id,
|
|
album: item.album,
|
|
album-id: item.album-id,
|
|
artwork: item.image,
|
|
quality-tier: item.quality-tier,
|
|
quality-detail: item.quality-detail,
|
|
is-favorite: item.is-favorite,
|
|
source: item.source,
|
|
};
|
|
media-action(kind, id, action) => {
|
|
if (kind == "artist") {
|
|
FavoritesActions.open-artist(id);
|
|
} else if (kind == "album") {
|
|
FavoritesActions.open-album(id);
|
|
} else {
|
|
FavoritesActions.track-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
if card-slot.in-window && item.kind == "album": AlbumCard {
|
|
show-source-badge: LibraryAllState.show-local;
|
|
album: {
|
|
id: item.id,
|
|
title: item.title,
|
|
artist: item.artist,
|
|
artist-id: item.artist-id,
|
|
artwork: item.image,
|
|
quality-tier: item.quality-tier,
|
|
quality-label: item.quality-detail,
|
|
source: item.source,
|
|
is-favorite: item.is-favorite,
|
|
};
|
|
clicked(id) => { FavoritesActions.open-album(id); }
|
|
media-action(kind, id, action) => {
|
|
if (kind == "artist") {
|
|
FavoritesActions.open-artist(id);
|
|
} else {
|
|
FavoritesActions.album-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
if card-slot.in-window && item.kind == "artist": ArtistGridCard {
|
|
artist: {
|
|
id: item.id,
|
|
title: item.title,
|
|
artwork: item.image,
|
|
following: item.is-favorite,
|
|
};
|
|
clicked(id) => { FavoritesActions.open-artist(id); }
|
|
media-action(kind, id, action) => {
|
|
if (action == "follow") {
|
|
FavoritesActions.artist-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
if card-slot.in-window && item.kind == "playlist": PlaylistCard {
|
|
playlist: {
|
|
id: item.id,
|
|
title: item.title,
|
|
subtitle: item.subtitle,
|
|
cover-count: 1,
|
|
url1: item.image-url,
|
|
cover1: item.image,
|
|
is-owned: item.playlist-owned,
|
|
is-following: item.playlist-following,
|
|
is-copied: item.playlist-copied,
|
|
};
|
|
body-opens: true;
|
|
media-action(kind, id, action) => {
|
|
if (action == "open") {
|
|
FavoritesActions.open-playlist(id);
|
|
} else {
|
|
FavoritesActions.playlist-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
if card-slot.in-window && item.kind == "label": LabelCard {
|
|
label: {
|
|
id: item.id,
|
|
name: item.title,
|
|
image: item.image,
|
|
image-url: item.image-url,
|
|
};
|
|
clicked(id, name) => { FavoritesActions.open-label(id, name); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// List view — WINDOWED compact mixed rows (cover + title +
|
|
// subtitle). Only rows near the viewport mount their content
|
|
// (cells + context menu); off-band rows are same-footprint
|
|
// placeholder tiles (ViewportWindow engine, AlbumGrid pattern).
|
|
if !LibraryAllState.loading && LibraryAllState.load-error == ""
|
|
&& LibraryAllState.view-mode == "list": all-list-flick := Flickable {
|
|
viewport-height: all-list.preferred-height + 32px;
|
|
all-list-win := ViewportWindow {
|
|
visible-top: -all-list-flick.viewport-y;
|
|
visible-height: all-list-flick.height;
|
|
content-offset: 16px;
|
|
row-stride: 58px; // 56px row + 2px spacing
|
|
columns: 1;
|
|
window-changed(first, last) => {
|
|
LibraryAllActions.window-changed(first, last);
|
|
}
|
|
}
|
|
all-list := VerticalLayout {
|
|
x: 32px;
|
|
y: 16px;
|
|
width: parent.width - 64px;
|
|
spacing: 2px;
|
|
for item[i] in LibraryAllState.items-visible: row-rect := Rectangle {
|
|
height: 56px;
|
|
property <bool> in-window: i >= all-list-win.first-item
|
|
&& i <= all-list-win.last-item;
|
|
border-radius: 6px;
|
|
// Placeholder tile while the row content is
|
|
// unmounted (fast scroll).
|
|
background: row-rect.in-window ? transparent : Theme.surface-elevated;
|
|
// Context-menu opener position (row-local px). Defaults
|
|
// by the ⋯ button; a right-click anywhere on the row
|
|
// overrides it to the cursor (window pointer minus the
|
|
// row's absolute position). Every cell TouchArea repeats
|
|
// the right-click handler so the whole row is sensitive.
|
|
property <length> menu-x: row-rect.width - 206px;
|
|
property <length> menu-y: 48px;
|
|
if row-rect.in-window: Rectangle {
|
|
border-radius: 6px;
|
|
background: row-ta.has-hover || menu-btn-ta.has-hover
|
|
? Theme.surface-hover : transparent;
|
|
// Row body — click plays/opens by kind; right-click
|
|
// opens the menu at the cursor. Behind the cells.
|
|
row-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
if (item.kind == "track") {
|
|
FavoritesActions.track-action(item.id, "play");
|
|
} else if (item.kind == "album") {
|
|
FavoritesActions.open-album(item.id);
|
|
} else if (item.kind == "artist") {
|
|
FavoritesActions.open-artist(item.id);
|
|
} else if (item.kind == "playlist") {
|
|
FavoritesActions.open-playlist(item.id);
|
|
} else if (item.kind == "label") {
|
|
FavoritesActions.open-label(item.id, item.title);
|
|
}
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down
|
|
&& e.button == PointerEventButton.right) {
|
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
}
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 12px;
|
|
// Col — artwork (round for artists) + hover play.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 44px;
|
|
height: 44px;
|
|
border-radius: item.kind == "artist" ? 22px : 6px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: item.image;
|
|
image-fit: item.kind == "label" ? contain : cover;
|
|
}
|
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": Rectangle {
|
|
width: 100%;
|
|
height: 100%;
|
|
background: #000000a6;
|
|
opacity: play-ta.has-hover ? 1.0 : 0.0;
|
|
animate opacity { duration: 150ms; }
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/play-fill.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: #ffffff;
|
|
}
|
|
play-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
|
else { FavoritesActions.playlist-action(item.id, "play"); }
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Col — title + subtitle (artist link for track/album).
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
spacing: 2px;
|
|
title-ta := TouchArea {
|
|
height: 18px;
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
|
else if (item.kind == "album") { FavoritesActions.open-album(item.id); }
|
|
else if (item.kind == "artist") { FavoritesActions.open-artist(item.id); }
|
|
else if (item.kind == "playlist") { FavoritesActions.open-playlist(item.id); }
|
|
else { FavoritesActions.open-label(item.id, item.title); }
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
Text {
|
|
width: 100%;
|
|
text: item.title;
|
|
color: title-ta.has-hover ? Theme.accent : Theme.text-primary;
|
|
font-size: 14px;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
if item.subtitle != "": sub-ta := TouchArea {
|
|
height: 16px;
|
|
mouse-cursor: (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) ? pointer : default;
|
|
clicked => {
|
|
if (item.artist-id != "" && (item.kind == "track" || item.kind == "album")) {
|
|
FavoritesActions.open-artist(item.artist-id);
|
|
}
|
|
}
|
|
pointer-event(e) => {
|
|
if (e.kind == PointerEventKind.down && e.button == PointerEventButton.right) {
|
|
row-rect.menu-x = ShellState.pointer-x - row-rect.absolute-position.x;
|
|
row-rect.menu-y = ShellState.pointer-y - row-rect.absolute-position.y;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
Text {
|
|
width: 100%;
|
|
text: item.subtitle;
|
|
color: (item.artist-id != "" && (item.kind == "track" || item.kind == "album") && sub-ta.has-hover)
|
|
? Theme.accent : Theme.text-muted;
|
|
font-size: 12px;
|
|
overflow: elide;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
// Col — type (icon + label).
|
|
Rectangle {
|
|
width: 116px;
|
|
HorizontalLayout {
|
|
spacing: 6px;
|
|
alignment: start;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: item.kind == "track" ? @image-url("../assets/icons/music.svg")
|
|
: item.kind == "playlist" ? @image-url("../assets/icons/list-music.svg")
|
|
: item.kind == "artist" ? @image-url("../assets/icons/user.svg")
|
|
: item.kind == "label" ? @image-url("../assets/icons/disc-3.svg")
|
|
: @image-url("../assets/icons/disc.svg");
|
|
width: 13px;
|
|
height: 13px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Text {
|
|
text: item.kind == "track" ? @tr("Track")
|
|
: item.kind == "album" ? @tr("Album")
|
|
: item.kind == "artist" ? @tr("Artist")
|
|
: item.kind == "playlist" ? @tr("Playlist")
|
|
: @tr("Label");
|
|
color: Theme.text-muted;
|
|
font-size: 10px;
|
|
font-weight: Typography.semibold;
|
|
letter-spacing: 1.2px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
}
|
|
// Col — source glyph. Only when local/Plex can
|
|
// appear (show-local on); otherwise the whole feed
|
|
// is Qobuz, so the column is hidden rather than
|
|
// painting the Qobuz mark on every row.
|
|
if LibraryAllState.show-local: Rectangle {
|
|
width: 44px;
|
|
SourceGlyph {
|
|
kind: item.source;
|
|
x: 0;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
}
|
|
// Col — quality (albums + tracks; the row badge).
|
|
Rectangle {
|
|
width: 150px;
|
|
if (item.kind == "album" || item.kind == "track") && item.quality-tier != "": QualityBadgeFull {
|
|
tier: item.quality-tier;
|
|
detail: item.quality-detail;
|
|
x: 0;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
}
|
|
}
|
|
// Col — favorite (heart) / follow (person) indicator.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: item.kind == "artist"
|
|
? @image-url("../assets/icons/user-plus.svg")
|
|
: (item.is-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg"));
|
|
width: 18px;
|
|
height: 18px;
|
|
tint: Theme.accent;
|
|
}
|
|
}
|
|
// Col — context-menu button.
|
|
VerticalLayout {
|
|
alignment: center;
|
|
menu-btn := Rectangle {
|
|
width: 30px;
|
|
height: 30px;
|
|
border-radius: 6px;
|
|
background: menu-btn-ta.has-hover ? Theme.surface-elevated : transparent;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/ellipsis.svg");
|
|
width: 16px;
|
|
height: 16px;
|
|
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
|
|
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
|
|
tint: Theme.text-secondary;
|
|
}
|
|
menu-btn-ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
// The ⋯ opener: anchor the menu by
|
|
// the button (right-click uses the
|
|
// cursor instead).
|
|
row-rect.menu-x = row-rect.width - 206px;
|
|
row-rect.menu-y = 48px;
|
|
row-menu.show();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Per-kind context menu (⋯ button + right-click).
|
|
row-menu := PopupWindow {
|
|
x: row-rect.menu-x;
|
|
y: row-rect.menu-y;
|
|
width: 196px;
|
|
height: (item.kind == "track" ? 6
|
|
: item.kind == "album" ? 6
|
|
: item.kind == "playlist" ? 4
|
|
: 1) * 33px + 10px;
|
|
ContextMenu {
|
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/play.svg");
|
|
label: @tr("Play");
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play"); }
|
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play"); }
|
|
else { FavoritesActions.playlist-action(item.id, "play"); }
|
|
}
|
|
}
|
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-start.svg");
|
|
label: @tr("Play next");
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "play-next"); }
|
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "play-next"); }
|
|
else { FavoritesActions.playlist-action(item.id, "play-next"); }
|
|
}
|
|
}
|
|
if item.kind == "track" || item.kind == "album" || item.kind == "playlist": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/list-end.svg");
|
|
label: @tr("Add to queue");
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "queue"); }
|
|
else if (item.kind == "album") { FavoritesActions.album-action(item.id, "queue"); }
|
|
else { FavoritesActions.playlist-action(item.id, "queue"); }
|
|
}
|
|
}
|
|
if item.kind == "album": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/library-big.svg");
|
|
label: @tr("Open album");
|
|
clicked => { FavoritesActions.open-album(item.id); }
|
|
}
|
|
if item.kind == "playlist": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/library-big.svg");
|
|
label: @tr("Open playlist");
|
|
clicked => { FavoritesActions.open-playlist(item.id); }
|
|
}
|
|
if (item.kind == "track" || item.kind == "album") && item.artist-id != "": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/user.svg");
|
|
label: @tr("Go to artist");
|
|
clicked => { FavoritesActions.open-artist(item.artist-id); }
|
|
}
|
|
if item.kind == "track" && item.album-id != "": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/disc.svg");
|
|
label: @tr("Go to album");
|
|
clicked => { FavoritesActions.open-album(item.album-id); }
|
|
}
|
|
if item.kind == "artist": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/user.svg");
|
|
label: @tr("Go to artist");
|
|
clicked => { FavoritesActions.open-artist(item.id); }
|
|
}
|
|
if item.kind == "label": ContextMenuItem {
|
|
icon: @image-url("../assets/icons/disc-3.svg");
|
|
label: @tr("Go to label");
|
|
clicked => { FavoritesActions.open-label(item.id, item.title); }
|
|
}
|
|
if item.kind == "track" || item.kind == "album": ContextMenuItem {
|
|
icon: item.is-favorite
|
|
? @image-url("../assets/icons/heart-filled.svg")
|
|
: @image-url("../assets/icons/heart.svg");
|
|
label: item.is-favorite ? @tr("Remove from Library") : @tr("Add to Library");
|
|
clicked => {
|
|
if (item.kind == "track") { FavoritesActions.track-action(item.id, "favorite"); }
|
|
else { FavoritesActions.album-action(item.id, "favorite"); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Tracks tab — virtualized self-scrolling list --------------
|
|
if !FavoritesState.loading && FavoritesState.load-error == ""
|
|
&& FavoritesState.active-tab == "tracks": Rectangle {
|
|
vertical-stretch: 1;
|
|
VerticalLayout {
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 16px;
|
|
spacing: 0px;
|
|
|
|
// Bulk action bar — visible in multi-select edit mode.
|
|
if FavoritesState.tracks-multi-select: MultiSelectBar {
|
|
selected-count: FavoritesState.tracks-selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), icon: @image-url("../assets/icons/list-music.svg"), danger: false, needs-selection: true },
|
|
{ id: "add-to-mixtape", label: @tr("Add to Mixtape/Collection"), icon: @image-url("../assets/icons/cassette-tape.svg"), danger: false, needs-selection: true },
|
|
{ id: "make-offline", label: @tr("Make available offline"), icon: @image-url("../assets/icons/cloud-download.svg"), danger: false, needs-selection: true },
|
|
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
FavoritesActions.bulk-action(id);
|
|
}
|
|
}
|
|
if FavoritesState.tracks-multi-select: Rectangle { height: 8px; }
|
|
|
|
if FavoritesState.tracks.length == 0: Text {
|
|
text: @tr("No tracks in your Library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.tracks.length > 0
|
|
&& FavoritesState.tracks-visible.length == 0: Text {
|
|
text: @tr("No tracks match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
|
|
track-list := ListView {
|
|
vertical-stretch: 1;
|
|
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 == "fav:tracks" && 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); }
|
|
for track[i] in FavoritesState.tracks-visible: VerticalLayout {
|
|
spacing: 0px;
|
|
// Group header rows when grouping by album / artist.
|
|
if FavoritesState.tracks-group-mode == "album" && track.album != ""
|
|
&& (i == 0 || FavoritesState.tracks-visible[i - 1].album != track.album): Rectangle {
|
|
height: 32px;
|
|
Text {
|
|
x: 2px;
|
|
width: parent.width - 4px;
|
|
height: 100%;
|
|
text: track.album;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
if FavoritesState.tracks-group-mode == "artist" && track.artist != ""
|
|
&& (i == 0 || FavoritesState.tracks-visible[i - 1].artist != track.artist): Rectangle {
|
|
height: 32px;
|
|
Text {
|
|
x: 2px;
|
|
width: parent.width - 4px;
|
|
height: 100%;
|
|
text: track.artist;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
vertical-alignment: center;
|
|
overflow: elide;
|
|
}
|
|
}
|
|
TrackRow {
|
|
track: track;
|
|
index: i;
|
|
show-artwork: true;
|
|
show-album: FavoritesState.tracks-group-mode != "album";
|
|
show-favorite: true;
|
|
show-download: true;
|
|
multi-select-mode: FavoritesState.tracks-multi-select;
|
|
media-action(kind, id, action) => {
|
|
if (kind == "album" && action == "open") {
|
|
FavoritesActions.open-album(id);
|
|
} else if (kind == "artist" && action == "open") {
|
|
FavoritesActions.open-artist(id);
|
|
} else if (action == "favorite") {
|
|
FavoritesActions.unfavorite-track(id);
|
|
} else {
|
|
FavoritesActions.track-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ListScrollbar {
|
|
x: parent.width - self.width - 4px;
|
|
width: 14px;
|
|
height: 100%;
|
|
viewport-height: track-list.viewport-height;
|
|
visible-height: track-list.visible-height;
|
|
viewport-y <=> track-list.viewport-y;
|
|
}
|
|
// A-Z jump strip (name grouping) — accurate (uniform rows).
|
|
if FavoritesState.tracks-group-mode == "name"
|
|
&& FavoritesState.tracks-alpha.length > 0: AlphaStrip {
|
|
x: parent.width - self.width - 20px;
|
|
height: 100%;
|
|
jumps: FavoritesState.tracks-alpha;
|
|
jump(ord, idx) => {
|
|
track-list.viewport-y = -Math.min(
|
|
idx * 50px / 1px,
|
|
Math.max(0, (track-list.viewport-height - track-list.visible-height) / 1px)) * 1px;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Albums / Artists / Playlists / Labels — scrolling ---------
|
|
if !FavoritesState.loading && FavoritesState.load-error == ""
|
|
&& FavoritesState.active-tab != "tracks"
|
|
&& FavoritesState.active-tab != "all"
|
|
&& !(FavoritesState.active-tab == "artists"
|
|
&& FavoritesState.artists-view-mode == "sidepanel"): Rectangle {
|
|
other-flick := Flickable {
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: other-page.preferred-height;
|
|
// Scroll-position restore on back/forward. One Flickable serves the
|
|
// albums/artists/playlists/labels tabs, so match the active tab's
|
|
// scope dynamically (see nav::scope_for → "fav:<tab>").
|
|
property <string> sr-armed: NavState.restore-scope;
|
|
function sr-restore() {
|
|
if (NavState.restore-scope == "fav:" + FavoritesState.active-tab
|
|
&& 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); }
|
|
|
|
other-page := VerticalLayout {
|
|
width: parent.width;
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 16px;
|
|
padding-bottom: 100px;
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// --- Albums tab ---
|
|
if FavoritesState.active-tab == "albums": VerticalLayout {
|
|
spacing: 0px;
|
|
// Bulk action bar — visible in multi-select edit
|
|
// mode (list view only).
|
|
if FavoritesState.albums-multi-select
|
|
&& FavoritesState.albums-view-mode == "list": MultiSelectBar {
|
|
selected-count: FavoritesState.albums-selected-count;
|
|
actions: [
|
|
{ id: "select-all", label: @tr("Select all"), icon: @image-url("../assets/icons/square-check-big.svg"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), icon: @image-url("../assets/icons/list-start.svg"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), icon: @image-url("../assets/icons/list-end.svg"), danger: false, needs-selection: true },
|
|
{ id: "remove-selected", label: @tr("Remove from Library"), icon: @image-url("../assets/icons/heart-crack.svg"), danger: true, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), icon: @image-url("../assets/icons/x.svg"), danger: false, needs-selection: true },
|
|
];
|
|
action(id) => {
|
|
FavoritesActions.albums-bulk-action(id);
|
|
}
|
|
}
|
|
if FavoritesState.albums-multi-select
|
|
&& FavoritesState.albums-view-mode == "list": Rectangle { height: 8px; }
|
|
if FavoritesState.albums.length == 0: Text {
|
|
text: @tr("No albums in your Library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.albums.length > 0
|
|
&& FavoritesState.albums-shown == 0: Text {
|
|
text: @tr("No albums match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
AlbumCollectionView {
|
|
albums: FavoritesState.albums-visible;
|
|
grouped: FavoritesState.albums-grouped;
|
|
view-mode: FavoritesState.albums-view-mode;
|
|
is-grouped: FavoritesState.albums-group-mode != "off";
|
|
show-source: false;
|
|
select-mode: FavoritesState.albums-multi-select;
|
|
// Viewport windowing (Phase 1): only cards near the
|
|
// visible band mount + get artwork dispatched. The
|
|
// offset is the page's padding-top (16px); the grid's
|
|
// one-viewport margin absorbs the small extras
|
|
// (empty-state texts) above the grid.
|
|
windowed: true;
|
|
visible-top: -other-flick.viewport-y;
|
|
visible-height: other-flick.height;
|
|
content-offset: 16px;
|
|
window-changed(first, last) => {
|
|
FavoritesActions.albums-window-changed(first, last);
|
|
}
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
if (action == "favorite") {
|
|
FavoritesActions.unfavorite-album(id);
|
|
} else {
|
|
FavoritesActions.album-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Artists tab ---
|
|
if FavoritesState.active-tab == "artists": VerticalLayout {
|
|
spacing: 0px;
|
|
if FavoritesState.artists.length == 0: Text {
|
|
text: @tr("No artists in your Library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.artists.length > 0
|
|
&& FavoritesState.artists-shown == 0: Text {
|
|
text: @tr("No artists match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
// Flat grid (grouping off).
|
|
if !FavoritesState.artists-group-enabled: artists-grid := Rectangle {
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (root.artist-card-w + root.gap)));
|
|
property <int> rows: Math.ceil(FavoritesState.artists-visible.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.artist-card-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
for artist[i] in FavoritesState.artists-visible: ArtistGridCard {
|
|
x: Math.mod(i, artists-grid.columns) * (root.artist-card-w + root.gap);
|
|
y: Math.floor(i / artists-grid.columns) * (root.artist-card-h + root.gap);
|
|
artist: {
|
|
id: artist.id,
|
|
title: artist.name,
|
|
artwork-url: artist.image-url,
|
|
artwork: artist.image,
|
|
following: true,
|
|
is-pinned: artist.is-pinned,
|
|
};
|
|
follow-mode: "none";
|
|
clicked(id) => { root.open-artist(id); }
|
|
}
|
|
}
|
|
// Grouped (A-Z) — sections with letter headers.
|
|
if FavoritesState.artists-group-enabled: VerticalLayout {
|
|
spacing: 10px;
|
|
for section[s] in FavoritesState.artists-grouped: VerticalLayout {
|
|
spacing: 6px;
|
|
Text {
|
|
text: section.title;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
section-grid := Rectangle {
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (root.artist-card-w + root.gap)));
|
|
property <int> rows: Math.ceil(section.artists.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.artist-card-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
for artist[i] in section.artists: ArtistGridCard {
|
|
x: Math.mod(i, section-grid.columns) * (root.artist-card-w + root.gap);
|
|
y: Math.floor(i / section-grid.columns) * (root.artist-card-h + root.gap);
|
|
artist: {
|
|
id: artist.id,
|
|
title: artist.name,
|
|
artwork-url: artist.image-url,
|
|
artwork: artist.image,
|
|
following: true,
|
|
is-pinned: artist.is-pinned,
|
|
};
|
|
follow-mode: "none";
|
|
clicked(id) => { root.open-artist(id); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Playlists tab (two sub-tabs: Library / Following) ---
|
|
if FavoritesState.active-tab == "playlists": VerticalLayout {
|
|
spacing: 0px;
|
|
// Sub-tab selector (underline tabs, no pills — ADR-008).
|
|
HorizontalLayout {
|
|
spacing: 24px;
|
|
alignment: start;
|
|
PlaylistSubTab {
|
|
label: @tr("Library");
|
|
tab-id: "favorites";
|
|
}
|
|
PlaylistSubTab {
|
|
label: @tr("Following");
|
|
tab-id: "following";
|
|
count: FavoritesState.playlists-following-count;
|
|
}
|
|
}
|
|
Rectangle { height: 16px; }
|
|
if FavoritesState.playlists-visible.length == 0
|
|
&& FavoritesState.playlists-search != "": Text {
|
|
text: @tr("No playlists match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.playlists-visible.length == 0
|
|
&& FavoritesState.playlists-search == ""
|
|
&& FavoritesState.playlists-sub-tab == "following": Text {
|
|
text: @tr("No followed playlists yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.playlists-visible.length == 0
|
|
&& FavoritesState.playlists-search == ""
|
|
&& FavoritesState.playlists-sub-tab != "following": Text {
|
|
text: @tr("No playlists in your Library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.playlists-view-mode != "list": playlists-grid := Rectangle {
|
|
property <length> pl-w: 200px;
|
|
property <length> pl-h: 246px;
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (self.pl-w + root.gap)));
|
|
property <int> rows: Math.ceil(FavoritesState.playlists-visible.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
for pl[i] in FavoritesState.playlists-visible: PlaylistCard {
|
|
x: Math.mod(i, playlists-grid.columns) * (playlists-grid.pl-w + root.gap);
|
|
y: Math.floor(i / playlists-grid.columns) * (playlists-grid.pl-h + root.gap);
|
|
playlist: pl;
|
|
body-opens: true;
|
|
media-action(k, id, a) => {
|
|
if (a == "open") {
|
|
FavoritesActions.open-playlist(id);
|
|
} else {
|
|
FavoritesActions.playlist-action(id, a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.playlists-view-mode == "list": VerticalLayout {
|
|
spacing: 2px;
|
|
for pl[i] in FavoritesState.playlists-visible: PlaylistListRow {
|
|
playlist: pl;
|
|
index: i;
|
|
media-action(k, id, a) => {
|
|
if (a == "open") {
|
|
FavoritesActions.open-playlist(id);
|
|
} else {
|
|
FavoritesActions.playlist-action(id, a);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Labels tab ---
|
|
if FavoritesState.active-tab == "labels": labels-grid := Rectangle {
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (root.artist-card-w + root.gap)));
|
|
property <int> rows: Math.ceil(FavoritesState.labels-visible.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.artist-card-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
if FavoritesState.labels.length == 0: Text {
|
|
text: @tr("No labels in your Library yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
if FavoritesState.labels.length > 0
|
|
&& FavoritesState.labels-visible.length == 0: Text {
|
|
text: @tr("No labels match your search.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for lbl[i] in FavoritesState.labels-visible: LabelCard {
|
|
x: Math.mod(i, labels-grid.columns) * (root.artist-card-w + root.gap);
|
|
y: Math.floor(i / labels-grid.columns) * (root.artist-card-h + root.gap);
|
|
label: lbl;
|
|
clicked(id, name) => { FavoritesActions.open-label(id, name); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
ListScrollbar {
|
|
x: parent.width - self.width - 4px;
|
|
width: 14px;
|
|
height: 100%;
|
|
viewport-height: other-flick.viewport-height;
|
|
visible-height: other-flick.height;
|
|
viewport-y <=> other-flick.viewport-y;
|
|
}
|
|
// A-Z jump strip (albums alpha grouping) — proportional jump.
|
|
if FavoritesState.active-tab == "albums"
|
|
&& FavoritesState.albums-group-mode == "alpha"
|
|
&& FavoritesState.albums-alpha.length > 0: AlphaStrip {
|
|
x: parent.width - self.width - 20px;
|
|
height: 100%;
|
|
jumps: FavoritesState.albums-alpha;
|
|
jump(ord, idx) => {
|
|
other-flick.viewport-y = -(ord / Math.max(1, FavoritesState.albums-alpha.length - 1))
|
|
* Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px;
|
|
}
|
|
}
|
|
// A-Z jump strip (artists grouping) — proportional jump.
|
|
if FavoritesState.active-tab == "artists"
|
|
&& FavoritesState.artists-group-enabled
|
|
&& FavoritesState.artists-alpha.length > 0: AlphaStrip {
|
|
x: parent.width - self.width - 20px;
|
|
height: 100%;
|
|
jumps: FavoritesState.artists-alpha;
|
|
jump(ord, idx) => {
|
|
other-flick.viewport-y = -(ord / Math.max(1, FavoritesState.artists-alpha.length - 1))
|
|
* Math.max(0, (other-flick.viewport-height - other-flick.height) / 1px) * 1px;
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Artists SIDEPANEL — two-column, each column self-scrolling ---
|
|
if !FavoritesState.loading && FavoritesState.load-error == ""
|
|
&& FavoritesState.active-tab == "artists"
|
|
&& FavoritesState.artists-view-mode == "sidepanel": HorizontalLayout {
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 16px;
|
|
spacing: 0px;
|
|
|
|
// LEFT: the favorites artist list, A-Z grouped, with a
|
|
// proportional alpha jump strip (like the album grouping).
|
|
Rectangle {
|
|
width: 300px;
|
|
clip: true;
|
|
left-flick := Flickable {
|
|
viewport-width: self.width;
|
|
viewport-height: left-col.preferred-height;
|
|
left-col := VerticalLayout {
|
|
width: parent.width;
|
|
padding-top: 4px;
|
|
padding-bottom: 40px;
|
|
padding-right: 22px;
|
|
spacing: 4px;
|
|
alignment: start;
|
|
for section[s] in FavoritesState.artists-grouped: VerticalLayout {
|
|
spacing: 2px;
|
|
Text {
|
|
text: section.title;
|
|
color: Theme.text-secondary;
|
|
font-size: Typography.legal;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
for artist[i] in section.artists: SidepanelArtistRow {
|
|
item: artist;
|
|
selected: FavoritesState.selected-artist-id == artist.id;
|
|
clicked(id, name) => {
|
|
FavoritesActions.select-artist(id, name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if FavoritesState.artists-alpha.length > 0: AlphaStrip {
|
|
x: parent.width - self.width - 2px;
|
|
height: 100%;
|
|
jumps: FavoritesState.artists-alpha;
|
|
jump(ord, idx) => {
|
|
left-flick.viewport-y = -(ord / Math.max(1, FavoritesState.artists-alpha.length - 1))
|
|
* Math.max(0, (left-flick.viewport-height - left-flick.height) / 1px) * 1px;
|
|
}
|
|
}
|
|
}
|
|
Rectangle {
|
|
width: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
|
|
// RIGHT: the selected artist's albums (5-state machine).
|
|
Rectangle {
|
|
horizontal-stretch: 1;
|
|
clip: true;
|
|
|
|
if FavoritesState.selected-artist-id == "": VerticalLayout {
|
|
alignment: center;
|
|
spacing: 10px;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/user.svg");
|
|
width: 44px;
|
|
height: 44px;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
Text {
|
|
text: @tr("Select an artist to see their albums.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
if FavoritesState.selected-artist-id != ""
|
|
&& FavoritesState.selected-albums-loading: VerticalLayout {
|
|
alignment: center;
|
|
HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 32px; }
|
|
}
|
|
}
|
|
if FavoritesState.selected-artist-id != ""
|
|
&& !FavoritesState.selected-albums-loading
|
|
&& FavoritesState.selected-albums-error != "": VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Couldn't load albums.");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
if FavoritesState.selected-artist-id != ""
|
|
&& !FavoritesState.selected-albums-loading
|
|
&& FavoritesState.selected-albums-error == ""
|
|
&& FavoritesState.selected-albums-total == 0: VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("No albums found.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
horizontal-alignment: center;
|
|
}
|
|
}
|
|
if FavoritesState.selected-artist-id != ""
|
|
&& !FavoritesState.selected-albums-loading
|
|
&& FavoritesState.selected-albums-error == ""
|
|
&& FavoritesState.selected-albums-total > 0: Flickable {
|
|
width: 100%;
|
|
height: 100%;
|
|
viewport-width: self.width;
|
|
viewport-height: sections-col.preferred-height;
|
|
sections-col := VerticalLayout {
|
|
width: parent.width;
|
|
padding-left: 20px;
|
|
padding-right: 12px;
|
|
padding-bottom: 100px;
|
|
spacing: 18px;
|
|
alignment: start;
|
|
for section[s] in FavoritesState.selected-artist-sections: VerticalLayout {
|
|
spacing: 8px;
|
|
Text {
|
|
text: section.title;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.semibold;
|
|
}
|
|
AlbumCollectionView {
|
|
albums: section.albums;
|
|
is-grouped: false;
|
|
view-mode: "grid";
|
|
show-source: false;
|
|
open-album(id) => {
|
|
root.open-album(id);
|
|
}
|
|
open-artist(id) => {
|
|
root.open-artist(id);
|
|
}
|
|
media-action(kind, id, action) => {
|
|
FavoritesActions.album-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
// Footer — jump to the full artist page (compilations
|
|
// and other-artist content live there).
|
|
Rectangle {
|
|
height: footer-txt.preferred-height + 10px;
|
|
footer-txt := Text {
|
|
text: @tr("Go to artist page");
|
|
color: Theme.accent;
|
|
font-size: Typography.body;
|
|
}
|
|
TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.open-artist(FavoritesState.selected-artist-id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Genre filter overlay — the shared popup, favorites context. Anchored
|
|
// under the toolbar's genre button (below the fixed chrome).
|
|
if GenreFilterState.open: Rectangle {
|
|
width: 100%;
|
|
height: 100%;
|
|
TouchArea {
|
|
clicked => {
|
|
GenreFilterState.open = false;
|
|
}
|
|
}
|
|
}
|
|
if GenreFilterState.open: GenreFilterPopup {
|
|
x: root.width - self.width - 32px;
|
|
y: 104px;
|
|
}
|
|
}
|