diff --git a/CHANGELOG.md b/CHANGELOG.md index 74fccd30..1d856188 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ ### Added +- **Lyrics view: smooth scrolling, browsing, and a timing nudge**: The lyrics view now glides between lines with a short eased animation instead of snapping. Scroll with `↑`/`↓` (or the mouse wheel) to read ahead or behind: auto-follow pauses while you browse (the browsed line is highlighted and a hint row appears) and resumes with `f`, `Esc`, or automatically after 8 seconds without input. `Ctrl+d`/`Ctrl+u` page five lines at a time and `Ctrl+a`/`Ctrl+e` jump to the first/last line. For LRC files whose timestamps do not line up with your audio, `→`/`←` nudge the lyric timing half a second earlier/later per press; the current offset is shown in the block title and resets on track change. The title also notes "(timing estimated)" when LRCLIB only had plain lyrics, so synthesized line timings are no longer presented as real ones. + - **Banner Gradient toggle**: A new setting (Settings → Theme → Banner Gradient, or `behavior.banner_gradient` in `config.yml`) turns off the home banner's animated RGB gradient and draws it in the theme's banner color instead. The Terminal (ANSI) preset defaults the gradient off so the banner follows the terminal palette out of the box — tools like pywal recolor it live along with the rest of the UI; an explicit `banner_gradient:` in the config overrides the preset default either way ([#336](https://github.com/LargeModGames/spotatui/issues/336)). - **Shuffle and repeat for Local Files, Subsonic, and YouTube**: The shuffle (`Ctrl+s`) and repeat (`Ctrl+r`) keys now work when one of the decoded sources owns playback, not just on Spotify. Repeat cycles Off → All → Track exactly as it does for Spotify: **All** wraps the context from the last track back to the first, **Track** replays the current song. Shuffle reorders in place and keeps the currently playing song playing (it becomes the new first track), so toggling it never interrupts audio; turning it back off restores the original order and keeps your place in it. Both modes, plus the shuffle order itself, survive a restart along with the rest of the playback session in `last_session.yml`. The modes are also routed through MPRIS, so `playerctl shuffle`/`loop` and desktop media controls drive them. Internet radio and native queue slots have no track queue of their own, so their playbar and MPRIS snapshot drop the shuffle/repeat controls entirely rather than showing inert ones — which is why the default `format.playbar_status_source` template gained self-contained `{shuffle}`/`{repeat}` segments (see `docs/configuration.md`). diff --git a/spotatui.wiki b/spotatui.wiki index 2e1441ed..11bf7af5 160000 --- a/spotatui.wiki +++ b/spotatui.wiki @@ -1 +1 @@ -Subproject commit 2e1441edc59ddbd7789a1ae81b22b18524a6b79d +Subproject commit 11bf7af5a37537793fb191d870ede543966a1429 diff --git a/src/core/app.rs b/src/core/app.rs index 579f65d4..402de29e 100644 --- a/src/core/app.rs +++ b/src/core/app.rs @@ -701,6 +701,91 @@ pub enum LyricsStatus { NotFound, } +/// UI state for the lyrics view: the eased scroll position plus the manual +/// browsing mode. The lyric data itself lives in `App::lyrics`. +#[derive(Default)] +pub struct LyricsViewState { + /// Animated scroll position as a fractional lyric-line index. + pub scroll_pos: f64, + /// `Some(i)` while the user browses manually; auto-follow is paused and the + /// view centers on line `i` until follow resumes. + pub manual_index: Option, + /// When the last manual scroll input happened, for the auto-resnap timeout. + pub last_manual_input: Option, + /// User nudge applied to the playback position when matching lyric + /// timestamps, for correcting misaligned LRC files. Positive shows lyrics + /// earlier. Reset on track change. + pub timing_offset_ms: i64, + /// Position the current glide started from. + anim_from: f64, + /// Line index the current glide is heading to. + anim_target: f64, + /// Progress through the current glide, 0.0..=1.0. + anim_progress: f64, +} + +/// How long one scroll glide takes. Row steps quantize evenly across this +/// window thanks to the ease-in-out curve. +const LYRICS_SCROLL_ANIM: Duration = Duration::from_millis(300); + +/// Symmetric acceleration/deceleration so the whole-row steps a terminal can +/// show land evenly spaced in time (a first-frame jump followed by a slow +/// tail reads as stutter, not smoothness). +fn ease_in_out_cubic(t: f64) -> f64 { + if t < 0.5 { + 4.0 * t * t * t + } else { + 1.0 - (-2.0 * t + 2.0).powi(3) / 2.0 + } +} + +impl LyricsViewState { + pub fn reset(&mut self) { + *self = Self::default(); + } + + /// Glide the scroll position toward `target` over a fixed duration. A new + /// target mid-glide restarts the curve from the current position, so + /// motion stays continuous under rapid manual scrolling. + pub fn ease_toward(&mut self, target: f64, elapsed: Duration) { + if target != self.anim_target { + self.anim_from = self.scroll_pos; + self.anim_target = target; + self.anim_progress = 0.0; + } + if self.anim_progress >= 1.0 { + return; + } + self.anim_progress = + (self.anim_progress + elapsed.as_secs_f64() / LYRICS_SCROLL_ANIM.as_secs_f64()).min(1.0); + let eased = ease_in_out_cubic(self.anim_progress); + self.scroll_pos = self.anim_from + (self.anim_target - self.anim_from) * eased; + } + + /// Jump straight to `target` with no animation (used while the view is not + /// visible so it opens already in position). + pub fn snap_to(&mut self, target: f64) { + self.scroll_pos = target; + self.anim_from = target; + self.anim_target = target; + self.anim_progress = 1.0; + } +} + +/// Index of the lyric line currently playing: the last line whose timestamp is +/// at or before `progress_ms`. +pub fn active_lyric_index(lyrics: &[(u128, String)], progress_ms: u128) -> usize { + let mut active_idx = 0; + for (i, (time, _)) in lyrics.iter().enumerate() { + if *time <= progress_ms { + active_idx = i; + } else { + break; + } + } + active_idx +} + /// Data domains plugins can request through the scripting API. Each domain has /// a generation counter in [`PluginDataGenerations`] that the network layer /// bumps whenever it writes that domain to `App`, so the script engine can tell @@ -1198,6 +1283,11 @@ pub struct App { /// Title/artist pair whose lyrics response is currently desired. Detached /// service responses must match this before mutating visible state. pub desired_lyrics_identity: Option<(String, String)>, + /// Scroll/browse state for the lyrics view. + pub lyrics_view: LyricsViewState, + /// Whether the current `lyrics` carry real LRC timestamps rather than + /// synthesized evenly-spaced ones derived from plain lyrics. + pub lyrics_synced: bool, pub global_song_count: Option, pub global_song_count_failed: bool, // Settings screen state @@ -1608,6 +1698,8 @@ impl Default for App { lyrics: None, lyrics_status: LyricsStatus::default(), desired_lyrics_identity: None, + lyrics_view: LyricsViewState::default(), + lyrics_synced: false, global_song_count: None, global_song_count_failed: false, // Settings defaults @@ -2605,6 +2697,41 @@ impl App { } } + /// Playback progress adjusted by the user's lyric timing nudge, for + /// matching against lyric timestamps. + pub fn lyric_progress_ms(&self) -> u128 { + let adjusted = self.song_progress_ms as i128 + i128::from(self.lyrics_view.timing_offset_ms); + adjusted.max(0) as u128 + } + + /// Advance the lyrics view scroll animation: glide toward the focused line + /// while the view is open, snap when it is not, and drop back to + /// auto-follow after the browsing timeout. + fn advance_lyrics_scroll(&mut self, elapsed: Duration) { + const BROWSE_RESNAP_AFTER: Duration = Duration::from_secs(8); + let Some(lyrics) = &self.lyrics else { + return; + }; + if lyrics.is_empty() { + return; + } + if self.lyrics_view.manual_index.is_some() + && self + .lyrics_view + .last_manual_input + .is_some_and(|at| at.elapsed() >= BROWSE_RESNAP_AFTER) + { + self.lyrics_view.manual_index = None; + } + let active = active_lyric_index(lyrics, self.lyric_progress_ms()); + let target = self.lyrics_view.manual_index.unwrap_or(active) as f64; + if self.get_current_route().id == RouteId::LyricsView { + self.lyrics_view.ease_toward(target, elapsed); + } else { + self.lyrics_view.snap_to(target); + } + } + pub fn update_on_tick(&mut self, elapsed: Duration) { // Increment global animation tick (wraps after ~9.4 quintillion ticks, effectively never) self.animation_tick = self.animation_tick.wrapping_add(1); @@ -2644,6 +2771,8 @@ impl App { } } + self.advance_lyrics_scroll(elapsed); + // Load watchdog: a native load that produces no Playing/TrackChanged // event within the window means the session is a zombie (passes // `is_connected`, drops Spirc commands) — force recovery; the parked @@ -6979,6 +7108,66 @@ mod tests { use std::collections::HashMap; use std::sync::mpsc::channel; + #[test] + fn active_lyric_index_picks_last_started_line() { + let lyrics = vec![ + (1_000u128, "a".to_string()), + (5_000, "b".to_string()), + (10_000, "c".to_string()), + ]; + assert_eq!(active_lyric_index(&lyrics, 0), 0); + assert_eq!(active_lyric_index(&lyrics, 4_999), 0); + assert_eq!(active_lyric_index(&lyrics, 5_000), 1); + assert_eq!(active_lyric_index(&lyrics, 99_000), 2); + } + + #[test] + fn lyrics_scroll_eases_monotonically_and_snaps_on_target() { + let mut state = LyricsViewState::default(); + let mut last = 0.0; + for _ in 0..200 { + state.ease_toward(10.0, Duration::from_millis(16)); + assert!(state.scroll_pos > last && state.scroll_pos <= 10.0); + last = state.scroll_pos; + if state.scroll_pos == 10.0 { + break; + } + } + assert_eq!(state.scroll_pos, 10.0, "easing never converged"); + } + + #[test] + fn browsing_resnaps_to_follow_after_timeout() { + let mut app = App::default(); + app.lyrics = Some(vec![(0, "a".to_string()), (5_000, "b".to_string())]); + app.lyrics_status = LyricsStatus::Found; + app.lyrics_view.manual_index = Some(1); + app.lyrics_view.last_manual_input = Instant::now().checked_sub(Duration::from_secs(9)); + app.advance_lyrics_scroll(Duration::from_millis(16)); + assert_eq!(app.lyrics_view.manual_index, None); + } + + #[test] + fn lyric_progress_applies_offset_and_clamps_at_zero() { + let mut app = App::default(); + app.song_progress_ms = 10_000; + app.lyrics_view.timing_offset_ms = 2_500; + assert_eq!(app.lyric_progress_ms(), 12_500); + app.lyrics_view.timing_offset_ms = -15_000; + assert_eq!(app.lyric_progress_ms(), 0); + } + + #[test] + fn browsing_persists_while_input_is_recent() { + let mut app = App::default(); + app.lyrics = Some(vec![(0, "a".to_string()), (5_000, "b".to_string())]); + app.lyrics_status = LyricsStatus::Found; + app.lyrics_view.manual_index = Some(1); + app.lyrics_view.last_manual_input = Some(Instant::now()); + app.advance_lyrics_scroll(Duration::from_millis(16)); + assert_eq!(app.lyrics_view.manual_index, Some(1)); + } + #[cfg(feature = "streaming")] #[test] fn parked_playback_retries_are_keyed_to_the_request() { diff --git a/src/infra/network/utils.rs b/src/infra/network/utils.rs index 7e8b0ee5..f17b0739 100644 --- a/src/infra/network/utils.rs +++ b/src/infra/network/utils.rs @@ -88,6 +88,7 @@ impl UtilsNetwork for Network { } app.lyrics_status = LyricsStatus::Loading; app.lyrics = None; + app.lyrics_synced = false; } match client @@ -114,6 +115,7 @@ impl UtilsNetwork for Network { } if !synced.is_empty() { app.lyrics = Some(synced); + app.lyrics_synced = true; app.lyrics_status = LyricsStatus::Found; } else if let Some(plain) = lrc_resp .plainLyrics @@ -121,6 +123,7 @@ impl UtilsNetwork for Network { .filter(|text| !text.trim().is_empty()) { app.lyrics = Some(synthesize_plain_lyrics(plain, duration)); + app.lyrics_synced = false; app.lyrics_status = LyricsStatus::Found; } else { app.lyrics_status = LyricsStatus::NotFound; diff --git a/src/tui/handlers/lyrics_view.rs b/src/tui/handlers/lyrics_view.rs index 573c2060..3c7e98c6 100644 --- a/src/tui/handlers/lyrics_view.rs +++ b/src/tui/handlers/lyrics_view.rs @@ -1,14 +1,68 @@ -use crate::core::app::App; +use super::common_key_events; +use crate::core::app::{active_lyric_index, App, LyricsStatus}; use crate::tui::event::Key; +use std::time::Instant; pub fn handler(key: Key, app: &mut App) { match key { - Key::Char('s') => { - super::playbar::toggle_like_currently_playing_item(app); + Key::Char('f') => { + app.lyrics_view.manual_index = None; } k if k == app.user_config.keys.back => { app.pop_navigation_stack(); } + k if common_key_events::up_event(k, &app.user_config.keys) => scroll_by(app, -1), + k if common_key_events::down_event(k, &app.user_config.keys) => scroll_by(app, 1), + k if common_key_events::left_event(k, &app.user_config.keys) => nudge_timing(app, -500), + k if common_key_events::right_event(k, &app.user_config.keys) => nudge_timing(app, 500), + k if k == app.user_config.keys.next_page => scroll_by(app, 5), + k if k == app.user_config.keys.previous_page => scroll_by(app, -5), + k if k == app.user_config.keys.jump_to_start => jump_to(app, 0), + k if k == app.user_config.keys.jump_to_end => jump_to(app, usize::MAX), _ => {} } } + +/// Scroll the browsed line by `delta`, entering manual mode from the +/// currently playing line when auto-follow was active. +pub(super) fn scroll_by(app: &mut App, delta: i64) { + if app.lyrics_status != LyricsStatus::Found { + return; + } + let Some(lyrics) = &app.lyrics else { + return; + }; + if lyrics.is_empty() { + return; + } + let from = app + .lyrics_view + .manual_index + .unwrap_or_else(|| active_lyric_index(lyrics, app.lyric_progress_ms())); + let target = (from as i64 + delta).clamp(0, lyrics.len() as i64 - 1) as usize; + app.lyrics_view.manual_index = Some(target); + app.lyrics_view.last_manual_input = Some(Instant::now()); +} + +/// Shift lyric timing relative to playback, for correcting misaligned LRC +/// files. Positive delta shows lyrics earlier. +fn nudge_timing(app: &mut App, delta_ms: i64) { + if app.lyrics_status != LyricsStatus::Found { + return; + } + app.lyrics_view.timing_offset_ms += delta_ms; +} + +fn jump_to(app: &mut App, index: usize) { + if app.lyrics_status != LyricsStatus::Found { + return; + } + let Some(lyrics) = &app.lyrics else { + return; + }; + if lyrics.is_empty() { + return; + } + app.lyrics_view.manual_index = Some(index.min(lyrics.len() - 1)); + app.lyrics_view.last_manual_input = Some(Instant::now()); +} diff --git a/src/tui/handlers/mod.rs b/src/tui/handlers/mod.rs index 83f4810f..f456a708 100644 --- a/src/tui/handlers/mod.rs +++ b/src/tui/handlers/mod.rs @@ -548,8 +548,15 @@ fn handle_escape(app: &mut App) { ActiveBlock::Party => { app.pop_navigation_stack(); } + ActiveBlock::LyricsView => { + // Esc first leaves lyrics browsing mode; a second Esc closes the view. + if app.lyrics_view.manual_index.is_some() { + app.lyrics_view.manual_index = None; + } else { + app.pop_navigation_stack(); + } + } ActiveBlock::SelectDevice - | ActiveBlock::LyricsView | ActiveBlock::CoverArtView | ActiveBlock::MiniPlayer | ActiveBlock::PluginScreen => { diff --git a/src/tui/handlers/mouse.rs b/src/tui/handlers/mouse.rs index cdc47c1b..daf9476f 100644 --- a/src/tui/handlers/mouse.rs +++ b/src/tui/handlers/mouse.rs @@ -1,4 +1,4 @@ -use super::{common_key_events, library, playbar, playlist, settings}; +use super::{common_key_events, library, lyrics_view, playbar, playlist, settings}; use crate::core::app::{ ActiveBlock, App, RouteId, SettingValue, SettingsCategory, LIBRARY_OPTIONS, }; @@ -26,12 +26,23 @@ pub fn handler(mouse: MouseEvent, app: &mut App) { } if current_route_id == RouteId::LyricsView { - handle_fullscreen_playbar_mouse( - ActiveBlock::LyricsView, - fullscreen_view_playbar_area(app), - mouse, - app, - ); + let playbar_area = fullscreen_view_playbar_area(app); + let over_playbar = + playbar_area.is_some_and(|area| rect_contains(area, mouse.column, mouse.row)); + if !over_playbar { + match mouse.kind { + MouseEventKind::ScrollUp => { + lyrics_view::scroll_by(app, -1); + return; + } + MouseEventKind::ScrollDown => { + lyrics_view::scroll_by(app, 1); + return; + } + _ => {} + } + } + handle_fullscreen_playbar_mouse(ActiveBlock::LyricsView, playbar_area, mouse, app); return; } diff --git a/src/tui/runner.rs b/src/tui/runner.rs index aeae769c..f1338d65 100644 --- a/src/tui/runner.rs +++ b/src/tui/runner.rs @@ -682,6 +682,8 @@ pub async fn start_ui( let animation_active = (current_route.id == RouteId::Home && app.user_config.behavior.banner_gradient) || current_route.active_block == ActiveBlock::Analysis + || (current_route.id == RouteId::LyricsView + && app.lyrics_status == crate::core::app::LyricsStatus::Found) || app.liked_song_animation_frame.is_some(); let current_tick_rate = if animation_active { app.user_config.behavior.animation_tick_rate_milliseconds @@ -879,6 +881,7 @@ pub async fn start_ui( let identity = snapshot.as_ref().map(track_identity); if identity != last_track_identity { last_track_identity = identity; + app.lyrics_view.reset(); match snapshot.as_ref() { Some(snapshot) => { use crate::infra::media_metadata::PlaybackItemKind; diff --git a/src/tui/ui/help.rs b/src/tui/ui/help.rs index 0810f386..010a8dc9 100644 --- a/src/tui/ui/help.rs +++ b/src/tui/ui/help.rs @@ -153,6 +153,27 @@ pub fn get_help_docs(app: &App) -> Vec> { key_bindings.lyrics_view.to_string(), String::from("General"), ], + vec![ + String::from("Scroll lyrics (pauses auto-follow)"), + format!( + "{}/{} | / | /", + key_bindings.move_up, key_bindings.move_down + ), + String::from("Lyrics view"), + ], + vec![ + String::from("Resume following the current lyric line"), + String::from("f or "), + String::from("Lyrics view"), + ], + vec![ + String::from("Nudge lyric timing earlier/later"), + format!( + "{}/{} | / | /", + key_bindings.move_right, key_bindings.move_left + ), + String::from("Lyrics view"), + ], vec![ String::from("Toggle miniplayer view"), key_bindings.miniplayer_view.to_string(), diff --git a/src/tui/ui/lyrics.rs b/src/tui/ui/lyrics.rs new file mode 100644 index 00000000..4f2683fa --- /dev/null +++ b/src/tui/ui/lyrics.rs @@ -0,0 +1,295 @@ +use crate::core::app::{active_lyric_index, App, LyricsStatus}; +use crate::core::layout::fullscreen_view_layout; +use ratatui::{ + layout::{Alignment, Rect}, + style::{Modifier, Style}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use super::player::draw_playbar; + +pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) { + let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); + + draw_lyrics(f, app, content_area); + if let Some(playbar_area) = playbar_area { + draw_playbar(f, app, playbar_area); + } +} + +fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { + let theme = &app.user_config.theme; + + let mut notes: Vec = Vec::new(); + if app.lyrics_status == LyricsStatus::Found { + if !app.lyrics_synced { + notes.push("timing estimated".to_string()); + } + let offset_ms = app.lyrics_view.timing_offset_ms; + if offset_ms != 0 { + notes.push(format!("offset {:+.1}s", offset_ms as f64 / 1000.0)); + } + } + let title = if notes.is_empty() { + " Lyrics ".to_string() + } else { + format!(" Lyrics ({}) ", notes.join(", ")) + }; + let block = Block::default() + .borders(Borders::ALL) + .title(title) + .style(Style::default().fg(theme.inactive)); + f.render_widget(block.clone(), area); + + let inner_area = block.inner(area); + if inner_area.width == 0 || inner_area.height == 0 { + return; + } + + if app.lyrics_status != LyricsStatus::Found { + draw_state_message(f, app, inner_area); + return; + } + + let Some(lyrics) = &app.lyrics else { + return; + }; + if lyrics.is_empty() { + return; + } + + // Reserve the bottom row for the browsing hint while in manual mode. + let manual = app.lyrics_view.manual_index.is_some(); + let mut lyric_area = inner_area; + if manual && lyric_area.height > 1 { + lyric_area.height -= 1; + } + + let active_idx = active_lyric_index(lyrics, app.lyric_progress_ms()); + let focus_idx = app + .lyrics_view + .manual_index + .unwrap_or(active_idx) + .min(lyrics.len() - 1); + + // The focused line sits at the vertical center; `scroll_pos` is the eased + // fractional line index, so the whole column glides between lines instead + // of snapping (line spacing stays intact because every line shares the same + // rounding offset). + let target_row = i64::from(lyric_area.y) + i64::from(lyric_area.height / 2); + let scroll_offset = app.lyrics_view.scroll_pos; + + for (line_idx, (_, text)) in lyrics.iter().enumerate() { + let y = target_row + (line_idx as f64 - scroll_offset).round() as i64; + if y < i64::from(lyric_area.y) || y >= i64::from(lyric_area.y) + i64::from(lyric_area.height) { + continue; + } + + let style = if line_idx == active_idx { + Style::default() + .fg(theme.highlighted_lyrics) + .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) + } else if manual && line_idx == focus_idx { + Style::default().fg(theme.hovered) + } else { + Style::default().fg(theme.inactive) + }; + + f.render_widget( + Paragraph::new(text.clone()) + .style(style) + .alignment(Alignment::Center), + Rect { + x: lyric_area.x, + y: y as u16, + width: lyric_area.width, + height: 1, + }, + ); + } + + if manual { + f.render_widget( + Paragraph::new("browsing · ↑/↓ scroll · Esc follow") + .style(Style::default().fg(theme.hint)) + .alignment(Alignment::Center), + Rect { + y: inner_area.y + inner_area.height - 1, + height: 1, + ..inner_area + }, + ); + } +} + +/// Centered two-line message for the non-Found lyric states. +fn draw_state_message(f: &mut Frame<'_>, app: &App, inner_area: Rect) { + let (primary, secondary) = match app.lyrics_status { + LyricsStatus::Loading => ("Fetching lyrics…", "from LRCLIB"), + LyricsStatus::NotFound => ("No lyrics for this track", "lyrics provided by LRCLIB"), + LyricsStatus::NotStarted => ("Nothing playing", "start a track to see lyrics"), + LyricsStatus::Found => return, + }; + + let theme = &app.user_config.theme; + let primary_y = inner_area.y + inner_area.height.saturating_sub(1) / 2; + f.render_widget( + Paragraph::new(primary) + .style(Style::default().fg(theme.text)) + .alignment(Alignment::Center), + Rect { + y: primary_y, + height: 1, + ..inner_area + }, + ); + let secondary_y = primary_y + 1; + if secondary_y < inner_area.y + inner_area.height { + f.render_widget( + Paragraph::new(secondary) + .style(Style::default().fg(theme.inactive)) + .alignment(Alignment::Center), + Rect { + y: secondary_y, + height: 1, + ..inner_area + }, + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::app::{ActiveBlock, RouteId}; + use ratatui::{backend::TestBackend, buffer::Buffer, Terminal}; + + fn app_with_lyrics() -> App { + let mut app = App::default(); + app.lyrics = Some(vec![ + (0, "first line".to_string()), + (10_000, "second line".to_string()), + (20_000, "third line".to_string()), + ]); + app.lyrics_status = LyricsStatus::Found; + app.lyrics_synced = true; + app.song_progress_ms = 11_000; + app.lyrics_view.scroll_pos = 1.0; + app.push_navigation_stack(RouteId::LyricsView, ActiveBlock::LyricsView); + app + } + + fn render(app: &App) -> Buffer { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + terminal.draw(|f| draw_lyrics_view(f, app)).unwrap(); + terminal.backend().buffer().clone() + } + + fn buffer_text(buffer: &Buffer) -> String { + (0..buffer.area().height) + .map(|y| { + (0..buffer.area().width) + .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect::() + }) + .collect::>() + .join("\n") + } + + fn row_of(buffer: &Buffer, needle: &str) -> Option { + (0..buffer.area().height).find(|&y| { + (0..buffer.area().width) + .filter_map(|x| buffer.cell((x, y)).map(|c| c.symbol().to_string())) + .collect::() + .contains(needle) + }) + } + + #[test] + fn renders_all_lines_with_active_centered() { + let app = app_with_lyrics(); + let buffer = render(&app); + let text = buffer_text(&buffer); + assert!(text.contains(" Lyrics "), "missing block title:\n{text}"); + assert!(text.contains("first line"), "missing prior line:\n{text}"); + assert!(text.contains("second line"), "missing active line:\n{text}"); + assert!(text.contains("third line"), "missing next line:\n{text}"); + + // scroll_pos = 1.0 puts the active line at the vertical center of the + // lyric area. + let active_row = row_of(&buffer, "second line").expect("active line row"); + let expected = 1 + (24 - app.user_config.behavior.playbar_height_rows - 2) / 2; + assert_eq!(active_row, expected); + } + + #[test] + fn fractional_scroll_shifts_the_whole_column() { + let mut app = app_with_lyrics(); + let before = row_of(&render(&app), "second line").expect("active line row"); + app.lyrics_view.scroll_pos = 1.6; + let buffer = render(&app); + let after = row_of(&buffer, "second line").expect("active line row"); + assert_eq!(after, before - 1, "column should glide up mid-animation"); + // Line spacing stays intact while gliding. + let third = row_of(&buffer, "third line").expect("next line row"); + assert_eq!(third, after + 1); + } + + #[test] + fn title_notes_estimated_timing_for_plain_lyrics() { + let mut app = app_with_lyrics(); + app.lyrics_synced = false; + let text = buffer_text(&render(&app)); + assert!( + text.contains("Lyrics (timing estimated)"), + "missing timing hint:\n{text}" + ); + } + + #[test] + fn title_shows_timing_offset_and_it_shifts_the_active_line() { + let mut app = app_with_lyrics(); + // progress 11s + 9.5s nudge = 20.5s, past the third line's timestamp. + app.lyrics_view.timing_offset_ms = 9_500; + let buffer = render(&app); + let text = buffer_text(&buffer); + assert!( + text.contains("Lyrics (offset +9.5s)"), + "missing offset note:\n{text}" + ); + let active_style = buffer + .cell(( + buffer.area().width / 2, + row_of(&buffer, "third line").expect("third line row"), + )) + .unwrap() + .style(); + assert_eq!( + active_style.fg, + Some(app.user_config.theme.highlighted_lyrics), + "nudged line should be the highlighted one" + ); + } + + #[test] + fn renders_browsing_hint_in_manual_mode() { + let mut app = app_with_lyrics(); + app.lyrics_view.manual_index = Some(2); + let text = buffer_text(&render(&app)); + assert!(text.contains("browsing"), "missing browsing hint:\n{text}"); + assert!(text.contains("third line"), "missing browsed line:\n{text}"); + } + + #[test] + fn renders_not_found_state() { + let mut app = app_with_lyrics(); + app.lyrics = None; + app.lyrics_status = LyricsStatus::NotFound; + let text = buffer_text(&render(&app)); + assert!( + text.contains("No lyrics for this track"), + "missing not-found message:\n{text}" + ); + } +} diff --git a/src/tui/ui/mod.rs b/src/tui/ui/mod.rs index a3677bd2..f06f6420 100644 --- a/src/tui/ui/mod.rs +++ b/src/tui/ui/mod.rs @@ -7,6 +7,7 @@ pub mod friends; pub mod help; pub mod home; pub mod library; +pub mod lyrics; pub mod player; pub mod plugin_screen; pub mod popups; @@ -26,10 +27,11 @@ pub use self::discover::draw_discover; pub use self::friends::draw_friends; pub use self::home::draw_home; pub use self::library::draw_user_block; +pub use self::lyrics::draw_lyrics_view; #[cfg(feature = "cover-art")] pub use self::player::draw_cover_art_view; pub use self::player::draw_miniplayer; -pub use self::player::{draw_device_list, draw_lyrics_view, draw_playbar}; +pub use self::player::{draw_device_list, draw_playbar}; pub use self::plugin_screen::draw_plugin_screen; pub use self::popups::{ draw_announcement_prompt, draw_dialog, draw_error_screen, draw_exit_prompt, draw_help_menu, diff --git a/src/tui/ui/player.rs b/src/tui/ui/player.rs index 0d0b0b9f..04f69927 100644 --- a/src/tui/ui/player.rs +++ b/src/tui/ui/player.rs @@ -1,10 +1,15 @@ +#[cfg(feature = "cover-art")] +use crate::core::layout::fullscreen_view_layout; +#[cfg(feature = "cover-art")] +use ratatui::layout::Alignment; + use crate::core::{ app::{ActiveBlock, App, SourceFocus}, - layout::{fullscreen_view_layout, miniplayer_playbar_area}, + layout::miniplayer_playbar_area, source::Source, }; use ratatui::{ - layout::{Alignment, Constraint, Layout, Position, Rect}, + layout::{Constraint, Layout, Position, Rect}, style::{Modifier, Style}, text::{Line, Span, Text}, widgets::{ @@ -695,15 +700,6 @@ fn center_rect_within(bounds: Rect, size: Rect) -> Rect { } } -pub fn draw_lyrics_view(f: &mut Frame<'_>, app: &App) { - let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); - - draw_lyrics(f, app, content_area); - if let Some(playbar_area) = playbar_area { - draw_playbar(f, app, playbar_area); - } -} - #[cfg(feature = "cover-art")] pub fn draw_cover_art_view(f: &mut Frame<'_>, app: &App) { let (content_area, playbar_area) = fullscreen_view_layout(&app.user_config.behavior, f.area()); @@ -837,105 +833,6 @@ fn extract_track_info(app: &App) -> (Option, Option) { } } -fn draw_lyrics(f: &mut Frame<'_>, app: &App, area: Rect) { - use crate::core::app::LyricsStatus; - - // Draw bordered block first - let block = Block::default() - .borders(Borders::ALL) - .title(" Lyrics ") - .style(Style::default().fg(app.user_config.theme.inactive)); - f.render_widget(block.clone(), area); - - let inner_area = block.inner(area); - - if app.lyrics_status != LyricsStatus::Found { - let text = match app.lyrics_status { - LyricsStatus::Loading => "Loading lyrics...", - LyricsStatus::NotFound => "No lyrics found for this track.", - LyricsStatus::NotStarted => "Waiting for track update...", - LyricsStatus::Found => "", - }; - - if !text.is_empty() { - let p = Paragraph::new(text) - .style(Style::default().fg(app.user_config.theme.inactive)) - .alignment(Alignment::Center); - - // Center vertically in inner area - let vertical_center = inner_area.y + inner_area.height / 2; - let top_area = Rect { - x: inner_area.x, - y: vertical_center.saturating_sub(0), // Just one line centered - width: inner_area.width, - height: 1, - }; - f.render_widget(p, top_area); - } - return; - } - - if let Some(lyrics) = &app.lyrics { - if lyrics.is_empty() { - return; - } - - let current_time = app.song_progress_ms; - let mut active_idx = 0; - for (i, (time, _)) in lyrics.iter().enumerate() { - if *time <= current_time { - active_idx = i; - } else { - break; - } - } - - // Target position for active line: Vertical center of inner_area - let target_row = inner_area.y + (inner_area.height / 2); - - let area_height = inner_area.height as i32; - let area_y = inner_area.y as i32; - - // Loop through all visible rows of the screen area - for row in 0..area_height { - let screen_y = area_y + row; - - // screen_y = target_row + (line_idx - active_idx) - // line_idx = screen_y - target_row + active_idx - - let offset_from_target = screen_y - (target_row as i32); - let line_idx = active_idx as i32 + offset_from_target; - - if line_idx >= 0 && line_idx < lyrics.len() as i32 { - let (_, text) = &lyrics[line_idx as usize]; - let is_active = line_idx == active_idx as i32; - - // Use explicit RGB colors for cross-terminal compatibility - // Some terminals (like Kitty with custom themes) remap ANSI colors - let style = if is_active { - Style::default() - .fg(app.user_config.theme.highlighted_lyrics) // Use theme color for highlighted lyrics - .add_modifier(app.user_config.behavior.emphasis(Modifier::BOLD)) - } else { - Style::default().fg(app.user_config.theme.inactive) // Dim gray for inactive lines - }; - - let p = Paragraph::new(text.clone()) - .style(style) - .alignment(Alignment::Center); - - let line_rect = Rect { - x: inner_area.x, - y: screen_y as u16, - width: inner_area.width, - height: 1, - }; - f.render_widget(p, line_rect); - } - } - } -} - /// Display snapshot for an engine playbar (local files, Subsonic, radio, or the /// native queue slot). ///