feat(slint): favorites Playlists view — sub-tabs + rich card grid

Swap the passive single-cover FavPlaylistCard tile for the shared
discover/PlaylistCard (collage + hover overlay play/fav/more + context
menu) over playlists-visible, and add the Library/Following underline
sub-tab selector (no pills, ADR-008) above the grid with per-sub-tab and
search-empty states. Extend the shared PlaylistCard with a body-opens
prop so a favorites card-body click opens the playlist while the play
button plays (discover/search keep body=play, unchanged). Add the
Playlists search box to the row-2 toolbar. Remove the dead
FavoritePlaylistItem struct + playlists field + FavPlaylistCard.

cc
This commit is contained in:
vicrodh committed 2026-05-27 14:11:31 -06:00
1 parent 2d2f65a1cb
commit 116bc13660
3 files changed
+114 -75

No files matched your search

@@ -49,8 +49,12 @@ component OverlayButton inherits Rectangle {
export component PlaylistCard inherits Rectangle {
in property <SearchPlaylistItem> playlist;
// kind ("playlist"), playlist id, action ("play" | "play-next" |
// "queue" | "favorite").
// "queue" | "favorite" | "open").
callback media-action(string, string, string);
// When true, a click on the card BODY opens the playlist ("open")
// instead of playing it — the Favorites grid wants body=open, the play
// button=play. Discover/Search leave it false (body=play), unchanged.
in property <bool> body-opens: false;
width: 220px;
height: 266px;
@@ -87,7 +91,7 @@ export component PlaylistCard inherits Rectangle {
height: 100%;
mouse-cursor: pointer;
clicked => {
root.media-action("playlist", root.playlist.id, "play");
root.media-action("playlist", root.playlist.id, root.body-opens ? "open" : "play");
}
}
+108 -62
View File
@@ -14,7 +14,6 @@ import {
AlbumCardItem,
TrackItem,
FavoriteArtistItem,
FavoritePlaylistItem,
FavoriteLabelItem,
DragState,
GenreFilterState,
@@ -22,6 +21,7 @@ import {
AlphaJump,
} 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";
@@ -131,56 +131,46 @@ component FavTabMenu inherits HorizontalLayout {
}
}
component FavPlaylistCard inherits Rectangle {
in property <FavoritePlaylistItem> item;
callback clicked(string);
// 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;
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);
}
}
}
row := HorizontalLayout {
padding-left: 2px;
padding-right: 2px;
spacing: 6px;
Text {
text: root.item.name;
color: Theme.text-primary;
text: root.label;
color: root.active ? Theme.text-primary : Theme.text-muted;
font-size: Typography.body;
font-weight: Typography.medium;
overflow: elide;
width: 180px;
font-weight: root.active ? Typography.semibold : Typography.regular;
vertical-alignment: center;
}
Text {
text: root.item.tracks-line;
if root.count > 0: Text {
text: root.count;
color: Theme.text-muted;
font-size: Typography.legal;
overflow: elide;
width: 180px;
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);
}
}
}
@@ -629,6 +619,22 @@ export component FavoritesView inherits Rectangle {
}
}
}
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);
}
}
}
}
}
Rectangle { height: 14px; }
@@ -889,27 +895,67 @@ export component FavoritesView inherits Rectangle {
}
}
// --- 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 {
// --- 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 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); }
playlists-grid := Rectangle {
property <length> pl-w: 220px;
property <length> pl-h: 266px;
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);
}
}
}
}
}
-11
View File
@@ -1438,16 +1438,6 @@ export struct FavoriteArtistItem {
image: image,
}
// One playlist tile in the Favorites > Playlists grid.
export struct FavoritePlaylistItem {
id: string,
name: string,
owner: string,
tracks-line: string, // "N tracks"
cover-url: string,
cover: image,
}
// One label card in the Favorites > Labels grid.
export struct FavoriteLabelItem {
id: string,
@@ -1486,7 +1476,6 @@ export global FavoritesState {
in property <[AlphaJump]> albums-alpha: []; // A-Z jumps (alpha grouping)
in property <int> albums-shown: 0; // count after filter (grouped or flat)
in property <[FavoriteArtistItem]> artists: [];
in property <[FavoritePlaylistItem]> playlists: [];
// Playlists has two internal sub-tabs (mirror Tauri favoritePlaylists /
// followingPlaylists), both rendered with the rich SearchPlaylistItem
// (4-cover collage). `playlists-visible` is the current sub-tab after the