Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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: 1 addition & 1 deletion gui/src/bindings/HelpStrings.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

export type HelpStrings = { arrow_key_template: string, key_alternative_template: string, next_page: string, previous_page: string, playlist_start: string, playlist_end: string, current_album: string, current_artist_albums: string, current_context: string, volume_up: string, volume_down: string, next_track: string, previous_track: string, force_previous_track: string, seek_backward: string, seek_forward: string, toggle_shuffle: string, copy_song_url: string, copy_album_url: string, cycle_repeat: string, move_left: string, move_down: string, move_up: string, move_right: string, list_top: string, list_middle: string, list_bottom: string, search: string, filter_help: string, search_playlist_picker: string, filter_settings: string, toggle_playback: string, active_mode: string, audio_analysis: string, visualizer_style: string, lyrics_view: string, lyrics_scroll: string, lyrics_follow: string, lyrics_timing: string, miniplayer: string, cover_art: string, back: string, source_device: string, settings_open: string, settings_save: string, settings_button: string, hover_mode: string, save_track: string, add_selected_to_playlist: string, add_playing_to_playlist: string, quick_add_playing: string, sidebar_narrower: string, sidebar_wider: string, panels_shorter: string, panels_taller: string, layout_reset: string, remove_playlist_track: string, search_playlist: string, clear_playlist_filter: string, play_selection: string, recommendations: string, artist_tracks: string, search_submit: string, cursor_left: string, cursor_right: string, input_clear: string, input_delete_before: string, input_delete_after: string, input_delete_word: string, input_start: string, input_end: string, input_escape: string, album_delete: string, artist_unfollow: string, show_remove: string, playlist_delete: string, playlist_mirror: string, radio_remove: string, follow_artist_playlist: string, album_save: string, playlist_random: string, episode_sort: string, show_follow: string, show_unfollow: string, queue_add: string, queue_show: string, queue_remove: string, queue_move: string, queue_play: string, toggle_saved: string, radio_favorite: string, listening_recap: string, stats_open: string, stats_period: string, stats_play: string, playlist_sync_open: string, playlist_sync_run: string, playlist_sync_remove: string, sort_open: string, party_open: string, dj_open: string, dj_auto_queue: string, dj_vibe: string, dj_fresh: string, dj_model: string, pane_detach: string, pane_close: string, pane_focus_left: string, pane_focus_down: string, pane_focus_up: string, pane_focus_right: string, pane_move_left: string, pane_move_down: string, pane_move_up: string, pane_move_right: string, pane_rotate: string, pane_grow: string, pane_shrink: string, pane_reset: string, context_pagination: string, context_general: string, context_general_playlist_search: string, context_help: string, context_playlist_picker: string, context_settings: string, context_lyrics: string, context_selected_block: string, context_add_track: string, context_playbar: string, context_global: string, context_layout: string, context_playlist_tracks: string, context_filtered_playlist_tracks: string, context_library_artists: string, context_search_input: string, context_library_albums: string, context_artist_unfollow: string, context_library_podcasts: string, context_playlist: string, context_radio: string, context_search_results: string, context_save_album: string, context_selected_playlist: string, context_selected_show: string, context_hovered_track: string, context_queue: string, context_stats: string, context_playlist_sync: string, context_sortable_lists: string, context_ai_dj: string, library_sidebar: string, title: string, column_description: string, column_event: string, column_context: string, no_matches_template: string, filter_prompt: string, filter_edit_hint: string, matches_template: string, filter_count_hint_template: string, filter_hint: string, needs_spotify: string, not_for_source_template: string, only_for_source_template: string, unavailable_description_template: string, };
export type HelpStrings = { arrow_key_template: string, key_alternative_template: string, next_page: string, previous_page: string, playlist_start: string, playlist_end: string, current_album: string, current_artist_albums: string, current_context: string, volume_up: string, volume_down: string, next_track: string, previous_track: string, force_previous_track: string, seek_backward: string, seek_forward: string, toggle_shuffle: string, copy_song_url: string, copy_album_url: string, cycle_repeat: string, move_left: string, move_down: string, move_up: string, move_right: string, list_top: string, list_middle: string, list_bottom: string, search: string, filter_help: string, search_playlist_picker: string, filter_settings: string, toggle_playback: string, active_mode: string, audio_analysis: string, visualizer_style: string, lyrics_view: string, lyrics_scroll: string, lyrics_follow: string, lyrics_timing: string, lyrics_seek: string, miniplayer: string, cover_art: string, back: string, source_device: string, settings_open: string, settings_save: string, settings_button: string, hover_mode: string, save_track: string, add_selected_to_playlist: string, add_playing_to_playlist: string, quick_add_playing: string, sidebar_narrower: string, sidebar_wider: string, panels_shorter: string, panels_taller: string, layout_reset: string, remove_playlist_track: string, search_playlist: string, clear_playlist_filter: string, play_selection: string, recommendations: string, artist_tracks: string, search_submit: string, cursor_left: string, cursor_right: string, input_clear: string, input_delete_before: string, input_delete_after: string, input_delete_word: string, input_start: string, input_end: string, input_escape: string, album_delete: string, artist_unfollow: string, show_remove: string, playlist_delete: string, playlist_mirror: string, radio_remove: string, follow_artist_playlist: string, album_save: string, playlist_random: string, episode_sort: string, show_follow: string, show_unfollow: string, queue_add: string, queue_show: string, queue_remove: string, queue_move: string, queue_play: string, toggle_saved: string, radio_favorite: string, listening_recap: string, stats_open: string, stats_period: string, stats_play: string, playlist_sync_open: string, playlist_sync_run: string, playlist_sync_remove: string, sort_open: string, party_open: string, dj_open: string, dj_auto_queue: string, dj_vibe: string, dj_fresh: string, dj_model: string, pane_detach: string, pane_close: string, pane_focus_left: string, pane_focus_down: string, pane_focus_up: string, pane_focus_right: string, pane_move_left: string, pane_move_down: string, pane_move_up: string, pane_move_right: string, pane_rotate: string, pane_grow: string, pane_shrink: string, pane_reset: string, context_pagination: string, context_general: string, context_general_playlist_search: string, context_help: string, context_playlist_picker: string, context_settings: string, context_lyrics: string, context_selected_block: string, context_add_track: string, context_playbar: string, context_global: string, context_layout: string, context_playlist_tracks: string, context_filtered_playlist_tracks: string, context_library_artists: string, context_search_input: string, context_library_albums: string, context_artist_unfollow: string, context_library_podcasts: string, context_playlist: string, context_radio: string, context_search_results: string, context_save_album: string, context_selected_playlist: string, context_selected_show: string, context_hovered_track: string, context_queue: string, context_stats: string, context_playlist_sync: string, context_sortable_lists: string, context_ai_dj: string, library_sidebar: string, title: string, column_description: string, column_event: string, column_context: string, no_matches_template: string, filter_prompt: string, filter_edit_hint: string, matches_template: string, filter_count_hint_template: string, filter_hint: string, needs_spotify: string, not_for_source_template: string, only_for_source_template: string, unavailable_description_template: string, };
9 changes: 6 additions & 3 deletions gui/src/bindings/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -601,6 +601,7 @@
"listening_recap": "Generate listening recap card (selected period on Stats, 30 days elsewhere)",
"lyrics_follow": "Resume following the current lyric line",
"lyrics_scroll": "Scroll lyrics (pauses auto-follow)",
"lyrics_seek": "Seek to selected lyric line",
"lyrics_timing": "Nudge lyric timing earlier/later",
"lyrics_view": "Go to lyrics view",
"matches_template": "matches for '{filter}'",
Expand Down Expand Up @@ -1036,7 +1037,7 @@
"local_files_empty": "Local Files (no folders — set behavior.local_music_path)",
"local_source_short": "Local",
"log_location": "Log located in {path}",
"lyrics_browsing": "browsing · ↑/↓ scroll · Esc follow",
"lyrics_browsing": "browsing · ↑/↓ scroll · Esc follow · Enter seek",
"lyrics_credit": "lyrics provided by LRCLIB",
"lyrics_from": "from LRCLIB",
"lyrics_loading": "Fetching lyrics…",
Expand Down Expand Up @@ -2710,6 +2711,7 @@
"listening_recap": "生成收听回顾卡片(统计页用所选时间段,其他界面用 30 天)",
"lyrics_follow": "恢复跟随当前歌词",
"lyrics_scroll": "滚动歌词(暂停自动跟随)",
"lyrics_seek": "跳转到所选歌词行",
"lyrics_timing": "微调歌词时间:提前或延后",
"lyrics_view": "打开歌词界面",
"matches_template": "“{filter}”的匹配项",
Expand Down Expand Up @@ -3145,7 +3147,7 @@
"local_files_empty": "本地文件(没有文件夹,请设置 behavior.local_music_path)",
"local_source_short": "本地",
"log_location": "日志位于 {path}",
"lyrics_browsing": "浏览中 · ↑/↓ 滚动 · Esc 跟随",
"lyrics_browsing": "浏览中 · ↑/↓ 滚动 · Esc 跟随 · Enter 跳转",
"lyrics_credit": "歌词由 LRCLIB 提供",
"lyrics_from": "来自 LRCLIB",
"lyrics_loading": "正在获取歌词…",
Expand Down Expand Up @@ -4819,6 +4821,7 @@
"listening_recap": "產生聆聽回顧卡片(統計頁使用所選期間,其他畫面使用 30 天)",
"lyrics_follow": "恢復跟隨目前歌詞",
"lyrics_scroll": "捲動歌詞(暫停自動跟隨)",
"lyrics_seek": "跳轉到所選歌詞行",
"lyrics_timing": "微調歌詞時間:提前或延後",
"lyrics_view": "開啟歌詞畫面",
"matches_template": "「{filter}」的符合項目",
Expand Down Expand Up @@ -5254,7 +5257,7 @@
"local_files_empty": "本機檔案(無資料夾,請設定 behavior.local_music_path)",
"local_source_short": "本機",
"log_location": "日誌位於 {path}",
"lyrics_browsing": "瀏覽中 · ↑/↓ 捲動 · Esc 跟隨",
"lyrics_browsing": "瀏覽中 · ↑/↓ 捲動 · Esc 跟隨 · Enter 跳轉",
"lyrics_credit": "歌詞由 LRCLIB 提供",
"lyrics_from": "來自 LRCLIB",
"lyrics_loading": "正在取得歌詞…",
Expand Down
25 changes: 24 additions & 1 deletion src/core/app/seek.rs
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,11 @@ impl App {
_ => return,
};

let new_progress = position_ms.min(duration_ms);
// Clamp to strictly less than track duration so an absolute seek (such as
// from lyrics selection or playbar scrubbing) cannot overshoot and trigger
// `NextTrack` in `apply_seek`.
let max_seek = duration_ms.saturating_sub(1);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
let new_progress = position_ms.min(max_seek);
self.seek_ms = Some(new_progress as u128);

// Use native streaming player for instant control (bypasses event channel latency)
Expand Down Expand Up @@ -457,4 +461,23 @@ mod tests {

assert!(matches!(rx.try_recv(), Ok(IoEvent::NextTrack)));
}

#[test]
fn seek_to_clamps_below_track_duration_to_prevent_next_track() {
let (tx, rx) = std::sync::mpsc::channel();
let mut app = App::new(tx, UserConfig::new(), Some(SystemTime::now()));
app.current_playback_context = Some(playing_track_context(full_track("ctx", "Context")));

// Duration of full_track is 180_000 ms. Seek far past duration.
app.seek_to(300_000);

// Flushes pending API seek
app.flush_pending_api_seek();

match rx.try_recv() {
Ok(IoEvent::Seek(ms)) => assert_eq!(ms, 179_999),
Ok(IoEvent::NextTrack) => panic!("seek_to triggered NextTrack!"),
_other => panic!("expected Seek(179_999)"),
}
}
}
4 changes: 4 additions & 0 deletions src/core/i18n/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ pub struct HelpStrings {
pub lyrics_scroll: &'static str,
pub lyrics_follow: &'static str,
pub lyrics_timing: &'static str,
pub lyrics_seek: &'static str,
pub miniplayer: &'static str,
pub cover_art: &'static str,
pub back: &'static str,
Expand Down Expand Up @@ -212,6 +213,7 @@ pub const EN: HelpStrings = HelpStrings {
lyrics_scroll: "Scroll lyrics (pauses auto-follow)",
lyrics_follow: "Resume following the current lyric line",
lyrics_timing: "Nudge lyric timing earlier/later",
lyrics_seek: "Seek to selected lyric line",
miniplayer: "Toggle miniplayer view",
cover_art: "Go to cover art view",
back: "Go back or exit when nowhere left to back to",
Expand Down Expand Up @@ -381,6 +383,7 @@ pub const ZH_HANS: HelpStrings = HelpStrings {
lyrics_scroll: "滚动歌词(暂停自动跟随)",
lyrics_follow: "恢复跟随当前歌词",
lyrics_timing: "微调歌词时间:提前或延后",
lyrics_seek: "跳转到所选歌词行",
miniplayer: "切换迷你播放器",
cover_art: "打开封面界面",
back: "返回;无处可返回时退出",
Expand Down Expand Up @@ -548,6 +551,7 @@ pub const ZH_HANT: HelpStrings = HelpStrings {
lyrics_scroll: "捲動歌詞(暫停自動跟隨)",
lyrics_follow: "恢復跟隨目前歌詞",
lyrics_timing: "微調歌詞時間:提前或延後",
lyrics_seek: "跳轉到所選歌詞行",
miniplayer: "切換迷你播放器",
cover_art: "開啟封面畫面",
back: "返回;無處可返回時離開",
Expand Down
6 changes: 3 additions & 3 deletions src/core/i18n/screens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ pub const EN: ScreenStrings = ScreenStrings {
no_devices: "No devices found: Make sure a device is active",
timing_estimated: "timing estimated",
lyrics_title: " Lyrics ",
lyrics_browsing: "browsing · ↑/↓ scroll · Esc follow",
lyrics_browsing: "browsing · ↑/↓ scroll · Esc follow · Enter seek",
lyrics_loading: "Fetching lyrics…",
lyrics_from: "from LRCLIB",
lyrics_missing: "No lyrics for this track",
Expand Down Expand Up @@ -582,7 +582,7 @@ pub const ZH_HANS: ScreenStrings = ScreenStrings {
no_devices: "未找到设备:请确保有设备处于活动状态",
timing_estimated: "时间为估算值",
lyrics_title: " 歌词 ",
lyrics_browsing: "浏览中 · ↑/↓ 滚动 · Esc 跟随",
lyrics_browsing: "浏览中 · ↑/↓ 滚动 · Esc 跟随 · Enter 跳转",
lyrics_loading: "正在获取歌词…",
lyrics_from: "来自 LRCLIB",
lyrics_missing: "此歌曲暂无歌词",
Expand Down Expand Up @@ -849,7 +849,7 @@ pub const ZH_HANT: ScreenStrings = ScreenStrings {
no_devices: "找不到裝置:請確認有裝置處於作用中",
timing_estimated: "時間為估計值",
lyrics_title: " 歌詞 ",
lyrics_browsing: "瀏覽中 · ↑/↓ 捲動 · Esc 跟隨",
lyrics_browsing: "瀏覽中 · ↑/↓ 捲動 · Esc 跟隨 · Enter 跳轉",
lyrics_loading: "正在取得歌詞…",
lyrics_from: "來自 LRCLIB",
lyrics_missing: "此歌曲尚無歌詞",
Expand Down
134 changes: 134 additions & 0 deletions src/tui/handlers/lyrics_view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ pub fn handler(key: Key, app: &mut App) {
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),
k if k == app.user_config.keys.submit || k == Key::Enter => seek_to_focused(app),
Comment thread
LargeModGames marked this conversation as resolved.
Resolved
_ => {}
}
}
Expand Down Expand Up @@ -67,3 +68,136 @@ fn jump_to(app: &mut App, index: usize) {
app.view.lyrics_view.manual_index = Some(index.min(lyrics.len() - 1));
app.view.lyrics_view.last_manual_input = Some(Instant::now());
}

fn seek_to_focused(app: &mut App) {
if app.lyrics_status() != LyricsStatus::Found || !app.lyrics_synced() {
return;
}
let Some(lyrics) = app.lyrics() else {
return;
};
if lyrics.is_empty() {
return;
}
let target_index = app
.view
.lyrics_view
.manual_index
.unwrap_or_else(|| active_lyric_index(lyrics, app.lyric_progress_ms()))
.min(lyrics.len() - 1);

let target_time_ms = lyrics[target_index].0;
let seek_ms = (target_time_ms as i128 - i128::from(app.view.lyrics_view.timing_offset_ms)).max(0);
let target_u32 = u32::try_from(seek_ms).unwrap_or(u32::MAX);

app.apply(crate::core::action::Action::SeekTo(target_u32));
app.view.lyrics_view.manual_index = None;
}

#[cfg(test)]
mod tests {
use super::*;

fn playing_context() -> rspotify::model::CurrentPlaybackContext {
use rspotify::model::{
context::Actions, CurrentlyPlayingType, Device, DeviceType, PlayableItem, RepeatState,
};
rspotify::model::CurrentPlaybackContext {
device: Device {
id: Some("dev".to_string()),
is_active: true,
is_private_session: false,
is_restricted: false,
name: "Test Device".to_string(),
_type: DeviceType::Computer,
volume_percent: Some(50),
},
repeat_state: RepeatState::Off,
shuffle_state: false,
context: None,
timestamp: chrono::Utc::now(),
progress: Some(chrono::Duration::milliseconds(0)),
is_playing: true,
item: Some(PlayableItem::Track(crate::core::test_helpers::full_track(
"4uLU6hMCjMI75M1A2tKUQC",
"Test Song",
))),
currently_playing_type: CurrentlyPlayingType::Track,
actions: Actions::default(),
}
}

fn app_with_sample_lyrics() -> App {
let mut test_app = App::default();
test_app.current_playback_context = Some(playing_context());
test_app.set_lyrics(
LyricsStatus::Found,
Some(vec![
(0, "Line 0".to_string()),
(5_000, "Line 1".to_string()),
(10_000, "Line 2".to_string()),
]),
true,
);
test_app
}

#[test]
fn enter_seeks_to_browsed_lyric_and_resumes_follow() {
let mut test_app = app_with_sample_lyrics();
test_app.view.lyrics_view.timing_offset_ms = 500;
// Browse to Line 2 (at 10_000 ms)
scroll_by(&mut test_app, 2);
assert_eq!(test_app.view.lyrics_view.manual_index, Some(2));

handler(Key::Enter, &mut test_app);

// 10_000 ms browsed lyric minus 500 ms timing offset
assert_eq!(test_app.song_progress_ms, 9_500);
// Manual browsing should be cleared (auto-follow resumes)
assert_eq!(test_app.view.lyrics_view.manual_index, None);
}

#[test]
fn enter_without_browsing_seeks_to_active_lyric() {
let mut test_app = app_with_sample_lyrics();
test_app.song_progress_ms = 6_000;
assert_eq!(test_app.view.lyrics_view.manual_index, None);

handler(Key::Enter, &mut test_app);

// Active line at 6_000 ms is Line 1 (5_000 ms)
assert_eq!(test_app.song_progress_ms, 5_000);
assert_eq!(test_app.view.lyrics_view.manual_index, None);
}

#[test]
fn enter_does_nothing_when_lyrics_unsynced() {
let mut test_app = App::default();
test_app.current_playback_context = Some(playing_context());
test_app.song_progress_ms = 1_000;
test_app.set_lyrics(
LyricsStatus::Found,
Some(vec![(0, "Unsynced text".to_string())]),
false,
);
test_app.view.lyrics_view.manual_index = Some(0);

handler(Key::Enter, &mut test_app);

assert_eq!(test_app.song_progress_ms, 1_000);
assert_eq!(test_app.view.lyrics_view.manual_index, Some(0));
}

#[test]
fn enter_does_nothing_when_lyrics_not_found() {
let mut test_app = App::default();
test_app.current_playback_context = Some(playing_context());
test_app.song_progress_ms = 1_000;
test_app.set_lyrics(LyricsStatus::NotFound, None, false);

handler(Key::Enter, &mut test_app);

assert_eq!(test_app.song_progress_ms, 1_000);
}
}
Loading
Loading