Files
qbz/crates/qbz-slint/ui/favorites/FavoritesView.slint
T
vicrodh e9836184e7 feat(slint): favorites tracks multi-select + bulk remove
Add a select toggle to the Tracks header that enters multi-select mode
(per-row checkboxes via the shared TrackRow) and shows the shared
MultiSelectBar with Select all / Remove from favorites / Clear. Selection
lives on the rendered tracks-visible rows (mirrors playlist.rs); the
toggle-select router arm and recount gain a Favorites case. Bulk remove
loops remove_favorite, syncs fav_cache, then reloads the tab. Play next /
queue / add-to-playlist / make-offline are deferred (each needs a backend
path: batch enqueue / multi-id picker / offline cache).

cc
2026-05-26 22:40:06 -06:00

565 lines
21 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 { ExpandableSearch } from "../primitives/ExpandableSearch.slint";
import { CircleAction } from "../primitives/CircleAction.slint";
import { MultiSelectBar } from "../primitives/MultiSelectBar.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";
// 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;
flick := 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.
HorizontalLayout {
spacing: 12px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: @tr("Favorites");
color: Theme.text-primary;
font-size: Typography.section;
font-weight: Typography.bold;
}
}
// Tracks header actions — Play all + Shuffle the favorites
// queue. Shown only when the Tracks tab has rendered rows.
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();
}
}
}
}
}
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;
// Toolbar — results count (left) + expandable search (right).
if FavoritesState.tracks.length > 0: HorizontalLayout {
spacing: 8px;
VerticalLayout {
alignment: center;
horizontal-stretch: 1;
Text {
text: FavoritesState.tracks-visible.length
+ (FavoritesState.tracks-search != ""
? " / " + FavoritesState.tracks.length
: "")
+ " " + @tr("tracks");
color: Theme.text-muted;
font-size: Typography.legal;
}
}
VerticalLayout {
alignment: center;
ExpandableSearch {
placeholder: @tr("Search");
query <=> FavoritesState.tracks-search;
edited(q) => {
FavoritesActions.search-tracks(q);
}
}
}
}
if FavoritesState.tracks.length > 0: Rectangle { height: 12px; }
// 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: "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;
}
// Search filtered everything out.
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;
}
for track[i] in FavoritesState.tracks-visible: TrackRow {
track: track;
index: i;
show-artwork: true;
show-album: true;
show-favorite: true;
multi-select-mode: FavoritesState.tracks-multi-select;
// Album / artist name links open the respective page; the
// row drops `kind`, so route those explicitly (the
// track-action path forces kind="track" and would treat an
// album-id as an artist-id).
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 {
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); }
}
}
}
}
// Shared scrollbar in the right gutter (homologated cross-view).
ListScrollbar {
x: parent.width - self.width - 4px;
width: 14px;
height: 100%;
viewport-height: flick.viewport-height;
visible-height: flick.height;
viewport-y <=> flick.viewport-y;
}
}