mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 19:27:19 +00:00
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
This commit is contained in:
parent
9c6b54b705
commit
1d579f5311
12 changed files with 1088 additions and 160 deletions
22
README.md
22
README.md
|
|
@ -21,17 +21,17 @@ Stream your favourite music from YouTube Music — all in one client, in your te
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **YouTube Music streaming** — search and stream tracks directly from YouTube Music via `yt-dlp` and `mpv`
|
- **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`
|
- **Vim-style keybinds** - navigate with `j`/`k`, enter commands with `:`, quit with `q`
|
||||||
- **Queue management** — add, remove, reorder, and clear tracks in your queue
|
- **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
|
- **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
|
- **Shuffle & repeat** - toggle shuffle and cycle through off / repeat-all / repeat-one modes
|
||||||
- **Volume & seek control** — adjust volume and seek with configurable step sizes
|
- **Volume & seek control** - adjust volume and seek with configurable step sizes
|
||||||
- **Session restore** — automatically restores your queue and playback position on next launch
|
- **Session restore** - automatically restores your queue and playback position on next launch
|
||||||
- **Artist search** — drill into an artist's discography from the results view
|
- **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)
|
- **Command palette** - full `:command` interface (`:search`, `:play`, `:volume`, `:seek`, `:save`, `:load`, `:import`, and more)
|
||||||
- **Autoplay** — optionally keep playing through results when the queue ends
|
- **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
|
- **TOML config** - persistent config at `~/.config/drift/config.toml` (your platform's config directory); drift never rewrites it, so comments are preserved
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
BIN
archive.zip
Normal file
BIN
archive.zip
Normal file
Binary file not shown.
388
src/app.rs
388
src/app.rs
|
|
@ -8,7 +8,7 @@ use ratatui::widgets::TableState;
|
||||||
|
|
||||||
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
use crate::command::{self, Amount, Command, COMMAND_NAMES};
|
||||||
use crate::config::{self, Config};
|
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::player::{Player, PlayerEvent};
|
||||||
use crate::session::Session;
|
use crate::session::Session;
|
||||||
use crate::store::Store;
|
use crate::store::Store;
|
||||||
|
|
@ -58,6 +58,8 @@ pub enum Mode {
|
||||||
ArtistPick,
|
ArtistPick,
|
||||||
ConfirmDelete,
|
ConfirmDelete,
|
||||||
Help,
|
Help,
|
||||||
|
Menu,
|
||||||
|
Info,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
|
@ -74,6 +76,52 @@ pub enum Level {
|
||||||
Error,
|
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 struct Status {
|
||||||
pub text: String,
|
pub text: String,
|
||||||
pub level: Level,
|
pub level: Level,
|
||||||
|
|
@ -93,7 +141,16 @@ pub struct App {
|
||||||
pub pl_state: TableState,
|
pub pl_state: TableState,
|
||||||
|
|
||||||
pub cmd_buf: String,
|
pub cmd_buf: String,
|
||||||
|
pub cmd_search: bool,
|
||||||
|
pub menu_items: Vec<MenuItem>,
|
||||||
|
pub menu_sel: usize,
|
||||||
|
pub menu_title: String,
|
||||||
|
pub menu_sub: bool,
|
||||||
|
pub menu_track: Option<Track>,
|
||||||
|
pub cmd_sel: usize,
|
||||||
|
cmd_cycle: Option<String>,
|
||||||
pub artist_opts: Vec<String>,
|
pub artist_opts: Vec<String>,
|
||||||
|
pub info_track: Option<Track>,
|
||||||
pub artist_sel: usize,
|
pub artist_sel: usize,
|
||||||
pub pending_delete: Option<String>,
|
pub pending_delete: Option<String>,
|
||||||
pub help_scroll: usize,
|
pub help_scroll: usize,
|
||||||
|
|
@ -161,7 +218,16 @@ impl App {
|
||||||
open: None,
|
open: None,
|
||||||
pl_state: TableState::default(),
|
pl_state: TableState::default(),
|
||||||
cmd_buf: String::new(),
|
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(),
|
artist_opts: Vec::new(),
|
||||||
|
info_track: None,
|
||||||
artist_sel: 0,
|
artist_sel: 0,
|
||||||
pending_delete: None,
|
pending_delete: None,
|
||||||
help_scroll: 0,
|
help_scroll: 0,
|
||||||
|
|
@ -370,18 +436,23 @@ impl App {
|
||||||
Mode::Command => self.key_command(key),
|
Mode::Command => self.key_command(key),
|
||||||
Mode::ArtistPick => self.key_artist_pick(key),
|
Mode::ArtistPick => self.key_artist_pick(key),
|
||||||
Mode::ConfirmDelete => self.key_confirm_delete(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),
|
Mode::Normal => self.key_normal(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn key_normal(&mut self, key: KeyEvent) {
|
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() {
|
if let Some(p) = self.pending.take() {
|
||||||
let n = self.count.take();
|
let n = self.count.take();
|
||||||
if let KeyCode::Char(c) = key.code {
|
if let KeyCode::Char(c) = key.code {
|
||||||
match (p, c) {
|
match (p, c) {
|
||||||
('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))),
|
('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', 'r') => self.set_view(View::Results),
|
||||||
('g', 'q') => self.set_view(View::Queue),
|
('g', 'q') => self.set_view(View::Queue),
|
||||||
('g', 'p') => self.set_view(View::Playlists),
|
('g', 'p') => self.set_view(View::Playlists),
|
||||||
|
|
@ -433,7 +504,7 @@ impl App {
|
||||||
self.pending = Some('g');
|
self.pending = Some('g');
|
||||||
self.count = n;
|
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') => {
|
KeyCode::Char('d') => {
|
||||||
if matches!(self.view, View::Queue | View::Playlists) {
|
if matches!(self.view, View::Queue | View::Playlists) {
|
||||||
self.pending = Some('d');
|
self.pending = Some('d');
|
||||||
|
|
@ -470,11 +541,13 @@ impl App {
|
||||||
KeyCode::Char('r') => self.cycle_repeat(),
|
KeyCode::Char('r') => self.cycle_repeat(),
|
||||||
|
|
||||||
KeyCode::Char('/') => {
|
KeyCode::Char('/') => {
|
||||||
self.cmd_buf = "search ".into();
|
self.cmd_buf.clear();
|
||||||
|
self.cmd_search = true;
|
||||||
self.enter_command_mode();
|
self.enter_command_mode();
|
||||||
}
|
}
|
||||||
KeyCode::Char(':') => {
|
KeyCode::Char(':') => {
|
||||||
self.cmd_buf.clear();
|
self.cmd_buf.clear();
|
||||||
|
self.cmd_search = false;
|
||||||
self.enter_command_mode();
|
self.enter_command_mode();
|
||||||
}
|
}
|
||||||
KeyCode::Char('?') => self.open_help(),
|
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<Track> {
|
||||||
|
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<MenuItem> = 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<MenuItem> = 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) {
|
fn key_confirm_delete(&mut self, key: KeyEvent) {
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
let name = self.pending_delete.take();
|
let name = self.pending_delete.take();
|
||||||
|
|
@ -497,8 +759,13 @@ impl App {
|
||||||
self.mode = Mode::Help;
|
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) {
|
fn key_help(&mut self, key: KeyEvent) {
|
||||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
let ctrl = is_ctrl(&key);
|
||||||
let s = self.help_scroll;
|
let s = self.help_scroll;
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => {
|
KeyCode::Esc | KeyCode::Char('q') | KeyCode::Char('?') | KeyCode::Enter => {
|
||||||
|
|
@ -519,10 +786,45 @@ impl App {
|
||||||
fn enter_command_mode(&mut self) {
|
fn enter_command_mode(&mut self) {
|
||||||
self.mode = Mode::Command;
|
self.mode = Mode::Command;
|
||||||
self.hist_pos = None;
|
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) {
|
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 {
|
match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
|
|
@ -532,12 +834,18 @@ impl App {
|
||||||
let line = std::mem::take(&mut self.cmd_buf);
|
let line = std::mem::take(&mut self.cmd_buf);
|
||||||
self.mode = Mode::Normal;
|
self.mode = Mode::Normal;
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
if !trimmed.is_empty() {
|
if trimmed.is_empty() {
|
||||||
if self.history.last().map(String::as_str) != Some(trimmed) {
|
return;
|
||||||
self.history.push(trimmed.to_string());
|
|
||||||
}
|
}
|
||||||
self.run_command(trimmed);
|
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 => {
|
KeyCode::Backspace => {
|
||||||
if self.cmd_buf.is_empty() {
|
if self.cmd_buf.is_empty() {
|
||||||
|
|
@ -554,17 +862,30 @@ impl App {
|
||||||
}
|
}
|
||||||
KeyCode::Up => self.history_step(-1),
|
KeyCode::Up => self.history_step(-1),
|
||||||
KeyCode::Down => 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),
|
KeyCode::Char(c) if !ctrl => self.cmd_buf.push(c),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn history_step(&mut self, dir: isize) {
|
fn history_step(&mut self, dir: isize) {
|
||||||
if self.history.is_empty() {
|
let search = self.cmd_search;
|
||||||
|
let entries: Vec<String> = 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;
|
return;
|
||||||
}
|
}
|
||||||
let len = self.history.len() as isize;
|
let len = entries.len() as isize;
|
||||||
let pos = match (self.hist_pos, dir) {
|
let pos = match (self.hist_pos, dir) {
|
||||||
(None, -1) => len - 1,
|
(None, -1) => len - 1,
|
||||||
(None, _) => return,
|
(None, _) => return,
|
||||||
|
|
@ -576,25 +897,36 @@ impl App {
|
||||||
} else {
|
} else {
|
||||||
let pos = pos.max(0) as usize;
|
let pos = pos.max(0) as usize;
|
||||||
self.hist_pos = Some(pos);
|
self.hist_pos = Some(pos);
|
||||||
self.cmd_buf = self.history[pos].clone();
|
self.cmd_buf = entries[pos].clone();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn complete(&mut self) {
|
/// Tab / Shift-Tab: cycle through the commands matching the typed prefix.
|
||||||
if self.cmd_buf.contains(' ') {
|
fn complete(&mut self, dir: isize) {
|
||||||
|
if self.cmd_search || self.cmd_buf.contains(' ') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let matches: Vec<&&str> = COMMAND_NAMES
|
if self.cmd_cycle.is_none() {
|
||||||
.iter()
|
self.cmd_cycle = Some(self.cmd_buf.clone());
|
||||||
.filter(|c| c.starts_with(self.cmd_buf.as_str()))
|
self.cmd_sel = if dir < 0 { usize::MAX } else { 0 };
|
||||||
.collect();
|
} else {
|
||||||
match matches.len() {
|
let n = self.command_matches().len().max(1) as isize;
|
||||||
0 => {}
|
self.cmd_sel = (self.cmd_sel as isize + dir).rem_euclid(n) as usize;
|
||||||
1 => self.cmd_buf = format!("{} ", matches[0]),
|
|
||||||
_ => {
|
|
||||||
let list = matches.iter().map(|s| **s).collect::<Vec<_>>().join(" ");
|
|
||||||
self.info(list);
|
|
||||||
}
|
}
|
||||||
|
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() {
|
fn playlist_drill_in() {
|
||||||
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
std::env::set_var("DRIFT_CONFIG_DIR", &dir);
|
||||||
std::env::set_var("XDG_STATE_HOME", &dir);
|
std::env::set_var("DRIFT_STATE_DIR", &dir);
|
||||||
|
|
||||||
let (tx, _rx) = mpsc::channel();
|
let (tx, _rx) = mpsc::channel();
|
||||||
let mut app = App::new(tx);
|
let mut app = App::new(tx);
|
||||||
|
|
|
||||||
14
src/main.rs
14
src/main.rs
|
|
@ -5,6 +5,7 @@ mod model;
|
||||||
mod player;
|
mod player;
|
||||||
mod session;
|
mod session;
|
||||||
mod store;
|
mod store;
|
||||||
|
mod sys;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod ytmusic;
|
mod ytmusic;
|
||||||
|
|
||||||
|
|
@ -30,12 +31,9 @@ USAGE:
|
||||||
Inside the player press ? for key bindings and :help for commands.";
|
Inside the player press ? for key bindings and :help for commands.";
|
||||||
|
|
||||||
fn tool_version(name: &str) -> Option<String> {
|
fn tool_version(name: &str) -> Option<String> {
|
||||||
let out = Command::new(name)
|
let mut cmd = Command::new(name);
|
||||||
.arg("--version")
|
cmd.arg("--version").stdin(Stdio::null()).stderr(Stdio::null());
|
||||||
.stdin(Stdio::null())
|
let out = sys::no_window(&mut cmd).output().ok()?;
|
||||||
.stderr(Stdio::null())
|
|
||||||
.output()
|
|
||||||
.ok()?;
|
|
||||||
out.status.success().then(|| {
|
out.status.success().then(|| {
|
||||||
String::from_utf8_lossy(&out.stdout)
|
String::from_utf8_lossy(&out.stdout)
|
||||||
.lines()
|
.lines()
|
||||||
|
|
@ -51,11 +49,11 @@ fn missing_dependencies(verbose: bool) -> Vec<String> {
|
||||||
for (tool, hint) in [
|
for (tool, hint) in [
|
||||||
(
|
(
|
||||||
"mpv",
|
"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",
|
"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) {
|
match tool_version(tool) {
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ impl Player {
|
||||||
cmd.stdin(Stdio::null())
|
cmd.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::null());
|
||||||
|
crate::sys::no_window(&mut cmd);
|
||||||
|
|
||||||
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
|
// Linux only: ask the kernel to SIGTERM mpv if drift dies unexpectedly.
|
||||||
#[cfg(target_os = "linux")]
|
#[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 start = Instant::now();
|
||||||
let (reader, writer) = loop {
|
let (reader, writer) = loop {
|
||||||
match ipc::connect(&sock_path) {
|
match ipc::connect(&sock_path) {
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,7 @@ pub struct Session {
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
fn path() -> Result<PathBuf> {
|
fn path() -> Result<PathBuf> {
|
||||||
let dir = dirs::state_dir()
|
let dir = crate::store::state_base()
|
||||||
.or_else(dirs::data_local_dir)
|
|
||||||
.ok_or_else(|| anyhow!("cannot determine state directory"))?;
|
.ok_or_else(|| anyhow!("cannot determine state directory"))?;
|
||||||
Ok(dir.join("drift").join("session.json"))
|
Ok(dir.join("drift").join("session.json"))
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
src/store.rs
37
src/store.rs
|
|
@ -18,13 +18,44 @@ pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
|
||||||
}
|
}
|
||||||
let tmp = path.with_extension("tmp");
|
let tmp = path.with_extension("tmp");
|
||||||
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
|
||||||
fs::rename(&tmp, path).with_context(|| format!("replacing {}", path.display()))?;
|
// On Windows the rename can fail briefly while an antivirus scanner or
|
||||||
Ok(())
|
// 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<PathBuf> {
|
||||||
|
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<PathBuf> {
|
||||||
|
std::env::var_os("DRIFT_STATE_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.or_else(dirs::state_dir)
|
||||||
|
.or_else(dirs::data_local_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Store {
|
impl Store {
|
||||||
pub fn dir() -> Result<PathBuf> {
|
pub fn dir() -> Result<PathBuf> {
|
||||||
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 new_dir = dir.join("drift");
|
||||||
let old_dir = dir.join("ytm-tui");
|
let old_dir = dir.join("ytm-tui");
|
||||||
if !new_dir.exists() && old_dir.exists() {
|
if !new_dir.exists() && old_dir.exists() {
|
||||||
|
|
|
||||||
91
src/sys.rs
Normal file
91
src/sys.rs
Normal file
|
|
@ -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<usize> = 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::<ExtendedLimits>() 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) {}
|
||||||
BIN
src/test
BIN
src/test
Binary file not shown.
|
|
@ -1,6 +0,0 @@
|
||||||
#include <stdio.h>
|
|
||||||
|
|
||||||
int main(int argc, char **argv)
|
|
||||||
{
|
|
||||||
printf("%d", argc);
|
|
||||||
}
|
|
||||||
682
src/ui.rs
682
src/ui.rs
|
|
@ -1,10 +1,10 @@
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style, Stylize};
|
use ratatui::style::{Color, Modifier, Style, Stylize};
|
||||||
use ratatui::text::{Line, Span};
|
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 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};
|
use crate::model::{fmt_time, Track};
|
||||||
|
|
||||||
const ACCENT: Color = Color::Cyan;
|
const ACCENT: Color = Color::Cyan;
|
||||||
|
|
@ -27,11 +27,10 @@ fn dim() -> Style {
|
||||||
|
|
||||||
pub fn draw(f: &mut Frame, app: &mut App) {
|
pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
let area = f.area();
|
let area = f.area();
|
||||||
let [top, body, playing, bottom] = Layout::vertical([
|
let [top, body, playing] = Layout::vertical([
|
||||||
Constraint::Length(1),
|
Constraint::Length(1),
|
||||||
Constraint::Min(3),
|
Constraint::Min(3),
|
||||||
Constraint::Length(4),
|
Constraint::Length(4),
|
||||||
Constraint::Length(1),
|
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
|
|
@ -42,13 +41,41 @@ pub fn draw(f: &mut Frame, app: &mut App) {
|
||||||
View::Playlists => draw_playlists(f, app, body),
|
View::Playlists => draw_playlists(f, app, body),
|
||||||
}
|
}
|
||||||
draw_now_playing(f, app, playing);
|
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 {
|
if app.mode == Mode::Help {
|
||||||
draw_help(f, app, area);
|
draw_help(f, app, area);
|
||||||
}
|
}
|
||||||
if app.mode == Mode::ArtistPick {
|
if app.mode == Mode::ArtistPick {
|
||||||
draw_artist_picker(f, app, area);
|
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<Line<'static>> {
|
||||||
|
let mut spans: Vec<Span<'static>> = 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) {
|
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()),
|
View::Queue => (&app.queue, " Queue ".to_string()),
|
||||||
_ => (&app.results, format!(" {} ", app.results_title)),
|
_ => (&app.results, format!(" {} ", app.results_title)),
|
||||||
};
|
};
|
||||||
let block = Block::bordered()
|
let mut block = Block::bordered()
|
||||||
.title(format!("{title}({}) ", tracks.len()))
|
.title(format!("{title}({}) ", tracks.len()))
|
||||||
.border_style(Style::new().fg(ACCENT));
|
.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() {
|
if tracks.is_empty() {
|
||||||
let hint: &[&str] = if view == View::Queue {
|
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 {
|
let Some(tracks) = app.store.playlists.get(name) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
let block = Block::bordered()
|
let mut block = Block::bordered()
|
||||||
.title(format!(" {name} ({}) ", tracks.len()))
|
.title(format!(" {name} ({}) ", tracks.len()))
|
||||||
.border_style(Style::new().fg(ACCENT));
|
.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() {
|
if tracks.is_empty() {
|
||||||
return empty_hint(
|
return empty_hint(
|
||||||
f,
|
f,
|
||||||
|
|
@ -244,9 +287,14 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
let [left, right] =
|
let [left, right] =
|
||||||
Layout::horizontal([Constraint::Percentage(35), Constraint::Percentage(65)]).areas(area);
|
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()))
|
.title(format!(" Playlists ({}) ", app.store.playlists.len()))
|
||||||
.border_style(Style::new().fg(ACCENT));
|
.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() {
|
if app.store.playlists.is_empty() {
|
||||||
empty_hint(
|
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<Span<'static>> {
|
||||||
|
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) {
|
fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let block = Block::bordered()
|
let active = app.play_state != PlayState::Stopped;
|
||||||
.title(" Now Playing ")
|
let mut block = Block::bordered()
|
||||||
.border_style(Style::new().fg(ACCENT));
|
.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);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
let [l1, l2] = Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(inner);
|
||||||
|
|
||||||
let mut flags = vec![format!("vol {}%", app.volume)];
|
let on = Style::new().fg(ACCENT).bold();
|
||||||
if app.shuffle {
|
let vol_cells = 5usize;
|
||||||
flags.push("shuffle".into());
|
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 {
|
if app.repeat != crate::model::RepeatMode::Off {
|
||||||
flags.push(format!("repeat:{}", app.repeat.label()));
|
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())
|
||||||
|
});
|
||||||
}
|
}
|
||||||
let flags = flags.join(" · ");
|
flags.push(Span::styled(format!(" {:>3}%", app.volume), Style::new().fg(Color::Gray)));
|
||||||
let flags_w = flags.chars().count() as u16 + 1;
|
flags.push(Span::raw(" "));
|
||||||
|
let flags_w: usize = flags.iter().map(|s| s.content.chars().count()).sum();
|
||||||
let [title_area, flags_area] =
|
let [title_area, flags_area] =
|
||||||
Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w)]).areas(l1);
|
Layout::horizontal([Constraint::Min(1), Constraint::Length(flags_w as u16)]).areas(l1);
|
||||||
f.render_widget(
|
f.render_widget(Paragraph::new(Line::from(flags)), flags_area);
|
||||||
Paragraph::new(flags)
|
|
||||||
.style(dim())
|
|
||||||
.alignment(Alignment::Right),
|
|
||||||
flags_area,
|
|
||||||
);
|
|
||||||
|
|
||||||
let icon = match app.play_state {
|
let mut spans: Vec<Span> = vec![Span::raw(" ")];
|
||||||
PlayState::Playing => "▶".to_string(),
|
match app.play_state {
|
||||||
PlayState::Paused => "⏸".to_string(),
|
PlayState::Loading => spans.push(Span::styled(
|
||||||
PlayState::Loading => SPINNER[app.tick % SPINNER.len()].to_string(),
|
format!("{} ", SPINNER[app.tick % SPINNER.len()]),
|
||||||
PlayState::Stopped => "■".to_string(),
|
Style::new().fg(Color::Yellow),
|
||||||
};
|
)),
|
||||||
let line = match (app.now_track(), app.play_state) {
|
PlayState::Playing => {
|
||||||
(Some(t), s) if s != PlayState::Stopped => Line::from(vec![
|
spans.extend(equalizer(app, 6));
|
||||||
Span::styled(format!("{icon} "), Style::new().fg(Color::Green)),
|
spans.push(Span::raw(" "));
|
||||||
Span::styled(t.title.clone(), Style::new().bold()),
|
}
|
||||||
Span::styled(format!(" {}", t.artist), Style::new().fg(Color::Gray)),
|
PlayState::Paused => {
|
||||||
]),
|
spans.push(Span::styled("⏸ ", Style::new().fg(Color::Yellow)));
|
||||||
(Some(t), _) => Line::from(vec![
|
spans.push(Span::raw(" "));
|
||||||
Span::styled(format!("{icon} "), dim()),
|
}
|
||||||
Span::styled(format!("Last played: {} — {}", t.title, t.artist), dim()),
|
_ => {
|
||||||
]),
|
}
|
||||||
_ => Line::from(vec![
|
}
|
||||||
Span::styled(format!("{icon} "), dim()),
|
spans.push(Span::raw(" "));
|
||||||
Span::styled("Nothing playing", dim()),
|
match (app.now_track(), active) {
|
||||||
]),
|
(Some(t), true) => {
|
||||||
};
|
spans.push(Span::styled(t.title.clone(), Style::new().fg(Color::White).bold()));
|
||||||
f.render_widget(Paragraph::new(line), title_area);
|
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 left = format!(" {} ", fmt_time(app.position));
|
||||||
let right = format!(
|
let right = format!(
|
||||||
|
|
@ -370,66 +491,390 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
||||||
} else {
|
} else {
|
||||||
0.0
|
0.0
|
||||||
};
|
};
|
||||||
let filled = (ratio * bar_w as f64).round() as usize;
|
let head = ((ratio * bar_w as f64) as usize).min(bar_w.saturating_sub(1));
|
||||||
let line = Line::from(vec![
|
let mut line = vec![Span::styled(left, Style::new().fg(Color::Gray))];
|
||||||
Span::raw(left),
|
for i in 0..bar_w {
|
||||||
Span::styled("━".repeat(filled), Style::new().fg(ACCENT)),
|
line.push(if !active || app.duration <= 0.0 {
|
||||||
Span::styled("─".repeat(bar_w.saturating_sub(filled)), dim()),
|
Span::styled("─", dim())
|
||||||
Span::raw(right),
|
} else if i < head {
|
||||||
]);
|
Span::styled("━", Style::new().fg(gradient(i as f64 / bar_w.max(1) as f64)))
|
||||||
f.render_widget(Paragraph::new(line), l2);
|
} 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) {
|
fn draw_toast(f: &mut Frame, app: &App, body: Rect) {
|
||||||
if app.mode == Mode::ConfirmDelete {
|
let Some(s) = app.visible_status() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
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::<String>() + "…";
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Line> = 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:<label_w$}"), header_style()),
|
||||||
|
Span::styled(first, Style::new().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
for rest in parts {
|
||||||
|
lines.push(Line::from(vec![
|
||||||
|
Span::raw(" ".repeat(label_w + 1)),
|
||||||
|
Span::styled(rest, Style::new().fg(Color::White)),
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let popup = popup_rect(area, w, lines.len() as u16 + 2, false);
|
||||||
|
if popup.width < 12 || popup.height < 3 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
f.render_widget(Clear, popup);
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(lines).block(
|
||||||
|
Block::bordered()
|
||||||
|
.border_type(BorderType::Rounded)
|
||||||
|
.border_style(Style::new().fg(ACCENT))
|
||||||
|
.title(Span::styled(" ♪ Song info ", Style::new().fg(ACCENT).bold()))
|
||||||
|
.title_bottom(Line::styled(" any key to close ", dim()).right_aligned()),
|
||||||
|
),
|
||||||
|
popup,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn popup_rect(area: Rect, want_w: u16, want_h: u16, top_third: bool) -> 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<char> = 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<Line> = 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::<String>(),
|
||||||
|
Style::new().fg(ACCENT),
|
||||||
|
),
|
||||||
|
Span::styled(text.chars().skip(11).collect::<String>(), 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<Line> = items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, it)| {
|
||||||
|
let key = if it.key == NO_KEY { ' ' } else { it.key };
|
||||||
|
let label = format!("{:<label_w$}", it.label);
|
||||||
|
let selected = i == app.menu_sel;
|
||||||
|
if selected {
|
||||||
|
let mut text = format!(" {key} {label}");
|
||||||
|
if !it.detail.is_empty() {
|
||||||
|
text.push_str(&format!(" {}", it.detail));
|
||||||
|
}
|
||||||
|
let text: String = text.chars().take(width).collect();
|
||||||
|
let pad = width.saturating_sub(text.chars().count());
|
||||||
|
return Line::styled(format!("{text}{}", " ".repeat(pad)), selected_style());
|
||||||
|
}
|
||||||
|
let danger = matches!(
|
||||||
|
it.action,
|
||||||
|
MenuAction::Remove | MenuAction::DeletePlaylist | MenuAction::ClearQueue
|
||||||
|
);
|
||||||
|
let label_style = if danger {
|
||||||
|
Style::new().fg(Color::Red)
|
||||||
|
} else {
|
||||||
|
Style::new().fg(Color::White)
|
||||||
|
};
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(key.to_string(), Style::new().fg(Color::Yellow).bold()),
|
||||||
|
Span::raw(" "),
|
||||||
|
Span::styled(label, label_style),
|
||||||
|
];
|
||||||
|
if !it.detail.is_empty() {
|
||||||
|
spans.push(Span::styled(format!(" {}", it.detail), dim()));
|
||||||
|
}
|
||||||
|
Line::from(spans)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_confirm(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let name = app.pending_delete.as_deref().unwrap_or_default();
|
let name = app.pending_delete.as_deref().unwrap_or_default();
|
||||||
f.render_widget(
|
let msg = format!(" Delete playlist \u{201c}{name}\u{201d}? ");
|
||||||
Paragraph::new(format!(" Delete playlist \u{201c}{name}\u{201d}? (y/N)"))
|
let w = (msg.chars().count() as u16 + 4).max(34);
|
||||||
.style(Style::new().fg(Color::Yellow).bold()),
|
let popup = popup_rect(area, w, 5, false);
|
||||||
area,
|
if popup.width < 12 || popup.height < 3 {
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let lines = vec![
|
||||||
if app.mode == Mode::Command {
|
Line::styled(msg, Style::new().fg(Color::White).bold()),
|
||||||
let text = format!(":{}", app.cmd_buf);
|
Line::from(vec![
|
||||||
let col = text
|
Span::styled(" y ", Style::new().fg(Color::Black).bg(Color::Red).bold()),
|
||||||
.chars()
|
Span::styled(" delete ", dim()),
|
||||||
.count()
|
Span::styled(" any other key ", Style::new().fg(Color::Gray)),
|
||||||
.min(area.width.saturating_sub(1) as usize) as u16;
|
Span::styled(" cancel", dim()),
|
||||||
f.render_widget(Paragraph::new(text), area);
|
]),
|
||||||
f.set_cursor_position((area.x + col, area.y));
|
];
|
||||||
return;
|
f.render_widget(Clear, popup);
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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() {
|
|
||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(format!("{pending} "))
|
Paragraph::new(lines).alignment(Alignment::Center).block(
|
||||||
.alignment(Alignment::Right)
|
Block::bordered()
|
||||||
.style(Style::new().fg(Color::Yellow)),
|
.border_type(BorderType::Rounded)
|
||||||
area,
|
.title(" Confirm ")
|
||||||
|
.border_style(Style::new().fg(Color::Yellow)),
|
||||||
|
),
|
||||||
|
popup,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const HELP: &[(&str, &str)] = &[
|
const HELP: &[(&str, &str)] = &[
|
||||||
("THIS WINDOW", "#"),
|
("THIS WINDOW", "#"),
|
||||||
|
|
@ -437,7 +882,7 @@ const HELP: &[(&str, &str)] = &[
|
||||||
("q Esc ?", "close"),
|
("q Esc ?", "close"),
|
||||||
("NAVIGATION", "#"),
|
("NAVIGATION", "#"),
|
||||||
("j k / ↓ ↑", "move (counts work: 5j)"),
|
("j k / ↓ ↑", "move (counts work: 5j)"),
|
||||||
("gg G", "top / bottom (5G → row 5)"),
|
("gg ge Home End", "top / bottom (5gg or 5ge → row 5)"),
|
||||||
("Ctrl-d Ctrl-u", "half page down / up"),
|
("Ctrl-d Ctrl-u", "half page down / up"),
|
||||||
("Tab S-Tab", "cycle Results / Queue / Playlists"),
|
("Tab S-Tab", "cycle Results / Queue / Playlists"),
|
||||||
("gr gq gp", "jump to Results / Queue / Playlists"),
|
("gr gq gp", "jump to Results / Queue / Playlists"),
|
||||||
|
|
@ -455,6 +900,12 @@ const HELP: &[(&str, &str)] = &[
|
||||||
"ga or :artist",
|
"ga or :artist",
|
||||||
"songs by the artist of the selected song; a popup asks which one when there are several",
|
"songs by the artist of the selected song; a popup asks which one when there are several",
|
||||||
),
|
),
|
||||||
|
("SONG MENU ( G )", "#"),
|
||||||
|
("G", "menu for the highlighted song or playlist"),
|
||||||
|
("j k ⏎", "move / choose (the yellow letter is a shortcut)"),
|
||||||
|
("p n a g l", "play · play next · add to queue · artist · playlist"),
|
||||||
|
("x i", "remove · song info"),
|
||||||
|
("Esc q G", "close (Esc goes back from a sub-menu)"),
|
||||||
("QUEUE", "#"),
|
("QUEUE", "#"),
|
||||||
("a A P", "add selected / add all / play all results"),
|
("a A P", "add selected / add all / play all results"),
|
||||||
(
|
(
|
||||||
|
|
@ -648,8 +1099,8 @@ mod tests {
|
||||||
fn open_playlist_renders() {
|
fn open_playlist_renders() {
|
||||||
let dir = std::env::temp_dir().join(format!("drift-ui-test-{}", std::process::id()));
|
let dir = std::env::temp_dir().join(format!("drift-ui-test-{}", std::process::id()));
|
||||||
std::fs::create_dir_all(&dir).unwrap();
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
std::env::set_var("XDG_CONFIG_HOME", &dir);
|
std::env::set_var("DRIFT_CONFIG_DIR", &dir);
|
||||||
std::env::set_var("XDG_STATE_HOME", &dir);
|
std::env::set_var("DRIFT_STATE_DIR", &dir);
|
||||||
|
|
||||||
let (tx, _rx) = mpsc::channel();
|
let (tx, _rx) = mpsc::channel();
|
||||||
let mut app = App::new(tx);
|
let mut app = App::new(tx);
|
||||||
|
|
@ -691,6 +1142,33 @@ mod tests {
|
||||||
let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap();
|
let mut tiny = Terminal::new(TestBackend::new(20, 6)).unwrap();
|
||||||
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
|
||||||
|
key(&mut app, KeyCode::Char(':'));
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(text(&term).contains("Command"));
|
||||||
|
key(&mut app, KeyCode::Tab);
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
key(&mut app, KeyCode::Esc);
|
||||||
|
key(&mut app, KeyCode::Char('/'));
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(text(&term).contains("Search YouTube Music"));
|
||||||
|
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
key(&mut app, KeyCode::Esc);
|
||||||
|
|
||||||
|
key(&mut app, KeyCode::Char('G'));
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(text(&term).contains("Play now"));
|
||||||
|
tiny.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
key(&mut app, KeyCode::Char('l'));
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(text(&term).contains("New playlist"));
|
||||||
|
key(&mut app, KeyCode::Esc);
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(text(&term).contains("Play now"));
|
||||||
|
key(&mut app, KeyCode::Esc);
|
||||||
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
|
assert!(!text(&term).contains("Play now"));
|
||||||
|
|
||||||
key(&mut app, KeyCode::Char('?'));
|
key(&mut app, KeyCode::Char('?'));
|
||||||
term.draw(|f| draw(f, &mut app)).unwrap();
|
term.draw(|f| draw(f, &mut app)).unwrap();
|
||||||
let s = text(&term);
|
let s = text(&term);
|
||||||
|
|
|
||||||
|
|
@ -305,8 +305,8 @@ fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
|
fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
|
||||||
let out = Command::new("yt-dlp")
|
let mut cmd = Command::new("yt-dlp");
|
||||||
.args([
|
cmd.args([
|
||||||
"--flat-playlist",
|
"--flat-playlist",
|
||||||
"--dump-json",
|
"--dump-json",
|
||||||
"--no-warnings",
|
"--no-warnings",
|
||||||
|
|
@ -315,7 +315,8 @@ fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
|
||||||
"15",
|
"15",
|
||||||
])
|
])
|
||||||
.arg(target)
|
.arg(target)
|
||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null());
|
||||||
|
let out = crate::sys::no_window(&mut cmd)
|
||||||
.output()
|
.output()
|
||||||
.map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
|
.map_err(|e| anyhow!("could not run yt-dlp ({e}); is it installed and on PATH?"))?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue