//! Modal dialog input: the add-to-playlist picker, new-playlist name entry, //! metadata edit forms and delete confirmations. The popup is taken out of //! the state, handled as an owned value and put back unless the action //! closed it. use std::io::Write as _; use std::process::{Command, Stdio}; use std::sync::Arc; use crossterm::event::{KeyCode, KeyEvent}; use crate::app::Runtime; use crate::app::event::AppEvent; use crate::app::state::{ self, AppState, DeleteTarget, DevicePresenceSection, EditField, EditTarget, FedInputField, Loadable, Popup, addable_playlists, }; use crate::library::models::{ReleaseEdit, TrackEdit, TrackItem}; #[derive(Clone)] pub(crate) struct ConnectedDevicePopupRow { pub device_id: String, pub name: String, pub is_self: bool, pub online: bool, pub section: DevicePresenceSection, pub active: bool, pub playing: bool, pub paused: bool, pub queue_len: usize, } pub(crate) fn connected_device_rows(state: &AppState) -> Vec { let mut rows = Vec::new(); if let Some(status) = &state.federation.devices { let now = state::unix_time_ms(); for index in state::device_status_order(state) { let Some(device) = status.devices.get(index) else { continue; }; let snapshot = state.device_playback.remote.get(&device.device_id); let is_self = device.is_self || device.device_id == state.device_playback.self_device_id; let section = state::device_presence_section(state, device, now); let online = section == DevicePresenceSection::Online; rows.push(ConnectedDevicePopupRow { device_id: device.device_id.clone(), name: state::device_display_name(device), is_self, online, section, active: state::device_status_active(state, &device.device_id), playing: if is_self { state.player.playing } else { snapshot.is_some_and(|snapshot| snapshot.state.playing) }, paused: if is_self { state.player.paused } else { snapshot.is_some_and(|snapshot| snapshot.state.paused) }, queue_len: if is_self { state.player.queue.len() } else { snapshot .map(|snapshot| snapshot.state.queue.len()) .unwrap_or(0) }, }); } } if rows .iter() .all(|row| row.device_id != state.device_playback.self_device_id) { rows.insert( 0, ConnectedDevicePopupRow { device_id: state.device_playback.self_device_id.clone(), name: state.device_playback.self_device_name.clone(), is_self: true, online: true, section: DevicePresenceSection::Online, active: state.device_playback.role == crate::app::state::DevicePlaybackRole::Active, playing: state.player.playing, paused: state.player.paused, queue_len: state.player.queue.len(), }, ); } rows } pub fn handle_key(state: &mut AppState, runtime: &mut Runtime, key: KeyEvent) { let Some(popup) = state.popup.take() else { return; }; match popup { Popup::AddToPlaylist { target, cursor } => { handle_picker(state, runtime, target, cursor, key); } Popup::NewPlaylist { for_target, input, busy, } => handle_name_entry(state, runtime, for_target, input, busy, key), Popup::Edit { target, title, fields, focus, error, } => handle_edit(state, runtime, target, title, fields, focus, error, key), Popup::ConfirmDelete { target, label } => { handle_confirm_delete(state, runtime, target, label, key); } Popup::LibraryFilters { cursor } => handle_library_filters(state, runtime, cursor, key), Popup::TrackInfo { tracks, cursor, scroll, } => handle_track_info(state, runtime, tracks, cursor, scroll, key), Popup::TrackArtists { tracks, cursor, scroll, selected, } => handle_track_artists(state, runtime, tracks, cursor, scroll, selected, key), Popup::LogDetail(entry) => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::LogDetail(entry)), }, Popup::FedInput { field, input } => handle_fed_input(state, runtime, field, input, key), Popup::FedText { title, text } => match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} _ => state.popup = Some(Popup::FedText { title, text }), }, Popup::FederationStatusDetails { scroll } => { handle_federation_status_details(state, scroll, key); } Popup::DevicePairing { request_id, device_id, name, client_version, requester_group_id, requester_group_active_devices, } => handle_device_pairing( state, runtime, request_id, device_id, name, client_version, requester_group_id, requester_group_active_devices, key, ), Popup::ConfirmDeviceRevoke { device_id, name } => { handle_device_revoke(state, runtime, device_id, name, key); } Popup::ConnectedDevices { cursor } => { handle_connected_devices(state, runtime, cursor, key); } } } fn handle_federation_status_details(state: &mut AppState, scroll: usize, key: KeyEvent) { let next_scroll = match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => return, KeyCode::Up | KeyCode::Char('k') => scroll.saturating_sub(1), KeyCode::Down | KeyCode::Char('j') => scroll + 1, KeyCode::PageUp => scroll.saturating_sub(8), KeyCode::PageDown => scroll + 8, _ => scroll, }; state.popup = Some(Popup::FederationStatusDetails { scroll: next_scroll, }); } fn handle_connected_devices( state: &mut AppState, runtime: &mut Runtime, cursor: usize, key: KeyEvent, ) { let rows = connected_device_rows(state); let other_rows: Vec<_> = rows.iter().filter(|row| !row.is_self).cloned().collect(); let last = other_rows.len(); let cursor = cursor.min(last); match key.code { KeyCode::Esc | KeyCode::Char('q') => {} KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::ConnectedDevices { cursor: cursor.saturating_sub(1), }); } KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::ConnectedDevices { cursor: (cursor + 1).min(last), }); } KeyCode::Enter => { if cursor == 0 { super::transfer_active_to_this_device(state, runtime); state.status_message = Some("active playback moved to this device".into()); } else if let Some(row) = other_rows.get(cursor.saturating_sub(1).min(last)) { if row.active && let Some(snapshot) = state.device_playback.remote.get(&row.device_id).cloned() { super::become_control_device(state, runtime, snapshot); } else { super::transfer_active_to_remote_device( state, runtime, row.device_id.clone(), row.name.clone(), ); } } } _ => state.popup = Some(Popup::ConnectedDevices { cursor }), } } fn handle_library_filters(state: &mut AppState, runtime: &Runtime, cursor: usize, key: KeyEvent) { match key.code { KeyCode::Esc | KeyCode::Char('q') => {} KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::LibraryFilters { cursor: 0 }); } KeyCode::Enter | KeyCode::Char(' ') => { state.global.filters.hide_featured_only = !state.global.filters.hide_featured_only; super::save_app_settings(state); super::reset_artist_pagination(state); super::refresh_artists(state, runtime); state.popup = Some(Popup::LibraryFilters { cursor }); } _ => state.popup = Some(Popup::LibraryFilters { cursor }), } } /// One-line text entry on the Federation tab (network id / peer ticket). fn handle_fed_input( state: &mut AppState, runtime: &mut Runtime, field: FedInputField, mut input: crate::app::input::LineEdit, key: KeyEvent, ) { match key.code { KeyCode::Esc => {} KeyCode::Enter => { let value = input.trim().to_string(); match field { FedInputField::NetworkId => { state.federation.settings.network_id = value; // An empty id turns federation off rather than leaving a // node bound to an unnamed network; a freshly set id // enables it right away. state.federation.settings.enabled = !state.federation.settings.network_id.is_empty(); super::fed_apply_settings(state, runtime); } FedInputField::ConnectTicket => { if value.is_empty() { state.status_message = Some("ticket is empty".into()); } else { super::fed_connect(runtime, value); } } FedInputField::DeviceName => { if value.is_empty() { state.status_message = Some("device name is empty".into()); } else { super::perform_effect( state, runtime, crate::app::update::Effect::DeviceSetName(value), ); } } FedInputField::ConnectInvite => { if value.is_empty() { state.status_message = Some("invite is empty".into()); } else { super::perform_effect( state, runtime, crate::app::update::Effect::DeviceConnectInvite(value), ); } } } } _ => { input.handle_key(key); state.popup = Some(Popup::FedInput { field, input }); } } } fn handle_device_pairing( state: &mut AppState, runtime: &Runtime, request_id: String, device_id: String, name: String, client_version: String, requester_group_id: Option, requester_group_active_devices: usize, key: KeyEvent, ) { let group_conflict = requester_group_id.is_some() && requester_group_active_devices > 1; match key.code { KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => { if let Err(err) = runtime.devices.answer_pairing(&request_id, false, false) { state.status_message = Some(format!("pairing: {err:#}")); } else { state.status_message = Some("device pairing denied".to_string()); } state.federation.devices = Some(runtime.devices.status()); } KeyCode::Char('y') => { if let Err(err) = runtime .devices .answer_pairing(&request_id, true, group_conflict) { state.status_message = Some(format!("pairing: {err:#}")); } else { state.status_message = Some(if group_conflict { format!("device \"{name}\" accepted; joining its sync group") } else { format!("device \"{name}\" accepted") }); } state.federation.devices = Some(runtime.devices.status()); } KeyCode::Char('c') if group_conflict => { if let Err(err) = runtime.devices.answer_pairing(&request_id, true, false) { state.status_message = Some(format!("pairing: {err:#}")); } else { state.status_message = Some(format!("device \"{name}\" accepted into this sync group")); } state.federation.devices = Some(runtime.devices.status()); } _ => { state.popup = Some(Popup::DevicePairing { request_id, device_id, name, client_version, requester_group_id, requester_group_active_devices, }); } } } fn handle_device_revoke( state: &mut AppState, runtime: &mut Runtime, device_id: String, name: String, key: KeyEvent, ) { match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('n') | KeyCode::Char('q') => {} KeyCode::Char('y') => { super::perform_effect( state, runtime, crate::app::update::Effect::DeviceRevoke(device_id), ); } _ => state.popup = Some(Popup::ConfirmDeviceRevoke { device_id, name }), } } /// Pasted text goes into the focused text field when one is open. pub fn handle_paste(state: &mut AppState, pasted: &str) { let cleaned: String = pasted.chars().filter(|c| !c.is_control()).collect(); match &mut state.popup { Some(Popup::NewPlaylist { input, busy, .. }) if !*busy => input.insert_str(&cleaned), Some(Popup::FedInput { input, .. }) => input.insert_str(&cleaned), Some(Popup::Edit { fields, focus, .. }) => { if let Some(field) = fields.get_mut(*focus) { field.value.insert_str(&cleaned); } } _ => {} } } // --------------------------------------------------------------------------- // Edit form // --------------------------------------------------------------------------- #[allow( clippy::too_many_arguments, reason = "owned popup state passed back in" )] fn handle_edit( state: &mut AppState, runtime: &Runtime, target: EditTarget, title: String, mut fields: Vec, mut focus: usize, error: Option, key: KeyEvent, ) { match key.code { KeyCode::Esc => return, KeyCode::Enter => { match save_edit(runtime, target, &fields) { Ok(message) => { state.status_message = Some(message); return; } Err(message) => { state.popup = Some(Popup::Edit { target, title, fields, focus, error: Some(message), }); return; } }; } KeyCode::Tab | KeyCode::Down => focus = (focus + 1) % fields.len().max(1), KeyCode::BackTab | KeyCode::Up => { let len = fields.len().max(1); focus = (focus + len - 1) % len; } _ => { if let Some(field) = fields.get_mut(focus) { field.value.handle_key(key); } } } state.popup = Some(Popup::Edit { target, title, fields, focus, error, }); } /// Validate the form and write it to the library. Returns the status /// message on success, the error text to show in the form otherwise. fn save_edit( runtime: &Runtime, target: EditTarget, fields: &[EditField], ) -> Result { let value = |label: &str| { fields .iter() .find(|field| field.label == label) .map(|field| field.value.trim().to_string()) .unwrap_or_default() }; let number = |label: &str| -> Result, String> { let raw = value(label); if raw.is_empty() { return Ok(None); } raw.parse::() .map(Some) .map_err(|_| format!("{label} must be a number")) }; let names = |label: &str| -> Vec { value(label) .split(';') .map(|name| name.trim().to_string()) .filter(|name| !name.is_empty()) .collect() }; let library = Arc::clone(&runtime.library); let result = match target { EditTarget::Track(id) => { let title = value("Title"); if title.is_empty() { return Err("title is empty".to_string()); } let artists = names("Artists"); if artists.is_empty() { return Err("at least one artist is required".to_string()); } let cover_path = value("Cover path"); let edit = TrackEdit { title, artists, featured_artists: names("Featured"), track_number: number("Track #")?, disc_number: number("Disc #")?, cover_path: (!cover_path.is_empty()).then_some(cover_path), }; library.update_track(id, &edit) } EditTarget::Release(id) => { let title = value("Title"); if title.is_empty() { return Err("title is empty".to_string()); } let release_type = value("Type").to_lowercase(); let release_type = if release_type.is_empty() { "album".to_string() } else { release_type }; let edit = ReleaseEdit { title, release_type, year: number("Year")?, artists: Vec::new(), }; library.update_release(id, &edit) } EditTarget::Artist(id) => { let name = value("Name"); if name.is_empty() { return Err("name is empty".to_string()); } let image = value("Image path"); let image = (!image.is_empty()).then_some(image); library.update_artist(id, &name, image.as_deref()) } EditTarget::Playlist(id) => { let title = value("Title"); if title.is_empty() { return Err("title is empty".to_string()); } let result = library.update_playlist(id, &title, None); if result.is_ok() && let Err(err) = runtime.devices.record_playlist_renamed(id, &title) { tracing::warn!(%err, playlist = id, "recording synced playlist rename failed"); } result } }; match result { Ok(()) => { let _ = runtime.event_tx.send(AppEvent::LibraryChanged { message: Some("saved".to_string()), }); Ok("saved".to_string()) } Err(err) => Err(format!("{err:#}")), } } // --------------------------------------------------------------------------- // Delete confirmation // --------------------------------------------------------------------------- fn handle_confirm_delete( state: &mut AppState, runtime: &Runtime, target: DeleteTarget, label: String, key: KeyEvent, ) { match key.code { KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('q') => {} KeyCode::Enter | KeyCode::Char('y') => { let library = Arc::clone(&runtime.library); let result = match target { DeleteTarget::Track(id) => library.delete_track(id), DeleteTarget::Release(id) => library.delete_release(id), DeleteTarget::Artist(id) => library.delete_artist(id), DeleteTarget::Playlist(id) => match runtime.devices.record_playlist_deleted(id) { Ok(()) => library.delete_playlist(id), Err(err) => { tracing::warn!(%err, playlist = id, "recording synced playlist deletion failed"); library.delete_playlist(id) } }, }; match result { Ok(()) => { let _ = runtime.event_tx.send(AppEvent::LibraryChanged { message: Some(format!("deleted {label}")), }); } Err(err) => state.status_message = Some(format!("delete failed: {err:#}")), } } _ => state.popup = Some(Popup::ConfirmDelete { target, label }), } } // --------------------------------------------------------------------------- // Track info // --------------------------------------------------------------------------- fn handle_track_info( state: &mut AppState, runtime: &mut Runtime, tracks: Vec, cursor: usize, scroll: usize, key: KeyEvent, ) { let len = tracks.len(); match key.code { KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {} KeyCode::Char('a') => { let artists = tracks .get(cursor.min(len.saturating_sub(1))) .map(crate::app::update::track_artist_refs) .unwrap_or_default(); match artists.len() { 0 => { state.status_message = Some("this track has no artists".into()); state.popup = Some(Popup::TrackInfo { tracks, cursor, scroll, }); } // A single artist opens directly; the popup closes. 1 => open_artist(state, runtime, &artists[0]), _ => { state.popup = Some(Popup::TrackArtists { tracks, cursor, scroll, selected: 0, }); } } } KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::TrackInfo { tracks, cursor, scroll: scroll.saturating_sub(1), }); } KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::TrackInfo { tracks, cursor, scroll: scroll + 1, }); } KeyCode::Left | KeyCode::Char('h') => { state.popup = Some(Popup::TrackInfo { tracks, cursor: cursor.saturating_sub(1), scroll: 0, }); } KeyCode::Right | KeyCode::Char('l') => { state.popup = Some(Popup::TrackInfo { tracks, cursor: if len == 0 { 0 } else { (cursor + 1).min(len - 1) }, scroll: 0, }); } KeyCode::Char('c') => { if let Some(track) = tracks.get(cursor.min(len.saturating_sub(1))) { match crate::share::track_share_link(track) { Some(link) => match copy_to_clipboard(&link) { Ok(()) => state.status_message = Some("frid link copied".into()), Err(err) => state.status_message = Some(format!("copy failed: {err}")), }, None => state.status_message = Some("no content id for this track yet".into()), } } state.popup = Some(Popup::TrackInfo { tracks, cursor: cursor.min(len.saturating_sub(1)), scroll, }); } _ => { state.popup = Some(Popup::TrackInfo { tracks, cursor: cursor.min(len.saturating_sub(1)), scroll, }); } } } /// The artist picker over the track info: j/k choose, Enter jumps to the /// artist page, Esc returns to the info view. fn handle_track_artists( state: &mut AppState, runtime: &mut Runtime, tracks: Vec, cursor: usize, scroll: usize, selected: usize, key: KeyEvent, ) { let artists = tracks .get(cursor.min(tracks.len().saturating_sub(1))) .map(crate::app::update::track_artist_refs) .unwrap_or_default(); if artists.is_empty() { state.popup = Some(Popup::TrackInfo { tracks, cursor, scroll, }); return; } let last = artists.len() - 1; match key.code { KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('a') => { state.popup = Some(Popup::TrackInfo { tracks, cursor, scroll, }); } KeyCode::Enter => open_artist(state, runtime, &artists[selected.min(last)]), KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::TrackArtists { tracks, cursor, scroll, selected: selected.saturating_sub(1), }); } KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::TrackArtists { tracks, cursor, scroll, selected: (selected + 1).min(last), }); } _ => { state.popup = Some(Popup::TrackArtists { tracks, cursor, scroll, selected: selected.min(last), }); } } } /// Closes the popup and jumps to the artist's page (local or federated). fn open_artist( state: &mut AppState, runtime: &mut Runtime, artist: &crate::library::models::ArtistRef, ) { state.track_selection.clear(); if let Some(effect) = crate::app::update::open_artist_ref(state, artist) { super::perform_effect(state, runtime, effect); } } fn copy_to_clipboard(text: &str) -> Result<(), String> { #[cfg(target_os = "macos")] { return run_clipboard_command("pbcopy", &[], text); } #[cfg(target_os = "windows")] { return run_clipboard_command("cmd", &["/C", "clip"], text); } #[cfg(all(unix, not(target_os = "macos")))] { for (program, args) in [ ("wl-copy", &[][..]), ("xclip", &["-selection", "clipboard"][..]), ("xsel", &["--clipboard", "--input"][..]), ] { if run_clipboard_command(program, args, text).is_ok() { return Ok(()); } } Err("clipboard command not found (tried wl-copy, xclip, xsel)".into()) } } fn run_clipboard_command(program: &str, args: &[&str], text: &str) -> Result<(), String> { let mut child = Command::new(program) .args(args) .stdin(Stdio::piped()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn() .map_err(|err| format!("{program}: {err}"))?; let Some(stdin) = child.stdin.as_mut() else { return Err(format!("{program}: stdin unavailable")); }; stdin .write_all(text.as_bytes()) .map_err(|err| format!("{program}: {err}"))?; drop(child.stdin.take()); let status = child.wait().map_err(|err| format!("{program}: {err}"))?; if status.success() { Ok(()) } else { Err(format!("{program}: exited with {status}")) } } // --------------------------------------------------------------------------- // Add-to-playlist picker & new playlist // --------------------------------------------------------------------------- fn handle_picker( state: &mut AppState, runtime: &Runtime, target: crate::app::state::PlaylistAddTarget, cursor: usize, key: KeyEvent, ) { let waiting = matches!(&state.playlists.list, None | Some(Loadable::Loading)); let options = addable_playlists(state); let new_index = options.len(); match key.code { KeyCode::Esc => {} KeyCode::Up | KeyCode::Char('k') => { state.popup = Some(Popup::AddToPlaylist { target, cursor: cursor.saturating_sub(1), }); } KeyCode::Down | KeyCode::Char('j') => { state.popup = Some(Popup::AddToPlaylist { target, cursor: (cursor + 1).min(new_index), }); } KeyCode::Enter => { if waiting { state.status_message = Some("loading playlists…".into()); state.popup = Some(Popup::AddToPlaylist { target, cursor }); } else if cursor < options.len() { if let Some((id, title)) = options.get(cursor).cloned() { spawn_add_target(runtime, id, title, target); } } else { state.popup = Some(Popup::NewPlaylist { for_target: Some(target), input: crate::app::input::LineEdit::default(), busy: false, }); } } _ => state.popup = Some(Popup::AddToPlaylist { target, cursor }), } } fn handle_name_entry( state: &mut AppState, runtime: &Runtime, for_target: Option, mut input: crate::app::input::LineEdit, busy: bool, key: KeyEvent, ) { if busy { state.popup = Some(Popup::NewPlaylist { for_target, input, busy, }); return; } match key.code { KeyCode::Esc => { // Reached from the picker → step back to it; otherwise close. if let Some(target) = for_target { let cursor = addable_playlists(state).len(); state.popup = Some(Popup::AddToPlaylist { target, cursor }); } } KeyCode::Enter => { let title = input.trim().to_string(); if title.is_empty() { state.status_message = Some("playlist name is empty".into()); state.popup = Some(Popup::NewPlaylist { for_target, input, busy: false, }); return; } spawn_create_playlist(runtime, title, for_target.clone()); state.popup = Some(Popup::NewPlaylist { for_target, input, busy: true, }); } _ => { input.handle_key(key); state.popup = Some(Popup::NewPlaylist { for_target, input, busy: false, }); } } } /// Adds a target to a playlist: local tracks directly; federated ones are /// downloaded into the library first, then linked. pub(crate) fn spawn_add_target( runtime: &Runtime, playlist_id: i64, playlist_title: String, target: crate::app::state::PlaylistAddTarget, ) { match target { crate::app::state::PlaylistAddTarget::Local(tracks) => { let library = Arc::clone(&runtime.library); let devices = Arc::clone(&runtime.devices); let tx = runtime.event_tx.clone(); let ids: Vec = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect(); let fed_tracks: Vec = tracks .iter() .filter(|track| track.is_fed_pending()) .filter_map(|track| track.fed.clone()) .collect(); tokio::task::spawn_blocking(move || { let result = library .add_tracks_to_playlist(playlist_id, &ids) .and_then(|()| library.add_fed_tracks_to_playlist(playlist_id, &fed_tracks)) .map_err(|err| format!("{err:#}")); if result.is_ok() && let Err(err) = devices.record_playlist_tracks_added(playlist_id, &ids) { tracing::warn!(%err, playlist_id, "recording synced playlist add failed"); } if result.is_ok() && let Err(err) = devices.record_playlist_fed_tracks_added(playlist_id, &fed_tracks) { tracing::warn!(%err, playlist_id, "recording synced federated playlist add failed"); } let _ = tx.send(AppEvent::PlaylistTracksAdded { playlist_id, playlist_title, result, }); }); } crate::app::state::PlaylistAddTarget::Fed(tracks) => { super::fed_download_spawn(runtime, tracks, Some((playlist_id, playlist_title))); } } } fn spawn_create_playlist( runtime: &Runtime, title: String, add_target: Option, ) { let library = Arc::clone(&runtime.library); let devices = Arc::clone(&runtime.devices); let tx = runtime.event_tx.clone(); tokio::task::spawn_blocking(move || { let result = library .create_playlist(&title) .map_err(|err| format!("{err:#}")); if let Ok(playlist) = &result && let Err(err) = devices.record_playlist_created(playlist.id, &playlist.title) { tracing::warn!(%err, playlist = playlist.id, "recording synced playlist creation failed"); } let _ = tx.send(AppEvent::PlaylistCreated { result, add_target }); }); }