Each favorites tab is now its own history entry (NavEntry::Favorites{tab},
mirroring Discover) recorded from both the header nav and the in-view tab
bar, so back/forward moves between tabs instead of exiting Favorites. Add
the back/forward NavButtons at the same position as every other page
(11px top padding + 22px gap) — FavoritesView was the only view missing
it — and shrink the "Favorites" heading to Typography.section (the album
name size in AlbumView).
cc
446 lines
16 KiB
Plaintext
446 lines
16 KiB
Plaintext
// Library > Favorites — the user's saved tracks / albums / artists /
|
|
// playlists / labels. Mirrors Tauri's FavoritesView.svelte core: a
|
|
// tab bar over per-tab content. Tracks reuse the shared
|
|
// TrackRow, albums the Discover AlbumCard, artists a round-
|
|
// portrait grid. Playlists + Labels are stubbed pending their
|
|
// backing views.
|
|
|
|
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,
|
|
} from "../state.slint";
|
|
import { AlbumCard } from "../discover/AlbumCard.slint";
|
|
import { TrackRow } from "../primitives/TrackRow.slint";
|
|
import { QbzIcon } from "../primitives/QbzIcon.slint";
|
|
import { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
|
|
import { NavButtons } from "../shell/NavButtons.slint";
|
|
|
|
// One tab in the favorites tab bar.
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
export component FavoritesView inherits Rectangle {
|
|
callback open-album(string);
|
|
callback open-artist(string);
|
|
|
|
property <length> album-w: 220px;
|
|
property <length> album-h: 266px;
|
|
property <length> artist-w: 160px;
|
|
property <length> artist-h: 200px;
|
|
property <length> gap: 24px;
|
|
|
|
background: Theme.surface-main;
|
|
|
|
Flickable {
|
|
interactive: !DragState.active;
|
|
viewport-width: self.width;
|
|
viewport-height: page.preferred-height;
|
|
|
|
page := VerticalLayout {
|
|
width: parent.width;
|
|
padding-left: 32px;
|
|
padding-right: 32px;
|
|
padding-top: 11px;
|
|
padding-bottom: 100px;
|
|
spacing: 0px;
|
|
alignment: start;
|
|
|
|
// Back / forward nav buttons — same position as every other
|
|
// page (11px top padding, then a 22px gap to the header).
|
|
HorizontalLayout {
|
|
alignment: start;
|
|
NavButtons { }
|
|
}
|
|
|
|
Rectangle { height: 22px; }
|
|
|
|
// Page title — same size as the album name in AlbumView
|
|
// (Typography.section), not the larger Typography.title.
|
|
Text {
|
|
text: @tr("Favorites");
|
|
color: Theme.text-primary;
|
|
font-size: Typography.section;
|
|
font-weight: Typography.bold;
|
|
}
|
|
|
|
Rectangle { height: 16px; }
|
|
|
|
// --- Tab bar -----------------------------------------------
|
|
HorizontalLayout {
|
|
spacing: 20px;
|
|
alignment: start;
|
|
FavTab {
|
|
label: @tr("Tracks");
|
|
id: "tracks";
|
|
count: FavoritesState.tracks-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
FavTab {
|
|
label: @tr("Albums");
|
|
id: "albums";
|
|
count: FavoritesState.albums-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
FavTab {
|
|
label: @tr("Artists");
|
|
id: "artists";
|
|
count: FavoritesState.artists-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
FavTab {
|
|
label: @tr("Playlists");
|
|
id: "playlists";
|
|
count: FavoritesState.playlists-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
FavTab {
|
|
label: @tr("Labels");
|
|
id: "labels";
|
|
count: FavoritesState.labels-total;
|
|
selected(id) => { FavoritesActions.select-tab(id); }
|
|
}
|
|
}
|
|
Rectangle {
|
|
height: 1px;
|
|
background: Theme.border-subtle;
|
|
}
|
|
Rectangle { height: 20px; }
|
|
|
|
if FavoritesState.loading: HorizontalLayout {
|
|
alignment: center;
|
|
LoadingSpinner { size: 36px; }
|
|
}
|
|
|
|
// --- Tracks tab --------------------------------------------
|
|
if !FavoritesState.loading
|
|
&& FavoritesState.active-tab == "tracks": VerticalLayout {
|
|
spacing: 0px;
|
|
if FavoritesState.tracks.length == 0: Text {
|
|
text: @tr("No favorite tracks yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for track[i] in FavoritesState.tracks: TrackRow {
|
|
track: track;
|
|
index: i;
|
|
show-artwork: true;
|
|
media-action(kind, id, action) => {
|
|
FavoritesActions.track-action(id, action);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Albums tab --------------------------------------------
|
|
if !FavoritesState.loading
|
|
&& FavoritesState.active-tab == "albums": albums-grid := Rectangle {
|
|
property <int> columns: Math.max(
|
|
1,
|
|
Math.floor((self.width + root.gap) / (root.album-w + root.gap)));
|
|
property <int> rows: Math.ceil(FavoritesState.albums.length / self.columns);
|
|
height: self.rows > 0
|
|
? self.rows * root.album-h + Math.max(0, self.rows - 1) * root.gap
|
|
: 0px;
|
|
if FavoritesState.albums.length == 0: Text {
|
|
text: @tr("No favorite albums yet.");
|
|
color: Theme.text-muted;
|
|
font-size: Typography.body;
|
|
}
|
|
for album[i] in FavoritesState.albums: AlbumCard {
|
|
x: Math.mod(i, albums-grid.columns) * (root.album-w + root.gap);
|
|
y: Math.floor(i / albums-grid.columns) * (root.album-h + root.gap);
|
|
width: root.album-w;
|
|
height: root.album-h;
|
|
album: album;
|
|
clicked(id) => { root.open-album(id); }
|
|
media-action(kind, id, action) => {
|
|
if (kind == "artist") {
|
|
root.open-artist(id);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Artists tab -------------------------------------------
|
|
if !FavoritesState.loading
|
|
&& 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.loading
|
|
&& 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.loading
|
|
&& 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); }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|