feat(slint): rename + delete playlist (edit modal)

Completes playlist CRUD. The detail header shows an edit button only
for the user's own playlists (is-owner = the open id is in the sidebar
list); it opens an EditPlaylistModal:

  - Save -> update_playlist (rename); refreshes the detail header name
    and the sidebar list.
  - Delete -> delete_playlist; closes the modal, navigates back, and
    refreshes the sidebar.

Adds the pen-line icon (Lucide) used by the edit button.

cc
This commit is contained in:
vicrodh committed 2026-05-22 06:47:57 -06:00
1 parent f387114ecf
commit 75cdb10ee1
8 files changed
+284 -1

No files matched your search

+102
View File
@@ -670,8 +670,11 @@ fn navigate_playlist(
});
if let Some(data) = playlist::load(&runtime, id).await {
let jobs = playlist::artwork_jobs(&data);
let pid = data.id.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
playlist::apply(&w, data);
let owned = sidebar::contains(&w, &pid);
w.global::<PlaylistState>().set_is_owner(owned);
});
artwork::spawn_loads(jobs, weak.clone(), image_cache.clone());
}
@@ -1548,6 +1551,18 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
}
("playlist", "edit") => {
// Open the edit modal, prefilled from the open playlist.
if let Some(w) = weak.upgrade() {
let ps = w.global::<PlaylistState>();
let pid = ps.get_id();
let name = ps.get_name();
let es = w.global::<EditPlaylistState>();
es.set_id(pid);
es.set_name(name);
es.set_open(true);
}
}
("playlist-track", track_id) => {
let idx = playlist::index_of(track_id);
let runtime = runtime.clone();
@@ -2353,6 +2368,93 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
});
}
// Edit playlist (rename / delete).
{
let weak = window.as_weak();
window
.global::<EditPlaylistActions>()
.on_close(move || {
if let Some(w) = weak.upgrade() {
w.global::<EditPlaylistState>().set_open(false);
}
});
}
{
// Rename the playlist, then refresh the detail header + sidebar.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<EditPlaylistActions>()
.on_save(move || {
let Some(w) = weak.upgrade() else { return; };
let es = w.global::<EditPlaylistState>();
let name = es.get_name().to_string();
let id = es.get_id().to_string();
if name.trim().is_empty() || es.get_busy() {
return;
}
let (Ok(pid),) = (id.parse::<u64>(),) else { return; };
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
match runtime.core().update_playlist(pid, Some(name.trim()), None, None).await {
Ok(_) => {
let nm = name.trim().to_string();
let r2 = runtime.clone();
let w2 = weak.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<PlaylistState>().set_name(nm.into());
w.global::<EditPlaylistState>().set_open(false);
load_sidebar_playlists(r2, w2, &h2);
});
}
Err(e) => log::error!("[qbz-slint] rename playlist failed: {e}"),
}
});
});
}
{
// Delete the playlist, then navigate back + refresh the sidebar.
let runtime = app_runtime.clone();
let weak = window.as_weak();
let handle = tokio_rt.handle().clone();
window
.global::<EditPlaylistActions>()
.on_delete(move || {
let Some(w) = weak.upgrade() else { return; };
let id = w.global::<EditPlaylistState>().get_id().to_string();
let Ok(pid) = id.parse::<u64>() else { return; };
w.global::<EditPlaylistState>().set_busy(true);
let runtime = runtime.clone();
let weak = weak.clone();
let handle = handle.clone();
handle.clone().spawn(async move {
match runtime.core().delete_playlist(pid).await {
Ok(()) => {
let r2 = runtime.clone();
let w2 = weak.clone();
let h2 = handle.clone();
let _ = weak.upgrade_in_event_loop(move |w| {
w.global::<EditPlaylistState>().set_busy(false);
w.global::<EditPlaylistState>().set_open(false);
load_sidebar_playlists(r2, w2, &h2);
w.global::<NavState>().invoke_request_back();
});
}
Err(e) => {
log::error!("[qbz-slint] delete playlist failed: {e}");
let _ = weak.upgrade_in_event_loop(|w| {
w.global::<EditPlaylistState>().set_busy(false);
});
}
}
});
});
}
// Sidebar playlists — open a row, or create a new playlist.
{
let runtime = app_runtime.clone();
+11
View File
@@ -54,3 +54,14 @@ pub fn apply(window: &AppWindow, playlists: Vec<SidebarPlaylist>) {
pub fn set_active(window: &AppWindow, id: &str) {
window.global::<SidebarState>().set_active_id(id.into());
}
/// Whether `id` is one of the user's own playlists (in the sidebar
/// list) — used to gate playlist editing.
pub fn contains(window: &AppWindow, id: &str) -> bool {
use slint::Model;
window
.global::<SidebarState>()
.get_playlists()
.iter()
.any(|p| p.id == id)
}
+1 -1
View File
@@ -17,7 +17,7 @@ import { LoginScreen } from "login/LoginScreen.slint";
import { AppShell } from "shell/AppShell.slint";
// Re-export the state globals so the Rust layer can populate them.
export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions } from "state.slint";
export { HomeState, HomeActions, ForYouState, MixState, GenreFilterState, GenreFilterActions, AlbumState, ArtistState, NavState, SessionState, SettingsState, AlbumActions, ArtistActions, ArtworkActions, NowPlayingState, QueueState, SearchState, SearchActions, NetworkSidebarState, NetworkSidebarActions, MusicianState, MusicianActions, LabelState, LabelActions, LocationViewState, LocationViewActions, FavoritesState, FavoritesActions, PlaylistPickerState, PlaylistPickerActions, PlaylistState, SidebarState, SidebarActions, CreatePlaylistState, CreatePlaylistActions, EditPlaylistState, EditPlaylistActions } from "state.slint";
// Which top-level screen is shown. The app starts on `splash` while it
// restores a saved session, then resolves to `shell` or `login`.
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 20h9" />
<path d="M16.376 3.622a1 1 0 0 1 3.002 3.002L7.368 18.635a2 2 0 0 1-.855.506l-2.872.838a.5.5 0 0 1-.62-.62l.838-2.872a2 2 0 0 1 .506-.854z" />
</svg>

After

Width:  |  Height:  |  Size: 353 B

@@ -52,6 +52,7 @@ export component PlaylistView inherits Rectangle {
callback play-all();
callback shuffle();
callback toggle-favorite();
callback edit();
callback media-action(string, string, string);
background: Theme.surface-main;
@@ -173,6 +174,16 @@ export component PlaylistView inherits Rectangle {
}
}
}
// Edit (rename / delete) — only the user's own.
if PlaylistState.is-owner: VerticalLayout {
alignment: center;
CircleAction {
icon: @image-url("../assets/icons/pen-line.svg");
clicked => {
root.edit();
}
}
}
}
}
}
@@ -0,0 +1,132 @@
// "Edit playlist" modal — rename + delete. Opened from the playlist
// detail header's edit button (EditPlaylistState.open).
import { Theme } from "../foundation/semantic-colors.slint";
import { Typography } from "../foundation/typography.slint";
import { Radius } from "../foundation/radius.slint";
import { EditPlaylistState, EditPlaylistActions } from "../state.slint";
import { QbzIcon } from "QbzIcon.slint";
import { LineEdit } from "std-widgets.slint";
export component EditPlaylistModal inherits Rectangle {
visible: EditPlaylistState.open;
if EditPlaylistState.open: Rectangle {
background: #000000bf;
TouchArea {
mouse-cursor: default;
clicked => {
EditPlaylistActions.close();
}
}
Rectangle {
width: Math.min(root.width - 80px, 420px);
height: panel.preferred-height;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
border-radius: Radius.md;
background: Theme.surface-card;
border-width: 1px;
border-color: Theme.border-subtle;
drop-shadow-blur: 32px;
drop-shadow-color: #00000080;
TouchArea { }
panel := VerticalLayout {
padding: 20px;
spacing: 16px;
HorizontalLayout {
Text {
text: @tr("Edit playlist");
color: Theme.text-primary;
font-size: Typography.heading;
font-weight: Typography.semibold;
horizontal-stretch: 1;
vertical-alignment: center;
}
close-x := TouchArea {
width: 28px;
height: 28px;
mouse-cursor: pointer;
clicked => {
EditPlaylistActions.close();
}
QbzIcon {
source: @image-url("../assets/icons/x.svg");
width: 17px;
height: 17px;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
tint: close-x.has-hover ? Theme.text-primary : Theme.text-muted;
}
}
}
name-input := LineEdit {
placeholder-text: @tr("Playlist name");
text <=> EditPlaylistState.name;
accepted => {
EditPlaylistActions.save();
}
}
HorizontalLayout {
spacing: 10px;
// Delete (destructive, left).
Rectangle {
width: del-label.preferred-width + 28px;
height: 36px;
border-radius: Radius.sm;
background: del-ta.has-hover ? #ef444433 : transparent;
border-width: 1px;
border-color: #ef4444;
del-label := Text {
text: @tr("Delete");
color: #ef4444;
font-size: Typography.body;
font-weight: Typography.medium;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
del-ta := TouchArea {
mouse-cursor: EditPlaylistState.busy ? default : pointer;
clicked => {
if (!EditPlaylistState.busy) {
EditPlaylistActions.delete();
}
}
}
}
// Spacer.
Rectangle { horizontal-stretch: 1; }
// Save (right).
Rectangle {
width: save-label.preferred-width + 36px;
height: 36px;
border-radius: Radius.sm;
opacity: (EditPlaylistState.name != "" && !EditPlaylistState.busy) ? 1.0 : 0.5;
background: save-ta.has-hover ? #ffffffd6 : Theme.accent;
save-label := Text {
text: @tr("Save");
color: #ffffff;
font-size: Typography.body;
font-weight: Typography.semibold;
x: (parent.width - self.width) / 2;
y: (parent.height - self.height) / 2;
}
save-ta := TouchArea {
mouse-cursor: (EditPlaylistState.name != "" && !EditPlaylistState.busy)
? pointer : default;
clicked => {
if (EditPlaylistState.name != "" && !EditPlaylistState.busy) {
EditPlaylistActions.save();
}
}
}
}
}
}
}
}
}
+7
View File
@@ -12,6 +12,7 @@ import { HeaderBar } from "HeaderBar.slint";
import { HeaderMenuOverlay } from "HeaderMenuOverlay.slint";
import { PlaylistPickerModal } from "../primitives/PlaylistPickerModal.slint";
import { CreatePlaylistModal } from "../primitives/CreatePlaylistModal.slint";
import { EditPlaylistModal } from "../primitives/EditPlaylistModal.slint";
import { PlayerBar } from "PlayerBar.slint";
import { QueueSidebar } from "QueueSidebar.slint";
import { LyricsSidebar } from "LyricsSidebar.slint";
@@ -162,6 +163,9 @@ export component AppShell inherits Rectangle {
toggle-favorite => {
root.media-action("playlist", "", "favorite");
}
edit => {
root.media-action("playlist", "", "edit");
}
media-action(kind, id, action) => {
root.media-action(kind, id, action);
}
@@ -226,4 +230,7 @@ export component AppShell inherits Rectangle {
// Global "Create playlist" modal, opened from the sidebar "+".
CreatePlaylistModal { }
// Global "Edit playlist" modal (rename / delete).
EditPlaylistModal { }
}
+16
View File
@@ -243,10 +243,26 @@ export global PlaylistState {
in property <string> cover-url;
in property <image> cover;
in property <bool> is-favorite: false;
// True when the playlist is one of the user's own (editable).
in property <bool> is-owner: false;
in property <[TrackItem]> tracks: [];
in property <bool> loading: false;
}
// "Edit playlist" modal state (rename + delete).
export global EditPlaylistState {
in-out property <bool> open: false;
in property <string> id;
in-out property <string> name;
in property <bool> busy: false;
}
export global EditPlaylistActions {
callback save();
callback delete();
callback close();
}
// Settings screen state. Audio and Playback preferences are loaded from
// the persistence stores by the Rust settings controller and pushed here;
// changes flow back through the settings-bool / settings-select /