feat(slint): playlist track editing (multi-select remove)
The playlist detail view gains an edit mode (owner-only), reusing
TrackRow's multi-select-mode:
- A "Select" toggle in the header turns on per-row checkboxes and a
bulk action bar (N selected, Select all, Remove).
- toggle-select is now context-aware (playlist vs artist Popular);
PlaylistState tracks selected-count for the bar.
- Remove calls remove_tracks_from_playlist with the selected ids,
then reloads the playlist and leaves edit mode.
Track reordering is the remaining playlist-editing piece (deferred —
it is the most involved).
cc
This commit is contained in:
4 files changed
+198
-5
No files matched your search
@@ -1435,12 +1435,15 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
("track", "toggle-select") => {
|
("track", "toggle-select") => {
|
||||||
// Multi-select foundation — flips `selected` on the
|
// Flip `selected` on the matching row, in whichever
|
||||||
// matching row in ArtistState.top-tracks. Other
|
// multi-select surface is showing: the playlist
|
||||||
// track-row contexts (album, search) will be wired as
|
// detail or the artist Popular Tracks.
|
||||||
// selection lands there.
|
|
||||||
if let Some(w) = weak.upgrade() {
|
if let Some(w) = weak.upgrade() {
|
||||||
let model = w.global::<ArtistState>().get_top_tracks();
|
let model = if w.global::<NavState>().get_view() == ContentView::Playlist {
|
||||||
|
w.global::<PlaylistState>().get_tracks()
|
||||||
|
} else {
|
||||||
|
w.global::<ArtistState>().get_top_tracks()
|
||||||
|
};
|
||||||
if let Some(vm) = model
|
if let Some(vm) = model
|
||||||
.as_any()
|
.as_any()
|
||||||
.downcast_ref::<slint::VecModel<TrackItem>>()
|
.downcast_ref::<slint::VecModel<TrackItem>>()
|
||||||
@@ -1455,6 +1458,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if w.global::<NavState>().get_view() == ContentView::Playlist {
|
||||||
|
playlist::recount_selected(&w);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The mix tile sends id = mix kind, action = "open".
|
// The mix tile sends id = mix kind, action = "open".
|
||||||
@@ -1551,6 +1557,53 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
("playlist", "select-toggle") => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let on = w.global::<PlaylistState>().get_multi_select_mode();
|
||||||
|
playlist::set_multi_select(&w, !on);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("playlist", "select-all") => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
playlist::select_all(&w);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
("playlist", "remove-selected") => {
|
||||||
|
if let Some(w) = weak.upgrade() {
|
||||||
|
let pid = w.global::<PlaylistState>().get_id().to_string();
|
||||||
|
let ids = playlist::selected_ids(&w);
|
||||||
|
if let (Ok(pid), false) = (pid.parse::<u64>(), ids.is_empty()) {
|
||||||
|
let runtime = runtime.clone();
|
||||||
|
let weak = weak.clone();
|
||||||
|
let handle = handle.clone();
|
||||||
|
let image_cache = image_cache.clone();
|
||||||
|
handle.clone().spawn(async move {
|
||||||
|
match runtime
|
||||||
|
.core()
|
||||||
|
.remove_tracks_from_playlist(pid, &ids)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(()) => {
|
||||||
|
// Reload the playlist + leave edit mode.
|
||||||
|
let _ = weak.upgrade_in_event_loop(|w| {
|
||||||
|
playlist::set_multi_select(&w, false);
|
||||||
|
});
|
||||||
|
navigate_playlist(
|
||||||
|
runtime.clone(),
|
||||||
|
weak.clone(),
|
||||||
|
&handle,
|
||||||
|
image_cache.clone(),
|
||||||
|
pid.to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(e) => log::error!(
|
||||||
|
"[qbz-slint] remove tracks from playlist failed: {e}"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
("playlist", "edit") => {
|
("playlist", "edit") => {
|
||||||
// Open the edit modal, prefilled from the open playlist.
|
// Open the edit modal, prefilled from the open playlist.
|
||||||
if let Some(w) = weak.upgrade() {
|
if let Some(w) = weak.upgrade() {
|
||||||
|
|||||||
@@ -204,3 +204,63 @@ pub fn index_of(track_id: &str) -> usize {
|
|||||||
.and_then(|c| c.iter().position(|t| t.id.to_string() == track_id))
|
.and_then(|c| c.iter().position(|t| t.id.to_string() == track_id))
|
||||||
.unwrap_or(0)
|
.unwrap_or(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Multi-select edit mode ====================
|
||||||
|
|
||||||
|
use slint::Model;
|
||||||
|
|
||||||
|
/// Recount selected rows into PlaylistState.selected-count.
|
||||||
|
pub fn recount_selected(window: &AppWindow) {
|
||||||
|
let model = window.global::<PlaylistState>().get_tracks();
|
||||||
|
let count = (0..model.row_count())
|
||||||
|
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
|
||||||
|
.count() as i32;
|
||||||
|
window.global::<PlaylistState>().set_selected_count(count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enter/leave edit mode. Leaving clears any selection.
|
||||||
|
pub fn set_multi_select(window: &AppWindow, on: bool) {
|
||||||
|
let state = window.global::<PlaylistState>();
|
||||||
|
if !on {
|
||||||
|
let model = state.get_tracks();
|
||||||
|
for i in 0..model.row_count() {
|
||||||
|
if let Some(mut item) = model.row_data(i) {
|
||||||
|
if item.selected {
|
||||||
|
item.selected = false;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.set_selected_count(0);
|
||||||
|
}
|
||||||
|
state.set_multi_select_mode(on);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle select-all: select every row, or clear if all are selected.
|
||||||
|
pub fn select_all(window: &AppWindow) {
|
||||||
|
let model = window.global::<PlaylistState>().get_tracks();
|
||||||
|
let total = model.row_count();
|
||||||
|
let selected = (0..total)
|
||||||
|
.filter(|&i| model.row_data(i).map(|t| t.selected).unwrap_or(false))
|
||||||
|
.count();
|
||||||
|
let target = selected != total; // if not all selected -> select all
|
||||||
|
for i in 0..total {
|
||||||
|
if let Some(mut item) = model.row_data(i) {
|
||||||
|
if item.selected != target {
|
||||||
|
item.selected = target;
|
||||||
|
model.set_row_data(i, item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recount_selected(window);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The ids of the currently-selected rows.
|
||||||
|
pub fn selected_ids(window: &AppWindow) -> Vec<u64> {
|
||||||
|
let model = window.global::<PlaylistState>().get_tracks();
|
||||||
|
(0..model.row_count())
|
||||||
|
.filter_map(|i| model.row_data(i))
|
||||||
|
.filter(|t| t.selected)
|
||||||
|
.filter_map(|t| t.id.parse::<u64>().ok())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
@@ -184,6 +184,17 @@ export component PlaylistView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Select tracks (multi-select edit mode).
|
||||||
|
if PlaylistState.is-owner && PlaylistState.tracks.length > 0: VerticalLayout {
|
||||||
|
alignment: center;
|
||||||
|
CircleAction {
|
||||||
|
icon: @image-url("../assets/icons/square-check-big.svg");
|
||||||
|
active: PlaylistState.multi-select-mode;
|
||||||
|
clicked => {
|
||||||
|
root.media-action("playlist", "", "select-toggle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,12 +259,77 @@ export component PlaylistView inherits Rectangle {
|
|||||||
}
|
}
|
||||||
Rectangle { height: 6px; }
|
Rectangle { height: 6px; }
|
||||||
|
|
||||||
|
// Bulk action bar — visible in edit mode.
|
||||||
|
if PlaylistState.multi-select-mode: Rectangle {
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: Theme.surface-elevated;
|
||||||
|
HorizontalLayout {
|
||||||
|
padding-left: 14px;
|
||||||
|
padding-right: 10px;
|
||||||
|
spacing: 12px;
|
||||||
|
Text {
|
||||||
|
text: PlaylistState.selected-count + " " + @tr("selected");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.body;
|
||||||
|
vertical-alignment: center;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
pill-all := Rectangle {
|
||||||
|
width: all-label.preferred-width + 24px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
background: all-ta.has-hover ? Theme.surface-hover : transparent;
|
||||||
|
all-label := Text {
|
||||||
|
text: @tr("Select all");
|
||||||
|
color: Theme.text-secondary;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
all-ta := TouchArea {
|
||||||
|
mouse-cursor: pointer;
|
||||||
|
clicked => {
|
||||||
|
root.media-action("playlist", "", "select-all");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pill-remove := Rectangle {
|
||||||
|
width: rm-label.preferred-width + 24px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: Radius.sm;
|
||||||
|
opacity: PlaylistState.selected-count > 0 ? 1.0 : 0.5;
|
||||||
|
background: rm-ta.has-hover ? #ef444433 : transparent;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: #ef4444;
|
||||||
|
rm-label := Text {
|
||||||
|
text: @tr("Remove");
|
||||||
|
color: #ef4444;
|
||||||
|
font-size: Typography.legal;
|
||||||
|
font-weight: Typography.medium;
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
}
|
||||||
|
rm-ta := TouchArea {
|
||||||
|
mouse-cursor: PlaylistState.selected-count > 0 ? pointer : default;
|
||||||
|
clicked => {
|
||||||
|
if (PlaylistState.selected-count > 0) {
|
||||||
|
root.media-action("playlist", "", "remove-selected");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if PlaylistState.multi-select-mode: Rectangle { height: 8px; }
|
||||||
|
|
||||||
for track[i] in PlaylistState.tracks: TrackRow {
|
for track[i] in PlaylistState.tracks: TrackRow {
|
||||||
track: track;
|
track: track;
|
||||||
index: i;
|
index: i;
|
||||||
show-artwork: true;
|
show-artwork: true;
|
||||||
show-album: true;
|
show-album: true;
|
||||||
show-favorite: true;
|
show-favorite: true;
|
||||||
|
multi-select-mode: PlaylistState.multi-select-mode;
|
||||||
media-action(kind, id, action) => {
|
media-action(kind, id, action) => {
|
||||||
if (action == "play") {
|
if (action == "play") {
|
||||||
root.play-track(id);
|
root.play-track(id);
|
||||||
|
|||||||
@@ -245,6 +245,10 @@ export global PlaylistState {
|
|||||||
in property <bool> is-favorite: false;
|
in property <bool> is-favorite: false;
|
||||||
// True when the playlist is one of the user's own (editable).
|
// True when the playlist is one of the user's own (editable).
|
||||||
in property <bool> is-owner: false;
|
in property <bool> is-owner: false;
|
||||||
|
// Track edit mode — rows show a selection checkbox and a bulk bar
|
||||||
|
// appears (remove selected). Only available to the owner.
|
||||||
|
in-out property <bool> multi-select-mode: false;
|
||||||
|
in property <int> selected-count: 0;
|
||||||
in property <[TrackItem]> tracks: [];
|
in property <[TrackItem]> tracks: [];
|
||||||
in property <bool> loading: false;
|
in property <bool> loading: false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user