Krolyxon
Commit 96dd350

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.
Author
krolyxon
Date
2026-10-17 17:05 +0530
Parent
3de1a21
Commit
96dd350f4cb964008c398b6c339757a3e76a3d77

+5476 −6

33 files changed

  1. M Cargo.lock +14 −0
  2. M Cargo.toml +2 −1
  3. M src/action.rs +4 −1
  4. A src/app.rs +533 −0
  5. A src/event.rs +20 −0
  6. M src/lib.rs +16 −0
  7. A src/logging.rs +58 −0
  8. A src/lyrics/lrc.rs +165 −0
  9. A src/lyrics/mod.rs +373 −0
  10. M src/main.rs +59 −1
  11. M src/mpd/commands.rs +9 −3
  12. M src/mpd/mod.rs +2 −0
  13. A src/mpd/selection.rs +36 −0
  14. A src/popups/add_to_playlist.rs +115 −0
  15. A src/popups/confirm.rs +69 −0
  16. A src/popups/help.rs +134 −0
  17. A src/popups/input.rs +94 −0
  18. A src/popups/mod.rs +60 −0
  19. A src/screens/browser.rs +334 −0
  20. A src/screens/library.rs +413 −0
  21. A src/screens/lyrics.rs +132 −0
  22. A src/screens/mod.rs +164 −0
  23. A src/screens/playlists.rs +348 −0
  24. A src/screens/queue.rs +284 −0
  25. A src/screens/tag_editor.rs +469 −0
  26. A src/screens/visualizer.rs +107 −0
  27. A src/state.rs +177 −0
  28. A src/tags.rs +112 −0
  29. A src/ui/input.rs +160 −0
  30. A src/ui/list.rs +327 −0
  31. A src/ui/mod.rs +328 −0
  32. A src/visualizer/bars.rs +130 −0
  33. A src/visualizer/mod.rs +228 −0
M Cargo.lock View
@@ -1512,6 +1512,7 @@ dependencies = [
15121512 "rustls-platform-verifier",
15131513 "serde",
15141514 "serde_json",
1515+ "serde_urlencoded",
15151516 "sync_wrapper",
15161517 "tokio",
15171518 "tokio-rustls",
@@ -1546,6 +1547,7 @@ dependencies = [
15461547 "clap",
15471548 "crossterm",
15481549 "futures-util",
1550+ "libc",
15491551 "lofty",
15501552 "nucleo-matcher",
15511553 "ratatui",
@@ -1795,6 +1797,18 @@ dependencies = [
17951797 "serde_core",
17961798 ]
17971799
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+
17981812 [[package]]
17991813 name = "sharded-slab"
18001814 version = "0.1.7"
M Cargo.toml View
@@ -16,11 +16,12 @@ anyhow = "1.0.104"
1616 clap = { version = "4.6.7", features = ["derive"] }
1717 crossterm = { version = "0.29.0", features = ["event-stream"] }
1818 futures-util = "0.3.34"
19+libc = "0.2.186"
1920 lofty = "0.25.4"
2021 nucleo-matcher = "0.3.1"
2122 ratatui = { version = "0.30.2", default-features = false, features = ["crossterm_0_29"] }
2223 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"] }
2425 serde = { version = "1.0.229", features = ["derive"] }
2526 serde_json = "1.0.154"
2627 thiserror = "2.0.21"
M src/action.rs View
@@ -35,7 +35,10 @@ macro_rules! named_enum {
3535 .iter()
3636 .copied()
3737 .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+ })
3942 }
4043 }
4144
A src/app.rs View
@@ -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+}
A src/event.rs View
@@ -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+}
M src/lib.rs View
@@ -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+
17 pub mod action;
8+pub mod app;
29 pub mod config;
10+pub mod event;
11+pub mod logging;
12+pub mod lyrics;
313 pub mod mpd;
14+pub mod popups;
15+pub mod screens;
16+pub mod state;
17+pub mod tags;
18+pub mod ui;
419 pub mod util;
20+pub mod visualizer;
A src/logging.rs View
@@ -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+}
A src/lyrics/lrc.rs View
@@ -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+}
A src/lyrics/mod.rs View
@@ -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+}
M src/main.rs View
@@ -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+}
M src/mpd/commands.rs View
@@ -94,13 +94,19 @@ pub fn add_id(uri: &str) -> Command {
9494 Command::new("addid").arg(uri)
9595 }
9696
97+pub fn delete_pos(pos: u32) -> Command {
98+ Command::new("delete").arg(pos.to_string())
99+}
100+
97101 pub fn delete_id(id: u32) -> Command {
98102 Command::new("deleteid").arg(id.to_string())
99103 }
100104
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())
104110 .arg(to.to_string())
105111 }
106112
M src/mpd/mod.rs View
@@ -4,11 +4,13 @@ pub mod commands;
44 mod connection;
55 mod error;
66 pub mod protocol;
7+mod selection;
78 mod types;
89 mod worker;
910
1011 pub use connection::{Address, Connection};
1112 pub use error::{Ack, MpdError, MpdResult};
1213 pub use protocol::Command;
14+pub use selection::Selection;
1315 pub use types::{Album, DirEntry, OnOff, PlayState, PlaylistInfo, Song, Status, Subsystem};
1416 pub use worker::{MpdEvent, MpdHandle, Query, Reply, Request, spawn};
A src/mpd/selection.rs View
@@ -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+}
A src/popups/add_to_playlist.rs View
@@ -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+}
A src/popups/confirm.rs View
@@ -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+}
A src/popups/help.rs View
@@ -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+}
A src/popups/input.rs View
@@ -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+}
A src/popups/mod.rs View
@@ -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+}
A src/screens/browser.rs View
@@ -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+}
A src/screens/library.rs View
@@ -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+}
A src/screens/lyrics.rs View
@@ -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+}
A src/screens/mod.rs View
@@ -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+}
A src/screens/playlists.rs View
@@ -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+}
A src/screens/queue.rs View
@@ -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+}
A src/screens/tag_editor.rs View
@@ -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+}
A src/screens/visualizer.rs View
@@ -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+}
A src/state.rs View
@@ -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+}
A src/tags.rs View
@@ -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+}
A src/ui/input.rs View
@@ -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+}
A src/ui/list.rs View
@@ -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+}
A src/ui/mod.rs View
@@ -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+}
A src/visualizer/bars.rs View
@@ -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+}
A src/visualizer/mod.rs View
@@ -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+}