From 1cd329d112b659768ffffdb3ba95cec8089cdef5 Mon Sep 17 00:00:00 2001 From: olivr Date: Thu, 29 Oct 2026 18:07:20 +0100 Subject: [PATCH 01/11] feat: add native support for saving albums and results as playlists --- README.md | 1 + src/app.rs | 196 +++++++++++++++++++++++++++++++++++++++++++++++++ src/command.rs | 19 +++++ src/ui.rs | 13 +++- 4 files changed, 228 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 840730c..f29b8d2 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,7 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:add [N]` `:addall` `:playall` | queue results | | `:save name` `:load name` `:append name` | work with playlists | +| `:savealbum [name]` `:saveresults [name]` | save album or results directly as a playlist | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | | `:album [query]` | open the selected song's album, or search albums | diff --git a/src/app.rs b/src/app.rs index 952b95f..9467137 100644 --- a/src/app.rs +++ b/src/app.rs @@ -37,6 +37,10 @@ pub enum AppEvent { title: String, result: Result, String>, }, + SavePlaylist { + name: String, + result: Result, String>, + }, Media(Cmd), Audio { id: String, @@ -114,6 +118,9 @@ pub enum MenuAction { Artist, Similar, Album, + SaveTrackAlbum, + SaveAlbum, + SaveResults, PlaylistMenu, PickPlaylist(String), NewPlaylist, @@ -543,6 +550,7 @@ impl App { ('g', 'a') => self.go_to_artist(), ('g', 's') => self.go_similar(), ('g', 'b') => self.go_album(), + ('g', 'B') => self.save_album(None), _ => {} } } @@ -688,6 +696,7 @@ impl App { items.push(menu_item('s', "Similar songs", MenuAction::Similar)); if track.album.is_some() { items.push(menu_item('b', "Show album", MenuAction::Album)); + items.push(menu_item('B', "Save album to playlist", MenuAction::SaveTrackAlbum)); } items.push(menu_item('l', "Add to playlist…", MenuAction::PlaylistMenu)); if in_queue { @@ -699,6 +708,11 @@ impl App { if self.view == View::Results { items.push(menu_item('Q', "Queue all results", MenuAction::QueueAll)); items.push(menu_item('P', "Play all results", MenuAction::PlayAll)); + if self.results_title.starts_with("Album: ") { + items.push(menu_item('S', "Save album as playlist…", MenuAction::SaveAlbum)); + } else { + items.push(menu_item('S', "Save results as playlist…", MenuAction::SaveResults)); + } } if in_queue { items.push(menu_item('c', "Clear queue", MenuAction::ClearQueue)); @@ -806,6 +820,21 @@ impl App { MenuAction::Artist => self.go_to_artist(), MenuAction::Similar => self.go_similar(), MenuAction::Album => self.go_album(), + MenuAction::SaveTrackAlbum => { + if let Some(t) = track { + let album_title = t.album.clone().unwrap_or_else(|| "Album".to_string()); + self.save_album_background(t, album_title); + } + } + MenuAction::SaveAlbum => { + let default_name = self.results_title.strip_prefix("Album: ").unwrap_or("").trim(); + if !default_name.is_empty() { + self.prefill_command(&format!("savealbum \"{default_name}\"")); + } else { + self.prefill_command("savealbum "); + } + } + MenuAction::SaveResults => self.prefill_command("saveresults "), MenuAction::PlaylistMenu => {} MenuAction::PickPlaylist(name) => self.add_to_playlist(&name), MenuAction::NewPlaylist => self.prefill_command("addto "), @@ -1134,6 +1163,8 @@ impl App { } Command::Save(name) => self.save_playlist(&name, false), Command::SaveForce(name) => self.save_playlist(&name, true), + Command::SaveAlbum(name) => self.save_album(name), + Command::SaveResults(name) => self.save_results_as_playlist(name), Command::Load(name) => match self.store.resolve(&name) { Some(n) => self.load_playlist(&n, true), None => self.error(format!("no playlist named '{name}'")), @@ -1283,6 +1314,56 @@ impl App { }); } + fn save_album(&mut self, name: Option) { + if self.view == View::Results + && self.results_title.starts_with("Album: ") + && !self.results.is_empty() + { + return self.save_results_as_playlist(name); + } + + if let Some(track) = self.artist_target() { + if let Some(album) = track.album.clone() { + let target_name = name.unwrap_or(album); + return self.save_album_background(track, target_name); + } + } + + if self.view == View::Results && !self.results.is_empty() { + return self.save_results_as_playlist(name); + } + + self.error("No album to save — open an album or select a song with an album"); + } + + fn save_album_background(&mut self, track: Track, target_name: String) { + let Some(album) = track.album else { + return self.error("No album known for this song"); + }; + let artist = split_artists(&track.artist) + .into_iter() + .next() + .unwrap_or_default(); + self.busy = Some(format!("Saving album \u{201c}{album}\u{201d}")); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = (|| -> Result, String> { + let found = ytmusic::search_collections(&format!("{album} {artist}"), Kind::Album)?; + let want = album.to_lowercase(); + let pick = found + .iter() + .find(|c| c.title.to_lowercase() == want) + .or(found.first()) + .ok_or_else(|| "album not found".to_string())?; + ytmusic::browse_tracks(&pick.browse_id, &pick.title, &pick.artist) + })(); + let _ = tx.send(AppEvent::SavePlaylist { + name: target_name, + result, + }); + }); + } + fn start_collection_search(&mut self, query: String, kind: Kind) { let (what, title) = match kind { Kind::Album => ("albums", format!("Albums: {query}")), @@ -1306,6 +1387,7 @@ impl App { } match key.code { KeyCode::Esc | KeyCode::Char('q') => self.close_collection_pick(), + KeyCode::Char('s') | KeyCode::Char('S') => self.save_picked_collection(), KeyCode::Char('j') | KeyCode::Down => self.pick_sel = (self.pick_sel + 1).min(len - 1), KeyCode::Char('k') | KeyCode::Up => self.pick_sel = self.pick_sel.saturating_sub(1), KeyCode::Char('g') | KeyCode::Home => self.pick_sel = 0, @@ -1340,6 +1422,23 @@ impl App { }); } + fn save_picked_collection(&mut self) { + let c = self.picks.get(self.pick_sel).cloned(); + self.close_collection_pick(); + let Some(c) = c else { return }; + let kind = if c.browse_id.starts_with("VL") { "Playlist" } else { "Album" }; + let target_name = c.title.clone(); + self.busy = Some(format!("Saving {kind} \u{201c}{target_name}\u{201d}")); + let tx = self.tx.clone(); + std::thread::spawn(move || { + let result = ytmusic::browse_tracks(&c.browse_id, &c.title, &c.artist); + let _ = tx.send(AppEvent::SavePlaylist { + name: target_name, + result, + }); + }); + } + fn on_media(&mut self, cmd: Cmd) { match cmd { Cmd::Play => match self.play_state { @@ -1492,6 +1591,29 @@ impl App { } } AppEvent::Media(cmd) => self.on_media(cmd), + AppEvent::SavePlaylist { name, result } => { + self.busy = None; + match result { + Ok(tracks) if tracks.is_empty() => { + self.error(format!("Album \u{201c}{name}\u{201d} contains no tracks")); + } + Ok(tracks) => { + let n = tracks.len(); + let key = self.store.resolve_exact(&name).unwrap_or(name); + self.store.playlists.insert(key.clone(), tracks); + if self.persist() { + self.info(format!( + "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + )); + let sel = self.selected(); + self.set_selected(sel); + } + } + Err(e) => { + self.error(format!("Could not save album \u{201c}{name}\u{201d}: {e}")) + } + } + } AppEvent::Lyrics { id, result } => { if self.lyrics_id.as_deref() != Some(id.as_str()) { return; @@ -2179,6 +2301,40 @@ impl App { } } + fn save_results_as_playlist(&mut self, name: Option) { + if self.results.is_empty() { + return self.error("No results to save — search or open an album first"); + } + let target_name = match name { + Some(n) if !n.trim().is_empty() => n.trim().to_string(), + _ => { + if let Some(rest) = self.results_title.strip_prefix("Album: ") { + rest.trim().to_string() + } else if let Some(rest) = self.results_title.strip_prefix("Playlist: ") { + rest.trim().to_string() + } else if let Some(album) = self.results.iter().find_map(|t| t.album.as_deref()) { + album.to_string() + } else { + return self.error( + "No album name found; usage: :savealbum or :saveresults ", + ); + } + } + }; + let n = self.results.len(); + let key = self.store.resolve_exact(&target_name).unwrap_or(target_name); + self.store + .playlists + .insert(key.clone(), self.results.clone()); + if self.persist() { + self.info(format!( + "Saved {n} tracks to playlist \u{201c}{key}\u{201d}" + )); + let sel = self.selected(); + self.set_selected(sel); + } + } + fn add_to_playlist(&mut self, name: &str) { let i = self.selected(); let track = match self.view { @@ -2543,6 +2699,46 @@ mod tests { check_playlist_playback(&mut app); + // Test savealbum and saveresults + let mut t1 = track("song1111111", "Dreams"); + t1.album = Some("Rumours".into()); + let mut t2 = track("song2222222", "Go Your Own Way"); + t2.album = Some("Rumours".into()); + app.results = vec![t1.clone(), t2.clone()]; + app.results_title = "Album: Rumours".into(); + app.view = View::Results; + + // :savealbum with no args uses album name from results_title + app.run_command("savealbum"); + assert_eq!(app.store.playlists.get("Rumours").map(Vec::len), Some(2)); + + // :savealbum with custom name + app.run_command(r#"savealbum "Fleetwood Mac - Rumours""#); + assert_eq!( + app.store + .playlists + .get("Fleetwood Mac - Rumours") + .map(Vec::len), + Some(2) + ); + + // :saveresults with custom name + app.run_command(r#"saveresults "Classic Hits""#); + assert_eq!( + app.store.playlists.get("Classic Hits").map(Vec::len), + Some(2) + ); + + // Test AppEvent::SavePlaylist background event + app.on_event(AppEvent::SavePlaylist { + name: "Background Album".into(), + result: Ok(vec![track("bg111111111", "BG Track")]), + }); + assert_eq!( + app.store.playlists.get("Background Album").map(Vec::len), + Some(1) + ); + let _ = std::fs::remove_dir_all(&dir); } } diff --git a/src/command.rs b/src/command.rs index ccb116d..3f6ada2 100644 --- a/src/command.rs +++ b/src/command.rs @@ -31,6 +31,8 @@ pub enum Command { Repeat(Option), Save(String), SaveForce(String), + SaveAlbum(Option), + SaveResults(Option), Load(String), Append(String), DeletePlaylist(String), @@ -67,6 +69,8 @@ pub const COMMAND_NAMES: &[&str] = &[ "shuffle", "repeat", "save", + "savealbum", + "saveresults", "load", "append", "delpl", @@ -174,6 +178,8 @@ pub fn parse(line: &str) -> Result, String> { }, "save" | "w" => Save(name_arg(arg, "save")?), "save!" | "w!" => SaveForce(name_arg(arg, "save!")?), + "savealbum" | "sa" => SaveAlbum((!arg.is_empty()).then(|| arg.to_string())), + "saveresults" | "sr" => SaveResults((!arg.is_empty()).then(|| arg.to_string())), "load" | "e" => Load(name_arg(arg, "load")?), "append" => Append(name_arg(arg, "append")?), "delpl" | "deletepl" | "delete" => DeletePlaylist(name_arg(arg, "delpl")?), @@ -284,5 +290,18 @@ mod tests { parse("save! my mix"), Ok(Some(Command::SaveForce("my mix".into()))) ); + assert_eq!(parse("savealbum"), Ok(Some(Command::SaveAlbum(None)))); + assert_eq!( + parse(r#"savealbum "Abbey Road""#), + Ok(Some(Command::SaveAlbum(Some("Abbey Road".into())))) + ); + assert_eq!( + parse("saveresults"), + Ok(Some(Command::SaveResults(None))) + ); + assert_eq!( + parse(r#"saveresults "My Hits""#), + Ok(Some(Command::SaveResults(Some("My Hits".into())))) + ); } } diff --git a/src/ui.rs b/src/ui.rs index 6894886..4da6ecf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1132,6 +1132,14 @@ const HELP: &[(&str, &str)] = &[ ":save n :load n", "save the queue as a playlist / load one (:save! n replaces an existing one)", ), + ( + ":savealbum [n]", + "save album or current album results as a playlist", + ), + ( + ":saveresults [n]", + "save current results as a playlist", + ), ( ":append n :delpl n", "append a playlist to the queue / delete it", @@ -1281,7 +1289,10 @@ fn draw_collection_picker(f: &mut Frame, app: &App, area: Rect) { } }) .collect(); - lines.push(Line::styled(" Enter select · j k move · Esc cancel", dim())); + lines.push(Line::styled( + " Enter select · s save playlist · j k move · Esc cancel", + dim(), + )); f.render_widget(Clear, popup); f.render_widget( Paragraph::new(lines).block( From d594184ff780888fbdf8a342a128cc1ce7724f2b Mon Sep 17 00:00:00 2001 From: olivr Date: Thu, 29 Oct 2026 18:18:23 +0100 Subject: [PATCH 02/11] fix(ui): add savealbum and saveresults to command palette descriptions --- src/ui.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ui.rs b/src/ui.rs index 4da6ecf..e49af1a 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -806,6 +806,8 @@ const CMD_INFO: &[(&str, &str)] = &[ ("shuffle", "toggle shuffle"), ("repeat", "repeat · off | all | one"), ("save", "save the queue as a playlist"), + ("savealbum", "save album as a playlist · savealbum [name]"), + ("saveresults", "save results as a playlist · saveresults [name]"), ("load", "load a playlist into the queue"), ("append", "append a playlist to the queue"), ("delpl", "delete a playlist"), From ad2a1462e804d26069d02d55c4c3765f5272f1db Mon Sep 17 00:00:00 2001 From: olivr Date: Thu, 29 Oct 2026 18:32:14 +0100 Subject: [PATCH 03/11] fix(app): restore split_artists import after rebase onto upstream 1.4 --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 9467137..1f49e89 100644 --- a/src/app.rs +++ b/src/app.rs @@ -9,7 +9,7 @@ use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; use crate::config::{self, Config}; use crate::lyrics::{self, Lyrics}; -use crate::model::{Collection, RepeatMode, Track}; +use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; use crate::mpris::{Cmd, Mpris}; use crate::player::{self, Player, PlayerEvent}; From fc21fbffd8a4dcb816f11b21439a31ab28e15139 Mon Sep 17 00:00:00 2001 From: Socat Date: Thu, 29 Oct 2026 18:47:25 +0100 Subject: [PATCH 04/11] docs(readme): add install script distros --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 840730c..5699ce0 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ makepkg -si ### Install script -On Arch, Debian/Ubuntu, Fedora and Void, the install script installs the dependencies and builds Drift with cargo: +On Arch, Debian/Ubuntu, Fedora, openSUSE, Gentoo & Void the install script installs the dependencies and builds Drift with cargo: ```bash git clone https://github.com/socatlolmeow/drift.git From 27353d3358e3c94211b1b096c5136f566828a7a3 Mon Sep 17 00:00:00 2001 From: Socat Date: Fri, 30 Oct 2026 02:31:11 +0100 Subject: [PATCH 05/11] docs(readme): update features meow >w< --- README.md | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 37199a3..550d231 100644 --- a/README.md +++ b/README.md @@ -22,16 +22,15 @@ Search, queue and play music with no account, no login and no browser. ## Features -- **No login** - search and stream straight from YouTube Music - **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line -- **Synced lyrics** - a side panel or fullscreen view with the current line highlighted as it's sung -- **Playlists & queue** - save, load and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- **Song menu** - press `G` on a song to play, queue, add to a playlist, jump to the artist or see its info +- **Synced lyrics** - a side panel or fullscreen view that highlights the current line and fills it in as it's sung +- **Queue & playlists** - build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL +- **Discovery** - press `ga` for an artist's songs, `gs` for similar songs, or use `:album` and `:playlist` to search albums and playlists +- **Song menu** - press `G` on a song to play it, qeue it, add it to a playlist, jump to the artist or see its info - **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history -- **Artist browsing** - press `ga` to see an artist's songs -- **Similar songs** - press `gs` to see similar songs -- **Audio visualizer** - a live visualizer while music plays -- **Configurable** - volume, seek step, repeat/shuffle defaults and accent color in a single TOML file +- **Picks up where you left off** - your queue and position are restored the next time you start Drift +- **Desktop integration** - media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich precense +- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color and more in a single TOML file ## Installation From f25b96d5fdad89b320817d2fae83ae6ceb8d8895 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Fri, 30 Oct 2026 14:01:45 +0100 Subject: [PATCH 06/11] feat(discord): show song name instead of application name --- src/discord.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/discord.rs b/src/discord.rs index 367349a..1a0af81 100644 --- a/src/discord.rs +++ b/src/discord.rs @@ -2,7 +2,9 @@ use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use discord_rich_presence::activity::{Activity, ActivityType, Assets, Button, Timestamps}; +use discord_rich_presence::activity::{ + Activity, ActivityType, Assets, Button, StatusDisplayType, Timestamps, +}; use discord_rich_presence::{DiscordIpc, DiscordIpcClient}; use crate::app::PlayState; @@ -158,6 +160,7 @@ fn send(client: &mut DiscordIpcClient, msg: &Msg) -> bool { }; let mut activity = Activity::new() .activity_type(ActivityType::Listening) + .status_display_type(StatusDisplayType::Details) .details(&p.title) .state(&artist) .assets(assets) From 5237a6bae81aa43c41d2a9ba321b41ca1930ad85 Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Mon, 2 Nov 2026 17:57:31 +0100 Subject: [PATCH 07/11] feat(player): stream audio by default, add [audio] config for cookies and downloads Long tracks (15 min to 1 hour+) didnt playbecause every song was fully downloaded with yt-dlp into the cache before mpv was started. Resolve the direct media URL with yt-dlp -g and hand it to mpv, so playback starts within a couple of seconds. Cached files are still used when present. Add an [audio] section to config.toml: - download: download songs to the cache before playing (default false, i.e. stream). Prefetch of the next track only runs in this mode. - download_max_minutes: in download mode, songs longer than this (or of unknown length) are still streamed so 0 means no limit. default is le 15. - cookies_from_browser: passed to yt-dlp as --cookies-from-browser for playback, for when YouTube asks to sign in or blocks a song. --- README.md | 9 ++++- src/app.rs | 61 +++++++++++++++++++++++++++--- src/config.rs | 100 +++++++++++++++++++++++++++++++++++++++++++++++--- src/player.rs | 44 +++++++++++++++++++++- 4 files changed, 200 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 550d231..4ec6a77 100644 --- a/README.md +++ b/README.md @@ -169,9 +169,16 @@ popular_first = false accent = "cyan" # a color name or a hex value like "#FF8800" [discord] -client_id = "" # change this for Discord RPC to work in Drift +client_id = "" # change this for Discord RPC to work in Drift + +[audio] +download = false # false: stream songs; true: download to the cache first +download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit) +cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback ``` +By default Drift streams songs, so playback starts right away even for hour long tracks. Set `download = true` to cache songs on disk first (this also prefetches the next song). If YouTube asks you to sign in or playback fails, set `cookies_from_browser` to the browser where youre logged into YouTube. If you alr have a `config.toml`, add the `[audio]` section by hand, since Drift never rewrites it. + Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. ## Contributing diff --git a/src/app.rs b/src/app.rs index 1f49e89..5dd2f32 100644 --- a/src/app.rs +++ b/src/app.rs @@ -7,7 +7,7 @@ use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::widgets::TableState; use crate::command::{self, Amount, Command, COMMAND_NAMES}; -use crate::config::{self, Config}; +use crate::config::{self, Audio, Config}; use crate::lyrics::{self, Lyrics}; use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; @@ -46,6 +46,10 @@ pub enum AppEvent { id: String, result: Result, }, + Stream { + id: String, + result: Result, + }, } const FIRST_PAGE: usize = 20; @@ -215,6 +219,8 @@ pub struct App { fail_streak: u8, want: Option, fetching: Option, + resolving: Option, + audio: Audio, result_cursor: Option, buffered: Vec, next_page: Option, @@ -315,6 +321,8 @@ impl App { fail_streak: 0, want: None, fetching: None, + resolving: None, + audio: cfg.audio.clone(), result_cursor: None, buffered: Vec::new(), next_page: None, @@ -1625,6 +1633,7 @@ impl App { }; } AppEvent::Audio { id, result } => self.on_audio(id, result), + AppEvent::Stream { id, result } => self.on_stream(id, result), AppEvent::Player(generation, pe) => { if generation == self.player_gen { self.on_player(pe); @@ -1754,17 +1763,53 @@ impl App { self.fetching = Some(id.to_string()); let tx = self.tx.clone(); let id = id.to_string(); + let audio = self.audio.clone(); std::thread::spawn(move || { - let result = player::fetch_audio(&id); + let result = player::fetch_audio(&id, &audio); let _ = tx.send(AppEvent::Audio { id, result }); }); } - fn load_track(&mut self, id: &str) { + fn load_track(&mut self, id: &str, duration: Option) { self.want = Some(id.to_string()); match player::cached_audio(id) { Some(path) => self.on_audio(id.to_string(), Ok(path)), - None => self.fetch_audio(id), + None if self.audio.should_download(duration) => self.fetch_audio(id), + None => self.resolve_stream(id), + } + } + + /* Start playing from the network right away; no full download first. */ + fn resolve_stream(&mut self, id: &str) { + if self.resolving.as_deref() == Some(id) { + return; + } + self.resolving = Some(id.to_string()); + let tx = self.tx.clone(); + let id = id.to_string(); + let audio = self.audio.clone(); + std::thread::spawn(move || { + let result = player::resolve_stream(&id, &audio); + let _ = tx.send(AppEvent::Stream { id, result }); + }); + } + + fn on_stream(&mut self, id: String, result: Result) { + if self.resolving.as_deref() == Some(id.as_str()) { + self.resolving = None; + } + if self.want.as_deref() != Some(id.as_str()) { + return; + } + self.want = None; + match result { + Ok(url) => { + if let Err(e) = self.with_player(|p| p.load(&url)) { + self.play_state = PlayState::Stopped; + self.error(format!("Playback failed: {e:#}")); + } + } + Err(msg) => self.on_player(PlayerEvent::LoadError(msg)), } } @@ -1795,6 +1840,10 @@ impl App { let Some(next) = self.current.and_then(|c| self.queue.get(c + 1)) else { return; }; + /* streamed tracks (the default, or too long to download) aren't prefetched */ + if !self.audio.should_download(next.duration) { + return; + } let id = next.id.clone(); if player::cached_audio(&id).is_none() { self.fetch_audio(&id); @@ -1822,7 +1871,7 @@ impl App { }; self.duration = track.duration.map_or(0.0, f64::from); self.info(format!("♪ {}", track.label())); - self.load_track(&track.id); + self.load_track(&track.id, track.duration); } fn play_default(&mut self) { @@ -1914,7 +1963,7 @@ impl App { self.position = 0.0; self.duration = track.duration.map_or(0.0, f64::from); self.info(format!("♪ {}", track.label())); - self.load_track(&track.id); + self.load_track(&track.id, track.duration); } fn set_pause(&mut self, pause: bool) { diff --git a/src/config.rs b/src/config.rs index 07e67a5..afaf6ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -80,6 +80,45 @@ pub struct Discord { pub client_id: String, } +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct Audio { + pub download: bool, + pub download_max_minutes: u32, + pub cookies_from_browser: String, +} + +impl Default for Audio { + fn default() -> Self { + Audio { + download: false, + download_max_minutes: 15, + cookies_from_browser: String::new(), + } + } +} + +impl Audio { + /// Browser name for yt-dlp's --cookies-from-browser, if one is set. + pub fn cookies(&self) -> Option<&str> { + let c = self.cookies_from_browser.trim(); + (!c.is_empty()).then_some(c) + } + + /// Whether a track should be downloaded to the cache before playing, + /// as opposed to streamed. Unknown lengths are streamed when a limit is set. + pub fn should_download(&self, duration: Option) -> bool { + if !self.download { + return false; + } + match (self.download_max_minutes, duration) { + (0, _) => true, + (max, Some(d)) => d <= max.saturating_mul(60), + (_, None) => false, + } + } +} + fn default_accent() -> Color { Color::Cyan } @@ -104,6 +143,7 @@ pub struct Config { pub artist: Artist, pub ui: Ui, pub discord: Discord, + pub audio: Audio, } impl Config { @@ -111,7 +151,7 @@ impl Config { Ok(Store::dir()?.join("config.toml")) } - pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord) -> String { + pub fn render(p: &Playback, a: &Artist, u: &Ui, d: &Discord, au: &Audio) -> String { let accent = match u.accent { Color::Reset => "reset", Color::Black => "black", @@ -163,6 +203,19 @@ accent = \"{accent}\" # https://discord.com/developers/applications (its name is shown after # \"Listening to\") and paste its Application ID here. Empty means off. client_id = \"{client_id}\" + +[audio] +# false: stream songs straight from YouTube (starts playing right away). +# true: download songs to the cache first, then play (and prefetch the next one). +download = {download} +# With download = true, songs longer than this many minutes are still streamed +# instead of downloaded. 0 means download everything, however long. +download_max_minutes = {dl_max} +# Use your browser's cookies with yt-dlp when YouTube refuses to play something +# (\"Sign in to confirm you're not a bot\", age-restricted songs, ...). +# Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", +# \"firefox:my-profile\". Empty means off. Close the browser if reading fails. +cookies_from_browser = \"{cookies}\" ", volume = p.volume, shuffle = p.shuffle, @@ -174,6 +227,9 @@ client_id = \"{client_id}\" popular = a.popular_first, accent = accent, client_id = d.client_id, + download = au.download, + dl_max = au.download_max_minutes, + cookies = au.cookies_from_browser.replace(['\\', '"'], ""), ) } @@ -188,7 +244,7 @@ client_id = \"{client_id}\" let text = match fs::read_to_string(&path) { Ok(t) => t, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default())); + let _ = Self::write(&Self::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); return (Config::default(), None); } Err(e) => { @@ -267,7 +323,7 @@ pub fn migrate_legacy() { let _ = Session::write(&s); } } - let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default())); + let _ = Config::write(&Config::render(&p, &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default())); } #[cfg(test)] @@ -276,7 +332,7 @@ mod tests { #[test] fn default_file_parses_back() { - let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default()); + let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &Audio::default()); let c: Config = toml::from_str(&text).unwrap(); let d = Playback::default(); assert_eq!(c.playback.volume, d.volume); @@ -343,8 +399,42 @@ mod tests { // Roundtrip: render then parse let u = Ui { accent: Color::Blue }; - let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default()); + let text = Config::render(&Playback::default(), &Artist::default(), &u, &Discord::default(), &Audio::default()); let c: Config = toml::from_str(&text).unwrap(); assert_eq!(c.ui.accent, Color::Blue); } + + #[test] + fn audio_section() { + let c: Config = toml::from_str("").unwrap(); + assert!(!c.audio.download); + assert_eq!(c.audio.cookies(), None); + let c: Config = toml::from_str( + "[audio]\ndownload = true\ndownload_max_minutes = 10\ncookies_from_browser = \" firefox \"", + ) + .unwrap(); + assert_eq!(c.audio.cookies(), Some("firefox")); + assert!(c.audio.should_download(Some(600))); + assert!(!c.audio.should_download(Some(601))); + assert!(!c.audio.should_download(None)); + let mut a = c.audio.clone(); + a.download_max_minutes = 0; + assert!(a.should_download(Some(10_000))); + a.download = false; + assert!(!a.should_download(Some(1))); + } + + #[test] + fn audio_roundtrip() { + let a = Audio { + download: true, + download_max_minutes: 30, + cookies_from_browser: "chrome".into(), + }; + let text = Config::render(&Playback::default(), &Artist::default(), &Ui::default(), &Discord::default(), &a); + let c: Config = toml::from_str(&text).unwrap(); + assert!(c.audio.download); + assert_eq!(c.audio.download_max_minutes, 30); + assert_eq!(c.audio.cookies(), Some("chrome")); + } } diff --git a/src/player.rs b/src/player.rs index 8b41a85..a4ea4b2 100644 --- a/src/player.rs +++ b/src/player.rs @@ -6,6 +6,8 @@ use std::time::{Duration, Instant}; use anyhow::{anyhow, bail, Result}; use serde_json::{json, Value}; +use crate::config::Audio; + /// Platform-specific transport for mpv's JSON IPC. /// Unix: a Unix domain socket. Windows: a named pipe (`\\.\pipe\...`). #[cfg(unix)] @@ -311,6 +313,15 @@ impl Drop for Player { const KEEP_FILES: usize = 40; +/// Base yt-dlp command with the user's cookie setting applied. +fn ytdlp(audio: &Audio) -> Command { + let mut cmd = Command::new("yt-dlp"); + if let Some(browser) = audio.cookies() { + cmd.arg("--cookies-from-browser").arg(browser); + } + cmd +} + fn audio_dir() -> PathBuf { dirs::cache_dir() .unwrap_or_else(std::env::temp_dir) @@ -326,13 +337,13 @@ pub fn cached_audio(id: &str) -> Option { .find(|p| p.file_stem().and_then(|s| s.to_str()) == Some(id)) } -pub fn fetch_audio(id: &str) -> Result { +pub fn fetch_audio(id: &str, audio: &Audio) -> Result { if let Some(path) = cached_audio(id) { return Ok(path); } let dir = audio_dir(); std::fs::create_dir_all(&dir).map_err(|e| format!("can't create {}: {e}", dir.display()))?; - let mut cmd = Command::new("yt-dlp"); + let mut cmd = ytdlp(audio); cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "--no-progress", "--no-mtime"]) .arg("-P") .arg(&dir) @@ -357,6 +368,35 @@ pub fn fetch_audio(id: &str) -> Result { .unwrap_or_else(|| "yt-dlp downloaded nothing".into())) } +/// Ask yt-dlp for the direct media URL so mpv can start playing right away +/// instead of waiting for the whole file to download. +pub fn resolve_stream(id: &str, audio: &Audio) -> Result { + let mut cmd = ytdlp(audio); + cmd.args(["-f", "bestaudio/best", "--no-playlist", "--no-warnings", "-g"]) + .arg(format!("https://music.youtube.com/watch?v={id}")) + .stdin(Stdio::null()); + crate::sys::no_window(&mut cmd); + let out = cmd + .output() + .map_err(|e| format!("could not run yt-dlp ({e}); is it installed and on PATH?"))?; + let stdout = String::from_utf8_lossy(&out.stdout); + if let Some(url) = stdout + .lines() + .map(str::trim) + .find(|l| l.starts_with("http")) + .filter(|_| out.status.success()) + { + return Ok(url.to_string()); + } + let stderr = String::from_utf8_lossy(&out.stderr); + Err(stderr + .lines() + .rev() + .find_map(|l| l.strip_prefix("ERROR: ")) + .map(ytdl_reason) + .unwrap_or_else(|| "yt-dlp returned no stream URL".into())) +} + fn prune(dir: &std::path::Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; From 8da1876835e34f673afd7c96c99b928100195459 Mon Sep 17 00:00:00 2001 From: Socat Date: Mon, 2 Nov 2026 19:01:40 +0100 Subject: [PATCH 08/11] Add GitHub Actions workflow for Rust project --- .github/workflows/rust.yml | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 .github/workflows/rust.yml diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..9fd45e0 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,22 @@ +name: Rust + +on: + push: + branches: [ "main" ] + pull_request: + branches: [ "main" ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose From 4570de2da70d49082864a1874063aa4efecfed9d Mon Sep 17 00:00:00 2001 From: socatlolmeow Date: Tue, 3 Nov 2026 14:04:44 +0100 Subject: [PATCH 09/11] feat(config): configurable keybinds via [keys] in config.toml, add cookies_from_browser option Mainview keys can now be remapped per action. vim keys remain the default and anything left uset keeps its default. Supports ctrl+ keys, named keys and multi-key sequences like "gg". Invalid entries show a warning on start :3 You're now able to use cookies from your browser to fetch the songs. See config.toml. --- README.md | 48 ++++++- src/app.rs | 196 +++++++++++++++------------- src/config.rs | 35 ++++- src/keys.rs | 353 ++++++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 1 + src/ui.rs | 4 +- 6 files changed, 545 insertions(+), 92 deletions(-) create mode 100644 src/keys.rs diff --git a/README.md b/README.md index 4ec6a77..411fc69 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Search, queue and play music with no account, no login and no browser. - **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history - **Picks up where you left off** - your queue and position are restored the next time you start Drift - **Desktop integration** - media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich precense -- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color and more in a single TOML file +- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color, custom keybinds and more in a single TOML file ## Installation @@ -116,12 +116,16 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `G` | song menu | | `ga` | go to the artist | | `gs` | see similar songs | +| `gb` | go to the album | +| `gB` | save the album as a playlist | | `L` | toggle the lyrics panel | | `F` | fullscreen lyrics | | `/` `:` | search / command line | | `?` | help | | `q` | quit | +These are the defaults. See [Custom keys](#custom-keys) to change them. + ### Commands | Command | Action | @@ -179,6 +183,48 @@ cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp whe By default Drift streams songs, so playback starts right away even for hour long tracks. Set `download = true` to cache songs on disk first (this also prefetches the next song). If YouTube asks you to sign in or playback fails, set `cookies_from_browser` to the browser where youre logged into YouTube. If you alr have a `config.toml`, add the `[audio]` section by hand, since Drift never rewrites it. +### Custom keys + +The keys in the table above are the defaults. To change them add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys; everything you leave out keeps its default. + +```toml +[keys] +quit = "Q" +move_down = ["j", "down", "ctrl+n"] +move_up = ["k", "up", "ctrl+p"] +go_top = ["gg", "home"] +shuffle = [] # an empty list unbinds the action +``` + +A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `tab`, `backtab`, `backspace`, `delete`, `insert`, `home`, `end`, `pageup`, `pagedown`, `up`, `down`, `left`, `right`, `menu`, `f1` to `f12`), `ctrl+` plus a key, or a sequence like `"gg"` or `"ctrl+x g"`. Counts (`5j`) keep working on any binding. If you bind a key that is a default of another action, the other action loses it. A mistake in `[keys]` shows a message at startup and falls back to the default keys. + +| Action | Default keys | +| --- | --- | +| `move_down` `move_up` | `j` `down` / `k` `up` | +| `half_page_down` `half_page_up` | `ctrl+d` / `ctrl+u` | +| `page_down` `page_up` | `ctrl+f` `pagedown` / `ctrl+b` `pageup` | +| `go_top` `go_bottom` | `gg` `home` / `ge` `end` | +| `next_view` `prev_view` | `tab` / `backtab` | +| `view_results` `view_queue` `view_playlists` | `gr` / `gq` / `gp` | +| `activate` | `enter` | +| `pause` | `space` | +| `next` `prev` | `n` `>` / `N` `<` | +| `seek_back` `seek_forward` | `h` `left` / `l` `right` | +| `volume_up` `volume_down` | `+` `=` / `-` `_` | +| `add` `add_all` `play_all` | `a` / `A` / `P` | +| `delete` | `x` `delete` `dd` | +| `move_item_down` `move_item_up` | `J` / `K` | +| `shuffle` `repeat` | `s` / `r` | +| `menu` | `G` `menu` | +| `go_artist` `go_similar` `go_album` `save_album` | `ga` / `gs` / `gb` / `gB` | +| `lyrics_panel` `lyrics` | `L` / `F` | +| `search` `command` | `/` / `:` | +| `help` | `?` | +| `quit` | `q` | +| `back` | `backspace` `esc` | + +In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable; popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. + Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. ## Contributing diff --git a/src/app.rs b/src/app.rs index 5dd2f32..d8fdade 100644 --- a/src/app.rs +++ b/src/app.rs @@ -11,6 +11,7 @@ use crate::config::{self, Audio, Config}; use crate::lyrics::{self, Lyrics}; use crate::model::{split_artists, Collection, RepeatMode, Track}; use crate::discord::Discord; +use crate::keys::{Action, Key, Keymap, Lookup}; use crate::mpris::{Cmd, Mpris}; use crate::player::{self, Player, PlayerEvent}; use crate::session::Session; @@ -204,7 +205,7 @@ pub struct App { history: Vec, hist_pos: Option, pub count: Option, - pub pending: Option, + pub pending: Vec, pub play_state: PlayState, pub position: f64, @@ -232,6 +233,7 @@ pub struct App { mpris: Option, discord: Option, owns_session: bool, + keymap: Keymap, seek_step: f64, volume_step: f64, autoplay: bool, @@ -307,7 +309,8 @@ impl App { history: Vec::new(), hist_pos: None, count: None, - pending: None, + pending: Vec::new(), + keymap: cfg.keymap.clone(), play_state: PlayState::Stopped, position: 0.0, duration: 0.0, @@ -541,122 +544,103 @@ impl App { } fn key_normal(&mut self, key: KeyEvent) { - let ctrl = is_ctrl(&key); + let key = Key::from(key); - if let Some(p) = self.pending.take() { - let n = self.count.take(); - if let KeyCode::Char(c) = key.code { - match (p, c) { - ('g', 'g') => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), - ('g', 'e') => { - self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) - } - ('g', 'r') => self.set_view(View::Results), - ('g', 'q') => self.set_view(View::Queue), - ('g', 'p') => self.set_view(View::Playlists), - ('d', 'd') => self.delete_selected(), - ('g', 'a') => self.go_to_artist(), - ('g', 's') => self.go_similar(), - ('g', 'b') => self.go_album(), - ('g', 'B') => self.save_album(None), - _ => {} - } - } - return; - } - - if let KeyCode::Char(c @ '0'..='9') = key.code { - if !ctrl && !(c == '0' && self.count.is_none()) { - let d = c as usize - '0' as usize; + if self.pending.is_empty() { + if let Some(d) = key.digit().filter(|&d| d != 0 || self.count.is_some()) { self.count = Some((self.count.unwrap_or(0) * 10 + d).min(9999)); return; } } - let n = self.count.take(); + + self.pending.push(key); + match self.keymap.lookup(&self.pending) { + Lookup::Pending => {} + Lookup::Run(action) => { + self.pending.clear(); + let n = self.count.take(); + self.run_action(action, n); + } + Lookup::Nothing => { + self.pending.clear(); + self.count = None; + } + } + } + + fn run_action(&mut self, action: Action, n: Option) { let times = n.unwrap_or(1); if self.view == View::Playlists { - match key.code { - KeyCode::Right | KeyCode::Char('l') if !self.in_playlist() && !ctrl => { + match action { + Action::SeekForward if !self.in_playlist() => { return self.open_selected_playlist(); } - KeyCode::Left | KeyCode::Char('h') | KeyCode::Backspace | KeyCode::Esc - if self.in_playlist() && !ctrl => - { + Action::SeekBack | Action::Back if self.in_playlist() => { return self.close_playlist(); } _ => {} } } - match key.code { - KeyCode::Char('d') if ctrl => self.move_sel(10), - KeyCode::Char('u') if ctrl => self.move_sel(-10), - KeyCode::Char('f') if ctrl => self.move_sel(20), - KeyCode::Char('b') if ctrl => self.move_sel(-20), - KeyCode::PageDown => self.move_sel(20), - KeyCode::PageUp => self.move_sel(-20), - KeyCode::Home => self.set_selected(0), - KeyCode::End => self.set_selected(usize::MAX), + match action { + Action::HalfPageDown => self.move_sel(10), + Action::HalfPageUp => self.move_sel(-10), + Action::PageDown => self.move_sel(20), + Action::PageUp => self.move_sel(-20), + Action::MoveDown => self.move_sel(times as isize), + Action::MoveUp => self.move_sel(-(times as isize)), + Action::GoTop => self.set_selected(n.map_or(0, |n| n.saturating_sub(1))), + Action::GoBottom => { + self.set_selected(n.map_or(usize::MAX, |n| n.saturating_sub(1))) + } + Action::Menu => self.open_menu(), + Action::Delete => self.delete_selected(), - KeyCode::Char('j') | KeyCode::Down => self.move_sel(times as isize), - KeyCode::Char('k') | KeyCode::Up => self.move_sel(-(times as isize)), - KeyCode::Char('g') => { - self.pending = Some('g'); - self.count = n; - } - KeyCode::Char('G') | KeyCode::Menu => self.open_menu(), - KeyCode::Char('d') => { - if matches!(self.view, View::Queue | View::Playlists) { - self.pending = Some('d'); - } - } - KeyCode::Char('x') | KeyCode::Delete => self.delete_selected(), + Action::NextView => self.set_view(View::from_idx(self.view.idx() + 1)), + Action::PrevView => self.set_view(View::from_idx(self.view.idx() + 2)), + Action::ViewResults => self.set_view(View::Results), + Action::ViewQueue => self.set_view(View::Queue), + Action::ViewPlaylists => self.set_view(View::Playlists), - KeyCode::Tab => self.set_view(View::from_idx(self.view.idx() + 1)), - KeyCode::BackTab => self.set_view(View::from_idx(self.view.idx() + 2)), - - KeyCode::Enter => self.activate(), - 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('+') | KeyCode::Char('=') => { - self.set_volume(Amount::Delta(self.volume_step * times as f64)) - } - KeyCode::Char('-') | KeyCode::Char('_') => { + Action::Activate => self.activate(), + Action::Pause => self.toggle_pause(), + Action::Next => self.next(), + Action::Prev => self.prev(), + Action::SeekBack => self.seek(Amount::Delta(-self.seek_step * times as f64)), + Action::SeekForward => self.seek(Amount::Delta(self.seek_step * times as f64)), + Action::VolumeUp => self.set_volume(Amount::Delta(self.volume_step * times as f64)), + Action::VolumeDown => { self.set_volume(Amount::Delta(-self.volume_step * times as f64)) } - KeyCode::Char('a') => self.add_selected(), - KeyCode::Char('A') => self.add_all_results(), - KeyCode::Char('P') => self.play_all_results(), - KeyCode::Char('J') => self.move_item(1), - KeyCode::Char('K') => self.move_item(-1), - KeyCode::Char('s') => self.toggle_shuffle(), - KeyCode::Char('r') => self.cycle_repeat(), + Action::Add => self.add_selected(), + Action::AddAll => self.add_all_results(), + Action::PlayAll => self.play_all_results(), + Action::MoveItemDown => self.move_item(1), + Action::MoveItemUp => self.move_item(-1), + Action::Shuffle => self.toggle_shuffle(), + Action::Repeat => self.cycle_repeat(), - KeyCode::Char('/') => { + Action::Search => { self.cmd_buf.clear(); self.cmd_search = true; self.enter_command_mode(); } - KeyCode::Char(':') => { + Action::Command => { self.cmd_buf.clear(); self.cmd_search = false; self.enter_command_mode(); } - KeyCode::Char('L') => self.lyrics_panel = !self.lyrics_panel, - KeyCode::Char('F') => self.open_lyrics(), - KeyCode::Char('?') => self.open_help(), - KeyCode::Char('q') => self.should_quit = true, - KeyCode::Esc => self.status = None, - _ => {} + Action::LyricsPanel => self.lyrics_panel = !self.lyrics_panel, + Action::Lyrics => self.open_lyrics(), + Action::GoArtist => self.go_to_artist(), + Action::GoSimilar => self.go_similar(), + Action::GoAlbum => self.go_album(), + Action::SaveAlbum => self.save_album(None), + Action::Help => self.open_help(), + Action::Quit => self.should_quit = true, + Action::Back => self.status = None, } } @@ -2536,6 +2520,7 @@ impl App { #[cfg(test)] mod tests { use super::*; + use crate::keys::Keys; use std::sync::mpsc; fn track(id: &str, title: &str) -> Track { @@ -2652,6 +2637,40 @@ mod tests { app.shuffle = false; } + fn check_custom_keys(app: &mut App) { + let custom = [ + ("move_down", Keys::Many(vec!["e".into()])), + ("go_top", Keys::One("zz".into())), + ("quit", Keys::One("Q".into())), + ]; + let custom = custom.into_iter().map(|(k, v)| (k.into(), v)).collect(); + app.keymap = Keymap::build(&custom).unwrap(); + + app.results = (0..5).map(|i| track(&format!("{i:011}"), "T")).collect(); + app.view = View::Results; + app.states[View::Results.idx()].select(Some(0)); + + press(app, KeyCode::Char('j')); + assert_eq!(app.selected(), 0); + press(app, KeyCode::Char('e')); + assert_eq!(app.selected(), 1); + press(app, KeyCode::Char('2')); + press(app, KeyCode::Char('e')); + assert_eq!(app.selected(), 3); + press(app, KeyCode::Char('z')); + assert!(app.count.is_none() && !app.pending.is_empty()); + press(app, KeyCode::Char('z')); + assert_eq!(app.selected(), 0); + assert!(app.pending.is_empty()); + + press(app, KeyCode::Char('q')); + assert!(!app.should_quit); + press(app, KeyCode::Char('Q')); + assert!(app.should_quit); + app.should_quit = false; + app.keymap = Keymap::default(); + } + #[test] fn playlist_drill_in() { let dir = std::env::temp_dir().join(format!("drift-test-{}", std::process::id())); @@ -2742,6 +2761,7 @@ mod tests { check_artist_popup(&mut app); check_paging(&mut app); + check_custom_keys(&mut app); app.run_command("remove"); assert_eq!(app.queue.len(), 1); diff --git a/src/config.rs b/src/config.rs index afaf6ee..cb92b38 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,4 @@ +use std::collections::HashMap; use std::fs; use std::path::PathBuf; use std::str::FromStr; @@ -7,6 +8,7 @@ use ratatui::style::Color; use serde::de::{self, Visitor}; use serde::{Deserialize, Deserializer}; +use crate::keys::{Keymap, Keys}; use crate::model::{RepeatMode, Track}; use crate::session::Session; use crate::store::{write_atomic, Store}; @@ -144,6 +146,9 @@ pub struct Config { pub ui: Ui, pub discord: Discord, pub audio: Audio, + pub keys: HashMap, + #[serde(skip)] + pub keymap: Keymap, } impl Config { @@ -216,6 +221,17 @@ download_max_minutes = {dl_max} # Examples: \"firefox\", \"chrome\", \"chromium\", \"brave\", \"edge\", # \"firefox:my-profile\". Empty means off. Close the browser if reading fails. cookies_from_browser = \"{cookies}\" + +[keys] +# Remap keys here. Each line sets an action to one key or a list of keys and +# replaces that action's default keys. Everything you leave out keeps its vim +# default. See the README for the list of actions. +# Write keys as a character (\"x\", \"G\"), a name (space, enter, esc, tab, backtab, +# backspace, delete, home, end, pageup, pagedown, up, down, left, right, f1-f12), +# ctrl+key (\"ctrl+d\") or a sequence (\"gg\", \"ctrl+x g\"). An empty list unbinds. +# quit = \"Q\" +# move_down = [\"j\", \"down\"] +# go_top = [\"gg\", \"home\"] ", volume = p.volume, shuffle = p.shuffle, @@ -257,7 +273,14 @@ cookies_from_browser = \"{cookies}\" match toml::from_str::(&text) { Ok(mut c) => { c.playback.sanitize(); - (c, None) + let warning = match Keymap::build(&c.keys) { + Ok(keymap) => { + c.keymap = keymap; + None + } + Err(e) => Some(format!("config.toml [keys]: {e}; using default keys")), + }; + (c, warning) } Err(e) => { let bak = path.with_extension("toml.bak"); @@ -404,6 +427,16 @@ mod tests { assert_eq!(c.ui.accent, Color::Blue); } + #[test] + fn keys_section() { + let c: Config = toml::from_str("").unwrap(); + assert!(c.keys.is_empty()); + let c: Config = + toml::from_str("[keys]\nquit = \"Q\"\nmove_down = [\"e\", \"down\"]").unwrap(); + assert!(Keymap::build(&c.keys).is_ok()); + assert_eq!(c.keys.len(), 2); + } + #[test] fn audio_section() { let c: Config = toml::from_str("").unwrap(); diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..50a997a --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,353 @@ +use std::collections::HashMap; +use std::fmt; + +use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + MoveDown, + MoveUp, + HalfPageDown, + HalfPageUp, + PageDown, + PageUp, + GoTop, + GoBottom, + Menu, + Delete, + NextView, + PrevView, + ViewResults, + ViewQueue, + ViewPlaylists, + Activate, + Pause, + Next, + Prev, + SeekBack, + SeekForward, + VolumeUp, + VolumeDown, + Add, + AddAll, + PlayAll, + MoveItemDown, + MoveItemUp, + Shuffle, + Repeat, + Search, + Command, + LyricsPanel, + Lyrics, + GoArtist, + GoSimilar, + GoAlbum, + SaveAlbum, + Help, + Quit, + Back, +} + +const DEFAULTS: &[(&str, Action, &[&str])] = &[ + ("move_down", Action::MoveDown, &["j", "down"]), + ("move_up", Action::MoveUp, &["k", "up"]), + ("half_page_down", Action::HalfPageDown, &["ctrl+d"]), + ("half_page_up", Action::HalfPageUp, &["ctrl+u"]), + ("page_down", Action::PageDown, &["ctrl+f", "pagedown"]), + ("page_up", Action::PageUp, &["ctrl+b", "pageup"]), + ("go_top", Action::GoTop, &["gg", "home"]), + ("go_bottom", Action::GoBottom, &["ge", "end"]), + ("menu", Action::Menu, &["G", "menu"]), + ("delete", Action::Delete, &["x", "delete", "dd"]), + ("next_view", Action::NextView, &["tab"]), + ("prev_view", Action::PrevView, &["backtab"]), + ("view_results", Action::ViewResults, &["gr"]), + ("view_queue", Action::ViewQueue, &["gq"]), + ("view_playlists", Action::ViewPlaylists, &["gp"]), + ("activate", Action::Activate, &["enter"]), + ("pause", Action::Pause, &["space"]), + ("next", Action::Next, &["n", ">"]), + ("prev", Action::Prev, &["N", "<"]), + ("seek_back", Action::SeekBack, &["h", "left"]), + ("seek_forward", Action::SeekForward, &["l", "right"]), + ("volume_up", Action::VolumeUp, &["+", "="]), + ("volume_down", Action::VolumeDown, &["-", "_"]), + ("add", Action::Add, &["a"]), + ("add_all", Action::AddAll, &["A"]), + ("play_all", Action::PlayAll, &["P"]), + ("move_item_down", Action::MoveItemDown, &["J"]), + ("move_item_up", Action::MoveItemUp, &["K"]), + ("shuffle", Action::Shuffle, &["s"]), + ("repeat", Action::Repeat, &["r"]), + ("search", Action::Search, &["/"]), + ("command", Action::Command, &[":"]), + ("lyrics_panel", Action::LyricsPanel, &["L"]), + ("lyrics", Action::Lyrics, &["F"]), + ("go_artist", Action::GoArtist, &["ga"]), + ("go_similar", Action::GoSimilar, &["gs"]), + ("go_album", Action::GoAlbum, &["gb"]), + ("save_album", Action::SaveAlbum, &["gB"]), + ("help", Action::Help, &["?"]), + ("quit", Action::Quit, &["q"]), + ("back", Action::Back, &["backspace", "esc"]), +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Key { + code: KeyCode, + ctrl: bool, +} + +impl Key { + pub fn digit(&self) -> Option { + match self.code { + KeyCode::Char(c @ '0'..='9') if !self.ctrl => Some(c as usize - '0' as usize), + _ => None, + } + } +} + +impl From for Key { + fn from(e: KeyEvent) -> Key { + Key { + code: e.code, + ctrl: e.modifiers.contains(KeyModifiers::CONTROL) + && !e.modifiers.contains(KeyModifiers::ALT), + } + } +} + +impl fmt::Display for Key { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + if self.ctrl { + f.write_str("^")?; + } + match self.code { + KeyCode::Char(c) => write!(f, "{c}"), + other => write!(f, "{other}"), + } + } +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(untagged)] +pub enum Keys { + One(String), + Many(Vec), +} + +impl Keys { + fn as_slice(&self) -> &[String] { + match self { + Keys::One(s) => std::slice::from_ref(s), + Keys::Many(v) => v, + } + } +} + +pub enum Lookup { + Run(Action), + Pending, + Nothing, +} + +#[derive(Debug, Clone)] +pub struct Keymap(Vec<(Vec, Action)>); + +impl Default for Keymap { + fn default() -> Keymap { + Keymap::build(&HashMap::new()).expect("default keys are valid") + } +} + +impl Keymap { + pub fn build(custom: &HashMap) -> Result { + if let Some(name) = custom + .keys() + .find(|n| !DEFAULTS.iter().any(|d| d.0 == n.as_str())) + { + return Err(format!("unknown action \"{name}\"")); + } + + let mut map: Vec<(Vec, Action)> = Vec::new(); + for (name, action, _) in DEFAULTS { + let Some(keys) = custom.get(*name) else { + continue; + }; + for text in keys.as_slice() { + let seq = parse_sequence(text).map_err(|e| format!("{name}: {e}"))?; + if let Some((_, other)) = map.iter().find(|(s, _)| *s == seq) { + if other != action { + return Err(format!("\"{text}\" is bound to two actions")); + } + continue; + } + map.push((seq, *action)); + } + } + + for (name, action, keys) in DEFAULTS { + if custom.contains_key(*name) { + continue; + } + for text in *keys { + let seq = parse_sequence(text).expect("default keys are valid"); + if !map.iter().any(|(s, _)| *s == seq) { + map.push((seq, *action)); + } + } + } + Ok(Keymap(map)) + } + + pub fn lookup(&self, seq: &[Key]) -> Lookup { + if let Some((_, action)) = self.0.iter().find(|(s, _)| s == seq) { + Lookup::Run(*action) + } else if self.0.iter().any(|(s, _)| s.starts_with(seq)) { + Lookup::Pending + } else { + Lookup::Nothing + } + } +} + +fn parse_sequence(text: &str) -> Result, String> { + let mut keys = Vec::new(); + for token in text.split_whitespace() { + match parse_key(token) { + Some(key) => keys.push(key), + None if !token.contains('+') => keys.extend(token.chars().map(|c| Key { + code: KeyCode::Char(c), + ctrl: false, + })), + None => return Err(format!("unknown key \"{token}\"")), + } + } + if keys.is_empty() { + return Err("empty key".into()); + } + Ok(keys) +} + +fn parse_key(token: &str) -> Option { + let (ctrl, name) = match token.get(..5) { + Some(p) if p.eq_ignore_ascii_case("ctrl+") => (true, &token[5..]), + _ => (false, token), + }; + let mut chars = name.chars(); + let code = match (chars.next()?, chars.next()) { + (c, None) if ctrl => KeyCode::Char(c.to_ascii_lowercase()), + (c, None) => KeyCode::Char(c), + _ => match name.to_ascii_lowercase().as_str() { + "space" => KeyCode::Char(' '), + "enter" | "return" => KeyCode::Enter, + "esc" | "escape" => KeyCode::Esc, + "tab" => KeyCode::Tab, + "backtab" => KeyCode::BackTab, + "backspace" => KeyCode::Backspace, + "delete" | "del" => KeyCode::Delete, + "insert" => KeyCode::Insert, + "home" => KeyCode::Home, + "end" => KeyCode::End, + "pageup" => KeyCode::PageUp, + "pagedown" => KeyCode::PageDown, + "up" => KeyCode::Up, + "down" => KeyCode::Down, + "left" => KeyCode::Left, + "right" => KeyCode::Right, + "menu" => KeyCode::Menu, + f => match f.strip_prefix('f')?.parse::() { + Ok(n @ 1..=12) => KeyCode::F(n), + _ => return None, + }, + }, + }; + Some(Key { code, ctrl }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn custom(entries: &[(&str, &[&str])]) -> HashMap { + entries + .iter() + .map(|(name, keys)| { + let keys = keys.iter().map(|k| k.to_string()).collect(); + (name.to_string(), Keys::Many(keys)) + }) + .collect() + } + + fn seq(text: &str) -> Vec { + parse_sequence(text).unwrap() + } + + fn run(map: &Keymap, text: &str) -> Option { + match map.lookup(&seq(text)) { + Lookup::Run(a) => Some(a), + _ => None, + } + } + + #[test] + fn defaults_are_vim() { + let map = Keymap::default(); + assert_eq!(run(&map, "j"), Some(Action::MoveDown)); + assert_eq!(run(&map, "down"), Some(Action::MoveDown)); + assert_eq!(run(&map, "gg"), Some(Action::GoTop)); + assert_eq!(run(&map, "dd"), Some(Action::Delete)); + assert_eq!(run(&map, "ctrl+d"), Some(Action::HalfPageDown)); + assert_eq!(run(&map, "G"), Some(Action::Menu)); + assert_eq!(run(&map, "space"), Some(Action::Pause)); + assert!(matches!(map.lookup(&seq("g")), Lookup::Pending)); + assert!(matches!(map.lookup(&seq("gz")), Lookup::Nothing)); + } + + #[test] + fn parsing() { + assert_eq!(seq("ctrl+D"), seq("ctrl+d")); + assert_eq!(seq("gg"), seq("g g")); + assert_eq!(seq("+").len(), 1); + assert_eq!(seq("ctrl+x g").len(), 2); + assert_eq!(seq("f5")[0].code, KeyCode::F(5)); + assert!(parse_sequence("").is_err()); + assert!(parse_sequence("ctrl+nope").is_err()); + assert!(parse_key("f13").is_none()); + } + + #[test] + fn custom_replaces_default_keys() { + let map = Keymap::build(&custom(&[("quit", &["Q"]), ("move_down", &["e"])])).unwrap(); + assert_eq!(run(&map, "Q"), Some(Action::Quit)); + assert_eq!(run(&map, "q"), None); + assert_eq!(run(&map, "e"), Some(Action::MoveDown)); + assert_eq!(run(&map, "j"), None); + assert_eq!(run(&map, "k"), Some(Action::MoveUp)); + } + + #[test] + fn custom_wins_over_other_defaults() { + let map = Keymap::build(&custom(&[("quit", &["x"])])).unwrap(); + assert_eq!(run(&map, "x"), Some(Action::Quit)); + assert_eq!(run(&map, "dd"), Some(Action::Delete)); + } + + #[test] + fn single_string_and_disable() { + let toml = "quit = \"Q\"\nshuffle = []"; + let keys: HashMap = toml::from_str(toml).unwrap(); + let map = Keymap::build(&keys).unwrap(); + assert_eq!(run(&map, "Q"), Some(Action::Quit)); + assert_eq!(run(&map, "s"), None); + } + + #[test] + fn bad_config_is_rejected() { + assert!(Keymap::build(&custom(&[("fly", &["f"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["ctrl+nope"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["z"]), ("add", &["z"])])).is_err()); + assert!(Keymap::build(&custom(&[("quit", &["z", "z"])])).is_ok()); + } +} diff --git a/src/main.rs b/src/main.rs index c3634f6..b43714a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod app; mod command; mod config; mod discord; +mod keys; mod lyrics; mod model; mod mpris; diff --git a/src/ui.rs b/src/ui.rs index e49af1a..1f27bdf 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -432,8 +432,8 @@ fn draw_now_playing(f: &mut Frame, app: &App, area: Rect) { if let Some(c) = app.count { pending.push_str(&c.to_string()); } - if let Some(p) = app.pending { - pending.push(p); + for key in &app.pending { + pending.push_str(&key.to_string()); } if !pending.is_empty() { block = block.title_bottom( From a1a31069263e627b2ee073c4b66c79efe79c24ac Mon Sep 17 00:00:00 2001 From: Socat Date: Tue, 3 Nov 2026 14:14:49 +0100 Subject: [PATCH 10/11] change(cargo.toml): change license Changed the license in Cargo.toml from MIT to GPL v3 or later :3 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 185e096..22d50f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "drift" version = "0.1.0" edition = "2021" description = "A Vim-style terminal music player for YouTube Music (no login required)" -license = "MIT" +license = "GPL-3.0-or-later" [workspace] From 8d24596cbdb35bf70c5a69d50e17666ec34afb69 Mon Sep 17 00:00:00 2001 From: Socat Date: Tue, 3 Nov 2026 14:18:23 +0100 Subject: [PATCH 11/11] update(readme): update readme with table of contents Added a table of contents to the README for user friendlyness. --- README.md | 162 ++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 122 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 411fc69..f5e0613 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,61 @@
-

Drift

- -[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) -![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square) -![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) +# Drift ### A YouTube Music player for your terminal, with Vim keybinds. Search, queue and play music with no account, no login and no browser. +[![Stars](https://img.shields.io/github/stars/socatlolmeow/drift?style=flat-square&logo=github)](https://github.com/socatlolmeow/drift/stargazers) +[![Build](https://img.shields.io/github/actions/workflow/status/socatlolmeow/drift/rust.yml?branch=main&style=flat-square&label=build)](https://github.com/socatlolmeow/drift/actions/workflows/rust.yml) +[![License](https://img.shields.io/github/license/socatlolmeow/drift?style=flat-square)](LICENSE) +![Rust](https://img.shields.io/badge/built%20with-Rust-orange?style=flat-square&logo=rust) + +Drift showcase +
--- -
- Drift showcase -
+## Table of contents + +- [Features](#features) +- [Installation](#installation) + - [Arch Linux (AUR)](#arch-linux-aur) + - [Install script](#install-script) + - [From source](#from-source) + - [Nix](#nix) + - [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually) +- [Usage](#usage) +- [Keybinds](#keybinds) +- [Commands](#commands) +- [Lyrics](#lyrics) +- [Configuration](#configuration) + - [Streaming or downloading](#streaming-or-downloading) + - [Discord rich presence](#discord-rich-presence) + - [Custom keys](#custom-keys) + - [Environment variables](#environment-variables) +- [Troubleshooting](#troubleshooting) +- [Contributing](#contributing) +- [License](#license) --- ## Features -- **Vim-style keybinds** - `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line -- **Synced lyrics** - a side panel or fullscreen view that highlights the current line and fills it in as it's sung -- **Queue & playlists** - build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL -- **Discovery** - press `ga` for an artist's songs, `gs` for similar songs, or use `:album` and `:playlist` to search albums and playlists -- **Song menu** - press `G` on a song to play it, qeue it, add it to a playlist, jump to the artist or see its info -- **Command palette** - `:search`, `:seek`, `:vol`, `:import` and more, with completion and history -- **Picks up where you left off** - your queue and position are restored the next time you start Drift -- **Desktop integration** - media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich precense -- **Configurable** - volume, seek step, shuffle/repeat defaults, accent color, custom keybinds and more in a single TOML file +- Vim-style keybinds: `j`/`k`, `gg`/`ge`, counts like `5j`, and `:` for the command line +- Synced lyrics in a side panel or fullscreen, with the current line filled in as it's sung +- Queue and playlists. Build a queue, save and reorder playlists, or import any YouTube / YouTube Music playlist by URL +- Discovery: `ga` for an artist's songs, `gs` for similar songs, `gb` for the album, and `:album` / `:playlist` to search for albums and playlists +- A song menu (`G`) to play a song, queue it, add it to a playlist, jump to the artist or see its info +- A command palette with completion and history +- Your queue and position are restored the next time you start Drift +- Media keys and `playerctl` on Linux (MPRIS), plus optional Discord rich presence +- One TOML file for volume, seek step, shuffle/repeat defaults, accent color and custom keybinds ## Installation -Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) for stream extraction. Run `drift --check` at any time to verify both are installed. +Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/yt-dlp/yt-dlp) to get the audio streams. Run `drift --check` at any time to see if both are installed. ### Arch Linux (AUR) @@ -42,7 +63,7 @@ Drift needs [mpv](https://mpv.io/) for playback and [yt-dlp](https://github.com/ yay -S driftmusic-git ``` -Any AUR helper works, or build it by hand: +Any AUR helper works. You can also build it by hand: ```bash git clone https://aur.archlinux.org/driftmusic-git.git @@ -50,11 +71,11 @@ cd driftmusic-git makepkg -si ``` -`mpv` and `yt-dlp` are installed automatically. +`mpv` and `yt-dlp` are pulled in automatically. ### Install script -On Arch, Debian/Ubuntu, Fedora, openSUSE, Gentoo & Void the install script installs the dependencies and builds Drift with cargo: +Works on Arch, Debian/Ubuntu, Fedora, openSUSE, Void, Alpine and Gentoo. It installs the dependencies and builds Drift with cargo. ```bash git clone https://github.com/socatlolmeow/drift.git @@ -74,8 +95,15 @@ cargo install --path . This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed Rust with rustup. -
-Installing mpv and yt-dlp manually +### Nix + +The repo has a flake that puts `mpv` and `yt-dlp` on Drift's `PATH` for you: + +```bash +nix run github:socatlolmeow/drift +``` + +### Installing mpv and yt-dlp manually | Platform | Command | | --- | --- | @@ -84,20 +112,22 @@ This puts `drift` in `~/.cargo/bin/`, which is on your `$PATH` if you installed | Arch | `sudo pacman -S mpv yt-dlp` | | Windows | `winget install mpv.net yt-dlp.yt-dlp` or `scoop install mpv yt-dlp` | -
- ## Usage ```bash drift # start the player drift "daft punk" # start with a search -drift --check # verify mpv and yt-dlp are installed +drift --check # check that mpv and yt-dlp are installed +drift --help +drift --version ``` -Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of keybinds. +Press `/` to search, `Enter` to play, `a` to add a song to the queue and `?` for the full list of keybinds. Inside the player, `:help` lists the commands. ## Keybinds +These are the defaults. You can change them, see [Custom keys](#custom-keys). + | Key | Action | | --- | --- | | `j` `k` / `↓` `↑` | move (counts work: `5j`) | @@ -124,31 +154,39 @@ Press `/` to search, `Enter` to play, `a` to queue and `?` for the full list of | `?` | help | | `q` | quit | -These are the defaults. See [Custom keys](#custom-keys) to change them. +## Commands -### Commands +Press `:` to open the command line. Most commands have short aliases (`:vol`, `:sa`, `:ar` and so on). | Command | Action | | --- | --- | | `:search "query"` | search YouTube Music | | `:play [N]` | play or resume, or play row `N` | +| `:pause` `:resume` `:toggle` `:stop` | control playback | +| `:next` `:prev` | next / previous track | | `:seek 90` `1:30` `+10` | absolute or relative seek | | `:vol 50` `+5` | set or change the volume | | `:shuffle` `:repeat [off\|all\|one]` | playback modes | | `:add [N]` `:addall` `:playall` | queue results | +| `:remove [N]` `:clear` | remove from the queue / clear it | | `:save name` `:load name` `:append name` | work with playlists | -| `:savealbum [name]` `:saveresults [name]` | save album or results directly as a playlist | +| `:save! name` | save and overwrite an existing playlist | +| `:savealbum [name]` `:saveresults [name]` | save an album or the results directly as a playlist | | `:addto name` `:delpl name` | add a song to / delete a playlist | | `:import URL` | import a YouTube / YouTube Music playlist | | `:album [query]` | open the selected song's album, or search albums | | `:playlist query` | search YouTube Music playlists | -| `:similar` | radio: songs similar to the selected one (also `:radio`) | +| `:similar` | songs similar to the selected one (also `:radio`) | | `:artist` | songs by the selected song's artist | +| `:results` `:queue` `:playlists` | switch view | | `:lyrics` | toggle the lyrics panel | +| `:quit` | quit | + +`:save` refuses to overwrite an existing playlist. Use `:save!` to replace it or `:append` to add to the end of it. ## Lyrics -Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text you can scroll with `j`/`k`. +Press `L` for a side panel (needs a terminal at least 70 columns wide) or `F` for fullscreen. The current line is highlighted and fills in as it's sung. Songs without synced lyrics show plain text that you can scroll with `j`/`k`. Lyrics come from [LRCLIB](https://lrclib.net) and are only fetched while the panel or fullscreen view is open. @@ -173,19 +211,40 @@ popular_first = false accent = "cyan" # a color name or a hex value like "#FF8800" [discord] -client_id = "" # change this for Discord RPC to work in Drift +client_id = "" # set this to enable Discord rich presence [audio] download = false # false: stream songs; true: download to the cache first download_max_minutes = 15 # with download = true, longer songs are still streamed (0 = no limit) cookies_from_browser = "" # e.g. "firefox" or "chrome"; passed to yt-dlp when YouTube blocks playback + +[keys] +quit = "Q" +move_down = ["j", "down", "ctrl+n"] +move_up = ["k", "up", "ctrl+p"] +go_top = ["gg", "home"] +shuffle = [] # an empty list unbinds the action ``` -By default Drift streams songs, so playback starts right away even for hour long tracks. Set `download = true` to cache songs on disk first (this also prefetches the next song). If YouTube asks you to sign in or playback fails, set `cookies_from_browser` to the browser where youre logged into YouTube. If you alr have a `config.toml`, add the `[audio]` section by hand, since Drift never rewrites it. +If you already have a `config.toml` from an older version, add the `[audio]` section by hand, since Drift never rewrites the file. + +### Streaming or downloading + +By default Drift streams songs, so playback starts right away, even for hour-long tracks. With `download = true` songs are cached on disk first, and the next song in the queue is fetched in the background while the current one plays. + +### Discord rich presence + +This is off by default. + +1. Create an application at https://discord.com/developers/applications. Its name is what shows up after "Listening to". +2. Copy the Application ID into `client_id` under `[discord]` in `config.toml`. +3. Restart Drift. + +Your status then shows the current song with cover art, album, a progress bar and a "Listen Along" button. If Discord isn't running, Drift keeps retrying quietly in the background. ### Custom keys -The keys in the table above are the defaults. To change them add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys; everything you leave out keeps its default. +To change keys, add a `[keys]` section to `config.toml`. Each line sets an action to one key or a list of keys and replaces that action's default keys. Everything you leave out keeps its default. ```toml [keys] @@ -223,17 +282,40 @@ A key is a character (`"x"`, `"G"`, `"/"`), a name (`space`, `enter`, `esc`, `ta | `quit` | `q` | | `back` | `backspace` `esc` | -In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable; popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. +In the Playlists tab `seek_forward` opens the selected playlist, and `seek_back` or `back` leaves it. Only the main view is remappable. Popups, the command line and fullscreen lyrics keep their own keys, and the in-app help (`?`) still lists the defaults. -Set `DRIFT_CONFIG_DIR` or `DRIFT_STATE_DIR` to change where Drift keeps its config, playlists and session. +### Environment variables + +| Variable | What it does | +| --- | --- | +| `DRIFT_CONFIG_DIR` | where Drift keeps `config.toml` and your playlists | +| `DRIFT_STATE_DIR` | where Drift keeps the saved session | +| `DRIFT_MPV_ARGS` | extra arguments passed to mpv | + +## Troubleshooting + +**Drift says mpv or yt-dlp is missing.** Run `drift --check` to see which one, and install it with the table in [Installing mpv and yt-dlp manually](#installing-mpv-and-yt-dlp-manually). + +**Playback fails with "Sign in to confirm you're not a bot".** YouTube wants a login. Set `cookies_from_browser` under `[audio]` to a browser you're logged into YouTube with (for example `"firefox"`), then restart Drift. Updating yt-dlp also helps, since YouTube changes things often. + +**The lyrics panel doesn't show up.** It needs a terminal at least 70 columns wide. Make the window wider or use `F` for fullscreen lyrics. + +**`drift: command not found`.** Make sure `~/.cargo/bin` is in your `$PATH`. + +**`gb` opens the wrong album.** Drift looks albums up by album and artist name, so it can guess wrong, and the song needs album info to begin with. `:album ` lets you search for it yourself. + +You can run several Drift windows at once. Only the first one restores and saves the session, and playlists saved from different windows are merged instead of overwritten. ## Contributing -Bug reports, ideas and pull requests are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) before you start. +Bug reports, ideas and pull requests are welcome. Read [CONTRIBUTING.md](CONTRIBUTING.md) before you start. To build and run the tests: -## Star History +```bash +cargo build +cargo test +``` -[![Star History Chart](https://api.star-history.com/svg?repos=socatlolmeow/drift&type=Date)](https://star-history.com/#socatlolmeow/drift) +Recent changes are listed in [CHANGELOG.md](CHANGELOG.md). ## License