From f1eb55801f2309b19a85798e492d581a2b329cd9 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Sat, 10 Oct 2026 18:13:30 +0200 Subject: [PATCH] (add): add confirmation for deleting playlists + fix playlist name overwriting --- src/app.rs | 178 +++++++++++++++++-------------------------------- src/config.rs | 39 +++-------- src/model.rs | 10 +-- src/player.rs | 28 +++----- src/session.rs | 27 ++------ src/store.rs | 48 ++++++++----- src/ui.rs | 162 +++++++++++--------------------------------- src/ytmusic.rs | 22 ++---- 8 files changed, 161 insertions(+), 353 deletions(-) diff --git a/src/app.rs b/src/app.rs index 9be0ff9..a3bf50a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -48,6 +48,7 @@ pub enum Mode { Normal, Command, ArtistPick, + ConfirmDelete, Help, } @@ -86,6 +87,7 @@ pub struct App { pub cmd_buf: String, pub artist_opts: Vec, pub artist_sel: usize, + pub pending_delete: Option, pub help_scroll: usize, history: Vec, hist_pos: Option, @@ -148,6 +150,7 @@ impl App { cmd_buf: String::new(), artist_opts: Vec::new(), artist_sel: 0, + pending_delete: None, help_scroll: 0, history: Vec::new(), hist_pos: None, @@ -230,17 +233,13 @@ impl App { } fn set_status(&mut self, text: String, level: Level) { - self.status = Some(Status { - text, - level, - at: Instant::now(), - }); + self.status = Some(Status { text, level, at: Instant::now() }); } pub fn visible_status(&self) -> Option<&Status> { - self.status - .as_ref() - .filter(|s| s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 }) + self.status.as_ref().filter(|s| { + s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 } + }) } pub fn on_tick(&mut self) { @@ -267,17 +266,11 @@ impl App { pub fn in_playlist(&self) -> bool { self.view == View::Playlists - && self - .open - .as_ref() - .is_some_and(|n| self.store.playlists.contains_key(n)) + && self.open.as_ref().is_some_and(|n| self.store.playlists.contains_key(n)) } pub fn open_len(&self) -> usize { - self.open - .as_ref() - .and_then(|n| self.store.playlists.get(n)) - .map_or(0, |t| t.len()) + self.open.as_ref().and_then(|n| self.store.playlists.get(n)).map_or(0, |t| t.len()) } fn cur_len(&self) -> usize { @@ -347,6 +340,7 @@ impl App { Mode::Help => self.key_help(key), Mode::Command => self.key_command(key), Mode::ArtistPick => self.key_artist_pick(key), + Mode::ConfirmDelete => self.key_confirm_delete(key), Mode::Normal => self.key_normal(key), } } @@ -425,12 +419,8 @@ impl App { KeyCode::Char(' ') => self.toggle_pause(), KeyCode::Char('n') | KeyCode::Char('>') => self.next(), KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), - KeyCode::Char('h') | KeyCode::Left => { - self.seek(Amount::Delta(-self.seek_step * times as f64)) - } - KeyCode::Char('l') | KeyCode::Right => { - self.seek(Amount::Delta(self.seek_step * times as f64)) - } + KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step * times as f64)), + KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step * times as f64)), KeyCode::Char('+') | KeyCode::Char('=') => { self.set_volume(Amount::Delta(self.volume_step * times as f64)) } @@ -461,6 +451,14 @@ impl App { } } + fn key_confirm_delete(&mut self, key: KeyEvent) { + self.mode = Mode::Normal; + let name = self.pending_delete.take(); + if let (KeyCode::Char('y'), Some(name)) = (key.code, name) { + self.delete_playlist(&name); + } + } + fn open_help(&mut self) { self.help_scroll = 0; self.mode = Mode::Help; @@ -680,9 +678,7 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_artist_pick(), - KeyCode::Char('j') | KeyCode::Down => { - self.artist_sel = (self.artist_sel + 1).min(len - 1) - } + KeyCode::Char('j') | KeyCode::Down => self.artist_sel = (self.artist_sel + 1).min(len - 1), KeyCode::Char('k') | KeyCode::Up => self.artist_sel = self.artist_sel.saturating_sub(1), KeyCode::Char(c @ '1'..='9') => { let i = c as usize - '1' as usize; @@ -720,28 +716,22 @@ impl App { .into_iter() .filter(|t| { t.artist.to_lowercase() == wanted - || split_artists(&t.artist) - .iter() - .any(|a| a.to_lowercase() == wanted) + || split_artists(&t.artist).iter().any(|a| a.to_lowercase() == wanted) }) .collect()) }); } fn start_search(&mut self, query: String) { - self.spawn_loader( - format!("Search: {query}"), - format!("Searching \u{201c}{query}\u{201d}"), - move || ytmusic::search(&query), - ); + self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || { + ytmusic::search(&query) + }); } fn start_import(&mut self, url: String) { - self.spawn_loader( - "Imported playlist".into(), - "Importing playlist".into(), - move || ytmusic::import(&url), - ); + self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || { + ytmusic::import(&url) + }); } fn spawn_loader(&mut self, title: String, busy: String, work: F) @@ -853,9 +843,7 @@ impl App { "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" )); } else { - self.error(format!( - "Can't play \u{201c}{name}\u{201d} ({msg}) — skipping" - )); + self.error(format!("Can't play \u{201c}{name}\u{201d} ({msg}) — skipping")); self.advance(true); } } @@ -874,9 +862,7 @@ impl App { } fn start_track(&mut self, i: usize, keep_resume: bool) { - let Some(track) = self.queue.get(i).cloned() else { - return; - }; + let Some(track) = self.queue.get(i).cloned() else { return }; if !keep_resume { self.resume = None; } @@ -967,9 +953,7 @@ impl App { } fn play_radio(&mut self, i: usize) { - let Some(track) = self.radio.get(i).cloned() else { - return; - }; + let Some(track) = self.radio.get(i).cloned() else { return }; self.on_radio = true; self.result_cursor = Some(i); self.resume = None; @@ -994,11 +978,7 @@ impl App { } match self.with_player(|p| p.set_pause(pause)) { Ok(()) => { - self.play_state = if pause { - PlayState::Paused - } else { - PlayState::Playing - }; + self.play_state = if pause { PlayState::Paused } else { PlayState::Playing }; } Err(e) => self.error(format!("{e:#}")), } @@ -1162,10 +1142,7 @@ impl App { if let Some(c) = self.current { self.played.insert(c); } - self.info(format!( - "Shuffle: {}", - if self.shuffle { "on" } else { "off" } - )); + self.info(format!("Shuffle: {}", if self.shuffle { "on" } else { "off" })); } fn cycle_repeat(&mut self) { @@ -1219,11 +1196,7 @@ impl App { if self.results.is_empty() { return self.error("No results to play"); } - let start = if self.view == View::Results { - self.selected() - } else { - 0 - }; + let start = if self.view == View::Results { self.selected() } else { 0 }; self.queue = self.results.clone(); self.played.clear(); self.current = None; @@ -1266,11 +1239,7 @@ impl App { } let sel = self.states[View::Queue.idx()].selected().unwrap_or(0); let len = self.queue.len(); - self.states[View::Queue.idx()].select(if len == 0 { - None - } else { - Some(sel.min(len - 1)) - }); + self.states[View::Queue.idx()].select(if len == 0 { None } else { Some(sel.min(len - 1)) }); self.info(format!("Removed: {}", removed.label())); } @@ -1300,13 +1269,12 @@ impl App { View::Queue => self.remove_from_queue(i), View::Playlists if self.in_playlist() => self.remove_playlist_track(i), View::Playlists => { - if let Some(n) = self.playlist_name(i) { - self.delete_playlist(&n) + if let Some(name) = self.playlist_name(i) { + self.pending_delete = Some(name); + self.mode = Mode::ConfirmDelete; } } - View::Results => { - self.info("Results can't be deleted — they are replaced by the next search") - } + View::Results => self.info("Results can't be deleted — they are replaced by the next search"), } } @@ -1325,13 +1293,9 @@ impl App { return self.error("Queue is empty — nothing to save"); } let n = self.queue.len(); - self.store - .playlists - .insert(name.to_string(), self.queue.clone()); + self.store.playlists.insert(name.to_string(), self.queue.clone()); if self.persist() { - self.info(format!( - "Saved {n} tracks to playlist \u{201c}{name}\u{201d}" - )); + self.info(format!("Saved {n} tracks to playlist \u{201c}{name}\u{201d}")); } } @@ -1350,7 +1314,7 @@ impl App { let Some(track) = track else { return self.error("Select a track in Results or Queue first"); }; - let key = self.store.resolve(name).unwrap_or_else(|| name.to_string()); + let key = self.store.resolve_exact(name).unwrap_or_else(|| name.to_string()); let list = self.store.playlists.entry(key.clone()).or_default(); if list.iter().any(|t| t.id == track.id) { return self.info(format!("Already in \u{201c}{key}\u{201d}")); @@ -1377,14 +1341,10 @@ impl App { self.result_cursor = None; self.states[View::Queue.idx()].select(Some(0)); self.play_index(0); - self.info(format!( - "Playing playlist \u{201c}{name}\u{201d} ({n} tracks)" - )); + self.info(format!("Playing playlist \u{201c}{name}\u{201d} ({n} tracks)")); } else { self.queue.extend(tracks); - self.info(format!( - "Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue" - )); + self.info(format!("Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue")); } } @@ -1418,13 +1378,9 @@ impl App { } fn remove_playlist_track(&mut self, i: usize) { - let Some(name) = self.open.clone() else { - return; - }; + let Some(name) = self.open.clone() else { return }; let removed = { - let Some(list) = self.store.playlists.get_mut(&name) else { - return; - }; + let Some(list) = self.store.playlists.get_mut(&name) else { return }; if i >= list.len() { None } else { @@ -1435,13 +1391,9 @@ impl App { let Some((track, len)) = removed else { return self.error("No track selected"); }; - self.pl_state - .select(if len == 0 { None } else { Some(i.min(len - 1)) }); + self.pl_state.select(if len == 0 { None } else { Some(i.min(len - 1)) }); if self.persist() { - self.info(format!( - "Removed from \u{201c}{name}\u{201d}: {}", - track.label() - )); + self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label())); } } @@ -1453,9 +1405,7 @@ impl App { } fn move_playlist_track(&mut self, dir: isize) { - let Some(name) = self.open.clone() else { - return; - }; + let Some(name) = self.open.clone() else { return }; let i = self.selected(); let j = i as isize + dir; let moved = match self.store.playlists.get_mut(&name) { @@ -1530,15 +1480,9 @@ mod tests { let mut app = App::new(tx); app.store.playlists.insert( "mix".into(), - vec![ - track("aaaaaaaaaaa", "A"), - track("bbbbbbbbbbb", "B"), - track("ccccccccccc", "C"), - ], + vec![track("aaaaaaaaaaa", "A"), track("bbbbbbbbbbb", "B"), track("ccccccccccc", "C")], ); - app.store - .playlists - .insert("other".into(), vec![track("ddddddddddd", "D")]); + app.store.playlists.insert("other".into(), vec![track("ddddddddddd", "D")]); press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab); @@ -1557,18 +1501,12 @@ mod tests { assert_eq!(app.selected(), 2); press(&mut app, KeyCode::Char('K')); - let ids: Vec<&str> = app.store.playlists["mix"] - .iter() - .map(|t| t.id.as_str()) - .collect(); + let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); assert_eq!(ids, ["aaaaaaaaaaa", "ccccccccccc", "bbbbbbbbbbb"]); assert_eq!(app.selected(), 1); press(&mut app, KeyCode::Char('x')); - let ids: Vec<&str> = app.store.playlists["mix"] - .iter() - .map(|t| t.id.as_str()) - .collect(); + let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); assert_eq!(ids, ["aaaaaaaaaaa", "bbbbbbbbbbb"]); assert_eq!(app.selected(), 1); @@ -1597,6 +1535,16 @@ mod tests { press(&mut app, KeyCode::Tab); assert_eq!(app.open, None); + app.set_view(View::Playlists); + press(&mut app, KeyCode::Char('x')); + assert!(app.mode == Mode::ConfirmDelete); + press(&mut app, KeyCode::Char('n')); + assert!(app.mode == Mode::Normal); + assert_eq!(app.store.playlists.len(), 2); + press(&mut app, KeyCode::Char('x')); + press(&mut app, KeyCode::Char('y')); + assert_eq!(app.store.playlists.len(), 1); + check_artist_popup(&mut app); app.run_command("remove"); diff --git a/src/config.rs b/src/config.rs index cd1577c..d06b9a9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -99,10 +99,7 @@ volume_step = {vstep:?} return (Config::default(), None); } Err(e) => { - return ( - Config::default(), - Some(format!("cannot read {}: {e}", path.display())), - ) + return (Config::default(), Some(format!("cannot read {}: {e}", path.display()))) } }; match toml::from_str::(&text) { @@ -140,20 +137,12 @@ pub fn migrate_legacy() { } let Ok(path) = Config::path() else { return }; - let Ok(text) = fs::read_to_string(&path) else { - return; - }; - let Ok(table) = text.parse::() else { - return; - }; - if table.contains_key("playback") - || !(table.contains_key("queue") || table.contains_key("position")) - { + let Ok(text) = fs::read_to_string(&path) else { return }; + let Ok(table) = text.parse::() else { return }; + if table.contains_key("playback") || !(table.contains_key("queue") || table.contains_key("position")) { return; } - let Ok(old) = toml::from_str::(&text) else { - return; - }; + let Ok(old) = toml::from_str::(&text) else { return }; let d = Playback::default(); let mut p = Playback { @@ -164,11 +153,7 @@ pub fn migrate_legacy() { ..d }; p.sanitize(); - let session = Session { - queue: old.queue, - current: old.current, - position: old.position, - }; + let session = Session { queue: old.queue, current: old.current, position: old.position }; if let Ok(s) = session.render() { if !Session::exists() { let _ = Session::write(&s); @@ -198,22 +183,14 @@ mod tests { assert_eq!(c.playback.volume, 80); let c: Config = toml::from_str("[playback]\nvolume = 10\nrepeat = \"one\"\nseek_step = 10").unwrap(); - assert_eq!( - (c.playback.volume, c.playback.repeat), - (10, RepeatMode::One) - ); + assert_eq!((c.playback.volume, c.playback.repeat), (10, RepeatMode::One)); assert_eq!(c.playback.seek_step, 10.0); assert!(toml::from_str::("[playback]\nrepeat = \"sometimes\"").is_err()); } #[test] fn sanitize_bad_values() { - let mut p = Playback { - volume: 250, - seek_step: -3.0, - volume_step: 900.0, - ..Playback::default() - }; + let mut p = Playback { volume: 250, seek_step: -3.0, volume_step: 900.0, ..Playback::default() }; p.sanitize(); assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); } diff --git a/src/model.rs b/src/model.rs index 685c42c..daa24a8 100644 --- a/src/model.rs +++ b/src/model.rs @@ -24,9 +24,7 @@ impl Track { pub fn split_artists(credit: &str) -> Vec { const MARK: char = '\u{1}'; let mut s = credit.to_string(); - for sep in [ - " & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ", - ] { + for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] { s = s.replace(sep, &MARK.to_string()); } let mut out: Vec = Vec::new(); @@ -63,11 +61,7 @@ pub fn parse_clock(s: &str) -> Option { } pub fn fmt_time(secs: f64) -> String { - let s = if secs.is_finite() { - secs.max(0.0) as u64 - } else { - 0 - }; + let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 }; let (h, m, s) = (s / 3600, (s % 3600) / 60, s % 60); if h > 0 { format!("{h}:{m:02}:{s:02}") diff --git a/src/player.rs b/src/player.rs index 37806af..a7d9995 100644 --- a/src/player.rs +++ b/src/player.rs @@ -26,7 +26,10 @@ pub struct Player { } impl Player { - pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result { + pub fn spawn( + volume: u8, + on_event: impl Fn(PlayerEvent) + Send + 'static, + ) -> Result { let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir); let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id())); let _ = std::fs::remove_file(&sock_path); @@ -48,14 +51,10 @@ impl Player { .arg(format!("--volume={volume}")) .arg(format!("--input-ipc-server={}", sock_path.display())); - if let Ok(extra) = - std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) - { + if let Ok(extra) = std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) { cmd.args(extra.split_whitespace()); } - cmd.stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()); + cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); #[cfg(target_os = "linux")] unsafe { @@ -99,21 +98,14 @@ impl Player { sock_path, }; - for (id, prop) in [ - (1, "time-pos"), - (2, "duration"), - (3, "pause"), - (4, "volume"), - ] { + for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] { player.send(json!(["observe_property", id, prop]))?; } std::thread::spawn(move || { for line in BufReader::new(reader).lines() { let Ok(line) = line else { break }; - let Ok(v) = serde_json::from_str::(&line) else { - continue; - }; + let Ok(v) = serde_json::from_str::(&line) else { continue }; if let Some(ev) = parse_event(&v) { on_event(ev); } @@ -216,9 +208,7 @@ mod tests { fn mpv_roundtrip() { use std::sync::mpsc; let wav = std::env::temp_dir().join("drift-test.wav"); - let samples: Vec = (0..16_000u32) - .map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }) - .collect(); + let samples: Vec = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect(); let mut data = Vec::new(); data.extend_from_slice(b"RIFF"); data.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes()); diff --git a/src/session.rs b/src/session.rs index 7af170a..d3c2d01 100644 --- a/src/session.rs +++ b/src/session.rs @@ -28,12 +28,8 @@ impl Session { } pub fn load() -> Session { - let Ok(path) = Self::path() else { - return Session::default(); - }; - let Ok(text) = fs::read_to_string(&path) else { - return Session::default(); - }; + let Ok(path) = Self::path() else { return Session::default() }; + let Ok(text) = fs::read_to_string(&path) else { return Session::default() }; match serde_json::from_str::(&text) { Ok(mut s) => { s.sanitize(); @@ -72,13 +68,7 @@ mod tests { use super::*; fn track(id: &str) -> Track { - Track { - id: id.into(), - title: "T".into(), - artist: "A".into(), - album: None, - duration: Some(200), - } + Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) } } #[test] @@ -94,15 +84,8 @@ mod tests { #[test] fn roundtrip() { - let ok = Session { - queue: vec![track("aaaaaaaaaaa")], - current: Some(0), - position: 42.5, - }; + let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 }; let again: Session = serde_json::from_str(&ok.render().unwrap()).unwrap(); - assert_eq!( - (again.current, again.position, again.queue), - (Some(0), 42.5, ok.queue) - ); + assert_eq!((again.current, again.position, again.queue), (Some(0), 42.5, ok.queue)); } } diff --git a/src/store.rs b/src/store.rs index 71a776f..e62d947 100644 --- a/src/store.rs +++ b/src/store.rs @@ -72,27 +72,41 @@ impl Store { write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?) } - pub fn resolve(&self, name: &str) -> Option { + pub fn resolve_exact(&self, name: &str) -> Option { if self.playlists.contains_key(name) { return Some(name.to_string()); } let lower = name.to_lowercase(); - let ci: Vec<&String> = self - .playlists - .keys() - .filter(|k| k.to_lowercase() == lower) - .collect(); - if ci.len() == 1 { - return Some(ci[0].clone()); + let mut found = self.playlists.keys().filter(|k| k.to_lowercase() == lower); + match (found.next(), found.next()) { + (Some(k), None) => Some(k.clone()), + _ => None, } - let pre: Vec<&String> = self - .playlists - .keys() - .filter(|k| k.to_lowercase().starts_with(&lower)) - .collect(); - if pre.len() == 1 { - return Some(pre[0].clone()); - } - None + } + + pub fn resolve(&self, name: &str) -> Option { + self.resolve_exact(name).or_else(|| { + let lower = name.to_lowercase(); + let mut found = self.playlists.keys().filter(|k| k.to_lowercase().starts_with(&lower)); + match (found.next(), found.next()) { + (Some(k), None) => Some(k.clone()), + _ => None, + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exact_ignores_prefixes() { + let mut s = Store::default(); + s.playlists.insert("rock-classics".into(), Vec::new()); + s.playlists.insert("Jazz".into(), Vec::new()); + assert_eq!(s.resolve_exact("rock"), None); + assert_eq!(s.resolve("rock").as_deref(), Some("rock-classics")); + assert_eq!(s.resolve_exact("jazz").as_deref(), Some("Jazz")); } } diff --git a/src/ui.rs b/src/ui.rs index 0748f06..941bc4c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -11,10 +11,7 @@ const ACCENT: Color = Color::Cyan; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; fn selected_style() -> Style { - Style::new() - .fg(Color::Black) - .bg(ACCENT) - .add_modifier(Modifier::BOLD) + Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD) } fn header_style() -> Style { @@ -59,10 +56,7 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { ]) .areas(area); - f.render_widget( - Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), - name, - ); + f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name); let titles = [ format!(" Results {} ", app.results.len()), @@ -113,11 +107,7 @@ fn track_rows<'a>( .enumerate() .map(|(i, t)| { let is_playing = playing.is_some_and(|(id, _)| id == t.id); - let marker = if is_playing { - playing.map_or("", |p| p.1) - } else { - "" - }; + let marker = if is_playing { playing.map_or("", |p| p.1) } else { "" }; let mut cells = vec![ Cell::from(marker), Cell::from(format!("{:>3}", i + 1)).style(dim()), @@ -128,7 +118,8 @@ fn track_rows<'a>( cells.push(Cell::from(t.album.as_deref().unwrap_or(""))); } cells.push( - Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))).style(dim()), + Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))) + .style(dim()), ); let row = Row::new(cells); if is_playing { @@ -165,9 +156,7 @@ fn track_table<'a>(rows: Vec>, show_album: bool, block: Block<'a>) -> Ta fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { let text: Vec = lines.iter().map(|l| Line::from(*l).style(dim())).collect(); f.render_widget( - Paragraph::new(text) - .alignment(Alignment::Center) - .block(block), + Paragraph::new(text).alignment(Alignment::Center).block(block), area, ); } @@ -187,17 +176,9 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { if tracks.is_empty() { let hint: &[&str] = if view == View::Queue { - &[ - "", - "The queue is empty.", - "Press `a` on a search result to queue it.", - ] + &["", "The queue is empty.", "Press `a` on a search result to queue it."] } else { - &[ - "", - "No results yet.", - "Press / and type a query, or use :search \"daft punk\"", - ] + &["", "No results yet.", "Press / and type a query, or use :search \"daft punk\""] }; return empty_hint(f, block, area, hint); } @@ -211,9 +192,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { let now = playing_marker(app); let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker)); - let Some(tracks) = app.store.playlists.get(name) else { - return; - }; + let Some(tracks) = app.store.playlists.get(name) else { return }; let block = Block::bordered() .title(format!(" {name} ({}) ", tracks.len())) .border_style(Style::new().fg(ACCENT)); @@ -222,11 +201,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { f, block, area, - &[ - "", - "This playlist is empty.", - "Add songs with :addto \"name\" from Results or Queue.", - ], + &["", "This playlist is empty.", "Add songs with :addto \"name\" from Results or Queue."], ); } let show_album = area.width >= 76; @@ -253,12 +228,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { f, block, left, - &[ - "", - "No playlists yet.", - "Queue some songs, then", - ":save \"name\"", - ], + &["", "No playlists yet.", "Queue some songs, then", ":save \"name\""], ); let b = Block::bordered().title(" Preview ").border_style(dim()); f.render_widget(b, right); @@ -280,11 +250,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { .collect(); let table = Table::new( rows, - [ - Constraint::Length(3), - Constraint::Fill(1), - Constraint::Length(5), - ], + [Constraint::Length(3), Constraint::Fill(1), Constraint::Length(5)], ) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .block(block) @@ -293,10 +259,7 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { let sel = app.selected(); let preview_block = Block::bordered().title(" Preview ").border_style(dim()); - match app - .playlist_name(sel) - .and_then(|n| app.store.playlists.get(&n)) - { + match app.playlist_name(sel).and_then(|n| app.store.playlists.get(&n)) { Some(tracks) if !tracks.is_empty() => { let show_album = right.width >= 70; let rows = track_rows(tracks, None, show_album); @@ -325,12 +288,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { let flags_w = flags.chars().count() as u16 + 1; let [title_area, flags_area] = Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1); - f.render_widget( - Paragraph::new(flags) - .style(dim()) - .alignment(Alignment::Right), - flags_area, - ); + f.render_widget(Paragraph::new(flags).style(dim()).alignment(Alignment::Right), flags_area); let icon = match app.play_state { PlayState::Playing => "▶".to_string(), @@ -356,14 +314,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { f.render_widget(Paragraph::new(line), title_area); let left = format!(" {} ", fmt_time(app.position)); - let right = format!( - " {} ", - if app.duration > 0.0 { - fmt_time(app.duration) - } else { - "--:--".into() - } - ); + let right = format!(" {} ", if app.duration > 0.0 { fmt_time(app.duration) } else { "--:--".into() }); let bar_w = (l2.width as usize).saturating_sub(left.chars().count() + right.chars().count()); let ratio = if app.duration > 0.0 { (app.position / app.duration).clamp(0.0, 1.0) @@ -381,12 +332,19 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { } fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { + if app.mode == Mode::ConfirmDelete { + let name = app.pending_delete.as_deref().unwrap_or_default(); + f.render_widget( + Paragraph::new(format!(" Delete playlist \u{201c}{name}\u{201d}? (y/N)")) + .style(Style::new().fg(Color::Yellow).bold()), + area, + ); + return; + } + if app.mode == Mode::Command { let text = format!(":{}", app.cmd_buf); - let col = text - .chars() - .count() - .min(area.width.saturating_sub(1) as usize) as u16; + let col = text.chars().count().min(area.width.saturating_sub(1) as usize) as u16; f.render_widget(Paragraph::new(text), area); f.set_cursor_position((area.x + col, area.y)); return; @@ -413,9 +371,7 @@ fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { } if !pending.is_empty() { f.render_widget( - Paragraph::new(format!("{pending} ")) - .alignment(Alignment::Right) - .style(Style::new().fg(Color::Yellow)), + Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)), area, ); } @@ -432,25 +388,16 @@ const HELP: &[(&str, &str)] = &[ ("Tab S-Tab", "cycle Results / Queue / Playlists"), ("gr gq gp", "jump to Results / Queue / Playlists"), ("PLAYBACK", "#"), - ( - "Enter", - "play selected (on a playlist: load it; inside one: play from there)", - ), + ("Enter", "play selected (on a playlist: load it; inside one: play from there)"), ("Space", "pause / resume"), ("n N (or > <)", "next / previous track"), ("h l / ← →", "seek -5s / +5s (counts: 6l)"), ("+ -", "volume up / down"), ("s r", "toggle shuffle / cycle repeat"), - ( - "ga or :artist", - "songs by the artist of the selected song; a popup asks which one when there are several", - ), + ("ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several"), ("QUEUE", "#"), ("a A P", "add selected / add all / play all results"), - ( - "dd x", - "remove from queue / delete playlist / delete track in a playlist", - ), + ("dd x", "remove from queue / delete playlist / delete track in a playlist"), ("J K", "move queue or playlist item down / up"), ("PLAYLISTS", "#"), ("→ or l", "open the highlighted playlist"), @@ -464,19 +411,10 @@ const HELP: &[(&str, &str)] = &[ (":vol 50 | +5", "set or change the volume"), (":shuffle :repeat [m]", "modes: off, all or one"), (":add [N] :addall", "queue a result / all results"), - ( - ":playall :remove [N]", - "play all results / remove from queue", - ), + (":playall :remove [N]", "play all results / remove from queue"), (":clear", "empty the queue"), - ( - ":save n :load n", - "save the queue as a playlist / load one", - ), - ( - ":append n :delpl n", - "append a playlist to the queue / delete it", - ), + (":save n :load n", "save the queue as a playlist / load one"), + (":append n :delpl n", "append a playlist to the queue / delete it"), (":addto n", "add the selected track to playlist n"), (":import URL", "load a YouTube / YT Music playlist"), (":artist", "same as ga"), @@ -532,10 +470,7 @@ fn help_lines(inner_w: usize) -> Vec> { } } for rest in desc { - lines.push(Line::from(vec![ - Span::raw(" ".repeat(key_w)), - Span::raw(rest), - ])); + lines.push(Line::from(vec![Span::raw(" ".repeat(key_w)), Span::raw(rest)])); } } lines @@ -543,12 +478,7 @@ fn help_lines(inner_w: usize) -> Vec> { fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { let n = app.artist_opts.len() as u16; - let widest = app - .artist_opts - .iter() - .map(|a| a.chars().count()) - .max() - .unwrap_or(0) as u16; + let widest = app.artist_opts.iter().map(|a| a.chars().count()).max().unwrap_or(0) as u16; let w = (widest + 10).max(34).min(area.width); let h = (n + 3).min(area.height); let popup = Rect { @@ -603,12 +533,7 @@ fn draw_help(f: &mut Frame, app: &mut App, area: Rect) { .border_style(Style::new().fg(Color::Yellow)); if max_scroll > 0 { let end = (scroll + visible).min(lines.len()); - block = block.title_bottom(format!( - " j/k scroll · {}-{} of {} ", - scroll + 1, - end, - lines.len() - )); + block = block.title_bottom(format!(" j/k scroll · {}-{} of {} ", scroll + 1, end, lines.len())); } let shown: Vec = lines.into_iter().skip(scroll).take(visible).collect(); @@ -626,12 +551,7 @@ mod tests { use std::sync::mpsc; fn text(term: &Terminal) -> String { - term.backend() - .buffer() - .content() - .iter() - .map(|c| c.symbol()) - .collect() + term.backend().buffer().content().iter().map(|c| c.symbol()).collect() } #[test] @@ -651,13 +571,7 @@ mod tests { duration: Some(90), }; app.store.playlists.insert("empty".into(), vec![]); - app.store.playlists.insert( - "mix".into(), - vec![ - t("aaaaaaaaaaa", "First Song"), - t("bbbbbbbbbbb", "Second Song"), - ], - ); + app.store.playlists.insert("mix".into(), vec![t("aaaaaaaaaaa", "First Song"), t("bbbbbbbbbbb", "Second Song")]); let key = |app: &mut App, c| app.on_key(KeyEvent::new(c, KeyModifiers::NONE)); let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap(); diff --git a/src/ytmusic.rs b/src/ytmusic.rs index c6ee46f..d02383f 100644 --- a/src/ytmusic.rs +++ b/src/ytmusic.rs @@ -16,9 +16,7 @@ pub fn search(query: &str) -> Result, String> { Ok(tracks) if !tracks.is_empty() => Ok(tracks), Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), Err(primary) => ytdlp_search(query).map_err(|fallback| { - format!( - "YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})" - ) + format!("YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})") }), } } @@ -75,9 +73,7 @@ fn search_innertube(query: &str) -> Result> { .set("Cookie", "SOCS=CAI") .send_json(body) .map_err(|e| anyhow!("request failed: {e}"))?; - let v: Value = resp - .into_json() - .context("invalid JSON from YouTube Music")?; + let v: Value = resp.into_json().context("invalid JSON from YouTube Music")?; Ok(parse_search_response(&v)) } @@ -119,9 +115,7 @@ fn column_segments(item: &Value, col: usize) -> Vec { let mut segments = Vec::new(); let mut cur = String::new(); for run in runs.into_iter().flatten() { - let Some(text) = run.get("text").and_then(Value::as_str) else { - continue; - }; + let Some(text) = run.get("text").and_then(Value::as_str) else { continue }; if text.trim() == "•" { segments.push(std::mem::take(&mut cur)); } else { @@ -148,10 +142,7 @@ fn parse_item(item: &Value) -> Option { let title = column_segments(item, 0).into_iter().next()?; let mut meta = column_segments(item, 1); - if matches!( - meta.first().map(String::as_str), - Some("Song" | "Video" | "Episode") - ) { + if matches!(meta.first().map(String::as_str), Some("Song" | "Video" | "Episode")) { meta.remove(0); } let duration = match meta.last() { @@ -162,10 +153,7 @@ fn parse_item(item: &Value) -> Option { } _ => None, }; - let artist = meta - .first() - .cloned() - .unwrap_or_else(|| "Unknown artist".into()); + let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into()); let album = meta .get(1) .filter(|a| !a.contains(" views") && !a.contains(" plays"))