diff --git a/src/app/action.rs b/src/app/action.rs index 1aa5c1a..9610c84 100644 --- a/src/app/action.rs +++ b/src/app/action.rs @@ -31,6 +31,7 @@ pub enum Action { QueueAddNext, QueueAddLast, ClearQueue, + GoToRelease, ToggleHelp, ToggleViewMode, OpenCommandLine, @@ -67,6 +68,7 @@ impl Action { Action::QueueAddNext => "Queue: add next".into(), Action::QueueAddLast => "Queue: add to end".into(), Action::ClearQueue => "Queue: clear".into(), + Action::GoToRelease => "Open the track's release".into(), Action::ToggleHelp => "Show / hide keybindings".into(), Action::ToggleViewMode => "Toggle tiles / table view".into(), Action::OpenCommandLine => "Open command line (:/name searches)".into(), diff --git a/src/app/mod.rs b/src/app/mod.rs index c46ca18..9e308fe 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -687,6 +687,8 @@ fn reset_library_state(state: &mut AppState) { state.playlists = state::PlaylistsTab::default(); state.playlist_views.clear(); state.queue_tab = state::QueueTab::default(); + state.pending_release_focus = None; + state.jump_origin = None; state.likes.clear(); state.likes_loaded = false; state.search = state::SearchState::default(); @@ -769,6 +771,26 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent } }; state.release_views.insert(id, entry); + // A Shift-J jump was waiting for this release: focus its track. + if let Some((release_id, track_id)) = state.pending_release_focus { + if release_id == id { + state.pending_release_focus = None; + if let Some(state::Loadable::Ready(detail)) = state.release_views.get(&id) { + let position = detail + .tracks + .iter() + .position(|t| t.id == track_id) + .unwrap_or(0); + if let Some(state::GlobalView::Release { id: top, cursor }) = + state.global.stack.last_mut() + { + if *top == release_id { + *cursor = position; + } + } + } + } + } } AppEvent::SearchLoaded { seq, result } => { if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) { diff --git a/src/app/state.rs b/src/app/state.rs index 213e628..32c462f 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -446,6 +446,13 @@ pub struct AppState { pub likes_loaded: bool, pub logs: LogsTab, pub queue_tab: QueueTab, + /// Shift-J jump in flight: focus this (release, track) once the release + /// view finishes loading. + pub pending_release_focus: Option<(i64, i64)>, + /// Where a Shift-J jump came from (tab, stack depth of the pushed + /// view): Esc from that view returns to the origin tab instead of + /// unwinding the Global stack. + pub jump_origin: Option<(Tab, usize)>, pub cmdline: Cmdline, pub search: SearchState, /// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by diff --git a/src/app/update.rs b/src/app/update.rs index f11386b..05c65c0 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -187,6 +187,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option { } Action::QueueAddNext => queue_add(state, true), Action::QueueAddLast => queue_add(state, false), + Action::GoToRelease => { + let track = selected_track(state).or_else(|| state.player.current.clone()); + match track { + Some(track) => open_release_for_track(state, &track), + None => state.status_message = Some("no track selected".into()), + } + None + } Action::ClearQueue => { let had_tracks = !state.player.queue.is_empty(); state.player.queue.clear(); @@ -288,6 +296,40 @@ fn queue_add(state: &mut AppState, next: bool) -> Option { None } +/// Shift-J: open the release the track belongs to, with the cursor on that +/// track. If the release view is still loading, the focus is applied when +/// it arrives (`pending_release_focus`). +fn open_release_for_track(state: &mut AppState, track: &TrackItem) { + let release_id = track.release_id; + let cursor = match state.release_views.get(&release_id) { + Some(Loadable::Ready(detail)) => detail + .tracks + .iter() + .position(|t| t.id == track.id) + .unwrap_or(0), + _ => { + state.pending_release_focus = Some((release_id, track.id)); + 0 + } + }; + let origin = state.active_tab; + state.active_tab = Tab::Global; + match state.global.stack.last_mut() { + Some(GlobalView::Release { id, cursor: current }) if *id == release_id => { + *current = cursor; + } + _ => state.global.stack.push(GlobalView::Release { + id: release_id, + cursor, + }), + } + // Jumps from another tab return there on Esc; jumps within Global + // unwind the navigation stack as usual. + if origin != Tab::Global { + state.jump_origin = Some((origin, state.global.stack.len() - 1)); + } +} + /// Insert tracks after the playing one (`next`) or at the end. Keeps the /// gapless prefetch index pointing at the same track if items shift. pub fn enqueue_tracks(state: &mut AppState, tracks: Vec, next: bool) { @@ -860,6 +902,16 @@ fn go_back(state: &mut AppState) { state.playlists.opened = None; } Tab::Global => { + // Esc on a view opened by Shift-J from another tab goes back to + // that tab, not down the Global stack. + if let Some((origin, depth)) = state.jump_origin { + if state.global.stack.len() == depth + 1 { + state.global.stack.pop(); + state.jump_origin = None; + state.active_tab = origin; + return; + } + } if let Some(popped) = state.global.stack.pop() { if matches!(popped, GlobalView::Search { .. }) { state.search = SearchState::default(); @@ -873,6 +925,8 @@ fn go_back(state: &mut AppState) { fn switch_tab(state: &mut AppState, tab: Tab) { state.active_tab = tab; state.help_visible = false; + // Manually leaving a view cancels any pending Shift-J return path. + state.jump_origin = None; } fn reset_tab(state: &mut AppState, tab: Tab) { @@ -1261,6 +1315,74 @@ mod tests { assert!(state.player.original_order.is_none()); } + #[test] + fn shift_j_opens_release_from_queue() { + use crate::api::models::{ReleaseDetail, TrackItem}; + let track = |id: i64, release_id: i64| TrackItem { + id, + title: format!("t{id}"), + track_number: None, + duration_seconds: 1.0, + artists: vec![], + featured_artists: vec![], + release_id, + release_title: "r".into(), + release_year: None, + cover_url: None, + stream_url: format!("/s/{id}"), + audio_format: None, + audio_bitrate: None, + audio_sample_rate: None, + file_size_bytes: None, + lastfm_playcount: None, + }; + let mut state = AppState { + active_tab: Tab::Queue, + ..AppState::default() + }; + state.player.queue = vec![track(1, 7), track(2, 7)]; + state.queue_tab.cursor = 1; + + // Release not loaded yet → jump queued as pending focus. + update(&mut state, Action::GoToRelease); + assert_eq!(state.active_tab, Tab::Global); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Release { id: 7, cursor: 0 }) + ); + assert_eq!(state.pending_release_focus, Some((7, 2))); + + // Esc returns to the origin tab, not to the Global grid. + update(&mut state, Action::Back); + assert_eq!(state.active_tab, Tab::Queue); + assert!(state.global.stack.is_empty()); + assert!(state.jump_origin.is_none()); + + // With the release cached, the cursor lands on the track directly. + state.global.stack.clear(); + state.pending_release_focus = None; + state.release_views.insert( + 7, + Loadable::Ready(ReleaseDetail { + id: 7, + title: "r".into(), + release_type: "album".into(), + year: None, + cover_url: None, + artists: vec![], + tracks: vec![track(1, 7), track(2, 7)], + uploaders: vec![], + }), + ); + state.active_tab = Tab::Queue; + update(&mut state, Action::GoToRelease); + assert_eq!( + state.global.stack.last(), + Some(&GlobalView::Release { id: 7, cursor: 1 }) + ); + assert!(state.pending_release_focus.is_none()); + } + #[test] fn view_toggle() { let mut state = AppState::default(); diff --git a/src/config/default_keymap.toml b/src/config/default_keymap.toml index 6a6cf5a..235f7a5 100644 --- a/src/config/default_keymap.toml +++ b/src/config/default_keymap.toml @@ -60,6 +60,10 @@ key_sequence = "shift-c" command = "ClearQueue" context = "queue" +[[keymaps]] +key_sequence = "shift-j" +command = "GoToRelease" + [[keymaps]] key_sequence = "j" command = "MoveDown"