feat(slint): favorites Playlists grid/list toggle + List view (persisted)

Add the user-requested grid/list switch to the Playlists toolbar
(parameterized the shared ViewToggle so albums + playlists both drive it)
and a new PlaylistListRow (collage thumb + name + owner·tracks + play +
overflow menu: play/play-next/queue/favorite), bound to playlists-view-mode.
The view mode persists via favorites_prefs (playlists_view). Tauri ships
grid only — the list view is a labeled Slint-better addition.

cc
This commit is contained in:
vicrodh committed 2026-05-27 14:21:27 -06:00
1 parent 120670147c
commit a75911b09b
3 files changed
+202 -7

No files matched your search

+5
View File
@@ -20,6 +20,8 @@ struct Prefs {
albums_group: String, albums_group: String,
#[serde(default = "d_off")] #[serde(default = "d_off")]
tracks_group: String, tracks_group: String,
#[serde(default = "d_grid")]
playlists_view: String,
} }
impl Default for Prefs { impl Default for Prefs {
@@ -29,6 +31,7 @@ impl Default for Prefs {
albums_sort: d_default(), albums_sort: d_default(),
albums_group: d_off(), albums_group: d_off(),
tracks_group: d_off(), tracks_group: d_off(),
playlists_view: d_grid(),
} }
} }
} }
@@ -65,6 +68,7 @@ pub fn load(window: &AppWindow) {
st.set_albums_sort_by(p.albums_sort.into()); st.set_albums_sort_by(p.albums_sort.into());
st.set_albums_group_mode(p.albums_group.into()); st.set_albums_group_mode(p.albums_group.into());
st.set_tracks_group_mode(p.tracks_group.into()); st.set_tracks_group_mode(p.tracks_group.into());
st.set_playlists_view_mode(p.playlists_view.into());
} }
/// Persist the current toolbar choices read from `FavoritesState`. /// Persist the current toolbar choices read from `FavoritesState`.
@@ -78,6 +82,7 @@ pub fn save(window: &AppWindow) {
albums_sort: st.get_albums_sort_by().into(), albums_sort: st.get_albums_sort_by().into(),
albums_group: st.get_albums_group_mode().into(), albums_group: st.get_albums_group_mode().into(),
tracks_group: st.get_tracks_group_mode().into(), tracks_group: st.get_tracks_group_mode().into(),
playlists_view: st.get_playlists_view_mode().into(),
}; };
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent); let _ = std::fs::create_dir_all(parent);
+12
View File
@@ -4338,6 +4338,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
} }
}); });
} }
{
// Playlists grid/list view toggle (persisted).
let weak = window.as_weak();
window
.global::<FavoritesActions>()
.on_playlists_set_view(move |v| {
if let Some(w) = weak.upgrade() {
w.global::<FavoritesState>().set_playlists_view_mode(v);
favorites_prefs::save(&w);
}
});
}
{ {
// Playlist card actions: play / play-next / queue / share / favorite. // Playlist card actions: play / play-next / queue / share / favorite.
let runtime = app_runtime.clone(); let runtime = app_runtime.clone();
@@ -15,6 +15,7 @@ import {
TrackItem, TrackItem,
FavoriteArtistItem, FavoriteArtistItem,
FavoriteLabelItem, FavoriteLabelItem,
SearchPlaylistItem,
DragState, DragState,
GenreFilterState, GenreFilterState,
GenreFilterActions, GenreFilterActions,
@@ -30,6 +31,8 @@ import { MultiSelectBar } from "../primitives/MultiSelectBar.slint";
import { ToggleButton } from "../primitives/ToggleButton.slint"; import { ToggleButton } from "../primitives/ToggleButton.slint";
import { QbzSelect } from "../primitives/QbzSelect.slint"; import { QbzSelect } from "../primitives/QbzSelect.slint";
import { QbzIcon } from "../primitives/QbzIcon.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 { LoadingSpinner } from "../primitives/LoadingSpinner.slint";
import { ListScrollbar } from "../primitives/ListScrollbar.slint"; import { ListScrollbar } from "../primitives/ListScrollbar.slint";
import { NavButtons } from "../shell/NavButtons.slint"; import { NavButtons } from "../shell/NavButtons.slint";
@@ -175,6 +178,150 @@ component PlaylistSubTab inherits Rectangle {
} }
} }
// 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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
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("Favorite");
clicked => {
root.media-action("playlist", root.playlist.id, "favorite");
}
}
}
}
}
}
}
}
// One label tile in the Favorites > Labels grid — a circular cover (or // One label tile in the Favorites > Labels grid — a circular cover (or
// an indigo->violet gradient + disc placeholder), name, and album count. // an indigo->violet gradient + disc placeholder), name, and album count.
// Mirrors FavArtistCard; the gradient placeholder is the 1:1 match with // Mirrors FavArtistCard; the gradient placeholder is the 1:1 match with
@@ -337,8 +484,11 @@ component FavGenreButton inherits Rectangle {
} }
} }
// Grid/list segmented toggle (albums toolbar). // Grid/list segmented toggle. Parameterized so albums + playlists can both
// drive it with their own view-mode state.
component ViewToggle inherits Rectangle { component ViewToggle inherits Rectangle {
in property <string> view-mode; // current "grid" | "list"
callback set-view(string);
width: 60px; width: 60px;
height: 30px; height: 30px;
border-radius: 6px; border-radius: 6px;
@@ -347,18 +497,18 @@ component ViewToggle inherits Rectangle {
HorizontalLayout { HorizontalLayout {
ToggleButton { ToggleButton {
icon: @image-url("../assets/icons/layout-grid.svg"); icon: @image-url("../assets/icons/layout-grid.svg");
active: FavoritesState.albums-view-mode == "grid"; active: root.view-mode == "grid";
sm: true; sm: true;
clicked => { clicked => {
FavoritesActions.albums-set-view("grid"); root.set-view("grid");
} }
} }
ToggleButton { ToggleButton {
icon: @image-url("../assets/icons/list.svg"); icon: @image-url("../assets/icons/list.svg");
active: FavoritesState.albums-view-mode == "list"; active: root.view-mode == "list";
sm: true; sm: true;
clicked => { clicked => {
FavoritesActions.albums-set-view("list"); root.set-view("list");
} }
} }
} }
@@ -601,7 +751,12 @@ export component FavoritesView inherits Rectangle {
} }
VerticalLayout { VerticalLayout {
alignment: center; alignment: center;
ViewToggle { } ViewToggle {
view-mode: FavoritesState.albums-view-mode;
set-view(v) => {
FavoritesActions.albums-set-view(v);
}
}
} }
} }
if FavoritesState.active-tab == "labels" if FavoritesState.active-tab == "labels"
@@ -634,6 +789,15 @@ export component FavoritesView inherits Rectangle {
} }
} }
} }
VerticalLayout {
alignment: center;
ViewToggle {
view-mode: FavoritesState.playlists-view-mode;
set-view(v) => {
FavoritesActions.playlists-set-view(v);
}
}
}
} }
} }
@@ -933,7 +1097,7 @@ export component FavoritesView inherits Rectangle {
color: Theme.text-muted; color: Theme.text-muted;
font-size: Typography.body; font-size: Typography.body;
} }
playlists-grid := Rectangle { if FavoritesState.playlists-view-mode != "list": playlists-grid := Rectangle {
property <length> pl-w: 220px; property <length> pl-w: 220px;
property <length> pl-h: 266px; property <length> pl-h: 266px;
property <int> columns: Math.max( property <int> columns: Math.max(
@@ -957,6 +1121,20 @@ export component FavoritesView inherits Rectangle {
} }
} }
} }
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 --- // --- Labels tab ---