Files
qbz/crates/qbz-ui/ui/favorites/FavoritesView.slint
T
vicrodh a9dff18d25 fix(ui): make CircleAction buttons visible on light themes
CircleAction defaults to hardcoded light-on-dark colors, so every instance
sitting on the normal page background (not over artwork) vanished on light
themes. Set on-surface: true on those clusters — Popular Tracks (artist +
label), the PlaylistView hero row, LocalAlbumView, AwardView,
PurchaseDetailView, MixtapeDetailView, FavoritesView, LocalLibraryView — so
they use theme-appropriate colors. Buttons over artwork/dark headers keep the
light-on-dark defaults.

cc
2026-07-13 13:11:12 -06:00

1547 lines
69 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,
AlbumCardItem,
TrackItem,
FavoriteArtistItem,
FavoriteLabelItem,
SearchPlaylistItem,
DragState,
GenreFilterState,
GenreFilterActions,
AlphaJump,
NavState,
} from "../state.slint";
import { AlbumCollectionView } from "../discover/AlbumCollectionView.slint";
import { PlaylistCard } from "../discover/PlaylistCard.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 { ContextMenu } from "../primitives/ContextMenu.slint";
import { ContextMenuItem } from "../primitives/ContextMenuItem.slint";
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.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: "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); }
}
// 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);
}
}
}
// Compact list row for the Favorites Playlists list view (Slint-better;
// Tauri ships grid only). Thumb + name + owner·tracks + play + more-menu.
component PlaylistListRow inherits Rectangle {
in property <SearchPlaylistItem> playlist;
in property <int> index;
callback media-action(string, string, string);
property <bool> row-hover: body-ta.has-hover || play-ta.has-hover || more-ta.has-hover;
height: 60px;
border-radius: 8px;
background: root.row-hover
? #ffffff14
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
// Full-row click target (behind the action buttons) -> open the playlist.
body-ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "open");
}
}
HorizontalLayout {
padding-left: 10px;
padding-right: 10px;
spacing: 12px;
VerticalLayout {
alignment: center;
Rectangle {
width: 44px;
height: 44px;
border-radius: 6px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
image-fit: cover;
source: root.playlist.cover1;
}
if root.playlist.cover-count == 0: QbzIcon {
source: @image-url("../assets/icons/list-music.svg");
width: 20px;
height: 20px;
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.playlist.title;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
}
Text {
text: root.playlist.subtitle;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
}
}
VerticalLayout {
alignment: center;
play-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
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: root.row-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
VerticalLayout {
alignment: center;
more-ta := TouchArea {
width: 30px;
height: 30px;
mouse-cursor: pointer;
clicked => {
row-menu.show();
}
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;
}
row-menu := PopupWindow {
x: -166px;
y: 32px;
width: 196px;
height: 150px;
ContextMenu {
ContextMenuItem {
icon: @image-url("../assets/icons/play.svg");
label: @tr("Play");
clicked => {
root.media-action("playlist", root.playlist.id, "play");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-plus.svg");
label: @tr("Play next");
clicked => {
root.media-action("playlist", root.playlist.id, "play-next");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/list-end.svg");
label: @tr("Add to queue");
clicked => {
root.media-action("playlist", root.playlist.id, "queue");
}
}
ContextMenuItem {
icon: @image-url("../assets/icons/heart.svg");
label: @tr("Add to Library");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
}
}
}
}
}
// One label tile in the Favorites > Labels grid — a circular cover (or
// an indigo->violet gradient + disc placeholder), name, and album count.
// Mirrors FavArtistCard; the gradient placeholder is the 1:1 match with
// Tauri's label card.
component FavLabelCard inherits Rectangle {
in property <FavoriteLabelItem> item;
callback clicked(string, string);
width: 160px;
height: 200px;
VerticalLayout {
spacing: 10px;
Rectangle {
width: 140px;
height: 140px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
border-radius: 70px;
background: root.item.image-url == ""
? @linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%)
: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: root.item.image;
image-fit: cover;
}
if root.item.image-url == "": QbzIcon {
source: @image-url("../assets/icons/disc-3.svg");
width: 44px;
height: 44px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: #ffffff;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.id, root.item.name);
}
}
}
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
width: 160px;
}
if root.item.albums-line != "": Text {
text: root.item.albums-line;
color: Theme.text-muted;
font-size: Typography.legal;
horizontal-alignment: center;
width: 160px;
}
}
}
component FavArtistCard inherits Rectangle {
in property <FavoriteArtistItem> item;
callback clicked(string);
width: 160px;
height: 200px;
VerticalLayout {
spacing: 10px;
Rectangle {
width: 120px;
height: 120px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
border-radius: 60px;
background: Theme.surface-elevated;
clip: true;
Image {
width: 100%;
height: 100%;
source: root.item.image;
image-fit: cover;
}
if root.item.image-url == "": QbzIcon {
source: @image-url("../assets/icons/user.svg");
width: 44px;
height: 44px;
x: Math.round((parent.width - self.width) / 2 / 1px) * 1px;
y: Math.round((parent.height - self.height) / 2 / 1px) * 1px;
tint: Theme.text-muted;
}
ta := TouchArea {
mouse-cursor: pointer;
clicked => {
root.clicked(root.item.id);
}
}
}
// albums_count line intentionally omitted (Tauri #169).
Text {
text: root.item.name;
color: Theme.text-primary;
font-size: Typography.body;
font-weight: Typography.medium;
horizontal-alignment: center;
overflow: elide;
width: 160px;
}
}
}
// 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 {
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("favorites");
GenreFilterState.open = !GenreFilterState.open;
}
}
}
// 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;
property <length> gap: 24px;
background: 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(); }
}
}
}
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");
}
}
}
}
}
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();
}
}
}
}
}
// --- 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-plus.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: "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/trash-2.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 == "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;
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;
// 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-w + root.gap)));
property <int> rows: Math.ceil(FavoritesState.artists-visible.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
for artist[i] in FavoritesState.artists-visible: FavArtistCard {
x: Math.mod(i, artists-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / artists-grid.columns) * (root.artist-h + root.gap);
item: artist;
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-w + root.gap)));
property <int> rows: Math.ceil(section.artists.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
: 0px;
for artist[i] in section.artists: FavArtistCard {
x: Math.mod(i, section-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / section-grid.columns) * (root.artist-h + root.gap);
item: artist;
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-w + root.gap)));
property <int> rows: Math.ceil(FavoritesState.labels-visible.length / self.columns);
height: self.rows > 0
? self.rows * root.artist-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 label[i] in FavoritesState.labels-visible: FavLabelCard {
x: Math.mod(i, labels-grid.columns) * (root.artist-w + root.gap);
y: Math.floor(i / labels-grid.columns) * (root.artist-h + root.gap);
item: label;
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;
}
}