a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv
chromecast
mpris
navidrome
jellyfin
upnp
tui
1use std::io;
2use std::sync::atomic::{AtomicU64, Ordering};
3use std::sync::Arc;
4use std::time::{Duration, Instant};
5
6use anyhow::{anyhow, Context, Result};
7use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
8use crossterm::execute;
9use crossterm::terminal::{
10 disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
11};
12use parking_lot::Mutex;
13use ratatui::backend::CrosstermBackend;
14use ratatui::layout::{Alignment, Constraint, Direction, Layout, Margin, Rect};
15use ratatui::style::{Modifier, Style};
16use ratatui::text::{Line, Span};
17use ratatui::widgets::{List, ListItem, ListState, Paragraph, Wrap};
18use ratatui::{Frame, Terminal};
19use tokio::sync::mpsc;
20use tracing::warn;
21
22use fin_config::{Config, RendererPref};
23use fin_jellyfin::{BaseItem, ItemKind, JellyfinClient, StreamFormat};
24use fin_media::MediaClient;
25use fin_player::{
26 discover_chromecasts, discover_upnp_renderers, CastDevice, ChromecastRenderer, LocalRenderer,
27 PlaybackState, QueueItem, Renderer, RendererKind, UpnpDevice, UpnpRenderer,
28};
29
30/// A single row in the Devices screen. Both Chromecast and UPnP renderers
31/// share the same list so the user picks by name rather than by protocol.
32#[derive(Debug, Clone)]
33pub enum RemoteDevice {
34 Cast(CastDevice),
35 Upnp(UpnpDevice),
36}
37
38impl RemoteDevice {
39 pub fn display_name(&self) -> String {
40 match self {
41 Self::Cast(d) => d.display_name(),
42 Self::Upnp(d) => d.display_name(),
43 }
44 }
45
46 pub fn kind_label(&self) -> &'static str {
47 match self {
48 Self::Cast(_) => "chromecast",
49 Self::Upnp(_) => "upnp",
50 }
51 }
52
53 pub fn subtitle(&self) -> String {
54 match self {
55 Self::Cast(d) => format!("{} {}:{}", d.model, d.address, d.port),
56 Self::Upnp(d) => {
57 let model = if d.model.is_empty() {
58 "-".to_string()
59 } else {
60 d.model.clone()
61 };
62 let manuf = if d.manufacturer.is_empty() {
63 String::new()
64 } else {
65 format!("{} ", d.manufacturer)
66 };
67 format!("{manuf}{model} {}", d.address)
68 }
69 }
70 }
71}
72
73use crate::event::{spawn_event_loop, AppEvent};
74use crate::screens::{item_row_line, RowLayout, Screen};
75use crate::theme::{accent_style, base_style, muted_style, title_style, Palette};
76use crate::widgets::{neon_block, EqSliders, HelpModal, NeonTabs, PlayerBar};
77
78/// Everything the render loop needs.
79pub struct App {
80 pub config: Arc<Mutex<Config>>,
81 pub jellyfin: Arc<Mutex<Arc<dyn MediaClient>>>,
82 pub renderer: Arc<Mutex<Arc<dyn Renderer>>>,
83 pub renderer_kind: Arc<Mutex<RendererKind>>,
84 pub renderer_label: Arc<Mutex<String>>,
85 screen: Screen,
86 // shared display state
87 music_items: Arc<Mutex<Vec<BaseItem>>>,
88 video_items: Arc<Mutex<Vec<BaseItem>>>,
89 playlists: Arc<Mutex<Vec<BaseItem>>>,
90 playlist_items: Arc<Mutex<Vec<BaseItem>>>,
91 open_playlist: Arc<Mutex<Option<BaseItem>>>,
92 album_tracks: Arc<Mutex<Vec<BaseItem>>>,
93 open_album: Arc<Mutex<Option<BaseItem>>>,
94 series_children: Arc<Mutex<Vec<BaseItem>>>,
95 open_series: Arc<Mutex<Option<BaseItem>>>,
96 search_results: Arc<Mutex<Vec<BaseItem>>>,
97 devices: Arc<Mutex<Vec<RemoteDevice>>>,
98 search_generation: Arc<AtomicU64>,
99 // ephemeral
100 search_query: String,
101 search_input_focused: bool,
102 list_state: ListState,
103 status_message: Arc<Mutex<Option<(String, Instant)>>>,
104 playback_state: Arc<Mutex<PlaybackState>>,
105 /// Which EQ band the user is currently editing (0..N-1). Only meaningful
106 /// on the Settings screen. Nudged by `[` / `]`.
107 eq_selected_band: usize,
108 /// Toggled by `?`. When true, every other key is swallowed by the modal
109 /// and the underlying UI is dimmed by the popup's own background fill.
110 help_open: bool,
111 /// Scrobble bookkeeping — the item id we last told the server about,
112 /// plus the moment we sent the last progress ping so we can throttle.
113 scrobble_reported_id: Option<String>,
114 scrobble_last_progress: Instant,
115 scrobble_session_id: String,
116 should_quit: bool,
117 logo_pulse: u8,
118}
119
120impl App {
121 pub fn new(
122 config: Config,
123 jellyfin: Arc<dyn MediaClient>,
124 renderer: Arc<dyn Renderer>,
125 ) -> Self {
126 let kind = renderer.kind();
127 let label = match kind {
128 RendererKind::Mpv => "this machine".into(),
129 RendererKind::Chromecast => "Chromecast".into(),
130 RendererKind::Upnp => "UPnP".into(),
131 };
132 let mut list_state = ListState::default();
133 list_state.select(Some(0));
134 Self {
135 config: Arc::new(Mutex::new(config)),
136 jellyfin: Arc::new(Mutex::new(jellyfin)),
137 renderer: Arc::new(Mutex::new(renderer)),
138 renderer_kind: Arc::new(Mutex::new(kind)),
139 renderer_label: Arc::new(Mutex::new(label)),
140 screen: Screen::Music,
141 music_items: Arc::new(Mutex::new(vec![])),
142 video_items: Arc::new(Mutex::new(vec![])),
143 playlists: Arc::new(Mutex::new(vec![])),
144 playlist_items: Arc::new(Mutex::new(vec![])),
145 open_playlist: Arc::new(Mutex::new(None)),
146 album_tracks: Arc::new(Mutex::new(vec![])),
147 open_album: Arc::new(Mutex::new(None)),
148 series_children: Arc::new(Mutex::new(vec![])),
149 open_series: Arc::new(Mutex::new(None)),
150 search_results: Arc::new(Mutex::new(vec![])),
151 devices: Arc::new(Mutex::new(vec![])),
152 search_generation: Arc::new(AtomicU64::new(0)),
153 search_query: String::new(),
154 search_input_focused: true,
155 list_state,
156 status_message: Arc::new(Mutex::new(None)),
157 playback_state: Arc::new(Mutex::new(PlaybackState::default())),
158 eq_selected_band: 0,
159 help_open: false,
160 scrobble_reported_id: None,
161 scrobble_last_progress: Instant::now(),
162 // One session id per fin process — Jellyfin uses it to correlate
163 // Start / Progress / Stopped events, Subsonic ignores it.
164 scrobble_session_id: uuid::Uuid::new_v4().to_string(),
165 should_quit: false,
166 logo_pulse: 0,
167 }
168 }
169
170 /// Handy accessor — the current Jellyfin client. Swapped out atomically
171 /// when the user switches servers.
172 fn jf(&self) -> Arc<dyn MediaClient> {
173 self.jellyfin.lock().clone()
174 }
175
176 fn current_list(&self) -> Vec<BaseItem> {
177 match self.screen {
178 Screen::Search => self.search_results.lock().clone(),
179 Screen::Music => {
180 if self.open_album.lock().is_some() {
181 self.album_tracks.lock().clone()
182 } else {
183 self.music_items.lock().clone()
184 }
185 }
186 Screen::Videos => {
187 if self.open_series.lock().is_some() {
188 self.series_children.lock().clone()
189 } else {
190 self.video_items.lock().clone()
191 }
192 }
193 Screen::Playlists => {
194 if self.open_playlist.lock().is_some() {
195 self.playlist_items.lock().clone()
196 } else {
197 self.playlists.lock().clone()
198 }
199 }
200 Screen::Queue => {
201 let items = self.playback_state.lock().queue.clone();
202 items
203 .into_iter()
204 .map(|q| BaseItem {
205 id: q.id,
206 name: q.title,
207 type_: if q.is_video {
208 "Video".into()
209 } else {
210 "Audio".into()
211 },
212 album: None,
213 album_id: None,
214 album_artist: None,
215 artists: if q.subtitle.is_empty() {
216 None
217 } else {
218 Some(vec![q.subtitle])
219 },
220 series_name: None,
221 production_year: None,
222 run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64),
223 media_type: None,
224 container: None,
225 index_number: None,
226 parent_index_number: None,
227 image_tags: None,
228 is_folder: None,
229 overview: None,
230 })
231 .collect()
232 }
233 Screen::Devices | Screen::Settings => vec![],
234 }
235 }
236
237 fn selected_item(&self) -> Option<BaseItem> {
238 let list = self.current_list();
239 self.list_state
240 .selected()
241 .and_then(|i| list.get(i).cloned())
242 }
243
244 /// Length of the currently-navigable list for the active screen.
245 /// Devices and Settings render their own row types, not `BaseItem`s, so
246 /// `current_list()` is empty for them — nav has to size against the real
247 /// backing collection instead.
248 fn list_len(&self) -> usize {
249 match self.screen {
250 Screen::Devices => self.devices.lock().len(),
251 Screen::Settings => self.config.lock().servers.len(),
252 _ => self.current_list().len(),
253 }
254 }
255
256 fn set_status(&self, msg: impl Into<String>) {
257 *self.status_message.lock() = Some((msg.into(), Instant::now()));
258 }
259
260 async fn load_screen(&self) {
261 match self.screen {
262 Screen::Music => {
263 let jf = self.jf();
264 let out = self.music_items.clone();
265 let status = self.status_message.clone();
266 tokio::spawn(async move {
267 // No limit — Jellyfin's `Limit=<omitted>` returns all
268 // matching items. Users with 50k+ tracks get everything.
269 match jf
270 .items(None, &["MusicAlbum"], true, Some("SortName"), None)
271 .await
272 {
273 Ok(v) => {
274 *status.lock() =
275 Some((format!("♪ {} album(s)", v.len()), Instant::now()));
276 *out.lock() = v;
277 }
278 Err(e) => *status.lock() = Some((format!("music: {}", e), Instant::now())),
279 }
280 });
281 }
282 Screen::Videos => {
283 let jf = self.jf();
284 let out = self.video_items.clone();
285 let status = self.status_message.clone();
286 tokio::spawn(async move {
287 match jf
288 .items(None, &["Movie", "Series"], true, Some("SortName"), None)
289 .await
290 {
291 Ok(v) => {
292 *status.lock() =
293 Some((format!("▶ {} item(s)", v.len()), Instant::now()));
294 *out.lock() = v;
295 }
296 Err(e) => *status.lock() = Some((format!("videos: {}", e), Instant::now())),
297 }
298 });
299 }
300 Screen::Playlists => {
301 let jf = self.jf();
302 let out = self.playlists.clone();
303 let status = self.status_message.clone();
304 tokio::spawn(async move {
305 match jf.playlists().await {
306 Ok(v) => *out.lock() = v,
307 Err(e) => {
308 *status.lock() = Some((format!("playlists: {}", e), Instant::now()))
309 }
310 }
311 });
312 }
313 Screen::Devices => {
314 let out = self.devices.clone();
315 let status = self.status_message.clone();
316 tokio::spawn(async move {
317 *status.lock() = Some((
318 "Scanning for Chromecasts & UPnP renderers…".into(),
319 Instant::now(),
320 ));
321 let (casts, upnps) = tokio::join!(
322 discover_chromecasts(Duration::from_secs(4)),
323 discover_upnp_renderers(Duration::from_secs(4)),
324 );
325 let mut merged: Vec<RemoteDevice> = Vec::new();
326 match casts {
327 Ok(v) => merged.extend(v.into_iter().map(RemoteDevice::Cast)),
328 Err(e) => tracing::warn!(?e, "chromecast scan failed"),
329 }
330 match upnps {
331 Ok(v) => merged.extend(v.into_iter().map(RemoteDevice::Upnp)),
332 Err(e) => tracing::warn!(?e, "upnp scan failed"),
333 }
334 merged.sort_by(|a, b| a.display_name().cmp(&b.display_name()));
335 *status.lock() =
336 Some((format!("Found {} device(s).", merged.len()), Instant::now()));
337 *out.lock() = merged;
338 });
339 }
340 _ => {}
341 }
342 }
343
344 /// fzf-style instant search — fires on every keystroke.
345 /// Uses a generation counter so a slower response for an older query
346 /// can never overwrite results from a newer one.
347 fn run_search(&self) {
348 let query = self.search_query.trim().to_string();
349 let gen = self.search_generation.fetch_add(1, Ordering::SeqCst) + 1;
350 if query.is_empty() {
351 self.search_results.lock().clear();
352 *self.status_message.lock() = None;
353 return;
354 }
355 // Immediate feedback while the request is in flight.
356 *self.status_message.lock() =
357 Some((format!("⌕ searching for “{}”…", query), Instant::now()));
358 let jf = self.jf();
359 let out = self.search_results.clone();
360 let status = self.status_message.clone();
361 let generation = self.search_generation.clone();
362 tokio::spawn(async move {
363 // Very small coalescing window — enough to avoid firing three
364 // requests when a fast typist hits three keys in <30ms, but not
365 // so long that the UI feels dead.
366 tokio::time::sleep(Duration::from_millis(40)).await;
367 if generation.load(Ordering::SeqCst) != gen {
368 return;
369 }
370 match jf
371 .search(
372 &query,
373 &[
374 "Audio",
375 "MusicAlbum",
376 "MusicArtist",
377 "Movie",
378 "Series",
379 "Episode",
380 ],
381 50,
382 )
383 .await
384 {
385 Ok(v) => {
386 if generation.load(Ordering::SeqCst) == gen {
387 *status.lock() = Some((
388 format!("⌕ {} match(es) for “{}”", v.len(), query),
389 Instant::now(),
390 ));
391 *out.lock() = v;
392 }
393 }
394 Err(e) => {
395 if generation.load(Ordering::SeqCst) == gen {
396 *status.lock() = Some((format!("⌕ search failed: {}", e), Instant::now()));
397 tracing::warn!(query=%query, error=?e, "jellyfin search failed");
398 }
399 }
400 }
401 });
402 }
403
404 fn base_item_to_queue(&self, item: &BaseItem, format: StreamFormat) -> Result<QueueItem> {
405 let url = self.jf().stream_url(item, format)?;
406 let is_video = item.kind().is_video();
407 // Content type derives from the URL that stream_url() actually built,
408 // so it always matches what the receiver will be handed.
409 let content_type = JellyfinClient::content_type_for_url(&url).to_string();
410 let image_url = item
411 .image_tags
412 .as_ref()
413 .and_then(|v| v.get("Primary"))
414 .and_then(|v| v.as_str())
415 .map(|tag| self.jf().image_url(&item.id, tag, 480));
416 Ok(QueueItem {
417 id: item.id.clone(),
418 title: item.name.clone(),
419 subtitle: item.subtitle(),
420 stream_url: url,
421 image_url,
422 duration_secs: item.duration_secs(),
423 is_video,
424 content_type,
425 })
426 }
427
428 async fn expand_playable(&self, item: BaseItem) -> Result<Vec<BaseItem>> {
429 match item.kind() {
430 ItemKind::MusicAlbum => {
431 self.jf()
432 .items(
433 Some(&item.id),
434 &["Audio"],
435 false,
436 Some("ParentIndexNumber,IndexNumber,SortName"),
437 None,
438 )
439 .await
440 }
441 ItemKind::MusicArtist => {
442 self.jf()
443 .items(Some(&item.id), &["Audio"], true, Some("Album"), None)
444 .await
445 }
446 ItemKind::Playlist => self.jf().playlist_items(&item.id).await,
447 ItemKind::Series => {
448 self.jf()
449 .items(
450 Some(&item.id),
451 &["Episode"],
452 true,
453 Some("ParentIndexNumber,IndexNumber,SortName"),
454 None,
455 )
456 .await
457 }
458 _ => Ok(vec![item]),
459 }
460 }
461
462 /// Bump the bass shelf gain by `delta_db`, clamp to ±24 dB, persist,
463 /// and push the new values to the DSP.
464 async fn nudge_tone_bass(&mut self, delta_db: i32) {
465 self.nudge_tone(delta_db, 0).await;
466 }
467
468 /// Bump the treble shelf gain by `delta_db`, clamp to ±24 dB, persist,
469 /// and push the new values to the DSP.
470 async fn nudge_tone_treble(&mut self, delta_db: i32) {
471 self.nudge_tone(0, delta_db).await;
472 }
473
474 async fn nudge_tone(&mut self, d_bass: i32, d_treble: i32) {
475 let (bass, treble, bass_cut, treble_cut) = {
476 let mut cfg = self.config.lock();
477 cfg.bass = (cfg.bass + d_bass).clamp(-24, 24);
478 cfg.treble = (cfg.treble + d_treble).clamp(-24, 24);
479 let _ = cfg.save();
480 (cfg.bass, cfg.treble, cfg.bass_cutoff, cfg.treble_cutoff)
481 };
482 let renderer = self.renderer.lock().clone();
483 let _ = renderer.set_tone(bass, treble, bass_cut, treble_cut).await;
484 self.set_status(format!("Tone: bass {:+} dB treble {:+} dB", bass, treble));
485 }
486
487 /// Adjust the selected EQ band's gain by `delta_tenths` (Rockbox
488 /// tenths-of-dB units). Persists to config and reapplies to the DSP.
489 async fn nudge_eq_band_gain(&mut self, delta_tenths: i32) {
490 let (new_enabled, bands) = {
491 let mut cfg = self.config.lock();
492 if cfg.eq_band_settings.is_empty() {
493 self.set_status("No EQ bands — add [[eq_band_settings]] to config.toml");
494 return;
495 }
496 let idx = self
497 .eq_selected_band
498 .min(cfg.eq_band_settings.len() - 1);
499 let band = &mut cfg.eq_band_settings[idx];
500 band.gain = (band.gain + delta_tenths).clamp(-240, 240);
501 let hz = band.cutoff;
502 let g = band.gain as f32 / 10.0;
503 drop(cfg);
504 self.set_status(format!(
505 "band {}: {} Hz → {:+.1} dB",
506 idx + 1,
507 hz,
508 g
509 ));
510 let cfg = self.config.lock();
511 let _ = cfg.save();
512 (cfg.eq_enabled, cfg.eq_band_settings.clone())
513 };
514 let renderer = self.renderer.lock().clone();
515 let _ = renderer.set_eq(new_enabled, bands).await;
516 }
517
518 /// Delete the highlighted entry from the queue. If it's the item being
519 /// played, playback jumps to the next entry (or stops if the queue is
520 /// now empty); otherwise the currently-playing track keeps going and
521 /// only the surrounding queue shrinks.
522 async fn remove_selected_from_queue(&self) {
523 let Some(idx) = self.list_state.selected() else {
524 return;
525 };
526 let items = self.playback_state.lock().queue.clone();
527 if items.is_empty() || idx >= items.len() {
528 self.set_status("Nothing to remove.");
529 return;
530 }
531 let title = items[idx].title.clone();
532 let renderer = self.renderer.lock().clone();
533 match renderer.remove_from_queue(idx).await {
534 Ok(()) => self.set_status(format!("− Removed “{}”", title)),
535 Err(e) => self.set_status(format!("remove: {}", e)),
536 }
537 }
538
539 /// Enter on the Queue screen must NOT replace the queue with just the
540 /// selected item. Instead, restart playback from the current queue's
541 /// selected index — same items, new playhead.
542 async fn jump_to_queue_index(&self) {
543 let Some(idx) = self.list_state.selected() else {
544 return;
545 };
546 let items = self.playback_state.lock().queue.clone();
547 if items.is_empty() || idx >= items.len() {
548 self.set_status("Nothing to jump to.");
549 return;
550 }
551 let title = items[idx].title.clone();
552 let renderer = self.renderer.lock().clone();
553 match renderer.play(items, idx).await {
554 Ok(()) => self.set_status(format!("▶ Jumped to “{}”", title)),
555 Err(e) => self.set_status(format!("jump: {}", e)),
556 }
557 }
558
559 async fn play_selected(&mut self, mode: PlayMode) {
560 let Some(item) = self.selected_item() else {
561 self.set_status("Nothing selected.");
562 return;
563 };
564
565 // Drill-in behaviour: Enter (PlayNow) on a *container* opens it and
566 // shows its children. `a` (Enqueue), `n` (PlayNext), and `x`
567 // (PlayContainer) always dispatch straight to the renderer.
568 if mode == PlayMode::PlayNow {
569 match (self.screen, item.kind()) {
570 (Screen::Playlists, _) if self.open_playlist.lock().is_none() => {
571 self.open_playlist_selected(item).await;
572 return;
573 }
574 (Screen::Music, ItemKind::MusicAlbum) if self.open_album.lock().is_none() => {
575 self.open_album_selected(item).await;
576 return;
577 }
578 (Screen::Videos, ItemKind::Series) if self.open_series.lock().is_none() => {
579 self.open_series_selected(item).await;
580 return;
581 }
582 _ => {}
583 }
584 }
585
586 let items = match self.expand_playable(item).await {
587 Ok(v) => v,
588 Err(e) => {
589 self.set_status(format!("expand: {}", e));
590 return;
591 }
592 };
593 if items.is_empty() {
594 self.set_status("Nothing playable here.");
595 return;
596 }
597 // Both renderers get a Direct URL — Jellyfin serves the source
598 // container as-is so no unnecessary transcoding happens. Chromecast
599 // handles MP3/AAC/FLAC/Opus audio and H.264 MP4 video natively;
600 // for anything else you can force HLS transcoding with `--hls`
601 // from the CLI (or by switching the renderer's format).
602 let format = StreamFormat::Direct;
603 let mut queue_items = Vec::with_capacity(items.len());
604 for it in &items {
605 match self.base_item_to_queue(it, format) {
606 Ok(q) => queue_items.push(q),
607 Err(e) => warn!(?e, "skip item"),
608 }
609 }
610 if queue_items.is_empty() {
611 self.set_status("No playable stream URLs.");
612 return;
613 }
614 let renderer = self.renderer.lock().clone();
615 let title = queue_items
616 .first()
617 .map(|q| q.title.clone())
618 .unwrap_or_default();
619 let count = queue_items.len();
620 let result = match mode {
621 PlayMode::PlayNow | PlayMode::PlayContainer => renderer.play(queue_items, 0).await,
622 PlayMode::Enqueue => renderer.enqueue(queue_items).await,
623 PlayMode::PlayNext => renderer.play_next(queue_items).await,
624 };
625 match result {
626 Ok(()) => self.set_status(match mode {
627 PlayMode::PlayNow => format!("▶ Playing “{}” ({} item(s))", title, count),
628 PlayMode::PlayContainer => {
629 format!("▶▶ Playing full container ({} item(s))", count)
630 }
631 PlayMode::Enqueue => format!("+ Queued {} item(s)", count),
632 PlayMode::PlayNext => format!("↥ Playing next: {} item(s)", count),
633 }),
634 Err(e) => self.set_status(format!("renderer: {}", e)),
635 }
636 }
637
638 async fn open_album_selected(&mut self, item: BaseItem) {
639 let jf = self.jf();
640 let out = self.album_tracks.clone();
641 let open = self.open_album.clone();
642 let status = self.status_message.clone();
643 let id = item.id.clone();
644 let name = item.name.clone();
645 *open.lock() = Some(item);
646 tokio::spawn(async move {
647 match jf
648 .items(
649 Some(&id),
650 &["Audio"],
651 false,
652 Some("ParentIndexNumber,IndexNumber,SortName"),
653 None,
654 )
655 .await
656 {
657 Ok(mut v) => {
658 // Belt-and-braces: Jellyfin already applies the sort
659 // above, but some libraries have stale metadata and we
660 // don't want a single mis-tagged track to break disc
661 // grouping downstream.
662 sort_album_tracks(&mut v);
663 *status.lock() =
664 Some((format!("◈ {} — {} track(s)", name, v.len()), Instant::now()));
665 *out.lock() = v;
666 }
667 Err(e) => *status.lock() = Some((format!("album: {}", e), Instant::now())),
668 }
669 });
670 self.list_state.select(Some(0));
671 }
672
673 async fn open_series_selected(&mut self, item: BaseItem) {
674 let jf = self.jf();
675 let out = self.series_children.clone();
676 let open = self.open_series.clone();
677 let status = self.status_message.clone();
678 let id = item.id.clone();
679 let name = item.name.clone();
680 *open.lock() = Some(item);
681 tokio::spawn(async move {
682 match jf
683 .items(
684 Some(&id),
685 &["Episode"],
686 true,
687 Some("ParentIndexNumber,IndexNumber,SortName"),
688 None,
689 )
690 .await
691 {
692 Ok(v) => {
693 *status.lock() = Some((
694 format!("▶ {} — {} episode(s)", name, v.len()),
695 Instant::now(),
696 ));
697 *out.lock() = v;
698 }
699 Err(e) => *status.lock() = Some((format!("series: {}", e), Instant::now())),
700 }
701 });
702 self.list_state.select(Some(0));
703 }
704
705 async fn open_playlist_selected(&mut self, item: BaseItem) {
706 let jf = self.jf();
707 let out = self.playlist_items.clone();
708 let open = self.open_playlist.clone();
709 let status = self.status_message.clone();
710 let id = item.id.clone();
711 *open.lock() = Some(item);
712 tokio::spawn(async move {
713 match jf.playlist_items(&id).await {
714 Ok(v) => {
715 *status.lock() = Some((format!("Loaded {} items", v.len()), Instant::now()));
716 *out.lock() = v;
717 }
718 Err(e) => *status.lock() = Some((format!("playlist: {}", e), Instant::now())),
719 }
720 });
721 self.list_state.select(Some(0));
722 }
723
724 async fn use_selected_device(&self) -> Result<()> {
725 let devices = self.devices.lock().clone();
726 let Some(sel) = self
727 .list_state
728 .selected()
729 .and_then(|i| devices.get(i).cloned())
730 else {
731 return Err(anyhow!("no device selected"));
732 };
733 let name = sel.display_name();
734 self.set_status(format!("Connecting to {}…", name));
735 match sel {
736 RemoteDevice::Cast(dev) => {
737 let renderer = ChromecastRenderer::connect(dev).await?;
738 let arc: Arc<dyn Renderer> = Arc::new(renderer);
739 *self.renderer.lock() = arc;
740 *self.renderer_kind.lock() = RendererKind::Chromecast;
741 *self.renderer_label.lock() = name.clone();
742 let mut cfg = self.config.lock();
743 cfg.renderer = RendererPref::Chromecast;
744 cfg.last_chromecast = Some(name.clone());
745 let _ = cfg.save();
746 }
747 RemoteDevice::Upnp(dev) => {
748 let renderer = UpnpRenderer::connect(dev).await?;
749 let arc: Arc<dyn Renderer> = Arc::new(renderer);
750 *self.renderer.lock() = arc;
751 *self.renderer_kind.lock() = RendererKind::Upnp;
752 *self.renderer_label.lock() = name.clone();
753 let mut cfg = self.config.lock();
754 cfg.renderer = RendererPref::Upnp;
755 cfg.last_upnp = Some(name.clone());
756 let _ = cfg.save();
757 }
758 }
759 self.set_status(format!("Streaming to {}.", name));
760 Ok(())
761 }
762
763 /// Switch to a saved server by name — updates config, rebuilds the
764 /// Jellyfin client, and reloads the current screen.
765 fn switch_server(&self, name: &str) -> Result<()> {
766 let server = {
767 let mut cfg = self.config.lock();
768 cfg.switch_to(name)?;
769 cfg.save()?;
770 cfg.current().cloned()
771 };
772 let Some(server) = server else {
773 return Err(anyhow!("no active server after switch"));
774 };
775 let client = fin_media::client_from_stored(
776 server.server_kind,
777 &server.url,
778 &server.device_id,
779 &server.user_id,
780 &server.user_name,
781 &server.access_token,
782 )?;
783 *self.jellyfin.lock() = client;
784 // Clear cached content — belongs to the *previous* server.
785 self.music_items.lock().clear();
786 self.video_items.lock().clear();
787 self.playlists.lock().clear();
788 self.playlist_items.lock().clear();
789 *self.open_playlist.lock() = None;
790 self.search_results.lock().clear();
791 self.set_status(format!(
792 "◉ Switched to `{}` ({}, as {})",
793 server.name, server.url, server.user_name
794 ));
795 Ok(())
796 }
797
798 fn cycle_server(&self) {
799 let new_name = {
800 let mut cfg = self.config.lock();
801 cfg.cycle_next().map(|s| s.name.clone())
802 };
803 let Some(name) = new_name else {
804 self.set_status("No other server to switch to.");
805 return;
806 };
807 if let Err(e) = self.switch_server(&name) {
808 self.set_status(format!("switch: {}", e));
809 }
810 }
811
812 async fn switch_to_mpv(&self) {
813 // Local playback: audio → symphonia, video → mpv. Persistence is
814 // wired in so switching to local while there's a saved queue picks
815 // it up on next restart (the current session keeps whatever queue
816 // was already active).
817 let queue_path = fin_config::queue_path().ok();
818 let renderer = LocalRenderer::with_persist(queue_path);
819 let arc: Arc<dyn Renderer> = Arc::new(renderer);
820 *self.renderer.lock() = arc;
821 *self.renderer_kind.lock() = RendererKind::Mpv;
822 *self.renderer_label.lock() = "this machine".into();
823 {
824 let mut cfg = self.config.lock();
825 cfg.renderer = RendererPref::Mpv;
826 let _ = cfg.save();
827 }
828 self.set_status("Streaming locally (symphonia audio, mpv video).");
829 }
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
833enum PlayMode {
834 /// Default Enter behaviour — drills into containers, plays leaves.
835 PlayNow,
836 /// `a` — append to the current queue.
837 Enqueue,
838 /// `n` — play next (insert after the current item).
839 PlayNext,
840 /// `x` — play the whole container (or leaf) NOW, replacing the queue.
841 /// Skips drill-in so an album or playlist starts playing straight away.
842 PlayContainer,
843}
844
845pub async fn run_tui(app: App) -> Result<()> {
846 enable_raw_mode()?;
847 let mut stdout = io::stdout();
848 execute!(stdout, EnterAlternateScreen)?;
849 let backend = CrosstermBackend::new(stdout);
850 let mut terminal = Terminal::new(backend)?;
851
852 let mut app = app;
853 let mut events = spawn_event_loop(Duration::from_millis(200));
854 app.load_screen().await;
855
856 let result = event_loop(&mut app, &mut terminal, &mut events).await;
857
858 disable_raw_mode()?;
859 execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
860 terminal.show_cursor()?;
861 result
862}
863
864async fn event_loop(
865 app: &mut App,
866 terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
867 events: &mut mpsc::UnboundedReceiver<AppEvent>,
868) -> Result<()> {
869 while !app.should_quit {
870 // Refresh live playback state each tick.
871 *app.playback_state.lock() = app.renderer.lock().state();
872
873 emit_scrobble_events(app).await;
874
875 terminal.draw(|f| draw(f, app))?;
876
877 if let Some(ev) = events.recv().await {
878 match ev {
879 AppEvent::Tick => {
880 app.logo_pulse = app.logo_pulse.wrapping_add(1);
881 }
882 AppEvent::Resize(_, _) => {}
883 AppEvent::Key(k) => handle_key(app, k).await?,
884 }
885 } else {
886 break;
887 }
888 }
889 Ok(())
890}
891
892/// Emit Jellyfin session events / Subsonic scrobbles for track transitions.
893/// Detects three edges from `playback_state`:
894/// - `now_playing` changed to a new item → `report_stopped` on the old one,
895/// `report_started` on the new one.
896/// - Same item still playing → send `report_progress` every ~10 s (Jellyfin;
897/// Subsonic's default impl is a no-op).
898/// - Track dropped to None → `report_stopped` on whatever was reported.
899///
900/// Every network call is a best-effort spawn: failure is logged and the
901/// TUI keeps drawing. Reports only fire for audio items — Chromecast and
902/// UPnP receivers already send their own session events server-side.
903async fn emit_scrobble_events(app: &mut App) {
904 let state = app.playback_state.lock().clone();
905 let session_id = app.scrobble_session_id.clone();
906 let client = app.jellyfin.lock().clone();
907 let renderer_kind = *app.renderer_kind.lock();
908 // Only report for local audio playback — remote renderers manage their
909 // own reporting on the server side.
910 if !matches!(renderer_kind, fin_player::RendererKind::Mpv) {
911 return;
912 }
913
914 let now_playing_id = state
915 .now_playing
916 .as_ref()
917 .filter(|it| !it.is_video)
918 .map(|it| it.id.clone());
919
920 match (&app.scrobble_reported_id, &now_playing_id) {
921 (Some(prev), Some(cur)) if prev == cur => {
922 // Same track — throttled progress ping.
923 if state.status == fin_player::PlaybackStatus::Playing
924 && app.scrobble_last_progress.elapsed() >= Duration::from_secs(10)
925 {
926 if let Some(item) = state.now_playing.as_ref() {
927 let base_item = queue_item_to_base_item(item);
928 let client = client.clone();
929 let session_id = session_id.clone();
930 let pos = state.position_secs.max(0.0) as u64;
931 tokio::spawn(async move {
932 if let Err(e) = client
933 .report_progress(&base_item, pos, false, &session_id)
934 .await
935 {
936 warn!(?e, "scrobble progress failed");
937 }
938 });
939 app.scrobble_last_progress = Instant::now();
940 }
941 }
942 }
943 (prev_opt, Some(cur)) => {
944 // Track transition: stop the previous, start the new.
945 if let Some(prev_id) = prev_opt.clone() {
946 let client_c = client.clone();
947 let session_id_c = session_id.clone();
948 let stopped_pos = state.position_secs.max(0.0) as u64;
949 let stub = BaseItem {
950 id: prev_id.clone(),
951 name: prev_id,
952 type_: "Audio".into(),
953 album: None,
954 album_id: None,
955 album_artist: None,
956 artists: None,
957 series_name: None,
958 production_year: None,
959 run_time_ticks: None,
960 media_type: Some("Audio".into()),
961 container: None,
962 index_number: None,
963 parent_index_number: None,
964 image_tags: None,
965 is_folder: Some(false),
966 overview: None,
967 };
968 tokio::spawn(async move {
969 if let Err(e) = client_c
970 .report_stopped(&stub, stopped_pos, &session_id_c)
971 .await
972 {
973 warn!(?e, "scrobble stopped failed");
974 }
975 });
976 }
977 let item = state.now_playing.as_ref().unwrap();
978 let base_item = queue_item_to_base_item(item);
979 let client_c = client.clone();
980 let session_id_c = session_id.clone();
981 tokio::spawn(async move {
982 if let Err(e) = client_c.report_started(&base_item, &session_id_c).await {
983 warn!(?e, "scrobble started failed");
984 }
985 });
986 app.scrobble_reported_id = Some(cur.clone());
987 app.scrobble_last_progress = Instant::now();
988 }
989 (Some(prev_id), None) => {
990 // Playback ended — fire the closing report.
991 let prev_id = prev_id.clone();
992 let client_c = client.clone();
993 let session_id_c = session_id.clone();
994 let stopped_pos = state.position_secs.max(0.0) as u64;
995 let stub = BaseItem {
996 id: prev_id.clone(),
997 name: prev_id,
998 type_: "Audio".into(),
999 album: None,
1000 album_id: None,
1001 album_artist: None,
1002 artists: None,
1003 series_name: None,
1004 production_year: None,
1005 run_time_ticks: None,
1006 media_type: Some("Audio".into()),
1007 container: None,
1008 index_number: None,
1009 parent_index_number: None,
1010 image_tags: None,
1011 is_folder: Some(false),
1012 overview: None,
1013 };
1014 tokio::spawn(async move {
1015 if let Err(e) = client_c
1016 .report_stopped(&stub, stopped_pos, &session_id_c)
1017 .await
1018 {
1019 warn!(?e, "scrobble stopped failed");
1020 }
1021 });
1022 app.scrobble_reported_id = None;
1023 }
1024 (None, None) => {}
1025 }
1026}
1027
1028/// Adapt a `QueueItem` (what the renderer holds) into a `BaseItem` for the
1029/// scrobble APIs. The id is all any server actually needs to correlate.
1030fn queue_item_to_base_item(q: &fin_player::QueueItem) -> BaseItem {
1031 BaseItem {
1032 id: q.id.clone(),
1033 name: q.title.clone(),
1034 type_: if q.is_video { "Video".into() } else { "Audio".into() },
1035 album: None,
1036 album_id: None,
1037 album_artist: None,
1038 artists: if q.subtitle.is_empty() {
1039 None
1040 } else {
1041 Some(vec![q.subtitle.clone()])
1042 },
1043 series_name: None,
1044 production_year: None,
1045 run_time_ticks: q.duration_secs.map(|s| (s * 10_000_000) as i64),
1046 media_type: Some(if q.is_video { "Video".into() } else { "Audio".into() }),
1047 container: None,
1048 index_number: None,
1049 parent_index_number: None,
1050 image_tags: None,
1051 is_folder: Some(false),
1052 overview: None,
1053 }
1054}
1055
1056async fn handle_key(app: &mut App, key: KeyEvent) -> Result<()> {
1057 // When the help modal is open, only `?`, Esc, and Ctrl+C do anything —
1058 // everything else is deliberately swallowed so the user can't
1059 // accidentally play music or switch screens while reading.
1060 if app.help_open {
1061 match key.code {
1062 KeyCode::Char('?') | KeyCode::Esc => app.help_open = false,
1063 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1064 app.should_quit = true;
1065 }
1066 _ => {}
1067 }
1068 return Ok(());
1069 }
1070
1071 // fzf-style search: the input eats printable keys and updates results on
1072 // every keystroke. Arrow keys still navigate the results list; Enter plays
1073 // the highlighted item.
1074 if app.screen == Screen::Search && app.search_input_focused {
1075 match key.code {
1076 KeyCode::Esc => {
1077 app.search_input_focused = false;
1078 }
1079 KeyCode::Enter => {
1080 app.play_selected(PlayMode::PlayNow).await;
1081 }
1082 KeyCode::Down => {
1083 let len = app.current_list().len();
1084 let i = app.list_state.selected().unwrap_or(0);
1085 if len > 0 {
1086 app.list_state.select(Some((i + 1).min(len - 1)));
1087 }
1088 }
1089 KeyCode::Up => {
1090 let i = app.list_state.selected().unwrap_or(0);
1091 app.list_state.select(Some(i.saturating_sub(1)));
1092 }
1093 KeyCode::Backspace => {
1094 app.search_query.pop();
1095 app.list_state.select(Some(0));
1096 app.run_search();
1097 }
1098 KeyCode::Tab => {
1099 app.search_input_focused = false;
1100 }
1101 KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1102 app.should_quit = true;
1103 }
1104 KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1105 app.search_query.clear();
1106 app.run_search();
1107 }
1108 KeyCode::Char('a') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1109 app.play_selected(PlayMode::Enqueue).await;
1110 }
1111 KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
1112 app.play_selected(PlayMode::PlayNext).await;
1113 }
1114 KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
1115 app.search_query.push(c);
1116 app.list_state.select(Some(0));
1117 app.run_search();
1118 }
1119 _ => {}
1120 }
1121 return Ok(());
1122 }
1123
1124 match (key.code, key.modifiers) {
1125 (KeyCode::Char('q'), _) | (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
1126 app.should_quit = true;
1127 }
1128 (KeyCode::Tab, _) | (KeyCode::Right, KeyModifiers::CONTROL) => {
1129 app.screen = app.screen.next();
1130 app.list_state.select(Some(0));
1131 app.load_screen().await;
1132 }
1133 (KeyCode::BackTab, _) | (KeyCode::Left, KeyModifiers::CONTROL) => {
1134 app.screen = app.screen.prev();
1135 app.list_state.select(Some(0));
1136 app.load_screen().await;
1137 }
1138 (KeyCode::Char('1'), _) => {
1139 app.screen = Screen::Music;
1140 *app.open_album.lock() = None;
1141 app.list_state.select(Some(0));
1142 app.load_screen().await;
1143 }
1144 (KeyCode::Char('2'), _) => {
1145 app.screen = Screen::Videos;
1146 *app.open_series.lock() = None;
1147 app.list_state.select(Some(0));
1148 app.load_screen().await;
1149 }
1150 (KeyCode::Char('3'), _) => {
1151 app.screen = Screen::Playlists;
1152 *app.open_playlist.lock() = None;
1153 app.list_state.select(Some(0));
1154 app.load_screen().await;
1155 }
1156 (KeyCode::Char('4'), _) => {
1157 app.screen = Screen::Queue;
1158 app.list_state.select(Some(0));
1159 }
1160 (KeyCode::Char('5'), _) => {
1161 app.screen = Screen::Search;
1162 app.list_state.select(Some(0));
1163 app.search_input_focused = true;
1164 }
1165 (KeyCode::Char('6'), _) => {
1166 app.screen = Screen::Devices;
1167 app.list_state.select(Some(0));
1168 app.load_screen().await;
1169 }
1170 (KeyCode::Char('7'), _) => {
1171 app.screen = Screen::Settings;
1172 app.list_state.select(Some(0));
1173 }
1174 (KeyCode::Char('/'), _) => {
1175 app.screen = Screen::Search;
1176 app.search_input_focused = true;
1177 }
1178 (KeyCode::Char('r'), _) => {
1179 app.load_screen().await;
1180 app.set_status("Refreshed.");
1181 }
1182 (KeyCode::Esc, _) => {
1183 // Esc pops the current drill-in (album / series / playlist).
1184 match app.screen {
1185 Screen::Music if app.open_album.lock().is_some() => {
1186 *app.open_album.lock() = None;
1187 app.album_tracks.lock().clear();
1188 app.list_state.select(Some(0));
1189 }
1190 Screen::Videos if app.open_series.lock().is_some() => {
1191 *app.open_series.lock() = None;
1192 app.series_children.lock().clear();
1193 app.list_state.select(Some(0));
1194 }
1195 Screen::Playlists if app.open_playlist.lock().is_some() => {
1196 *app.open_playlist.lock() = None;
1197 app.playlist_items.lock().clear();
1198 app.list_state.select(Some(0));
1199 }
1200 _ => {}
1201 }
1202 }
1203 // Shift+↑ / Shift+↓ on Settings nudge the highlighted EQ band's
1204 // gain by ±1 dB. Must run BEFORE the generic nav arms below or
1205 // they'd swallow the shifted variant.
1206 (KeyCode::Up, m) if app.screen == Screen::Settings && m.contains(KeyModifiers::SHIFT) => {
1207 app.nudge_eq_band_gain(10).await;
1208 }
1209 (KeyCode::Down, m) if app.screen == Screen::Settings && m.contains(KeyModifiers::SHIFT) => {
1210 app.nudge_eq_band_gain(-10).await;
1211 }
1212 (KeyCode::Down, _) | (KeyCode::Char('j'), _) => {
1213 let len = app.list_len();
1214 let i = app.list_state.selected().unwrap_or(0);
1215 if len > 0 {
1216 app.list_state.select(Some((i + 1).min(len - 1)));
1217 }
1218 }
1219 (KeyCode::Up, _) | (KeyCode::Char('k'), _) => {
1220 let i = app.list_state.selected().unwrap_or(0);
1221 app.list_state.select(Some(i.saturating_sub(1)));
1222 }
1223 (KeyCode::PageDown, _) => {
1224 let len = app.list_len();
1225 let i = app.list_state.selected().unwrap_or(0);
1226 if len > 0 {
1227 app.list_state.select(Some((i + 10).min(len - 1)));
1228 }
1229 }
1230 (KeyCode::PageUp, _) => {
1231 let i = app.list_state.selected().unwrap_or(0);
1232 app.list_state.select(Some(i.saturating_sub(10)));
1233 }
1234 (KeyCode::Enter, _) => match app.screen {
1235 Screen::Devices => {
1236 if let Err(e) = app.use_selected_device().await {
1237 app.set_status(format!("device: {}", e));
1238 }
1239 }
1240 Screen::Settings => {
1241 let name = {
1242 let cfg = app.config.lock();
1243 app.list_state
1244 .selected()
1245 .and_then(|i| cfg.servers.get(i).map(|s| s.name.clone()))
1246 };
1247 if let Some(name) = name {
1248 if let Err(e) = app.switch_server(&name) {
1249 app.set_status(format!("switch: {}", e));
1250 } else {
1251 app.load_screen().await;
1252 }
1253 }
1254 }
1255 // The Queue screen shows the *current* queue — Enter jumps the
1256 // playhead inside it instead of collapsing the queue down to a
1257 // single item.
1258 Screen::Queue => app.jump_to_queue_index().await,
1259 _ => app.play_selected(PlayMode::PlayNow).await,
1260 },
1261 (KeyCode::Char('a'), _) => app.play_selected(PlayMode::Enqueue).await,
1262 (KeyCode::Char('n'), _) => app.play_selected(PlayMode::PlayNext).await,
1263 // `x` — play the highlighted container *right now*, without drilling in.
1264 (KeyCode::Char('x'), _) => app.play_selected(PlayMode::PlayContainer).await,
1265 (KeyCode::Char(' '), _) | (KeyCode::Char('p'), _) => {
1266 let renderer = app.renderer.lock().clone();
1267 let state = renderer.state();
1268 let _ = match state.status {
1269 fin_player::PlaybackStatus::Playing => renderer.pause().await,
1270 _ => renderer.resume().await,
1271 };
1272 }
1273 (KeyCode::Char('s'), _) => {
1274 let renderer = app.renderer.lock().clone();
1275 let _ = renderer.stop().await;
1276 }
1277 (KeyCode::Char('>'), _) | (KeyCode::Char('l'), _) => {
1278 let renderer = app.renderer.lock().clone();
1279 let _ = renderer.next().await;
1280 }
1281 (KeyCode::Char('<'), _) | (KeyCode::Char('h'), _) => {
1282 let renderer = app.renderer.lock().clone();
1283 let _ = renderer.previous().await;
1284 }
1285 (KeyCode::Char('+'), _) | (KeyCode::Char('='), _) => {
1286 let renderer = app.renderer.lock().clone();
1287 let v = renderer.state().volume + 0.05;
1288 let _ = renderer.set_volume(v.min(1.5)).await;
1289 }
1290 (KeyCode::Char('-'), _) | (KeyCode::Char('_'), _) => {
1291 let renderer = app.renderer.lock().clone();
1292 let v = renderer.state().volume - 0.05;
1293 let _ = renderer.set_volume(v.max(0.0)).await;
1294 }
1295 (KeyCode::Char('m'), _) => {
1296 app.switch_to_mpv().await;
1297 }
1298 (KeyCode::Char('t'), _) => {
1299 app.cycle_server();
1300 app.load_screen().await;
1301 }
1302 (KeyCode::Char('z'), _) => {
1303 let renderer = app.renderer.lock().clone();
1304 let current = renderer.state().shuffle;
1305 let _ = renderer.set_shuffle(!current).await;
1306 app.set_status(if current { "Shuffle off" } else { "Shuffle on" });
1307 }
1308 // Shift+R cycles repeat (`r` alone refreshes the screen; `l` is next).
1309 (KeyCode::Char('R'), _) => {
1310 let renderer = app.renderer.lock().clone();
1311 let next = renderer.state().repeat.next();
1312 let _ = renderer.set_repeat(next).await;
1313 app.set_status(format!("Repeat: {}", next.label()));
1314 }
1315 // Queue-screen-only: `d` removes the highlighted entry, `Shift+C`
1316 // clears the whole queue. Elsewhere these keys are inert.
1317 (KeyCode::Char('d'), _) if app.screen == Screen::Queue => {
1318 app.remove_selected_from_queue().await;
1319 }
1320 (KeyCode::Char('C'), _) if app.screen == Screen::Queue => {
1321 let renderer = app.renderer.lock().clone();
1322 let _ = renderer.stop().await;
1323 app.set_status("Queue cleared.");
1324 }
1325 // ReplayGain: `g` cycles off → track → album → off.
1326 (KeyCode::Char('g'), _) => {
1327 let mut new_settings = {
1328 let cfg = app.config.lock();
1329 cfg.replaygain
1330 };
1331 new_settings.mode = new_settings.mode.next();
1332 let renderer = app.renderer.lock().clone();
1333 if let Err(e) = renderer.set_replaygain(new_settings).await {
1334 app.set_status(format!("replaygain: {}", e));
1335 } else {
1336 let mut cfg = app.config.lock();
1337 cfg.replaygain = new_settings;
1338 let _ = cfg.save();
1339 drop(cfg);
1340 app.set_status(format!("ReplayGain: {}", new_settings.mode.label()));
1341 }
1342 }
1343 // Shift+F cycles common crossfade durations. Preserves the current
1344 // mode (Off keeps its stored duration; user still has to hit `f`
1345 // to actually enable the fade).
1346 (KeyCode::Char('F'), _) => {
1347 const DURATIONS: [f32; 4] = [3.0, 5.0, 8.0, 12.0];
1348 let mut new_settings = app.config.lock().crossfade;
1349 let cur = new_settings.duration_secs;
1350 let next = DURATIONS
1351 .iter()
1352 .copied()
1353 .find(|d| *d > cur + 0.001)
1354 .unwrap_or(DURATIONS[0]);
1355 new_settings.duration_secs = next;
1356 let renderer = app.renderer.lock().clone();
1357 if let Err(e) = renderer.set_crossfade(new_settings).await {
1358 app.set_status(format!("crossfade: {}", e));
1359 } else {
1360 let mut cfg = app.config.lock();
1361 cfg.crossfade = new_settings;
1362 let _ = cfg.save();
1363 drop(cfg);
1364 app.set_status(format!("Crossfade duration: {:.1}s", next));
1365 }
1366 }
1367 // Bass / treble shelves — Rockbox tone controls. `b/B` for bass,
1368 // `y/Y` (adjacent to `t`, which is taken for server cycle) for
1369 // treble. Each press moves the shelf gain by 1 dB, clamped to ±24.
1370 (KeyCode::Char('b'), _) => {
1371 app.nudge_tone_bass(-1).await;
1372 }
1373 (KeyCode::Char('B'), _) => {
1374 app.nudge_tone_bass(1).await;
1375 }
1376 (KeyCode::Char('y'), _) => {
1377 app.nudge_tone_treble(-1).await;
1378 }
1379 (KeyCode::Char('Y'), _) => {
1380 app.nudge_tone_treble(1).await;
1381 }
1382 // `?` opens the keyboard-shortcuts modal. The modal itself handles
1383 // Esc / ? to close and swallows every other key while it's up.
1384 (KeyCode::Char('?'), _) => {
1385 app.help_open = true;
1386 }
1387 // Equalizer: `E` toggles the Rockbox EQ pipeline.
1388 (KeyCode::Char('E'), _) => {
1389 let (new_enabled, bands) = {
1390 let mut cfg = app.config.lock();
1391 cfg.eq_enabled = !cfg.eq_enabled;
1392 let _ = cfg.save();
1393 (cfg.eq_enabled, cfg.eq_band_settings.clone())
1394 };
1395 let renderer = app.renderer.lock().clone();
1396 if let Err(e) = renderer.set_eq(new_enabled, bands).await {
1397 app.set_status(format!("eq: {}", e));
1398 } else {
1399 app.set_status(if new_enabled { "EQ: on" } else { "EQ: off" });
1400 }
1401 }
1402 // `[` / `]` move between EQ bands on the Settings screen.
1403 (KeyCode::Char('['), _) if app.screen == Screen::Settings => {
1404 let n = app.config.lock().eq_band_settings.len();
1405 if n > 0 {
1406 app.eq_selected_band =
1407 (app.eq_selected_band + n - 1) % n;
1408 }
1409 }
1410 (KeyCode::Char(']'), _) if app.screen == Screen::Settings => {
1411 let n = app.config.lock().eq_band_settings.len();
1412 if n > 0 {
1413 app.eq_selected_band = (app.eq_selected_band + 1) % n;
1414 }
1415 }
1416 // Crossfade: `f` cycles off → crossfade → mixed → off. Duration
1417 // is edited via Shift+F or config.toml.
1418 (KeyCode::Char('f'), _) => {
1419 let mut new_settings = {
1420 let cfg = app.config.lock();
1421 cfg.crossfade
1422 };
1423 new_settings.mode = new_settings.mode.next();
1424 let renderer = app.renderer.lock().clone();
1425 if let Err(e) = renderer.set_crossfade(new_settings).await {
1426 app.set_status(format!("crossfade: {}", e));
1427 } else {
1428 let mut cfg = app.config.lock();
1429 cfg.crossfade = new_settings;
1430 let _ = cfg.save();
1431 drop(cfg);
1432 app.set_status(if new_settings.mode.is_active() {
1433 format!(
1434 "Crossfade: {} ({:.1}s)",
1435 new_settings.mode.label(),
1436 new_settings.duration_secs
1437 )
1438 } else {
1439 "Crossfade: off".to_string()
1440 });
1441 }
1442 }
1443 _ => {}
1444 }
1445 Ok(())
1446}
1447
1448fn draw(f: &mut Frame<'_>, app: &mut App) {
1449 let size = f.area();
1450 f.render_widget(Paragraph::new("").style(base_style()), size);
1451 let chunks = Layout::default()
1452 .direction(Direction::Vertical)
1453 .constraints([
1454 Constraint::Length(3), // header
1455 Constraint::Length(3), // tabs
1456 Constraint::Min(4), // main
1457 Constraint::Length(6), // player bar
1458 Constraint::Length(1), // status/help
1459 ])
1460 .split(size);
1461
1462 draw_header(f, chunks[0], app);
1463 draw_tabs(f, chunks[1], app);
1464 draw_body(f, chunks[2], app);
1465 draw_player_bar(f, chunks[3], app);
1466 draw_status_bar(f, chunks[4], app);
1467
1468 // Draw the help modal LAST so it lands on top of everything, including
1469 // the player bar and status line.
1470 if app.help_open {
1471 let popup = HelpModal::area_for(size);
1472 f.render_widget(HelpModal, popup);
1473 }
1474}
1475
1476fn draw_header(f: &mut Frame<'_>, area: Rect, app: &App) {
1477 let pulse = ((app.logo_pulse as f32 * 0.15).sin() * 0.5 + 0.5) * 60.0 + 195.0;
1478 let r = 0u8;
1479 let g = pulse as u8;
1480 let b = (pulse * 0.85) as u8;
1481 let subtitle_col = ratatui::style::Color::Rgb(r, g, b);
1482 let (server, user, server_name, servers_total) = {
1483 let cfg = app.config.lock();
1484 let cur = cfg.current();
1485 (
1486 cur.map(|s| s.url.clone())
1487 .unwrap_or_else(|| "not logged in".into()),
1488 cur.map(|s| s.user_name.clone())
1489 .unwrap_or_else(|| "guest".into()),
1490 cur.map(|s| s.name.clone()).unwrap_or_default(),
1491 cfg.servers.len(),
1492 )
1493 };
1494 let servers_badge = if servers_total > 1 {
1495 format!(" ⇄ {} servers", servers_total)
1496 } else {
1497 String::new()
1498 };
1499 let line = Line::from(vec![
1500 Span::styled(
1501 " ⚡ fin ",
1502 Style::default()
1503 .fg(Palette::PRIMARY)
1504 .add_modifier(Modifier::BOLD),
1505 ),
1506 Span::styled(
1507 "— a neon jellyfin client",
1508 Style::default().fg(subtitle_col),
1509 ),
1510 Span::styled(
1511 format!(" {} ", user),
1512 Style::default()
1513 .fg(Palette::ACCENT)
1514 .add_modifier(Modifier::BOLD),
1515 ),
1516 Span::styled(format!("@ {} ", server), muted_style()),
1517 Span::styled(
1518 if server_name.is_empty() {
1519 String::new()
1520 } else {
1521 format!("[{}]", server_name)
1522 },
1523 Style::default().fg(Palette::HIGHLIGHT),
1524 ),
1525 Span::styled(servers_badge, Style::default().fg(Palette::SKY)),
1526 ]);
1527 let block = neon_block("", false);
1528 let inner = block.inner(area);
1529 f.render_widget(block, area);
1530 f.render_widget(Paragraph::new(line), inner);
1531}
1532
1533fn draw_tabs(f: &mut Frame<'_>, area: Rect, app: &App) {
1534 let block = neon_block("", false);
1535 let inner = block.inner(area);
1536 f.render_widget(block, area);
1537 let labels: Vec<(&str, &str)> = Screen::ALL.iter().map(|s| (s.icon(), s.label())).collect();
1538 let selected = Screen::ALL
1539 .iter()
1540 .position(|s| *s == app.screen)
1541 .unwrap_or(0);
1542 f.render_widget(NeonTabs::new(&labels, selected), inner);
1543}
1544
1545fn draw_body(f: &mut Frame<'_>, area: Rect, app: &mut App) {
1546 match app.screen {
1547 Screen::Search => draw_search(f, area, app),
1548 Screen::Devices => draw_devices(f, area, app),
1549 Screen::Settings => draw_settings(f, area, app),
1550 Screen::Playlists if app.open_playlist.lock().is_none() => {
1551 draw_list(f, area, app, " ▤ Playlists ");
1552 }
1553 Screen::Playlists => {
1554 let name = app
1555 .open_playlist
1556 .lock()
1557 .as_ref()
1558 .map(|p| p.name.clone())
1559 .unwrap_or_default();
1560 let title = format!(
1561 " ▤ {} — {} tracks (Esc to go back) ",
1562 name,
1563 app.playlist_items.lock().len()
1564 );
1565 draw_list_with_title(f, area, app, &title);
1566 }
1567 Screen::Music if app.open_album.lock().is_none() => {
1568 draw_list(f, area, app, " ♪ Music — Albums ")
1569 }
1570 Screen::Music => {
1571 let album = app.open_album.lock().clone();
1572 draw_album_tracks(f, area, app, album.as_ref());
1573 }
1574 Screen::Videos if app.open_series.lock().is_none() => {
1575 draw_list(f, area, app, " ▶ Videos — Movies & Series ")
1576 }
1577 Screen::Videos => {
1578 let name = app
1579 .open_series
1580 .lock()
1581 .as_ref()
1582 .map(|s| s.name.clone())
1583 .unwrap_or_default();
1584 let title = format!(
1585 " ▶ {} — {} episode(s) (Esc to go back) ",
1586 name,
1587 app.series_children.lock().len()
1588 );
1589 draw_list_with_title(f, area, app, &title);
1590 }
1591 Screen::Queue => {
1592 let state = app.playback_state.lock().clone();
1593 let total = state.queue.len();
1594 let title = if total == 0 {
1595 " ≡ Queue ".to_string()
1596 } else {
1597 let pos = state.current_index.map(|i| i + 1).unwrap_or(0);
1598 format!(" ≡ Queue ({}/{}) ", pos, total)
1599 };
1600 draw_list(f, area, app, &title);
1601 }
1602 }
1603}
1604
1605fn draw_list(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) {
1606 draw_list_with_title(f, area, app, title);
1607}
1608
1609fn draw_list_with_title(f: &mut Frame<'_>, area: Rect, app: &mut App, title: &str) {
1610 let items_data = app.current_list();
1611 // Compute the column layout ONCE, using the width available inside the
1612 // block minus the 3-column highlight symbol (" ▍ "). Every row uses the
1613 // same widths so titles / subtitles / times line up as columns.
1614 let block = neon_block(title, true);
1615 let inner = block.inner(area);
1616 let row_width = inner.width.saturating_sub(3); // highlight_symbol reserves 3 cols
1617 let layout = RowLayout::compute(row_width);
1618
1619 if items_data.is_empty() {
1620 f.render_widget(block, area);
1621 let msg = match app.screen {
1622 Screen::Queue => "Queue is empty — press Enter on an item to play, `a` to enqueue.",
1623 Screen::Music | Screen::Videos => "Loading… (press `r` to refresh)",
1624 Screen::Playlists => "No playlists yet.",
1625 Screen::Search if app.search_query.trim().is_empty() => {
1626 "type to search — results update as you type"
1627 }
1628 Screen::Search => "no matches",
1629 _ => "Nothing here.",
1630 };
1631 f.render_widget(
1632 Paragraph::new(Line::from(Span::styled(msg, muted_style())))
1633 .alignment(Alignment::Center),
1634 inner.inner(Margin::new(2, 1)),
1635 );
1636 return;
1637 }
1638 // The Queue screen paints its currently-playing row with a distinct
1639 // marker. Other screens leave `now_playing` false — they either aren't
1640 // showing the queue (Music/Videos/Playlists browse the library) or don't
1641 // have a stable notion of "currently playing" tied to the visible index.
1642 let playing_idx = if app.screen == Screen::Queue {
1643 app.playback_state.lock().current_index
1644 } else {
1645 None
1646 };
1647 let items: Vec<ListItem> = items_data
1648 .iter()
1649 .enumerate()
1650 .map(|(i, it)| {
1651 ListItem::new(item_row_line(
1652 it,
1653 Some(i) == app.list_state.selected(),
1654 Some(i) == playing_idx,
1655 layout,
1656 ))
1657 })
1658 .collect();
1659 let list = List::new(items)
1660 .block(block)
1661 .highlight_style(
1662 Style::default()
1663 .bg(Palette::SURFACE)
1664 .fg(Palette::PRIMARY)
1665 .add_modifier(Modifier::BOLD),
1666 )
1667 .highlight_symbol(" ▍ ");
1668 f.render_stateful_widget(list, area, &mut app.list_state);
1669}
1670
1671/// Client-side sort as a safety net over Jellyfin's server-side ordering.
1672/// Sorts by (disc, track, name); tracks missing metadata sink below tracks
1673/// with known indices instead of scattering through the list.
1674fn sort_album_tracks(items: &mut [BaseItem]) {
1675 items.sort_by(|a, b| {
1676 let disc_a = a.parent_index_number.unwrap_or(i32::MAX);
1677 let disc_b = b.parent_index_number.unwrap_or(i32::MAX);
1678 let track_a = a.index_number.unwrap_or(i32::MAX);
1679 let track_b = b.index_number.unwrap_or(i32::MAX);
1680 disc_a
1681 .cmp(&disc_b)
1682 .then(track_a.cmp(&track_b))
1683 .then_with(|| a.name.cmp(&b.name))
1684 });
1685}
1686
1687/// Album drill-in view. Groups tracks by disc when the album has more than
1688/// one, prepends every row with its track number, and includes the album's
1689/// production year in the title if present. Headers are rendered as
1690/// non-selectable rows — navigation transparently steps over them.
1691fn draw_album_tracks(f: &mut Frame<'_>, area: Rect, app: &mut App, album: Option<&BaseItem>) {
1692 let tracks = app.album_tracks.lock().clone();
1693 let name = album.map(|a| a.name.clone()).unwrap_or_default();
1694 let sub = album.map(|a| a.subtitle()).unwrap_or_default();
1695 let year = album
1696 .and_then(|a| a.production_year)
1697 .map(|y| format!(" ({y})"))
1698 .unwrap_or_default();
1699 let title = format!(
1700 " ◈ {}{} {} — {} track(s) (Esc to go back) ",
1701 name,
1702 year,
1703 sub,
1704 tracks.len()
1705 );
1706
1707 let block = neon_block(&title, true);
1708 let inner = block.inner(area);
1709
1710 if tracks.is_empty() {
1711 f.render_widget(block, area);
1712 f.render_widget(
1713 Paragraph::new(Line::from(Span::styled(
1714 " Loading tracks…",
1715 muted_style(),
1716 )))
1717 .alignment(Alignment::Center),
1718 inner.inner(Margin::new(2, 1)),
1719 );
1720 return;
1721 }
1722
1723 // How wide is a track-number column? Widest track number governs
1724 // padding so single-digit and triple-digit tracks line up.
1725 let widest_track = tracks
1726 .iter()
1727 .filter_map(|t| t.index_number)
1728 .max()
1729 .unwrap_or(1)
1730 .max(1);
1731 let tn_width = widest_track.to_string().len().max(2);
1732
1733 // Show disc headers only when the album actually has more than one
1734 // disc — single-disc albums render as a plain list.
1735 let discs: std::collections::BTreeSet<i32> = tracks
1736 .iter()
1737 .map(|t| t.parent_index_number.unwrap_or(1))
1738 .collect();
1739 let show_disc_headers = discs.len() > 1;
1740
1741 // Row layout matches the standard list widths so track columns line up
1742 // with the rest of the app. The 3 cols reserved for the highlight
1743 // symbol (" ▍ ") come off the top before layout compute.
1744 let row_width = inner.width.saturating_sub(3);
1745 let layout = RowLayout::compute(row_width);
1746
1747 // Build visual rows + map every TRACK index → visual row index so the
1748 // list cursor lands on the right visible line.
1749 let mut items: Vec<ListItem> = Vec::with_capacity(tracks.len() + discs.len());
1750 let mut track_to_visual: Vec<usize> = Vec::with_capacity(tracks.len());
1751 let mut header_visual_indices: Vec<usize> = Vec::new();
1752 let mut current_disc: Option<i32> = None;
1753
1754 for (ti, track) in tracks.iter().enumerate() {
1755 let disc = track.parent_index_number.unwrap_or(1);
1756 if show_disc_headers && current_disc != Some(disc) {
1757 header_visual_indices.push(items.len());
1758 items.push(ListItem::new(Line::from(vec![
1759 Span::styled(
1760 format!(" ▤ Disc {}", disc),
1761 Style::default()
1762 .fg(Palette::ACCENT)
1763 .add_modifier(Modifier::BOLD),
1764 ),
1765 ])));
1766 current_disc = Some(disc);
1767 }
1768 track_to_visual.push(items.len());
1769 items.push(ListItem::new(album_track_row(
1770 track,
1771 Some(ti) == app.list_state.selected(),
1772 layout,
1773 tn_width,
1774 )));
1775 }
1776
1777 // Map the app's TRACK-index selection into the visual list.
1778 let sel_track = app
1779 .list_state
1780 .selected()
1781 .unwrap_or(0)
1782 .min(tracks.len().saturating_sub(1));
1783 let sel_visual = track_to_visual.get(sel_track).copied();
1784
1785 let mut local_state = ListState::default();
1786 local_state.select(sel_visual);
1787
1788 let list = List::new(items)
1789 .block(block)
1790 .highlight_style(
1791 Style::default()
1792 .bg(Palette::SURFACE)
1793 .fg(Palette::PRIMARY)
1794 .add_modifier(Modifier::BOLD),
1795 )
1796 .highlight_symbol(" ▍ ");
1797 f.render_stateful_widget(list, area, &mut local_state);
1798}
1799
1800/// Build a track row for the album drill-in — same layout as `item_row_line`
1801/// but with the track number spliced in ahead of the title. Selection state
1802/// still colors the icon + main text.
1803fn album_track_row<'a>(
1804 track: &'a BaseItem,
1805 selected: bool,
1806 layout: RowLayout,
1807 tn_width: usize,
1808) -> Line<'a> {
1809 use unicode_width::UnicodeWidthStr;
1810
1811 let (icon_fg, main_style) = if selected {
1812 (
1813 Palette::PRIMARY,
1814 Style::default()
1815 .fg(Palette::FG)
1816 .add_modifier(Modifier::BOLD),
1817 )
1818 } else {
1819 (Palette::ACCENT, Style::default().fg(Palette::FG))
1820 };
1821
1822 let icon_text = format!(" ♪ ");
1823 let tn_text = match track.index_number {
1824 Some(n) => format!("{:>tn_width$}. ", n, tn_width = tn_width),
1825 None => format!("{:>tn_width$} ", "", tn_width = tn_width),
1826 };
1827 // The track number eats into what was the title column, so recompute
1828 // the title budget so subtitles and times stay aligned with sibling
1829 // rows.
1830 let tn_len = UnicodeWidthStr::width(tn_text.as_str());
1831 let title_budget = layout.title_col.saturating_sub(tn_len);
1832 let title = truncate_to_width(&track.name, title_budget);
1833 let title_padded = pad_right(&title, title_budget);
1834 let sub = track.subtitle();
1835 let sub_text = if layout.sub_col > 0 {
1836 pad_right(&truncate_to_width(&sub, layout.sub_col), layout.sub_col)
1837 } else {
1838 String::new()
1839 };
1840 let time = track
1841 .duration_secs()
1842 .map(fmt_dur_local)
1843 .unwrap_or_default();
1844 let time_pad = layout
1845 .time_col
1846 .saturating_sub(UnicodeWidthStr::width(time.as_str()));
1847 let time_text = format!("{}{}", " ".repeat(time_pad), time);
1848
1849 let gap1 = " ".repeat(layout.gap1);
1850 let gap2 = " ".repeat(layout.gap2);
1851
1852 Line::from(vec![
1853 Span::styled(
1854 icon_text,
1855 Style::default().fg(icon_fg).add_modifier(Modifier::BOLD),
1856 ),
1857 Span::styled(tn_text, muted_style()),
1858 Span::styled(title_padded, main_style),
1859 Span::raw(gap1),
1860 Span::styled(sub_text, Style::default().fg(Palette::MUTED)),
1861 Span::raw(gap2),
1862 Span::styled(time_text, Style::default().fg(Palette::SKY)),
1863 ])
1864}
1865
1866// --- small local formatting helpers (mirror screens/mod.rs privates so we
1867// don't have to make them pub; the album view is the only extra caller)
1868
1869fn fmt_dur_local(secs: u64) -> String {
1870 let (h, rem) = (secs / 3600, secs % 3600);
1871 let (m, s) = (rem / 60, rem % 60);
1872 if h > 0 {
1873 format!("{}:{:02}:{:02}", h, m, s)
1874 } else {
1875 format!("{}:{:02}", m, s)
1876 }
1877}
1878
1879fn truncate_to_width(s: &str, max_cols: usize) -> String {
1880 use unicode_width::UnicodeWidthChar;
1881 use unicode_width::UnicodeWidthStr;
1882
1883 if UnicodeWidthStr::width(s) <= max_cols {
1884 return s.to_string();
1885 }
1886 if max_cols <= 1 {
1887 return "…".into();
1888 }
1889 let target = max_cols - 1;
1890 let mut acc = String::new();
1891 let mut w = 0usize;
1892 for ch in s.chars() {
1893 let cw = ch.width().unwrap_or(0);
1894 if w + cw > target {
1895 break;
1896 }
1897 acc.push(ch);
1898 w += cw;
1899 }
1900 acc.push('…');
1901 acc
1902}
1903
1904fn pad_right(s: &str, cols: usize) -> String {
1905 use unicode_width::UnicodeWidthStr;
1906 let w = UnicodeWidthStr::width(s);
1907 if w >= cols {
1908 s.to_string()
1909 } else {
1910 format!("{}{}", s, " ".repeat(cols - w))
1911 }
1912}
1913
1914fn draw_search(f: &mut Frame<'_>, area: Rect, app: &mut App) {
1915 let chunks = Layout::default()
1916 .direction(Direction::Vertical)
1917 .constraints([Constraint::Length(3), Constraint::Min(3)])
1918 .split(area);
1919 let title = if app.search_input_focused {
1920 " ⌕ Search (typing) "
1921 } else {
1922 " ⌕ Search (press / or Tab to type) "
1923 };
1924 let block = neon_block(title, app.search_input_focused);
1925 let inner = block.inner(chunks[0]);
1926 f.render_widget(block, chunks[0]);
1927
1928 let prompt_style = Style::default()
1929 .fg(Palette::PRIMARY)
1930 .add_modifier(Modifier::BOLD);
1931 let cursor_visible = app.search_input_focused && app.logo_pulse % 2 == 0;
1932 let mut spans: Vec<Span> = vec![
1933 Span::styled(" ", Style::default()),
1934 Span::styled("⌕ ", prompt_style),
1935 ];
1936 if app.search_query.is_empty() {
1937 spans.push(Span::styled(
1938 "type to search music, movies, series…",
1939 muted_style(),
1940 ));
1941 if app.search_input_focused {
1942 spans.push(Span::styled(
1943 if cursor_visible { " ▏" } else { " " },
1944 Style::default().fg(Palette::PRIMARY),
1945 ));
1946 }
1947 } else {
1948 spans.push(Span::styled(
1949 app.search_query.clone(),
1950 Style::default()
1951 .fg(Palette::FG)
1952 .add_modifier(Modifier::BOLD),
1953 ));
1954 spans.push(Span::styled(
1955 if cursor_visible { "▏" } else { " " },
1956 Style::default()
1957 .fg(Palette::PRIMARY)
1958 .add_modifier(Modifier::BOLD),
1959 ));
1960 }
1961 f.render_widget(
1962 Paragraph::new(Line::from(spans)).style(Style::default().bg(Palette::SURFACE)),
1963 inner,
1964 );
1965 draw_list_with_title(f, chunks[1], app, " ⌕ Results ");
1966}
1967
1968fn draw_devices(f: &mut Frame<'_>, area: Rect, app: &mut App) {
1969 let devices = app.devices.lock().clone();
1970 let items: Vec<ListItem> = devices
1971 .iter()
1972 .enumerate()
1973 .map(|(i, d)| {
1974 let selected = Some(i) == app.list_state.selected();
1975 let icon_col = if selected {
1976 Palette::PRIMARY
1977 } else {
1978 Palette::ACCENT
1979 };
1980 let icon = match d {
1981 RemoteDevice::Cast(_) => " ",
1982 RemoteDevice::Upnp(_) => " ◈ ",
1983 };
1984 ListItem::new(Line::from(vec![
1985 Span::styled(
1986 icon,
1987 Style::default().fg(icon_col).add_modifier(Modifier::BOLD),
1988 ),
1989 Span::styled(
1990 d.display_name(),
1991 Style::default()
1992 .fg(Palette::FG)
1993 .add_modifier(Modifier::BOLD),
1994 ),
1995 Span::styled(
1996 format!(" [{}] ", d.kind_label()),
1997 Style::default().fg(Palette::HIGHLIGHT),
1998 ),
1999 Span::styled(d.subtitle(), Style::default().fg(Palette::SKY)),
2000 ]))
2001 })
2002 .collect();
2003 let title = " ◈ Renderers (Chromecast + UPnP — Enter to select, r to rescan) ";
2004 if items.is_empty() {
2005 let block = neon_block(title, true);
2006 let inner = block.inner(area);
2007 f.render_widget(block, area);
2008 f.render_widget(
2009 Paragraph::new(Line::from(Span::styled(
2010 "No devices found yet — press `r` to rescan.",
2011 muted_style(),
2012 )))
2013 .alignment(Alignment::Center),
2014 inner.inner(Margin::new(2, 1)),
2015 );
2016 return;
2017 }
2018 let list = List::new(items)
2019 .block(neon_block(title, true))
2020 .highlight_style(
2021 Style::default()
2022 .bg(Palette::SURFACE)
2023 .fg(Palette::PRIMARY)
2024 .add_modifier(Modifier::BOLD),
2025 )
2026 .highlight_symbol(" ▍ ");
2027 f.render_stateful_widget(list, area, &mut app.list_state);
2028}
2029
2030fn draw_settings(f: &mut Frame<'_>, area: Rect, app: &mut App) {
2031 let cfg_snapshot = app.config.lock().clone();
2032 let renderer_pref = cfg_snapshot.renderer.label();
2033 let last_cast = cfg_snapshot
2034 .last_chromecast
2035 .clone()
2036 .unwrap_or_else(|| "—".into());
2037 let last_upnp = cfg_snapshot.last_upnp.clone().unwrap_or_else(|| "—".into());
2038 let path = fin_config::config_path()
2039 .map(|p| p.display().to_string())
2040 .unwrap_or_default();
2041
2042 let rows = Layout::default()
2043 .direction(Direction::Vertical)
2044 .constraints([
2045 Constraint::Length(10),
2046 // EQ card — bigger so the vertical sliders have real
2047 // resolution; server list drops to a floor of 3 rows if
2048 // vertical space is tight.
2049 Constraint::Length(18),
2050 Constraint::Min(3),
2051 ])
2052 .split(area);
2053
2054 // Top card — global settings.
2055 let lines = vec![
2056 Line::from(vec![
2057 Span::styled(" Renderer ", title_style()),
2058 Span::styled(renderer_pref, accent_style()),
2059 Span::styled(
2060 " (press m for local, 6 → Enter for a chromecast / UPnP renderer)",
2061 muted_style(),
2062 ),
2063 ]),
2064 Line::from(vec![
2065 Span::styled(" Last Cast ", title_style()),
2066 Span::styled(last_cast, Style::default().fg(Palette::HIGHLIGHT)),
2067 ]),
2068 Line::from(vec![
2069 Span::styled(" Last UPnP ", title_style()),
2070 Span::styled(last_upnp, Style::default().fg(Palette::HIGHLIGHT)),
2071 ]),
2072 Line::from(vec![
2073 Span::styled(" ReplayGain ", title_style()),
2074 Span::styled(cfg_snapshot.replaygain.mode.label(), accent_style()),
2075 Span::styled(
2076 format!(
2077 " preamp {:+.1} dB clip-guard {} (press g to cycle)",
2078 cfg_snapshot.replaygain.preamp_db,
2079 if cfg_snapshot.replaygain.prevent_clip {
2080 "on"
2081 } else {
2082 "off"
2083 }
2084 ),
2085 muted_style(),
2086 ),
2087 ]),
2088 Line::from(vec![
2089 Span::styled(" Crossfade ", title_style()),
2090 Span::styled(cfg_snapshot.crossfade.mode.label(), accent_style()),
2091 Span::styled(
2092 format!(
2093 " duration {:.1} s (f: cycle mode, Shift+F: cycle duration)",
2094 cfg_snapshot.crossfade.duration_secs
2095 ),
2096 muted_style(),
2097 ),
2098 ]),
2099 Line::from(vec![
2100 Span::styled(" Tone ", title_style()),
2101 Span::styled(
2102 format!("bass {:+} dB", cfg_snapshot.bass),
2103 accent_style(),
2104 ),
2105 Span::styled(" ", muted_style()),
2106 Span::styled(
2107 format!("treble {:+} dB", cfg_snapshot.treble),
2108 accent_style(),
2109 ),
2110 Span::styled(
2111 " (b/B: bass, y/Y: treble; 1 dB steps)",
2112 muted_style(),
2113 ),
2114 ]),
2115 Line::from(vec![
2116 Span::styled(" Config File ", title_style()),
2117 Span::styled(path, Style::default().fg(Palette::SKY)),
2118 ]),
2119 Line::from(Span::styled(
2120 " All settings are also CLI flags — `fin --help`.",
2121 muted_style(),
2122 )),
2123 ];
2124 let block = neon_block(" ⚙ Settings ", false);
2125 let inner = block.inner(rows[0]);
2126 f.render_widget(block, rows[0]);
2127 f.render_widget(
2128 Paragraph::new(lines).wrap(Wrap { trim: false }),
2129 inner.inner(Margin::new(2, 0)),
2130 );
2131
2132 // EQ card — 10-band sliders + status line + interactive hints.
2133 let sel_band = if cfg_snapshot.eq_band_settings.is_empty() {
2134 None
2135 } else {
2136 Some(app.eq_selected_band.min(cfg_snapshot.eq_band_settings.len() - 1))
2137 };
2138 let eq_title = if cfg_snapshot.eq_enabled {
2139 format!(
2140 " ▤ Equalizer ({} bands, on) ",
2141 cfg_snapshot.eq_band_settings.len()
2142 )
2143 } else {
2144 format!(
2145 " ▤ Equalizer ({} bands, off) ",
2146 cfg_snapshot.eq_band_settings.len()
2147 )
2148 };
2149 let eq_block = neon_block(&eq_title, cfg_snapshot.eq_enabled);
2150 let eq_inner = eq_block.inner(rows[1]);
2151 f.render_widget(eq_block, rows[1]);
2152
2153 // Split the EQ card into a sliders row and a hint row.
2154 let eq_rows = Layout::default()
2155 .direction(Direction::Vertical)
2156 .constraints([Constraint::Min(4), Constraint::Length(1)])
2157 .split(eq_inner.inner(Margin::new(1, 0)));
2158 f.render_widget(
2159 EqSliders {
2160 bands: &cfg_snapshot.eq_band_settings,
2161 enabled: cfg_snapshot.eq_enabled,
2162 selected: sel_band,
2163 range_db: 24,
2164 },
2165 eq_rows[0],
2166 );
2167 let sel_hint = sel_band
2168 .and_then(|i| cfg_snapshot.eq_band_settings.get(i).map(|b| (i, b)))
2169 .map(|(i, b)| {
2170 format!(
2171 " band {}: {} Hz Q {:.1} {:+.1} dB ",
2172 i + 1,
2173 b.cutoff,
2174 b.q as f32 / 10.0,
2175 b.gain as f32 / 10.0,
2176 )
2177 })
2178 .unwrap_or_else(|| " no bands configured — add [[eq_band_settings]] to config.toml ".to_string());
2179 let hint = format!(
2180 "{}E: on/off [ / ]: band Shift+↑/↓: ±1 dB",
2181 sel_hint
2182 );
2183 f.render_widget(
2184 Paragraph::new(Span::styled(hint, muted_style())),
2185 eq_rows[1],
2186 );
2187
2188 // Bottom card — interactive server list. Enter switches, `t` cycles.
2189 let current = cfg_snapshot.current_server.clone().unwrap_or_default();
2190 let items: Vec<ListItem> = cfg_snapshot
2191 .servers
2192 .iter()
2193 .map(|s| {
2194 let is_current = s.name == current;
2195 let marker = if is_current { " ▍ " } else { " " };
2196 let name_style = if is_current {
2197 Style::default()
2198 .fg(Palette::PRIMARY)
2199 .add_modifier(Modifier::BOLD)
2200 } else {
2201 Style::default().fg(Palette::FG)
2202 };
2203 ListItem::new(Line::from(vec![
2204 Span::styled(
2205 marker,
2206 Style::default()
2207 .fg(Palette::PRIMARY)
2208 .add_modifier(Modifier::BOLD),
2209 ),
2210 Span::styled(format!("{:<18}", s.name), name_style),
2211 Span::styled(format!(" {} ", s.url), Style::default().fg(Palette::SKY)),
2212 Span::styled(
2213 format!("as {}", s.user_name),
2214 Style::default().fg(Palette::MUTED),
2215 ),
2216 ]))
2217 })
2218 .collect();
2219
2220 let title = format!(
2221 " ◉ Servers ({} saved — Enter to switch, t to cycle) ",
2222 cfg_snapshot.servers.len()
2223 );
2224
2225 if items.is_empty() {
2226 let block = neon_block(&title, true);
2227 let inner = block.inner(rows[2]);
2228 f.render_widget(block, rows[2]);
2229 f.render_widget(
2230 Paragraph::new(Line::from(Span::styled(
2231 " No servers yet — run `fin login <url>` to add one.",
2232 muted_style(),
2233 ))),
2234 inner.inner(Margin::new(2, 1)),
2235 );
2236 return;
2237 }
2238
2239 let list = List::new(items)
2240 .block(neon_block(&title, true))
2241 .highlight_style(
2242 Style::default()
2243 .bg(Palette::SURFACE)
2244 .fg(Palette::PRIMARY)
2245 .add_modifier(Modifier::BOLD),
2246 )
2247 .highlight_symbol("");
2248 f.render_stateful_widget(list, rows[2], &mut app.list_state);
2249}
2250
2251fn draw_player_bar(f: &mut Frame<'_>, area: Rect, app: &App) {
2252 let renderer_kind = *app.renderer_kind.lock();
2253 let label = app.renderer_label.lock().clone();
2254 let state = app.playback_state.lock().clone();
2255 f.render_widget(
2256 PlayerBar {
2257 state: &state,
2258 renderer: renderer_kind,
2259 renderer_label: &label,
2260 },
2261 area,
2262 );
2263}
2264
2265fn draw_status_bar(f: &mut Frame<'_>, area: Rect, app: &App) {
2266 // Transient status messages hide the help text; expire them so the help
2267 // reappears once the user has had a moment to read the message.
2268 const STATUS_TTL: Duration = Duration::from_secs(4);
2269 let msg = {
2270 let mut guard = app.status_message.lock();
2271 match guard.as_ref() {
2272 Some((text, set_at)) if set_at.elapsed() < STATUS_TTL => Some(text.clone()),
2273 Some(_) => {
2274 *guard = None;
2275 None
2276 }
2277 None => None,
2278 }
2279 };
2280 let help = " ?: help tab: screen ↑↓: nav enter: play/drill space: pause s: stop </>: skip +/-: vol z: shuffle R: repeat g: replaygain f/F: crossfade E: eq b/B: bass y/Y: treble m: local t: server /: search esc: back q: quit ";
2281 // Errors/warnings pop in warn-red; other status messages use the primary
2282 // teal so they stand out from the muted help text.
2283 let (text, style) = match msg {
2284 Some(m) if m.contains("failed") || m.contains("error") => (
2285 m,
2286 Style::default()
2287 .fg(Palette::ERROR)
2288 .add_modifier(Modifier::BOLD),
2289 ),
2290 Some(m) => (
2291 m,
2292 Style::default()
2293 .fg(Palette::PRIMARY)
2294 .add_modifier(Modifier::BOLD),
2295 ),
2296 None => (help.to_string(), Style::default().fg(Palette::MUTED)),
2297 };
2298 f.render_widget(
2299 Paragraph::new(Line::from(Span::styled(format!(" {} ", text), style)))
2300 .style(Style::default().bg(Palette::SURFACE)),
2301 area,
2302 );
2303}
2304
2305impl App {
2306 pub fn config_snapshot(&self) -> Config {
2307 self.config.lock().clone()
2308 }
2309 pub fn save_config(&self) -> Result<()> {
2310 self.config.lock().save().context("saving config")
2311 }
2312}
2313
2314#[cfg(test)]
2315mod tests {
2316 use super::*;
2317
2318 fn track(name: &str, disc: Option<i32>, tn: Option<i32>) -> BaseItem {
2319 BaseItem {
2320 id: name.into(),
2321 name: name.into(),
2322 type_: "Audio".into(),
2323 album: None,
2324 album_id: None,
2325 album_artist: None,
2326 artists: None,
2327 series_name: None,
2328 production_year: None,
2329 run_time_ticks: None,
2330 media_type: None,
2331 container: None,
2332 index_number: tn,
2333 parent_index_number: disc,
2334 image_tags: None,
2335 is_folder: None,
2336 overview: None,
2337 }
2338 }
2339
2340 fn names(items: &[BaseItem]) -> Vec<String> {
2341 items.iter().map(|i| i.name.clone()).collect()
2342 }
2343
2344 #[test]
2345 fn sort_by_disc_then_track_number() {
2346 let mut v = vec![
2347 track("d2-t1", Some(2), Some(1)),
2348 track("d1-t2", Some(1), Some(2)),
2349 track("d1-t1", Some(1), Some(1)),
2350 track("d2-t2", Some(2), Some(2)),
2351 ];
2352 sort_album_tracks(&mut v);
2353 assert_eq!(
2354 names(&v),
2355 vec!["d1-t1", "d1-t2", "d2-t1", "d2-t2"]
2356 );
2357 }
2358
2359 #[test]
2360 fn tracks_missing_metadata_sink_to_the_end() {
2361 let mut v = vec![
2362 track("orphan", None, None),
2363 track("t2", Some(1), Some(2)),
2364 track("t1", Some(1), Some(1)),
2365 ];
2366 sort_album_tracks(&mut v);
2367 assert_eq!(names(&v), vec!["t1", "t2", "orphan"]);
2368 }
2369
2370 #[test]
2371 fn ties_break_on_name() {
2372 let mut v = vec![
2373 track("Z", Some(1), Some(3)),
2374 track("A", Some(1), Some(3)),
2375 track("M", Some(1), Some(3)),
2376 ];
2377 sort_album_tracks(&mut v);
2378 assert_eq!(names(&v), vec!["A", "M", "Z"]);
2379 }
2380
2381 #[test]
2382 fn stable_when_already_sorted() {
2383 let mut v = vec![
2384 track("t1", Some(1), Some(1)),
2385 track("t2", Some(1), Some(2)),
2386 track("t3", Some(1), Some(3)),
2387 ];
2388 let before = names(&v);
2389 sort_album_tracks(&mut v);
2390 assert_eq!(names(&v), before);
2391 }
2392}