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:
3 files changed
+202
-7
No files matched your search
@@ -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);
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|||||||
Reference in new issue
Block a user