Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).
Expand Down
2 changes: 1 addition & 1 deletion spotatui.wiki
189 changes: 189 additions & 0 deletions src/core/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>,
/// When the last manual scroll input happened, for the auto-resnap timeout.
pub last_manual_input: Option<Instant>,
/// 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
Expand Down Expand Up @@ -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<u64>,
pub global_song_count_failed: bool,
// Settings screen state
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions src/infra/network/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ impl UtilsNetwork for Network {
}
app.lyrics_status = LyricsStatus::Loading;
app.lyrics = None;
app.lyrics_synced = false;
}

match client
Expand All @@ -114,13 +115,15 @@ 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
.as_deref()
.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;
Expand Down
60 changes: 57 additions & 3 deletions src/tui/handlers/lyrics_view.rs
Original file line number Diff line number Diff line change
@@ -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());
}
9 changes: 8 additions & 1 deletion src/tui/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down
25 changes: 18 additions & 7 deletions src/tui/handlers/mouse.rs
Original file line number Diff line number Diff line change
@@ -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,
};
Expand Down Expand Up @@ -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;
}

Expand Down
Loading
Loading