This commit is contained in:
socatlolmeow 2026-10-10 18:13:51 +02:00
commit a69820e5a3
8 changed files with 341 additions and 96 deletions

View file

@ -233,13 +233,17 @@ impl App {
}
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> {
self.status.as_ref().filter(|s| {
s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 }
})
self.status
.as_ref()
.filter(|s| s.at.elapsed().as_secs() < if s.level == Level::Error { 12 } else { 6 })
}
pub fn on_tick(&mut self) {
@ -266,11 +270,17 @@ impl App {
pub fn in_playlist(&self) -> bool {
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 {
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 {
@ -419,8 +429,12 @@ impl App {
KeyCode::Char(' ') => self.toggle_pause(),
KeyCode::Char('n') | KeyCode::Char('>') => self.next(),
KeyCode::Char('N') | KeyCode::Char('<') => self.prev(),
KeyCode::Char('h') | KeyCode::Left => 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('h') | KeyCode::Left => {
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('=') => {
self.set_volume(Amount::Delta(self.volume_step * times as f64))
}
@ -678,7 +692,9 @@ impl App {
}
match key.code {
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(c @ '1'..='9') => {
let i = c as usize - '1' as usize;
@ -716,22 +732,28 @@ impl App {
.into_iter()
.filter(|t| {
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())
});
}
fn start_search(&mut self, query: String) {
self.spawn_loader(format!("Search: {query}"), format!("Searching \u{201c}{query}\u{201d}"), move || {
ytmusic::search(&query)
});
self.spawn_loader(
format!("Search: {query}"),
format!("Searching \u{201c}{query}\u{201d}"),
move || ytmusic::search(&query),
);
}
fn start_import(&mut self, url: String) {
self.spawn_loader("Imported playlist".into(), "Importing playlist".into(), move || {
ytmusic::import(&url)
});
self.spawn_loader(
"Imported playlist".into(),
"Importing playlist".into(),
move || ytmusic::import(&url),
);
}
fn spawn_loader<F>(&mut self, title: String, busy: String, work: F)
@ -843,7 +865,9 @@ impl App {
"Playback failed 3 times in a row ({msg}). Update yt-dlp: yt-dlp -U"
));
} 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);
}
}
@ -862,7 +886,9 @@ impl App {
}
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 {
self.resume = None;
}
@ -953,7 +979,9 @@ impl App {
}
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.result_cursor = Some(i);
self.resume = None;
@ -978,7 +1006,11 @@ impl App {
}
match self.with_player(|p| p.set_pause(pause)) {
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:#}")),
}
@ -1142,7 +1174,10 @@ impl App {
if let Some(c) = self.current {
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) {
@ -1196,7 +1231,11 @@ impl App {
if self.results.is_empty() {
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.played.clear();
self.current = None;
@ -1239,7 +1278,11 @@ impl App {
}
let sel = self.states[View::Queue.idx()].selected().unwrap_or(0);
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()));
}
@ -1274,7 +1317,9 @@ impl App {
self.mode = Mode::ConfirmDelete;
}
}
View::Results => self.info("Results can't be deleted — they are replaced by the next search"),
View::Results => {
self.info("Results can't be deleted — they are replaced by the next search")
}
}
}
@ -1293,9 +1338,13 @@ impl App {
return self.error("Queue is empty — nothing to save");
}
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() {
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}"
));
}
}
@ -1314,7 +1363,10 @@ impl App {
let Some(track) = track else {
return self.error("Select a track in Results or Queue first");
};
let key = self.store.resolve_exact(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();
if list.iter().any(|t| t.id == track.id) {
return self.info(format!("Already in \u{201c}{key}\u{201d}"));
@ -1341,10 +1393,14 @@ impl App {
self.result_cursor = None;
self.states[View::Queue.idx()].select(Some(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 {
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"
));
}
}
@ -1378,9 +1434,13 @@ impl App {
}
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 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() {
None
} else {
@ -1391,9 +1451,13 @@ impl App {
let Some((track, len)) = removed else {
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() {
self.info(format!("Removed from \u{201c}{name}\u{201d}: {}", track.label()));
self.info(format!(
"Removed from \u{201c}{name}\u{201d}: {}",
track.label()
));
}
}
@ -1405,7 +1469,9 @@ impl App {
}
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 j = i as isize + dir;
let moved = match self.store.playlists.get_mut(&name) {
@ -1480,9 +1546,15 @@ mod tests {
let mut app = App::new(tx);
app.store.playlists.insert(
"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);
@ -1501,12 +1573,18 @@ mod tests {
assert_eq!(app.selected(), 2);
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!(app.selected(), 1);
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!(app.selected(), 1);