990 lines
34 KiB
Rust
990 lines
34 KiB
Rust
//! 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<ConnectedDevicePopupRow> {
|
|
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<String>,
|
|
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<EditField>,
|
|
mut focus: usize,
|
|
error: Option<String>,
|
|
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<String, String> {
|
|
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<Option<i32>, String> {
|
|
let raw = value(label);
|
|
if raw.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
raw.parse::<i32>()
|
|
.map(Some)
|
|
.map_err(|_| format!("{label} must be a number"))
|
|
};
|
|
let names = |label: &str| -> Vec<String> {
|
|
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<TrackItem>,
|
|
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<TrackItem>,
|
|
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<crate::app::state::PlaylistAddTarget>,
|
|
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<i64> = tracks.iter().map(|t| t.id).filter(|id| *id >= 0).collect();
|
|
let fed_tracks: Vec<crate::federation::FedTrack> = 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<crate::app::state::PlaylistAddTarget>,
|
|
) {
|
|
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 });
|
|
});
|
|
}
|