diff --git a/README.md b/README.md index 5f3c84a..3f7d28f 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,17 @@ Stream your favourite music from YouTube Music — all in one client, in your te ## Features -- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv` -- **Vim-style keybinds** — navigate with `j`/`k`, enter commands with `:`, quit with `q` -- **Queue management** — add, remove, reorder, and clear tracks in your queue -- **Playlists** — save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs -- **Shuffle & repeat** — toggle shuffle and cycle through off / repeat-all / repeat-one modes -- **Volume & seek control** — adjust volume and seek with configurable step sizes -- **Session restore** — automatically restores your queue and playback position on next launch -- **Artist search** — drill into an artist's discography from the results view -- **Command palette** — full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more) -- **Autoplay** — optionally keep playing through results when the queue ends -- **TOML config** — persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved +- **YouTube Music streaming** - search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv` +- **Vim-style keybinds** - navigate with `j`/`k`, enter commands with `:`, quit with `q` +- **Queue management** - add, remove, reorder, and clear tracks in your queue +- **Playlists** - save, load, append, and delete named playlists; import from YouTube/YouTube Music URLs +- **Shuffle & repeat** - toggle shuffle and cycle through off / repeat-all / repeat-one modes +- **Volume & seek control** - adjust volume and seek with configurable step sizes +- **Session restore** - automatically restores your queue and playback position on next launch +- **Artist search** - drill into an artist's discography from the results view +- **Command palette** - full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more) +- **Autoplay** - optionally keep playing through results when the queue ends +- **TOML config** - persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved --- diff --git a/archive.zip b/archive.zip new file mode 100644 index 0000000..d72e7af Binary files /dev/null and b/archive.zip differ diff --git a/src/app.rs b/src/app.rs index 4b8559e..e8e2ad9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::config::{self, Config}; -use crate::model::{split_artists, RepeatMode, Track}; +use crate::model::{fmt_time, split_artists, RepeatMode, Track}; use crate::player::{Player, PlayerEvent}; use crate::session::Session; use crate::store::Store; @@ -58,6 +58,8 @@ pub enum Mode { ArtistPick, ConfirmDelete, Help, + Menu, + Info, } #[derive(Clone, Copy, PartialEq, Eq)] @@ -74,6 +76,52 @@ pub enum Level { Error, } +/// Hotkey value meaning "this menu entry has no hotkey". +pub const NO_KEY: char = '\0'; + +#[derive(Clone, PartialEq, Eq)] +pub enum MenuAction { + Play, + PlayNext, + Queue, + Artist, + PlaylistMenu, + PickPlaylist(String), + NewPlaylist, + Remove, + Info, + QueueAll, + PlayAll, + ClearQueue, + SaveQueue, + OpenPlaylist, + AppendPlaylist, + DeletePlaylist, +} + +#[derive(Clone)] +pub struct MenuItem { + pub key: char, + pub label: String, + pub detail: String, + pub action: MenuAction, +} + +/// Ctrl held without Alt. Windows reports AltGr (needed for `@ \\ { [ ~` and +/// friends on many layouts) as Ctrl+Alt, which must still count as typing. +fn is_ctrl(key: &KeyEvent) -> bool { + key.modifiers.contains(KeyModifiers::CONTROL) && !key.modifiers.contains(KeyModifiers::ALT) +} + +fn menu_item(key: char, label: &str, action: MenuAction) -> MenuItem { + MenuItem { + key, + label: label.to_string(), + detail: String::new(), + action, + } +} + pub struct Status { pub text: String, pub level: Level, @@ -93,7 +141,16 @@ pub struct App { pub pl_state: TableState, pub cmd_buf: String, + pub cmd_search: bool, + pub menu_items: Vec, + pub menu_sel: usize, + pub menu_title: String, + pub menu_sub: bool, + pub menu_track: Option, + pub cmd_sel: usize, + cmd_cycle: Option, pub artist_opts: Vec, + pub info_track: Option, pub artist_sel: usize, pub pending_delete: Option, pub help_scroll: usize, @@ -161,7 +218,16 @@ impl App { open: None, pl_state: TableState::default(), cmd_buf: String::new(), + cmd_search: false, + menu_items: Vec::new(), + menu_sel: 0, + menu_title: String::new(), + menu_sub: false, + menu_track: None, + cmd_sel: 0, + cmd_cycle: None, artist_opts: Vec::new(), + info_track: None, artist_sel: 0, pending_delete: None, help_scroll: 0, @@ -370,18 +436,23 @@ impl App { Mode::Command => self.key_command(key), Mode::ArtistPick => self.key_artist_pick(key), Mode::ConfirmDelete => self.key_confirm_delete(key), + Mode::Menu => self.key_menu(key), + Mode::Info => self.key_info(key), Mode::Normal => self.key_normal(key), } } fn key_normal(&mut self, key: KeyEvent) { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let ctrl = is_ctrl(&key); if let Some(p) = self.pending.take() { let n = self.count.take(); if let KeyCode::Char(c) = key.code { match (p, c) { ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + ('g', 'e') => { + self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + } ('g', 'r') => self.set_view(View::Results), ('g', 'q') => self.set_view(View::Queue), ('g', 'p') => self.set_view(View::Playlists), @@ -433,7 +504,7 @@ impl App { self.pending = Some('g'); self.count = n; } - KeyCode::Char('G') => self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))), + KeyCode::Char('G') | KeyCode::Menu => self.open_menu(), KeyCode::Char('d') => { if matches!(self.view, View::Queue | View::Playlists) { self.pending = Some('d'); @@ -470,11 +541,13 @@ impl App { KeyCode::Char('r') => self.cycle_repeat(), KeyCode::Char('/') => { - self.cmd_buf = "search ".into(); + self.cmd_buf.clear(); + self.cmd_search = true; self.enter_command_mode(); } KeyCode::Char(':') => { self.cmd_buf.clear(); + self.cmd_search = false; self.enter_command_mode(); } KeyCode::Char('?') => self.open_help(), @@ -484,6 +557,195 @@ impl App { } } + /// The song under the cursor (no fallback to the playing song). + fn selected_track(&self) -> Option { + let sel = self.selected(); + match self.view { + View::Results => self.results.get(sel).cloned(), + View::Queue => self.queue.get(sel).cloned(), + View::Playlists if self.in_playlist() => self + .open + .as_ref() + .and_then(|n| self.store.playlists.get(n)) + .and_then(|t| t.get(sel)) + .cloned(), + View::Playlists => None, + } + } + + /// `G`: open the context menu for whatever is highlighted. + fn open_menu(&mut self) { + let mut items: Vec = Vec::new(); + if self.view == View::Playlists && !self.in_playlist() { + let Some(name) = self.playlist_name(self.selected()) else { + return self.error("No playlist selected"); + }; + let n = self.store.playlists.get(&name).map_or(0, Vec::len); + items.push(menu_item('o', "Open", MenuAction::OpenPlaylist)); + items.push(menu_item('p', "Play", MenuAction::Play)); + items.push(menu_item('a', "Append to queue", MenuAction::AppendPlaylist)); + items.push(menu_item('x', "Delete playlist…", MenuAction::DeletePlaylist)); + self.menu_title = format!("{name} ({n})"); + self.menu_track = None; + } else { + let Some(track) = self.selected_track() else { + return self.error("No song selected"); + }; + let in_queue = self.view == View::Queue; + items.push(menu_item('p', "Play now", MenuAction::Play)); + if !in_queue { + items.push(menu_item('n', "Play next", MenuAction::PlayNext)); + items.push(menu_item('a', "Add to queue", MenuAction::Queue)); + } + items.push(menu_item('g', "Show artist", MenuAction::Artist)); + items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); + if in_queue { + items.push(menu_item('x', "Remove from queue", MenuAction::Remove)); + } else if self.in_playlist() { + items.push(menu_item('x', "Remove from playlist", MenuAction::Remove)); + } + items.push(menu_item('i', "Song info", MenuAction::Info)); + if self.view == View::Results { + items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); + items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); + } + if in_queue { + items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); + items.push(menu_item('S', "Save queue as playlist…", MenuAction::SaveQueue)); + } + self.menu_title = track.title.clone(); + self.menu_track = Some(track); + } + self.menu_items = items; + self.menu_sel = 0; + self.menu_sub = false; + self.mode = Mode::Menu; + } + + fn open_playlist_menu(&mut self) { + let track = self.menu_track.clone(); + let mut items: Vec = self + .store + .playlists + .iter() + .enumerate() + .map(|(i, (name, list))| { + let has = track + .as_ref() + .is_some_and(|t| list.iter().any(|x| x.id == t.id)); + MenuItem { + key: if i < 9 { + char::from_digit(i as u32 + 1, 10).unwrap_or(NO_KEY) + } else { + NO_KEY + }, + label: name.clone(), + detail: if has { + "✓ already in it".to_string() + } else { + format!("{} songs", list.len()) + }, + action: MenuAction::PickPlaylist(name.clone()), + } + }) + .collect(); + items.push(menu_item('+', "New playlist…", MenuAction::NewPlaylist)); + self.menu_items = items; + self.menu_title = "Add to playlist".into(); + self.menu_sel = 0; + self.menu_sub = true; + } + + fn close_menu(&mut self) { + self.mode = Mode::Normal; + self.menu_items.clear(); + self.menu_sel = 0; + self.menu_sub = false; + self.menu_track = None; + } + + fn key_menu(&mut self, key: KeyEvent) { + let ctrl = is_ctrl(&key); + let len = self.menu_items.len(); + if len == 0 { + return self.close_menu(); + } + match key.code { + KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('G') | KeyCode::Menu => { + if self.menu_sub { + self.open_menu(); + } else { + self.close_menu(); + } + } + KeyCode::Char('j') | KeyCode::Down => self.menu_sel = (self.menu_sel + 1) % len, + KeyCode::Char('k') | KeyCode::Up => self.menu_sel = (self.menu_sel + len - 1) % len, + KeyCode::Char('n') if ctrl => self.menu_sel = (self.menu_sel + 1) % len, + KeyCode::Char('p') if ctrl => self.menu_sel = (self.menu_sel + len - 1) % len, + KeyCode::Home => self.menu_sel = 0, + KeyCode::End => self.menu_sel = len - 1, + KeyCode::Enter => self.run_menu(self.menu_sel), + KeyCode::Char(c) if !ctrl && c != NO_KEY => { + if let Some(i) = self.menu_items.iter().position(|it| it.key == c) { + self.menu_sel = i; + self.run_menu(i); + } + } + _ => {} + } + } + + fn run_menu(&mut self, i: usize) { + let Some(item) = self.menu_items.get(i).cloned() else { + return; + }; + if item.action == MenuAction::PlaylistMenu { + return self.open_playlist_menu(); + } + let track = self.menu_track.clone(); + self.close_menu(); + match item.action { + MenuAction::Play => self.activate(), + MenuAction::PlayNext => { + if let Some(t) = track { + self.play_next(t); + } + } + MenuAction::Queue | MenuAction::AppendPlaylist => self.add_selected(), + MenuAction::Artist => self.go_to_artist(), + MenuAction::PlaylistMenu => {} + MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), + MenuAction::NewPlaylist => self.prefill_command("addto "), + MenuAction::Remove | MenuAction::DeletePlaylist => self.delete_selected(), + MenuAction::Info => { + if let Some(t) = track { + self.info_track = Some(t); + self.mode = Mode::Info; + } + } + MenuAction::QueueAll => self.add_all_results(), + MenuAction::PlayAll => self.play_all_results(), + MenuAction::ClearQueue => self.clear_queue(), + MenuAction::SaveQueue => self.prefill_command("save "), + MenuAction::OpenPlaylist => self.open_selected_playlist(), + } + } + + /// Open the `:` popup with some text already typed (e.g. `save `). + fn prefill_command(&mut self, text: &str) { + self.cmd_buf = text.to_string(); + self.cmd_search = false; + self.enter_command_mode(); + } + + fn play_next(&mut self, t: Track) { + let label = t.label(); + let len = self.queue.len(); + let at = self.current.map_or(0, |c| (c + 1).min(len)); + self.queue.insert(at, t); + self.info(format!("Playing next: {label}")); + } + fn key_confirm_delete(&mut self, key: KeyEvent) { self.mode = Mode::Normal; let name = self.pending_delete.take(); @@ -497,8 +759,13 @@ impl App { self.mode = Mode::Help; } + fn key_info(&mut self, _key: KeyEvent) { + self.mode = Mode::Normal; + self.info_track = None; + } + fn key_help(&mut self, key: KeyEvent) { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let ctrl = is_ctrl(&key); let s = self.help_scroll; match key.code { KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => { @@ -519,10 +786,45 @@ impl App { fn enter_command_mode(&mut self) { self.mode = Mode::Command; self.hist_pos = None; + self.cmd_cycle = None; + self.cmd_sel = 0; + } + + /// Commands matching what is typed so far (only while typing the command name). + pub fn command_matches(&self) -> Vec<&'static str> { + if self.cmd_search || self.cmd_buf.contains(' ') { + return Vec::new(); + } + let prefix = self.cmd_cycle.as_deref().unwrap_or(self.cmd_buf.as_str()); + COMMAND_NAMES + .iter() + .copied() + .filter(|c| c.starts_with(prefix)) + .collect() + } + + /// True while Tab / Shift-Tab is stepping through the suggestion list. + pub fn cycling(&self) -> bool { + self.cmd_cycle.is_some() + } + + /// Most recent searches, newest first (shown in the `/` popup). + pub fn recent_searches(&self, n: usize) -> Vec<&str> { + self.history + .iter() + .rev() + .filter_map(|h| h.strip_prefix("search ")) + .take(n) + .collect() } fn key_command(&mut self, key: KeyEvent) { - let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let ctrl = is_ctrl(&key); + let cycling = matches!(key.code, KeyCode::Tab | KeyCode::BackTab) + || (ctrl && matches!(key.code, KeyCode::Char('n') | KeyCode::Char('p'))); + if !cycling { + self.cmd_cycle = None; + } match key.code { KeyCode::Esc => { self.mode = Mode::Normal; @@ -532,12 +834,18 @@ impl App { let line = std::mem::take(&mut self.cmd_buf); self.mode = Mode::Normal; let trimmed = line.trim(); - if !trimmed.is_empty() { - if self.history.last().map(String::as_str) != Some(trimmed) { - self.history.push(trimmed.to_string()); - } - self.run_command(trimmed); + if trimmed.is_empty() { + return; } + let full = if self.cmd_search { + format!("search {trimmed}") + } else { + trimmed.to_string() + }; + if self.history.last() != Some(&full) { + self.history.push(full.clone()); + } + self.run_command(&full); } KeyCode::Backspace => { if self.cmd_buf.is_empty() { @@ -554,17 +862,30 @@ impl App { } KeyCode::Up => self.history_step(-1), KeyCode::Down => self.history_step(1), - KeyCode::Tab => self.complete(), + KeyCode::Tab => self.complete(1), + KeyCode::BackTab => self.complete(-1), + KeyCode::Char('n') if ctrl => self.complete(1), + KeyCode::Char('p') if ctrl => self.complete(-1), KeyCode::Char(c) if !ctrl => self.cmd_buf.push(c), _ => {} } } fn history_step(&mut self, dir: isize) { - if self.history.is_empty() { + let search = self.cmd_search; + let entries: Vec = self + .history + .iter() + .filter_map(|h| match h.strip_prefix("search ") { + Some(q) if search => Some(q.to_string()), + _ if search => None, + _ => Some(h.clone()), + }) + .collect(); + if entries.is_empty() { return; } - let len = self.history.len() as isize; + let len = entries.len() as isize; let pos = match (self.hist_pos, dir) { (None, -1) => len - 1, (None, _) => return, @@ -576,25 +897,36 @@ impl App { } else { let pos = pos.max(0) as usize; self.hist_pos = Some(pos); - self.cmd_buf = self.history[pos].clone(); + self.cmd_buf = entries[pos].clone(); } } - fn complete(&mut self) { - if self.cmd_buf.contains(' ') { + /// Tab / Shift-Tab: cycle through the commands matching the typed prefix. + fn complete(&mut self, dir: isize) { + if self.cmd_search || self.cmd_buf.contains(' ') { return; } - let matches: Vec<&&str> = COMMAND_NAMES - .iter() - .filter(|c| c.starts_with(self.cmd_buf.as_str())) - .collect(); - match matches.len() { - 0 => {} - 1 => self.cmd_buf = format!("{} ", matches[0]), - _ => { - let list = matches.iter().map(|s| **s).collect::>().join(" "); - self.info(list); - } + if self.cmd_cycle.is_none() { + self.cmd_cycle = Some(self.cmd_buf.clone()); + self.cmd_sel = if dir < 0 { usize::MAX } else { 0 }; + } else { + let n = self.command_matches().len().max(1) as isize; + self.cmd_sel = (self.cmd_sel as isize + dir).rem_euclid(n) as usize; + } + let matches = self.command_matches(); + if matches.is_empty() { + self.cmd_cycle = None; + return; + } + if self.cmd_sel >= matches.len() { + self.cmd_sel = matches.len() - 1; + } + let pick = matches[self.cmd_sel]; + if matches.len() == 1 { + self.cmd_buf = format!("{pick} "); + self.cmd_cycle = None; + } else { + self.cmd_buf = pick.to_string(); } } @@ -1661,8 +1993,8 @@ mod tests { fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); std::fs::create_dir_all(&dir).unwrap(); - std::env::set_var("XDG_CONFIG_HOME", &dir); - std::env::set_var("XDG_STATE_HOME", &dir); + std::env::set_var("DRIFT_CONFIG_DIR", &dir); + std::env::set_var("DRIFT_STATE_DIR", &dir); let (tx, _rx) = mpsc::channel(); let mut app = App::new(tx); diff --git a/src/main.rs b/src/main.rs index 96ce68d..f9af212 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ mod model; mod player; mod session; mod store; +mod sys; mod ui; mod ytmusic; @@ -30,12 +31,9 @@ USAGE: Inside the player press ? for key bindings and :help for commands."; fn tool_version(name: &str) -> Option { - let out = Command::new(name) - .arg("--version") - .stdin(Stdio::null()) - .stderr(Stdio::null()) - .output() - .ok()?; + let mut cmd = Command::new(name); + cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null()); + let out = sys::no_window(&mut cmd).output().ok()?; out.status.success().then(|| { String::from_utf8_lossy(&out.stdout) .lines() @@ -51,11 +49,11 @@ fn missing_dependencies(verbose: bool) -> Vec { for (tool, hint) in [ ( "mpv", - "sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv", + "sudo apt install mpv | brew install mpv | sudo pacman -S mpv | winget install mpv.net / scoop install mpv", ), ( "yt-dlp", - "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp", + "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp | winget install yt-dlp.yt-dlp / scoop install yt-dlp", ), ] { match tool_version(tool) { diff --git a/src/player.rs b/src/player.rs index e1985c7..8229dbd 100644 --- a/src/player.rs +++ b/src/player.rs @@ -156,6 +156,7 @@ impl Player { cmd.stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()); + crate::sys::no_window(&mut cmd); // Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly. #[cfg(target_os = "linux")] @@ -175,6 +176,9 @@ impl Player { } })?; + // Windows: tie mpv's lifetime to ours. + crate::sys::kill_on_exit(&child); + let start = Instant::now(); let (reader, writer) = loop { match ipc::connect(&sock_path) { diff --git a/src/session.rs b/src/session.rs index 7af170a..45dc9a1 100644 --- a/src/session.rs +++ b/src/session.rs @@ -17,8 +17,7 @@ pub struct Session { impl Session { fn path() -> Result { - let dir = dirs::state_dir() - .or_else(dirs::data_local_dir) + let dir = crate::store::state_base() .ok_or_else(|| anyhow!("cannot determine state directory"))?; Ok(dir.join("drift").join("session.json")) } diff --git a/src/store.rs b/src/store.rs index 6a8cec1..ee05cde 100644 --- a/src/store.rs +++ b/src/store.rs @@ -18,13 +18,44 @@ pub fn write_atomic(path: &Path, text: &str) -> Result<()> { } let tmp = path.with_extension("tmp"); fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?; - fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?; - Ok(()) + // On Windows the rename can fail briefly while an antivirus scanner or + // indexer holds the destination open, so retry a few times. + let mut last = None; + for attempt in 0..6 { + match fs::rename(&tmp, path) { + Ok(()) => return Ok(()), + Err(e) => { + last = Some(e); + if attempt < 5 { + std::thread::sleep(std::time::Duration::from_millis(40)); + } + } + } + } + let _ = fs::remove_file(&tmp); + Err(last.expect("at least one attempt")) + .with_context(|| format!("replacing {}", path.display())) +} + +/// Base directory for settings and playlists (`DRIFT_CONFIG_DIR` overrides it). +pub fn config_base() -> Option { + std::env::var_os("DRIFT_CONFIG_DIR") + .map(PathBuf::from) + .or_else(dirs::config_dir) +} + +/// Base directory for the saved session (`DRIFT_STATE_DIR` overrides it). +/// `dirs::state_dir()` is `None` on Windows and macOS, so fall back to local data. +pub fn state_base() -> Option { + std::env::var_os("DRIFT_STATE_DIR") + .map(PathBuf::from) + .or_else(dirs::state_dir) + .or_else(dirs::data_local_dir) } impl Store { pub fn dir() -> Result { - let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?; + let dir = config_base().ok_or_else(|| anyhow!("cannot determine config directory"))?; let new_dir = dir.join("drift"); let old_dir = dir.join("ytm-tui"); if !new_dir.exists() && old_dir.exists() { diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 0000000..b20c526 --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,91 @@ +//! Small platform shims so the rest of the app stays free of `cfg` noise. + +use std::process::Command; + +/// Keep child processes from flashing a console window on Windows. +pub fn no_window(cmd: &mut Command) -> &mut Command { + #[cfg(windows)] + { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + cmd +} + +/// Windows: make sure `child` is killed when drift exits, even if drift +/// crashes (the equivalent of Linux's PR_SET_PDEATHSIG). +#[cfg(windows)] +pub fn kill_on_exit(child: &std::process::Child) { + use std::ffi::c_void; + use std::os::windows::io::AsRawHandle; + use std::sync::OnceLock; + + #[repr(C)] + struct BasicLimits { + per_process_user_time_limit: i64, + per_job_user_time_limit: i64, + limit_flags: u32, + minimum_working_set_size: usize, + maximum_working_set_size: usize, + active_process_limit: u32, + affinity: usize, + priority_class: u32, + scheduling_class: u32, + } + #[repr(C)] + struct IoCounters([u64; 6]); + #[repr(C)] + struct ExtendedLimits { + basic: BasicLimits, + io: IoCounters, + process_memory_limit: usize, + job_memory_limit: usize, + peak_process_memory_used: usize, + peak_job_memory_used: usize, + } + + extern "system" { + fn CreateJobObjectW(attrs: *mut c_void, name: *const u16) -> *mut c_void; + fn SetInformationJobObject( + job: *mut c_void, + class: i32, + info: *mut c_void, + len: u32, + ) -> i32; + fn AssignProcessToJobObject(job: *mut c_void, process: *mut c_void) -> i32; + } + + const JOB_OBJECT_EXTENDED_LIMIT_INFORMATION: i32 = 9; + const KILL_ON_JOB_CLOSE: u32 = 0x2000; + + // The job handle is intentionally kept open for the life of the process; + // when drift exits (or dies) Windows closes it and kills everything in it. + static JOB: OnceLock = OnceLock::new(); + let job = *JOB.get_or_init(|| unsafe { + let job = CreateJobObjectW(std::ptr::null_mut(), std::ptr::null()); + if job.is_null() { + return 0; + } + let mut info: ExtendedLimits = std::mem::zeroed(); + info.basic.limit_flags = KILL_ON_JOB_CLOSE; + let ok = SetInformationJobObject( + job, + JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + &mut info as *mut _ as *mut c_void, + std::mem::size_of::() as u32, + ); + if ok == 0 { + return 0; + } + job as usize + }); + if job != 0 { + unsafe { + AssignProcessToJobObject(job as *mut c_void, child.as_raw_handle() as *mut c_void); + } + } +} + +#[cfg(not(windows))] +pub fn kill_on_exit(_child: &std::process::Child) {} diff --git a/src/test b/src/test deleted file mode 100755 index df35c31..0000000 Binary files a/src/test and /dev/null differ diff --git a/src/test.c b/src/test.c deleted file mode 100644 index bc57162..0000000 --- a/src/test.c +++ /dev/null @@ -1,6 +0,0 @@ -#include - -int main(int argc, char **argv) -{ - printf("%d", argc); -} diff --git a/src/ui.rs b/src/ui.rs index 7995ddc..9c9ea49 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,10 +1,10 @@ use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style, Stylize}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Cell, Clear, Paragraph, Row, Table, Tabs}; +use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, Tabs}; use ratatui::Frame; -use crate::app::{App, Level, Mode, PlayState, View}; +use crate::app::{App, Level, MenuAction, Mode, PlayState, View, NO_KEY}; use crate::model::{fmt_time, Track}; const ACCENT: Color = Color::Cyan; @@ -27,11 +27,10 @@ fn dim() -> Style { pub fn draw(f: &mut Frame, app: &mut App) { let area = f.area(); - let [top, body, playing, bottom] = Layout::vertical([ + let [top, body, playing] = Layout::vertical([ Constraint::Length(1), Constraint::Min(3), Constraint::Length(4), - Constraint::Length(1), ]) .areas(area); @@ -42,13 +41,41 @@ pub fn draw(f: &mut Frame, app: &mut App) { View::Playlists => draw_playlists(f, app, body), } draw_now_playing(f, app, playing); - draw_cmdline(f, app, bottom); + if app.mode == Mode::Command { + draw_palette(f, app, area); + } + if app.mode == Mode::ConfirmDelete { + draw_confirm(f, app, area); + } + if app.mode == Mode::Menu { + draw_menu(f, app, area); + } if app.mode == Mode::Help { draw_help(f, app, area); } if app.mode == Mode::ArtistPick { draw_artist_picker(f, app, area); } + if app.mode == Mode::Info { + draw_info(f, app, area); + } + draw_toast(f, app, body); +} + +fn hints(items: &[(&str, &str)], width: u16) -> Option> { + let mut spans: Vec> = vec![Span::raw(" ")]; + let mut len = 2usize; + for (i, (key, label)) in items.iter().enumerate() { + if i > 0 { + spans.push(Span::styled(" ", dim())); + len += 2; + } + spans.push(Span::styled(key.to_string(), Style::new().fg(Color::Yellow))); + spans.push(Span::styled(format!(" {label}"), dim())); + len += key.chars().count() + 1 + label.chars().count(); + } + spans.push(Span::raw(" ")); + (len + 4 <= width as usize).then(|| Line::from(spans).right_aligned()) } fn draw_tabs(f: &mut Frame, app: &App, area: Rect) { @@ -181,9 +208,19 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { View::Queue => (&app.queue, " Queue ".to_string()), _ => (&app.results, format!(" {} ", app.results_title)), }; - let block = Block::bordered() + let mut block = Block::bordered() .title(format!("{title}({}) ", tracks.len())) .border_style(Style::new().fg(ACCENT)); + if !tracks.is_empty() { + let keys: &[(&str, &str)] = if view == View::Queue { + &[("⏎", "play"), ("x", "remove"), ("J/K", "move"), ("G", "menu"), ("?", "help")] + } else { + &[("⏎", "play"), ("a", "queue"), ("G", "menu"), ("/", "search"), ("?", "help")] + }; + if let Some(h) = hints(keys, area.width) { + block = block.title_bottom(h); + } + } if tracks.is_empty() { let hint: &[&str] = if view == View::Queue { @@ -214,9 +251,15 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { let Some(tracks) = app.store.playlists.get(name) else { return; }; - let block = Block::bordered() + let mut block = Block::bordered() .title(format!(" {name} ({}) ", tracks.len())) .border_style(Style::new().fg(ACCENT)); + if !tracks.is_empty() { + let keys = [("⏎", "play"), ("a", "queue"), ("x", "remove"), ("G", "menu"), ("←", "back")]; + if let Some(h) = hints(&keys, area.width) { + block = block.title_bottom(h); + } + } if tracks.is_empty() { return empty_hint( f, @@ -244,9 +287,14 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { let [left, right] = Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area); - let block = Block::bordered() + let mut block = Block::bordered() .title(format!(" Playlists ({}) ", app.store.playlists.len())) .border_style(Style::new().fg(ACCENT)); + if !app.store.playlists.is_empty() { + if let Some(h) = hints(&[("⏎", "load"), ("→", "open"), ("G", "menu")], left.width) { + block = block.title_bottom(h); + } + } if app.store.playlists.is_empty() { empty_hint( @@ -306,54 +354,127 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) { } } +const BARS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; + +fn gradient(_t: f64) -> Color { + ACCENT +} + +fn equalizer(app: &App, n: usize) -> Vec> { + let t = app.tick as f64; + (0..n) + .map(|i| { + let x = i as f64; + let level = match app.play_state { + PlayState::Playing => { + let v = ((t * 0.45 + x * 1.3).sin() + (t * 0.23 + x * 2.1).sin()) / 4.0 + 0.5; + (v.clamp(0.0, 1.0) * 7.0).round() as usize + } + _ => 0, + }; + let color = if app.play_state == PlayState::Playing { + gradient(x / n as f64) + } else { + Color::DarkGray + }; + Span::styled(BARS[level], Style::new().fg(color)) + }) + .collect() +} + fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { - let block = Block::bordered() - .title(" Now Playing ") - .border_style(Style::new().fg(ACCENT)); + let active = app.play_state != PlayState::Stopped; + let mut block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(if active { ACCENT } else { Color::DarkGray })) + .title(Line::from(vec![ + Span::styled(" ♪ ", Style::new().fg(ACCENT).bold()), + Span::styled("Now Playing ", Style::new().fg(ACCENT).bold()), + ])); + + let mut pending = String::new(); + if let Some(c) = app.count { + pending.push_str(&c.to_string()); + } + if let Some(p) = app.pending { + pending.push(p); + } + if !pending.is_empty() { + block = block.title_bottom( + Line::styled(format!(" {pending} "), Style::new().fg(Color::Yellow).bold()) + .right_aligned(), + ); + } + let inner = block.inner(area); f.render_widget(block, area); let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner); - let mut flags = vec![format!("vol {}%", app.volume)]; - if app.shuffle { - flags.push("shuffle".into()); + let on = Style::new().fg(ACCENT).bold(); + let vol_cells = 5usize; + let vol_fill = ((app.volume as usize * vol_cells) + 50) / 100; + let mut flags = vec![ + Span::styled("⇄", if app.shuffle { on } else { dim() }), + Span::raw(" "), + Span::styled( + match app.repeat { + crate::model::RepeatMode::Off => "↻".to_string(), + m => format!("↻ {}", m.label()), + }, + if app.repeat != crate::model::RepeatMode::Off { + on + } else { + dim() + }, + ), + Span::raw(" "), + Span::styled("vol ", dim()), + ]; + for i in 0..vol_cells { + flags.push(if i < vol_fill.min(vol_cells) { + Span::styled("━", Style::new().fg(gradient(i as f64 / vol_cells as f64))) + } else { + Span::styled("─", dim()) + }); } - if app.repeat != crate::model::RepeatMode::Off { - flags.push(format!("repeat:{}", app.repeat.label())); - } - let flags = flags.join(" · "); - let flags_w = flags.chars().count() as u16 + 1; + flags.push(Span::styled(format!(" {:>3}%", app.volume), Style::new().fg(Color::Gray))); + flags.push(Span::raw(" ")); + let flags_w: usize = flags.iter().map(|s| s.content.chars().count()).sum(); 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, - ); + Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w as u16)]).areas(l1); + f.render_widget(Paragraph::new(Line::from(flags)), flags_area); - let icon = match app.play_state { - PlayState::Playing => "▶".to_string(), - PlayState::Paused => "⏸".to_string(), - PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(), - PlayState::Stopped => "■".to_string(), - }; - let line = match (app.now_track(), app.play_state) { - (Some(t), s) if s != PlayState::Stopped => Line::from(vec![ - Span::styled(format!("{icon} "), Style::new().fg(Color::Green)), - Span::styled(t.title.clone(), Style::new().bold()), - Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)), - ]), - (Some(t), _) => Line::from(vec![ - Span::styled(format!("{icon} "), dim()), - Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()), - ]), - _ => Line::from(vec![ - Span::styled(format!("{icon} "), dim()), - Span::styled("Nothing playing", dim()), - ]), - }; - f.render_widget(Paragraph::new(line), title_area); + let mut spans: Vec = vec![Span::raw(" ")]; + match app.play_state { + PlayState::Loading => spans.push(Span::styled( + format!("{} ", SPINNER[app.tick % SPINNER.len()]), + Style::new().fg(Color::Yellow), + )), + PlayState::Playing => { + spans.extend(equalizer(app, 6)); + spans.push(Span::raw(" ")); + } + PlayState::Paused => { + spans.push(Span::styled("⏸ ", Style::new().fg(Color::Yellow))); + spans.push(Span::raw(" ")); + } + _ => { + } + } + spans.push(Span::raw(" ")); + match (app.now_track(), active) { + (Some(t), true) => { + spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold())); + spans.push(Span::styled(" · ", dim())); + spans.push(Span::styled(t.artist.clone(), Style::new().fg(ACCENT))); + } + (Some(t), false) => spans.push(Span::styled( + format!("Last played: {} — {}", t.title, t.artist), + dim(), + )), + _ => spans.push(Span::styled("Nothing playing · press / to search", dim())), + } + f.render_widget(Paragraph::new(Line::from(spans)), title_area); let left = format!(" {} ", fmt_time(app.position)); let right = format!( @@ -370,65 +491,389 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { } else { 0.0 }; - let filled = (ratio * bar_w as f64).round() as usize; - let line = Line::from(vec![ - Span::raw(left), - Span::styled("━".repeat(filled), Style::new().fg(ACCENT)), - Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()), - Span::raw(right), - ]); - f.render_widget(Paragraph::new(line), l2); + let head = ((ratio * bar_w as f64) as usize).min(bar_w.saturating_sub(1)); + let mut line = vec![Span::styled(left, Style::new().fg(Color::Gray))]; + for i in 0..bar_w { + line.push(if !active || app.duration <= 0.0 { + Span::styled("─", dim()) + } else if i < head { + Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64))) + } else if i == head { + Span::styled("●", Style::new().fg(Color::White).bold()) + } else { + Span::styled("─", dim()) + }); + } + line.push(Span::styled(right, Style::new().fg(Color::Gray))); + f.render_widget(Paragraph::new(Line::from(line)), l2); } -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, - ); +fn draw_toast(f: &mut Frame, app: &App, body: Rect) { + let Some(s) = app.visible_status() else { 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; - f.render_widget(Paragraph::new(text), area); - f.set_cursor_position((area.x + col, area.y)); - return; - } - - let line = match app.visible_status() { - Some(s) => { - let style = match s.level { - Level::Error => Style::new().fg(Color::Red).bold(), - Level::Info => Style::new().fg(Color::Green), - }; - Line::styled(format!(" {}", s.text), style) - } - None => Line::default(), }; - f.render_widget(Paragraph::new(line), area); + if body.width < 16 || body.height < 5 { + return; + } + let (icon, color) = match s.level { + Level::Error => ("✖", Color::Red), + Level::Info => ("●", Color::Green), + }; + let max_text = (body.width as usize).saturating_sub(8).min(70); + let mut text: String = s.text.lines().next().unwrap_or("").to_string(); + if text.chars().count() > max_text { + text = text.chars().take(max_text.saturating_sub(1)).collect::() + "…"; + } + let w = (text.chars().count() as u16 + 6).min(body.width); + let rect = Rect::new(body.right().saturating_sub(w + 1).max(body.x), body.bottom().saturating_sub(4), w, 3); + f.render_widget(Clear, rect); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(format!(" {icon} "), Style::new().fg(color).bold()), + Span::styled(text, Style::new().fg(Color::White)), + ])) + .block( + Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(color)), + ), + rect, + ); +} - let mut pending = String::new(); - if let Some(c) = app.count { - pending.push_str(&c.to_string()); +fn draw_info(f: &mut Frame, app: &App, area: Rect) { + let Some(t) = &app.info_track else { + return; + }; + let rows: Vec<(&str, String)> = vec![ + ("Title", t.title.clone()), + ("Artist", t.artist.clone()), + ("Album", t.album.clone().unwrap_or_else(|| "—".into())), + ("Length", t.duration.map_or("—".into(), |d| fmt_time(d as f64))), + ("ID", t.id.clone()), + ]; + let label_w = 8usize; + let widest = rows.iter().map(|(_, v)| v.chars().count()).max().unwrap_or(0); + let w = ((label_w + widest + 6).max(40) as u16).min(area.width.min(80)); + let value_w = (w as usize).saturating_sub(label_w + 4).max(1); + let mut lines: Vec = Vec::new(); + for (k, v) in &rows { + let mut parts = wrap_words(v, value_w).into_iter(); + let first = parts.next().unwrap_or_default(); + lines.push(Line::from(vec![ + Span::styled(format!(" {k: Rect { + let w = want_w.min(area.width); + let x = area.x + (area.width - w) / 2; + let y = if top_third { + area.y + area.height / 5 + } else { + area.y + area.height.saturating_sub(want_h) / 2 + }; + let h = want_h.min(area.bottom().saturating_sub(y)); + Rect::new(x, y, w, h) +} + +const CMD_INFO: &[(&str, &str)] = &[ + ("search", "search YouTube Music"), + ("play", "play / resume · play N plays row N"), + ("pause", "pause playback"), + ("resume", "resume playback"), + ("toggle", "toggle pause"), + ("stop", "stop playback"), + ("next", "skip to the next track"), + ("prev", "go back to the previous track"), + ("quit", "exit drift"), + ("add", "queue the selected result · add N"), + ("addall", "queue all results"), + ("playall", "play all results"), + ("remove", "remove from the queue · remove N"), + ("clear", "empty the queue"), + ("results", "show the Results tab"), + ("queue", "show the Queue tab"), + ("playlists", "show the Playlists tab"), + ("volume", "set volume · 50 | +5 | -5"), + ("seek", "seek · 90 | 1:30 | +10 | -10"), + ("shuffle", "toggle shuffle"), + ("repeat", "repeat · off | all | one"), + ("save", "save the queue as a playlist"), + ("load", "load a playlist into the queue"), + ("append", "append a playlist to the queue"), + ("delpl", "delete a playlist"), + ("addto", "add the selected track to a playlist"), + ("import", "import a YouTube / YT Music playlist URL"), + ("artist", "songs by the selected artist"), + ("help", "show key bindings"), +]; + +fn cmd_info(name: &str) -> &'static str { + CMD_INFO + .iter() + .find(|(n, _)| *n == name) + .map_or("", |(_, d)| *d) +} + +fn draw_palette(f: &mut Frame, app: &App, area: Rect) { + const MAX_ROWS: usize = 8; + let search = app.cmd_search; + let matches = app.command_matches(); + let recent = if search && app.cmd_buf.is_empty() { + app.recent_searches(5) + } else { + Vec::new() + }; + let first_word = app.cmd_buf.split_whitespace().next().unwrap_or(""); + let typing_args = !search && app.cmd_buf.contains(' '); + + let list_rows = if search { + recent.len().max(1) + } else if typing_args { + 1 + } else { + matches.len().clamp(1, MAX_ROWS) + }; + let want_h = 2 + 1 + 1 + list_rows as u16; + let popup = popup_rect(area, 64, want_h, true); + if popup.width < 12 || popup.height < 3 { + return; } + + let (title, prompt) = if search { + (" Search YouTube Music ", " / ") + } else { + (" Command ", " : ") + }; + let footer = if search { + " ⏎ search · ↑↓ history · Esc close " + } else { + " ⏎ run · Tab complete · Esc close " + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(if search { ACCENT } else { ACCENT })) + .title(Span::styled( + title, + Style::new() + .fg(if search { ACCENT } else { ACCENT }) + .bold(), + )) + .title_bottom(Line::styled(footer, dim()).right_aligned()); + let inner = block.inner(popup); + f.render_widget(Clear, popup); + f.render_widget(block, popup); + + let [input, sep, list] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(0), + ]) + .areas(inner); + + let prompt_w = prompt.chars().count(); + let avail = (input.width as usize).saturating_sub(prompt_w + 1); + let chars: Vec = app.cmd_buf.chars().collect(); + let shown: String = chars[chars.len().saturating_sub(avail)..].iter().collect(); + let shown_w = shown.chars().count(); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + prompt, + Style::new().fg(Color::Black).bg(if search { ACCENT } else { ACCENT }).bold(), + ), + Span::raw(" "), + Span::styled(shown, Style::new().fg(Color::White)), + ])), + input, + ); + if input.width as usize > prompt_w + 1 { + f.set_cursor_position((input.x + (prompt_w + 1 + shown_w) as u16, input.y)); + } + f.render_widget( + Paragraph::new("─".repeat(sep.width as usize)).style(dim()), + sep, + ); + + let mut lines: Vec = Vec::new(); + if search { + if recent.is_empty() { + lines.push(Line::styled(" type what you want to hear, then press Enter", dim())); + } else { + for r in recent { + lines.push(Line::from(vec![ + Span::styled(" ↺ ", dim()), + Span::styled(r.to_string(), Style::new().fg(Color::Gray)), + ])); + } + } + } else if typing_args { + let d = cmd_info(first_word); + lines.push(if d.is_empty() { + Line::styled(" ⏎ run", dim()) + } else { + Line::from(vec![ + Span::styled(format!(" {first_word} "), Style::new().fg(ACCENT).bold()), + Span::styled(d, dim()), + ]) + }); + } else if matches.is_empty() { + lines.push(Line::styled(" no matching command", Style::new().fg(Color::Red))); + } else { + let sel = app.cycling().then_some(app.cmd_sel); + let start = sel.map_or(0, |s| (s + 1).saturating_sub(MAX_ROWS)); + let width = list.width as usize; + for (i, name) in matches.iter().enumerate().skip(start).take(MAX_ROWS) { + let text = format!(" {name:<10}{}", cmd_info(name)); + let text: String = text.chars().take(width).collect(); + lines.push(if Some(i) == sel { + let pad = width.saturating_sub(text.chars().count()); + Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style()) + } else { + Line::from(vec![ + Span::styled( + text.chars().take(11).collect::(), + Style::new().fg(ACCENT), + ), + Span::styled(text.chars().skip(11).collect::(), dim()), + ]) + }); + } + } + f.render_widget(Paragraph::new(lines), list); +} + +fn draw_menu(f: &mut Frame, app: &App, area: Rect) { + let items = &app.menu_items; + if items.is_empty() { + return; + } + let label_w = items.iter().map(|i| i.label.chars().count()).max().unwrap_or(0); + let detail_w = items.iter().map(|i| i.detail.chars().count()).max().unwrap_or(0); + let body_w = 5 + label_w + if detail_w > 0 { detail_w + 3 } else { 0 } + 2; + let title_w = app.menu_title.chars().count() + 6; + let w = body_w.max(title_w).clamp(34, 60) as u16; + let popup = popup_rect(area, w, items.len() as u16 + 2, false); + if popup.width < 12 || popup.height < 3 { + return; + } + + let max_title = (popup.width as usize).saturating_sub(8); + let mut title: String = app.menu_title.chars().take(max_title).collect(); + if app.menu_title.chars().count() > max_title { + title.push('…'); + } + let title = if app.menu_sub { + format!(" {title} ") + } else { + format!(" ♪ {title} ") + }; + let footer = if app.menu_sub { + " ⏎ add · Esc back " + } else { + " ⏎ select · Esc close " + }; + let block = Block::bordered() + .border_type(BorderType::Rounded) + .border_style(Style::new().fg(ACCENT)) + .title(Span::styled(title, Style::new().fg(ACCENT).bold())) + .title_bottom(Line::styled(footer, dim()).right_aligned()); + let inner = block.inner(popup); + f.render_widget(Clear, popup); + f.render_widget(block, popup); + + let width = inner.width as usize; + let lines: Vec = items + .iter() + .enumerate() + .map(|(i, it)| { + let key = if it.key == NO_KEY { ' ' } else { it.key }; + let label = format!("{: Result> { } fn ytdlp_entries(target: &str) -> Result> { - let out = Command::new("yt-dlp") - .args([ + let mut cmd = Command::new("yt-dlp"); + cmd.args([ "--flat-playlist", "--dump-json", "--no-warnings", @@ -315,7 +315,8 @@ fn ytdlp_entries(target: &str) -> Result> { "15", ]) .arg(target) - .stdin(Stdio::null()) + .stdin(Stdio::null()); + let out = crate::sys::no_window(&mut cmd) .output() .map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;