a Jellyfin & Subsonic client for the terminal — powered by mpv, Chromecast and UPnP MediaRenderer
mpv chromecast mpris navidrome jellyfin upnp tui
0

Configure Feed

Select the types of activity you want to include in your feed.

fin / crates / fin-tui / src / app.rs
90 kB 2392 lines
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}