Add an AlphaStrip overlaid on the right edge when grouping alphabetically: tracks "Group: Name" (jumps the virtualized ListView to the first row of a letter — accurate, uniform 50px rows) and albums "Group: A-Z" (jumps the grid Flickable proportionally by section ordinal — approximate, always lands the section in view, per the agreed ArtistView-style behavior). derive_tracks/derive_albums build the per-letter jump model (tracks-alpha / albums-alpha). cc
945 lines
37 KiB
Plaintext
945 lines
37 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,
|
|
FavoritePlaylistItem,
|
|
FavoriteLabelItem,
|
|
DragState,
|
|
GenreFilterState,
|
|
GenreFilterActions,
|
|
AlphaJump,
|
|
} from "../state.slint";
|
|
import { AlbumCollectionView } from "../discover/AlbumCollectionView.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 { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { ListScrollbar } from "../primitives/ListScrollbar.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
import { ListView } from "std-widgets.slint";
|
|
|
|
// One tab in the favorites tab menu.
|
|
component FavTab inherits Rectangle {
|
|
in property <string> label;
|
|
in property <string> id;
|
|
in property <int> count;
|
|
callback selected(string);
|
|
|
|
property <bool> active: FavoritesState.active-tab == root.id;
|
|
height: 34px;
|
|
width: tab-row.preferred-width;
|
|
|
|
tab-row := HorizontalLayout {
|
|
padding-left: 4px;
|
|
padding-right: 4px;
|
|
spacing: 6px;
|
|
Text {
|
|
text: root.label;
|
|
color: root.active ? Theme.text-primary : Theme.text-muted;
|
|
font-size: Typography.body;
|
|
font-weight: root.active ? Typography.semibold : Typography.regular;
|
|
vertical-alignment: center;
|
|
}
|
|
if root.count > 0: Text {
|
|
text: root.count;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
// Active underline.
|
|
Rectangle {
|
|
y: parent.height - 2px;
|
|
height: 2px;
|
|
width: parent.width;
|
|
background: root.active ? Theme.accent : transparent;
|
|
border-radius: 1px;
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.selected(root.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
// The 5 favorites tabs as one floating group (row 1, right side). Counts
|
|
// stay live (FavoritesState.*-total are seeded up front).
|
|
component FavTabMenu inherits HorizontalLayout {
|
|
spacing: 18px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTab {
|
|
label: @tr("Tracks");
|
|
id: "tracks";
|
|
count: FavoritesState.tracks-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTab {
|
|
label: @tr("Albums");
|
|
id: "albums";
|
|
count: FavoritesState.albums-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTab {
|
|
label: @tr("Artists");
|
|
id: "artists";
|
|
count: FavoritesState.artists-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTab {
|
|
label: @tr("Playlists");
|
|
id: "playlists";
|
|
count: FavoritesState.playlists-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTab {
|
|
label: @tr("Labels");
|
|
id: "labels";
|
|
count: FavoritesState.labels-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
}
|
|
|
|
component FavPlaylistCard inherits Rectangle {
|
|
in property <FavoritePlaylistItem> item;
|
|
callback clicked(string);
|
|
|
|
width: 180px;
|
|
height: 234px;
|
|
|
|
VerticalLayout {
|
|
spacing: 8px;
|
|
Rectangle {
|
|
width: 180px;
|
|
height: 180px;
|
|
border-radius: Radius.sm;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
Image {
|
|
width: 100%;
|
|
height: 100%;
|
|
source: root.item.cover;
|
|
image-fit: cover;
|
|
}
|
|
if root.item.cover-url == "": QbzIcon {
|
|
source: @image-url("../assets/icons/list-music.svg");
|
|
width: 44px;
|
|
height: 44px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: Theme.text-muted;
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked(root.item.id);
|
|
}
|
|
}
|
|
}
|
|
Text {
|
|
text: root.item.name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
width: 180px;
|
|
}
|
|
Text {
|
|
text: root.item.tracks-line;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
overflow: elide;
|
|
width: 180px;
|
|
}
|
|
}
|
|
}
|
|
|
|
component FavLabelRow inherits Rectangle {
|
|
in property <FavoriteLabelItem> item;
|
|
in property <int> index;
|
|
callback clicked(string, string);
|
|
|
|
height: 52px;
|
|
border-radius: 8px;
|
|
background: ta.has-hover
|
|
? #ffffff16
|
|
: (Math.mod(root.index, 2) == 1 ? #ffffff07 : transparent);
|
|
|
|
HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 12px;
|
|
spacing: 12px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Rectangle {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/disc-3.svg");
|
|
width: 18px;
|
|
height: 18px;
|
|
x: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: Theme.text-muted;
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
horizontal-stretch: 1;
|
|
Text {
|
|
text: root.item.name;
|
|
color: Theme.text-primary;
|
|
font-size: Typography.body;
|
|
font-weight: Typography.medium;
|
|
overflow: elide;
|
|
}
|
|
if root.item.albums-line != "": Text {
|
|
text: root.item.albums-line;
|
|
color: Theme.text-muted;
|
|
font-size: Typography.legal;
|
|
}
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked(root.item.id, root.item.name);
|
|
}
|
|
}
|
|
}
|
|
|
|
component FavArtistCard inherits Rectangle {
|
|
in property <FavoriteArtistItem> item;
|
|
callback clicked(string);
|
|
|
|
width: 160px;
|
|
height: 200px;
|
|
|
|
VerticalLayout {
|
|
spacing: 10px;
|
|
Rectangle {
|
|
width: 140px;
|
|
height: 140px;
|
|
x: (parent.width - self.width) / 2;
|
|
border-radius: 70px;
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
tint: Theme.text-muted;
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
root.clicked(root.item.id);
|
|
}
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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: 34px;
|
|
border-radius: 6px;
|
|
background: root.active
|
|
? Theme.accent
|
|
: (ta.has-hover ? Theme.surface-hover : Theme.surface-elevated);
|
|
|
|
genre-row := HorizontalLayout {
|
|
padding-left: 12px;
|
|
padding-right: 14px;
|
|
spacing: 7px;
|
|
VerticalLayout {
|
|
alignment: center;
|
|
QbzIcon {
|
|
source: @image-url("../assets/icons/list-filter.svg");
|
|
width: 14px;
|
|
height: 14px;
|
|
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: 13px;
|
|
vertical-alignment: center;
|
|
}
|
|
}
|
|
ta := TouchArea {
|
|
mouse-cursor: pointer;
|
|
clicked => {
|
|
GenreFilterActions.set-context("favorites");
|
|
GenreFilterState.open = !GenreFilterState.open;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Grid/list segmented toggle (albums toolbar).
|
|
component ViewToggle inherits Rectangle {
|
|
width: 68px;
|
|
height: 34px;
|
|
border-radius: 6px;
|
|
background: Theme.surface-elevated;
|
|
clip: true;
|
|
HorizontalLayout {
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/layout-grid.svg");
|
|
active: FavoritesState.albums-view-mode == "grid";
|
|
clicked => {
|
|
FavoritesActions.albums-set-view("grid");
|
|
}
|
|
}
|
|
ToggleButton {
|
|
icon: @image-url("../assets/icons/list.svg");
|
|
active: FavoritesState.albums-view-mode == "list";
|
|
clicked => {
|
|
FavoritesActions.albums-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;
|
|
NavButtons { }
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
Text {
|
|
text: @tr("Favorites");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
}
|
|
}
|
|
Rectangle { horizontal-stretch: 1; }
|
|
VerticalLayout {
|
|
alignment: center;
|
|
FavTabMenu { }
|
|
}
|
|
}
|
|
|
|
Rectangle { height: 12px; }
|
|
|
|
// Row 2 — per-view action buttons (left), toolbar (right).
|
|
HorizontalLayout {
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
spacing: 8px;
|
|
|
|
// --- Left: action buttons ---
|
|
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;
|
|
clicked => {
|
|
FavoritesActions.play-all-tracks();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/shuffle.svg");
|
|
clicked => {
|
|
FavoritesActions.shuffle-tracks();
|
|
}
|
|
}
|
|
}
|
|
VerticalLayout {
|
|
alignment: center;
|
|
CircleAction {
|
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
|
active: FavoritesState.tracks-multi-select;
|
|
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");
|
|
clicked => {
|
|
FavoritesActions.albums-shuffle();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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");
|
|
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"),
|
|
];
|
|
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");
|
|
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"),
|
|
];
|
|
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"),
|
|
];
|
|
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 { }
|
|
}
|
|
}
|
|
}
|
|
|
|
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 favorites.");
|
|
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: (parent.width - self.width) / 2;
|
|
y: (parent.height - self.height) / 2;
|
|
}
|
|
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": 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"), danger: false, needs-selection: false },
|
|
{ id: "play-next", label: @tr("Play next"), danger: false, needs-selection: true },
|
|
{ id: "queue", label: @tr("Add to queue"), danger: false, needs-selection: true },
|
|
{ id: "add-to-playlist", label: @tr("Add to playlist"), danger: false, needs-selection: true },
|
|
{ id: "remove-selected", label: @tr("Remove from favorites"), danger: true, needs-selection: true },
|
|
{ id: "clear", label: @tr("Clear"), 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 favorite tracks 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;
|
|
}
|
|
|
|
Rectangle {
|
|
vertical-stretch: 1;
|
|
track-list := ListView {
|
|
width: 100%;
|
|
height: 100%;
|
|
vertical-scrollbar-policy: ScrollBarPolicy.always-off;
|
|
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;
|
|
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": Rectangle {
|
|
other-flick := Flickable {
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: other-page.preferred-height;
|
|
|
|
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 favorite albums 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;
|
|
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": 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.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.artist-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
if FavoritesState.artists.length == 0: Text {
|
|
text: @tr("No favorite artists yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for artist[i] in FavoritesState.artists: 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); }
|
|
}
|
|
}
|
|
|
|
// --- Playlists tab ---
|
|
if FavoritesState.active-tab == "playlists": playlists-grid := Rectangle {
|
|
property <length> pl-w: 180px;
|
|
property <length> pl-h: 234px;
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (self.pl-w + root.gap)));
|
|
property <int> rows: Math.ceil(FavoritesState.playlists.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * self.pl-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
if FavoritesState.playlists.length == 0: Text {
|
|
text: @tr("No favorite playlists yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for pl[i] in FavoritesState.playlists: FavPlaylistCard {
|
|
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);
|
|
item: pl;
|
|
clicked(id) => { FavoritesActions.open-playlist(id); }
|
|
}
|
|
}
|
|
|
|
// --- Labels tab ---
|
|
if FavoritesState.active-tab == "labels": VerticalLayout {
|
|
spacing: 0px;
|
|
if FavoritesState.labels.length == 0: Text {
|
|
text: @tr("No favorite labels yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for label[i] in FavoritesState.labels: FavLabelRow {
|
|
item: label;
|
|
index: i;
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|