rmptui96dd350
Add the application, screens, lyrics, visualizer and tag editor
- app: a single event loop over terminal input, MPD events, lyric lookups and visualizer frames. Key presses only enqueue requests, so seeking and volume changes take effect immediately. - state: client-side mirror of the server, updated incrementally from plchanges, with interpolated elapsed time for a smooth progress bar. - screens, each a self-contained component behind one Screen trait: queue (with an optional lyrics pane), browser (playlists at the root), playlist editor, media library (artist/album/song columns), tag editor, spectrum visualizer and synced lyrics. - popups: add-to-playlist, text input, confirmation and key help. - lyrics: LRC parser; lookups from a local .lrc file, the cache, then LRCLIB; titles are derived from file names when tags are missing. - visualizer: fifo reader thread with Hann-windowed FFT, log-spaced bands and gravity falloff; only runs while the screen is visible. - tags: lofty-based writes followed by an MPD rescan of the folder. - Queue moves and single deletes use positions so rapid repeated key presses stay correct before the queue is refreshed.
+5476 −6
33 files changed
- M Cargo.lock +14 −0
- M Cargo.toml +2 −1
- M src/action.rs +4 −1
- A src/app.rs +533 −0
- A src/event.rs +20 −0
- M src/lib.rs +16 −0
- A src/logging.rs +58 −0
- A src/lyrics/lrc.rs +165 −0
- A src/lyrics/mod.rs +373 −0
- M src/main.rs +59 −1
- M src/mpd/commands.rs +9 −3
- M src/mpd/mod.rs +2 −0
- A src/mpd/selection.rs +36 −0
- A src/popups/add_to_playlist.rs +115 −0
- A src/popups/confirm.rs +69 −0
- A src/popups/help.rs +134 −0
- A src/popups/input.rs +94 −0
- A src/popups/mod.rs +60 −0
- A src/screens/browser.rs +334 −0
- A src/screens/library.rs +413 −0
- A src/screens/lyrics.rs +132 −0
- A src/screens/mod.rs +164 −0
- A src/screens/playlists.rs +348 −0
- A src/screens/queue.rs +284 −0
- A src/screens/tag_editor.rs +469 −0
- A src/screens/visualizer.rs +107 −0
- A src/state.rs +177 −0
- A src/tags.rs +112 −0
- A src/ui/input.rs +160 −0
- A src/ui/list.rs +327 −0
- A src/ui/mod.rs +328 −0
- A src/visualizer/bars.rs +130 −0
- A src/visualizer/mod.rs +228 −0
| @@ -1512,6 +1512,7 @@ dependencies = [ | |||
| 1512 | 1512 | "rustls-platform-verifier", | |
| 1513 | 1513 | "serde", | |
| 1514 | 1514 | "serde_json", | |
| 1515 | + | "serde_urlencoded", | |
| 1515 | 1516 | "sync_wrapper", | |
| 1516 | 1517 | "tokio", | |
| 1517 | 1518 | "tokio-rustls", | |
| @@ -1546,6 +1547,7 @@ dependencies = [ | |||
| 1546 | 1547 | "clap", | |
| 1547 | 1548 | "crossterm", | |
| 1548 | 1549 | "futures-util", | |
| 1550 | + | "libc", | |
| 1549 | 1551 | "lofty", | |
| 1550 | 1552 | "nucleo-matcher", | |
| 1551 | 1553 | "ratatui", | |
| @@ -1795,6 +1797,18 @@ dependencies = [ | |||
| 1795 | 1797 | "serde_core", | |
| 1796 | 1798 | ] | |
| 1797 | 1799 | ||
| 1800 | + | [[package]] | |
| 1801 | + | name = "serde_urlencoded" | |
| 1802 | + | version = "0.7.1" | |
| 1803 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1804 | + | checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" | |
| 1805 | + | dependencies = [ | |
| 1806 | + | "form_urlencoded", | |
| 1807 | + | "itoa", | |
| 1808 | + | "ryu", | |
| 1809 | + | "serde", | |
| 1810 | + | ] | |
| 1811 | + | ||
| 1798 | 1812 | [[package]] | |
| 1799 | 1813 | name = "sharded-slab" | |
| 1800 | 1814 | version = "0.1.7" | |
| @@ -16,11 +16,12 @@ anyhow = "1.0.104" | |||
| 16 | 16 | clap = { version = "4.6.7", features = ["derive"] } | |
| 17 | 17 | crossterm = { version = "0.29.0", features = ["event-stream"] } | |
| 18 | 18 | futures-util = "0.3.34" | |
| 19 | + | libc = "0.2.186" | |
| 19 | 20 | lofty = "0.25.4" | |
| 20 | 21 | nucleo-matcher = "0.3.1" | |
| 21 | 22 | ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] } | |
| 22 | 23 | realfft = "3.5.0" | |
| 23 | − | reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] } | |
| 24 | + | reqwest = { version = "0.13.5", default-features = false, features = ["json", "query", "rustls"] } | |
| 24 | 25 | serde = { version = "1.0.229", features = ["derive"] } | |
| 25 | 26 | serde_json = "1.0.154" | |
| 26 | 27 | thiserror = "2.0.21" | |
| @@ -35,7 +35,10 @@ macro_rules! named_enum { | |||
| 35 | 35 | .iter() | |
| 36 | 36 | .copied() | |
| 37 | 37 | .find(|v| v.name() == s) | |
| 38 | − | .ok_or_else(|| format!("unknown {} `{s}`", stringify!($name).to_lowercase())) | |
| 38 | + | .ok_or_else(|| { | |
| 39 | + | let names: Vec<_> = Self::ALL.iter().map(|v| v.name()).collect(); | |
| 40 | + | format!("unknown value `{s}`, expected one of: {}", names.join(", ")) | |
| 41 | + | }) | |
| 39 | 42 | } | |
| 40 | 43 | } | |
| 41 | 44 | ||
| @@ -0,0 +1,533 @@ | |||
| 1 | + | //! The application: owns the state, routes events, and draws the frame. | |
| 2 | + | ||
| 3 | + | use std::time::{Duration, Instant}; | |
| 4 | + | ||
| 5 | + | use crossterm::event::{Event, EventStream, KeyEventKind, MouseButton, MouseEvent, MouseEventKind}; | |
| 6 | + | use futures_util::StreamExt; | |
| 7 | + | use ratatui::{DefaultTerminal, Frame, layout::Position}; | |
| 8 | + | use tokio::{ | |
| 9 | + | sync::mpsc, | |
| 10 | + | time::{MissedTickBehavior, interval}, | |
| 11 | + | }; | |
| 12 | + | use tracing::info; | |
| 13 | + | ||
| 14 | + | use crate::{ | |
| 15 | + | action::{Action, Context, ScreenId}, | |
| 16 | + | config::{Config, KeyCombo}, | |
| 17 | + | event::AppEvent, | |
| 18 | + | lyrics::LyricsService, | |
| 19 | + | mpd::{self, MpdEvent, MpdHandle, PlayState, Query, Reply, Subsystem, commands}, | |
| 20 | + | popups::{HelpPopup, Popup, PopupState}, | |
| 21 | + | screens::{Ctx, Effect, Screens}, | |
| 22 | + | state::MpdState, | |
| 23 | + | tags, | |
| 24 | + | ui::{self, FrameLayout, input::InputEvent, input::LineInput}, | |
| 25 | + | visualizer::Visualizer, | |
| 26 | + | }; | |
| 27 | + | ||
| 28 | + | struct Message { | |
| 29 | + | text: String, | |
| 30 | + | error: bool, | |
| 31 | + | until: Instant, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | pub struct App { | |
| 35 | + | config: Config, | |
| 36 | + | mpd: MpdState, | |
| 37 | + | client: MpdHandle, | |
| 38 | + | events_tx: mpsc::UnboundedSender<AppEvent>, | |
| 39 | + | events_rx: Option<mpsc::UnboundedReceiver<AppEvent>>, | |
| 40 | + | screens: Screens, | |
| 41 | + | active: ScreenId, | |
| 42 | + | popups: Vec<Box<dyn Popup>>, | |
| 43 | + | /// The search prompt, while it is open. | |
| 44 | + | search: Option<LineInput>, | |
| 45 | + | message: Option<Message>, | |
| 46 | + | lyrics: LyricsService, | |
| 47 | + | visualizer: Visualizer, | |
| 48 | + | layout: Option<FrameLayout>, | |
| 49 | + | tab_hits: Vec<(u16, u16, ScreenId)>, | |
| 50 | + | /// The volume before muting. | |
| 51 | + | muted_volume: Option<u8>, | |
| 52 | + | queue_requested: bool, | |
| 53 | + | running: bool, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | impl App { | |
| 57 | + | pub fn new(config: Config) -> anyhow::Result<Self> { | |
| 58 | + | let (address, password) = config.mpd_connection()?; | |
| 59 | + | info!("connecting to MPD at {address}"); | |
| 60 | + | let (events_tx, events_rx) = mpsc::unbounded_channel(); | |
| 61 | + | let client = mpd::spawn(address, password, events_tx.clone()); | |
| 62 | + | let visualizer = Visualizer::spawn(config.visualizer.clone()); | |
| 63 | + | let screens = Screens::new(&config, visualizer.subscribe()); | |
| 64 | + | let mut mpd = MpdState::default(); | |
| 65 | + | mpd.music_dir = config.music_directory(); | |
| 66 | + | let mut app = Self { | |
| 67 | + | lyrics: LyricsService::new(config.lyrics.clone(), events_tx.clone()), | |
| 68 | + | active: config.ui.startup_screen, | |
| 69 | + | config, | |
| 70 | + | mpd, | |
| 71 | + | client, | |
| 72 | + | events_tx, | |
| 73 | + | events_rx: Some(events_rx), | |
| 74 | + | screens, | |
| 75 | + | popups: Vec::new(), | |
| 76 | + | search: None, | |
| 77 | + | message: None, | |
| 78 | + | visualizer, | |
| 79 | + | layout: None, | |
| 80 | + | tab_hits: Vec::new(), | |
| 81 | + | muted_volume: None, | |
| 82 | + | queue_requested: false, | |
| 83 | + | running: true, | |
| 84 | + | }; | |
| 85 | + | app.switch_to(app.active); | |
| 86 | + | Ok(app) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | pub async fn run(mut self, terminal: &mut DefaultTerminal) -> anyhow::Result<()> { | |
| 90 | + | let mut events = self.events_rx.take().expect("run is called once"); | |
| 91 | + | let mut input = EventStream::new(); | |
| 92 | + | let mut spectrum = self.visualizer.subscribe(); | |
| 93 | + | // Redraws the progress bar while playing and expires messages. | |
| 94 | + | let mut tick = interval(Duration::from_millis(250)); | |
| 95 | + | tick.set_missed_tick_behavior(MissedTickBehavior::Skip); | |
| 96 | + | ||
| 97 | + | while self.running { | |
| 98 | + | terminal.draw(|frame| self.draw(frame))?; | |
| 99 | + | tokio::select! { | |
| 100 | + | event = input.next() => match event { | |
| 101 | + | Some(Ok(event)) => self.on_terminal_event(event), | |
| 102 | + | Some(Err(error)) => return Err(error.into()), | |
| 103 | + | None => break, | |
| 104 | + | }, | |
| 105 | + | Some(event) = events.recv() => { | |
| 106 | + | self.on_app_event(event); | |
| 107 | + | // Apply everything that is already queued before redrawing. | |
| 108 | + | while let Ok(event) = events.try_recv() { | |
| 109 | + | self.on_app_event(event); | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | _ = tick.tick() => {} | |
| 113 | + | Ok(()) = spectrum.changed(), if self.visualizer.is_active() => {} | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | Ok(()) | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | /// Runs `f` with a context, then applies the effects it queued. | |
| 120 | + | fn with_ctx<R>(&mut self, f: impl FnOnce(&mut Screens, &mut Ctx) -> R) -> R { | |
| 121 | + | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 122 | + | let result = f(&mut self.screens, &mut ctx); | |
| 123 | + | let effects = ctx.effects; | |
| 124 | + | self.apply_effects(effects); | |
| 125 | + | result | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | fn apply_effects(&mut self, effects: Vec<Effect>) { | |
| 129 | + | for effect in effects { | |
| 130 | + | match effect { | |
| 131 | + | Effect::Notify { text, error } => self.notify(text, error), | |
| 132 | + | Effect::Popup(popup) => self.popups.push(popup), | |
| 133 | + | Effect::Message(id, message) => { | |
| 134 | + | self.with_ctx(|screens, ctx| screens.get_mut(id).on_message(message, ctx)); | |
| 135 | + | } | |
| 136 | + | Effect::WriteTags(writes) => self.write_tags(writes), | |
| 137 | + | Effect::ReloadLyrics => { | |
| 138 | + | self.lyrics.ensure( | |
| 139 | + | self.mpd.current.as_ref(), | |
| 140 | + | self.mpd.music_dir.as_deref(), | |
| 141 | + | true, | |
| 142 | + | ); | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | fn notify(&mut self, text: String, error: bool) { | |
| 149 | + | self.message = Some(Message { | |
| 150 | + | text, | |
| 151 | + | error, | |
| 152 | + | until: Instant::now() + self.config.message_timeout(), | |
| 153 | + | }); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | fn switch_to(&mut self, screen: ScreenId) { | |
| 157 | + | self.active = screen; | |
| 158 | + | self.search = None; | |
| 159 | + | self.visualizer.set_active(screen == ScreenId::Visualizer); | |
| 160 | + | self.with_ctx(|screens, ctx| screens.get_mut(screen).on_enter(ctx)); | |
| 161 | + | self.refresh_lyrics(); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | fn lyrics_visible(&self) -> bool { | |
| 165 | + | match self.active { | |
| 166 | + | ScreenId::Lyrics => true, | |
| 167 | + | ScreenId::Queue => self.screens.queue.lyrics_pane_visible(), | |
| 168 | + | _ => false, | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | /// Loads lyrics for the current song if they are on screen. | |
| 173 | + | fn refresh_lyrics(&mut self) { | |
| 174 | + | if self.lyrics_visible() { | |
| 175 | + | self.lyrics.ensure( | |
| 176 | + | self.mpd.current.as_ref(), | |
| 177 | + | self.mpd.music_dir.as_deref(), | |
| 178 | + | false, | |
| 179 | + | ); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | // ----- terminal input ------------------------------------------------- | |
| 184 | + | ||
| 185 | + | fn on_terminal_event(&mut self, event: Event) { | |
| 186 | + | match event { | |
| 187 | + | Event::Key(key) if key.kind != KeyEventKind::Release => self.on_key(key.into()), | |
| 188 | + | Event::Mouse(mouse) if self.config.ui.mouse => self.on_mouse(mouse), | |
| 189 | + | _ => {} | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | fn on_key(&mut self, key: KeyCombo) { | |
| 194 | + | if let Some(mut popup) = self.popups.pop() { | |
| 195 | + | let mut ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 196 | + | let state = popup.handle_key(key, &mut ctx); | |
| 197 | + | let effects = ctx.effects; | |
| 198 | + | if state == PopupState::Open { | |
| 199 | + | self.popups.push(popup); | |
| 200 | + | } | |
| 201 | + | self.apply_effects(effects); | |
| 202 | + | return; | |
| 203 | + | } | |
| 204 | + | if self.search.is_some() { | |
| 205 | + | self.on_search_key(key); | |
| 206 | + | return; | |
| 207 | + | } | |
| 208 | + | let contexts = [self.active.context(), Context::Navigation, Context::Global]; | |
| 209 | + | let Some(action) = self.config.keymap.resolve(&contexts, key) else { | |
| 210 | + | return; | |
| 211 | + | }; | |
| 212 | + | if action.contexts().contains(&Context::Global) { | |
| 213 | + | self.global_action(action); | |
| 214 | + | } else if action == Action::Search { | |
| 215 | + | self.open_search(); | |
| 216 | + | } else { | |
| 217 | + | let active = self.active; | |
| 218 | + | let lyrics_before = self.lyrics_visible(); | |
| 219 | + | self.with_ctx(|screens, ctx| screens.get_mut(active).handle_action(action, ctx)); | |
| 220 | + | if !lyrics_before { | |
| 221 | + | self.refresh_lyrics(); | |
| 222 | + | } | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | fn open_search(&mut self) { | |
| 227 | + | let active = self.active; | |
| 228 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 229 | + | if self.screens.get_mut(active).search("", &ctx) { | |
| 230 | + | self.search = Some(LineInput::default()); | |
| 231 | + | } else { | |
| 232 | + | self.notify("Nothing to search on this screen".into(), false); | |
| 233 | + | } | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | fn on_search_key(&mut self, key: KeyCombo) { | |
| 237 | + | let Some(input) = self.search.as_mut() else { | |
| 238 | + | return; | |
| 239 | + | }; | |
| 240 | + | let event = match self.config.keymap.resolve(&[Context::Input], key) { | |
| 241 | + | Some(action) => input.apply(action), | |
| 242 | + | None => match key.as_text() { | |
| 243 | + | Some(ch) => { | |
| 244 | + | input.insert(ch); | |
| 245 | + | InputEvent::Changed | |
| 246 | + | } | |
| 247 | + | None => InputEvent::Ignored, | |
| 248 | + | }, | |
| 249 | + | }; | |
| 250 | + | let query = input.text().to_owned(); | |
| 251 | + | let active = self.active; | |
| 252 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 253 | + | match event { | |
| 254 | + | InputEvent::Changed => { | |
| 255 | + | self.screens.get_mut(active).search(&query, &ctx); | |
| 256 | + | } | |
| 257 | + | // Keep the results for `n`/`N`. | |
| 258 | + | InputEvent::Submit => self.search = None, | |
| 259 | + | InputEvent::Cancel => { | |
| 260 | + | self.screens.get_mut(active).search("", &ctx); | |
| 261 | + | self.search = None; | |
| 262 | + | } | |
| 263 | + | InputEvent::Moved | InputEvent::Ignored => {} | |
| 264 | + | } | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | fn global_action(&mut self, action: Action) { | |
| 268 | + | let status = &self.mpd.status; | |
| 269 | + | let seek = i64::from(self.config.playback.seek_seconds); | |
| 270 | + | let step = i32::from(self.config.playback.volume_step); | |
| 271 | + | let client = &self.client; | |
| 272 | + | match action { | |
| 273 | + | Action::Quit => self.running = false, | |
| 274 | + | Action::Help => { | |
| 275 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 276 | + | let popup = HelpPopup::new(self.active.context(), &ctx); | |
| 277 | + | self.popups.push(Box::new(popup)); | |
| 278 | + | } | |
| 279 | + | Action::TogglePause => client.exec(match status.state { | |
| 280 | + | PlayState::Play => commands::pause(true), | |
| 281 | + | PlayState::Pause => commands::pause(false), | |
| 282 | + | PlayState::Stop => commands::play(), | |
| 283 | + | }), | |
| 284 | + | Action::Pause => client.exec(commands::pause(true)), | |
| 285 | + | Action::Stop => client.exec(commands::stop()), | |
| 286 | + | Action::NextTrack => client.exec(commands::next()), | |
| 287 | + | Action::PreviousTrack => client.exec(commands::previous()), | |
| 288 | + | Action::SeekForward | Action::SeekBackward if status.state != PlayState::Stop => { | |
| 289 | + | let delta = if action == Action::SeekForward { | |
| 290 | + | seek | |
| 291 | + | } else { | |
| 292 | + | -seek | |
| 293 | + | }; | |
| 294 | + | client.exec(commands::seek_relative(delta)); | |
| 295 | + | } | |
| 296 | + | Action::SeekForward | Action::SeekBackward => {} | |
| 297 | + | Action::VolumeUp | Action::VolumeDown => match status.volume { | |
| 298 | + | Some(_) => { | |
| 299 | + | let delta = if action == Action::VolumeUp { | |
| 300 | + | step | |
| 301 | + | } else { | |
| 302 | + | -step | |
| 303 | + | }; | |
| 304 | + | client.exec(commands::change_volume(delta)); | |
| 305 | + | } | |
| 306 | + | None => self.notify("MPD has no mixer".into(), true), | |
| 307 | + | }, | |
| 308 | + | Action::ToggleMute => match status.volume { | |
| 309 | + | Some(0) => { | |
| 310 | + | let volume = self.muted_volume.take().unwrap_or(50); | |
| 311 | + | client.exec(commands::set_volume(volume)); | |
| 312 | + | } | |
| 313 | + | Some(volume) => { | |
| 314 | + | self.muted_volume = Some(volume); | |
| 315 | + | client.exec(commands::set_volume(0)); | |
| 316 | + | } | |
| 317 | + | None => self.notify("MPD has no mixer".into(), true), | |
| 318 | + | }, | |
| 319 | + | Action::ToggleRepeat => client.exec(commands::repeat(!status.repeat)), | |
| 320 | + | Action::ToggleRandom => client.exec(commands::random(!status.random)), | |
| 321 | + | Action::ToggleSingle => client.exec(commands::single(!status.single.is_on())), | |
| 322 | + | Action::ToggleConsume => client.exec(commands::consume(!status.consume.is_on())), | |
| 323 | + | Action::UpdateDatabase => { | |
| 324 | + | client.exec(commands::update(None)); | |
| 325 | + | self.notify("Updating the database…".into(), false); | |
| 326 | + | } | |
| 327 | + | Action::NextScreen => self.switch_to(self.active.next()), | |
| 328 | + | Action::PreviousScreen => self.switch_to(self.active.previous()), | |
| 329 | + | Action::ScreenQueue => self.switch_to(ScreenId::Queue), | |
| 330 | + | Action::ScreenBrowser => self.switch_to(ScreenId::Browser), | |
| 331 | + | Action::ScreenPlaylists => self.switch_to(ScreenId::Playlists), | |
| 332 | + | Action::ScreenLibrary => self.switch_to(ScreenId::Library), | |
| 333 | + | Action::ScreenTagEditor => self.switch_to(ScreenId::TagEditor), | |
| 334 | + | Action::ScreenVisualizer => self.switch_to(ScreenId::Visualizer), | |
| 335 | + | Action::ScreenLyrics => self.switch_to(ScreenId::Lyrics), | |
| 336 | + | _ => {} | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | fn on_mouse(&mut self, mouse: MouseEvent) { | |
| 341 | + | if !self.popups.is_empty() { | |
| 342 | + | return; | |
| 343 | + | } | |
| 344 | + | let Some(layout) = &self.layout else { | |
| 345 | + | return; | |
| 346 | + | }; | |
| 347 | + | let position = Position::new(mouse.column, mouse.row); | |
| 348 | + | let left_click = mouse.kind == MouseEventKind::Down(MouseButton::Left); | |
| 349 | + | if layout.tabs.contains(position) { | |
| 350 | + | if left_click | |
| 351 | + | && let Some(&(_, _, screen)) = self | |
| 352 | + | .tab_hits | |
| 353 | + | .iter() | |
| 354 | + | .find(|(start, end, _)| (*start..*end).contains(&mouse.column)) | |
| 355 | + | { | |
| 356 | + | self.switch_to(screen); | |
| 357 | + | } | |
| 358 | + | } else if layout.progress.contains(position) { | |
| 359 | + | if left_click | |
| 360 | + | && self.mpd.status.state != PlayState::Stop | |
| 361 | + | && let Some(target) = | |
| 362 | + | ui::progress_seek_target(layout.progress, mouse.column, &self.mpd) | |
| 363 | + | { | |
| 364 | + | self.client.exec(commands::seek_to(target.as_secs_f64())); | |
| 365 | + | } | |
| 366 | + | } else if layout.body.contains(position) { | |
| 367 | + | let active = self.active; | |
| 368 | + | self.with_ctx(|screens, ctx| screens.get_mut(active).on_mouse(mouse, ctx)); | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | ||
| 372 | + | // ----- background events ---------------------------------------------- | |
| 373 | + | ||
| 374 | + | fn on_app_event(&mut self, event: AppEvent) { | |
| 375 | + | match event { | |
| 376 | + | AppEvent::Mpd(event) => self.on_mpd_event(event), | |
| 377 | + | AppEvent::Lyrics { file, result } => { | |
| 378 | + | self.lyrics.finish(file, result); | |
| 379 | + | } | |
| 380 | + | AppEvent::TagsWritten(Ok(dirs)) => { | |
| 381 | + | let count = dirs.len(); | |
| 382 | + | self.client | |
| 383 | + | .exec_all(dirs.iter().map(|dir| commands::update(Some(dir))).collect()); | |
| 384 | + | self.notify(format!("Saved tags; rescanning {count} folder(s)"), false); | |
| 385 | + | } | |
| 386 | + | AppEvent::TagsWritten(Err(error)) => self.notify(error, true), | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | fn on_mpd_event(&mut self, event: MpdEvent) { | |
| 391 | + | match &event { | |
| 392 | + | MpdEvent::Connected { version } => { | |
| 393 | + | info!("connected to MPD {version}"); | |
| 394 | + | self.mpd.connected = true; | |
| 395 | + | self.mpd.reset_queue(); | |
| 396 | + | self.queue_requested = false; | |
| 397 | + | self.client.query(Query::Status); | |
| 398 | + | self.client.query(Query::Playlists); | |
| 399 | + | if self.config.music_directory().is_none() { | |
| 400 | + | self.client.query(Query::MusicDirectory); | |
| 401 | + | } | |
| 402 | + | self.notify(format!("Connected to MPD {version}"), false); | |
| 403 | + | } | |
| 404 | + | MpdEvent::Disconnected(reason) => { | |
| 405 | + | self.mpd.connected = false; | |
| 406 | + | self.notify(reason.clone(), true); | |
| 407 | + | } | |
| 408 | + | MpdEvent::Changed(subsystems) => { | |
| 409 | + | let mut status = false; | |
| 410 | + | for subsystem in subsystems { | |
| 411 | + | match subsystem { | |
| 412 | + | Subsystem::StoredPlaylist => self.client.query(Query::Playlists), | |
| 413 | + | Subsystem::Database => { | |
| 414 | + | self.mpd.db_version += 1; | |
| 415 | + | status = true; | |
| 416 | + | } | |
| 417 | + | _ => status = true, | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | if status { | |
| 421 | + | self.client.query(Query::Status); | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | MpdEvent::Reply(Reply::Status { status, current }) => { | |
| 425 | + | let song_changed = self.mpd.set_status(status.clone(), current.clone()); | |
| 426 | + | self.request_queue_if_stale(); | |
| 427 | + | if song_changed { | |
| 428 | + | self.refresh_lyrics(); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | MpdEvent::Reply(Reply::QueueChanges { | |
| 432 | + | status, | |
| 433 | + | since, | |
| 434 | + | changes, | |
| 435 | + | }) => { | |
| 436 | + | self.queue_requested = false; | |
| 437 | + | self.mpd | |
| 438 | + | .apply_queue_changes(status, *since, changes.clone()); | |
| 439 | + | self.request_queue_if_stale(); | |
| 440 | + | } | |
| 441 | + | MpdEvent::Reply(Reply::Playlists(playlists)) => { | |
| 442 | + | self.mpd.playlists.clone_from(playlists) | |
| 443 | + | } | |
| 444 | + | MpdEvent::Reply(Reply::MusicDirectory(dir)) => { | |
| 445 | + | if self.config.music_directory().is_none() { | |
| 446 | + | self.mpd.music_dir.clone_from(dir); | |
| 447 | + | } | |
| 448 | + | } | |
| 449 | + | MpdEvent::Reply(_) => {} | |
| 450 | + | MpdEvent::Error(error) => self.notify(error.clone(), true), | |
| 451 | + | } | |
| 452 | + | self.with_ctx(|screens, ctx| screens.for_each(|screen| screen.on_mpd(&event, ctx))); | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | fn request_queue_if_stale(&mut self) { | |
| 456 | + | if !self.queue_requested | |
| 457 | + | && let Some(since) = self.mpd.stale_queue_since() | |
| 458 | + | { | |
| 459 | + | self.queue_requested = true; | |
| 460 | + | self.client.query(Query::QueueChanges { since }); | |
| 461 | + | } | |
| 462 | + | } | |
| 463 | + | ||
| 464 | + | fn write_tags(&mut self, writes: Vec<tags::TagWrite>) { | |
| 465 | + | let events = self.events_tx.clone(); | |
| 466 | + | self.notify(format!("Writing tags of {} file(s)…", writes.len()), false); | |
| 467 | + | tokio::task::spawn_blocking(move || { | |
| 468 | + | let mut dirs: Vec<String> = Vec::new(); | |
| 469 | + | let mut errors = Vec::new(); | |
| 470 | + | for write in &writes { | |
| 471 | + | match tags::write(write) { | |
| 472 | + | Ok(()) => { | |
| 473 | + | let dir = write | |
| 474 | + | .uri | |
| 475 | + | .rsplit_once('/') | |
| 476 | + | .map_or("", |(dir, _)| dir) | |
| 477 | + | .to_owned(); | |
| 478 | + | if !dirs.contains(&dir) { | |
| 479 | + | dirs.push(dir); | |
| 480 | + | } | |
| 481 | + | } | |
| 482 | + | Err(error) => errors.push(error), | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | let result = match errors.as_slice() { | |
| 486 | + | [] => Ok(dirs), | |
| 487 | + | [error] => Err(error.clone()), | |
| 488 | + | [first, rest @ ..] => Err(format!("{first} (and {} more errors)", rest.len())), | |
| 489 | + | }; | |
| 490 | + | let _ = events.send(AppEvent::TagsWritten(result)); | |
| 491 | + | }); | |
| 492 | + | } | |
| 493 | + | ||
| 494 | + | // ----- drawing -------------------------------------------------------- | |
| 495 | + | ||
| 496 | + | fn draw(&mut self, frame: &mut Frame) { | |
| 497 | + | let layout = FrameLayout::new(frame.area()); | |
| 498 | + | let theme = &self.config.theme; | |
| 499 | + | ui::draw_header(frame, layout.header, &self.mpd, theme); | |
| 500 | + | self.tab_hits = ui::draw_tabs(frame, layout.tabs, self.active, theme); | |
| 501 | + | ||
| 502 | + | let ctx = Ctx::new(&self.config, &self.mpd, &self.client, &self.lyrics.state); | |
| 503 | + | self.screens | |
| 504 | + | .get_mut(self.active) | |
| 505 | + | .draw(frame, layout.body, &ctx); | |
| 506 | + | ui::draw_progress(frame, layout.progress, &self.mpd, theme); | |
| 507 | + | ||
| 508 | + | if self | |
| 509 | + | .message | |
| 510 | + | .as_ref() | |
| 511 | + | .is_some_and(|m| m.until <= Instant::now()) | |
| 512 | + | { | |
| 513 | + | self.message = None; | |
| 514 | + | } | |
| 515 | + | if let Some(search) = &self.search { | |
| 516 | + | search.render(frame, layout.status, " /", theme); | |
| 517 | + | } else if let Some(message) = &self.message { | |
| 518 | + | ui::draw_message(frame, layout.status, &message.text, message.error, theme); | |
| 519 | + | } else { | |
| 520 | + | let help = self | |
| 521 | + | .config | |
| 522 | + | .keymap | |
| 523 | + | .keys_for(Context::Global, Action::Help) | |
| 524 | + | .first() | |
| 525 | + | .map_or_else(String::new, |key| format!("{key} for help")); | |
| 526 | + | ui::draw_hint(frame, layout.status, &help, theme); | |
| 527 | + | } | |
| 528 | + | for popup in &mut self.popups { | |
| 529 | + | popup.draw(frame, frame.area(), &ctx); | |
| 530 | + | } | |
| 531 | + | self.layout = Some(layout); | |
| 532 | + | } | |
| 533 | + | } | |
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //! Events delivered to the application loop by background tasks. | |
| 2 | + | ||
| 3 | + | use crate::{lyrics::LookupResult, mpd::MpdEvent}; | |
| 4 | + | ||
| 5 | + | #[derive(Debug)] | |
| 6 | + | pub enum AppEvent { | |
| 7 | + | Mpd(MpdEvent), | |
| 8 | + | Lyrics { | |
| 9 | + | file: String, | |
| 10 | + | result: LookupResult, | |
| 11 | + | }, | |
| 12 | + | /// Tag writes finished: the directories to rescan, or an error. | |
| 13 | + | TagsWritten(Result<Vec<String>, String>), | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | impl From<MpdEvent> for AppEvent { | |
| 17 | + | fn from(event: MpdEvent) -> Self { | |
| 18 | + | Self::Mpd(event) | |
| 19 | + | } | |
| 20 | + | } | |
| @@ -1,4 +1,20 @@ | |||
| 1 | + | //! rmptui — a keyboard-driven MPD client for the terminal. | |
| 2 | + | //! | |
| 3 | + | //! The crate is split into an MPD client (`mpd`), configuration (`config`), | |
| 4 | + | //! the application loop (`app`) and its UI components (`screens`, `popups`, | |
| 5 | + | //! `ui`), plus the lyrics and visualizer services. | |
| 6 | + | ||
| 1 | 7 | pub mod action; | |
| 8 | + | pub mod app; | |
| 2 | 9 | pub mod config; | |
| 10 | + | pub mod event; | |
| 11 | + | pub mod logging; | |
| 12 | + | pub mod lyrics; | |
| 3 | 13 | pub mod mpd; | |
| 14 | + | pub mod popups; | |
| 15 | + | pub mod screens; | |
| 16 | + | pub mod state; | |
| 17 | + | pub mod tags; | |
| 18 | + | pub mod ui; | |
| 4 | 19 | pub mod util; | |
| 20 | + | pub mod visualizer; | |
| @@ -0,0 +1,58 @@ | |||
| 1 | + | //! File logging. The terminal belongs to the UI, so diagnostics go to a file. | |
| 2 | + | ||
| 3 | + | use std::{ | |
| 4 | + | fs::OpenOptions, | |
| 5 | + | io, | |
| 6 | + | path::Path, | |
| 7 | + | sync::{Arc, Mutex}, | |
| 8 | + | }; | |
| 9 | + | ||
| 10 | + | use tracing_subscriber::{EnvFilter, fmt}; | |
| 11 | + | ||
| 12 | + | pub const LOG_PATH: &str = "/tmp/rmptui.log"; | |
| 13 | + | ||
| 14 | + | /// Appends logs to `path`. The level can be set with `RMPTUI_LOG` | |
| 15 | + | /// (e.g. `RMPTUI_LOG=debug`); the default is `info`. | |
| 16 | + | pub fn init(path: &Path) -> io::Result<()> { | |
| 17 | + | let mut options = OpenOptions::new(); | |
| 18 | + | options.create(true).append(true); | |
| 19 | + | #[cfg(unix)] | |
| 20 | + | { | |
| 21 | + | use std::os::unix::fs::OpenOptionsExt; | |
| 22 | + | options.mode(0o600); | |
| 23 | + | } | |
| 24 | + | let file = Arc::new(Mutex::new(options.open(path)?)); | |
| 25 | + | let filter = EnvFilter::try_from_env("RMPTUI_LOG").unwrap_or_else(|_| EnvFilter::new("info")); | |
| 26 | + | fmt() | |
| 27 | + | .with_env_filter(filter) | |
| 28 | + | .with_ansi(false) | |
| 29 | + | .with_writer(move || MutexWriter(Arc::clone(&file))) | |
| 30 | + | .try_init() | |
| 31 | + | .map_err(io::Error::other)?; | |
| 32 | + | ||
| 33 | + | // Record panics in the log as well as on the (restored) terminal. | |
| 34 | + | let previous = std::panic::take_hook(); | |
| 35 | + | std::panic::set_hook(Box::new(move |info| { | |
| 36 | + | tracing::error!("panic: {info}"); | |
| 37 | + | previous(info); | |
| 38 | + | })); | |
| 39 | + | Ok(()) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | struct MutexWriter(Arc<Mutex<std::fs::File>>); | |
| 43 | + | ||
| 44 | + | impl io::Write for MutexWriter { | |
| 45 | + | fn write(&mut self, buf: &[u8]) -> io::Result<usize> { | |
| 46 | + | self.0 | |
| 47 | + | .lock() | |
| 48 | + | .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| 49 | + | .write(buf) | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | fn flush(&mut self) -> io::Result<()> { | |
| 53 | + | self.0 | |
| 54 | + | .lock() | |
| 55 | + | .unwrap_or_else(std::sync::PoisonError::into_inner) | |
| 56 | + | .flush() | |
| 57 | + | } | |
| 58 | + | } | |
| @@ -0,0 +1,165 @@ | |||
| 1 | + | //! Parser for LRC (synced lyrics) files. | |
| 2 | + | ||
| 3 | + | use std::time::Duration; | |
| 4 | + | ||
| 5 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 6 | + | pub struct LyricLine { | |
| 7 | + | pub time: Duration, | |
| 8 | + | pub text: String, | |
| 9 | + | } | |
| 10 | + | ||
| 11 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 12 | + | pub enum Lyrics { | |
| 13 | + | /// Lines with timestamps, sorted by time. | |
| 14 | + | Synced(Vec<LyricLine>), | |
| 15 | + | Plain(Vec<String>), | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | impl Lyrics { | |
| 19 | + | /// Parses LRC text. Text without any timestamps is treated as plain | |
| 20 | + | /// lyrics. | |
| 21 | + | pub fn parse(text: &str) -> Self { | |
| 22 | + | let mut offset_ms: i64 = 0; | |
| 23 | + | let mut synced = Vec::new(); | |
| 24 | + | for raw in text.lines() { | |
| 25 | + | let mut rest = raw.trim(); | |
| 26 | + | let mut times = Vec::new(); | |
| 27 | + | while let Some(tag) = rest.strip_prefix('[').and_then(|r| r.split_once(']')) { | |
| 28 | + | let (inner, tail) = tag; | |
| 29 | + | if let Some(time) = parse_timestamp(inner) { | |
| 30 | + | times.push(time); | |
| 31 | + | } else if let Some(value) = inner.strip_prefix("offset:") { | |
| 32 | + | offset_ms = value.trim().parse().unwrap_or(0); | |
| 33 | + | } else if !is_id_tag(inner) { | |
| 34 | + | break; | |
| 35 | + | } | |
| 36 | + | rest = tail; | |
| 37 | + | } | |
| 38 | + | let text = rest.trim().to_owned(); | |
| 39 | + | synced.extend(times.into_iter().map(|time| (time, text.clone()))); | |
| 40 | + | } | |
| 41 | + | if synced.is_empty() { | |
| 42 | + | return Self::Plain(text.lines().map(|l| l.trim_end().to_owned()).collect()); | |
| 43 | + | } | |
| 44 | + | // A positive offset makes lyrics appear sooner. | |
| 45 | + | let mut lines: Vec<LyricLine> = synced | |
| 46 | + | .into_iter() | |
| 47 | + | .map(|(time, text)| LyricLine { | |
| 48 | + | time: shift(time, -offset_ms), | |
| 49 | + | text, | |
| 50 | + | }) | |
| 51 | + | .collect(); | |
| 52 | + | lines.sort_by_key(|line| line.time); | |
| 53 | + | Self::Synced(lines) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | pub fn is_empty(&self) -> bool { | |
| 57 | + | match self { | |
| 58 | + | Self::Synced(lines) => lines.is_empty(), | |
| 59 | + | Self::Plain(lines) => lines.iter().all(|l| l.trim().is_empty()), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | pub fn len(&self) -> usize { | |
| 64 | + | match self { | |
| 65 | + | Self::Synced(lines) => lines.len(), | |
| 66 | + | Self::Plain(lines) => lines.len(), | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// The index of the line being sung at `elapsed`. | |
| 71 | + | pub fn current_line(&self, elapsed: Duration) -> Option<usize> { | |
| 72 | + | match self { | |
| 73 | + | Self::Synced(lines) => lines | |
| 74 | + | .partition_point(|line| line.time <= elapsed) | |
| 75 | + | .checked_sub(1), | |
| 76 | + | Self::Plain(_) => None, | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub fn line(&self, index: usize) -> &str { | |
| 81 | + | match self { | |
| 82 | + | Self::Synced(lines) => lines.get(index).map_or("", |l| l.text.as_str()), | |
| 83 | + | Self::Plain(lines) => lines.get(index).map_or("", String::as_str), | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | /// `mm:ss`, `mm:ss.xx` or `mm:ss.xxx`. | |
| 89 | + | fn parse_timestamp(tag: &str) -> Option<Duration> { | |
| 90 | + | let (minutes, seconds) = tag.split_once(':')?; | |
| 91 | + | let minutes: u64 = minutes.trim().parse().ok()?; | |
| 92 | + | let (secs, fraction) = seconds.split_once(['.', ':']).unwrap_or((seconds, "0")); | |
| 93 | + | let secs: u64 = secs.trim().parse().ok()?; | |
| 94 | + | if secs >= 60 || fraction.is_empty() || !fraction.chars().all(|c| c.is_ascii_digit()) { | |
| 95 | + | return None; | |
| 96 | + | } | |
| 97 | + | let millis = match fraction.len() { | |
| 98 | + | 1 => fraction.parse::<u64>().ok()? * 100, | |
| 99 | + | 2 => fraction.parse::<u64>().ok()? * 10, | |
| 100 | + | _ => fraction[..3].parse::<u64>().ok()?, | |
| 101 | + | }; | |
| 102 | + | Some(Duration::from_millis((minutes * 60 + secs) * 1000 + millis)) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn is_id_tag(tag: &str) -> bool { | |
| 106 | + | tag.split_once(':') | |
| 107 | + | .is_some_and(|(key, _)| !key.is_empty() && key.chars().all(|c| c.is_ascii_alphabetic())) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | fn shift(time: Duration, ms: i64) -> Duration { | |
| 111 | + | let shifted = time.as_millis() as i64 + ms; | |
| 112 | + | Duration::from_millis(shifted.max(0) as u64) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | #[cfg(test)] | |
| 116 | + | mod tests { | |
| 117 | + | use super::*; | |
| 118 | + | ||
| 119 | + | fn ms(ms: u64) -> Duration { | |
| 120 | + | Duration::from_millis(ms) | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | #[test] | |
| 124 | + | fn parses_synced_lyrics() { | |
| 125 | + | let lyrics = Lyrics::parse( | |
| 126 | + | "[ar:Someone]\n[ti:Song]\n[00:12.50]First\n[00:05.1][01:00.000]Chorus\n\n[00:20]Last", | |
| 127 | + | ); | |
| 128 | + | let Lyrics::Synced(lines) = &lyrics else { | |
| 129 | + | panic!("expected synced lyrics"); | |
| 130 | + | }; | |
| 131 | + | let times: Vec<_> = lines.iter().map(|l| l.time).collect(); | |
| 132 | + | assert_eq!(times, [ms(5_100), ms(12_500), ms(20_000), ms(60_000)]); | |
| 133 | + | assert_eq!(lines[0].text, "Chorus"); | |
| 134 | + | assert_eq!(lyrics.current_line(ms(0)), None); | |
| 135 | + | assert_eq!(lyrics.current_line(ms(12_500)), Some(1)); | |
| 136 | + | assert_eq!(lyrics.current_line(ms(90_000)), Some(3)); | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | #[test] | |
| 140 | + | fn applies_offset() { | |
| 141 | + | let lyrics = Lyrics::parse("[offset:+500]\n[00:01.00]a\n[00:00.20]b"); | |
| 142 | + | let Lyrics::Synced(lines) = lyrics else { | |
| 143 | + | panic!("expected synced lyrics"); | |
| 144 | + | }; | |
| 145 | + | assert_eq!(lines[0].time, ms(0)); | |
| 146 | + | assert_eq!(lines[1].time, ms(500)); | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | #[test] | |
| 150 | + | fn falls_back_to_plain() { | |
| 151 | + | let lyrics = Lyrics::parse("Just some\nwords [not a tag]\n"); | |
| 152 | + | assert_eq!( | |
| 153 | + | lyrics, | |
| 154 | + | Lyrics::Plain(vec!["Just some".into(), "words [not a tag]".into()]) | |
| 155 | + | ); | |
| 156 | + | assert_eq!(lyrics.current_line(ms(1000)), None); | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | #[test] | |
| 160 | + | fn rejects_bad_timestamps() { | |
| 161 | + | assert_eq!(parse_timestamp("1:75.00"), None); | |
| 162 | + | assert_eq!(parse_timestamp("ab:10"), None); | |
| 163 | + | assert_eq!(parse_timestamp("02:03.4567"), Some(ms(123_456))); | |
| 164 | + | } | |
| 165 | + | } | |
| @@ -0,0 +1,373 @@ | |||
| 1 | + | //! Finding lyrics for the current song. | |
| 2 | + | //! | |
| 3 | + | //! Sources, in order: an `.lrc` file next to the song, the on-disk cache, and | |
| 4 | + | //! [LRCLIB](https://lrclib.net). | |
| 5 | + | ||
| 6 | + | mod lrc; | |
| 7 | + | ||
| 8 | + | use std::{ | |
| 9 | + | collections::HashMap, | |
| 10 | + | path::{Path, PathBuf}, | |
| 11 | + | time::Duration, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | pub use lrc::{LyricLine, Lyrics}; | |
| 15 | + | use serde::Deserialize; | |
| 16 | + | use tokio::{sync::mpsc, task::JoinHandle}; | |
| 17 | + | use tracing::{debug, warn}; | |
| 18 | + | ||
| 19 | + | use crate::{config::LyricsConfig, event::AppEvent, mpd::Song, util}; | |
| 20 | + | ||
| 21 | + | /// What the UI knows about the lyrics of the current song. | |
| 22 | + | #[derive(Clone, Debug, Default)] | |
| 23 | + | pub enum LyricsStatus { | |
| 24 | + | #[default] | |
| 25 | + | Idle, | |
| 26 | + | Loading, | |
| 27 | + | Found(Lyrics), | |
| 28 | + | NotFound, | |
| 29 | + | Failed(String), | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | #[derive(Debug, Default)] | |
| 33 | + | pub struct LyricsState { | |
| 34 | + | /// The song the status belongs to. | |
| 35 | + | pub file: Option<String>, | |
| 36 | + | pub status: LyricsStatus, | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | impl LyricsState { | |
| 40 | + | pub fn lyrics(&self) -> Option<&Lyrics> { | |
| 41 | + | match &self.status { | |
| 42 | + | LyricsStatus::Found(lyrics) => Some(lyrics), | |
| 43 | + | _ => None, | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The result of a lookup, sent back as an [`AppEvent::Lyrics`]. | |
| 49 | + | pub type LookupResult = Result<Option<Lyrics>, String>; | |
| 50 | + | ||
| 51 | + | pub struct LyricsService { | |
| 52 | + | config: LyricsConfig, | |
| 53 | + | http: reqwest::Client, | |
| 54 | + | events: mpsc::UnboundedSender<AppEvent>, | |
| 55 | + | task: Option<JoinHandle<()>>, | |
| 56 | + | /// Results of this session, including misses, to avoid repeated lookups. | |
| 57 | + | memory: HashMap<String, Option<Lyrics>>, | |
| 58 | + | pub state: LyricsState, | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | impl LyricsService { | |
| 62 | + | pub fn new(config: LyricsConfig, events: mpsc::UnboundedSender<AppEvent>) -> Self { | |
| 63 | + | let http = reqwest::Client::builder() | |
| 64 | + | .user_agent(concat!( | |
| 65 | + | "rmptui/", | |
| 66 | + | env!("CARGO_PKG_VERSION"), | |
| 67 | + | " (https://github.com/krolyxon/rmptui)" | |
| 68 | + | )) | |
| 69 | + | .timeout(Duration::from_secs(10)) | |
| 70 | + | .build() | |
| 71 | + | .unwrap_or_default(); | |
| 72 | + | Self { | |
| 73 | + | config, | |
| 74 | + | http, | |
| 75 | + | events, | |
| 76 | + | task: None, | |
| 77 | + | memory: HashMap::new(), | |
| 78 | + | state: LyricsState::default(), | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Makes sure lyrics for `song` are loaded or being loaded. With `force`, | |
| 83 | + | /// skips the session memory and the cache. | |
| 84 | + | pub fn ensure(&mut self, song: Option<&Song>, music_dir: Option<&Path>, force: bool) { | |
| 85 | + | let Some(song) = song else { | |
| 86 | + | self.cancel(); | |
| 87 | + | self.state = LyricsState::default(); | |
| 88 | + | return; | |
| 89 | + | }; | |
| 90 | + | if !force && self.state.file.as_deref() == Some(song.file.as_str()) { | |
| 91 | + | return; | |
| 92 | + | } | |
| 93 | + | self.cancel(); | |
| 94 | + | self.state.file = Some(song.file.clone()); | |
| 95 | + | if !force && let Some(known) = self.memory.get(&song.file) { | |
| 96 | + | self.state.status = known | |
| 97 | + | .clone() | |
| 98 | + | .map_or(LyricsStatus::NotFound, LyricsStatus::Found); | |
| 99 | + | return; | |
| 100 | + | } | |
| 101 | + | self.state.status = LyricsStatus::Loading; | |
| 102 | + | let request = Lookup { | |
| 103 | + | file: song.file.clone(), | |
| 104 | + | artist: song.artist().map(str::to_owned), | |
| 105 | + | title: lookup_title(song), | |
| 106 | + | album: song.album().map(str::to_owned), | |
| 107 | + | duration: song.duration, | |
| 108 | + | local: music_dir.map(|dir| dir.join(&song.file).with_extension("lrc")), | |
| 109 | + | use_cache: self.config.cache && !force, | |
| 110 | + | write_cache: self.config.cache, | |
| 111 | + | online: self.config.online, | |
| 112 | + | base_url: self.config.lrclib_url.trim_end_matches('/').to_owned(), | |
| 113 | + | }; | |
| 114 | + | let http = self.http.clone(); | |
| 115 | + | let events = self.events.clone(); | |
| 116 | + | self.task = Some(tokio::spawn(async move { | |
| 117 | + | let file = request.file.clone(); | |
| 118 | + | let result = request.run(&http).await; | |
| 119 | + | let _ = events.send(AppEvent::Lyrics { file, result }); | |
| 120 | + | })); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// Applies a finished lookup. Returns whether it was for the current song. | |
| 124 | + | pub fn finish(&mut self, file: String, result: LookupResult) -> bool { | |
| 125 | + | match &result { | |
| 126 | + | Ok(found) => { | |
| 127 | + | self.memory.insert(file.clone(), found.clone()); | |
| 128 | + | } | |
| 129 | + | Err(error) => warn!("lyrics lookup for {file} failed: {error}"), | |
| 130 | + | } | |
| 131 | + | if self.state.file.as_deref() != Some(file.as_str()) { | |
| 132 | + | return false; | |
| 133 | + | } | |
| 134 | + | self.task = None; | |
| 135 | + | self.state.status = match result { | |
| 136 | + | Ok(Some(lyrics)) => LyricsStatus::Found(lyrics), | |
| 137 | + | Ok(None) => LyricsStatus::NotFound, | |
| 138 | + | Err(error) => LyricsStatus::Failed(error), | |
| 139 | + | }; | |
| 140 | + | true | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | fn cancel(&mut self) { | |
| 144 | + | if let Some(task) = self.task.take() { | |
| 145 | + | task.abort(); | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | struct Lookup { | |
| 151 | + | file: String, | |
| 152 | + | artist: Option<String>, | |
| 153 | + | title: Option<String>, | |
| 154 | + | album: Option<String>, | |
| 155 | + | duration: Option<Duration>, | |
| 156 | + | local: Option<PathBuf>, | |
| 157 | + | use_cache: bool, | |
| 158 | + | write_cache: bool, | |
| 159 | + | online: bool, | |
| 160 | + | base_url: String, | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | impl Lookup { | |
| 164 | + | async fn run(self, http: &reqwest::Client) -> LookupResult { | |
| 165 | + | if let Some(path) = &self.local | |
| 166 | + | && let Some(lyrics) = read_lyrics(path).await | |
| 167 | + | { | |
| 168 | + | debug!("lyrics for {} from {}", self.file, path.display()); | |
| 169 | + | return Ok(Some(lyrics)); | |
| 170 | + | } | |
| 171 | + | let (Some(artist), Some(title)) = (&self.artist, &self.title) else { | |
| 172 | + | return Ok(None); | |
| 173 | + | }; | |
| 174 | + | let cache_path = cache_path(artist, title); | |
| 175 | + | if self.use_cache | |
| 176 | + | && let Some(lyrics) = read_lyrics(&cache_path).await | |
| 177 | + | { | |
| 178 | + | return Ok(Some(lyrics)); | |
| 179 | + | } | |
| 180 | + | if !self.online { | |
| 181 | + | return Ok(None); | |
| 182 | + | } | |
| 183 | + | let Some(text) = self.fetch(http, artist, title).await? else { | |
| 184 | + | return Ok(None); | |
| 185 | + | }; | |
| 186 | + | if self.write_cache { | |
| 187 | + | if let Some(parent) = cache_path.parent() { | |
| 188 | + | let _ = tokio::fs::create_dir_all(parent).await; | |
| 189 | + | } | |
| 190 | + | if let Err(error) = tokio::fs::write(&cache_path, &text).await { | |
| 191 | + | warn!( | |
| 192 | + | "cannot write lyrics cache {}: {error}", | |
| 193 | + | cache_path.display() | |
| 194 | + | ); | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | let lyrics = Lyrics::parse(&text); | |
| 198 | + | Ok((!lyrics.is_empty()).then_some(lyrics)) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Asks LRCLIB for an exact match first, then falls back to a search. | |
| 202 | + | async fn fetch( | |
| 203 | + | &self, | |
| 204 | + | http: &reqwest::Client, | |
| 205 | + | artist: &str, | |
| 206 | + | title: &str, | |
| 207 | + | ) -> Result<Option<String>, String> { | |
| 208 | + | let mut query = vec![ | |
| 209 | + | ("artist_name", artist.to_owned()), | |
| 210 | + | ("track_name", title.to_owned()), | |
| 211 | + | ]; | |
| 212 | + | if let Some(album) = &self.album { | |
| 213 | + | query.push(("album_name", album.clone())); | |
| 214 | + | } | |
| 215 | + | if let Some(duration) = self.duration { | |
| 216 | + | query.push(("duration", duration.as_secs().to_string())); | |
| 217 | + | } | |
| 218 | + | let response = http | |
| 219 | + | .get(format!("{}/api/get", self.base_url)) | |
| 220 | + | .query(&query) | |
| 221 | + | .send() | |
| 222 | + | .await | |
| 223 | + | .map_err(|e| e.to_string())?; | |
| 224 | + | if response.status().is_success() { | |
| 225 | + | let record: Record = response.json().await.map_err(|e| e.to_string())?; | |
| 226 | + | if let Some(text) = record.text() { | |
| 227 | + | return Ok(Some(text)); | |
| 228 | + | } | |
| 229 | + | } else if response.status() != reqwest::StatusCode::NOT_FOUND { | |
| 230 | + | return Err(format!("LRCLIB answered {}", response.status())); | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | let records: Vec<Record> = http | |
| 234 | + | .get(format!("{}/api/search", self.base_url)) | |
| 235 | + | .query(&[("artist_name", artist), ("track_name", title)]) | |
| 236 | + | .send() | |
| 237 | + | .await | |
| 238 | + | .and_then(reqwest::Response::error_for_status) | |
| 239 | + | .map_err(|e| e.to_string())? | |
| 240 | + | .json() | |
| 241 | + | .await | |
| 242 | + | .map_err(|e| e.to_string())?; | |
| 243 | + | // Prefer synced lyrics, then plain ones. | |
| 244 | + | let synced = records | |
| 245 | + | .iter() | |
| 246 | + | .find_map(|r| r.synced_lyrics.clone().filter(|s| !s.trim().is_empty())); | |
| 247 | + | Ok(synced.or_else(|| records.iter().find_map(Record::text))) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | #[derive(Debug, Deserialize)] | |
| 252 | + | #[serde(rename_all = "camelCase")] | |
| 253 | + | struct Record { | |
| 254 | + | #[serde(default)] | |
| 255 | + | instrumental: bool, | |
| 256 | + | plain_lyrics: Option<String>, | |
| 257 | + | synced_lyrics: Option<String>, | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | impl Record { | |
| 261 | + | fn text(&self) -> Option<String> { | |
| 262 | + | if self.instrumental { | |
| 263 | + | return Some("♪ Instrumental ♪".to_owned()); | |
| 264 | + | } | |
| 265 | + | self.synced_lyrics | |
| 266 | + | .clone() | |
| 267 | + | .filter(|s| !s.trim().is_empty()) | |
| 268 | + | .or_else(|| self.plain_lyrics.clone().filter(|s| !s.trim().is_empty())) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | /// The title to look lyrics up by: the Title tag, or a title derived from | |
| 273 | + | /// the file name (`05. Artist - Title (Explicit).mp3` → `Title`), without | |
| 274 | + | /// markers that lyric databases do not include. | |
| 275 | + | fn lookup_title(song: &Song) -> Option<String> { | |
| 276 | + | let mut title = song | |
| 277 | + | .title() | |
| 278 | + | .map_or_else(|| title_from_file_name(song), str::to_owned); | |
| 279 | + | for marker in [ | |
| 280 | + | "(Explicit)", | |
| 281 | + | "[Explicit]", | |
| 282 | + | "(explicit)", | |
| 283 | + | "[explicit]", | |
| 284 | + | "(Clean)", | |
| 285 | + | "[Clean]", | |
| 286 | + | ] { | |
| 287 | + | title = title.replace(marker, ""); | |
| 288 | + | } | |
| 289 | + | let title = title.trim().to_owned(); | |
| 290 | + | // A bare track number is not a title. | |
| 291 | + | (!title.chars().all(|c| c.is_ascii_digit())).then_some(title) | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | /// Strips a leading track number (`05. `, `05 - `, `05_`) and `Artist - `. | |
| 295 | + | fn title_from_file_name(song: &Song) -> String { | |
| 296 | + | let stem = song.display_title(); | |
| 297 | + | let without_artist = |s: &str| -> Option<String> { | |
| 298 | + | let rest = s.strip_prefix(song.artist()?)?; | |
| 299 | + | Some(rest.trim_start_matches([' ', '-']).to_owned()) | |
| 300 | + | }; | |
| 301 | + | let digits = stem.len() - stem.trim_start_matches(|c: char| c.is_ascii_digit()).len(); | |
| 302 | + | let without_track = (digits > 0 && stem[digits..].starts_with(['.', '-', '_', ' '])) | |
| 303 | + | .then(|| stem[digits..].trim_start_matches(['.', '-', '_', ' '])); | |
| 304 | + | without_artist(stem) | |
| 305 | + | .or_else(|| without_track.and_then(without_artist)) | |
| 306 | + | .or_else(|| without_track.map(str::to_owned)) | |
| 307 | + | .unwrap_or_else(|| stem.to_owned()) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | async fn read_lyrics(path: &Path) -> Option<Lyrics> { | |
| 311 | + | let text = tokio::fs::read_to_string(path).await.ok()?; | |
| 312 | + | let lyrics = Lyrics::parse(&text); | |
| 313 | + | (!lyrics.is_empty()).then_some(lyrics) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | fn cache_path(artist: &str, title: &str) -> PathBuf { | |
| 317 | + | let name: String = format!("{artist} - {title}") | |
| 318 | + | .chars() | |
| 319 | + | .map(|c| { | |
| 320 | + | if matches!(c, '/' | '\\' | '\0') || c.is_control() { | |
| 321 | + | '_' | |
| 322 | + | } else { | |
| 323 | + | c | |
| 324 | + | } | |
| 325 | + | }) | |
| 326 | + | .take(200) | |
| 327 | + | .collect(); | |
| 328 | + | util::cache_dir().join("lyrics").join(format!("{name}.lrc")) | |
| 329 | + | } | |
| 330 | + | ||
| 331 | + | #[cfg(test)] | |
| 332 | + | mod tests { | |
| 333 | + | use super::*; | |
| 334 | + | ||
| 335 | + | #[test] | |
| 336 | + | fn derives_lookup_titles() { | |
| 337 | + | let song = |file: &str, tags: &[(&str, &str)]| Song { | |
| 338 | + | file: file.into(), | |
| 339 | + | tags: tags | |
| 340 | + | .iter() | |
| 341 | + | .map(|(k, v)| ((*k).into(), (*v).into())) | |
| 342 | + | .collect(), | |
| 343 | + | ..Song::default() | |
| 344 | + | }; | |
| 345 | + | let untagged = song( | |
| 346 | + | "x/05. 50 Cent - Candy Shop (Explicit).mp3", | |
| 347 | + | &[("Artist", "50 Cent")], | |
| 348 | + | ); | |
| 349 | + | assert_eq!(lookup_title(&untagged).as_deref(), Some("Candy Shop")); | |
| 350 | + | let tagged = song("x/a.flac", &[("Title", "Song [Explicit]")]); | |
| 351 | + | assert_eq!(lookup_title(&tagged).as_deref(), Some("Song")); | |
| 352 | + | assert_eq!(lookup_title(&song("x/01.mp3", &[])), None); | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | #[test] | |
| 356 | + | fn sanitizes_cache_names() { | |
| 357 | + | let path = cache_path("AC/DC", "T.N.T."); | |
| 358 | + | assert_eq!(path.file_name().unwrap(), "AC_DC - T.N.T..lrc"); | |
| 359 | + | } | |
| 360 | + | ||
| 361 | + | #[test] | |
| 362 | + | fn prefers_synced_record_text() { | |
| 363 | + | let record: Record = serde_json::from_str( | |
| 364 | + | r#"{"instrumental":false,"plainLyrics":"plain","syncedLyrics":"[00:01.00]synced"}"#, | |
| 365 | + | ) | |
| 366 | + | .unwrap(); | |
| 367 | + | assert_eq!(record.text().as_deref(), Some("[00:01.00]synced")); | |
| 368 | + | let record: Record = | |
| 369 | + | serde_json::from_str(r#"{"instrumental":true,"plainLyrics":null,"syncedLyrics":null}"#) | |
| 370 | + | .unwrap(); | |
| 371 | + | assert!(record.text().unwrap().contains("Instrumental")); | |
| 372 | + | } | |
| 373 | + | } | |
| @@ -1 +1,59 @@ | |||
| 1 | − | fn main() {} | |
| 1 | + | use std::{io, path::PathBuf, process::ExitCode}; | |
| 2 | + | ||
| 3 | + | use clap::Parser; | |
| 4 | + | use crossterm::{ | |
| 5 | + | event::{DisableMouseCapture, EnableMouseCapture}, | |
| 6 | + | execute, | |
| 7 | + | }; | |
| 8 | + | use rmptui::{app::App, config::Config, logging}; | |
| 9 | + | ||
| 10 | + | /// A fast, keyboard-driven MPD client for the terminal. | |
| 11 | + | #[derive(Debug, Parser)] | |
| 12 | + | #[command(version, about)] | |
| 13 | + | struct Args { | |
| 14 | + | /// Use this configuration file instead of the default location. | |
| 15 | + | #[arg(short, long, value_name = "FILE")] | |
| 16 | + | config: Option<PathBuf>, | |
| 17 | + | /// Print the default configuration and exit. | |
| 18 | + | #[arg(long)] | |
| 19 | + | print_default_config: bool, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | #[tokio::main] | |
| 23 | + | async fn main() -> ExitCode { | |
| 24 | + | let args = Args::parse(); | |
| 25 | + | if args.print_default_config { | |
| 26 | + | print!("{}", rmptui::config::DEFAULT_CONFIG); | |
| 27 | + | return ExitCode::SUCCESS; | |
| 28 | + | } | |
| 29 | + | if let Err(error) = logging::init(logging::LOG_PATH.as_ref()) { | |
| 30 | + | eprintln!("rmptui: cannot log to {}: {error}", logging::LOG_PATH); | |
| 31 | + | } | |
| 32 | + | match run(args).await { | |
| 33 | + | Ok(()) => ExitCode::SUCCESS, | |
| 34 | + | Err(error) => { | |
| 35 | + | tracing::error!("exiting: {error:#}"); | |
| 36 | + | eprintln!("rmptui: {error:#}"); | |
| 37 | + | ExitCode::FAILURE | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | async fn run(args: Args) -> anyhow::Result<()> { | |
| 43 | + | let (config, path) = Config::load(args.config.as_deref())?; | |
| 44 | + | tracing::info!("using configuration {}", path.display()); | |
| 45 | + | let mouse = config.ui.mouse; | |
| 46 | + | let app = App::new(config)?; | |
| 47 | + | ||
| 48 | + | // `init` installs a panic hook that restores the terminal. | |
| 49 | + | let mut terminal = ratatui::init(); | |
| 50 | + | if mouse { | |
| 51 | + | execute!(io::stdout(), EnableMouseCapture)?; | |
| 52 | + | } | |
| 53 | + | let result = app.run(&mut terminal).await; | |
| 54 | + | if mouse { | |
| 55 | + | let _ = execute!(io::stdout(), DisableMouseCapture); | |
| 56 | + | } | |
| 57 | + | ratatui::restore(); | |
| 58 | + | result | |
| 59 | + | } | |
| @@ -94,13 +94,19 @@ pub fn add_id(uri: &str) -> Command { | |||
| 94 | 94 | Command::new("addid").arg(uri) | |
| 95 | 95 | } | |
| 96 | 96 | ||
| 97 | + | pub fn delete_pos(pos: u32) -> Command { | |
| 98 | + | Command::new("delete").arg(pos.to_string()) | |
| 99 | + | } | |
| 100 | + | ||
| 97 | 101 | pub fn delete_id(id: u32) -> Command { | |
| 98 | 102 | Command::new("deleteid").arg(id.to_string()) | |
| 99 | 103 | } | |
| 100 | 104 | ||
| 101 | − | pub fn move_id(id: u32, to: u32) -> Command { | |
| 102 | − | Command::new("moveid") | |
| 103 | − | .arg(id.to_string()) | |
| 105 | + | /// Moves the song at queue position `from` to `to`. Positions (unlike ids) | |
| 106 | + | /// stay correct when several moves are sent before the queue is refreshed. | |
| 107 | + | pub fn move_pos(from: u32, to: u32) -> Command { | |
| 108 | + | Command::new("move") | |
| 109 | + | .arg(from.to_string()) | |
| 104 | 110 | .arg(to.to_string()) | |
| 105 | 111 | } | |
| 106 | 112 | ||
| @@ -4,11 +4,13 @@ pub mod commands; | |||
| 4 | 4 | mod connection; | |
| 5 | 5 | mod error; | |
| 6 | 6 | pub mod protocol; | |
| 7 | + | mod selection; | |
| 7 | 8 | mod types; | |
| 8 | 9 | mod worker; | |
| 9 | 10 | ||
| 10 | 11 | pub use connection::{Address, Connection}; | |
| 11 | 12 | pub use error::{Ack, MpdError, MpdResult}; | |
| 12 | 13 | pub use protocol::Command; | |
| 14 | + | pub use selection::Selection; | |
| 13 | 15 | pub use types::{Album, DirEntry, OnOff, PlayState, PlaylistInfo, Song, Status, Subsystem}; | |
| 14 | 16 | pub use worker::{MpdEvent, MpdHandle, Query, Reply, Request, spawn}; | |
| @@ -0,0 +1,36 @@ | |||
| 1 | + | use super::{Command, commands}; | |
| 2 | + | ||
| 3 | + | /// A set of songs chosen in the UI, to be added to the queue or a playlist. | |
| 4 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 5 | + | pub enum Selection { | |
| 6 | + | /// Songs or directories (added recursively). | |
| 7 | + | Uris(Vec<String>), | |
| 8 | + | /// Every song matching a filter expression. | |
| 9 | + | Filter(String), | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | impl Selection { | |
| 13 | + | pub fn is_empty(&self) -> bool { | |
| 14 | + | match self { | |
| 15 | + | Self::Uris(uris) => uris.is_empty(), | |
| 16 | + | Self::Filter(_) => false, | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | pub fn add_to_queue(&self) -> Vec<Command> { | |
| 21 | + | match self { | |
| 22 | + | Self::Uris(uris) => uris.iter().map(|uri| commands::add(uri)).collect(), | |
| 23 | + | Self::Filter(filter) => vec![commands::find_add(filter)], | |
| 24 | + | } | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | pub fn add_to_playlist(&self, playlist: &str) -> Vec<Command> { | |
| 28 | + | match self { | |
| 29 | + | Self::Uris(uris) => uris | |
| 30 | + | .iter() | |
| 31 | + | .map(|uri| commands::playlist_add(playlist, uri)) | |
| 32 | + | .collect(), | |
| 33 | + | Self::Filter(filter) => vec![commands::playlist_add_filter(playlist, filter)], | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| @@ -0,0 +1,115 @@ | |||
| 1 | + | use ratatui::{ | |
| 2 | + | Frame, | |
| 3 | + | layout::{Constraint, Rect}, | |
| 4 | + | text::Span, | |
| 5 | + | widgets::{Block, Row}, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | use super::{InputPopup, InputPurpose, Popup, PopupState, frame_popup}; | |
| 9 | + | use crate::{ | |
| 10 | + | action::{Action, Context}, | |
| 11 | + | config::KeyCombo, | |
| 12 | + | mpd::Selection, | |
| 13 | + | screens::Ctx, | |
| 14 | + | ui::list::ListView, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | enum Choice { | |
| 18 | + | Queue, | |
| 19 | + | NewPlaylist, | |
| 20 | + | Playlist(String), | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Chooses where to add a selection: the queue, a new or an existing playlist. | |
| 24 | + | pub struct AddToPlaylistPopup { | |
| 25 | + | selection: Selection, | |
| 26 | + | choices: Vec<Choice>, | |
| 27 | + | list: ListView, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl AddToPlaylistPopup { | |
| 31 | + | pub fn new(selection: Selection, ctx: &Ctx) -> Self { | |
| 32 | + | let mut choices = vec![Choice::Queue, Choice::NewPlaylist]; | |
| 33 | + | choices.extend( | |
| 34 | + | ctx.mpd | |
| 35 | + | .playlists | |
| 36 | + | .iter() | |
| 37 | + | .map(|p| Choice::Playlist(p.name.clone())), | |
| 38 | + | ); | |
| 39 | + | Self { | |
| 40 | + | selection, | |
| 41 | + | choices, | |
| 42 | + | list: ListView::default(), | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | fn choose(&mut self, ctx: &mut Ctx) -> PopupState { | |
| 47 | + | let Some(choice) = self | |
| 48 | + | .list | |
| 49 | + | .selected(self.choices.len()) | |
| 50 | + | .and_then(|i| self.choices.get(i)) | |
| 51 | + | else { | |
| 52 | + | return PopupState::Open; | |
| 53 | + | }; | |
| 54 | + | match choice { | |
| 55 | + | Choice::Queue => { | |
| 56 | + | ctx.client.exec_all(self.selection.add_to_queue()); | |
| 57 | + | ctx.notify("Added to the queue"); | |
| 58 | + | } | |
| 59 | + | Choice::NewPlaylist => { | |
| 60 | + | ctx.popup(InputPopup::new( | |
| 61 | + | "New playlist", | |
| 62 | + | "", | |
| 63 | + | InputPurpose::NewPlaylist(self.selection.clone()), | |
| 64 | + | )); | |
| 65 | + | } | |
| 66 | + | Choice::Playlist(name) => { | |
| 67 | + | ctx.client.exec_all(self.selection.add_to_playlist(name)); | |
| 68 | + | ctx.notify(format!("Added to playlist “{name}”")); | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | PopupState::Closed | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | impl Popup for AddToPlaylistPopup { | |
| 76 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 77 | + | let height = self.choices.len().min(15) as u16 + 2; | |
| 78 | + | let inner = frame_popup(frame, area, 44, height, "Add to", ctx); | |
| 79 | + | let theme = ctx.theme(); | |
| 80 | + | let choices = &self.choices; | |
| 81 | + | self.list.render( | |
| 82 | + | frame, | |
| 83 | + | inner, | |
| 84 | + | Block::new(), | |
| 85 | + | None, | |
| 86 | + | &[Constraint::Fill(1)], | |
| 87 | + | choices.len(), | |
| 88 | + | true, | |
| 89 | + | theme, | |
| 90 | + | |i| { | |
| 91 | + | Row::new([match &choices[i] { | |
| 92 | + | Choice::Queue => Span::styled("Current queue", theme.accent()), | |
| 93 | + | Choice::NewPlaylist => Span::styled("New playlist…", theme.accent()), | |
| 94 | + | Choice::Playlist(name) => Span::styled(name.clone(), theme.text()), | |
| 95 | + | }]) | |
| 96 | + | }, | |
| 97 | + | ); | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 101 | + | match ctx | |
| 102 | + | .config | |
| 103 | + | .keymap | |
| 104 | + | .resolve(&[Context::Popup, Context::Navigation], key) | |
| 105 | + | { | |
| 106 | + | Some(Action::Close) => PopupState::Closed, | |
| 107 | + | Some(Action::Activate | Action::Right) => self.choose(ctx), | |
| 108 | + | Some(action) => { | |
| 109 | + | self.list.navigate(action, self.choices.len()); | |
| 110 | + | PopupState::Open | |
| 111 | + | } | |
| 112 | + | None => PopupState::Open, | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | } | |
| @@ -0,0 +1,69 @@ | |||
| 1 | + | use crossterm::event::KeyCode; | |
| 2 | + | use ratatui::{ | |
| 3 | + | Frame, | |
| 4 | + | layout::{Alignment, Rect}, | |
| 5 | + | text::{Line, Span}, | |
| 6 | + | widgets::{Paragraph, Wrap}, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use super::{Popup, PopupState, frame_popup}; | |
| 10 | + | use crate::{ | |
| 11 | + | action::{Action, Context}, | |
| 12 | + | config::KeyCombo, | |
| 13 | + | mpd::Command, | |
| 14 | + | screens::Ctx, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | /// Asks before running destructive commands. | |
| 18 | + | pub struct ConfirmPopup { | |
| 19 | + | message: String, | |
| 20 | + | commands: Vec<Command>, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | impl ConfirmPopup { | |
| 24 | + | pub fn new(message: impl Into<String>, commands: Vec<Command>) -> Self { | |
| 25 | + | Self { | |
| 26 | + | message: message.into(), | |
| 27 | + | commands, | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | impl Popup for ConfirmPopup { | |
| 33 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 34 | + | let inner = frame_popup(frame, area, 50, 5, "Confirm", ctx); | |
| 35 | + | let theme = ctx.theme(); | |
| 36 | + | let text = vec![ | |
| 37 | + | Line::from(self.message.as_str()), | |
| 38 | + | Line::default(), | |
| 39 | + | Line::from(vec![ | |
| 40 | + | Span::styled("[y]", theme.accent()), | |
| 41 | + | Span::raw("es "), | |
| 42 | + | Span::styled("[n]", theme.accent()), | |
| 43 | + | Span::raw("o"), | |
| 44 | + | ]), | |
| 45 | + | ]; | |
| 46 | + | frame.render_widget( | |
| 47 | + | Paragraph::new(text) | |
| 48 | + | .alignment(Alignment::Center) | |
| 49 | + | .wrap(Wrap { trim: true }), | |
| 50 | + | inner, | |
| 51 | + | ); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 55 | + | let confirmed = matches!(key.code, KeyCode::Char('y' | 'Y')) | |
| 56 | + | || ctx.config.keymap.resolve(&[Context::Navigation], key) == Some(Action::Activate); | |
| 57 | + | if confirmed { | |
| 58 | + | ctx.client.exec_all(std::mem::take(&mut self.commands)); | |
| 59 | + | return PopupState::Closed; | |
| 60 | + | } | |
| 61 | + | let declined = matches!(key.code, KeyCode::Char('n' | 'N')) | |
| 62 | + | || ctx.config.keymap.resolve(&[Context::Popup], key) == Some(Action::Close); | |
| 63 | + | if declined { | |
| 64 | + | PopupState::Closed | |
| 65 | + | } else { | |
| 66 | + | PopupState::Open | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | } | |
| @@ -0,0 +1,134 @@ | |||
| 1 | + | use ratatui::{ | |
| 2 | + | Frame, | |
| 3 | + | layout::{Constraint, Rect}, | |
| 4 | + | text::Span, | |
| 5 | + | widgets::{Block, Row}, | |
| 6 | + | }; | |
| 7 | + | ||
| 8 | + | use super::{Popup, PopupState, frame_popup}; | |
| 9 | + | use crate::{ | |
| 10 | + | action::{Action, Context}, | |
| 11 | + | config::KeyCombo, | |
| 12 | + | screens::Ctx, | |
| 13 | + | ui::list::ListView, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | enum HelpRow { | |
| 17 | + | Heading(String), | |
| 18 | + | Binding { | |
| 19 | + | keys: String, | |
| 20 | + | description: &'static str, | |
| 21 | + | }, | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | /// Lists the key bindings, starting with the active screen's. | |
| 25 | + | pub struct HelpPopup { | |
| 26 | + | rows: Vec<HelpRow>, | |
| 27 | + | list: ListView, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | impl HelpPopup { | |
| 31 | + | pub fn new(screen: Context, ctx: &Ctx) -> Self { | |
| 32 | + | let mut order = vec![screen, Context::Navigation, Context::Global]; | |
| 33 | + | let rest: Vec<Context> = Context::ALL | |
| 34 | + | .iter() | |
| 35 | + | .copied() | |
| 36 | + | .filter(|c| !order.contains(c) && !is_screen_context(*c)) | |
| 37 | + | .collect(); | |
| 38 | + | order.extend(rest); | |
| 39 | + | let mut rows = Vec::new(); | |
| 40 | + | for context in order { | |
| 41 | + | let Some(actions) = ctx.config.keymap.bindings().get(&context) else { | |
| 42 | + | continue; | |
| 43 | + | }; | |
| 44 | + | let bound: Vec<_> = actions | |
| 45 | + | .iter() | |
| 46 | + | .filter(|(_, keys)| !keys.is_empty()) | |
| 47 | + | .collect(); | |
| 48 | + | if bound.is_empty() { | |
| 49 | + | continue; | |
| 50 | + | } | |
| 51 | + | if !rows.is_empty() { | |
| 52 | + | rows.push(HelpRow::Heading(String::new())); | |
| 53 | + | } | |
| 54 | + | rows.push(HelpRow::Heading(heading(context))); | |
| 55 | + | for (action, keys) in bound { | |
| 56 | + | rows.push(HelpRow::Binding { | |
| 57 | + | keys: keys | |
| 58 | + | .iter() | |
| 59 | + | .map(ToString::to_string) | |
| 60 | + | .collect::<Vec<_>>() | |
| 61 | + | .join(", "), | |
| 62 | + | description: action.description(), | |
| 63 | + | }); | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | Self { | |
| 67 | + | rows, | |
| 68 | + | list: ListView::default(), | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fn is_screen_context(context: Context) -> bool { | |
| 74 | + | !matches!( | |
| 75 | + | context, | |
| 76 | + | Context::Global | Context::Navigation | Context::Popup | Context::Input | |
| 77 | + | ) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | fn heading(context: Context) -> String { | |
| 81 | + | let name = context.name().replace('_', " "); | |
| 82 | + | let mut chars = name.chars(); | |
| 83 | + | chars | |
| 84 | + | .next() | |
| 85 | + | .map(|c| c.to_uppercase().chain(chars).collect()) | |
| 86 | + | .unwrap_or_default() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | impl Popup for HelpPopup { | |
| 90 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 91 | + | let inner = frame_popup( | |
| 92 | + | frame, | |
| 93 | + | area, | |
| 94 | + | 70, | |
| 95 | + | area.height.saturating_sub(4), | |
| 96 | + | "Key bindings", | |
| 97 | + | ctx, | |
| 98 | + | ); | |
| 99 | + | let theme = ctx.theme(); | |
| 100 | + | let rows = &self.rows; | |
| 101 | + | self.list.render( | |
| 102 | + | frame, | |
| 103 | + | inner, | |
| 104 | + | Block::new(), | |
| 105 | + | None, | |
| 106 | + | &[Constraint::Length(24), Constraint::Fill(1)], | |
| 107 | + | rows.len(), | |
| 108 | + | true, | |
| 109 | + | theme, | |
| 110 | + | |i| match &rows[i] { | |
| 111 | + | HelpRow::Heading(title) => Row::new([Span::styled(title.clone(), theme.accent())]), | |
| 112 | + | HelpRow::Binding { keys, description } => Row::new([ | |
| 113 | + | Span::styled(format!(" {keys}"), theme.text()), | |
| 114 | + | Span::styled(*description, theme.dim()), | |
| 115 | + | ]), | |
| 116 | + | }, | |
| 117 | + | ); | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 121 | + | let keymap = &ctx.config.keymap; | |
| 122 | + | match keymap.resolve(&[Context::Popup, Context::Navigation], key) { | |
| 123 | + | Some(Action::Close) => PopupState::Closed, | |
| 124 | + | Some(action) => { | |
| 125 | + | self.list.navigate(action, self.rows.len()); | |
| 126 | + | PopupState::Open | |
| 127 | + | } | |
| 128 | + | None if keymap.resolve(&[Context::Global], key) == Some(Action::Help) => { | |
| 129 | + | PopupState::Closed | |
| 130 | + | } | |
| 131 | + | None => PopupState::Open, | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | } | |
| @@ -0,0 +1,94 @@ | |||
| 1 | + | use ratatui::{Frame, layout::Rect}; | |
| 2 | + | ||
| 3 | + | use super::{Popup, PopupState, frame_popup}; | |
| 4 | + | use crate::{ | |
| 5 | + | action::{Context, ScreenId}, | |
| 6 | + | config::KeyCombo, | |
| 7 | + | mpd::{Selection, commands}, | |
| 8 | + | screens::{Ctx, Effect, ScreenMessage}, | |
| 9 | + | tags::TagField, | |
| 10 | + | ui::input::{InputEvent, LineInput}, | |
| 11 | + | }; | |
| 12 | + | ||
| 13 | + | /// What the entered text is for. | |
| 14 | + | #[derive(Clone, Debug)] | |
| 15 | + | pub enum InputPurpose { | |
| 16 | + | NewPlaylist(Selection), | |
| 17 | + | RenamePlaylist(String), | |
| 18 | + | SaveQueue, | |
| 19 | + | TagValue(TagField), | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | pub struct InputPopup { | |
| 23 | + | title: String, | |
| 24 | + | input: LineInput, | |
| 25 | + | purpose: InputPurpose, | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | impl InputPopup { | |
| 29 | + | pub fn new(title: impl Into<String>, initial: &str, purpose: InputPurpose) -> Self { | |
| 30 | + | Self { | |
| 31 | + | title: title.into(), | |
| 32 | + | input: LineInput::new(initial), | |
| 33 | + | purpose, | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | fn submit(&self, ctx: &mut Ctx) -> PopupState { | |
| 38 | + | let value = self.input.text().trim().to_owned(); | |
| 39 | + | if value.is_empty() && !matches!(self.purpose, InputPurpose::TagValue(_)) { | |
| 40 | + | return PopupState::Open; | |
| 41 | + | } | |
| 42 | + | match &self.purpose { | |
| 43 | + | InputPurpose::NewPlaylist(selection) => { | |
| 44 | + | if ctx.mpd.playlists.iter().any(|p| p.name == value) { | |
| 45 | + | ctx.notify(format!("Added to existing playlist “{value}”")); | |
| 46 | + | } else { | |
| 47 | + | ctx.notify(format!("Created playlist “{value}”")); | |
| 48 | + | } | |
| 49 | + | ctx.client.exec_all(selection.add_to_playlist(&value)); | |
| 50 | + | } | |
| 51 | + | InputPurpose::RenamePlaylist(from) => { | |
| 52 | + | if *from != value { | |
| 53 | + | ctx.client.exec(commands::rename(from, &value)); | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | InputPurpose::SaveQueue => { | |
| 57 | + | ctx.client.exec(commands::save(&value)); | |
| 58 | + | ctx.notify(format!("Saved the queue as “{value}”")); | |
| 59 | + | } | |
| 60 | + | InputPurpose::TagValue(field) => ctx.effects.push(Effect::Message( | |
| 61 | + | ScreenId::TagEditor, | |
| 62 | + | ScreenMessage::TagValue { | |
| 63 | + | field: *field, | |
| 64 | + | value, | |
| 65 | + | }, | |
| 66 | + | )), | |
| 67 | + | } | |
| 68 | + | PopupState::Closed | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | impl Popup for InputPopup { | |
| 73 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 74 | + | let width = (area.width * 3 / 5).max(40); | |
| 75 | + | let inner = frame_popup(frame, area, width, 3, &self.title, ctx); | |
| 76 | + | self.input.render(frame, inner, "", ctx.theme()); | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState { | |
| 80 | + | match ctx.config.keymap.resolve(&[Context::Input], key) { | |
| 81 | + | Some(action) => match self.input.apply(action) { | |
| 82 | + | InputEvent::Submit => self.submit(ctx), | |
| 83 | + | InputEvent::Cancel => PopupState::Closed, | |
| 84 | + | _ => PopupState::Open, | |
| 85 | + | }, | |
| 86 | + | None => { | |
| 87 | + | if let Some(ch) = key.as_text() { | |
| 88 | + | self.input.insert(ch); | |
| 89 | + | } | |
| 90 | + | PopupState::Open | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | } | |
| @@ -0,0 +1,60 @@ | |||
| 1 | + | //! Modal popups drawn above the screens. The topmost popup receives all | |
| 2 | + | //! keys until it closes. | |
| 3 | + | ||
| 4 | + | mod add_to_playlist; | |
| 5 | + | mod confirm; | |
| 6 | + | mod help; | |
| 7 | + | mod input; | |
| 8 | + | ||
| 9 | + | use ratatui::{ | |
| 10 | + | Frame, | |
| 11 | + | layout::{Constraint, Flex, Layout, Rect}, | |
| 12 | + | text::Line, | |
| 13 | + | widgets::{Block, BorderType, Clear}, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | pub use add_to_playlist::AddToPlaylistPopup; | |
| 17 | + | pub use confirm::ConfirmPopup; | |
| 18 | + | pub use help::HelpPopup; | |
| 19 | + | pub use input::{InputPopup, InputPurpose}; | |
| 20 | + | ||
| 21 | + | use crate::{config::KeyCombo, screens::Ctx}; | |
| 22 | + | ||
| 23 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 24 | + | pub enum PopupState { | |
| 25 | + | Open, | |
| 26 | + | Closed, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | pub trait Popup { | |
| 30 | + | /// Draws the popup; `area` is the whole terminal. | |
| 31 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx); | |
| 32 | + | ||
| 33 | + | fn handle_key(&mut self, key: KeyCombo, ctx: &mut Ctx) -> PopupState; | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | /// Clears a centered rectangle and draws a titled frame around it, | |
| 37 | + | /// returning the inner area. | |
| 38 | + | fn frame_popup( | |
| 39 | + | frame: &mut Frame, | |
| 40 | + | area: Rect, | |
| 41 | + | width: u16, | |
| 42 | + | height: u16, | |
| 43 | + | title: &str, | |
| 44 | + | ctx: &Ctx, | |
| 45 | + | ) -> Rect { | |
| 46 | + | let [area] = Layout::horizontal([Constraint::Length(width.min(area.width))]) | |
| 47 | + | .flex(Flex::Center) | |
| 48 | + | .areas(area); | |
| 49 | + | let [area] = Layout::vertical([Constraint::Length(height.min(area.height))]) | |
| 50 | + | .flex(Flex::Center) | |
| 51 | + | .areas(area); | |
| 52 | + | let block = Block::bordered() | |
| 53 | + | .border_type(BorderType::Rounded) | |
| 54 | + | .border_style(ctx.theme().accent()) | |
| 55 | + | .title(Line::from(format!(" {title} ")).style(ctx.theme().accent())); | |
| 56 | + | let inner = block.inner(area); | |
| 57 | + | frame.render_widget(Clear, area); | |
| 58 | + | frame.render_widget(block, area); | |
| 59 | + | inner | |
| 60 | + | } | |
| @@ -0,0 +1,334 @@ | |||
| 1 | + | //! Screen 2: the directory browser. Stored playlists appear at the bottom of | |
| 2 | + | //! the root directory. | |
| 3 | + | ||
| 4 | + | use std::collections::{HashMap, HashSet}; | |
| 5 | + | ||
| 6 | + | use crossterm::event::MouseEvent; | |
| 7 | + | use ratatui::{ | |
| 8 | + | Frame, | |
| 9 | + | layout::{Constraint, Rect}, | |
| 10 | + | style::Modifier, | |
| 11 | + | text::{Line, Span}, | |
| 12 | + | widgets::{Cell, Row}, | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | use super::{Ctx, Screen}; | |
| 16 | + | use crate::{ | |
| 17 | + | action::Action, | |
| 18 | + | mpd::{DirEntry, MpdEvent, Query, Reply, Request, Selection, Subsystem, commands}, | |
| 19 | + | popups::AddToPlaylistPopup, | |
| 20 | + | ui::{ | |
| 21 | + | self, | |
| 22 | + | list::{Click, ListView}, | |
| 23 | + | }, | |
| 24 | + | util::format_duration, | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | #[derive(Debug, Default)] | |
| 28 | + | pub struct BrowserScreen { | |
| 29 | + | path: String, | |
| 30 | + | entries: Vec<DirEntry>, | |
| 31 | + | loaded: bool, | |
| 32 | + | /// Parent directories and the cursor position in each. | |
| 33 | + | stack: Vec<(String, usize)>, | |
| 34 | + | /// The cursor position to restore once the directory has loaded. | |
| 35 | + | restore: Option<usize>, | |
| 36 | + | list: ListView, | |
| 37 | + | marks: HashSet<usize>, | |
| 38 | + | cache: HashMap<String, Vec<DirEntry>>, | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | impl BrowserScreen { | |
| 42 | + | fn load(&mut self, ctx: &Ctx) { | |
| 43 | + | if let Some(entries) = self.cache.get(&self.path) { | |
| 44 | + | self.entries = entries.clone(); | |
| 45 | + | self.loaded = true; | |
| 46 | + | self.apply_restore(); | |
| 47 | + | } else { | |
| 48 | + | self.loaded = false; | |
| 49 | + | ctx.client.query(Query::Directory(self.path.clone())); | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | fn apply_restore(&mut self) { | |
| 54 | + | if let Some(index) = self.restore.take() { | |
| 55 | + | self.list.select(index, self.entries.len()); | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | fn open(&mut self, path: String, ctx: &Ctx) { | |
| 60 | + | let index = self.list.selected(self.entries.len()).unwrap_or(0); | |
| 61 | + | self.stack | |
| 62 | + | .push((std::mem::replace(&mut self.path, path), index)); | |
| 63 | + | self.list.reset(); | |
| 64 | + | self.marks.clear(); | |
| 65 | + | self.load(ctx); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | fn back(&mut self, ctx: &Ctx) { | |
| 69 | + | if let Some((parent, index)) = self.stack.pop() { | |
| 70 | + | self.path = parent; | |
| 71 | + | self.list.reset(); | |
| 72 | + | self.marks.clear(); | |
| 73 | + | self.restore = Some(index); | |
| 74 | + | self.load(ctx); | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | fn selected(&self) -> Option<&DirEntry> { | |
| 79 | + | self.list | |
| 80 | + | .selected(self.entries.len()) | |
| 81 | + | .map(|i| &self.entries[i]) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | /// Marked entries, or the selected one, excluding playlists. | |
| 85 | + | fn selection(&self) -> Vec<String> { | |
| 86 | + | let entries: Vec<&DirEntry> = if self.marks.is_empty() { | |
| 87 | + | self.selected().into_iter().collect() | |
| 88 | + | } else { | |
| 89 | + | let mut marks: Vec<_> = self.marks.iter().copied().collect(); | |
| 90 | + | marks.sort_unstable(); | |
| 91 | + | marks.iter().filter_map(|&i| self.entries.get(i)).collect() | |
| 92 | + | }; | |
| 93 | + | entries | |
| 94 | + | .into_iter() | |
| 95 | + | .filter(|e| !matches!(e, DirEntry::Playlist(_))) | |
| 96 | + | .map(|e| e.path().to_owned()) | |
| 97 | + | .collect() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | fn activate(&mut self, ctx: &mut Ctx) { | |
| 101 | + | let Some(entry) = self.selected().cloned() else { | |
| 102 | + | return; | |
| 103 | + | }; | |
| 104 | + | match entry { | |
| 105 | + | DirEntry::Directory(path) => self.open(path, ctx), | |
| 106 | + | DirEntry::Song(song) => match ctx.mpd.queued_id(&song.file) { | |
| 107 | + | Some(id) => ctx.client.exec(commands::play_id(id)), | |
| 108 | + | None => ctx.client.request(Request::AddAndPlay(vec![song.file])), | |
| 109 | + | }, | |
| 110 | + | DirEntry::Playlist(name) => { | |
| 111 | + | ctx.client.request(Request::LoadAndPlay { | |
| 112 | + | name: name.clone(), | |
| 113 | + | offset: 0, | |
| 114 | + | }); | |
| 115 | + | ctx.notify(format!("Loaded playlist “{name}”")); | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | fn add_to_queue(&mut self, ctx: &mut Ctx) { | |
| 121 | + | if !self.marks.is_empty() { | |
| 122 | + | let uris = self.selection(); | |
| 123 | + | let count = uris.len(); | |
| 124 | + | ctx.client.exec_all(Selection::Uris(uris).add_to_queue()); | |
| 125 | + | ctx.notify(format!("Added {count} item(s) to the queue")); | |
| 126 | + | self.marks.clear(); | |
| 127 | + | return; | |
| 128 | + | } | |
| 129 | + | let Some(entry) = self.selected().cloned() else { | |
| 130 | + | return; | |
| 131 | + | }; | |
| 132 | + | match entry { | |
| 133 | + | DirEntry::Directory(ref path) => { | |
| 134 | + | ctx.client.exec(commands::add(path)); | |
| 135 | + | ctx.notify(format!("Added “{}” to the queue", entry.name())); | |
| 136 | + | } | |
| 137 | + | // Space toggles a song in and out of the queue. | |
| 138 | + | DirEntry::Song(song) => { | |
| 139 | + | let ids: Vec<u32> = ctx | |
| 140 | + | .mpd | |
| 141 | + | .queue | |
| 142 | + | .iter() | |
| 143 | + | .filter(|q| q.file == song.file) | |
| 144 | + | .filter_map(|q| q.id) | |
| 145 | + | .collect(); | |
| 146 | + | if ids.is_empty() { | |
| 147 | + | ctx.client.exec(commands::add(&song.file)); | |
| 148 | + | } else { | |
| 149 | + | ctx.client | |
| 150 | + | .exec_all(ids.into_iter().map(commands::delete_id).collect()); | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | DirEntry::Playlist(name) => { | |
| 154 | + | ctx.client.exec(commands::load(&name)); | |
| 155 | + | ctx.notify(format!("Added playlist “{name}” to the queue")); | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | self.list.navigate(Action::Down, self.entries.len()); | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | /// Directories first, then songs in server order, then playlists. | |
| 163 | + | fn sort_entries(entries: &mut [DirEntry]) { | |
| 164 | + | let rank = |e: &DirEntry| match e { | |
| 165 | + | DirEntry::Directory(_) => 0, | |
| 166 | + | DirEntry::Song(_) => 1, | |
| 167 | + | DirEntry::Playlist(_) => 2, | |
| 168 | + | }; | |
| 169 | + | entries.sort_by(|a, b| { | |
| 170 | + | rank(a).cmp(&rank(b)).then_with(|| match (a, b) { | |
| 171 | + | (DirEntry::Song(_), DirEntry::Song(_)) => std::cmp::Ordering::Equal, | |
| 172 | + | _ => a.name().to_lowercase().cmp(&b.name().to_lowercase()), | |
| 173 | + | }) | |
| 174 | + | }); | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | impl Screen for BrowserScreen { | |
| 178 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 179 | + | let theme = ctx.theme(); | |
| 180 | + | let title = Line::from(vec![ | |
| 181 | + | Span::raw(" Browse "), | |
| 182 | + | Span::styled(format!("/{} ", self.path), theme.dim()), | |
| 183 | + | ]); | |
| 184 | + | if self.entries.is_empty() { | |
| 185 | + | let block = ui::pane(title, true, theme); | |
| 186 | + | let inner = block.inner(area); | |
| 187 | + | frame.render_widget(block, area); | |
| 188 | + | let text = if self.loaded { | |
| 189 | + | "This folder is empty" | |
| 190 | + | } else { | |
| 191 | + | "Loading…" | |
| 192 | + | }; | |
| 193 | + | ui::draw_placeholder(frame, inner, &[text], theme); | |
| 194 | + | return; | |
| 195 | + | } | |
| 196 | + | let entries = &self.entries; | |
| 197 | + | let marks = &self.marks; | |
| 198 | + | self.list.render( | |
| 199 | + | frame, | |
| 200 | + | area, | |
| 201 | + | ui::pane(title, true, theme), | |
| 202 | + | None, | |
| 203 | + | &[Constraint::Fill(1), Constraint::Length(8)], | |
| 204 | + | entries.len(), | |
| 205 | + | true, | |
| 206 | + | theme, | |
| 207 | + | |i| { | |
| 208 | + | let (label, duration, style) = match &entries[i] { | |
| 209 | + | DirEntry::Directory(_) => ( | |
| 210 | + | format!("▸ {}/", entries[i].name()), | |
| 211 | + | String::new(), | |
| 212 | + | theme.directory(), | |
| 213 | + | ), | |
| 214 | + | DirEntry::Song(song) => { | |
| 215 | + | let style = if ctx.mpd.is_queued(&song.file) { | |
| 216 | + | theme.text().add_modifier(Modifier::BOLD) | |
| 217 | + | } else { | |
| 218 | + | theme.text() | |
| 219 | + | }; | |
| 220 | + | ( | |
| 221 | + | format!(" {}", ui::song_label(song)), | |
| 222 | + | song.duration.map(format_duration).unwrap_or_default(), | |
| 223 | + | style, | |
| 224 | + | ) | |
| 225 | + | } | |
| 226 | + | DirEntry::Playlist(name) => { | |
| 227 | + | (format!("≡ {name}"), String::new(), theme.accent()) | |
| 228 | + | } | |
| 229 | + | }; | |
| 230 | + | let style = if marks.contains(&i) { | |
| 231 | + | theme.marked() | |
| 232 | + | } else { | |
| 233 | + | style | |
| 234 | + | }; | |
| 235 | + | Row::new([ | |
| 236 | + | Cell::from(label), | |
| 237 | + | Cell::from(Span::styled(duration, theme.dim())), | |
| 238 | + | ]) | |
| 239 | + | .style(style) | |
| 240 | + | }, | |
| 241 | + | ); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 245 | + | if self.list.navigate(action, self.entries.len()) { | |
| 246 | + | return true; | |
| 247 | + | } | |
| 248 | + | match action { | |
| 249 | + | Action::Activate | Action::Right => self.activate(ctx), | |
| 250 | + | Action::Left => self.back(ctx), | |
| 251 | + | Action::AddToQueue => self.add_to_queue(ctx), | |
| 252 | + | Action::AddToPlaylist => { | |
| 253 | + | let uris = self.selection(); | |
| 254 | + | if !uris.is_empty() { | |
| 255 | + | let popup = AddToPlaylistPopup::new(Selection::Uris(uris), ctx); | |
| 256 | + | ctx.popup(popup); | |
| 257 | + | self.marks.clear(); | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | Action::ToggleMark => { | |
| 261 | + | if let Some(i) = self.list.selected(self.entries.len()) { | |
| 262 | + | if !self.marks.remove(&i) { | |
| 263 | + | self.marks.insert(i); | |
| 264 | + | } | |
| 265 | + | self.list.navigate(Action::Down, self.entries.len()); | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | Action::ClearMarks => self.marks.clear(), | |
| 269 | + | _ => return false, | |
| 270 | + | } | |
| 271 | + | true | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 275 | + | match event { | |
| 276 | + | MpdEvent::Reply(Reply::Directory { path, entries }) => { | |
| 277 | + | let mut entries = entries.clone(); | |
| 278 | + | sort_entries(&mut entries); | |
| 279 | + | if *path == self.path { | |
| 280 | + | self.entries = entries.clone(); | |
| 281 | + | self.loaded = true; | |
| 282 | + | self.apply_restore(); | |
| 283 | + | } | |
| 284 | + | self.cache.insert(path.clone(), entries); | |
| 285 | + | } | |
| 286 | + | MpdEvent::Changed(subsystems) => { | |
| 287 | + | let database = subsystems.contains(&Subsystem::Database); | |
| 288 | + | let playlists = subsystems.contains(&Subsystem::StoredPlaylist); | |
| 289 | + | if database || playlists { | |
| 290 | + | self.cache.clear(); | |
| 291 | + | if self.loaded && (database || self.path.is_empty()) { | |
| 292 | + | // Keep the cursor where it is after the reload. | |
| 293 | + | self.restore = self.list.selected(self.entries.len()); | |
| 294 | + | ctx.client.query(Query::Directory(self.path.clone())); | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | } | |
| 298 | + | MpdEvent::Connected { .. } => { | |
| 299 | + | self.cache.clear(); | |
| 300 | + | if self.loaded { | |
| 301 | + | ctx.client.query(Query::Directory(self.path.clone())); | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | _ => {} | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 309 | + | if !self.loaded { | |
| 310 | + | self.load(ctx); | |
| 311 | + | } | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 315 | + | if let Some(Click::Activate(_)) = self.list.mouse(event, self.entries.len()) { | |
| 316 | + | self.activate(ctx); | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | fn search(&mut self, query: &str, _ctx: &Ctx) -> bool { | |
| 321 | + | // Match what is displayed: names for folders, labels for songs. | |
| 322 | + | let names: Vec<String> = self | |
| 323 | + | .entries | |
| 324 | + | .iter() | |
| 325 | + | .map(|entry| match entry { | |
| 326 | + | DirEntry::Song(song) => ui::song_label(song), | |
| 327 | + | other => other.name().to_owned(), | |
| 328 | + | }) | |
| 329 | + | .collect(); | |
| 330 | + | self.list | |
| 331 | + | .search(query, names.iter().map(String::as_str), names.len()); | |
| 332 | + | true | |
| 333 | + | } | |
| 334 | + | } | |
| @@ -0,0 +1,413 @@ | |||
| 1 | + | //! Screen 4: the media library — artists, their albums, and album songs. | |
| 2 | + | ||
| 3 | + | use crossterm::event::MouseEvent; | |
| 4 | + | use ratatui::{ | |
| 5 | + | Frame, | |
| 6 | + | layout::{Constraint, Layout, Position, Rect}, | |
| 7 | + | style::Modifier, | |
| 8 | + | text::Span, | |
| 9 | + | widgets::{Cell, Row}, | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | use super::{Ctx, Screen}; | |
| 13 | + | use crate::{ | |
| 14 | + | action::Action, | |
| 15 | + | mpd::{ | |
| 16 | + | Album, MpdEvent, Query, Reply, Request, Selection, Song, Subsystem, commands, | |
| 17 | + | protocol::{filter_and, filter_eq}, | |
| 18 | + | }, | |
| 19 | + | popups::AddToPlaylistPopup, | |
| 20 | + | ui::{ | |
| 21 | + | self, | |
| 22 | + | list::{Click, ListView}, | |
| 23 | + | }, | |
| 24 | + | util::format_duration, | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] | |
| 28 | + | enum Column { | |
| 29 | + | #[default] | |
| 30 | + | Artists, | |
| 31 | + | Albums, | |
| 32 | + | Songs, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | #[derive(Debug, Default)] | |
| 36 | + | pub struct LibraryScreen { | |
| 37 | + | /// Group by `Artist` instead of `AlbumArtist`. | |
| 38 | + | use_artist: bool, | |
| 39 | + | focus: Column, | |
| 40 | + | artists: Vec<String>, | |
| 41 | + | artists_loaded: bool, | |
| 42 | + | artist_list: ListView, | |
| 43 | + | albums: Vec<Album>, | |
| 44 | + | albums_of: Option<String>, | |
| 45 | + | album_list: ListView, | |
| 46 | + | songs: Vec<Song>, | |
| 47 | + | songs_filter: Option<String>, | |
| 48 | + | song_list: ListView, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | impl LibraryScreen { | |
| 52 | + | fn tag(&self) -> &'static str { | |
| 53 | + | if self.use_artist { | |
| 54 | + | "Artist" | |
| 55 | + | } else { | |
| 56 | + | "AlbumArtist" | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | fn reload(&mut self, ctx: &Ctx) { | |
| 61 | + | self.artists_loaded = false; | |
| 62 | + | self.albums_of = None; | |
| 63 | + | self.songs_filter = None; | |
| 64 | + | ctx.client.query(Query::TagValues(self.tag().to_owned())); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | fn selected_artist(&self) -> Option<&str> { | |
| 68 | + | self.artist_list | |
| 69 | + | .selected(self.artists.len()) | |
| 70 | + | .map(|i| self.artists[i].as_str()) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | fn selected_album(&self) -> Option<&Album> { | |
| 74 | + | self.album_list | |
| 75 | + | .selected(self.albums.len()) | |
| 76 | + | .map(|i| &self.albums[i]) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | fn artist_filter(&self) -> Option<String> { | |
| 80 | + | self.selected_artist() | |
| 81 | + | .map(|artist| filter_eq(self.tag(), artist)) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn album_filter(&self) -> Option<String> { | |
| 85 | + | let artist = self.artist_filter()?; | |
| 86 | + | let album = self.selected_album()?; | |
| 87 | + | let mut parts = vec![artist, filter_eq("Album", &album.name)]; | |
| 88 | + | if !album.date.is_empty() { | |
| 89 | + | parts.push(filter_eq("Date", &album.date)); | |
| 90 | + | } | |
| 91 | + | Some(filter_and(parts)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// Requests albums and songs for the current selection when it changed. | |
| 95 | + | fn sync(&mut self, ctx: &Ctx) { | |
| 96 | + | let artist = self.selected_artist().map(str::to_owned); | |
| 97 | + | if artist != self.albums_of { | |
| 98 | + | self.albums_of.clone_from(&artist); | |
| 99 | + | self.albums.clear(); | |
| 100 | + | self.album_list.reset(); | |
| 101 | + | self.songs.clear(); | |
| 102 | + | self.songs_filter = None; | |
| 103 | + | if let Some(artist) = artist { | |
| 104 | + | ctx.client.query(Query::Albums { | |
| 105 | + | tag: self.tag().to_owned(), | |
| 106 | + | value: artist, | |
| 107 | + | }); | |
| 108 | + | } | |
| 109 | + | return; | |
| 110 | + | } | |
| 111 | + | let filter = self.album_filter(); | |
| 112 | + | if filter != self.songs_filter { | |
| 113 | + | self.songs_filter.clone_from(&filter); | |
| 114 | + | self.songs.clear(); | |
| 115 | + | self.song_list.reset(); | |
| 116 | + | if let Some(filter) = filter { | |
| 117 | + | ctx.client.query(Query::Find(filter)); | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | fn focused_list(&mut self) -> (&mut ListView, usize) { | |
| 123 | + | match self.focus { | |
| 124 | + | Column::Artists => (&mut self.artist_list, self.artists.len()), | |
| 125 | + | Column::Albums => (&mut self.album_list, self.albums.len()), | |
| 126 | + | Column::Songs => (&mut self.song_list, self.songs.len()), | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | fn selected_song(&self) -> Option<&Song> { | |
| 131 | + | self.song_list | |
| 132 | + | .selected(self.songs.len()) | |
| 133 | + | .map(|i| &self.songs[i]) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// The songs the focused column refers to. | |
| 137 | + | fn selection(&self) -> Option<Selection> { | |
| 138 | + | match self.focus { | |
| 139 | + | Column::Artists => self.artist_filter().map(Selection::Filter), | |
| 140 | + | Column::Albums => self.album_filter().map(Selection::Filter), | |
| 141 | + | Column::Songs => self | |
| 142 | + | .selected_song() | |
| 143 | + | .map(|s| Selection::Uris(vec![s.file.clone()])), | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | fn activate(&mut self, ctx: &mut Ctx) { | |
| 148 | + | match self.focus { | |
| 149 | + | Column::Artists if !self.albums.is_empty() => self.focus = Column::Albums, | |
| 150 | + | Column::Artists => {} | |
| 151 | + | Column::Albums => { | |
| 152 | + | if let Some(filter) = self.album_filter() { | |
| 153 | + | ctx.client.request(Request::FindAddAndPlay(filter)); | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | Column::Songs => { | |
| 157 | + | if let Some(song) = self.selected_song() { | |
| 158 | + | match ctx.mpd.queued_id(&song.file) { | |
| 159 | + | Some(id) => ctx.client.exec(commands::play_id(id)), | |
| 160 | + | None => ctx | |
| 161 | + | .client | |
| 162 | + | .request(Request::AddAndPlay(vec![song.file.clone()])), | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | fn unknown(value: &str) -> &str { | |
| 171 | + | if value.is_empty() { "<unknown>" } else { value } | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | impl Screen for LibraryScreen { | |
| 175 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 176 | + | let theme = ctx.theme(); | |
| 177 | + | let [artists_area, albums_area, songs_area] = Layout::horizontal([ | |
| 178 | + | Constraint::Percentage(28), | |
| 179 | + | Constraint::Percentage(32), | |
| 180 | + | Constraint::Percentage(40), | |
| 181 | + | ]) | |
| 182 | + | .areas(area); | |
| 183 | + | ||
| 184 | + | let artists = &self.artists; | |
| 185 | + | let title = if self.use_artist { | |
| 186 | + | " Artists " | |
| 187 | + | } else { | |
| 188 | + | " Album Artists " | |
| 189 | + | }; | |
| 190 | + | let block = ui::pane(title, self.focus == Column::Artists, theme); | |
| 191 | + | if artists.is_empty() { | |
| 192 | + | let inner = block.inner(artists_area); | |
| 193 | + | frame.render_widget(block, artists_area); | |
| 194 | + | let text = if self.artists_loaded { | |
| 195 | + | "No artists" | |
| 196 | + | } else { | |
| 197 | + | "Loading…" | |
| 198 | + | }; | |
| 199 | + | ui::draw_placeholder(frame, inner, &[text], theme); | |
| 200 | + | } else { | |
| 201 | + | self.artist_list.render( | |
| 202 | + | frame, | |
| 203 | + | artists_area, | |
| 204 | + | block, | |
| 205 | + | None, | |
| 206 | + | &[Constraint::Fill(1)], | |
| 207 | + | artists.len(), | |
| 208 | + | self.focus == Column::Artists, | |
| 209 | + | theme, | |
| 210 | + | |i| Row::new([Cell::from(unknown(&artists[i]).to_owned())]), | |
| 211 | + | ); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | let albums = &self.albums; | |
| 215 | + | let block = ui::pane(" Albums ", self.focus == Column::Albums, theme); | |
| 216 | + | if albums.is_empty() { | |
| 217 | + | frame.render_widget(block, albums_area); | |
| 218 | + | } else { | |
| 219 | + | self.album_list.render( | |
| 220 | + | frame, | |
| 221 | + | albums_area, | |
| 222 | + | block, | |
| 223 | + | None, | |
| 224 | + | &[Constraint::Length(6), Constraint::Fill(1)], | |
| 225 | + | albums.len(), | |
| 226 | + | self.focus == Column::Albums, | |
| 227 | + | theme, | |
| 228 | + | |i| { | |
| 229 | + | let album = &albums[i]; | |
| 230 | + | Row::new([ | |
| 231 | + | Cell::from(Span::styled( | |
| 232 | + | album.date.chars().take(4).collect::<String>(), | |
| 233 | + | theme.dim(), | |
| 234 | + | )), | |
| 235 | + | Cell::from(unknown(&album.name).to_owned()), | |
| 236 | + | ]) | |
| 237 | + | }, | |
| 238 | + | ); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | let songs = &self.songs; | |
| 242 | + | let block = ui::pane(" Songs ", self.focus == Column::Songs, theme); | |
| 243 | + | if songs.is_empty() { | |
| 244 | + | frame.render_widget(block, songs_area); | |
| 245 | + | } else { | |
| 246 | + | self.song_list.render( | |
| 247 | + | frame, | |
| 248 | + | songs_area, | |
| 249 | + | block, | |
| 250 | + | None, | |
| 251 | + | &[ | |
| 252 | + | Constraint::Length(3), | |
| 253 | + | Constraint::Fill(1), | |
| 254 | + | Constraint::Length(7), | |
| 255 | + | ], | |
| 256 | + | songs.len(), | |
| 257 | + | self.focus == Column::Songs, | |
| 258 | + | theme, | |
| 259 | + | |i| { | |
| 260 | + | let song = &songs[i]; | |
| 261 | + | let style = if ctx.mpd.is_queued(&song.file) { | |
| 262 | + | theme.text().add_modifier(Modifier::BOLD) | |
| 263 | + | } else { | |
| 264 | + | theme.text() | |
| 265 | + | }; | |
| 266 | + | Row::new([ | |
| 267 | + | Cell::from(Span::styled( | |
| 268 | + | song.track().unwrap_or_default().to_owned(), | |
| 269 | + | theme.dim(), | |
| 270 | + | )), | |
| 271 | + | Cell::from(song.display_title().to_owned()), | |
| 272 | + | Cell::from(Span::styled( | |
| 273 | + | song.duration.map(format_duration).unwrap_or_default(), | |
| 274 | + | theme.dim(), | |
| 275 | + | )), | |
| 276 | + | ]) | |
| 277 | + | .style(style) | |
| 278 | + | }, | |
| 279 | + | ); | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 284 | + | let (list, len) = self.focused_list(); | |
| 285 | + | if list.navigate(action, len) { | |
| 286 | + | self.sync(ctx); | |
| 287 | + | return true; | |
| 288 | + | } | |
| 289 | + | match action { | |
| 290 | + | Action::Left => { | |
| 291 | + | self.focus = match self.focus { | |
| 292 | + | Column::Songs => Column::Albums, | |
| 293 | + | _ => Column::Artists, | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | Action::Right => match self.focus { | |
| 297 | + | Column::Artists if !self.albums.is_empty() => self.focus = Column::Albums, | |
| 298 | + | Column::Albums if !self.songs.is_empty() => self.focus = Column::Songs, | |
| 299 | + | _ => {} | |
| 300 | + | }, | |
| 301 | + | Action::Activate => self.activate(ctx), | |
| 302 | + | Action::AddToQueue => { | |
| 303 | + | if let Some(selection) = self.selection() { | |
| 304 | + | ctx.client.exec_all(selection.add_to_queue()); | |
| 305 | + | ctx.notify("Added to the queue"); | |
| 306 | + | if self.focus == Column::Songs { | |
| 307 | + | self.song_list.navigate(Action::Down, self.songs.len()); | |
| 308 | + | } | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | Action::AddToPlaylist => { | |
| 312 | + | if let Some(selection) = self.selection() { | |
| 313 | + | let popup = AddToPlaylistPopup::new(selection, ctx); | |
| 314 | + | ctx.popup(popup); | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | Action::ToggleArtistTag => { | |
| 318 | + | self.use_artist = !self.use_artist; | |
| 319 | + | self.focus = Column::Artists; | |
| 320 | + | self.artists.clear(); | |
| 321 | + | self.artist_list.reset(); | |
| 322 | + | self.reload(ctx); | |
| 323 | + | } | |
| 324 | + | _ => return false, | |
| 325 | + | } | |
| 326 | + | true | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 330 | + | match event { | |
| 331 | + | MpdEvent::Reply(Reply::TagValues { tag, values }) if tag == self.tag() => { | |
| 332 | + | self.artists.clone_from(values); | |
| 333 | + | self.artists_loaded = true; | |
| 334 | + | self.sync(ctx); | |
| 335 | + | } | |
| 336 | + | MpdEvent::Reply(Reply::Albums { tag, value, albums }) | |
| 337 | + | if tag == self.tag() && Some(value) == self.albums_of.as_ref() => | |
| 338 | + | { | |
| 339 | + | self.albums.clone_from(albums); | |
| 340 | + | self.sync(ctx); | |
| 341 | + | } | |
| 342 | + | MpdEvent::Reply(Reply::Find { filter, songs }) | |
| 343 | + | if Some(filter) == self.songs_filter.as_ref() => | |
| 344 | + | { | |
| 345 | + | self.songs.clone_from(songs); | |
| 346 | + | } | |
| 347 | + | MpdEvent::Changed(subsystems) | |
| 348 | + | if subsystems.contains(&Subsystem::Database) && self.artists_loaded => | |
| 349 | + | { | |
| 350 | + | self.reload(ctx); | |
| 351 | + | } | |
| 352 | + | _ => {} | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 357 | + | if !self.artists_loaded { | |
| 358 | + | self.reload(ctx); | |
| 359 | + | } | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 363 | + | let position = Position::new(event.column, event.row); | |
| 364 | + | let column = [Column::Artists, Column::Albums, Column::Songs] | |
| 365 | + | .into_iter() | |
| 366 | + | .find(|&c| { | |
| 367 | + | let list = match c { | |
| 368 | + | Column::Artists => &self.artist_list, | |
| 369 | + | Column::Albums => &self.album_list, | |
| 370 | + | Column::Songs => &self.song_list, | |
| 371 | + | }; | |
| 372 | + | list.contains(position) | |
| 373 | + | }); | |
| 374 | + | let Some(column) = column else { | |
| 375 | + | return; | |
| 376 | + | }; | |
| 377 | + | self.focus = column; | |
| 378 | + | let (list, len) = self.focused_list(); | |
| 379 | + | let click = list.mouse(event, len); | |
| 380 | + | self.sync(ctx); | |
| 381 | + | if let Some(Click::Activate(_)) = click { | |
| 382 | + | self.activate(ctx); | |
| 383 | + | } | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | fn search(&mut self, query: &str, ctx: &Ctx) -> bool { | |
| 387 | + | match self.focus { | |
| 388 | + | Column::Artists => { | |
| 389 | + | self.artist_list.search( | |
| 390 | + | query, | |
| 391 | + | self.artists.iter().map(String::as_str), | |
| 392 | + | self.artists.len(), | |
| 393 | + | ); | |
| 394 | + | } | |
| 395 | + | Column::Albums => { | |
| 396 | + | self.album_list.search( | |
| 397 | + | query, | |
| 398 | + | self.albums.iter().map(|a| a.name.as_str()), | |
| 399 | + | self.albums.len(), | |
| 400 | + | ); | |
| 401 | + | } | |
| 402 | + | Column::Songs => { | |
| 403 | + | self.song_list.search( | |
| 404 | + | query, | |
| 405 | + | self.songs.iter().map(Song::display_title), | |
| 406 | + | self.songs.len(), | |
| 407 | + | ); | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | self.sync(ctx); | |
| 411 | + | true | |
| 412 | + | } | |
| 413 | + | } | |
| @@ -0,0 +1,132 @@ | |||
| 1 | + | //! Screen 7: lyrics of the current song. Also drawn as a pane on the queue. | |
| 2 | + | ||
| 3 | + | use crossterm::event::{MouseEvent, MouseEventKind}; | |
| 4 | + | use ratatui::{ | |
| 5 | + | Frame, | |
| 6 | + | layout::{Alignment, Rect}, | |
| 7 | + | style::Modifier, | |
| 8 | + | text::{Line, Span}, | |
| 9 | + | widgets::Paragraph, | |
| 10 | + | }; | |
| 11 | + | ||
| 12 | + | use super::{Ctx, Effect, Screen}; | |
| 13 | + | use crate::{ | |
| 14 | + | action::Action, | |
| 15 | + | lyrics::{Lyrics, LyricsStatus}, | |
| 16 | + | ui, | |
| 17 | + | }; | |
| 18 | + | ||
| 19 | + | #[derive(Debug, Default)] | |
| 20 | + | pub struct LyricsScreen { | |
| 21 | + | /// Scroll offset for plain (unsynced) lyrics. | |
| 22 | + | scroll: usize, | |
| 23 | + | height: usize, | |
| 24 | + | file: Option<String>, | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | /// Draws the lyrics pane without manual scrolling. | |
| 28 | + | pub fn draw_lyrics(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool) { | |
| 29 | + | render(frame, area, ctx, focused, 0); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | fn render(frame: &mut Frame, area: Rect, ctx: &Ctx, focused: bool, scroll: usize) -> usize { | |
| 33 | + | let theme = ctx.theme(); | |
| 34 | + | let title = match &ctx.mpd.current { | |
| 35 | + | Some(song) => format!(" Lyrics · {} ", ui::song_label(song)), | |
| 36 | + | None => " Lyrics ".to_owned(), | |
| 37 | + | }; | |
| 38 | + | let block = ui::pane(title, focused, theme); | |
| 39 | + | let inner = block.inner(area); | |
| 40 | + | frame.render_widget(block, area); | |
| 41 | + | let height = usize::from(inner.height); | |
| 42 | + | ||
| 43 | + | let lyrics = match (&ctx.mpd.current, &ctx.lyrics.status) { | |
| 44 | + | (None, _) => { | |
| 45 | + | ui::draw_placeholder(frame, inner, &["Nothing is playing"], theme); | |
| 46 | + | return height; | |
| 47 | + | } | |
| 48 | + | (Some(_), LyricsStatus::Found(lyrics)) => lyrics, | |
| 49 | + | (Some(_), LyricsStatus::Idle | LyricsStatus::Loading) => { | |
| 50 | + | ui::draw_placeholder(frame, inner, &["Looking for lyrics…"], theme); | |
| 51 | + | return height; | |
| 52 | + | } | |
| 53 | + | (Some(_), LyricsStatus::NotFound) => { | |
| 54 | + | ui::draw_placeholder(frame, inner, &["No lyrics found"], theme); | |
| 55 | + | return height; | |
| 56 | + | } | |
| 57 | + | (Some(_), LyricsStatus::Failed(error)) => { | |
| 58 | + | ui::draw_placeholder(frame, inner, &["Could not fetch lyrics", error], theme); | |
| 59 | + | return height; | |
| 60 | + | } | |
| 61 | + | }; | |
| 62 | + | ||
| 63 | + | let current = lyrics.current_line(ctx.mpd.elapsed()); | |
| 64 | + | let top = match (lyrics, current) { | |
| 65 | + | // Keep the current line in the middle. | |
| 66 | + | (Lyrics::Synced(_), Some(line)) => line.saturating_sub(height / 2), | |
| 67 | + | (Lyrics::Synced(_), None) => 0, | |
| 68 | + | (Lyrics::Plain(_), _) => scroll.min(lyrics.len().saturating_sub(1)), | |
| 69 | + | }; | |
| 70 | + | let lines: Vec<Line> = (top..lyrics.len().min(top + height)) | |
| 71 | + | .map(|i| { | |
| 72 | + | let text = lyrics.line(i); | |
| 73 | + | let style = match (lyrics, current) { | |
| 74 | + | (Lyrics::Synced(_), Some(c)) if i == c => { | |
| 75 | + | theme.playing().add_modifier(Modifier::BOLD) | |
| 76 | + | } | |
| 77 | + | (Lyrics::Synced(_), Some(c)) if i < c => theme.dim(), | |
| 78 | + | (Lyrics::Synced(_), None) => theme.dim(), | |
| 79 | + | _ => theme.text(), | |
| 80 | + | }; | |
| 81 | + | Line::from(Span::styled(text.to_owned(), style)) | |
| 82 | + | }) | |
| 83 | + | .collect(); | |
| 84 | + | frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), inner); | |
| 85 | + | height | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | impl Screen for LyricsScreen { | |
| 89 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 90 | + | // Plain lyrics start from the top for every song. | |
| 91 | + | let file = ctx.mpd.current.as_ref().map(|s| s.file.clone()); | |
| 92 | + | if file != self.file { | |
| 93 | + | self.file = file; | |
| 94 | + | self.scroll = 0; | |
| 95 | + | } | |
| 96 | + | self.height = render(frame, area, ctx, true, self.scroll); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 100 | + | let len = ctx.lyrics.lyrics().map_or(0, Lyrics::len); | |
| 101 | + | let page = self.height.max(1); | |
| 102 | + | let max = len.saturating_sub(1); | |
| 103 | + | self.scroll = match action { | |
| 104 | + | Action::Up => self.scroll.saturating_sub(1), | |
| 105 | + | Action::Down => (self.scroll + 1).min(max), | |
| 106 | + | Action::PageUp => self.scroll.saturating_sub(page), | |
| 107 | + | Action::PageDown => (self.scroll + page).min(max), | |
| 108 | + | Action::HalfPageUp => self.scroll.saturating_sub(page / 2), | |
| 109 | + | Action::HalfPageDown => (self.scroll + page / 2).min(max), | |
| 110 | + | Action::Top => 0, | |
| 111 | + | Action::Bottom => max, | |
| 112 | + | Action::ReloadLyrics => { | |
| 113 | + | ctx.effects.push(Effect::ReloadLyrics); | |
| 114 | + | ctx.notify("Fetching lyrics again…"); | |
| 115 | + | return true; | |
| 116 | + | } | |
| 117 | + | _ => return false, | |
| 118 | + | }; | |
| 119 | + | true | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 123 | + | let action = match event.kind { | |
| 124 | + | MouseEventKind::ScrollDown => Action::Down, | |
| 125 | + | MouseEventKind::ScrollUp => Action::Up, | |
| 126 | + | _ => return, | |
| 127 | + | }; | |
| 128 | + | for _ in 0..3 { | |
| 129 | + | self.handle_action(action, ctx); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| @@ -0,0 +1,164 @@ | |||
| 1 | + | //! The screens. Each is a self-contained component that renders itself, | |
| 2 | + | //! interprets actions, and reacts to server events. | |
| 3 | + | ||
| 4 | + | mod browser; | |
| 5 | + | mod library; | |
| 6 | + | mod lyrics; | |
| 7 | + | mod playlists; | |
| 8 | + | mod queue; | |
| 9 | + | mod tag_editor; | |
| 10 | + | mod visualizer; | |
| 11 | + | ||
| 12 | + | use std::sync::Arc; | |
| 13 | + | ||
| 14 | + | use crossterm::event::MouseEvent; | |
| 15 | + | use ratatui::{Frame, layout::Rect}; | |
| 16 | + | use tokio::sync::watch; | |
| 17 | + | ||
| 18 | + | pub use browser::BrowserScreen; | |
| 19 | + | pub use library::LibraryScreen; | |
| 20 | + | pub use lyrics::{LyricsScreen, draw_lyrics}; | |
| 21 | + | pub use playlists::PlaylistsScreen; | |
| 22 | + | pub use queue::QueueScreen; | |
| 23 | + | pub use tag_editor::TagEditorScreen; | |
| 24 | + | pub use visualizer::VisualizerScreen; | |
| 25 | + | ||
| 26 | + | use crate::{ | |
| 27 | + | action::{Action, ScreenId}, | |
| 28 | + | config::{Config, Theme}, | |
| 29 | + | lyrics::LyricsState, | |
| 30 | + | mpd::{MpdEvent, MpdHandle}, | |
| 31 | + | popups::Popup, | |
| 32 | + | state::MpdState, | |
| 33 | + | tags::{TagField, TagWrite}, | |
| 34 | + | visualizer::Spectrum, | |
| 35 | + | }; | |
| 36 | + | ||
| 37 | + | /// What a screen or popup may read, plus a queue of effects for the app. | |
| 38 | + | pub struct Ctx<'a> { | |
| 39 | + | pub config: &'a Config, | |
| 40 | + | pub mpd: &'a MpdState, | |
| 41 | + | pub client: &'a MpdHandle, | |
| 42 | + | pub lyrics: &'a LyricsState, | |
| 43 | + | pub effects: Vec<Effect>, | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl<'a> Ctx<'a> { | |
| 47 | + | pub fn new( | |
| 48 | + | config: &'a Config, | |
| 49 | + | mpd: &'a MpdState, | |
| 50 | + | client: &'a MpdHandle, | |
| 51 | + | lyrics: &'a LyricsState, | |
| 52 | + | ) -> Self { | |
| 53 | + | Self { | |
| 54 | + | config, | |
| 55 | + | mpd, | |
| 56 | + | client, | |
| 57 | + | lyrics, | |
| 58 | + | effects: Vec::new(), | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | pub fn theme(&self) -> &'a Theme { | |
| 63 | + | &self.config.theme | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | pub fn notify(&mut self, text: impl Into<String>) { | |
| 67 | + | self.effects.push(Effect::Notify { | |
| 68 | + | text: text.into(), | |
| 69 | + | error: false, | |
| 70 | + | }); | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | pub fn error(&mut self, text: impl Into<String>) { | |
| 74 | + | self.effects.push(Effect::Notify { | |
| 75 | + | text: text.into(), | |
| 76 | + | error: true, | |
| 77 | + | }); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | pub fn popup(&mut self, popup: impl Popup + 'static) { | |
| 81 | + | self.effects.push(Effect::Popup(Box::new(popup))); | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | /// Requests from screens and popups that only the app can fulfil. | |
| 86 | + | pub enum Effect { | |
| 87 | + | Notify { text: String, error: bool }, | |
| 88 | + | Popup(Box<dyn Popup>), | |
| 89 | + | Message(ScreenId, ScreenMessage), | |
| 90 | + | WriteTags(Vec<TagWrite>), | |
| 91 | + | ReloadLyrics, | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | /// Data a popup hands back to a screen. | |
| 95 | + | #[derive(Clone, Debug)] | |
| 96 | + | pub enum ScreenMessage { | |
| 97 | + | TagValue { field: TagField, value: String }, | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | pub trait Screen { | |
| 101 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx); | |
| 102 | + | ||
| 103 | + | /// Returns whether the action was handled. | |
| 104 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool; | |
| 105 | + | ||
| 106 | + | /// Called for every server event, whether or not the screen is visible. | |
| 107 | + | fn on_mpd(&mut self, _event: &MpdEvent, _ctx: &mut Ctx) {} | |
| 108 | + | ||
| 109 | + | /// Called when the screen becomes visible. | |
| 110 | + | fn on_enter(&mut self, _ctx: &mut Ctx) {} | |
| 111 | + | ||
| 112 | + | fn on_message(&mut self, _message: ScreenMessage, _ctx: &mut Ctx) {} | |
| 113 | + | ||
| 114 | + | fn on_mouse(&mut self, _event: MouseEvent, _ctx: &mut Ctx) {} | |
| 115 | + | ||
| 116 | + | /// Searches the focused list. Returns `false` if there is nothing to | |
| 117 | + | /// search on this screen. | |
| 118 | + | fn search(&mut self, _query: &str, _ctx: &Ctx) -> bool { | |
| 119 | + | false | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | /// All screens, owned by the app. | |
| 124 | + | pub struct Screens { | |
| 125 | + | pub queue: QueueScreen, | |
| 126 | + | pub browser: BrowserScreen, | |
| 127 | + | pub playlists: PlaylistsScreen, | |
| 128 | + | pub library: LibraryScreen, | |
| 129 | + | pub tag_editor: TagEditorScreen, | |
| 130 | + | pub visualizer: VisualizerScreen, | |
| 131 | + | pub lyrics: LyricsScreen, | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | impl Screens { | |
| 135 | + | pub fn new(config: &Config, spectrum: watch::Receiver<Arc<Spectrum>>) -> Self { | |
| 136 | + | Self { | |
| 137 | + | queue: QueueScreen::new(config), | |
| 138 | + | browser: BrowserScreen::default(), | |
| 139 | + | playlists: PlaylistsScreen::default(), | |
| 140 | + | library: LibraryScreen::default(), | |
| 141 | + | tag_editor: TagEditorScreen::default(), | |
| 142 | + | visualizer: VisualizerScreen::new(spectrum), | |
| 143 | + | lyrics: LyricsScreen::default(), | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | pub fn get_mut(&mut self, id: ScreenId) -> &mut dyn Screen { | |
| 148 | + | match id { | |
| 149 | + | ScreenId::Queue => &mut self.queue, | |
| 150 | + | ScreenId::Browser => &mut self.browser, | |
| 151 | + | ScreenId::Playlists => &mut self.playlists, | |
| 152 | + | ScreenId::Library => &mut self.library, | |
| 153 | + | ScreenId::TagEditor => &mut self.tag_editor, | |
| 154 | + | ScreenId::Visualizer => &mut self.visualizer, | |
| 155 | + | ScreenId::Lyrics => &mut self.lyrics, | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | pub fn for_each(&mut self, mut f: impl FnMut(&mut dyn Screen)) { | |
| 160 | + | for &id in ScreenId::ALL { | |
| 161 | + | f(self.get_mut(id)); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | } | |
| @@ -0,0 +1,348 @@ | |||
| 1 | + | //! Screen 3: stored playlists and their contents. | |
| 2 | + | ||
| 3 | + | use crossterm::event::MouseEvent; | |
| 4 | + | use ratatui::{ | |
| 5 | + | Frame, | |
| 6 | + | layout::{Constraint, Layout, Position, Rect}, | |
| 7 | + | text::{Line, Span}, | |
| 8 | + | widgets::{Cell, Row}, | |
| 9 | + | }; | |
| 10 | + | ||
| 11 | + | use super::{Ctx, Screen}; | |
| 12 | + | use crate::{ | |
| 13 | + | action::Action, | |
| 14 | + | mpd::{MpdEvent, Query, Reply, Request, Selection, Song, Subsystem, commands}, | |
| 15 | + | popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose}, | |
| 16 | + | ui::{ | |
| 17 | + | self, | |
| 18 | + | list::{Click, ListView}, | |
| 19 | + | }, | |
| 20 | + | util::format_duration, | |
| 21 | + | }; | |
| 22 | + | ||
| 23 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 24 | + | enum Focus { | |
| 25 | + | #[default] | |
| 26 | + | Playlists, | |
| 27 | + | Songs, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | #[derive(Debug, Default)] | |
| 31 | + | pub struct PlaylistsScreen { | |
| 32 | + | focus: Focus, | |
| 33 | + | playlists: ListView, | |
| 34 | + | songs: ListView, | |
| 35 | + | /// Contents of `songs_of`. | |
| 36 | + | contents: Vec<Song>, | |
| 37 | + | songs_of: Option<String>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | impl PlaylistsScreen { | |
| 41 | + | fn selected_name<'a>(&self, ctx: &Ctx<'a>) -> Option<&'a str> { | |
| 42 | + | let playlists = &ctx.mpd.playlists; | |
| 43 | + | self.playlists | |
| 44 | + | .selected(playlists.len()) | |
| 45 | + | .map(|i| playlists[i].name.as_str()) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Requests the contents of the selected playlist if they are not shown. | |
| 49 | + | fn sync_contents(&mut self, ctx: &Ctx) { | |
| 50 | + | let name = self.selected_name(ctx); | |
| 51 | + | if name != self.songs_of.as_deref() { | |
| 52 | + | self.songs_of = name.map(str::to_owned); | |
| 53 | + | self.contents.clear(); | |
| 54 | + | self.songs.reset(); | |
| 55 | + | if let Some(name) = name { | |
| 56 | + | ctx.client.query(Query::PlaylistSongs(name.to_owned())); | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | fn selected_song(&self) -> Option<(usize, &Song)> { | |
| 62 | + | self.songs | |
| 63 | + | .selected(self.contents.len()) | |
| 64 | + | .map(|i| (i, &self.contents[i])) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | fn move_song(&mut self, down: bool, ctx: &Ctx) { | |
| 68 | + | let (Some(name), Some((index, _))) = (self.songs_of.as_deref(), self.selected_song()) | |
| 69 | + | else { | |
| 70 | + | return; | |
| 71 | + | }; | |
| 72 | + | let target = if down { | |
| 73 | + | index + 1 | |
| 74 | + | } else { | |
| 75 | + | index.wrapping_sub(1) | |
| 76 | + | }; | |
| 77 | + | if target < self.contents.len() { | |
| 78 | + | ctx.client | |
| 79 | + | .exec(commands::playlist_move(name, index as u32, target as u32)); | |
| 80 | + | self.songs.select(target, self.contents.len()); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn playlist_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 85 | + | let Some(name) = self.selected_name(ctx).map(str::to_owned) else { | |
| 86 | + | return false; | |
| 87 | + | }; | |
| 88 | + | match action { | |
| 89 | + | Action::DeletePlaylist => ctx.popup(ConfirmPopup::new( | |
| 90 | + | format!("Delete the playlist “{name}”?"), | |
| 91 | + | vec![commands::remove_playlist(&name)], | |
| 92 | + | )), | |
| 93 | + | Action::RenamePlaylist => ctx.popup(InputPopup::new( | |
| 94 | + | "Rename playlist", | |
| 95 | + | &name, | |
| 96 | + | InputPurpose::RenamePlaylist(name.clone()), | |
| 97 | + | )), | |
| 98 | + | _ => return false, | |
| 99 | + | } | |
| 100 | + | true | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | impl Screen for PlaylistsScreen { | |
| 105 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 106 | + | let theme = ctx.theme(); | |
| 107 | + | let [left, right] = | |
| 108 | + | Layout::horizontal([Constraint::Percentage(30), Constraint::Percentage(70)]) | |
| 109 | + | .areas(area); | |
| 110 | + | ||
| 111 | + | let playlists = &ctx.mpd.playlists; | |
| 112 | + | let list_focused = self.focus == Focus::Playlists; | |
| 113 | + | let block = ui::pane( | |
| 114 | + | format!(" Playlists ({}) ", playlists.len()), | |
| 115 | + | list_focused, | |
| 116 | + | theme, | |
| 117 | + | ); | |
| 118 | + | if playlists.is_empty() { | |
| 119 | + | let inner = block.inner(left); | |
| 120 | + | frame.render_widget(block, left); | |
| 121 | + | ui::draw_placeholder( | |
| 122 | + | frame, | |
| 123 | + | inner, | |
| 124 | + | &["No playlists", "", "Create one with `a` on any song"], | |
| 125 | + | theme, | |
| 126 | + | ); | |
| 127 | + | } else { | |
| 128 | + | self.playlists.render( | |
| 129 | + | frame, | |
| 130 | + | left, | |
| 131 | + | block, | |
| 132 | + | None, | |
| 133 | + | &[Constraint::Fill(1)], | |
| 134 | + | playlists.len(), | |
| 135 | + | list_focused, | |
| 136 | + | theme, | |
| 137 | + | |i| Row::new([Cell::from(playlists[i].name.as_str())]), | |
| 138 | + | ); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | let contents = &self.contents; | |
| 142 | + | let total: std::time::Duration = contents.iter().filter_map(|s| s.duration).sum(); | |
| 143 | + | let title = match &self.songs_of { | |
| 144 | + | Some(name) => Line::from(vec![ | |
| 145 | + | Span::raw(format!(" {name} ")), | |
| 146 | + | Span::styled( | |
| 147 | + | format!("{} songs · {} ", contents.len(), format_duration(total)), | |
| 148 | + | theme.dim(), | |
| 149 | + | ), | |
| 150 | + | ]), | |
| 151 | + | None => Line::from(" Songs "), | |
| 152 | + | }; | |
| 153 | + | let block = ui::pane(title, !list_focused, theme); | |
| 154 | + | if contents.is_empty() { | |
| 155 | + | let inner = block.inner(right); | |
| 156 | + | frame.render_widget(block, right); | |
| 157 | + | let text = if self.songs_of.is_some() { | |
| 158 | + | "This playlist is empty" | |
| 159 | + | } else { | |
| 160 | + | "" | |
| 161 | + | }; | |
| 162 | + | ui::draw_placeholder(frame, inner, &[text], theme); | |
| 163 | + | return; | |
| 164 | + | } | |
| 165 | + | self.songs.render( | |
| 166 | + | frame, | |
| 167 | + | right, | |
| 168 | + | block, | |
| 169 | + | None, | |
| 170 | + | &[ | |
| 171 | + | Constraint::Fill(3), | |
| 172 | + | Constraint::Fill(4), | |
| 173 | + | Constraint::Fill(3), | |
| 174 | + | Constraint::Length(7), | |
| 175 | + | ], | |
| 176 | + | contents.len(), | |
| 177 | + | !list_focused, | |
| 178 | + | theme, | |
| 179 | + | |i| { | |
| 180 | + | let song = &contents[i]; | |
| 181 | + | let style = if ctx | |
| 182 | + | .mpd | |
| 183 | + | .current | |
| 184 | + | .as_ref() | |
| 185 | + | .is_some_and(|c| c.file == song.file) | |
| 186 | + | { | |
| 187 | + | theme.playing() | |
| 188 | + | } else { | |
| 189 | + | theme.text() | |
| 190 | + | }; | |
| 191 | + | Row::new([ | |
| 192 | + | Cell::from(song.artist().unwrap_or_default().to_owned()), | |
| 193 | + | Cell::from(song.display_title().to_owned()), | |
| 194 | + | Cell::from(song.album().unwrap_or_default().to_owned()), | |
| 195 | + | Cell::from(Span::styled( | |
| 196 | + | song.duration.map(format_duration).unwrap_or_default(), | |
| 197 | + | theme.dim(), | |
| 198 | + | )), | |
| 199 | + | ]) | |
| 200 | + | .style(style) | |
| 201 | + | }, | |
| 202 | + | ); | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 206 | + | if self.playlist_action(action, ctx) { | |
| 207 | + | return true; | |
| 208 | + | } | |
| 209 | + | match self.focus { | |
| 210 | + | Focus::Playlists => { | |
| 211 | + | if self.playlists.navigate(action, ctx.mpd.playlists.len()) { | |
| 212 | + | self.sync_contents(ctx); | |
| 213 | + | return true; | |
| 214 | + | } | |
| 215 | + | let Some(name) = self.selected_name(ctx).map(str::to_owned) else { | |
| 216 | + | return false; | |
| 217 | + | }; | |
| 218 | + | match action { | |
| 219 | + | Action::Activate => { | |
| 220 | + | ctx.client.request(Request::LoadAndPlay { | |
| 221 | + | name: name.clone(), | |
| 222 | + | offset: 0, | |
| 223 | + | }); | |
| 224 | + | ctx.notify(format!("Loaded playlist “{name}”")); | |
| 225 | + | } | |
| 226 | + | Action::AddToQueue => { | |
| 227 | + | ctx.client.exec(commands::load(&name)); | |
| 228 | + | ctx.notify(format!("Added playlist “{name}” to the queue")); | |
| 229 | + | } | |
| 230 | + | Action::Right if !self.contents.is_empty() => self.focus = Focus::Songs, | |
| 231 | + | Action::AddToPlaylist if !self.contents.is_empty() => { | |
| 232 | + | let files = self.contents.iter().map(|s| s.file.clone()).collect(); | |
| 233 | + | let popup = AddToPlaylistPopup::new(Selection::Uris(files), ctx); | |
| 234 | + | ctx.popup(popup); | |
| 235 | + | } | |
| 236 | + | _ => return false, | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | Focus::Songs => { | |
| 240 | + | if self.songs.navigate(action, self.contents.len()) { | |
| 241 | + | return true; | |
| 242 | + | } | |
| 243 | + | let Some(name) = self.songs_of.clone() else { | |
| 244 | + | return false; | |
| 245 | + | }; | |
| 246 | + | match action { | |
| 247 | + | Action::Left => self.focus = Focus::Playlists, | |
| 248 | + | Action::Activate | Action::Right => { | |
| 249 | + | if let Some((index, _)) = self.selected_song() { | |
| 250 | + | ctx.client.request(Request::LoadAndPlay { | |
| 251 | + | name, | |
| 252 | + | offset: index as u32, | |
| 253 | + | }); | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | Action::AddToQueue => { | |
| 257 | + | if let Some((_, song)) = self.selected_song() { | |
| 258 | + | ctx.client.exec(commands::add(&song.file)); | |
| 259 | + | ctx.notify(format!("Added “{}” to the queue", song.display_title())); | |
| 260 | + | } | |
| 261 | + | self.songs.navigate(Action::Down, self.contents.len()); | |
| 262 | + | } | |
| 263 | + | Action::Delete => { | |
| 264 | + | if let Some((index, _)) = self.selected_song() { | |
| 265 | + | ctx.client | |
| 266 | + | .exec(commands::playlist_delete(&name, index as u32)); | |
| 267 | + | } | |
| 268 | + | } | |
| 269 | + | Action::MoveItemUp => self.move_song(false, ctx), | |
| 270 | + | Action::MoveItemDown => self.move_song(true, ctx), | |
| 271 | + | Action::AddToPlaylist => { | |
| 272 | + | if let Some((_, song)) = self.selected_song() { | |
| 273 | + | let selection = Selection::Uris(vec![song.file.clone()]); | |
| 274 | + | let popup = AddToPlaylistPopup::new(selection, ctx); | |
| 275 | + | ctx.popup(popup); | |
| 276 | + | } | |
| 277 | + | } | |
| 278 | + | _ => return false, | |
| 279 | + | } | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | true | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 286 | + | match event { | |
| 287 | + | MpdEvent::Reply(Reply::PlaylistSongs { name, songs }) | |
| 288 | + | if Some(name) == self.songs_of.as_ref() => | |
| 289 | + | { | |
| 290 | + | self.contents.clone_from(songs); | |
| 291 | + | if self.contents.is_empty() { | |
| 292 | + | self.focus = Focus::Playlists; | |
| 293 | + | } | |
| 294 | + | } | |
| 295 | + | MpdEvent::Reply(Reply::Playlists(_)) => { | |
| 296 | + | // Follow the selected playlist when names change (e.g. a rename). | |
| 297 | + | self.sync_contents(ctx); | |
| 298 | + | } | |
| 299 | + | MpdEvent::Changed(subsystems) if subsystems.contains(&Subsystem::StoredPlaylist) => { | |
| 300 | + | if let Some(name) = &self.songs_of { | |
| 301 | + | ctx.client.query(Query::PlaylistSongs(name.clone())); | |
| 302 | + | } | |
| 303 | + | } | |
| 304 | + | _ => {} | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 309 | + | self.sync_contents(ctx); | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 313 | + | let position = Position::new(event.column, event.row); | |
| 314 | + | if self.playlists.contains(position) { | |
| 315 | + | self.focus = Focus::Playlists; | |
| 316 | + | let click = self.playlists.mouse(event, ctx.mpd.playlists.len()); | |
| 317 | + | self.sync_contents(ctx); | |
| 318 | + | if let Some(Click::Activate(_)) = click { | |
| 319 | + | self.handle_action(Action::Activate, ctx); | |
| 320 | + | } | |
| 321 | + | } else if self.songs.contains(position) { | |
| 322 | + | self.focus = Focus::Songs; | |
| 323 | + | if let Some(Click::Activate(_)) = self.songs.mouse(event, self.contents.len()) { | |
| 324 | + | self.handle_action(Action::Activate, ctx); | |
| 325 | + | } | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | ||
| 329 | + | fn search(&mut self, query: &str, ctx: &Ctx) -> bool { | |
| 330 | + | match self.focus { | |
| 331 | + | Focus::Playlists => { | |
| 332 | + | let playlists = &ctx.mpd.playlists; | |
| 333 | + | self.playlists.search( | |
| 334 | + | query, | |
| 335 | + | playlists.iter().map(|p| p.name.as_str()), | |
| 336 | + | playlists.len(), | |
| 337 | + | ); | |
| 338 | + | self.sync_contents(ctx); | |
| 339 | + | } | |
| 340 | + | Focus::Songs => { | |
| 341 | + | let texts: Vec<String> = self.contents.iter().map(ui::song_search_text).collect(); | |
| 342 | + | self.songs | |
| 343 | + | .search(query, texts.iter().map(String::as_str), texts.len()); | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | true | |
| 347 | + | } | |
| 348 | + | } | |
| @@ -0,0 +1,284 @@ | |||
| 1 | + | //! Screen 1: the play queue. | |
| 2 | + | ||
| 3 | + | use std::{collections::HashSet, time::Duration}; | |
| 4 | + | ||
| 5 | + | use crossterm::event::MouseEvent; | |
| 6 | + | use ratatui::{ | |
| 7 | + | Frame, | |
| 8 | + | layout::{Constraint, Layout, Rect}, | |
| 9 | + | style::Modifier, | |
| 10 | + | text::{Line, Span}, | |
| 11 | + | widgets::{Cell, Row}, | |
| 12 | + | }; | |
| 13 | + | ||
| 14 | + | use super::{Ctx, Screen, draw_lyrics}; | |
| 15 | + | use crate::{ | |
| 16 | + | action::Action, | |
| 17 | + | config::{ColumnField, Config}, | |
| 18 | + | mpd::{MpdEvent, Reply, Selection, commands}, | |
| 19 | + | popups::{AddToPlaylistPopup, ConfirmPopup, InputPopup, InputPurpose}, | |
| 20 | + | ui::{ | |
| 21 | + | self, | |
| 22 | + | list::{Click, ListView}, | |
| 23 | + | }, | |
| 24 | + | util::format_duration, | |
| 25 | + | }; | |
| 26 | + | ||
| 27 | + | pub struct QueueScreen { | |
| 28 | + | list: ListView, | |
| 29 | + | /// Marked songs by queue id. | |
| 30 | + | marks: HashSet<u32>, | |
| 31 | + | lyrics_pane: bool, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | impl QueueScreen { | |
| 35 | + | pub fn new(config: &Config) -> Self { | |
| 36 | + | Self { | |
| 37 | + | list: ListView::default(), | |
| 38 | + | marks: HashSet::new(), | |
| 39 | + | lyrics_pane: config.ui.lyrics_pane, | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | pub fn lyrics_pane_visible(&self) -> bool { | |
| 44 | + | self.lyrics_pane | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | /// Ids of the marked songs in queue order, or of the selected song. | |
| 48 | + | fn targets(&self, ctx: &Ctx) -> Vec<u32> { | |
| 49 | + | let queue = &ctx.mpd.queue; | |
| 50 | + | if self.marks.is_empty() { | |
| 51 | + | self.list | |
| 52 | + | .selected(queue.len()) | |
| 53 | + | .and_then(|i| queue[i].id) | |
| 54 | + | .into_iter() | |
| 55 | + | .collect() | |
| 56 | + | } else { | |
| 57 | + | queue | |
| 58 | + | .iter() | |
| 59 | + | .filter_map(|s| s.id.filter(|id| self.marks.contains(id))) | |
| 60 | + | .collect() | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn target_files(&self, ctx: &Ctx) -> Vec<String> { | |
| 65 | + | let ids: HashSet<u32> = self.targets(ctx).into_iter().collect(); | |
| 66 | + | ctx.mpd | |
| 67 | + | .queue | |
| 68 | + | .iter() | |
| 69 | + | .filter(|s| s.id.is_some_and(|id| ids.contains(&id))) | |
| 70 | + | .map(|s| s.file.clone()) | |
| 71 | + | .collect() | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | fn play_selected(&self, ctx: &Ctx) { | |
| 75 | + | let queue = &ctx.mpd.queue; | |
| 76 | + | if let Some(id) = self.list.selected(queue.len()).and_then(|i| queue[i].id) { | |
| 77 | + | ctx.client.exec(commands::play_id(id)); | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | fn move_selected(&mut self, down: bool, ctx: &Ctx) { | |
| 82 | + | let len = ctx.mpd.queue.len(); | |
| 83 | + | let Some(index) = self.list.selected(len) else { | |
| 84 | + | return; | |
| 85 | + | }; | |
| 86 | + | let target = if down { | |
| 87 | + | index + 1 | |
| 88 | + | } else { | |
| 89 | + | index.wrapping_sub(1) | |
| 90 | + | }; | |
| 91 | + | if target >= len { | |
| 92 | + | return; | |
| 93 | + | } | |
| 94 | + | ctx.client | |
| 95 | + | .exec(commands::move_pos(index as u32, target as u32)); | |
| 96 | + | self.list.select(target, len); | |
| 97 | + | } | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | impl Screen for QueueScreen { | |
| 101 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 102 | + | let area = if self.lyrics_pane { | |
| 103 | + | let width = ctx.config.ui.lyrics_pane_width; | |
| 104 | + | let [queue, lyrics] = Layout::horizontal([ | |
| 105 | + | Constraint::Percentage(100 - width), | |
| 106 | + | Constraint::Percentage(width), | |
| 107 | + | ]) | |
| 108 | + | .areas(area); | |
| 109 | + | draw_lyrics(frame, lyrics, ctx, false); | |
| 110 | + | queue | |
| 111 | + | } else { | |
| 112 | + | area | |
| 113 | + | }; | |
| 114 | + | ||
| 115 | + | let theme = ctx.theme(); | |
| 116 | + | let queue = &ctx.mpd.queue; | |
| 117 | + | let columns = &ctx.config.ui.queue_columns; | |
| 118 | + | let total: Duration = queue.iter().filter_map(|s| s.duration).sum(); | |
| 119 | + | let title = Line::from(vec![ | |
| 120 | + | Span::raw(" Queue "), | |
| 121 | + | Span::styled( | |
| 122 | + | format!("{} songs · {} ", queue.len(), format_duration(total)), | |
| 123 | + | theme.dim(), | |
| 124 | + | ), | |
| 125 | + | ]); | |
| 126 | + | let mut widths = vec![Constraint::Length(1)]; | |
| 127 | + | widths.extend(columns.iter().map(|c| match c.field { | |
| 128 | + | ColumnField::Duration => Constraint::Length(c.width.max(5)), | |
| 129 | + | ColumnField::Track | ColumnField::Disc => Constraint::Length(c.width.max(2)), | |
| 130 | + | _ => Constraint::Fill(c.width), | |
| 131 | + | })); | |
| 132 | + | let header = Row::new( | |
| 133 | + | std::iter::once(Cell::from("")) | |
| 134 | + | .chain(columns.iter().map(|c| Cell::from(c.field.title()))), | |
| 135 | + | ) | |
| 136 | + | .style(theme.dim().add_modifier(Modifier::BOLD)); | |
| 137 | + | ||
| 138 | + | if queue.is_empty() { | |
| 139 | + | let block = ui::pane(title, true, theme); | |
| 140 | + | let inner = block.inner(area); | |
| 141 | + | frame.render_widget(block, area); | |
| 142 | + | ui::draw_placeholder( | |
| 143 | + | frame, | |
| 144 | + | inner, | |
| 145 | + | &[ | |
| 146 | + | "The queue is empty.", | |
| 147 | + | "", | |
| 148 | + | "Add songs from the Browser (2) or the Library (4).", | |
| 149 | + | ], | |
| 150 | + | theme, | |
| 151 | + | ); | |
| 152 | + | return; | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | let marks = &self.marks; | |
| 156 | + | self.list.render( | |
| 157 | + | frame, | |
| 158 | + | area, | |
| 159 | + | ui::pane(title, true, theme), | |
| 160 | + | Some(header), | |
| 161 | + | &widths, | |
| 162 | + | queue.len(), | |
| 163 | + | true, | |
| 164 | + | theme, | |
| 165 | + | |i| { | |
| 166 | + | let song = &queue[i]; | |
| 167 | + | let marked = song.id.is_some_and(|id| marks.contains(&id)); | |
| 168 | + | let (marker, style) = if ctx.mpd.is_current(song) { | |
| 169 | + | ("▶", theme.playing()) | |
| 170 | + | } else if marked { | |
| 171 | + | ("●", theme.marked()) | |
| 172 | + | } else { | |
| 173 | + | (" ", theme.text()) | |
| 174 | + | }; | |
| 175 | + | let style = if marked { | |
| 176 | + | style.patch(theme.marked()) | |
| 177 | + | } else { | |
| 178 | + | style | |
| 179 | + | }; | |
| 180 | + | Row::new( | |
| 181 | + | std::iter::once(Cell::from(Span::styled(marker, style))).chain( | |
| 182 | + | columns | |
| 183 | + | .iter() | |
| 184 | + | .map(|c| Cell::from(ui::song_field(song, c.field))), | |
| 185 | + | ), | |
| 186 | + | ) | |
| 187 | + | .style(style) | |
| 188 | + | }, | |
| 189 | + | ); | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 193 | + | let len = ctx.mpd.queue.len(); | |
| 194 | + | if self.list.navigate(action, len) { | |
| 195 | + | return true; | |
| 196 | + | } | |
| 197 | + | match action { | |
| 198 | + | Action::Activate | Action::Right => self.play_selected(ctx), | |
| 199 | + | Action::Delete | Action::AddToQueue if self.marks.is_empty() => { | |
| 200 | + | // By position, so that repeated presses delete successive rows | |
| 201 | + | // even before the queue has been refreshed. | |
| 202 | + | if let Some(index) = self.list.selected(len) { | |
| 203 | + | ctx.client.exec(commands::delete_pos(index as u32)); | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | Action::Delete | Action::AddToQueue => { | |
| 207 | + | let ids = self.targets(ctx); | |
| 208 | + | ctx.client | |
| 209 | + | .exec_all(ids.into_iter().map(commands::delete_id).collect()); | |
| 210 | + | self.marks.clear(); | |
| 211 | + | } | |
| 212 | + | Action::MoveItemUp => self.move_selected(false, ctx), | |
| 213 | + | Action::MoveItemDown => self.move_selected(true, ctx), | |
| 214 | + | Action::ClearQueue if len > 0 => { | |
| 215 | + | ctx.popup(ConfirmPopup::new( | |
| 216 | + | format!("Remove all {len} songs from the queue?"), | |
| 217 | + | vec![commands::clear()], | |
| 218 | + | )); | |
| 219 | + | } | |
| 220 | + | Action::ShuffleQueue => ctx.client.exec(commands::shuffle()), | |
| 221 | + | Action::SaveQueue if len > 0 => { | |
| 222 | + | ctx.popup(InputPopup::new( | |
| 223 | + | "Save queue as", | |
| 224 | + | "", | |
| 225 | + | InputPurpose::SaveQueue, | |
| 226 | + | )); | |
| 227 | + | } | |
| 228 | + | Action::JumpToCurrent => match ctx.mpd.status.song { | |
| 229 | + | Some(pos) => self.list.select(pos as usize, len), | |
| 230 | + | None => ctx.notify("Nothing is playing"), | |
| 231 | + | }, | |
| 232 | + | Action::ToggleLyricsPane => self.lyrics_pane = !self.lyrics_pane, | |
| 233 | + | Action::ToggleMark => { | |
| 234 | + | if let Some(id) = self.list.selected(len).and_then(|i| ctx.mpd.queue[i].id) { | |
| 235 | + | if !self.marks.remove(&id) { | |
| 236 | + | self.marks.insert(id); | |
| 237 | + | } | |
| 238 | + | self.list.navigate(Action::Down, len); | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | Action::ClearMarks => self.marks.clear(), | |
| 242 | + | Action::AddToPlaylist => { | |
| 243 | + | let files = self.target_files(ctx); | |
| 244 | + | if !files.is_empty() { | |
| 245 | + | let popup = AddToPlaylistPopup::new(Selection::Uris(files), ctx); | |
| 246 | + | ctx.popup(popup); | |
| 247 | + | } | |
| 248 | + | } | |
| 249 | + | _ => return false, | |
| 250 | + | } | |
| 251 | + | true | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 255 | + | if let MpdEvent::Reply(Reply::QueueChanges { .. }) = event | |
| 256 | + | && !self.marks.is_empty() | |
| 257 | + | { | |
| 258 | + | let ids: HashSet<u32> = ctx.mpd.queue.iter().filter_map(|s| s.id).collect(); | |
| 259 | + | self.marks.retain(|id| ids.contains(id)); | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 264 | + | // Start on the playing song the first time. | |
| 265 | + | if self.list.selected(ctx.mpd.queue.len()) == Some(0) | |
| 266 | + | && let Some(pos) = ctx.mpd.status.song | |
| 267 | + | { | |
| 268 | + | self.list.select(pos as usize, ctx.mpd.queue.len()); | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 273 | + | if let Some(Click::Activate(_)) = self.list.mouse(event, ctx.mpd.queue.len()) { | |
| 274 | + | self.play_selected(ctx); | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | fn search(&mut self, query: &str, ctx: &Ctx) -> bool { | |
| 279 | + | let texts: Vec<String> = ctx.mpd.queue.iter().map(ui::song_search_text).collect(); | |
| 280 | + | self.list | |
| 281 | + | .search(query, texts.iter().map(String::as_str), texts.len()); | |
| 282 | + | true | |
| 283 | + | } | |
| 284 | + | } | |
| @@ -0,0 +1,469 @@ | |||
| 1 | + | //! Screen 5: editing tags of local files. | |
| 2 | + | //! | |
| 3 | + | //! Folders on the left, their songs in the middle, the tags of the selected | |
| 4 | + | //! (or all marked) songs on the right. Edits stay pending until saved. | |
| 5 | + | ||
| 6 | + | use std::collections::{BTreeMap, HashMap, HashSet}; | |
| 7 | + | ||
| 8 | + | use crossterm::event::MouseEvent; | |
| 9 | + | use ratatui::{ | |
| 10 | + | Frame, | |
| 11 | + | layout::{Constraint, Layout, Position, Rect}, | |
| 12 | + | text::{Line, Span}, | |
| 13 | + | widgets::{Cell, Row}, | |
| 14 | + | }; | |
| 15 | + | ||
| 16 | + | use super::{Ctx, Effect, Screen, ScreenMessage}; | |
| 17 | + | use crate::{ | |
| 18 | + | action::Action, | |
| 19 | + | mpd::{DirEntry, MpdEvent, Query, Reply, Song, Subsystem}, | |
| 20 | + | popups::{InputPopup, InputPurpose}, | |
| 21 | + | tags::{TagField, TagWrite}, | |
| 22 | + | ui::{ | |
| 23 | + | self, | |
| 24 | + | list::{Click, ListView}, | |
| 25 | + | }, | |
| 26 | + | }; | |
| 27 | + | ||
| 28 | + | #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] | |
| 29 | + | enum Pane { | |
| 30 | + | #[default] | |
| 31 | + | Folders, | |
| 32 | + | Songs, | |
| 33 | + | Fields, | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | #[derive(Debug, Default)] | |
| 37 | + | pub struct TagEditorScreen { | |
| 38 | + | focus: Pane, | |
| 39 | + | path: String, | |
| 40 | + | stack: Vec<(String, usize)>, | |
| 41 | + | restore: Option<usize>, | |
| 42 | + | loaded: bool, | |
| 43 | + | folders: Vec<String>, | |
| 44 | + | songs: Vec<Song>, | |
| 45 | + | folder_list: ListView, | |
| 46 | + | song_list: ListView, | |
| 47 | + | field_list: ListView, | |
| 48 | + | /// Marked songs by index. | |
| 49 | + | marks: HashSet<usize>, | |
| 50 | + | /// Pending edits by file. | |
| 51 | + | pending: HashMap<String, BTreeMap<TagField, String>>, | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | impl TagEditorScreen { | |
| 55 | + | fn has_parent(&self) -> bool { | |
| 56 | + | !self.path.is_empty() | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Rows of the folder pane: `..` (if not at the root), then folders. | |
| 60 | + | fn folder_rows(&self) -> usize { | |
| 61 | + | self.folders.len() + usize::from(self.has_parent()) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | fn load(&mut self, ctx: &Ctx) { | |
| 65 | + | self.loaded = false; | |
| 66 | + | ctx.client.query(Query::Directory(self.path.clone())); | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | fn open_selected_folder(&mut self, ctx: &Ctx) { | |
| 70 | + | let Some(index) = self.folder_list.selected(self.folder_rows()) else { | |
| 71 | + | return; | |
| 72 | + | }; | |
| 73 | + | if self.has_parent() && index == 0 { | |
| 74 | + | self.back(ctx); | |
| 75 | + | return; | |
| 76 | + | } | |
| 77 | + | let folder = self.folders[index - usize::from(self.has_parent())].clone(); | |
| 78 | + | self.stack | |
| 79 | + | .push((std::mem::replace(&mut self.path, folder), index)); | |
| 80 | + | self.reset_lists(); | |
| 81 | + | self.load(ctx); | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | fn back(&mut self, ctx: &Ctx) { | |
| 85 | + | if let Some((parent, index)) = self.stack.pop() { | |
| 86 | + | self.path = parent; | |
| 87 | + | self.reset_lists(); | |
| 88 | + | self.restore = Some(index); | |
| 89 | + | self.load(ctx); | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | fn reset_lists(&mut self) { | |
| 94 | + | self.folder_list.reset(); | |
| 95 | + | self.song_list.reset(); | |
| 96 | + | self.marks.clear(); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | /// Indices of the songs an edit applies to: marked ones, or the selected. | |
| 100 | + | fn targets(&self) -> Vec<usize> { | |
| 101 | + | if self.marks.is_empty() { | |
| 102 | + | self.song_list | |
| 103 | + | .selected(self.songs.len()) | |
| 104 | + | .into_iter() | |
| 105 | + | .collect() | |
| 106 | + | } else { | |
| 107 | + | let mut marks: Vec<_> = self.marks.iter().copied().collect(); | |
| 108 | + | marks.sort_unstable(); | |
| 109 | + | marks | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | /// The (possibly pending) value of `field` for a song. | |
| 114 | + | fn value<'a>(&'a self, song: &'a Song, field: TagField) -> &'a str { | |
| 115 | + | self.pending | |
| 116 | + | .get(&song.file) | |
| 117 | + | .and_then(|changes| changes.get(&field)) | |
| 118 | + | .map(String::as_str) | |
| 119 | + | .unwrap_or_else(|| song.tag(field.mpd_tag()).unwrap_or_default()) | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | /// The common value of `field` across the targets, or `None` if they differ. | |
| 123 | + | fn shared_value(&self, field: TagField) -> Option<String> { | |
| 124 | + | let mut values = self | |
| 125 | + | .targets() | |
| 126 | + | .into_iter() | |
| 127 | + | .map(|i| self.value(&self.songs[i], field)); | |
| 128 | + | let first = values.next()?; | |
| 129 | + | values.all(|v| v == first).then(|| first.to_owned()) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | fn edit_field(&mut self, ctx: &mut Ctx) { | |
| 133 | + | if self.targets().is_empty() { | |
| 134 | + | return; | |
| 135 | + | } | |
| 136 | + | let Some(index) = self.field_list.selected(TagField::ALL.len()) else { | |
| 137 | + | return; | |
| 138 | + | }; | |
| 139 | + | let field = TagField::ALL[index]; | |
| 140 | + | let count = self.targets().len(); | |
| 141 | + | let title = if count > 1 { | |
| 142 | + | format!("{} ({count} songs)", field.label()) | |
| 143 | + | } else { | |
| 144 | + | field.label().to_owned() | |
| 145 | + | }; | |
| 146 | + | let initial = self.shared_value(field).unwrap_or_default(); | |
| 147 | + | ctx.popup(InputPopup::new( | |
| 148 | + | title, | |
| 149 | + | &initial, | |
| 150 | + | InputPurpose::TagValue(field), | |
| 151 | + | )); | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | fn save(&mut self, ctx: &mut Ctx) { | |
| 155 | + | if self.pending.is_empty() { | |
| 156 | + | ctx.notify("No pending tag changes"); | |
| 157 | + | return; | |
| 158 | + | } | |
| 159 | + | let Some(music_dir) = ctx.mpd.music_dir.clone() else { | |
| 160 | + | ctx.error("Set mpd.music_directory in the config to write tags"); | |
| 161 | + | return; | |
| 162 | + | }; | |
| 163 | + | let writes = self | |
| 164 | + | .pending | |
| 165 | + | .drain() | |
| 166 | + | .map(|(uri, changes)| TagWrite { | |
| 167 | + | path: music_dir.join(&uri), | |
| 168 | + | uri, | |
| 169 | + | changes, | |
| 170 | + | }) | |
| 171 | + | .collect(); | |
| 172 | + | ctx.effects.push(Effect::WriteTags(writes)); | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | impl Screen for TagEditorScreen { | |
| 177 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 178 | + | let theme = ctx.theme(); | |
| 179 | + | if ctx.mpd.music_dir.is_none() { | |
| 180 | + | let block = ui::pane(" Tag editor ", true, theme); | |
| 181 | + | let inner = block.inner(area); | |
| 182 | + | frame.render_widget(block, area); | |
| 183 | + | ui::draw_placeholder( | |
| 184 | + | frame, | |
| 185 | + | inner, | |
| 186 | + | &[ | |
| 187 | + | "The tag editor needs access to the music files.", | |
| 188 | + | "", | |
| 189 | + | "Set `music_directory` in the [mpd] section of the config,", | |
| 190 | + | "or connect to MPD through its local Unix socket.", | |
| 191 | + | ], | |
| 192 | + | theme, | |
| 193 | + | ); | |
| 194 | + | return; | |
| 195 | + | } | |
| 196 | + | let [folders_area, songs_area, fields_area] = Layout::horizontal([ | |
| 197 | + | Constraint::Percentage(25), | |
| 198 | + | Constraint::Percentage(40), | |
| 199 | + | Constraint::Percentage(35), | |
| 200 | + | ]) | |
| 201 | + | .areas(area); | |
| 202 | + | ||
| 203 | + | let parent = self.has_parent(); | |
| 204 | + | let folders = &self.folders; | |
| 205 | + | let title = Line::from(vec![ | |
| 206 | + | Span::raw(" Folders "), | |
| 207 | + | Span::styled(format!("/{} ", self.path), theme.dim()), | |
| 208 | + | ]); | |
| 209 | + | self.folder_list.render( | |
| 210 | + | frame, | |
| 211 | + | folders_area, | |
| 212 | + | ui::pane(title, self.focus == Pane::Folders, theme), | |
| 213 | + | None, | |
| 214 | + | &[Constraint::Fill(1)], | |
| 215 | + | folders.len() + usize::from(parent), | |
| 216 | + | self.focus == Pane::Folders, | |
| 217 | + | theme, | |
| 218 | + | |i| { | |
| 219 | + | let name = if parent && i == 0 { | |
| 220 | + | "..".to_owned() | |
| 221 | + | } else { | |
| 222 | + | let path = &folders[i - usize::from(parent)]; | |
| 223 | + | format!("▸ {}/", path.rsplit('/').next().unwrap_or(path)) | |
| 224 | + | }; | |
| 225 | + | Row::new([Cell::from(name)]).style(theme.directory()) | |
| 226 | + | }, | |
| 227 | + | ); | |
| 228 | + | ||
| 229 | + | let songs = &self.songs; | |
| 230 | + | let pending = &self.pending; | |
| 231 | + | let marks = &self.marks; | |
| 232 | + | let title = if pending.is_empty() { | |
| 233 | + | Line::from(" Songs ") | |
| 234 | + | } else { | |
| 235 | + | Line::from(vec![ | |
| 236 | + | Span::raw(" Songs "), | |
| 237 | + | Span::styled(format!("{} unsaved ", pending.len()), theme.marked()), | |
| 238 | + | ]) | |
| 239 | + | }; | |
| 240 | + | let block = ui::pane(title, self.focus == Pane::Songs, theme); | |
| 241 | + | if songs.is_empty() { | |
| 242 | + | let inner = block.inner(songs_area); | |
| 243 | + | frame.render_widget(block, songs_area); | |
| 244 | + | let text = if self.loaded { | |
| 245 | + | "No songs in this folder" | |
| 246 | + | } else { | |
| 247 | + | "Loading…" | |
| 248 | + | }; | |
| 249 | + | ui::draw_placeholder(frame, inner, &[text], theme); | |
| 250 | + | } else { | |
| 251 | + | self.song_list.render( | |
| 252 | + | frame, | |
| 253 | + | songs_area, | |
| 254 | + | block, | |
| 255 | + | None, | |
| 256 | + | &[Constraint::Length(1), Constraint::Fill(1)], | |
| 257 | + | songs.len(), | |
| 258 | + | self.focus == Pane::Songs, | |
| 259 | + | theme, | |
| 260 | + | |i| { | |
| 261 | + | let song = &songs[i]; | |
| 262 | + | let changed = if pending.contains_key(&song.file) { | |
| 263 | + | "*" | |
| 264 | + | } else { | |
| 265 | + | " " | |
| 266 | + | }; | |
| 267 | + | let style = if marks.contains(&i) { | |
| 268 | + | theme.marked() | |
| 269 | + | } else { | |
| 270 | + | theme.text() | |
| 271 | + | }; | |
| 272 | + | Row::new([ | |
| 273 | + | Cell::from(Span::styled(changed, theme.marked())), | |
| 274 | + | Cell::from(song.file_name().to_owned()), | |
| 275 | + | ]) | |
| 276 | + | .style(style) | |
| 277 | + | }, | |
| 278 | + | ); | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | let targets = self.targets(); | |
| 282 | + | let title = match targets.len() { | |
| 283 | + | 0 => " Tags ".to_owned(), | |
| 284 | + | 1 => " Tags ".to_owned(), | |
| 285 | + | n => format!(" Tags · {n} songs "), | |
| 286 | + | }; | |
| 287 | + | let values: Vec<(TagField, Option<String>)> = TagField::ALL | |
| 288 | + | .iter() | |
| 289 | + | .map(|&f| (f, self.shared_value(f))) | |
| 290 | + | .collect(); | |
| 291 | + | let has_targets = !targets.is_empty(); | |
| 292 | + | self.field_list.render( | |
| 293 | + | frame, | |
| 294 | + | fields_area, | |
| 295 | + | ui::pane(title, self.focus == Pane::Fields, theme), | |
| 296 | + | None, | |
| 297 | + | &[Constraint::Length(13), Constraint::Fill(1)], | |
| 298 | + | values.len(), | |
| 299 | + | self.focus == Pane::Fields, | |
| 300 | + | theme, | |
| 301 | + | |i| { | |
| 302 | + | let (field, value) = &values[i]; | |
| 303 | + | let value = match value { | |
| 304 | + | _ if !has_targets => Span::raw(""), | |
| 305 | + | Some(v) => Span::styled(v.clone(), theme.text()), | |
| 306 | + | None => Span::styled("<various>", theme.dim()), | |
| 307 | + | }; | |
| 308 | + | Row::new([ | |
| 309 | + | Cell::from(Span::styled(field.label(), theme.dim())), | |
| 310 | + | Cell::from(value), | |
| 311 | + | ]) | |
| 312 | + | }, | |
| 313 | + | ); | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | fn handle_action(&mut self, action: Action, ctx: &mut Ctx) -> bool { | |
| 317 | + | match action { | |
| 318 | + | Action::SaveTags => { | |
| 319 | + | self.save(ctx); | |
| 320 | + | return true; | |
| 321 | + | } | |
| 322 | + | Action::RevertTags => { | |
| 323 | + | self.pending.clear(); | |
| 324 | + | ctx.notify("Discarded pending tag changes"); | |
| 325 | + | return true; | |
| 326 | + | } | |
| 327 | + | _ => {} | |
| 328 | + | } | |
| 329 | + | match self.focus { | |
| 330 | + | Pane::Folders => { | |
| 331 | + | if self.folder_list.navigate(action, self.folder_rows()) { | |
| 332 | + | return true; | |
| 333 | + | } | |
| 334 | + | match action { | |
| 335 | + | Action::Activate => self.open_selected_folder(ctx), | |
| 336 | + | Action::Left => self.back(ctx), | |
| 337 | + | Action::Right if !self.songs.is_empty() => self.focus = Pane::Songs, | |
| 338 | + | _ => return false, | |
| 339 | + | } | |
| 340 | + | } | |
| 341 | + | Pane::Songs => { | |
| 342 | + | if self.song_list.navigate(action, self.songs.len()) { | |
| 343 | + | return true; | |
| 344 | + | } | |
| 345 | + | match action { | |
| 346 | + | Action::Left => self.focus = Pane::Folders, | |
| 347 | + | Action::Right | Action::Activate => self.focus = Pane::Fields, | |
| 348 | + | Action::ToggleMark | Action::AddToQueue => { | |
| 349 | + | if let Some(i) = self.song_list.selected(self.songs.len()) { | |
| 350 | + | if !self.marks.remove(&i) { | |
| 351 | + | self.marks.insert(i); | |
| 352 | + | } | |
| 353 | + | self.song_list.navigate(Action::Down, self.songs.len()); | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | Action::ClearMarks => self.marks.clear(), | |
| 357 | + | _ => return false, | |
| 358 | + | } | |
| 359 | + | } | |
| 360 | + | Pane::Fields => { | |
| 361 | + | if self.field_list.navigate(action, TagField::ALL.len()) { | |
| 362 | + | return true; | |
| 363 | + | } | |
| 364 | + | match action { | |
| 365 | + | Action::Left => self.focus = Pane::Songs, | |
| 366 | + | Action::Activate | Action::Right => self.edit_field(ctx), | |
| 367 | + | _ => return false, | |
| 368 | + | } | |
| 369 | + | } | |
| 370 | + | } | |
| 371 | + | true | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | fn on_message(&mut self, message: ScreenMessage, _ctx: &mut Ctx) { | |
| 375 | + | let ScreenMessage::TagValue { field, value } = message; | |
| 376 | + | for index in self.targets() { | |
| 377 | + | let song = &self.songs[index]; | |
| 378 | + | let original = song.tag(field.mpd_tag()).unwrap_or_default(); | |
| 379 | + | let changes = self.pending.entry(song.file.clone()).or_default(); | |
| 380 | + | if value == original { | |
| 381 | + | changes.remove(&field); | |
| 382 | + | } else { | |
| 383 | + | changes.insert(field, value.clone()); | |
| 384 | + | } | |
| 385 | + | if changes.is_empty() { | |
| 386 | + | self.pending.remove(&song.file); | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | fn on_mpd(&mut self, event: &MpdEvent, ctx: &mut Ctx) { | |
| 392 | + | match event { | |
| 393 | + | MpdEvent::Reply(Reply::Directory { path, entries }) if *path == self.path => { | |
| 394 | + | let mut folders = Vec::new(); | |
| 395 | + | let mut songs = Vec::new(); | |
| 396 | + | for entry in entries { | |
| 397 | + | match entry { | |
| 398 | + | DirEntry::Directory(dir) => folders.push(dir.clone()), | |
| 399 | + | DirEntry::Song(song) => songs.push(song.clone()), | |
| 400 | + | DirEntry::Playlist(_) => {} | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | folders.sort_by_cached_key(|f| f.to_lowercase()); | |
| 404 | + | self.folders = folders; | |
| 405 | + | self.songs = songs; | |
| 406 | + | self.loaded = true; | |
| 407 | + | if let Some(index) = self.restore.take() { | |
| 408 | + | self.folder_list.select(index, self.folder_rows()); | |
| 409 | + | } | |
| 410 | + | } | |
| 411 | + | MpdEvent::Changed(subsystems) | |
| 412 | + | if subsystems.contains(&Subsystem::Database) && self.loaded => | |
| 413 | + | { | |
| 414 | + | // Show freshly written tags. | |
| 415 | + | ctx.client.query(Query::Directory(self.path.clone())); | |
| 416 | + | } | |
| 417 | + | _ => {} | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | fn on_enter(&mut self, ctx: &mut Ctx) { | |
| 422 | + | if !self.loaded { | |
| 423 | + | self.load(ctx); | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | fn on_mouse(&mut self, event: MouseEvent, ctx: &mut Ctx) { | |
| 428 | + | let position = Position::new(event.column, event.row); | |
| 429 | + | if self.folder_list.contains(position) { | |
| 430 | + | self.focus = Pane::Folders; | |
| 431 | + | if let Some(Click::Activate(_)) = self.folder_list.mouse(event, self.folder_rows()) { | |
| 432 | + | self.open_selected_folder(ctx); | |
| 433 | + | } | |
| 434 | + | } else if self.song_list.contains(position) { | |
| 435 | + | self.focus = Pane::Songs; | |
| 436 | + | self.song_list.mouse(event, self.songs.len()); | |
| 437 | + | } else if self.field_list.contains(position) { | |
| 438 | + | self.focus = Pane::Fields; | |
| 439 | + | if let Some(Click::Activate(_)) = self.field_list.mouse(event, TagField::ALL.len()) { | |
| 440 | + | self.edit_field(ctx); | |
| 441 | + | } | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | fn search(&mut self, query: &str, _ctx: &Ctx) -> bool { | |
| 446 | + | match self.focus { | |
| 447 | + | Pane::Folders => { | |
| 448 | + | let mut names: Vec<&str> = Vec::new(); | |
| 449 | + | if self.has_parent() { | |
| 450 | + | names.push(".."); | |
| 451 | + | } | |
| 452 | + | names.extend( | |
| 453 | + | self.folders | |
| 454 | + | .iter() | |
| 455 | + | .map(|f| f.rsplit('/').next().unwrap_or(f)), | |
| 456 | + | ); | |
| 457 | + | let len = names.len(); | |
| 458 | + | self.folder_list.search(query, names.into_iter(), len); | |
| 459 | + | } | |
| 460 | + | Pane::Songs => { | |
| 461 | + | let len = self.songs.len(); | |
| 462 | + | self.song_list | |
| 463 | + | .search(query, self.songs.iter().map(Song::file_name), len); | |
| 464 | + | } | |
| 465 | + | Pane::Fields => return false, | |
| 466 | + | } | |
| 467 | + | true | |
| 468 | + | } | |
| 469 | + | } | |
| @@ -0,0 +1,107 @@ | |||
| 1 | + | //! Screen 6: spectrum visualizer fed by MPD's fifo output. | |
| 2 | + | ||
| 3 | + | use std::sync::Arc; | |
| 4 | + | ||
| 5 | + | use ratatui::{Frame, layout::Rect}; | |
| 6 | + | use tokio::sync::watch; | |
| 7 | + | ||
| 8 | + | use super::{Ctx, Screen}; | |
| 9 | + | use crate::{ | |
| 10 | + | action::Action, | |
| 11 | + | ui, | |
| 12 | + | visualizer::{Bars, Spectrum}, | |
| 13 | + | }; | |
| 14 | + | ||
| 15 | + | const EIGHTHS: [&str; 9] = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]; | |
| 16 | + | ||
| 17 | + | pub struct VisualizerScreen { | |
| 18 | + | spectrum: watch::Receiver<Arc<Spectrum>>, | |
| 19 | + | bars: Bars, | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | impl VisualizerScreen { | |
| 23 | + | pub fn new(spectrum: watch::Receiver<Arc<Spectrum>>) -> Self { | |
| 24 | + | Self { | |
| 25 | + | spectrum, | |
| 26 | + | bars: Bars::default(), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | impl Screen for VisualizerScreen { | |
| 32 | + | fn draw(&mut self, frame: &mut Frame, area: Rect, ctx: &Ctx) { | |
| 33 | + | let theme = ctx.theme(); | |
| 34 | + | let config = &ctx.config.visualizer; | |
| 35 | + | let title = match &ctx.mpd.current { | |
| 36 | + | Some(song) => format!(" Visualizer · {} ", ui::song_label(song)), | |
| 37 | + | None => " Visualizer ".to_owned(), | |
| 38 | + | }; | |
| 39 | + | let block = ui::pane(title, true, theme); | |
| 40 | + | let inner = block.inner(area); | |
| 41 | + | frame.render_widget(block, area); | |
| 42 | + | ||
| 43 | + | let spectrum = Arc::clone(&self.spectrum.borrow_and_update()); | |
| 44 | + | if let Some(error) = &spectrum.error { | |
| 45 | + | let fifo = config.fifo.display().to_string(); | |
| 46 | + | ui::draw_placeholder( | |
| 47 | + | frame, | |
| 48 | + | inner, | |
| 49 | + | &[ | |
| 50 | + | error.as_str(), | |
| 51 | + | "", | |
| 52 | + | "Add a fifo output to mpd.conf and restart MPD:", | |
| 53 | + | "", | |
| 54 | + | "audio_output {", | |
| 55 | + | " type \"fifo\"", | |
| 56 | + | " name \"Visualizer feed\"", | |
| 57 | + | &format!(" path \"{fifo}\""), | |
| 58 | + | " format \"44100:16:2\"", | |
| 59 | + | "}", | |
| 60 | + | ], | |
| 61 | + | theme, | |
| 62 | + | ); | |
| 63 | + | return; | |
| 64 | + | } | |
| 65 | + | if inner.width == 0 || inner.height == 0 { | |
| 66 | + | return; | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | let stride = config.bar_width + config.bar_gap; | |
| 70 | + | let count = usize::from((inner.width + config.bar_gap) / stride); | |
| 71 | + | let heights = self.bars.update( | |
| 72 | + | &spectrum, | |
| 73 | + | count, | |
| 74 | + | config.min_frequency, | |
| 75 | + | config.max_frequency, | |
| 76 | + | config.falloff, | |
| 77 | + | ); | |
| 78 | + | let colors = &theme.visualizer; | |
| 79 | + | let rows = inner.height; | |
| 80 | + | // Center the bars horizontally. | |
| 81 | + | let used = (count as u16 * stride).saturating_sub(config.bar_gap); | |
| 82 | + | let left = inner.x + inner.width.saturating_sub(used) / 2; | |
| 83 | + | let buffer = frame.buffer_mut(); | |
| 84 | + | for (i, &height) in heights.iter().enumerate() { | |
| 85 | + | let eighths = (height * f32::from(rows) * 8.0).round() as u32; | |
| 86 | + | for row in 0..rows { | |
| 87 | + | let filled = eighths.saturating_sub(u32::from(row) * 8).min(8) as usize; | |
| 88 | + | if filled == 0 { | |
| 89 | + | break; | |
| 90 | + | } | |
| 91 | + | let y = inner.bottom() - 1 - row; | |
| 92 | + | let level = usize::from(row) * colors.len() / usize::from(rows); | |
| 93 | + | let color = colors[level.min(colors.len() - 1)]; | |
| 94 | + | for dx in 0..config.bar_width { | |
| 95 | + | let x = left + i as u16 * stride + dx; | |
| 96 | + | if x < inner.right() { | |
| 97 | + | buffer[(x, y)].set_symbol(EIGHTHS[filled]).set_fg(color); | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | fn handle_action(&mut self, _action: Action, _ctx: &mut Ctx) -> bool { | |
| 105 | + | false | |
| 106 | + | } | |
| 107 | + | } | |
| @@ -0,0 +1,177 @@ | |||
| 1 | + | //! The client-side mirror of the server state. | |
| 2 | + | ||
| 3 | + | use std::{ | |
| 4 | + | collections::HashMap, | |
| 5 | + | path::PathBuf, | |
| 6 | + | time::{Duration, Instant}, | |
| 7 | + | }; | |
| 8 | + | ||
| 9 | + | use crate::mpd::{PlayState, PlaylistInfo, Song, Status}; | |
| 10 | + | ||
| 11 | + | #[derive(Debug)] | |
| 12 | + | pub struct MpdState { | |
| 13 | + | pub connected: bool, | |
| 14 | + | pub status: Status, | |
| 15 | + | /// When `status` was received, for interpolating the elapsed time. | |
| 16 | + | status_at: Instant, | |
| 17 | + | pub current: Option<Song>, | |
| 18 | + | pub queue: Vec<Song>, | |
| 19 | + | /// The queue version `queue` reflects; `None` until first loaded. | |
| 20 | + | queue_version: Option<u32>, | |
| 21 | + | /// Queue ids by file, for "is this song queued?" checks. | |
| 22 | + | queued: HashMap<String, u32>, | |
| 23 | + | pub playlists: Vec<PlaylistInfo>, | |
| 24 | + | /// Bumped whenever the music database changes, to invalidate caches. | |
| 25 | + | pub db_version: u64, | |
| 26 | + | pub music_dir: Option<PathBuf>, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl Default for MpdState { | |
| 30 | + | fn default() -> Self { | |
| 31 | + | Self { | |
| 32 | + | connected: false, | |
| 33 | + | status: Status::default(), | |
| 34 | + | status_at: Instant::now(), | |
| 35 | + | current: None, | |
| 36 | + | queue: Vec::new(), | |
| 37 | + | queue_version: None, | |
| 38 | + | queued: HashMap::new(), | |
| 39 | + | playlists: Vec::new(), | |
| 40 | + | db_version: 0, | |
| 41 | + | music_dir: None, | |
| 42 | + | } | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | impl MpdState { | |
| 47 | + | /// Elapsed time of the current song, interpolated while playing. | |
| 48 | + | pub fn elapsed(&self) -> Duration { | |
| 49 | + | let elapsed = match self.status.state { | |
| 50 | + | PlayState::Play => self.status.elapsed + self.status_at.elapsed(), | |
| 51 | + | _ => self.status.elapsed, | |
| 52 | + | }; | |
| 53 | + | match self.status.duration { | |
| 54 | + | Some(duration) => elapsed.min(duration), | |
| 55 | + | None => elapsed, | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | pub fn is_playing(&self) -> bool { | |
| 60 | + | self.status.state == PlayState::Play | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// Stores a fresh status. Returns whether the current song changed. | |
| 64 | + | pub fn set_status(&mut self, status: Status, current: Option<Song>) -> bool { | |
| 65 | + | let changed = self.current.as_ref().map(|s| (&s.file, s.id)) | |
| 66 | + | != current.as_ref().map(|s| (&s.file, s.id)); | |
| 67 | + | self.status = status; | |
| 68 | + | self.status_at = Instant::now(); | |
| 69 | + | self.current = current; | |
| 70 | + | changed | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// The queue version to request changes from, if the queue is stale. | |
| 74 | + | pub fn stale_queue_since(&self) -> Option<u32> { | |
| 75 | + | match self.queue_version { | |
| 76 | + | None => Some(0), | |
| 77 | + | Some(version) if version != self.status.playlist_version => Some(version), | |
| 78 | + | Some(_) => None, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Applies the result of `plchanges`. | |
| 83 | + | pub fn apply_queue_changes(&mut self, status: &Status, since: u32, changes: Vec<Song>) { | |
| 84 | + | if since == 0 { | |
| 85 | + | self.queue.clear(); | |
| 86 | + | } else if self.queue_version != Some(since) { | |
| 87 | + | // A diff against a version we no longer have. | |
| 88 | + | return; | |
| 89 | + | } | |
| 90 | + | let length = status.playlist_length as usize; | |
| 91 | + | self.queue.resize_with(length, Song::default); | |
| 92 | + | for song in changes { | |
| 93 | + | if let Some(slot) = song.pos.and_then(|pos| self.queue.get_mut(pos as usize)) { | |
| 94 | + | *slot = song; | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | self.queue_version = Some(status.playlist_version); | |
| 98 | + | self.queued.clear(); | |
| 99 | + | for song in &self.queue { | |
| 100 | + | if let Some(id) = song.id { | |
| 101 | + | self.queued.entry(song.file.clone()).or_insert(id); | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | /// Forgets everything, e.g. after a reconnect. | |
| 107 | + | pub fn reset_queue(&mut self) { | |
| 108 | + | self.queue_version = None; | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// The queue id of `file`, if it is queued. | |
| 112 | + | pub fn queued_id(&self, file: &str) -> Option<u32> { | |
| 113 | + | self.queued.get(file).copied() | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | pub fn is_queued(&self, file: &str) -> bool { | |
| 117 | + | self.queued.contains_key(file) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | pub fn is_current(&self, song: &Song) -> bool { | |
| 121 | + | song.id.is_some() && song.id == self.status.song_id | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[cfg(test)] | |
| 126 | + | mod tests { | |
| 127 | + | use super::*; | |
| 128 | + | ||
| 129 | + | fn song(file: &str, pos: u32, id: u32) -> Song { | |
| 130 | + | Song { | |
| 131 | + | file: file.into(), | |
| 132 | + | pos: Some(pos), | |
| 133 | + | id: Some(id), | |
| 134 | + | ..Song::default() | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | fn status(version: u32, length: u32) -> Status { | |
| 139 | + | Status { | |
| 140 | + | playlist_version: version, | |
| 141 | + | playlist_length: length, | |
| 142 | + | ..Status::default() | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | #[test] | |
| 147 | + | fn applies_full_and_incremental_changes() { | |
| 148 | + | let mut state = MpdState::default(); | |
| 149 | + | assert_eq!(state.stale_queue_since(), Some(0)); | |
| 150 | + | state.apply_queue_changes( | |
| 151 | + | &status(5, 3), | |
| 152 | + | 0, | |
| 153 | + | vec![song("a", 0, 1), song("b", 1, 2), song("c", 2, 3)], | |
| 154 | + | ); | |
| 155 | + | state.status = status(5, 3); | |
| 156 | + | assert_eq!(state.stale_queue_since(), None); | |
| 157 | + | assert_eq!(state.queued_id("b"), Some(2)); | |
| 158 | + | ||
| 159 | + | // "b" deleted: "c" moves up and the queue shrinks. | |
| 160 | + | state.apply_queue_changes(&status(6, 2), 5, vec![song("c", 1, 3)]); | |
| 161 | + | let files: Vec<_> = state.queue.iter().map(|s| s.file.as_str()).collect(); | |
| 162 | + | assert_eq!(files, ["a", "c"]); | |
| 163 | + | assert!(!state.is_queued("b")); | |
| 164 | + | ||
| 165 | + | // A diff against an unknown version is ignored. | |
| 166 | + | state.apply_queue_changes(&status(9, 1), 7, vec![]); | |
| 167 | + | assert_eq!(state.queue.len(), 2); | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | #[test] | |
| 171 | + | fn detects_song_changes() { | |
| 172 | + | let mut state = MpdState::default(); | |
| 173 | + | assert!(state.set_status(Status::default(), Some(song("a", 0, 1)))); | |
| 174 | + | assert!(!state.set_status(Status::default(), Some(song("a", 0, 1)))); | |
| 175 | + | assert!(state.set_status(Status::default(), None)); | |
| 176 | + | } | |
| 177 | + | } | |
| @@ -0,0 +1,112 @@ | |||
| 1 | + | //! Reading and writing tags of local files for the tag editor. | |
| 2 | + | ||
| 3 | + | use std::{collections::BTreeMap, path::PathBuf}; | |
| 4 | + | ||
| 5 | + | use lofty::{config::WriteOptions, prelude::*, tag::Tag}; | |
| 6 | + | ||
| 7 | + | /// The tags the editor can change. | |
| 8 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] | |
| 9 | + | pub enum TagField { | |
| 10 | + | Title, | |
| 11 | + | Artist, | |
| 12 | + | AlbumArtist, | |
| 13 | + | Album, | |
| 14 | + | Track, | |
| 15 | + | Disc, | |
| 16 | + | Date, | |
| 17 | + | Genre, | |
| 18 | + | Comment, | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | impl TagField { | |
| 22 | + | pub const ALL: [Self; 9] = [ | |
| 23 | + | Self::Title, | |
| 24 | + | Self::Artist, | |
| 25 | + | Self::AlbumArtist, | |
| 26 | + | Self::Album, | |
| 27 | + | Self::Track, | |
| 28 | + | Self::Disc, | |
| 29 | + | Self::Date, | |
| 30 | + | Self::Genre, | |
| 31 | + | Self::Comment, | |
| 32 | + | ]; | |
| 33 | + | ||
| 34 | + | pub fn label(self) -> &'static str { | |
| 35 | + | match self { | |
| 36 | + | Self::Title => "Title", | |
| 37 | + | Self::Artist => "Artist", | |
| 38 | + | Self::AlbumArtist => "Album Artist", | |
| 39 | + | Self::Album => "Album", | |
| 40 | + | Self::Track => "Track", | |
| 41 | + | Self::Disc => "Disc", | |
| 42 | + | Self::Date => "Date", | |
| 43 | + | Self::Genre => "Genre", | |
| 44 | + | Self::Comment => "Comment", | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// The tag name MPD reports for this field. | |
| 49 | + | pub fn mpd_tag(self) -> &'static str { | |
| 50 | + | match self { | |
| 51 | + | Self::Title => "Title", | |
| 52 | + | Self::Artist => "Artist", | |
| 53 | + | Self::AlbumArtist => "AlbumArtist", | |
| 54 | + | Self::Album => "Album", | |
| 55 | + | Self::Track => "Track", | |
| 56 | + | Self::Disc => "Disc", | |
| 57 | + | Self::Date => "Date", | |
| 58 | + | Self::Genre => "Genre", | |
| 59 | + | Self::Comment => "Comment", | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | fn item_key(self) -> ItemKey { | |
| 64 | + | match self { | |
| 65 | + | Self::Title => ItemKey::TrackTitle, | |
| 66 | + | Self::Artist => ItemKey::TrackArtist, | |
| 67 | + | Self::AlbumArtist => ItemKey::AlbumArtist, | |
| 68 | + | Self::Album => ItemKey::AlbumTitle, | |
| 69 | + | Self::Track => ItemKey::TrackNumber, | |
| 70 | + | Self::Disc => ItemKey::DiscNumber, | |
| 71 | + | Self::Date => ItemKey::RecordingDate, | |
| 72 | + | Self::Genre => ItemKey::Genre, | |
| 73 | + | Self::Comment => ItemKey::Comment, | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | /// Pending changes for one file. An empty value removes the tag. | |
| 79 | + | #[derive(Clone, Debug, PartialEq, Eq)] | |
| 80 | + | pub struct TagWrite { | |
| 81 | + | pub path: PathBuf, | |
| 82 | + | /// Library-relative path, for reporting and rescanning. | |
| 83 | + | pub uri: String, | |
| 84 | + | pub changes: BTreeMap<TagField, String>, | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Writes `changes` into the file, creating its primary tag if needed. | |
| 88 | + | pub fn write(write: &TagWrite) -> Result<(), String> { | |
| 89 | + | let describe = |error: &dyn std::fmt::Display| format!("{}: {error}", write.uri); | |
| 90 | + | let mut file = lofty::read_from_path(&write.path).map_err(|e| describe(&e))?; | |
| 91 | + | if file.primary_tag().is_none() { | |
| 92 | + | file.insert_tag(Tag::new(file.primary_tag_type())); | |
| 93 | + | } | |
| 94 | + | let tag = file | |
| 95 | + | .primary_tag_mut() | |
| 96 | + | .ok_or_else(|| format!("{}: cannot create a tag", write.uri))?; | |
| 97 | + | for (field, value) in &write.changes { | |
| 98 | + | let value = value.trim(); | |
| 99 | + | if value.is_empty() { | |
| 100 | + | tag.remove_key(field.item_key()); | |
| 101 | + | } else if !tag.insert_text(field.item_key(), value.to_owned()) { | |
| 102 | + | return Err(format!( | |
| 103 | + | "{}: the {:?} tag cannot hold {}", | |
| 104 | + | write.uri, | |
| 105 | + | tag.tag_type(), | |
| 106 | + | field.label() | |
| 107 | + | )); | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | tag.save_to_path(&write.path, WriteOptions::default()) | |
| 111 | + | .map_err(|e| describe(&e)) | |
| 112 | + | } | |
| @@ -0,0 +1,160 @@ | |||
| 1 | + | //! A single-line text editor. | |
| 2 | + | ||
| 3 | + | use ratatui::{ | |
| 4 | + | Frame, | |
| 5 | + | layout::{Position, Rect}, | |
| 6 | + | text::{Line, Span}, | |
| 7 | + | }; | |
| 8 | + | use unicode_width::UnicodeWidthStr; | |
| 9 | + | ||
| 10 | + | use crate::{action::Action, config::Theme}; | |
| 11 | + | ||
| 12 | + | #[derive(Clone, Debug, Default)] | |
| 13 | + | pub struct LineInput { | |
| 14 | + | text: String, | |
| 15 | + | /// Cursor position as a byte offset on a char boundary. | |
| 16 | + | cursor: usize, | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | /// What an edit did. | |
| 20 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 21 | + | pub enum InputEvent { | |
| 22 | + | Changed, | |
| 23 | + | Moved, | |
| 24 | + | Submit, | |
| 25 | + | Cancel, | |
| 26 | + | Ignored, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | impl LineInput { | |
| 30 | + | pub fn new(text: impl Into<String>) -> Self { | |
| 31 | + | let text = text.into(); | |
| 32 | + | Self { | |
| 33 | + | cursor: text.len(), | |
| 34 | + | text, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | pub fn text(&self) -> &str { | |
| 39 | + | &self.text | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | pub fn insert(&mut self, ch: char) { | |
| 43 | + | self.text.insert(self.cursor, ch); | |
| 44 | + | self.cursor += ch.len_utf8(); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | fn previous_boundary(&self) -> usize { | |
| 48 | + | self.text[..self.cursor] | |
| 49 | + | .char_indices() | |
| 50 | + | .next_back() | |
| 51 | + | .map_or(0, |(i, _)| i) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | fn next_boundary(&self) -> usize { | |
| 55 | + | self.text[self.cursor..] | |
| 56 | + | .chars() | |
| 57 | + | .next() | |
| 58 | + | .map_or(self.cursor, |c| self.cursor + c.len_utf8()) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | /// Applies an input action. | |
| 62 | + | pub fn apply(&mut self, action: Action) -> InputEvent { | |
| 63 | + | match action { | |
| 64 | + | Action::Submit => InputEvent::Submit, | |
| 65 | + | Action::Cancel => InputEvent::Cancel, | |
| 66 | + | Action::Backspace if self.cursor > 0 => { | |
| 67 | + | let start = self.previous_boundary(); | |
| 68 | + | self.text.drain(start..self.cursor); | |
| 69 | + | self.cursor = start; | |
| 70 | + | InputEvent::Changed | |
| 71 | + | } | |
| 72 | + | Action::DeleteChar if self.cursor < self.text.len() => { | |
| 73 | + | let end = self.next_boundary(); | |
| 74 | + | self.text.drain(self.cursor..end); | |
| 75 | + | InputEvent::Changed | |
| 76 | + | } | |
| 77 | + | Action::DeleteWord if self.cursor > 0 => { | |
| 78 | + | let before = &self.text[..self.cursor]; | |
| 79 | + | let trimmed = before.trim_end(); | |
| 80 | + | let start = trimmed.rfind(char::is_whitespace).map_or(0, |i| { | |
| 81 | + | i + trimmed[i..].chars().next().map_or(1, char::len_utf8) | |
| 82 | + | }); | |
| 83 | + | self.text.drain(start..self.cursor); | |
| 84 | + | self.cursor = start; | |
| 85 | + | InputEvent::Changed | |
| 86 | + | } | |
| 87 | + | Action::CursorLeft => { | |
| 88 | + | self.cursor = self.previous_boundary(); | |
| 89 | + | InputEvent::Moved | |
| 90 | + | } | |
| 91 | + | Action::CursorRight => { | |
| 92 | + | self.cursor = self.next_boundary(); | |
| 93 | + | InputEvent::Moved | |
| 94 | + | } | |
| 95 | + | Action::CursorHome => { | |
| 96 | + | self.cursor = 0; | |
| 97 | + | InputEvent::Moved | |
| 98 | + | } | |
| 99 | + | Action::CursorEnd => { | |
| 100 | + | self.cursor = self.text.len(); | |
| 101 | + | InputEvent::Moved | |
| 102 | + | } | |
| 103 | + | _ => InputEvent::Ignored, | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | /// Draws `prefix` followed by the text and places the terminal cursor. | |
| 108 | + | pub fn render(&self, frame: &mut Frame, area: Rect, prefix: &str, theme: &Theme) { | |
| 109 | + | let prefix_width = prefix.width(); | |
| 110 | + | let available = usize::from(area.width).saturating_sub(prefix_width + 1); | |
| 111 | + | let before_cursor = self.text[..self.cursor].width(); | |
| 112 | + | // Scroll horizontally so that the cursor stays visible. | |
| 113 | + | let skip = before_cursor.saturating_sub(available); | |
| 114 | + | let mut visible = String::new(); | |
| 115 | + | let mut width = 0; | |
| 116 | + | for ch in self.text.chars() { | |
| 117 | + | let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0); | |
| 118 | + | if width >= skip { | |
| 119 | + | visible.push(ch); | |
| 120 | + | } | |
| 121 | + | width += w; | |
| 122 | + | } | |
| 123 | + | let line = Line::from(vec![ | |
| 124 | + | Span::styled(prefix.to_owned(), theme.accent()), | |
| 125 | + | Span::styled(visible, theme.text()), | |
| 126 | + | ]); | |
| 127 | + | frame.render_widget(line, area); | |
| 128 | + | let x = area.x + (prefix_width + before_cursor - skip) as u16; | |
| 129 | + | frame.set_cursor_position(Position::new(x.min(area.right().saturating_sub(1)), area.y)); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | #[cfg(test)] | |
| 134 | + | mod tests { | |
| 135 | + | use super::*; | |
| 136 | + | ||
| 137 | + | #[test] | |
| 138 | + | fn edits_unicode_text() { | |
| 139 | + | let mut input = LineInput::new("héllo"); | |
| 140 | + | assert_eq!(input.apply(Action::Backspace), InputEvent::Changed); | |
| 141 | + | assert_eq!(input.text(), "héll"); | |
| 142 | + | input.apply(Action::CursorHome); | |
| 143 | + | input.apply(Action::CursorRight); | |
| 144 | + | input.apply(Action::DeleteChar); | |
| 145 | + | assert_eq!(input.text(), "hll"); | |
| 146 | + | input.insert('é'); | |
| 147 | + | assert_eq!(input.text(), "héll"); | |
| 148 | + | assert_eq!(input.apply(Action::Submit), InputEvent::Submit); | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | #[test] | |
| 152 | + | fn deletes_words() { | |
| 153 | + | let mut input = LineInput::new("one two "); | |
| 154 | + | input.apply(Action::DeleteWord); | |
| 155 | + | assert_eq!(input.text(), "one "); | |
| 156 | + | input.apply(Action::DeleteWord); | |
| 157 | + | assert_eq!(input.text(), ""); | |
| 158 | + | assert_eq!(input.apply(Action::Backspace), InputEvent::Ignored); | |
| 159 | + | } | |
| 160 | + | } | |
| @@ -0,0 +1,327 @@ | |||
| 1 | + | //! A virtualized, searchable list used by every screen. | |
| 2 | + | ||
| 3 | + | use std::time::{Duration, Instant}; | |
| 4 | + | ||
| 5 | + | use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; | |
| 6 | + | use nucleo_matcher::{ | |
| 7 | + | Config as MatcherConfig, Matcher, Utf32Str, | |
| 8 | + | pattern::{CaseMatching, Normalization, Pattern}, | |
| 9 | + | }; | |
| 10 | + | use ratatui::{ | |
| 11 | + | Frame, | |
| 12 | + | layout::{Constraint, Position, Rect}, | |
| 13 | + | style::{Modifier, Style}, | |
| 14 | + | widgets::{Block, Row, Table, TableState}, | |
| 15 | + | }; | |
| 16 | + | ||
| 17 | + | use crate::{action::Action, config::Theme}; | |
| 18 | + | ||
| 19 | + | /// Cursor, scroll offset and search state of a list. | |
| 20 | + | /// | |
| 21 | + | /// Only the visible rows are built when drawing, so lists with tens of | |
| 22 | + | /// thousands of items stay cheap. | |
| 23 | + | #[derive(Debug, Default)] | |
| 24 | + | pub struct ListView { | |
| 25 | + | selected: usize, | |
| 26 | + | offset: usize, | |
| 27 | + | /// Rows visible in the last draw. | |
| 28 | + | height: usize, | |
| 29 | + | /// Where the rows were drawn last, for mouse hit-testing. | |
| 30 | + | rows_area: Rect, | |
| 31 | + | last_click: Option<(usize, Instant)>, | |
| 32 | + | pub search: Search, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /// What a mouse event on a list did. | |
| 36 | + | #[derive(Clone, Copy, Debug, PartialEq, Eq)] | |
| 37 | + | pub enum Click { | |
| 38 | + | /// The cursor moved (click or wheel). | |
| 39 | + | Moved, | |
| 40 | + | /// A row was double-clicked. | |
| 41 | + | Activate(usize), | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | impl ListView { | |
| 45 | + | pub fn selected(&self, len: usize) -> Option<usize> { | |
| 46 | + | (len > 0).then(|| self.selected.min(len - 1)) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | pub fn select(&mut self, index: usize, len: usize) { | |
| 50 | + | self.selected = index.min(len.saturating_sub(1)); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | /// Resets cursor, scroll and search, e.g. when the content is replaced. | |
| 54 | + | pub fn reset(&mut self) { | |
| 55 | + | self.selected = 0; | |
| 56 | + | self.offset = 0; | |
| 57 | + | self.search.clear(); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | /// Handles movement actions. Returns whether the action was one. | |
| 61 | + | pub fn navigate(&mut self, action: Action, len: usize) -> bool { | |
| 62 | + | let page = self.height.max(1); | |
| 63 | + | let current = self.selected.min(len.saturating_sub(1)); | |
| 64 | + | let target = match action { | |
| 65 | + | Action::Up => current.saturating_sub(1), | |
| 66 | + | Action::Down => current + 1, | |
| 67 | + | Action::Top => 0, | |
| 68 | + | Action::Bottom => len.saturating_sub(1), | |
| 69 | + | Action::PageUp => current.saturating_sub(page), | |
| 70 | + | Action::PageDown => current + page, | |
| 71 | + | Action::HalfPageUp => current.saturating_sub(page / 2), | |
| 72 | + | Action::HalfPageDown => current + page / 2, | |
| 73 | + | Action::SearchNext => match self.search.next_after(current) { | |
| 74 | + | Some(index) => index, | |
| 75 | + | None => return true, | |
| 76 | + | }, | |
| 77 | + | Action::SearchPrevious => match self.search.previous_before(current) { | |
| 78 | + | Some(index) => index, | |
| 79 | + | None => return true, | |
| 80 | + | }, | |
| 81 | + | _ => return false, | |
| 82 | + | }; | |
| 83 | + | self.select(target, len); | |
| 84 | + | true | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | /// Scrolls by `delta` rows (mouse wheel), moving the cursor along. | |
| 88 | + | pub fn scroll(&mut self, delta: isize, len: usize) { | |
| 89 | + | let target = self.selected.saturating_add_signed(delta); | |
| 90 | + | self.select(target, len); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | /// Whether a position lies within the rows drawn last. | |
| 94 | + | pub fn contains(&self, position: Position) -> bool { | |
| 95 | + | self.rows_area.contains(position) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// Handles wheel scrolling and (double) clicks inside the list. | |
| 99 | + | pub fn mouse(&mut self, event: MouseEvent, len: usize) -> Option<Click> { | |
| 100 | + | let position = Position::new(event.column, event.row); | |
| 101 | + | match event.kind { | |
| 102 | + | MouseEventKind::ScrollDown => self.scroll(3, len), | |
| 103 | + | MouseEventKind::ScrollUp => self.scroll(-3, len), | |
| 104 | + | MouseEventKind::Down(MouseButton::Left) => { | |
| 105 | + | let index = self.index_at(position, len)?; | |
| 106 | + | self.select(index, len); | |
| 107 | + | let double = self.last_click.is_some_and(|(last, at)| { | |
| 108 | + | last == index && at.elapsed() < Duration::from_millis(400) | |
| 109 | + | }); | |
| 110 | + | if double { | |
| 111 | + | self.last_click = None; | |
| 112 | + | return Some(Click::Activate(index)); | |
| 113 | + | } | |
| 114 | + | self.last_click = Some((index, Instant::now())); | |
| 115 | + | } | |
| 116 | + | _ => return None, | |
| 117 | + | } | |
| 118 | + | Some(Click::Moved) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | /// The item under a terminal position from the last draw. | |
| 122 | + | pub fn index_at(&self, position: Position, len: usize) -> Option<usize> { | |
| 123 | + | if !self.rows_area.contains(position) { | |
| 124 | + | return None; | |
| 125 | + | } | |
| 126 | + | let index = self.offset + usize::from(position.y - self.rows_area.y); | |
| 127 | + | (index < len).then_some(index) | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | /// Recomputes search matches and jumps to the first one at or after the | |
| 131 | + | /// cursor. | |
| 132 | + | pub fn search<'a>(&mut self, query: &str, items: impl Iterator<Item = &'a str>, len: usize) { | |
| 133 | + | self.search.update(query, items); | |
| 134 | + | let current = self.selected.min(len.saturating_sub(1)); | |
| 135 | + | if let Some(index) = self.search.first_from(current) { | |
| 136 | + | self.select(index, len); | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | /// Draws rows `offset..offset + height` inside `block`. | |
| 141 | + | #[allow(clippy::too_many_arguments)] | |
| 142 | + | pub fn render<'a>( | |
| 143 | + | &mut self, | |
| 144 | + | frame: &mut Frame, | |
| 145 | + | area: Rect, | |
| 146 | + | block: Block<'a>, | |
| 147 | + | header: Option<Row<'a>>, | |
| 148 | + | widths: &[Constraint], | |
| 149 | + | len: usize, | |
| 150 | + | focused: bool, | |
| 151 | + | theme: &Theme, | |
| 152 | + | mut row: impl FnMut(usize) -> Row<'a>, | |
| 153 | + | ) { | |
| 154 | + | let inner = block.inner(area); | |
| 155 | + | let header_height = u16::from(header.is_some()); | |
| 156 | + | self.rows_area = Rect { | |
| 157 | + | y: inner.y + header_height, | |
| 158 | + | height: inner.height.saturating_sub(header_height), | |
| 159 | + | ..inner | |
| 160 | + | }; | |
| 161 | + | self.height = usize::from(self.rows_area.height); | |
| 162 | + | self.selected = self.selected.min(len.saturating_sub(1)); | |
| 163 | + | if self.selected < self.offset { | |
| 164 | + | self.offset = self.selected; | |
| 165 | + | } else if self.height > 0 && self.selected >= self.offset + self.height { | |
| 166 | + | self.offset = self.selected + 1 - self.height; | |
| 167 | + | } | |
| 168 | + | self.offset = self.offset.min(len.saturating_sub(self.height)); | |
| 169 | + | ||
| 170 | + | let end = (self.offset + self.height).min(len); | |
| 171 | + | let rows: Vec<Row> = (self.offset..end) | |
| 172 | + | .map(|index| { | |
| 173 | + | let r = row(index); | |
| 174 | + | if self.search.is_match(index) { | |
| 175 | + | r.style(Style::new().add_modifier(Modifier::UNDERLINED)) | |
| 176 | + | } else { | |
| 177 | + | r | |
| 178 | + | } | |
| 179 | + | }) | |
| 180 | + | .collect(); | |
| 181 | + | let highlight = if focused { | |
| 182 | + | theme.selection() | |
| 183 | + | } else { | |
| 184 | + | theme.selection_inactive() | |
| 185 | + | }; | |
| 186 | + | let mut table = Table::new(rows, widths.to_vec()) | |
| 187 | + | .block(block) | |
| 188 | + | .column_spacing(1) | |
| 189 | + | .row_highlight_style(highlight); | |
| 190 | + | if let Some(header) = header { | |
| 191 | + | table = table.header(header); | |
| 192 | + | } | |
| 193 | + | let mut state = TableState::default(); | |
| 194 | + | if len > 0 { | |
| 195 | + | state.select(Some(self.selected - self.offset)); | |
| 196 | + | } | |
| 197 | + | frame.render_stateful_widget(table, area, &mut state); | |
| 198 | + | } | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | /// Fuzzy search results over a list, kept in list order so that `n`/`N` | |
| 202 | + | /// walk through them like in a pager. | |
| 203 | + | #[derive(Debug, Default)] | |
| 204 | + | pub struct Search { | |
| 205 | + | query: String, | |
| 206 | + | matches: Vec<usize>, | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | impl Search { | |
| 210 | + | pub fn update<'a>(&mut self, query: &str, items: impl Iterator<Item = &'a str>) { | |
| 211 | + | self.query = query.to_owned(); | |
| 212 | + | self.matches.clear(); | |
| 213 | + | if query.trim().is_empty() { | |
| 214 | + | return; | |
| 215 | + | } | |
| 216 | + | let pattern = Pattern::parse(query, CaseMatching::Smart, Normalization::Smart); | |
| 217 | + | let mut matcher = Matcher::new(MatcherConfig::DEFAULT); | |
| 218 | + | let mut buf = Vec::new(); | |
| 219 | + | self.matches | |
| 220 | + | .extend(items.enumerate().filter_map(|(index, item)| { | |
| 221 | + | pattern | |
| 222 | + | .score(Utf32Str::new(item, &mut buf), &mut matcher) | |
| 223 | + | .map(|_| index) | |
| 224 | + | })); | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | pub fn clear(&mut self) { | |
| 228 | + | self.query.clear(); | |
| 229 | + | self.matches.clear(); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | pub fn query(&self) -> &str { | |
| 233 | + | &self.query | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | pub fn match_count(&self) -> usize { | |
| 237 | + | self.matches.len() | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | pub fn is_match(&self, index: usize) -> bool { | |
| 241 | + | self.matches.binary_search(&index).is_ok() | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | /// The first match at or after `index`, wrapping around. | |
| 245 | + | pub fn first_from(&self, index: usize) -> Option<usize> { | |
| 246 | + | let start = self.matches.partition_point(|&m| m < index); | |
| 247 | + | self.matches | |
| 248 | + | .get(start) | |
| 249 | + | .or_else(|| self.matches.first()) | |
| 250 | + | .copied() | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | /// The first match after `index`, wrapping around. | |
| 254 | + | pub fn next_after(&self, index: usize) -> Option<usize> { | |
| 255 | + | self.first_from(index.saturating_add(1)) | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | /// The last match before `index`, wrapping around. | |
| 259 | + | pub fn previous_before(&self, index: usize) -> Option<usize> { | |
| 260 | + | let end = self.matches.partition_point(|&m| m < index); | |
| 261 | + | end.checked_sub(1) | |
| 262 | + | .and_then(|i| self.matches.get(i)) | |
| 263 | + | .or_else(|| self.matches.last()) | |
| 264 | + | .copied() | |
| 265 | + | } | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | #[cfg(test)] | |
| 269 | + | mod tests { | |
| 270 | + | use super::*; | |
| 271 | + | ||
| 272 | + | const ITEMS: [&str; 5] = ["Alpha", "beta", "Gamma", "alphabet", "delta"]; | |
| 273 | + | ||
| 274 | + | fn search(query: &str) -> Search { | |
| 275 | + | let mut search = Search::default(); | |
| 276 | + | search.update(query, ITEMS.into_iter()); | |
| 277 | + | search | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | #[test] | |
| 281 | + | fn finds_fuzzy_matches_in_list_order() { | |
| 282 | + | let s = search("alp"); | |
| 283 | + | assert_eq!(s.matches, [0, 3]); | |
| 284 | + | assert!(s.is_match(3) && !s.is_match(1)); | |
| 285 | + | assert_eq!(search("").match_count(), 0); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | #[test] | |
| 289 | + | fn cycles_matches_with_wraparound() { | |
| 290 | + | let s = search("alp"); | |
| 291 | + | assert_eq!(s.next_after(0), Some(3)); | |
| 292 | + | assert_eq!(s.next_after(3), Some(0)); | |
| 293 | + | assert_eq!(s.first_from(3), Some(3)); | |
| 294 | + | assert_eq!(s.previous_before(3), Some(0)); | |
| 295 | + | assert_eq!(s.previous_before(0), Some(3)); | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | #[test] | |
| 299 | + | fn navigates_and_clamps() { | |
| 300 | + | let mut view = ListView { | |
| 301 | + | height: 10, | |
| 302 | + | ..ListView::default() | |
| 303 | + | }; | |
| 304 | + | assert!(view.navigate(Action::Bottom, 25)); | |
| 305 | + | assert_eq!(view.selected(25), Some(24)); | |
| 306 | + | view.navigate(Action::PageUp, 25); | |
| 307 | + | assert_eq!(view.selected(25), Some(14)); | |
| 308 | + | view.navigate(Action::HalfPageDown, 25); | |
| 309 | + | assert_eq!(view.selected(25), Some(19)); | |
| 310 | + | view.navigate(Action::Down, 25); | |
| 311 | + | view.navigate(Action::PageDown, 25); | |
| 312 | + | assert_eq!(view.selected(25), Some(24)); | |
| 313 | + | assert_eq!(view.selected(3), Some(2)); | |
| 314 | + | assert_eq!(view.selected(0), None); | |
| 315 | + | assert!(!view.navigate(Action::Quit, 25)); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[test] | |
| 319 | + | fn search_jumps_to_first_match_from_cursor() { | |
| 320 | + | let mut view = ListView::default(); | |
| 321 | + | view.select(1, ITEMS.len()); | |
| 322 | + | view.search("alp", ITEMS.into_iter(), ITEMS.len()); | |
| 323 | + | assert_eq!(view.selected(ITEMS.len()), Some(3)); | |
| 324 | + | view.navigate(Action::SearchNext, ITEMS.len()); | |
| 325 | + | assert_eq!(view.selected(ITEMS.len()), Some(0)); | |
| 326 | + | } | |
| 327 | + | } | |
| @@ -0,0 +1,328 @@ | |||
| 1 | + | //! Widgets and the parts of the frame shared by all screens. | |
| 2 | + | ||
| 3 | + | pub mod input; | |
| 4 | + | pub mod list; | |
| 5 | + | ||
| 6 | + | use std::time::Duration; | |
| 7 | + | ||
| 8 | + | use ratatui::{ | |
| 9 | + | Frame, | |
| 10 | + | layout::{Alignment, Constraint, Layout, Rect}, | |
| 11 | + | style::{Modifier, Style}, | |
| 12 | + | text::{Line, Span}, | |
| 13 | + | widgets::{Block, BorderType, Paragraph}, | |
| 14 | + | }; | |
| 15 | + | use unicode_width::UnicodeWidthStr; | |
| 16 | + | ||
| 17 | + | use crate::{ | |
| 18 | + | action::ScreenId, | |
| 19 | + | config::{ColumnField, Theme}, | |
| 20 | + | mpd::{PlayState, Song}, | |
| 21 | + | state::MpdState, | |
| 22 | + | util::format_duration, | |
| 23 | + | }; | |
| 24 | + | ||
| 25 | + | /// A bordered pane; focused panes get the accent color. | |
| 26 | + | pub fn pane<'a>(title: impl Into<Line<'a>>, focused: bool, theme: &Theme) -> Block<'a> { | |
| 27 | + | let style = if focused { | |
| 28 | + | theme.accent() | |
| 29 | + | } else { | |
| 30 | + | theme.border() | |
| 31 | + | }; | |
| 32 | + | Block::bordered() | |
| 33 | + | .border_type(BorderType::Rounded) | |
| 34 | + | .border_style(style) | |
| 35 | + | .title(title.into().style(style)) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | /// The text of one song column. | |
| 39 | + | pub fn song_field(song: &Song, field: ColumnField) -> String { | |
| 40 | + | let text = match field { | |
| 41 | + | ColumnField::Artist => song.artist(), | |
| 42 | + | ColumnField::AlbumArtist => song.album_artist(), | |
| 43 | + | ColumnField::Title => Some(song.display_title()), | |
| 44 | + | ColumnField::Album => song.album(), | |
| 45 | + | ColumnField::Track => song.track(), | |
| 46 | + | ColumnField::Disc => song.tag("Disc"), | |
| 47 | + | ColumnField::Date => song.tag("Date"), | |
| 48 | + | ColumnField::Genre => song.tag("Genre"), | |
| 49 | + | ColumnField::Duration => return song.duration.map(format_duration).unwrap_or_default(), | |
| 50 | + | ColumnField::File => Some(song.file.as_str()), | |
| 51 | + | }; | |
| 52 | + | text.unwrap_or_default().to_owned() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// `Artist - Title`, or just the title. | |
| 56 | + | pub fn song_label(song: &Song) -> String { | |
| 57 | + | match song.artist() { | |
| 58 | + | Some(artist) => format!("{artist} - {}", song.display_title()), | |
| 59 | + | None => song.display_title().to_owned(), | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | /// The text a song is searched by. | |
| 64 | + | pub fn song_search_text(song: &Song) -> String { | |
| 65 | + | format!( | |
| 66 | + | "{} {} {}", | |
| 67 | + | song.artist().unwrap_or_default(), | |
| 68 | + | song.display_title(), | |
| 69 | + | song.album().unwrap_or_default() | |
| 70 | + | ) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | /// The rows of the frame around the active screen. | |
| 74 | + | pub struct FrameLayout { | |
| 75 | + | pub header: Rect, | |
| 76 | + | pub tabs: Rect, | |
| 77 | + | pub body: Rect, | |
| 78 | + | pub progress: Rect, | |
| 79 | + | pub status: Rect, | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | impl FrameLayout { | |
| 83 | + | pub fn new(area: Rect) -> Self { | |
| 84 | + | let [header, tabs, body, progress, status] = Layout::vertical([ | |
| 85 | + | Constraint::Length(2), | |
| 86 | + | Constraint::Length(1), | |
| 87 | + | Constraint::Fill(1), | |
| 88 | + | Constraint::Length(1), | |
| 89 | + | Constraint::Length(1), | |
| 90 | + | ]) | |
| 91 | + | .areas(area); | |
| 92 | + | Self { | |
| 93 | + | header, | |
| 94 | + | tabs, | |
| 95 | + | body, | |
| 96 | + | progress, | |
| 97 | + | status, | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | pub fn draw_header(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) { | |
| 103 | + | let [top, bottom] = Layout::vertical([Constraint::Length(1); 2]).areas(area); | |
| 104 | + | let status = &mpd.status; | |
| 105 | + | ||
| 106 | + | let (icon, title, details) = match (&mpd.current, mpd.connected) { | |
| 107 | + | (_, false) => ("✕", "Not connected".to_owned(), String::new()), | |
| 108 | + | (Some(song), true) => { | |
| 109 | + | let icon = match status.state { | |
| 110 | + | PlayState::Play => "▶", | |
| 111 | + | PlayState::Pause => "⏸", | |
| 112 | + | PlayState::Stop => "■", | |
| 113 | + | }; | |
| 114 | + | let mut details = song.artist().unwrap_or("Unknown artist").to_owned(); | |
| 115 | + | if let Some(album) = song.album() { | |
| 116 | + | details.push_str(" — "); | |
| 117 | + | details.push_str(album); | |
| 118 | + | if let Some(date) = song.tag("Date") { | |
| 119 | + | details.push_str(&format!(" ({date})")); | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | (icon, song.display_title().to_owned(), details) | |
| 123 | + | } | |
| 124 | + | (None, true) => ("■", "Stopped".to_owned(), String::new()), | |
| 125 | + | }; | |
| 126 | + | ||
| 127 | + | let flag = |on: bool, letter: &'static str| { | |
| 128 | + | if on { | |
| 129 | + | Span::styled(letter, theme.accent().add_modifier(Modifier::BOLD)) | |
| 130 | + | } else { | |
| 131 | + | Span::styled(letter, theme.dim()) | |
| 132 | + | } | |
| 133 | + | }; | |
| 134 | + | let volume = status | |
| 135 | + | .volume | |
| 136 | + | .map_or_else(|| "vol n/a".to_owned(), |v| format!("vol {v:>3}%")); | |
| 137 | + | let right = Line::from(vec![ | |
| 138 | + | Span::styled(volume, theme.text()), | |
| 139 | + | Span::styled(" [", theme.dim()), | |
| 140 | + | flag(status.repeat, "r"), | |
| 141 | + | flag(status.random, "z"), | |
| 142 | + | flag(status.single.is_on(), "s"), | |
| 143 | + | flag(status.consume.is_on(), "c"), | |
| 144 | + | Span::styled("]", theme.dim()), | |
| 145 | + | ]); | |
| 146 | + | let mut audio = Vec::new(); | |
| 147 | + | if status.updating_db { | |
| 148 | + | audio.push("updating database".to_owned()); | |
| 149 | + | } | |
| 150 | + | if let Some(bitrate) = status.bitrate { | |
| 151 | + | audio.push(format!("{bitrate} kbps")); | |
| 152 | + | } | |
| 153 | + | if let Some(format) = status.audio.as_deref() { | |
| 154 | + | audio.push(format_audio(format)); | |
| 155 | + | } | |
| 156 | + | let right_width = right.width() as u16; | |
| 157 | + | ||
| 158 | + | let [title_area, flags_area] = | |
| 159 | + | Layout::horizontal([Constraint::Fill(1), Constraint::Length(right_width + 1)]).areas(top); | |
| 160 | + | frame.render_widget( | |
| 161 | + | Line::from(vec![ | |
| 162 | + | Span::styled(format!(" {icon} "), theme.accent()), | |
| 163 | + | Span::styled(title, theme.text().add_modifier(Modifier::BOLD)), | |
| 164 | + | ]), | |
| 165 | + | title_area, | |
| 166 | + | ); | |
| 167 | + | frame.render_widget(right.alignment(Alignment::Right), flags_area); | |
| 168 | + | ||
| 169 | + | let audio = audio.join(" · "); | |
| 170 | + | let [details_area, audio_area] = Layout::horizontal([ | |
| 171 | + | Constraint::Fill(1), | |
| 172 | + | Constraint::Length(audio.width() as u16 + 1), | |
| 173 | + | ]) | |
| 174 | + | .areas(bottom); | |
| 175 | + | frame.render_widget( | |
| 176 | + | Line::from(Span::styled(format!(" {details}"), theme.dim())), | |
| 177 | + | details_area, | |
| 178 | + | ); | |
| 179 | + | frame.render_widget( | |
| 180 | + | Line::from(Span::styled(audio, theme.dim())).alignment(Alignment::Right), | |
| 181 | + | audio_area, | |
| 182 | + | ); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | /// `44100:16:2` → `44.1 kHz · 16 bit · stereo`. | |
| 186 | + | fn format_audio(format: &str) -> String { | |
| 187 | + | let mut parts = format.split(':'); | |
| 188 | + | let (Some(rate), Some(bits), Some(channels)) = (parts.next(), parts.next(), parts.next()) | |
| 189 | + | else { | |
| 190 | + | return format.to_owned(); | |
| 191 | + | }; | |
| 192 | + | let rate = rate | |
| 193 | + | .parse::<f64>() | |
| 194 | + | .map_or_else(|_| rate.to_owned(), |r| format!("{} kHz", r / 1000.0)); | |
| 195 | + | let bits = match bits { | |
| 196 | + | "f" => "float".to_owned(), | |
| 197 | + | "dsd" => "DSD".to_owned(), | |
| 198 | + | bits => format!("{bits} bit"), | |
| 199 | + | }; | |
| 200 | + | let channels = match channels { | |
| 201 | + | "1" => "mono".to_owned(), | |
| 202 | + | "2" => "stereo".to_owned(), | |
| 203 | + | n => format!("{n} ch"), | |
| 204 | + | }; | |
| 205 | + | format!("{rate} · {bits} · {channels}") | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | /// Draws the tab bar and returns each tab's horizontal span for mouse hits. | |
| 209 | + | pub fn draw_tabs( | |
| 210 | + | frame: &mut Frame, | |
| 211 | + | area: Rect, | |
| 212 | + | active: ScreenId, | |
| 213 | + | theme: &Theme, | |
| 214 | + | ) -> Vec<(u16, u16, ScreenId)> { | |
| 215 | + | let mut spans = vec![Span::raw(" ")]; | |
| 216 | + | let mut hits = Vec::new(); | |
| 217 | + | let mut x = area.x + 1; | |
| 218 | + | for (i, &screen) in ScreenId::ALL.iter().enumerate() { | |
| 219 | + | let label = format!(" {} {} ", i + 1, screen.title()); | |
| 220 | + | let width = label.width() as u16; | |
| 221 | + | let style = if screen == active { | |
| 222 | + | theme.selection().add_modifier(Modifier::BOLD) | |
| 223 | + | } else { | |
| 224 | + | theme.dim() | |
| 225 | + | }; | |
| 226 | + | hits.push((x, x + width, screen)); | |
| 227 | + | x += width + 1; | |
| 228 | + | spans.push(Span::styled(label, style)); | |
| 229 | + | spans.push(Span::raw(" ")); | |
| 230 | + | } | |
| 231 | + | frame.render_widget(Line::from(spans), area); | |
| 232 | + | hits | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | /// Draws `elapsed ━━━━──── total`. | |
| 236 | + | pub fn draw_progress(frame: &mut Frame, area: Rect, mpd: &MpdState, theme: &Theme) { | |
| 237 | + | let elapsed = mpd.elapsed(); | |
| 238 | + | let total = mpd.status.duration.unwrap_or_default(); | |
| 239 | + | let left = format!(" {} ", format_duration(elapsed)); | |
| 240 | + | let right = format!(" {} ", format_duration(total)); | |
| 241 | + | let [left_area, bar, right_area] = progress_layout(area, &left, &right); | |
| 242 | + | frame.render_widget(Span::styled(left, theme.dim()), left_area); | |
| 243 | + | frame.render_widget(Span::styled(right, theme.dim()), right_area); | |
| 244 | + | ||
| 245 | + | let width = usize::from(bar.width); | |
| 246 | + | let filled = if total.is_zero() || mpd.status.state == PlayState::Stop { | |
| 247 | + | 0 | |
| 248 | + | } else { | |
| 249 | + | ((elapsed.as_secs_f64() / total.as_secs_f64()) * width as f64).round() as usize | |
| 250 | + | } | |
| 251 | + | .min(width); | |
| 252 | + | frame.render_widget( | |
| 253 | + | Line::from(vec![ | |
| 254 | + | Span::styled("━".repeat(filled), theme.accent()), | |
| 255 | + | Span::styled("─".repeat(width - filled), theme.border()), | |
| 256 | + | ]), | |
| 257 | + | bar, | |
| 258 | + | ); | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | fn progress_layout(area: Rect, left: &str, right: &str) -> [Rect; 3] { | |
| 262 | + | Layout::horizontal([ | |
| 263 | + | Constraint::Length(left.width() as u16), | |
| 264 | + | Constraint::Fill(1), | |
| 265 | + | Constraint::Length(right.width() as u16), | |
| 266 | + | ]) | |
| 267 | + | .areas(area) | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | /// Maps a click on the progress bar to a position in the song. | |
| 271 | + | pub fn progress_seek_target(area: Rect, x: u16, mpd: &MpdState) -> Option<Duration> { | |
| 272 | + | let total = mpd.status.duration?; | |
| 273 | + | let left = format!(" {} ", format_duration(mpd.elapsed())); | |
| 274 | + | let right = format!(" {} ", format_duration(total)); | |
| 275 | + | let [_, bar, _] = progress_layout(area, &left, &right); | |
| 276 | + | if bar.width == 0 || x < bar.x || x >= bar.right() { | |
| 277 | + | return None; | |
| 278 | + | } | |
| 279 | + | let fraction = f64::from(x - bar.x) / f64::from(bar.width); | |
| 280 | + | Some(total.mul_f64(fraction)) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | /// A one-line message, styled as an error if needed. | |
| 284 | + | pub fn draw_message(frame: &mut Frame, area: Rect, text: &str, error: bool, theme: &Theme) { | |
| 285 | + | let style = if error { theme.error() } else { theme.text() }; | |
| 286 | + | frame.render_widget( | |
| 287 | + | Paragraph::new(Line::from(Span::styled(format!(" {text}"), style))), | |
| 288 | + | area, | |
| 289 | + | ); | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | /// Key hints shown in the status line when nothing else is. | |
| 293 | + | pub fn draw_hint(frame: &mut Frame, area: Rect, text: &str, theme: &Theme) { | |
| 294 | + | frame.render_widget( | |
| 295 | + | Line::from(Span::styled(format!(" {text}"), theme.dim())).alignment(Alignment::Left), | |
| 296 | + | area, | |
| 297 | + | ); | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | /// A centered, dimmed placeholder for empty panes. | |
| 301 | + | pub fn draw_placeholder(frame: &mut Frame, area: Rect, lines: &[&str], theme: &Theme) { | |
| 302 | + | let top = area.height.saturating_sub(lines.len() as u16) / 2; | |
| 303 | + | let text: Vec<Line> = std::iter::repeat_n(Line::default(), usize::from(top)) | |
| 304 | + | .chain( | |
| 305 | + | lines | |
| 306 | + | .iter() | |
| 307 | + | .map(|l| Line::from(Span::styled(*l, theme.dim()))), | |
| 308 | + | ) | |
| 309 | + | .collect(); | |
| 310 | + | frame.render_widget( | |
| 311 | + | Paragraph::new(text) | |
| 312 | + | .alignment(Alignment::Center) | |
| 313 | + | .style(Style::new()), | |
| 314 | + | area, | |
| 315 | + | ); | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | #[cfg(test)] | |
| 319 | + | mod tests { | |
| 320 | + | use super::*; | |
| 321 | + | ||
| 322 | + | #[test] | |
| 323 | + | fn formats_audio() { | |
| 324 | + | assert_eq!(format_audio("44100:16:2"), "44.1 kHz · 16 bit · stereo"); | |
| 325 | + | assert_eq!(format_audio("96000:f:1"), "96 kHz · float · mono"); | |
| 326 | + | assert_eq!(format_audio("weird"), "weird"); | |
| 327 | + | } | |
| 328 | + | } | |
| @@ -0,0 +1,130 @@ | |||
| 1 | + | //! Turning spectra into smoothly animated bars. | |
| 2 | + | ||
| 3 | + | use std::time::Instant; | |
| 4 | + | ||
| 5 | + | use super::Spectrum; | |
| 6 | + | ||
| 7 | + | /// Bar heights (0.0–1.0) with gravity falloff between frames. | |
| 8 | + | #[derive(Debug)] | |
| 9 | + | pub struct Bars { | |
| 10 | + | heights: Vec<f32>, | |
| 11 | + | last_update: Instant, | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | impl Default for Bars { | |
| 15 | + | fn default() -> Self { | |
| 16 | + | Self { | |
| 17 | + | heights: Vec::new(), | |
| 18 | + | last_update: Instant::now(), | |
| 19 | + | } | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | /// Decibel range mapped onto the bar height. | |
| 24 | + | const FLOOR_DB: f32 = -60.0; | |
| 25 | + | const CEILING_DB: f32 = 0.0; | |
| 26 | + | ||
| 27 | + | impl Bars { | |
| 28 | + | /// Updates `count` bars from `spectrum`, covering `min_hz..max_hz` on a | |
| 29 | + | /// logarithmic scale. `falloff` is in heights per second. | |
| 30 | + | pub fn update( | |
| 31 | + | &mut self, | |
| 32 | + | spectrum: &Spectrum, | |
| 33 | + | count: usize, | |
| 34 | + | min_hz: f32, | |
| 35 | + | max_hz: f32, | |
| 36 | + | falloff: f32, | |
| 37 | + | ) -> &[f32] { | |
| 38 | + | let now = Instant::now(); | |
| 39 | + | let dt = now.duration_since(self.last_update).as_secs_f32().min(0.25); | |
| 40 | + | self.last_update = now; | |
| 41 | + | self.heights.resize(count, 0.0); | |
| 42 | + | let targets = band_levels(spectrum, count, min_hz, max_hz); | |
| 43 | + | for (height, target) in self.heights.iter_mut().zip(targets) { | |
| 44 | + | *height = if target >= *height { | |
| 45 | + | // Rise quickly but not instantly, to avoid flicker. | |
| 46 | + | *height + (target - *height) * 0.7 | |
| 47 | + | } else { | |
| 48 | + | (*height - falloff * dt).max(target) | |
| 49 | + | }; | |
| 50 | + | } | |
| 51 | + | &self.heights | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Levels (0.0–1.0) of `count` log-spaced bands. | |
| 56 | + | pub fn band_levels(spectrum: &Spectrum, count: usize, min_hz: f32, max_hz: f32) -> Vec<f32> { | |
| 57 | + | let bins = &spectrum.magnitudes; | |
| 58 | + | if count == 0 || bins.is_empty() || spectrum.bin_hz <= 0.0 { | |
| 59 | + | return vec![0.0; count]; | |
| 60 | + | } | |
| 61 | + | let max_hz = max_hz.min(spectrum.bin_hz * (bins.len() - 1) as f32); | |
| 62 | + | let ratio = (max_hz / min_hz).max(1.0); | |
| 63 | + | (0..count) | |
| 64 | + | .map(|band| { | |
| 65 | + | let lo = min_hz * ratio.powf(band as f32 / count as f32); | |
| 66 | + | let hi = min_hz * ratio.powf((band + 1) as f32 / count as f32); | |
| 67 | + | let first = (lo / spectrum.bin_hz).floor() as usize; | |
| 68 | + | let last = ((hi / spectrum.bin_hz).ceil() as usize) | |
| 69 | + | .max(first + 1) | |
| 70 | + | .min(bins.len()); | |
| 71 | + | let peak = bins[first.min(bins.len() - 1)..last] | |
| 72 | + | .iter() | |
| 73 | + | .copied() | |
| 74 | + | .fold(0.0f32, f32::max); | |
| 75 | + | // Tilt +3 dB per octave so that treble is not dwarfed by bass. | |
| 76 | + | let center = (lo * hi).sqrt(); | |
| 77 | + | let tilt = 3.0 * (center / 1000.0).log2(); | |
| 78 | + | let db = 20.0 * peak.max(1e-9).log10() + tilt; | |
| 79 | + | ((db - FLOOR_DB) / (CEILING_DB - FLOOR_DB)).clamp(0.0, 1.0) | |
| 80 | + | }) | |
| 81 | + | .collect() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | #[cfg(test)] | |
| 85 | + | mod tests { | |
| 86 | + | use super::*; | |
| 87 | + | ||
| 88 | + | fn spectrum_with_peak(hz: f32) -> Spectrum { | |
| 89 | + | let bin_hz = 44_100.0 / 2048.0; | |
| 90 | + | let mut magnitudes = vec![0.0; 1025]; | |
| 91 | + | magnitudes[(hz / bin_hz).round() as usize] = 1.0; | |
| 92 | + | Spectrum { | |
| 93 | + | magnitudes, | |
| 94 | + | bin_hz, | |
| 95 | + | error: None, | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | #[test] | |
| 100 | + | fn peak_lands_in_the_right_band() { | |
| 101 | + | let levels = band_levels(&spectrum_with_peak(1000.0), 20, 40.0, 16_000.0); | |
| 102 | + | let loudest = levels | |
| 103 | + | .iter() | |
| 104 | + | .enumerate() | |
| 105 | + | .max_by(|a, b| a.1.total_cmp(b.1)) | |
| 106 | + | .unwrap() | |
| 107 | + | .0; | |
| 108 | + | // 1 kHz sits a bit past the middle of 40 Hz–16 kHz on a log scale. | |
| 109 | + | assert!((10..=12).contains(&loudest), "loudest band {loudest}"); | |
| 110 | + | assert!(levels[0] == 0.0); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | #[test] | |
| 114 | + | fn silence_and_empty_input_give_zero() { | |
| 115 | + | assert_eq!( | |
| 116 | + | band_levels(&Spectrum::default(), 4, 40.0, 16_000.0), | |
| 117 | + | [0.0; 4] | |
| 118 | + | ); | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | #[test] | |
| 122 | + | fn bars_fall_gradually() { | |
| 123 | + | let mut bars = Bars::default(); | |
| 124 | + | let loud = spectrum_with_peak(1000.0); | |
| 125 | + | let peak = bars.update(&loud, 20, 40.0, 16_000.0, 1.0).to_vec(); | |
| 126 | + | let after = bars.update(&Spectrum::default(), 20, 40.0, 16_000.0, 1.0); | |
| 127 | + | let i = peak.iter().position(|&h| h > 0.0).unwrap(); | |
| 128 | + | assert!(after[i] > 0.0 && after[i] <= peak[i]); | |
| 129 | + | } | |
| 130 | + | } | |
| @@ -0,0 +1,228 @@ | |||
| 1 | + | //! Reads PCM data from MPD's fifo output and computes spectra. | |
| 2 | + | //! | |
| 3 | + | //! A dedicated thread does the I/O and FFT while the visualizer is visible | |
| 4 | + | //! and publishes each spectrum on a `watch` channel; the UI turns spectra into | |
| 5 | + | //! bars (see [`bars`]). | |
| 6 | + | ||
| 7 | + | mod bars; | |
| 8 | + | ||
| 9 | + | use std::{ | |
| 10 | + | fs::{File, OpenOptions}, | |
| 11 | + | io::{self, Read}, | |
| 12 | + | sync::{ | |
| 13 | + | Arc, | |
| 14 | + | atomic::{AtomicBool, Ordering}, | |
| 15 | + | }, | |
| 16 | + | thread, | |
| 17 | + | time::{Duration, Instant}, | |
| 18 | + | }; | |
| 19 | + | ||
| 20 | + | pub use bars::Bars; | |
| 21 | + | use realfft::{RealFftPlanner, RealToComplex}; | |
| 22 | + | use tokio::sync::watch; | |
| 23 | + | use tracing::warn; | |
| 24 | + | ||
| 25 | + | use crate::config::VisualizerConfig; | |
| 26 | + | ||
| 27 | + | const FFT_SIZE: usize = 2048; | |
| 28 | + | ||
| 29 | + | /// One analysis frame. | |
| 30 | + | #[derive(Clone, Debug, Default)] | |
| 31 | + | pub struct Spectrum { | |
| 32 | + | /// Normalized magnitude per FFT bin (0 Hz up to Nyquist). | |
| 33 | + | pub magnitudes: Vec<f32>, | |
| 34 | + | /// Width of one bin in Hz. | |
| 35 | + | pub bin_hz: f32, | |
| 36 | + | /// Why there is no audio, if the fifo cannot be read. | |
| 37 | + | pub error: Option<String>, | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | pub struct Visualizer { | |
| 41 | + | active: Arc<AtomicBool>, | |
| 42 | + | spectrum: watch::Receiver<Arc<Spectrum>>, | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | impl Visualizer { | |
| 46 | + | pub fn spawn(config: VisualizerConfig) -> Self { | |
| 47 | + | let active = Arc::new(AtomicBool::new(false)); | |
| 48 | + | let (tx, spectrum) = watch::channel(Arc::new(Spectrum::default())); | |
| 49 | + | let flag = Arc::clone(&active); | |
| 50 | + | let spawned = thread::Builder::new() | |
| 51 | + | .name("visualizer".into()) | |
| 52 | + | .spawn(move || Reader::new(config, flag, tx).run()); | |
| 53 | + | if let Err(error) = spawned { | |
| 54 | + | warn!("cannot start visualizer thread: {error}"); | |
| 55 | + | } | |
| 56 | + | Self { active, spectrum } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | /// Starts or stops reading the fifo. | |
| 60 | + | pub fn set_active(&self, active: bool) { | |
| 61 | + | self.active.store(active, Ordering::Relaxed); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | pub fn is_active(&self) -> bool { | |
| 65 | + | self.active.load(Ordering::Relaxed) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | pub fn latest(&self) -> Arc<Spectrum> { | |
| 69 | + | Arc::clone(&self.spectrum.borrow()) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | /// A receiver to await new spectra on. | |
| 73 | + | pub fn subscribe(&self) -> watch::Receiver<Arc<Spectrum>> { | |
| 74 | + | self.spectrum.clone() | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | struct Reader { | |
| 79 | + | config: VisualizerConfig, | |
| 80 | + | active: Arc<AtomicBool>, | |
| 81 | + | tx: watch::Sender<Arc<Spectrum>>, | |
| 82 | + | fft: Arc<dyn RealToComplex<f32>>, | |
| 83 | + | window: Vec<f32>, | |
| 84 | + | /// The most recent mono samples, oldest first. | |
| 85 | + | samples: Vec<f32>, | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | impl Reader { | |
| 89 | + | fn new( | |
| 90 | + | config: VisualizerConfig, | |
| 91 | + | active: Arc<AtomicBool>, | |
| 92 | + | tx: watch::Sender<Arc<Spectrum>>, | |
| 93 | + | ) -> Self { | |
| 94 | + | let window = (0..FFT_SIZE) | |
| 95 | + | .map(|i| { | |
| 96 | + | let phase = 2.0 * std::f32::consts::PI * i as f32 / (FFT_SIZE - 1) as f32; | |
| 97 | + | 0.5 - 0.5 * phase.cos() | |
| 98 | + | }) | |
| 99 | + | .collect(); | |
| 100 | + | Self { | |
| 101 | + | fft: RealFftPlanner::<f32>::new().plan_fft_forward(FFT_SIZE), | |
| 102 | + | config, | |
| 103 | + | active, | |
| 104 | + | tx, | |
| 105 | + | window, | |
| 106 | + | samples: vec![0.0; FFT_SIZE], | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | fn run(mut self) { | |
| 111 | + | loop { | |
| 112 | + | while !self.active.load(Ordering::Relaxed) { | |
| 113 | + | if self.tx.is_closed() { | |
| 114 | + | return; | |
| 115 | + | } | |
| 116 | + | thread::sleep(Duration::from_millis(100)); | |
| 117 | + | } | |
| 118 | + | match open_fifo(&self.config) { | |
| 119 | + | Ok(file) => self.read_while_active(file), | |
| 120 | + | Err(error) => { | |
| 121 | + | self.publish(Spectrum { | |
| 122 | + | error: Some(format!( | |
| 123 | + | "cannot open {}: {error}", | |
| 124 | + | self.config.fifo.display() | |
| 125 | + | )), | |
| 126 | + | ..Spectrum::default() | |
| 127 | + | }); | |
| 128 | + | thread::sleep(Duration::from_secs(1)); | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | fn read_while_active(&mut self, mut file: File) { | |
| 135 | + | let channels = usize::from(self.config.channels); | |
| 136 | + | let frame_bytes = 2 * channels; | |
| 137 | + | let interval = Duration::from_secs_f64(1.0 / f64::from(self.config.framerate)); | |
| 138 | + | let mut buf = vec![0u8; 16 * 1024]; | |
| 139 | + | let mut pending: Vec<u8> = Vec::new(); | |
| 140 | + | let mut last_frame = Instant::now(); | |
| 141 | + | let mut last_data = Instant::now(); | |
| 142 | + | let mut silent = false; | |
| 143 | + | ||
| 144 | + | // Drop audio that piled up while nobody was reading. | |
| 145 | + | while matches!(file.read(&mut buf), Ok(n) if n > 0) {} | |
| 146 | + | ||
| 147 | + | while self.active.load(Ordering::Relaxed) { | |
| 148 | + | match file.read(&mut buf) { | |
| 149 | + | Ok(0) => thread::sleep(Duration::from_millis(10)), | |
| 150 | + | Ok(n) => { | |
| 151 | + | pending.extend_from_slice(&buf[..n]); | |
| 152 | + | let usable = pending.len() - pending.len() % frame_bytes; | |
| 153 | + | for frame in pending[..usable].chunks_exact(frame_bytes) { | |
| 154 | + | let sum: f32 = frame | |
| 155 | + | .chunks_exact(2) | |
| 156 | + | .map(|s| f32::from(i16::from_le_bytes([s[0], s[1]]))) | |
| 157 | + | .sum(); | |
| 158 | + | self.samples | |
| 159 | + | .push(sum / (channels as f32 * f32::from(i16::MAX))); | |
| 160 | + | } | |
| 161 | + | pending.drain(..usable); | |
| 162 | + | let excess = self.samples.len().saturating_sub(FFT_SIZE); | |
| 163 | + | self.samples.drain(..excess); | |
| 164 | + | last_data = Instant::now(); | |
| 165 | + | silent = false; | |
| 166 | + | } | |
| 167 | + | Err(error) if error.kind() == io::ErrorKind::WouldBlock => { | |
| 168 | + | thread::sleep(Duration::from_millis(4)); | |
| 169 | + | } | |
| 170 | + | Err(error) if error.kind() == io::ErrorKind::Interrupted => {} | |
| 171 | + | Err(error) => { | |
| 172 | + | warn!("reading visualizer fifo failed: {error}"); | |
| 173 | + | return; | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | if last_frame.elapsed() >= interval { | |
| 177 | + | last_frame = Instant::now(); | |
| 178 | + | if last_data.elapsed() > Duration::from_millis(150) { | |
| 179 | + | // Paused or stopped: publish silence once so bars fall. | |
| 180 | + | if !silent { | |
| 181 | + | silent = true; | |
| 182 | + | self.samples.fill(0.0); | |
| 183 | + | self.analyze(); | |
| 184 | + | } | |
| 185 | + | } else { | |
| 186 | + | self.analyze(); | |
| 187 | + | } | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | fn analyze(&mut self) { | |
| 193 | + | let mut input: Vec<f32> = self | |
| 194 | + | .samples | |
| 195 | + | .iter() | |
| 196 | + | .zip(&self.window) | |
| 197 | + | .map(|(s, w)| s * w) | |
| 198 | + | .collect(); | |
| 199 | + | let mut output = self.fft.make_output_vec(); | |
| 200 | + | if self.fft.process(&mut input, &mut output).is_err() { | |
| 201 | + | return; | |
| 202 | + | } | |
| 203 | + | // A full-scale sine yields |X| = N/4 with a Hann window. | |
| 204 | + | let scale = 4.0 / FFT_SIZE as f32; | |
| 205 | + | let magnitudes = output.iter().map(|c| c.norm() * scale).collect(); | |
| 206 | + | self.publish(Spectrum { | |
| 207 | + | magnitudes, | |
| 208 | + | bin_hz: self.config.sample_rate as f32 / FFT_SIZE as f32, | |
| 209 | + | error: None, | |
| 210 | + | }); | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | fn publish(&self, spectrum: Spectrum) { | |
| 214 | + | let _ = self.tx.send(Arc::new(spectrum)); | |
| 215 | + | } | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | fn open_fifo(config: &VisualizerConfig) -> io::Result<File> { | |
| 219 | + | let mut options = OpenOptions::new(); | |
| 220 | + | options.read(true); | |
| 221 | + | #[cfg(unix)] | |
| 222 | + | { | |
| 223 | + | use std::os::unix::fs::OpenOptionsExt; | |
| 224 | + | // Never block, whether or not MPD has the fifo open for writing. | |
| 225 | + | options.custom_flags(libc::O_NONBLOCK); | |
| 226 | + | } | |
| 227 | + | options.open(&config.fifo) | |
| 228 | + | } | |