(cleanup): cleanup on src/

This commit is contained in:
socatlolmeow 2026-10-10 18:04:20 +02:00
commit 576801f108
10 changed files with 503 additions and 193 deletions

View file

@ -15,7 +15,7 @@ use crate::store::Store;
use crate::ytmusic; use crate::ytmusic;
pub enum AppEvent { pub enum AppEvent {
Player(PlayerEvent), Player(u64, PlayerEvent),
Results { Results {
id: u64, id: u64,
title: String, title: String,
@ -105,6 +105,7 @@ pub struct App {
on_radio: bool, on_radio: bool,
seek_step: f64, seek_step: f64,
volume_step: f64, volume_step: f64,
autoplay: bool,
resume: Option<(String, f64)>, resume: Option<(String, f64)>,
saved: String, saved: String,
save_failed: bool, save_failed: bool,
@ -116,6 +117,7 @@ pub struct App {
search_id: u64, search_id: u64,
player: Option<Player>, player: Option<Player>,
player_gen: u64,
tx: Sender<AppEvent>, tx: Sender<AppEvent>,
rng: u64, rng: u64,
} }
@ -164,6 +166,7 @@ impl App {
on_radio: false, on_radio: false,
seek_step: p.seek_step, seek_step: p.seek_step,
volume_step: p.volume_step, volume_step: p.volume_step,
autoplay: p.autoplay,
resume: None, resume: None,
saved: String::new(), saved: String::new(),
save_failed: false, save_failed: false,
@ -173,6 +176,7 @@ impl App {
should_quit: false, should_quit: false,
search_id: 0, search_id: 0,
player: None, player: None,
player_gen: 0,
tx, tx,
rng: seed, rng: seed,
}; };
@ -226,13 +230,17 @@ impl App {
} }
fn set_status(&mut self, text: String, level: Level) { fn set_status(&mut self, text: String, level: Level) {
self.status = Some(Status { text, level, at: Instant::now() }); self.status = Some(Status {
text,
level,
at: Instant::now(),
});
} }
pub fn visible_status(&self) -> Option<&Status> { pub fn visible_status(&self) -> Option<&Status> {
self.status.as_ref().filter(|s| { self.status
s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 } .as_ref()
}) .filter(|s| s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 })
} }
pub fn on_tick(&mut self) { pub fn on_tick(&mut self) {
@ -259,11 +267,17 @@ impl App {
pub fn in_playlist(&self) -> bool { pub fn in_playlist(&self) -> bool {
self.view == View::Playlists self.view == View::Playlists
&& self.open.as_ref().is_some_and(|n| self.store.playlists.contains_key(n)) && self
.open
.as_ref()
.is_some_and(|n| self.store.playlists.contains_key(n))
} }
pub fn open_len(&self) -> usize { pub fn open_len(&self) -> usize {
self.open.as_ref().and_then(|n| self.store.playlists.get(n)).map_or(0, |t| t.len()) self.open
.as_ref()
.and_then(|n| self.store.playlists.get(n))
.map_or(0, |t| t.len())
} }
fn cur_len(&self) -> usize { fn cur_len(&self) -> usize {
@ -411,8 +425,12 @@ impl App {
KeyCode::Char(' ') => self.toggle_pause(), KeyCode::Char(' ') => self.toggle_pause(),
KeyCode::Char('n') | KeyCode::Char('>') => self.next(), KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
KeyCode::Char('N') | KeyCode::Char('<') => self.prev(), KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
KeyCode::Char('h') | KeyCode::Left => self.seek(Amount::Delta(-self.seek_step * times as f64)), KeyCode::Char('h') | KeyCode::Left => {
KeyCode::Char('l') | KeyCode::Right => self.seek(Amount::Delta(self.seek_step * times as f64)), self.seek(Amount::Delta(-self.seek_step * times as f64))
}
KeyCode::Char('l') | KeyCode::Right => {
self.seek(Amount::Delta(self.seek_step * times as f64))
}
KeyCode::Char('+') | KeyCode::Char('=') => { KeyCode::Char('+') | KeyCode::Char('=') => {
self.set_volume(Amount::Delta(self.volume_step * times as f64)) self.set_volume(Amount::Delta(self.volume_step * times as f64))
} }
@ -562,7 +580,7 @@ impl App {
Command::Search(q) => self.start_search(q), Command::Search(q) => self.start_search(q),
Command::Play(None) => self.play_default(), Command::Play(None) => self.play_default(),
Command::Play(Some(n)) => { Command::Play(Some(n)) => {
let len = self.len_of(self.view); let len = self.cur_len();
if n > len { if n > len {
self.error(format!("no row {n} (only {len} here)")); self.error(format!("no row {n} (only {len} here)"));
} else { } else {
@ -584,10 +602,12 @@ impl App {
}, },
Command::AddAll => self.add_all_results(), Command::AddAll => self.add_all_results(),
Command::PlayAll => self.play_all_results(), Command::PlayAll => self.play_all_results(),
Command::Remove(n) => { Command::Remove(Some(n)) => self.remove_from_queue(n - 1),
let i = n.map_or(self.selected(), |n| n - 1); Command::Remove(None) if self.view == View::Queue => {
let i = self.selected();
self.remove_from_queue(i); self.remove_from_queue(i);
} }
Command::Remove(None) => self.error("Open the queue or give a row number: :remove N"),
Command::Clear => self.clear_queue(), Command::Clear => self.clear_queue(),
Command::ShowResults => self.set_view(View::Results), Command::ShowResults => self.set_view(View::Results),
Command::ShowQueue => self.set_view(View::Queue), Command::ShowQueue => self.set_view(View::Queue),
@ -660,7 +680,9 @@ impl App {
} }
match key.code { match key.code {
KeyCode::Esc | KeyCode::Char('q') => self.close_artist_pick(), KeyCode::Esc | KeyCode::Char('q') => self.close_artist_pick(),
KeyCode::Char('j') | KeyCode::Down => self.artist_sel = (self.artist_sel + 1).min(len - 1), KeyCode::Char('j') | KeyCode::Down => {
self.artist_sel = (self.artist_sel + 1).min(len - 1)
}
KeyCode::Char('k') | KeyCode::Up => self.artist_sel = self.artist_sel.saturating_sub(1), KeyCode::Char('k') | KeyCode::Up => self.artist_sel = self.artist_sel.saturating_sub(1),
KeyCode::Char(c @ '1'..='9') => { KeyCode::Char(c @ '1'..='9') => {
let i = c as usize - '1' as usize; let i = c as usize - '1' as usize;
@ -698,22 +720,28 @@ impl App {
.into_iter() .into_iter()
.filter(|t| { .filter(|t| {
t.artist.to_lowercase() == wanted t.artist.to_lowercase() == wanted
|| split_artists(&t.artist).iter().any(|a| a.to_lowercase() == wanted) || split_artists(&t.artist)
.iter()
.any(|a| a.to_lowercase() == wanted)
}) })
.collect()) .collect())
}); });
} }
fn start_search(&mut self, query: String) { fn start_search(&mut self, query: String) {
self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || { self.spawn_loader(
ytmusic::search(&query) format!("Search: {query}"),
}); format!("Searching \u{201c}{query}\u{201d}"),
move || ytmusic::search(&query),
);
} }
fn start_import(&mut self, url: String) { fn start_import(&mut self, url: String) {
self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || { self.spawn_loader(
ytmusic::import(&url) "Imported playlist".into(),
}); "Importing playlist".into(),
move || ytmusic::import(&url),
);
} }
fn spawn_loader<F>(&mut self, title: String, busy: String, work: F) fn spawn_loader<F>(&mut self, title: String, busy: String, work: F)
@ -750,7 +778,11 @@ impl App {
Err(e) => self.error(e), Err(e) => self.error(e),
} }
} }
AppEvent::Player(pe) => self.on_player(pe), AppEvent::Player(generation, pe) => {
if generation == self.player_gen {
self.on_player(pe);
}
}
} }
} }
@ -761,9 +793,11 @@ impl App {
} }
} }
if self.player.is_none() { if self.player.is_none() {
self.player_gen += 1;
let generation = self.player_gen;
let tx = self.tx.clone(); let tx = self.tx.clone();
let p = Player::spawn(self.volume, move |ev| { let p = Player::spawn(self.volume, move |ev| {
let _ = tx.send(AppEvent::Player(ev)); let _ = tx.send(AppEvent::Player(generation, ev));
})?; })?;
self.player = Some(p); self.player = Some(p);
} }
@ -819,7 +853,9 @@ impl App {
"Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U" "Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U"
)); ));
} else { } else {
self.error(format!("Can't play \u{201c}{name}\u{201d} ({msg}) — skipping")); self.error(format!(
"Can't play \u{201c}{name}\u{201d} ({msg}) — skipping"
));
self.advance(true); self.advance(true);
} }
} }
@ -838,7 +874,9 @@ impl App {
} }
fn start_track(&mut self, i: usize, keep_resume: bool) { fn start_track(&mut self, i: usize, keep_resume: bool) {
let Some(track) = self.queue.get(i).cloned() else { return }; let Some(track) = self.queue.get(i).cloned() else {
return;
};
if !keep_resume { if !keep_resume {
self.resume = None; self.resume = None;
} }
@ -929,7 +967,9 @@ impl App {
} }
fn play_radio(&mut self, i: usize) { fn play_radio(&mut self, i: usize) {
let Some(track) = self.radio.get(i).cloned() else { return }; let Some(track) = self.radio.get(i).cloned() else {
return;
};
self.on_radio = true; self.on_radio = true;
self.result_cursor = Some(i); self.result_cursor = Some(i);
self.resume = None; self.resume = None;
@ -954,7 +994,11 @@ impl App {
} }
match self.with_player(|p| p.set_pause(pause)) { match self.with_player(|p| p.set_pause(pause)) {
Ok(()) => { Ok(()) => {
self.play_state = if pause { PlayState::Paused } else { PlayState::Playing }; self.play_state = if pause {
PlayState::Paused
} else {
PlayState::Playing
};
} }
Err(e) => self.error(format!("{e:#}")), Err(e) => self.error(format!("{e:#}")),
} }
@ -1062,7 +1106,11 @@ impl App {
} }
fn advance_radio(&mut self, auto: bool) { fn advance_radio(&mut self, auto: bool) {
let next = self.result_cursor.map(|c| c + 1).filter(|&i| i < self.radio.len()); let next = self
.result_cursor
.filter(|_| self.on_radio || self.autoplay)
.map(|c| c + 1)
.filter(|&i| i < self.radio.len());
match next { match next {
Some(i) => self.play_radio(i), Some(i) => self.play_radio(i),
None if auto => { None if auto => {
@ -1114,7 +1162,10 @@ impl App {
if let Some(c) = self.current { if let Some(c) = self.current {
self.played.insert(c); self.played.insert(c);
} }
self.info(format!("Shuffle: {}", if self.shuffle { "on" } else { "off" })); self.info(format!(
"Shuffle: {}",
if self.shuffle { "on" } else { "off" }
));
} }
fn cycle_repeat(&mut self) { fn cycle_repeat(&mut self) {
@ -1168,7 +1219,11 @@ impl App {
if self.results.is_empty() { if self.results.is_empty() {
return self.error("No results to play"); return self.error("No results to play");
} }
let start = if self.view == View::Results { self.selected() } else { 0 }; let start = if self.view == View::Results {
self.selected()
} else {
0
};
self.queue = self.results.clone(); self.queue = self.results.clone();
self.played.clear(); self.played.clear();
self.current = None; self.current = None;
@ -1211,7 +1266,11 @@ impl App {
} }
let sel = self.states[View::Queue.idx()].selected().unwrap_or(0); let sel = self.states[View::Queue.idx()].selected().unwrap_or(0);
let len = self.queue.len(); let len = self.queue.len();
self.states[View::Queue.idx()].select(if len == 0 { None } else { Some(sel.min(len - 1)) }); self.states[View::Queue.idx()].select(if len == 0 {
None
} else {
Some(sel.min(len - 1))
});
self.info(format!("Removed: {}", removed.label())); self.info(format!("Removed: {}", removed.label()));
} }
@ -1240,8 +1299,14 @@ impl App {
match self.view { match self.view {
View::Queue => self.remove_from_queue(i), View::Queue => self.remove_from_queue(i),
View::Playlists if self.in_playlist() => self.remove_playlist_track(i), View::Playlists if self.in_playlist() => self.remove_playlist_track(i),
View::Playlists => if let Some(n) = self.playlist_name(i) { self.delete_playlist(&n) }, View::Playlists => {
View::Results => self.info("Results can't be deleted — they are replaced by the next search"), if let Some(n) = self.playlist_name(i) {
self.delete_playlist(&n)
}
}
View::Results => {
self.info("Results can't be deleted — they are replaced by the next search")
}
} }
} }
@ -1260,9 +1325,13 @@ impl App {
return self.error("Queue is empty — nothing to save"); return self.error("Queue is empty — nothing to save");
} }
let n = self.queue.len(); let n = self.queue.len();
self.store.playlists.insert(name.to_string(), self.queue.clone()); self.store
.playlists
.insert(name.to_string(), self.queue.clone());
if self.persist() { if self.persist() {
self.info(format!("Saved {n} tracks to playlist \u{201c}{name}\u{201d}")); self.info(format!(
"Saved {n} tracks to playlist \u{201c}{name}\u{201d}"
));
} }
} }
@ -1308,10 +1377,14 @@ impl App {
self.result_cursor = None; self.result_cursor = None;
self.states[View::Queue.idx()].select(Some(0)); self.states[View::Queue.idx()].select(Some(0));
self.play_index(0); self.play_index(0);
self.info(format!("Playing playlist \u{201c}{name}\u{201d} ({n} tracks)")); self.info(format!(
"Playing playlist \u{201c}{name}\u{201d} ({n} tracks)"
));
} else { } else {
self.queue.extend(tracks); self.queue.extend(tracks);
self.info(format!("Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue")); self.info(format!(
"Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue"
));
} }
} }
@ -1345,9 +1418,13 @@ impl App {
} }
fn remove_playlist_track(&mut self, i: usize) { fn remove_playlist_track(&mut self, i: usize) {
let Some(name) = self.open.clone() else { return }; let Some(name) = self.open.clone() else {
return;
};
let removed = { let removed = {
let Some(list) = self.store.playlists.get_mut(&name) else { return }; let Some(list) = self.store.playlists.get_mut(&name) else {
return;
};
if i >= list.len() { if i >= list.len() {
None None
} else { } else {
@ -1358,9 +1435,13 @@ impl App {
let Some((track, len)) = removed else { let Some((track, len)) = removed else {
return self.error("No track selected"); return self.error("No track selected");
}; };
self.pl_state.select(if len == 0 { None } else { Some(i.min(len - 1)) }); self.pl_state
.select(if len == 0 { None } else { Some(i.min(len - 1)) });
if self.persist() { if self.persist() {
self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label())); self.info(format!(
"Removed from \u{201c}{name}\u{201d}: {}",
track.label()
));
} }
} }
@ -1372,7 +1453,9 @@ impl App {
} }
fn move_playlist_track(&mut self, dir: isize) { fn move_playlist_track(&mut self, dir: isize) {
let Some(name) = self.open.clone() else { return }; let Some(name) = self.open.clone() else {
return;
};
let i = self.selected(); let i = self.selected();
let j = i as isize + dir; let j = i as isize + dir;
let moved = match self.store.playlists.get_mut(&name) { let moved = match self.store.playlists.get_mut(&name) {
@ -1447,9 +1530,15 @@ mod tests {
let mut app = App::new(tx); let mut app = App::new(tx);
app.store.playlists.insert( app.store.playlists.insert(
"mix".into(), "mix".into(),
vec![track("aaaaaaaaaaa", "A"), track("bbbbbbbbbbb", "B"), track("ccccccccccc", "C")], vec![
track("aaaaaaaaaaa", "A"),
track("bbbbbbbbbbb", "B"),
track("ccccccccccc", "C"),
],
); );
app.store.playlists.insert("other".into(), vec![track("ddddddddddd", "D")]); app.store
.playlists
.insert("other".into(), vec![track("ddddddddddd", "D")]);
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab);
press(&mut app, KeyCode::Tab); press(&mut app, KeyCode::Tab);
@ -1468,12 +1557,18 @@ mod tests {
assert_eq!(app.selected(), 2); assert_eq!(app.selected(), 2);
press(&mut app, KeyCode::Char('K')); press(&mut app, KeyCode::Char('K'));
let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); let ids: Vec<&str> = app.store.playlists["mix"]
.iter()
.map(|t| t.id.as_str())
.collect();
assert_eq!(ids, ["aaaaaaaaaaa", "ccccccccccc", "bbbbbbbbbbb"]); assert_eq!(ids, ["aaaaaaaaaaa", "ccccccccccc", "bbbbbbbbbbb"]);
assert_eq!(app.selected(), 1); assert_eq!(app.selected(), 1);
press(&mut app, KeyCode::Char('x')); press(&mut app, KeyCode::Char('x'));
let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect(); let ids: Vec<&str> = app.store.playlists["mix"]
.iter()
.map(|t| t.id.as_str())
.collect();
assert_eq!(ids, ["aaaaaaaaaaa", "bbbbbbbbbbb"]); assert_eq!(ids, ["aaaaaaaaaaa", "bbbbbbbbbbb"]);
assert_eq!(app.selected(), 1); assert_eq!(app.selected(), 1);
@ -1504,6 +1599,9 @@ mod tests {
check_artist_popup(&mut app); check_artist_popup(&mut app);
app.run_command("remove");
assert_eq!(app.queue.len(), 1);
let _ = std::fs::remove_dir_all(&dir); let _ = std::fs::remove_dir_all(&dir);
} }
} }

View file

@ -40,9 +40,35 @@ pub enum Command {
} }
pub const COMMAND_NAMES: &[&str] = &[ pub const COMMAND_NAMES: &[&str] = &[
"search", "play", "pause", "resume", "toggle", "stop", "next", "prev", "quit", "add", "search",
"addall", "playall", "remove", "clear", "results", "queue", "playlists", "volume", "seek", "play",
"shuffle", "repeat", "save", "load", "append", "delpl", "addto", "import", "artist", "help", "pause",
"resume",
"toggle",
"stop",
"next",
"prev",
"quit",
"add",
"addall",
"playall",
"remove",
"clear",
"results",
"queue",
"playlists",
"volume",
"seek",
"shuffle",
"repeat",
"save",
"load",
"append",
"delpl",
"addto",
"import",
"artist",
"help",
]; ];
fn unquote(s: &str) -> &str { fn unquote(s: &str) -> &str {
@ -61,7 +87,9 @@ fn index_arg(arg: &str, cmd: &str) -> Result<Option<usize>, String> {
} }
match arg.parse::<usize>() { match arg.parse::<usize>() {
Ok(n) if n >= 1 => Ok(Some(n)), Ok(n) if n >= 1 => Ok(Some(n)),
_ => Err(format!(":{cmd} expects a row number (1, 2, 3 …), got '{arg}'")), _ => Err(format!(
":{cmd} expects a row number (1, 2, 3 …), got '{arg}'"
)),
} }
} }
@ -159,9 +187,18 @@ mod tests {
#[test] #[test]
fn search_variants() { fn search_variants() {
assert_eq!(parse(r#"search "daft punk""#), Ok(Some(Command::Search("daft punk".into())))); assert_eq!(
assert_eq!(parse(":search daft punk"), Ok(Some(Command::Search("daft punk".into())))); parse(r#"search "daft punk""#),
assert_eq!(parse(r#"s"lofi beats""#), Ok(Some(Command::Search("lofi beats".into())))); Ok(Some(Command::Search("daft punk".into())))
);
assert_eq!(
parse(":search daft punk"),
Ok(Some(Command::Search("daft punk".into())))
);
assert_eq!(
parse(r#"s"lofi beats""#),
Ok(Some(Command::Search("lofi beats".into())))
);
assert!(parse("search").is_err()); assert!(parse("search").is_err());
} }
@ -178,10 +215,22 @@ mod tests {
#[test] #[test]
fn amounts() { fn amounts() {
assert_eq!(parse("vol 50"), Ok(Some(Command::Volume(Amount::Abs(50.0))))); assert_eq!(
assert_eq!(parse("vol -10"), Ok(Some(Command::Volume(Amount::Delta(-10.0))))); parse("vol 50"),
assert_eq!(parse("seek 1:30"), Ok(Some(Command::Seek(Amount::Abs(90.0))))); Ok(Some(Command::Volume(Amount::Abs(50.0))))
assert_eq!(parse("seek +15"), Ok(Some(Command::Seek(Amount::Delta(15.0))))); );
assert_eq!(
parse("vol -10"),
Ok(Some(Command::Volume(Amount::Delta(-10.0))))
);
assert_eq!(
parse("seek 1:30"),
Ok(Some(Command::Seek(Amount::Abs(90.0))))
);
assert_eq!(
parse("seek +15"),
Ok(Some(Command::Seek(Amount::Delta(15.0))))
);
assert!(parse("vol loud").is_err()); assert!(parse("vol loud").is_err());
} }
@ -193,7 +242,10 @@ mod tests {
#[test] #[test]
fn playlists() { fn playlists() {
assert_eq!(parse(r#"save "my mix""#), Ok(Some(Command::Save("my mix".into())))); assert_eq!(
parse(r#"save "my mix""#),
Ok(Some(Command::Save("my mix".into())))
);
assert!(parse("save").is_err()); assert!(parse("save").is_err());
} }
} }

View file

@ -1,12 +1,12 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{Context, Result}; use anyhow::Result;
use serde::Deserialize; use serde::Deserialize;
use crate::model::{RepeatMode, Track}; use crate::model::{RepeatMode, Track};
use crate::session::Session; use crate::session::Session;
use crate::store::Store; use crate::store::{write_atomic, Store};
#[derive(Debug, Clone, Copy, Deserialize)] #[derive(Debug, Clone, Copy, Deserialize)]
#[serde(default)] #[serde(default)]
@ -56,14 +56,14 @@ pub struct Config {
impl Config { impl Config {
fn path() -> Result<PathBuf> { fn path() -> Result<PathBuf> {
Ok(Store::path()?.with_file_name("config.toml")) Ok(Store::dir()?.join("config.toml"))
} }
pub fn render(p: &Playback) -> String { pub fn render(p: &Playback) -> String {
format!( format!(
"\ "\
# Drift settings. Drift only read this file and never rewrites it, so your # Drift settings. Drift only reads this file and never rewrites it, so your
# comments are safe. Restart drift after editing # comments are safe. Restart drift after editing.
[playback] [playback]
volume = {volume} volume = {volume}
@ -85,14 +85,7 @@ volume_step = {vstep:?}
} }
fn write(text: &str) -> Result<()> { fn write(text: &str) -> Result<()> {
let path = Self::path()?; write_atomic(&Self::path()?, text)
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
} }
pub fn load() -> (Config, Option<String>) { pub fn load() -> (Config, Option<String>) {
@ -106,7 +99,10 @@ volume_step = {vstep:?}
return (Config::default(), None); return (Config::default(), None);
} }
Err(e) => { Err(e) => {
return (Config::default(), Some(format!("cannot read {}: {e}", path.display()))) return (
Config::default(),
Some(format!("cannot read {}: {e}", path.display())),
)
} }
}; };
match toml::from_str::<Config>(&text) { match toml::from_str::<Config>(&text) {
@ -144,12 +140,20 @@ pub fn migrate_legacy() {
} }
let Ok(path) = Config::path() else { return }; let Ok(path) = Config::path() else { return };
let Ok(text) = fs::read_to_string(&path) else { return }; let Ok(text) = fs::read_to_string(&path) else {
let Ok(table) = text.parse::<toml::Table>() else { return }; return;
if table.contains_key("playback") || !(table.contains_key("queue") || table.contains_key("position")) { };
let Ok(table) = text.parse::<toml::Table>() else {
return;
};
if table.contains_key("playback")
|| !(table.contains_key("queue") || table.contains_key("position"))
{
return; return;
} }
let Ok(old) = toml::from_str::<Legacy>(&text) else { return }; let Ok(old) = toml::from_str::<Legacy>(&text) else {
return;
};
let d = Playback::default(); let d = Playback::default();
let mut p = Playback { let mut p = Playback {
@ -160,7 +164,11 @@ pub fn migrate_legacy() {
..d ..d
}; };
p.sanitize(); p.sanitize();
let session = Session { queue: old.queue, current: old.current, position: old.position }; let session = Session {
queue: old.queue,
current: old.current,
position: old.position,
};
if let Ok(s) = session.render() { if let Ok(s) = session.render() {
if !Session::exists() { if !Session::exists() {
let _ = Session::write(&s); let _ = Session::write(&s);
@ -190,14 +198,22 @@ mod tests {
assert_eq!(c.playback.volume, 80); assert_eq!(c.playback.volume, 80);
let c: Config = let c: Config =
toml::from_str("[playback]\nvolume = 10\nrepeat = \"one\"\nseek_step = 10").unwrap(); toml::from_str("[playback]\nvolume = 10\nrepeat = \"one\"\nseek_step = 10").unwrap();
assert_eq!((c.playback.volume, c.playback.repeat), (10, RepeatMode::One)); assert_eq!(
(c.playback.volume, c.playback.repeat),
(10, RepeatMode::One)
);
assert_eq!(c.playback.seek_step, 10.0); assert_eq!(c.playback.seek_step, 10.0);
assert!(toml::from_str::<Config>("[playback]\nrepeat = \"sometimes\"").is_err()); assert!(toml::from_str::<Config>("[playback]\nrepeat = \"sometimes\"").is_err());
} }
#[test] #[test]
fn sanitize_bad_values() { fn sanitize_bad_values() {
let mut p = Playback { volume: 250, seek_step: -3.0, volume_step: 900.0, ..Playback::default() }; let mut p = Playback {
volume: 250,
seek_step: -3.0,
volume_step: 900.0,
..Playback::default()
};
p.sanitize(); p.sanitize();
assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0)); assert_eq!((p.volume, p.seek_step, p.volume_step), (100, 5.0, 100.0));
} }

View file

@ -49,8 +49,14 @@ fn tool_version(name: &str) -> Option<String> {
fn missing_dependencies(verbose: bool) -> Vec<String> { fn missing_dependencies(verbose: bool) -> Vec<String> {
let mut missing = Vec::new(); let mut missing = Vec::new();
for (tool, hint) in [ for (tool, hint) in [
("mpv", "sudo apt install mpv | brew install mpv | sudo pacman -S mpv"), (
("yt-dlp", "pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp"), "mpv",
"sudo apt install mpv | brew install mpv | sudo pacman -S mpv",
),
(
"yt-dlp",
"pipx install yt-dlp | brew install yt-dlp | sudo pacman -S yt-dlp",
),
] { ] {
match tool_version(tool) { match tool_version(tool) {
Some(v) => { Some(v) => {
@ -122,7 +128,7 @@ fn run(initial_query: Option<String>) -> Result<()> {
app.run_command(&format!("search {q}")); app.run_command(&format!("search {q}"));
} }
let mut terminal = ratatui::init(); let mut terminal = ratatui::try_init()?;
let result = event_loop(&mut terminal, &mut app, &rx); let result = event_loop(&mut terminal, &mut app, &rx);
ratatui::restore(); ratatui::restore();
app.shutdown(); app.shutdown();

View file

@ -24,7 +24,9 @@ impl Track {
pub fn split_artists(credit: &str) -> Vec<String> { pub fn split_artists(credit: &str) -> Vec<String> {
const MARK: char = '\u{1}'; const MARK: char = '\u{1}';
let mut s = credit.to_string(); let mut s = credit.to_string();
for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] { for sep in [
" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × ",
] {
s = s.replace(sep, &MARK.to_string()); s = s.replace(sep, &MARK.to_string());
} }
let mut out: Vec<String> = Vec::new(); let mut out: Vec<String> = Vec::new();
@ -52,7 +54,7 @@ pub fn parse_clock(s: &str) -> Option<f64> {
let mut total = 0.0; let mut total = 0.0;
for part in s.split(':') { for part in s.split(':') {
let v: f64 = part.trim().parse().ok()?; let v: f64 = part.trim().parse().ok()?;
if v < 0.0 { if !v.is_finite() || v < 0.0 {
return None; return None;
} }
total = total * 60.0 + v; total = total * 60.0 + v;
@ -61,7 +63,11 @@ pub fn parse_clock(s: &str) -> Option<f64> {
} }
pub fn fmt_time(secs: f64) -> String { pub fn fmt_time(secs: f64) -> String {
let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 }; let s = if secs.is_finite() {
secs.max(0.0) as u64
} else {
0
};
let (h, m, s) = (s / 3600, (s % 3600) / 60, s % 60); let (h, m, s) = (s / 3600, (s % 3600) / 60, s % 60);
if h > 0 { if h > 0 {
format!("{h}:{m:02}:{s:02}") format!("{h}:{m:02}:{s:02}")
@ -104,8 +110,10 @@ mod tests {
fn clock_parsing() { fn clock_parsing() {
assert_eq!(parse_clock("3:45"), Some(225.0)); assert_eq!(parse_clock("3:45"), Some(225.0));
assert_eq!(parse_clock("1:02:03"), Some(3723.0)); assert_eq!(parse_clock("1:02:03"), Some(3723.0));
assert_eq!(parse_clock("90"), Some(5400.0 / 60.0)); assert_eq!(parse_clock("90"), Some(90.0));
assert_eq!(parse_clock("abc"), None); assert_eq!(parse_clock("abc"), None);
assert_eq!(parse_clock("NaN"), None);
assert_eq!(parse_clock("inf"), None);
assert_eq!(fmt_time(225.0), "3:45"); assert_eq!(fmt_time(225.0), "3:45");
assert_eq!(fmt_time(3723.0), "1:02:03"); assert_eq!(fmt_time(3723.0), "1:02:03");
} }

View file

@ -26,12 +26,9 @@ pub struct Player {
} }
impl Player { impl Player {
pub fn spawn( pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<Player> {
volume: u8, let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
on_event: impl Fn(PlayerEvent) + Send + 'static, let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
) -> Result<Player> {
let sock_path =
std::env::temp_dir().join(format!("drift-{}.sock", std::process::id()));
let _ = std::fs::remove_file(&sock_path); let _ = std::fs::remove_file(&sock_path);
let mut cmd = Command::new("mpv"); let mut cmd = Command::new("mpv");
@ -51,13 +48,14 @@ impl Player {
.arg(format!("--volume={volume}")) .arg(format!("--volume={volume}"))
.arg(format!("--input-ipc-server={}", sock_path.display())); .arg(format!("--input-ipc-server={}", sock_path.display()));
if let Ok(extra) = std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) { if let Ok(extra) =
std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS"))
{
cmd.args(extra.split_whitespace()); cmd.args(extra.split_whitespace());
} }
cmd cmd.stdin(Stdio::null())
.stdin(Stdio::null()) .stdout(Stdio::null())
.stdout(Stdio::null()) .stderr(Stdio::null());
.stderr(Stdio::null());
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
unsafe { unsafe {
@ -101,14 +99,21 @@ impl Player {
sock_path, sock_path,
}; };
for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] { for (id, prop) in [
(1, "time-pos"),
(2, "duration"),
(3, "pause"),
(4, "volume"),
] {
player.send(json!(["observe_property", id, prop]))?; player.send(json!(["observe_property", id, prop]))?;
} }
std::thread::spawn(move || { std::thread::spawn(move || {
for line in BufReader::new(reader).lines() { for line in BufReader::new(reader).lines() {
let Ok(line) = line else { break }; let Ok(line) = line else { break };
let Ok(v) = serde_json::from_str::<Value>(&line) else { continue }; let Ok(v) = serde_json::from_str::<Value>(&line) else {
continue;
};
if let Some(ev) = parse_event(&v) { if let Some(ev) = parse_event(&v) {
on_event(ev); on_event(ev);
} }
@ -211,7 +216,9 @@ mod tests {
fn mpv_roundtrip() { fn mpv_roundtrip() {
use std::sync::mpsc; use std::sync::mpsc;
let wav = std::env::temp_dir().join("drift-test.wav"); let wav = std::env::temp_dir().join("drift-test.wav");
let samples: Vec<u8> = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect(); let samples: Vec<u8> = (0..16_000u32)
.map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 })
.collect();
let mut data = Vec::new(); let mut data = Vec::new();
data.extend_from_slice(b"RIFF"); data.extend_from_slice(b"RIFF");
data.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes()); data.extend_from_slice(&(36 + samples.len() as u32).to_le_bytes());

View file

@ -1,10 +1,11 @@
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::model::{is_valid_id, Track}; use crate::model::{is_valid_id, Track};
use crate::store::write_atomic;
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@ -27,8 +28,12 @@ impl Session {
} }
pub fn load() -> Session { pub fn load() -> Session {
let Ok(path) = Self::path() else { return Session::default() }; let Ok(path) = Self::path() else {
let Ok(text) = fs::read_to_string(&path) else { return Session::default() }; return Session::default();
};
let Ok(text) = fs::read_to_string(&path) else {
return Session::default();
};
match serde_json::from_str::<Session>(&text) { match serde_json::from_str::<Session>(&text) {
Ok(mut s) => { Ok(mut s) => {
s.sanitize(); s.sanitize();
@ -58,14 +63,7 @@ impl Session {
} }
pub fn write(text: &str) -> Result<()> { pub fn write(text: &str) -> Result<()> {
let path = Self::path()?; write_atomic(&Self::path()?, text)
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
fs::write(&tmp, text).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
} }
} }
@ -74,25 +72,37 @@ mod tests {
use super::*; use super::*;
fn track(id: &str) -> Track { fn track(id: &str) -> Track {
Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) } Track {
id: id.into(),
title: "T".into(),
artist: "A".into(),
album: None,
duration: Some(200),
}
} }
#[test] #[test]
fn roundtrip_and_sanitize() { fn sanitize_drops_bad_tracks_and_clamps() {
let s = Session { let mut s = Session {
queue: vec![track("aaaaaaaaaaa"), track("bad")], queue: vec![track("aaaaaaaaaaa"), track("bad")],
current: Some(7), current: Some(7),
position: f64::NAN, position: f64::NAN,
}; };
let mut back: Session = serde_json::from_str(&s.render().unwrap().replace("NaN", "null")).unwrap_or_default(); s.sanitize();
back.queue = s.queue.clone(); assert_eq!((s.queue.len(), s.current, s.position), (1, Some(0), 0.0));
back.current = s.current; }
back.position = s.position;
back.sanitize();
assert_eq!((back.queue.len(), back.current, back.position), (1, Some(0), 0.0));
let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 }; #[test]
fn roundtrip() {
let ok = Session {
queue: vec![track("aaaaaaaaaaa")],
current: Some(0),
position: 42.5,
};
let again: Session = serde_json::from_str(&ok.render().unwrap()).unwrap(); let again: Session = serde_json::from_str(&ok.render().unwrap()).unwrap();
assert_eq!((again.current, again.position, again.queue), (Some(0), 42.5, ok.queue)); assert_eq!(
(again.current, again.position, again.queue),
(Some(0), 42.5, ok.queue)
);
} }
} }

View file

@ -1,6 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fs; use std::fs;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -12,15 +12,29 @@ pub struct Store {
pub playlists: BTreeMap<String, Vec<Track>>, pub playlists: BTreeMap<String, Vec<Track>>,
} }
pub fn write_atomic(path: &Path, text: &str) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
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(())
}
impl Store { impl Store {
pub fn path() -> Result<PathBuf> { pub fn dir() -> Result<PathBuf> {
let dir = dirs::config_dir().ok_or_else(|| anyhow!("cannot determine config directory"))?; let dir = dirs::config_dir().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() {
let _ = fs::rename(&old_dir, &new_dir); let _ = fs::rename(&old_dir, &new_dir);
} }
Ok(new_dir.join("playlists.json")) Ok(new_dir)
}
fn path() -> Result<PathBuf> {
Ok(Self::dir()?.join("playlists.json"))
} }
pub fn load() -> (Store, Option<String>) { pub fn load() -> (Store, Option<String>) {
@ -55,15 +69,7 @@ impl Store {
} }
pub fn save(&self) -> Result<()> { pub fn save(&self) -> Result<()> {
let path = Self::path()?; write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?)
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("creating {}", dir.display()))?;
}
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_string_pretty(self)?;
fs::write(&tmp, json).with_context(|| format!("writing {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("replacing {}", path.display()))?;
Ok(())
} }
pub fn resolve(&self, name: &str) -> Option<String> { pub fn resolve(&self, name: &str) -> Option<String> {

198
src/ui.rs
View file

@ -11,7 +11,10 @@ const ACCENT: Color = Color::Cyan;
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
fn selected_style() -> Style { fn selected_style() -> Style {
Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD) Style::new()
.fg(Color::Black)
.bg(ACCENT)
.add_modifier(Modifier::BOLD)
} }
fn header_style() -> Style { fn header_style() -> Style {
@ -56,7 +59,10 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
]) ])
.areas(area); .areas(area);
f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name); f.render_widget(
Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()),
name,
);
let titles = [ let titles = [
format!(" Results {} ", app.results.len()), format!(" Results {} ", app.results.len()),
@ -83,6 +89,20 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
} }
} }
fn playing_marker(app: &App) -> Option<(String, &'static str)> {
let id = app
.now_track()
.filter(|_| app.play_state != PlayState::Stopped)?
.id
.clone();
let marker = match app.play_state {
PlayState::Paused => "⏸",
PlayState::Loading => "…",
_ => "▶",
};
Some((id, marker))
}
fn track_rows<'a>( fn track_rows<'a>(
tracks: &'a [Track], tracks: &'a [Track],
playing: Option<(&str, &'a str)>, playing: Option<(&str, &'a str)>,
@ -93,7 +113,11 @@ fn track_rows<'a>(
.enumerate() .enumerate()
.map(|(i, t)| { .map(|(i, t)| {
let is_playing = playing.is_some_and(|(id, _)| id == t.id); let is_playing = playing.is_some_and(|(id, _)| id == t.id);
let marker = if is_playing { playing.map_or("", |p| p.1) } else { "" }; let marker = if is_playing {
playing.map_or("", |p| p.1)
} else {
""
};
let mut cells = vec![ let mut cells = vec![
Cell::from(marker), Cell::from(marker),
Cell::from(format!("{:>3}", i + 1)).style(dim()), Cell::from(format!("{:>3}", i + 1)).style(dim()),
@ -104,8 +128,7 @@ fn track_rows<'a>(
cells.push(Cell::from(t.album.as_deref().unwrap_or(""))); cells.push(Cell::from(t.album.as_deref().unwrap_or("")));
} }
cells.push( cells.push(
Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))) Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64))).style(dim()),
.style(dim()),
); );
let row = Row::new(cells); let row = Row::new(cells);
if is_playing { if is_playing {
@ -142,23 +165,17 @@ fn track_table<'a>(rows: Vec<Row<'a>>, show_album: bool, block: Block<'a>) -> Ta
fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) { fn empty_hint(f: &mut Frame, block: Block, area: Rect, lines: &[&str]) {
let text: Vec<Line> = lines.iter().map(|l| Line::from(*l).style(dim())).collect(); let text: Vec<Line> = lines.iter().map(|l| Line::from(*l).style(dim())).collect();
f.render_widget( f.render_widget(
Paragraph::new(text).alignment(Alignment::Center).block(block), Paragraph::new(text)
.alignment(Alignment::Center)
.block(block),
area, area,
); );
} }
fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) { fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
let show_album = area.width >= 76 && view == View::Results; let show_album = area.width >= 76 && view == View::Results;
let marker = match app.play_state { let now = playing_marker(app);
PlayState::Paused => "⏸", let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker));
PlayState::Loading => "…",
_ => "▶",
};
let now_id = app
.now_track()
.filter(|_| app.play_state != PlayState::Stopped)
.map(|t| t.id.clone());
let playing = now_id.as_deref().map(|id| (id, marker));
let (tracks, title) = match view { let (tracks, title) = match view {
View::Queue => (&app.queue, " Queue ".to_string()), View::Queue => (&app.queue, " Queue ".to_string()),
@ -170,9 +187,17 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
if tracks.is_empty() { if tracks.is_empty() {
let hint: &[&str] = if view == View::Queue { let hint: &[&str] = if view == View::Queue {
&["", "The queue is empty.", "Press `a` on a search result to queue it."] &[
"",
"The queue is empty.",
"Press `a` on a search result to queue it.",
]
} else { } else {
&["", "No results yet.", "Press / and type a query, or use :search \"daft punk\""] &[
"",
"No results yet.",
"Press / and type a query, or use :search \"daft punk\"",
]
}; };
return empty_hint(f, block, area, hint); return empty_hint(f, block, area, hint);
} }
@ -183,18 +208,12 @@ fn draw_tracks(f: &mut Frame, app: &mut App, area: Rect, view: View) {
} }
fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) { fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
let marker = match app.play_state { let now = playing_marker(app);
PlayState::Paused => "⏸", let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker));
PlayState::Loading => "…",
_ => "▶",
};
let now_id = app
.now_track()
.filter(|_| app.play_state != PlayState::Stopped)
.map(|t| t.id.clone());
let playing = now_id.as_deref().map(|id| (id, marker));
let Some(tracks) = app.store.playlists.get(name) else { return }; let Some(tracks) = app.store.playlists.get(name) else {
return;
};
let block = Block::bordered() let 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));
@ -203,7 +222,11 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
f, f,
block, block,
area, area,
&["", "This playlist is empty.", "Add songs with :addto \"name\" from Results or Queue."], &[
"",
"This playlist is empty.",
"Add songs with :addto \"name\" from Results or Queue.",
],
); );
} }
let show_album = area.width >= 76; let show_album = area.width >= 76;
@ -230,7 +253,12 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
f, f,
block, block,
left, left,
&["", "No playlists yet.", "Queue some songs, then", ":save \"name\""], &[
"",
"No playlists yet.",
"Queue some songs, then",
":save \"name\"",
],
); );
let b = Block::bordered().title(" Preview ").border_style(dim()); let b = Block::bordered().title(" Preview ").border_style(dim());
f.render_widget(b, right); f.render_widget(b, right);
@ -252,7 +280,11 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
.collect(); .collect();
let table = Table::new( let table = Table::new(
rows, rows,
[Constraint::Length(3), Constraint::Fill(1), Constraint::Length(5)], [
Constraint::Length(3),
Constraint::Fill(1),
Constraint::Length(5),
],
) )
.header(Row::new(vec!["", "Name", "Songs"]).style(header_style())) .header(Row::new(vec!["", "Name", "Songs"]).style(header_style()))
.block(block) .block(block)
@ -261,7 +293,10 @@ fn draw_playlists(f: &mut Frame, app: &mut App, area: Rect) {
let sel = app.selected(); let sel = app.selected();
let preview_block = Block::bordered().title(" Preview ").border_style(dim()); let preview_block = Block::bordered().title(" Preview ").border_style(dim());
match app.playlist_name(sel).and_then(|n| app.store.playlists.get(&n)) { match app
.playlist_name(sel)
.and_then(|n| app.store.playlists.get(&n))
{
Some(tracks) if !tracks.is_empty() => { Some(tracks) if !tracks.is_empty() => {
let show_album = right.width >= 70; let show_album = right.width >= 70;
let rows = track_rows(tracks, None, show_album); let rows = track_rows(tracks, None, show_album);
@ -290,7 +325,12 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
let flags_w = flags.chars().count() as u16 + 1; let flags_w = flags.chars().count() as u16 + 1;
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)]).areas(l1);
f.render_widget(Paragraph::new(flags).style(dim()).alignment(Alignment::Right), flags_area); f.render_widget(
Paragraph::new(flags)
.style(dim())
.alignment(Alignment::Right),
flags_area,
);
let icon = match app.play_state { let icon = match app.play_state {
PlayState::Playing => "▶".to_string(), PlayState::Playing => "▶".to_string(),
@ -316,7 +356,14 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
f.render_widget(Paragraph::new(line), title_area); f.render_widget(Paragraph::new(line), title_area);
let left = format!(" {} ", fmt_time(app.position)); let left = format!(" {} ", fmt_time(app.position));
let right = format!(" {} ", if app.duration > 0.0 { fmt_time(app.duration) } else { "--:--".into() }); let right = format!(
" {} ",
if app.duration > 0.0 {
fmt_time(app.duration)
} else {
"--:--".into()
}
);
let bar_w = (l2.width as usize).saturating_sub(left.chars().count() + right.chars().count()); let bar_w = (l2.width as usize).saturating_sub(left.chars().count() + right.chars().count());
let ratio = if app.duration > 0.0 { let ratio = if app.duration > 0.0 {
(app.position / app.duration).clamp(0.0, 1.0) (app.position / app.duration).clamp(0.0, 1.0)
@ -336,9 +383,12 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) { fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
if app.mode == Mode::Command { if app.mode == Mode::Command {
let text = format!(":{}", app.cmd_buf); let text = format!(":{}", app.cmd_buf);
let x = area.x + text.chars().count() as u16; let col = text
.chars()
.count()
.min(area.width.saturating_sub(1) as usize) as u16;
f.render_widget(Paragraph::new(text), area); f.render_widget(Paragraph::new(text), area);
f.set_cursor_position((x.min(area.right().saturating_sub(1)), area.y)); f.set_cursor_position((area.x + col, area.y));
return; return;
} }
@ -363,7 +413,9 @@ fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
} }
if !pending.is_empty() { if !pending.is_empty() {
f.render_widget( f.render_widget(
Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)), Paragraph::new(format!("{pending} "))
.alignment(Alignment::Right)
.style(Style::new().fg(Color::Yellow)),
area, area,
); );
} }
@ -380,16 +432,25 @@ const HELP: &[(&str, &str)] = &[
("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"),
("PLAYBACK", "#"), ("PLAYBACK", "#"),
("Enter", "play selected (on a playlist: load it; inside one: play from there)"), (
"Enter",
"play selected (on a playlist: load it; inside one: play from there)",
),
("Space", "pause / resume"), ("Space", "pause / resume"),
("n N (or > <)", "next / previous track"), ("n N (or > <)", "next / previous track"),
("h l / ← →", "seek -5s / +5s (counts: 6l)"), ("h l / ← →", "seek -5s / +5s (counts: 6l)"),
("+ -", "volume up / down"), ("+ -", "volume up / down"),
("s r", "toggle shuffle / cycle repeat"), ("s r", "toggle shuffle / cycle repeat"),
("ga or :artist", "songs by the artist of the selected song; a popup asks which one when there are several"), (
"ga or :artist",
"songs by the artist of the selected song; a popup asks which one when there are several",
),
("QUEUE", "#"), ("QUEUE", "#"),
("a A P", "add selected / add all / play all results"), ("a A P", "add selected / add all / play all results"),
("dd x", "remove from queue / delete playlist / delete track in a playlist"), (
"dd x",
"remove from queue / delete playlist / delete track in a playlist",
),
("J K", "move queue or playlist item down / up"), ("J K", "move queue or playlist item down / up"),
("PLAYLISTS", "#"), ("PLAYLISTS", "#"),
("→ or l", "open the highlighted playlist"), ("→ or l", "open the highlighted playlist"),
@ -403,10 +464,19 @@ const HELP: &[(&str, &str)] = &[
(":vol 50 | +5", "set or change the volume"), (":vol 50 | +5", "set or change the volume"),
(":shuffle :repeat [m]", "modes: off, all or one"), (":shuffle :repeat [m]", "modes: off, all or one"),
(":add [N] :addall", "queue a result / all results"), (":add [N] :addall", "queue a result / all results"),
(":playall :remove [N]", "play all results / remove from queue"), (
":playall :remove [N]",
"play all results / remove from queue",
),
(":clear", "empty the queue"), (":clear", "empty the queue"),
(":save n :load n", "save the queue as a playlist / load one"), (
(":append n :delpl n", "append a playlist to the queue / delete it"), ":save n :load n",
"save the queue as a playlist / load one",
),
(
":append n :delpl n",
"append a playlist to the queue / delete it",
),
(":addto n", "add the selected track to playlist n"), (":addto n", "add the selected track to playlist n"),
(":import URL", "load a YouTube / YT Music playlist"), (":import URL", "load a YouTube / YT Music playlist"),
(":artist", "same as ga"), (":artist", "same as ga"),
@ -462,7 +532,10 @@ fn help_lines(inner_w: usize) -> Vec<Line<'static>> {
} }
} }
for rest in desc { for rest in desc {
lines.push(Line::from(vec![Span::raw(" ".repeat(key_w)), Span::raw(rest)])); lines.push(Line::from(vec![
Span::raw(" ".repeat(key_w)),
Span::raw(rest),
]));
} }
} }
lines lines
@ -470,9 +543,14 @@ fn help_lines(inner_w: usize) -> Vec<Line<'static>> {
fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) { fn draw_artist_picker(f: &mut Frame, app: &App, area: Rect) {
let n = app.artist_opts.len() as u16; let n = app.artist_opts.len() as u16;
let widest = app.artist_opts.iter().map(|a| a.chars().count()).max().unwrap_or(0) as u16; let widest = app
.artist_opts
.iter()
.map(|a| a.chars().count())
.max()
.unwrap_or(0) as u16;
let w = (widest + 10).max(34).min(area.width); let w = (widest + 10).max(34).min(area.width);
let h = (n + 4).min(area.height); let h = (n + 3).min(area.height);
let popup = Rect { let popup = Rect {
x: area.x + (area.width - w) / 2, x: area.x + (area.width - w) / 2,
y: area.y + (area.height - h) / 2, y: area.y + (area.height - h) / 2,
@ -525,7 +603,12 @@ fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
.border_style(Style::new().fg(Color::Yellow)); .border_style(Style::new().fg(Color::Yellow));
if max_scroll > 0 { if max_scroll > 0 {
let end = (scroll + visible).min(lines.len()); let end = (scroll + visible).min(lines.len());
block = block.title_bottom(format!(" j/k scroll · {}-{} of {} ", scroll + 1, end, lines.len())); block = block.title_bottom(format!(
" j/k scroll · {}-{} of {} ",
scroll + 1,
end,
lines.len()
));
} }
let shown: Vec<Line> = lines.into_iter().skip(scroll).take(visible).collect(); let shown: Vec<Line> = lines.into_iter().skip(scroll).take(visible).collect();
@ -543,14 +626,20 @@ mod tests {
use std::sync::mpsc; use std::sync::mpsc;
fn text(term: &Terminal<TestBackend>) -> String { fn text(term: &Terminal<TestBackend>) -> String {
term.backend().buffer().content().iter().map(|c| c.symbol()).collect() term.backend()
.buffer()
.content()
.iter()
.map(|c| c.symbol())
.collect()
} }
#[test] #[test]
fn open_playlist_renders() { fn open_playlist_renders() {
let dir = std::env::temp_dir().join(format!("drift-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("XDG_CONFIG_HOME", &dir);
std::env::set_var("XDG_STATE_HOME", &dir);
let (tx, _rx) = mpsc::channel(); let (tx, _rx) = mpsc::channel();
let mut app = App::new(tx); let mut app = App::new(tx);
@ -562,7 +651,13 @@ mod tests {
duration: Some(90), duration: Some(90),
}; };
app.store.playlists.insert("empty".into(), vec![]); app.store.playlists.insert("empty".into(), vec![]);
app.store.playlists.insert("mix".into(), vec![t("aaaaaaaaaaa", "First Song"), t("bbbbbbbbbbb", "Second Song")]); app.store.playlists.insert(
"mix".into(),
vec![
t("aaaaaaaaaaa", "First Song"),
t("bbbbbbbbbbb", "Second Song"),
],
);
let key = |app: &mut App, c| app.on_key(KeyEvent::new(c, KeyModifiers::NONE)); let key = |app: &mut App, c| app.on_key(KeyEvent::new(c, KeyModifiers::NONE));
let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap(); let mut term = Terminal::new(TestBackend::new(100, 24)).unwrap();
@ -598,5 +693,6 @@ mod tests {
narrow.draw(|f| draw(f, &mut app)).unwrap(); narrow.draw(|f| draw(f, &mut app)).unwrap();
key(&mut app, KeyCode::Char('q')); key(&mut app, KeyCode::Char('q'));
let _ = std::fs::remove_dir_all(&dir);
} }
} }

View file

@ -16,7 +16,9 @@ pub fn search(query: &str) -> Result<Vec<Track>, String> {
Ok(tracks) if !tracks.is_empty() => Ok(tracks), Ok(tracks) if !tracks.is_empty() => Ok(tracks),
Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")), Ok(_) => ytdlp_search(query).map_err(|e| format!("{e:#}")),
Err(primary) => ytdlp_search(query).map_err(|fallback| { Err(primary) => ytdlp_search(query).map_err(|fallback| {
format!("YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})") format!(
"YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})"
)
}), }),
} }
} }
@ -25,7 +27,7 @@ pub fn import(url: &str) -> Result<Vec<Track>, String> {
if !(url.starts_with("http://") || url.starts_with("https://")) { if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("import expects a full http(s) URL".into()); return Err("import expects a full http(s) URL".into());
} }
ytdlp_entries(url, &[]).map_err(|e| format!("{e:#}")) ytdlp_entries(url).map_err(|e| format!("{e:#}"))
} }
fn client_version() -> String { fn client_version() -> String {
@ -73,7 +75,9 @@ fn search_innertube(query: &str) -> Result<Vec<Track>> {
.set("Cookie", "SOCS=CAI") .set("Cookie", "SOCS=CAI")
.send_json(body) .send_json(body)
.map_err(|e| anyhow!("request failed: {e}"))?; .map_err(|e| anyhow!("request failed: {e}"))?;
let v: Value = resp.into_json().context("invalid JSON from YouTube Music")?; let v: Value = resp
.into_json()
.context("invalid JSON from YouTube Music")?;
Ok(parse_search_response(&v)) Ok(parse_search_response(&v))
} }
@ -115,7 +119,9 @@ fn column_segments(item: &Value, col: usize) -> Vec<String> {
let mut segments = Vec::new(); let mut segments = Vec::new();
let mut cur = String::new(); let mut cur = String::new();
for run in runs.into_iter().flatten() { for run in runs.into_iter().flatten() {
let Some(text) = run.get("text").and_then(Value::as_str) else { continue }; let Some(text) = run.get("text").and_then(Value::as_str) else {
continue;
};
if text.trim() == "•" { if text.trim() == "•" {
segments.push(std::mem::take(&mut cur)); segments.push(std::mem::take(&mut cur));
} else { } else {
@ -142,7 +148,10 @@ fn parse_item(item: &Value) -> Option<Track> {
let title = column_segments(item, 0).into_iter().next()?; let title = column_segments(item, 0).into_iter().next()?;
let mut meta = column_segments(item, 1); let mut meta = column_segments(item, 1);
if matches!(meta.first().map(String::as_str), Some("Song" | "Video" | "Episode")) { if matches!(
meta.first().map(String::as_str),
Some("Song" | "Video" | "Episode")
) {
meta.remove(0); meta.remove(0);
} }
let duration = match meta.last() { let duration = match meta.last() {
@ -153,7 +162,10 @@ fn parse_item(item: &Value) -> Option<Track> {
} }
_ => None, _ => None,
}; };
let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into()); let artist = meta
.first()
.cloned()
.unwrap_or_else(|| "Unknown artist".into());
let album = meta let album = meta
.get(1) .get(1)
.filter(|a| !a.contains(" views") && !a.contains(" plays")) .filter(|a| !a.contains(" views") && !a.contains(" plays"))
@ -180,10 +192,10 @@ pub fn parse_search_response(v: &Value) -> Vec<Track> {
} }
fn ytdlp_search(query: &str) -> Result<Vec<Track>> { fn ytdlp_search(query: &str) -> Result<Vec<Track>> {
ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"), &[]) ytdlp_entries(&format!("ytsearch{FALLBACK_COUNT}:{query}"))
} }
fn ytdlp_entries(target: &str, extra: &[&str]) -> Result<Vec<Track>> { fn ytdlp_entries(target: &str) -> Result<Vec<Track>> {
let out = Command::new("yt-dlp") let out = Command::new("yt-dlp")
.args([ .args([
"--flat-playlist", "--flat-playlist",
@ -193,7 +205,6 @@ fn ytdlp_entries(target: &str, extra: &[&str]) -> Result<Vec<Track>> {
"--socket-timeout", "--socket-timeout",
"15", "15",
]) ])
.args(extra)
.arg(target) .arg(target)
.stdin(Stdio::null()) .stdin(Stdio::null())
.output() .output()