Added Shift+J to jump to release

This commit is contained in:
Ultradesu
2026-06-10 17:01:40 +01:00
parent 00a558570c
commit 5be633d168
5 changed files with 157 additions and 0 deletions
+2
View File
@@ -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(),
+22
View File
@@ -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) {
+7
View File
@@ -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
+122
View File
@@ -187,6 +187,14 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
}
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<Effect> {
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<TrackItem>, 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();
+4
View File
@@ -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"