Added Shift+J to jump to release
This commit is contained in:
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user