mirror of
https://github.com/socatlolmeow/drift.git
synced 2026-11-03 11:20:20 +00:00
(add): add confirmation for deleting playlists + fix playlist name overwriting
This commit is contained in:
parent
bcff53c373
commit
f1eb55801f
8 changed files with 161 additions and 353 deletions
178
src/app.rs
178
src/app.rs
|
|
@ -48,6 +48,7 @@ pub enum Mode {
|
||||||
Normal,
|
Normal,
|
||||||
Command,
|
Command,
|
||||||
ArtistPick,
|
ArtistPick,
|
||||||
|
ConfirmDelete,
|
||||||
Help,
|
Help,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,6 +87,7 @@ pub struct App {
|
||||||
pub cmd_buf: String,
|
pub cmd_buf: String,
|
||||||
pub artist_opts: Vec<String>,
|
pub artist_opts: Vec<String>,
|
||||||
pub artist_sel: usize,
|
pub artist_sel: usize,
|
||||||
|
pub pending_delete: Option<String>,
|
||||||
pub help_scroll: usize,
|
pub help_scroll: usize,
|
||||||
history: Vec<String>,
|
history: Vec<String>,
|
||||||
hist_pos: Option<usize>,
|
hist_pos: Option<usize>,
|
||||||
|
|
@ -148,6 +150,7 @@ impl App {
|
||||||
cmd_buf: String::new(),
|
cmd_buf: String::new(),
|
||||||
artist_opts: Vec::new(),
|
artist_opts: Vec::new(),
|
||||||
artist_sel: 0,
|
artist_sel: 0,
|
||||||
|
pending_delete: None,
|
||||||
help_scroll: 0,
|
help_scroll: 0,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
hist_pos: None,
|
hist_pos: None,
|
||||||
|
|
@ -230,17 +233,13 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn set_status(&mut self, text: String, level: Level) {
|
fn set_status(&mut self, text: String, level: Level) {
|
||||||
self.status = Some(Status {
|
self.status = Some(Status { text, level, at: Instant::now() });
|
||||||
text,
|
|
||||||
level,
|
|
||||||
at: Instant::now(),
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn visible_status(&self) -> Option<&Status> {
|
pub fn visible_status(&self) -> Option<&Status> {
|
||||||
self.status
|
self.status.as_ref().filter(|s| {
|
||||||
.as_ref()
|
s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 }
|
||||||
.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) {
|
||||||
|
|
@ -267,17 +266,11 @@ impl App {
|
||||||
|
|
||||||
pub fn in_playlist(&self) -> bool {
|
pub fn in_playlist(&self) -> bool {
|
||||||
self.view == View::Playlists
|
self.view == View::Playlists
|
||||||
&& self
|
&& self.open.as_ref().is_some_and(|n| self.store.playlists.contains_key(n))
|
||||||
.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
|
self.open.as_ref().and_then(|n| self.store.playlists.get(n)).map_or(0, |t| t.len())
|
||||||
.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 {
|
||||||
|
|
@ -347,6 +340,7 @@ impl App {
|
||||||
Mode::Help => self.key_help(key),
|
Mode::Help => self.key_help(key),
|
||||||
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::Normal => self.key_normal(key),
|
Mode::Normal => self.key_normal(key),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -425,12 +419,8 @@ 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 => {
|
KeyCode::Char('h') | KeyCode::Left => 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('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))
|
||||||
}
|
}
|
||||||
|
|
@ -461,6 +451,14 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn key_confirm_delete(&mut self, key: KeyEvent) {
|
||||||
|
self.mode = Mode::Normal;
|
||||||
|
let name = self.pending_delete.take();
|
||||||
|
if let (KeyCode::Char('y'), Some(name)) = (key.code, name) {
|
||||||
|
self.delete_playlist(&name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn open_help(&mut self) {
|
fn open_help(&mut self) {
|
||||||
self.help_scroll = 0;
|
self.help_scroll = 0;
|
||||||
self.mode = Mode::Help;
|
self.mode = Mode::Help;
|
||||||
|
|
@ -680,9 +678,7 @@ 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 => {
|
KeyCode::Char('j') | KeyCode::Down => self.artist_sel = (self.artist_sel + 1).min(len - 1),
|
||||||
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;
|
||||||
|
|
@ -720,28 +716,22 @@ impl App {
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|t| {
|
.filter(|t| {
|
||||||
t.artist.to_lowercase() == wanted
|
t.artist.to_lowercase() == wanted
|
||||||
|| split_artists(&t.artist)
|
|| split_artists(&t.artist).iter().any(|a| a.to_lowercase() == wanted)
|
||||||
.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(
|
self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || {
|
||||||
format!("Search: {query}"),
|
ytmusic::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(
|
self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || {
|
||||||
"Imported playlist".into(),
|
ytmusic::import(&url)
|
||||||
"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)
|
||||||
|
|
@ -853,9 +843,7 @@ 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!(
|
self.error(format!("Can't play \u{201c}{name}\u{201d} ({msg}) — skipping"));
|
||||||
"Can't play \u{201c}{name}\u{201d} ({msg}) — skipping"
|
|
||||||
));
|
|
||||||
self.advance(true);
|
self.advance(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -874,9 +862,7 @@ 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 {
|
let Some(track) = self.queue.get(i).cloned() else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
if !keep_resume {
|
if !keep_resume {
|
||||||
self.resume = None;
|
self.resume = None;
|
||||||
}
|
}
|
||||||
|
|
@ -967,9 +953,7 @@ 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 {
|
let Some(track) = self.radio.get(i).cloned() else { return };
|
||||||
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;
|
||||||
|
|
@ -994,11 +978,7 @@ 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 {
|
self.play_state = if pause { PlayState::Paused } else { PlayState::Playing };
|
||||||
PlayState::Paused
|
|
||||||
} else {
|
|
||||||
PlayState::Playing
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
Err(e) => self.error(format!("{e:#}")),
|
Err(e) => self.error(format!("{e:#}")),
|
||||||
}
|
}
|
||||||
|
|
@ -1162,10 +1142,7 @@ 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!(
|
self.info(format!("Shuffle: {}", if self.shuffle { "on" } else { "off" }));
|
||||||
"Shuffle: {}",
|
|
||||||
if self.shuffle { "on" } else { "off" }
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cycle_repeat(&mut self) {
|
fn cycle_repeat(&mut self) {
|
||||||
|
|
@ -1219,11 +1196,7 @@ 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 {
|
let start = if self.view == View::Results { self.selected() } else { 0 };
|
||||||
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;
|
||||||
|
|
@ -1266,11 +1239,7 @@ 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 {
|
self.states[View::Queue.idx()].select(if len == 0 { None } else { Some(sel.min(len - 1)) });
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(sel.min(len - 1))
|
|
||||||
});
|
|
||||||
self.info(format!("Removed: {}", removed.label()));
|
self.info(format!("Removed: {}", removed.label()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1300,13 +1269,12 @@ impl App {
|
||||||
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 => {
|
View::Playlists => {
|
||||||
if let Some(n) = self.playlist_name(i) {
|
if let Some(name) = self.playlist_name(i) {
|
||||||
self.delete_playlist(&n)
|
self.pending_delete = Some(name);
|
||||||
|
self.mode = Mode::ConfirmDelete;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
View::Results => {
|
View::Results => self.info("Results can't be deleted — they are replaced by the next search"),
|
||||||
self.info("Results can't be deleted — they are replaced by the next search")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1325,13 +1293,9 @@ impl App {
|
||||||
return self.error("Queue is empty — nothing to save");
|
return self.error("Queue is empty — nothing to save");
|
||||||
}
|
}
|
||||||
let n = self.queue.len();
|
let n = self.queue.len();
|
||||||
self.store
|
self.store.playlists.insert(name.to_string(), self.queue.clone());
|
||||||
.playlists
|
|
||||||
.insert(name.to_string(), self.queue.clone());
|
|
||||||
if self.persist() {
|
if self.persist() {
|
||||||
self.info(format!(
|
self.info(format!("Saved {n} tracks to playlist \u{201c}{name}\u{201d}"));
|
||||||
"Saved {n} tracks to playlist \u{201c}{name}\u{201d}"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1350,7 +1314,7 @@ impl App {
|
||||||
let Some(track) = track else {
|
let Some(track) = track else {
|
||||||
return self.error("Select a track in Results or Queue first");
|
return self.error("Select a track in Results or Queue first");
|
||||||
};
|
};
|
||||||
let key = self.store.resolve(name).unwrap_or_else(|| name.to_string());
|
let key = self.store.resolve_exact(name).unwrap_or_else(|| name.to_string());
|
||||||
let list = self.store.playlists.entry(key.clone()).or_default();
|
let list = self.store.playlists.entry(key.clone()).or_default();
|
||||||
if list.iter().any(|t| t.id == track.id) {
|
if list.iter().any(|t| t.id == track.id) {
|
||||||
return self.info(format!("Already in \u{201c}{key}\u{201d}"));
|
return self.info(format!("Already in \u{201c}{key}\u{201d}"));
|
||||||
|
|
@ -1377,14 +1341,10 @@ 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!(
|
self.info(format!("Playing playlist \u{201c}{name}\u{201d} ({n} tracks)"));
|
||||||
"Playing playlist \u{201c}{name}\u{201d} ({n} tracks)"
|
|
||||||
));
|
|
||||||
} else {
|
} else {
|
||||||
self.queue.extend(tracks);
|
self.queue.extend(tracks);
|
||||||
self.info(format!(
|
self.info(format!("Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue"));
|
||||||
"Appended \u{201c}{name}\u{201d} ({n} tracks) to the queue"
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1418,13 +1378,9 @@ impl App {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_playlist_track(&mut self, i: usize) {
|
fn remove_playlist_track(&mut self, i: usize) {
|
||||||
let Some(name) = self.open.clone() else {
|
let Some(name) = self.open.clone() else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
let removed = {
|
let removed = {
|
||||||
let Some(list) = self.store.playlists.get_mut(&name) else {
|
let Some(list) = self.store.playlists.get_mut(&name) else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
if i >= list.len() {
|
if i >= list.len() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -1435,13 +1391,9 @@ 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
|
self.pl_state.select(if len == 0 { None } else { Some(i.min(len - 1)) });
|
||||||
.select(if len == 0 { None } else { Some(i.min(len - 1)) });
|
|
||||||
if self.persist() {
|
if self.persist() {
|
||||||
self.info(format!(
|
self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label()));
|
||||||
"Removed from \u{201c}{name}\u{201d}: {}",
|
|
||||||
track.label()
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1453,9 +1405,7 @@ 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 {
|
let Some(name) = self.open.clone() else { return };
|
||||||
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) {
|
||||||
|
|
@ -1530,15 +1480,9 @@ 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![
|
vec![track("aaaaaaaaaaa", "A"), track("bbbbbbbbbbb", "B"), track("ccccccccccc", "C")],
|
||||||
track("aaaaaaaaaaa", "A"),
|
|
||||||
track("bbbbbbbbbbb", "B"),
|
|
||||||
track("ccccccccccc", "C"),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
app.store
|
app.store.playlists.insert("other".into(), vec![track("ddddddddddd", "D")]);
|
||||||
.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);
|
||||||
|
|
@ -1557,18 +1501,12 @@ 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"]
|
let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect();
|
||||||
.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"]
|
let ids: Vec<&str> = app.store.playlists["mix"].iter().map(|t| t.id.as_str()).collect();
|
||||||
.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);
|
||||||
|
|
||||||
|
|
@ -1597,6 +1535,16 @@ mod tests {
|
||||||
press(&mut app, KeyCode::Tab);
|
press(&mut app, KeyCode::Tab);
|
||||||
assert_eq!(app.open, None);
|
assert_eq!(app.open, None);
|
||||||
|
|
||||||
|
app.set_view(View::Playlists);
|
||||||
|
press(&mut app, KeyCode::Char('x'));
|
||||||
|
assert!(app.mode == Mode::ConfirmDelete);
|
||||||
|
press(&mut app, KeyCode::Char('n'));
|
||||||
|
assert!(app.mode == Mode::Normal);
|
||||||
|
assert_eq!(app.store.playlists.len(), 2);
|
||||||
|
press(&mut app, KeyCode::Char('x'));
|
||||||
|
press(&mut app, KeyCode::Char('y'));
|
||||||
|
assert_eq!(app.store.playlists.len(), 1);
|
||||||
|
|
||||||
check_artist_popup(&mut app);
|
check_artist_popup(&mut app);
|
||||||
|
|
||||||
app.run_command("remove");
|
app.run_command("remove");
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,7 @@ volume_step = {vstep:?}
|
||||||
return (Config::default(), None);
|
return (Config::default(), None);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return (
|
return (Config::default(), Some(format!("cannot read {}: {e}", path.display())))
|
||||||
Config::default(),
|
|
||||||
Some(format!("cannot read {}: {e}", path.display())),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match toml::from_str::<Config>(&text) {
|
match toml::from_str::<Config>(&text) {
|
||||||
|
|
@ -140,20 +137,12 @@ 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 {
|
let Ok(text) = fs::read_to_string(&path) else { return };
|
||||||
return;
|
let Ok(table) = text.parse::<toml::Table>() else { 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 {
|
let Ok(old) = toml::from_str::<Legacy>(&text) else { return };
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
let d = Playback::default();
|
let d = Playback::default();
|
||||||
let mut p = Playback {
|
let mut p = Playback {
|
||||||
|
|
@ -164,11 +153,7 @@ pub fn migrate_legacy() {
|
||||||
..d
|
..d
|
||||||
};
|
};
|
||||||
p.sanitize();
|
p.sanitize();
|
||||||
let session = Session {
|
let session = Session { queue: old.queue, current: old.current, position: old.position };
|
||||||
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);
|
||||||
|
|
@ -198,22 +183,14 @@ 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!(
|
assert_eq!((c.playback.volume, c.playback.repeat), (10, RepeatMode::One));
|
||||||
(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 {
|
let mut p = Playback { volume: 250, seek_step: -3.0, volume_step: 900.0, ..Playback::default() };
|
||||||
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));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
10
src/model.rs
10
src/model.rs
|
|
@ -24,9 +24,7 @@ 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 [
|
for sep in [" & ", ", ", " feat. ", " Feat. ", " feat ", " ft. ", " Ft. ", " × "] {
|
||||||
" & ", ", ", " 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();
|
||||||
|
|
@ -63,11 +61,7 @@ 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() {
|
let s = if secs.is_finite() { secs.max(0.0) as u64 } else { 0 };
|
||||||
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}")
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,10 @@ pub struct Player {
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Player {
|
impl Player {
|
||||||
pub fn spawn(volume: u8, on_event: impl Fn(PlayerEvent) + Send + 'static) -> Result<Player> {
|
pub fn spawn(
|
||||||
|
volume: u8,
|
||||||
|
on_event: impl Fn(PlayerEvent) + Send + 'static,
|
||||||
|
) -> Result<Player> {
|
||||||
let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
|
let sock_dir = dirs::runtime_dir().unwrap_or_else(std::env::temp_dir);
|
||||||
let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
|
let sock_path = sock_dir.join(format!("drift-{}.sock", std::process::id()));
|
||||||
let _ = std::fs::remove_file(&sock_path);
|
let _ = std::fs::remove_file(&sock_path);
|
||||||
|
|
@ -48,14 +51,10 @@ 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) =
|
if let Ok(extra) = std::env::var("DRIFT_MPV_ARGS").or_else(|_| std::env::var("YTM_MPV_ARGS")) {
|
||||||
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.stdin(Stdio::null())
|
cmd.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());
|
||||||
.stdout(Stdio::null())
|
|
||||||
.stderr(Stdio::null());
|
|
||||||
|
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
unsafe {
|
unsafe {
|
||||||
|
|
@ -99,21 +98,14 @@ impl Player {
|
||||||
sock_path,
|
sock_path,
|
||||||
};
|
};
|
||||||
|
|
||||||
for (id, prop) in [
|
for (id, prop) in [(1, "time-pos"), (2, "duration"), (3, "pause"), (4, "volume")] {
|
||||||
(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 {
|
let Ok(v) = serde_json::from_str::<Value>(&line) else { continue };
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if let Some(ev) = parse_event(&v) {
|
if let Some(ev) = parse_event(&v) {
|
||||||
on_event(ev);
|
on_event(ev);
|
||||||
}
|
}
|
||||||
|
|
@ -216,9 +208,7 @@ 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)
|
let samples: Vec<u8> = (0..16_000u32).map(|i| if (i / 20) % 2 == 0 { 140 } else { 116 }).collect();
|
||||||
.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());
|
||||||
|
|
|
||||||
|
|
@ -28,12 +28,8 @@ impl Session {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load() -> Session {
|
pub fn load() -> Session {
|
||||||
let Ok(path) = Self::path() else {
|
let Ok(path) = Self::path() else { return Session::default() };
|
||||||
return Session::default();
|
let Ok(text) = fs::read_to_string(&path) else { 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();
|
||||||
|
|
@ -72,13 +68,7 @@ mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
fn track(id: &str) -> Track {
|
fn track(id: &str) -> Track {
|
||||||
Track {
|
Track { id: id.into(), title: "T".into(), artist: "A".into(), album: None, duration: Some(200) }
|
||||||
id: id.into(),
|
|
||||||
title: "T".into(),
|
|
||||||
artist: "A".into(),
|
|
||||||
album: None,
|
|
||||||
duration: Some(200),
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -94,15 +84,8 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn roundtrip() {
|
fn roundtrip() {
|
||||||
let ok = Session {
|
let ok = Session { queue: vec![track("aaaaaaaaaaa")], current: Some(0), position: 42.5 };
|
||||||
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!(
|
assert_eq!((again.current, again.position, again.queue), (Some(0), 42.5, ok.queue));
|
||||||
(again.current, again.position, again.queue),
|
|
||||||
(Some(0), 42.5, ok.queue)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
48
src/store.rs
48
src/store.rs
|
|
@ -72,27 +72,41 @@ impl Store {
|
||||||
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?)
|
write_atomic(&Self::path()?, &serde_json::to_string_pretty(self)?)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn resolve(&self, name: &str) -> Option<String> {
|
pub fn resolve_exact(&self, name: &str) -> Option<String> {
|
||||||
if self.playlists.contains_key(name) {
|
if self.playlists.contains_key(name) {
|
||||||
return Some(name.to_string());
|
return Some(name.to_string());
|
||||||
}
|
}
|
||||||
let lower = name.to_lowercase();
|
let lower = name.to_lowercase();
|
||||||
let ci: Vec<&String> = self
|
let mut found = self.playlists.keys().filter(|k| k.to_lowercase() == lower);
|
||||||
.playlists
|
match (found.next(), found.next()) {
|
||||||
.keys()
|
(Some(k), None) => Some(k.clone()),
|
||||||
.filter(|k| k.to_lowercase() == lower)
|
_ => None,
|
||||||
.collect();
|
|
||||||
if ci.len() == 1 {
|
|
||||||
return Some(ci[0].clone());
|
|
||||||
}
|
}
|
||||||
let pre: Vec<&String> = self
|
}
|
||||||
.playlists
|
|
||||||
.keys()
|
pub fn resolve(&self, name: &str) -> Option<String> {
|
||||||
.filter(|k| k.to_lowercase().starts_with(&lower))
|
self.resolve_exact(name).or_else(|| {
|
||||||
.collect();
|
let lower = name.to_lowercase();
|
||||||
if pre.len() == 1 {
|
let mut found = self.playlists.keys().filter(|k| k.to_lowercase().starts_with(&lower));
|
||||||
return Some(pre[0].clone());
|
match (found.next(), found.next()) {
|
||||||
}
|
(Some(k), None) => Some(k.clone()),
|
||||||
None
|
_ => None,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exact_ignores_prefixes() {
|
||||||
|
let mut s = Store::default();
|
||||||
|
s.playlists.insert("rock-classics".into(), Vec::new());
|
||||||
|
s.playlists.insert("Jazz".into(), Vec::new());
|
||||||
|
assert_eq!(s.resolve_exact("rock"), None);
|
||||||
|
assert_eq!(s.resolve("rock").as_deref(), Some("rock-classics"));
|
||||||
|
assert_eq!(s.resolve_exact("jazz").as_deref(), Some("Jazz"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
162
src/ui.rs
162
src/ui.rs
|
|
@ -11,10 +11,7 @@ const ACCENT: Color = Color::Cyan;
|
||||||
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
const SPINNER: [&str; 10] = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
||||||
|
|
||||||
fn selected_style() -> Style {
|
fn selected_style() -> Style {
|
||||||
Style::new()
|
Style::new().fg(Color::Black).bg(ACCENT).add_modifier(Modifier::BOLD)
|
||||||
.fg(Color::Black)
|
|
||||||
.bg(ACCENT)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn header_style() -> Style {
|
fn header_style() -> Style {
|
||||||
|
|
@ -59,10 +56,7 @@ fn draw_tabs(f: &mut Frame, app: &App, area: Rect) {
|
||||||
])
|
])
|
||||||
.areas(area);
|
.areas(area);
|
||||||
|
|
||||||
f.render_widget(
|
f.render_widget(Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()), name);
|
||||||
Paragraph::new(" ♪ Drift").style(Style::new().fg(ACCENT).bold()),
|
|
||||||
name,
|
|
||||||
);
|
|
||||||
|
|
||||||
let titles = [
|
let titles = [
|
||||||
format!(" Results {} ", app.results.len()),
|
format!(" Results {} ", app.results.len()),
|
||||||
|
|
@ -113,11 +107,7 @@ 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 {
|
let marker = if is_playing { playing.map_or("", |p| p.1) } else { "" };
|
||||||
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()),
|
||||||
|
|
@ -128,7 +118,8 @@ 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))).style(dim()),
|
Cell::from(t.duration.map_or(String::new(), |d| fmt_time(d as f64)))
|
||||||
|
.style(dim()),
|
||||||
);
|
);
|
||||||
let row = Row::new(cells);
|
let row = Row::new(cells);
|
||||||
if is_playing {
|
if is_playing {
|
||||||
|
|
@ -165,9 +156,7 @@ 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)
|
Paragraph::new(text).alignment(Alignment::Center).block(block),
|
||||||
.alignment(Alignment::Center)
|
|
||||||
.block(block),
|
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -187,17 +176,9 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -211,9 +192,7 @@ fn draw_open_playlist(f: &mut Frame, app: &mut App, area: Rect, name: &str) {
|
||||||
let now = playing_marker(app);
|
let now = playing_marker(app);
|
||||||
let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker));
|
let playing = now.as_ref().map(|(id, marker)| (id.as_str(), *marker));
|
||||||
|
|
||||||
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 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));
|
||||||
|
|
@ -222,11 +201,7 @@ 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;
|
||||||
|
|
@ -253,12 +228,7 @@ 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);
|
||||||
|
|
@ -280,11 +250,7 @@ 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)
|
||||||
|
|
@ -293,10 +259,7 @@ 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
|
match app.playlist_name(sel).and_then(|n| app.store.playlists.get(&n)) {
|
||||||
.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);
|
||||||
|
|
@ -325,12 +288,7 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
||||||
let flags_w = flags.chars().count() as u16 + 1;
|
let 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(
|
f.render_widget(Paragraph::new(flags).style(dim()).alignment(Alignment::Right), flags_area);
|
||||||
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(),
|
||||||
|
|
@ -356,14 +314,7 @@ 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!(
|
let right = format!(" {} ", if app.duration > 0.0 { fmt_time(app.duration) } else { "--:--".into() });
|
||||||
" {} ",
|
|
||||||
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)
|
||||||
|
|
@ -381,12 +332,19 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
|
fn draw_cmdline(f: &mut Frame, app: &App, area: Rect) {
|
||||||
|
if app.mode == Mode::ConfirmDelete {
|
||||||
|
let name = app.pending_delete.as_deref().unwrap_or_default();
|
||||||
|
f.render_widget(
|
||||||
|
Paragraph::new(format!(" Delete playlist \u{201c}{name}\u{201d}? (y/N)"))
|
||||||
|
.style(Style::new().fg(Color::Yellow).bold()),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if app.mode == Mode::Command {
|
if app.mode == Mode::Command {
|
||||||
let text = format!(":{}", app.cmd_buf);
|
let text = format!(":{}", app.cmd_buf);
|
||||||
let col = text
|
let col = text.chars().count().min(area.width.saturating_sub(1) as usize) as u16;
|
||||||
.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((area.x + col, area.y));
|
f.set_cursor_position((area.x + col, area.y));
|
||||||
return;
|
return;
|
||||||
|
|
@ -413,9 +371,7 @@ 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} "))
|
Paragraph::new(format!("{pending} ")).alignment(Alignment::Right).style(Style::new().fg(Color::Yellow)),
|
||||||
.alignment(Alignment::Right)
|
|
||||||
.style(Style::new().fg(Color::Yellow)),
|
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -432,25 +388,16 @@ 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"),
|
||||||
|
|
@ -464,19 +411,10 @@ 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"),
|
||||||
":save n :load n",
|
(":append n :delpl n", "append a playlist to the queue / delete it"),
|
||||||
"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"),
|
||||||
|
|
@ -532,10 +470,7 @@ fn help_lines(inner_w: usize) -> Vec<Line<'static>> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for rest in desc {
|
for rest in desc {
|
||||||
lines.push(Line::from(vec![
|
lines.push(Line::from(vec![Span::raw(" ".repeat(key_w)), Span::raw(rest)]));
|
||||||
Span::raw(" ".repeat(key_w)),
|
|
||||||
Span::raw(rest),
|
|
||||||
]));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines
|
lines
|
||||||
|
|
@ -543,12 +478,7 @@ 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
|
let widest = app.artist_opts.iter().map(|a| a.chars().count()).max().unwrap_or(0) as u16;
|
||||||
.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 + 3).min(area.height);
|
let h = (n + 3).min(area.height);
|
||||||
let popup = Rect {
|
let popup = Rect {
|
||||||
|
|
@ -603,12 +533,7 @@ fn draw_help(f: &mut Frame, app: &mut App, area: Rect) {
|
||||||
.border_style(Style::new().fg(Color::Yellow));
|
.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!(
|
block = block.title_bottom(format!(" j/k scroll · {}-{} of {} ", scroll + 1, end, lines.len()));
|
||||||
" 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();
|
||||||
|
|
@ -626,12 +551,7 @@ mod tests {
|
||||||
use std::sync::mpsc;
|
use std::sync::mpsc;
|
||||||
|
|
||||||
fn text(term: &Terminal<TestBackend>) -> String {
|
fn text(term: &Terminal<TestBackend>) -> String {
|
||||||
term.backend()
|
term.backend().buffer().content().iter().map(|c| c.symbol()).collect()
|
||||||
.buffer()
|
|
||||||
.content()
|
|
||||||
.iter()
|
|
||||||
.map(|c| c.symbol())
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -651,13 +571,7 @@ 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(
|
app.store.playlists.insert("mix".into(), vec![t("aaaaaaaaaaa", "First Song"), t("bbbbbbbbbbb", "Second Song")]);
|
||||||
"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();
|
||||||
|
|
|
||||||
|
|
@ -16,9 +16,7 @@ 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!(
|
format!("YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})")
|
||||||
"YouTube Music search failed ({primary:#}); yt-dlp fallback failed ({fallback:#})"
|
|
||||||
)
|
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -75,9 +73,7 @@ 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
|
let v: Value = resp.into_json().context("invalid JSON from YouTube Music")?;
|
||||||
.into_json()
|
|
||||||
.context("invalid JSON from YouTube Music")?;
|
|
||||||
Ok(parse_search_response(&v))
|
Ok(parse_search_response(&v))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,9 +115,7 @@ 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 {
|
let Some(text) = run.get("text").and_then(Value::as_str) else { continue };
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if text.trim() == "•" {
|
if text.trim() == "•" {
|
||||||
segments.push(std::mem::take(&mut cur));
|
segments.push(std::mem::take(&mut cur));
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -148,10 +142,7 @@ 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!(
|
if matches!(meta.first().map(String::as_str), Some("Song" | "Video" | "Episode")) {
|
||||||
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() {
|
||||||
|
|
@ -162,10 +153,7 @@ fn parse_item(item: &Value) -> Option<Track> {
|
||||||
}
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let artist = meta
|
let artist = meta.first().cloned().unwrap_or_else(|| "Unknown artist".into());
|
||||||
.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"))
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue