From 1d579f531170d5fd423aa9b7bc5f0ab5bc1f98b6 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Sun, 11 Oct 2026 17:49:53 +0200 Subject: [PATCH] UI Overhaul - Windows support: drift now runs natively on Windows, using named pipes instead of Unix sockets. - Song context menu: press G on any song for quick actions like play, queue, add to playlist, and song info - Redesined command menu: the : prompt has a new, cleaner design - Visualizer: a live audio visualizer now sits in the bottomleft corner - Keybind hints: the most useful shortcuts are showen next to the volume control --- README.md | 22 +- archive.zip | Bin 0 -> 42884 bytes src/app.rs | 392 +++++++++++++++++++++++++--- src/main.rs | 14 +- src/player.rs | 4 + src/session.rs | 3 +- src/store.rs | 37 ++- src/sys.rs | 91 +++++++ src/test | Bin 15968 -> 0 bytes src/test.c | 6 - src/ui.rs | 682 +++++++++++++++++++++++++++++++++++++++++-------- src/ytmusic.rs | 7 +- 12 files changed, 1093 insertions(+), 165 deletions(-) create mode 100644 archive.zip create mode 100644 src/sys.rs delete mode 100755 src/test delete mode 100644 src/test.c 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 0000000000000000000000000000000000000000..d72e7affb9dbdd88b70477cd033b2d301e3177cc GIT binary patch literal 42884 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 KGcZ^z0r3C@5Yd$Y literal 0 HcmV?d00001 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 df35c311e166714fb7e6d539fefa377a2e581bf5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15968 zcmb<-^>JfjWMqH=W(GS35YIsXBH{p{7#5g884L^z4h$9y{0t5Z@(gkeYzzzxEMPH+ zJWM@|_F#sH!)Oi&mjNUU(zgc0U|?WCr)8k(U^K{0AR!P9(g$K=!xkV1F)%Q|Xa=Z1 zK>Aog8lZfbIE*d@DPUk=fYC5E$8Wx@)H-c~nG(6F16^L~VFdC#5Bm|;C*g_G+ zV_-n1Vetr~(e0~%>O-f|f(e{PKsE$FElDBAz5u8`4yeKCG|XQx8e|8^z7VLs4N!gP zbO%Tg0|Nt$hWRh>X-Ntw9-z_$DOj=!g4%~G-VHzz%)r0^qoL6V4j&N)25`P`_X}mX z+<0i}OBE*J^mBjempwN$)&DTBUOy)@$;?DQCq*|WGq1Elx5C0q*UUt(IA6~QoM%C1 zfiOYGA}Ii=7eeC0m|jRp9VP~%l^7Tpu&GbOp}rJ{xG)0)1IR3pnJ73uJ|{CNIlj0g zv8W_IH!(AhAwE92A~8NKGcPeGvnrLLpeQr1B#i;23gmS&26rD%C+B!0JtI9+INR8S zA>K2@H$Ek`C^bE^xFoeG#Me0|KQA>TF)1gNAwE7mH$M+*NjyX;R)q`<44?>uWGWEJ z$iT#)$-n^1t_-D8nVg_lGlq(7nK+FNq{klWDOkRTr7HtLh{O$OzJ`fEfF>XTXhwsH z!}tfF27=NgD9#|JFfg1z5(l{yCVl})9F`YAayO8~!Q~-Hkb!~W0g^b#4yZ7wum>~Q zp=lG$86`$TU^E0qLtr!nMnhmU1V%$(Gz5lh2z=(3`{mL6hQp(q^`JfjgGcLu5~lwb zJerSi90vR8zv(G`28REtXY?5u_~jiK{;Puc86dfr5B~rE57KY=*0b|soJZ#)kLD*I z0zzC34;Y^G=(WAYz`*cdG+Lj5;Y*qxzkCZw4}(Xq?Lv?}y{xl9l;w{SQIBp`I}p3| zKndsn2mJC4Accpav`4RPAxN^-8Z>tbn&XoA@c(}VXb%0u|No%rD8rBc|ARZ_ z3=9mQIZBYr7^{L97%K!ArFqynCNM(8L38yV-v9rv22$X{4xZ5hnQ6knz_8=}|Nr1A zSUv$aJ_#>=?sAR>274)MEn^k%G$2TC35pxiFBi zkM1$EGBf-D&y#}YslaA}=2ICM7((9v2Lq7BTxfhzBtF=E(3w3324)Tx@Ejil1G4}d zvjh`^0D}MnGY2=b00RRzLKT`EXim+5fq?-uhX$IjWolqJ01|`E%`q}CqVSo}_#m|) zcY@S|^n?5c5(8lndlZj`z-S1JhQMeDjE2By2#kinXb6mkz-S1Jh5(@ufUPftttSNa zFF<-gYZgH?4~PI^Q0Iezfq@sqW?*2*fUZlFgz{nQ7NwzlSRYIZ%7?9A1ohiMLNNKS z|NiHL_$NReNofBF#Ge4|*ns+MApQ%eJgA=r;`f6H21t(!#FPLL3=9mQejAhtrl8sw zUO?9?!qz;()WiCxp!JF%4dNgIv<4MwF-$xG)Nx~AV1T*%08||(l!lr2`#;1#Fs1*X zd;_Td|4=?GoIgPM3Q+l$eMo_YYvImk0N}c^C@N z+y`22&cwjOumDXQG{yiDhoyU%{h)Qsj0{o?5}<($1_tmtX^>nPm@UcxOUEEF5Uz#V z3(H3!F%a$qs~2H_MpvT_@upWK} z*gAMHmw|!dF<89>Jb%E%f8toTEyjr0(g0HnqC>#qk_;Tsa07{fun{9@?Xx62JV9b0 zY{|&LAjHHAFGoOPAZ!m32Qkoa5Y!xW^W(wxqA6xzV8~-+U=U=IWPqhln2KtUI0GNt z6VPIYp$lxj6oUeq`y;^a;bDO7Z-I>k^+VOe(h1D`*--VC(DD&HHpRfeumEH)6J|K9 z!QuX6jM&$^KLnZ2#03gen3WJZIJv0AP%ob$K0YxiGrlA-ogulXBq=pLGcQ;#p8>J1 zJ-#G2J{h#Ky_g|BJ|#asJtsdYF(*EyB)_OQKC!fdAvr&{ASbmXHAT+=y9&_K`po#m zqN2pg_|&|TqDqFeqQu|Jz+)9S{cqCqYW?p6qiqewQ;u5`NklRx8Qa~c}jXNY%+^mFv}bY_T;clQg8clChEdbk8J#Jl_WJ30Eq`@6XXyN1MvI6C>b z#xr27|960`?B#{B^8|WqhxK_~CfcSWjJY>y3#s+}+l;ZsOjKsVY(B=S7 z|M=pPl+3*N(&E$l*W?m+PUQs?M zoEY>{GoUe_ky3=`30#(C6!PiM<-8RP;%7+c@UCTAV$O3DV2GN zxtYlfdg=KkU_uY<5{Ruy#l;MI$@#gtsd*)kj6fL)YQKQ$d{{dH*S;v2EwJ(f#s{qy z1J!9T{kZn2!PJA)!q^}hv~BL&|Nr?g{jhcsjE1$JVD`e=BOtvX3@W}5qtFZtuyzxS z2Gu_xb)e=3$ZnW^So_5Tx^Dw!Kdc=FqhakXkQpEhG6O`zZ~$l#ACmiF?HU-(2UQ4j zKg=E&9m~ML0BSSA_^@^poQCR0cRy5^AqQNKf_BtEL}2YU7!7N;!R$x(e;HK26GS_M z0<;|nqha9$(hp*z>j%wyfWic-4N*=*+aI9x1vMH>fy@CjphPveK4t*#lLQGVK=r|C zkRTFF8d>9RD|HIUyyMGc?KP>*x_dkO27ECXQ zM%O3=CH^Jx{m_{fKi$5rrVG{!b!xyCVi@yI4 zl)hkUK{UGlT?`BiAV0$F$F)Baq!)zI^`Ax44{QIzXsCBUTEQ6I|F;0_d>2Ro zto;ewUx;oWj1QxqGcYjlL(2e=epq|=26TTZOdUuLgkgLb{h0xhc47S&SUVKHKNS=? zNc}vRK3F>b1C4)}e%L;?1JH()2FOw*4AT#zL0gkSR>1Vb`h72;`q9G=rVmDQF+$q> zF#WLhCI_@119K=GB7Yahgk%bK;trin!Qjf Spei6#y(xs2L(_o9WdHz`RpeR# 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?"))?;