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:
8 files changed
+284
-1
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
}
|
||||
@@ -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 /
|
||||
|
||||
Reference in new issue
Block a user