Added Shift+J to jump to release
This commit is contained in:
@@ -31,6 +31,7 @@ pub enum Action {
|
|||||||
QueueAddNext,
|
QueueAddNext,
|
||||||
QueueAddLast,
|
QueueAddLast,
|
||||||
ClearQueue,
|
ClearQueue,
|
||||||
|
GoToRelease,
|
||||||
ToggleHelp,
|
ToggleHelp,
|
||||||
ToggleViewMode,
|
ToggleViewMode,
|
||||||
OpenCommandLine,
|
OpenCommandLine,
|
||||||
@@ -67,6 +68,7 @@ impl Action {
|
|||||||
Action::QueueAddNext => "Queue: add next".into(),
|
Action::QueueAddNext => "Queue: add next".into(),
|
||||||
Action::QueueAddLast => "Queue: add to end".into(),
|
Action::QueueAddLast => "Queue: add to end".into(),
|
||||||
Action::ClearQueue => "Queue: clear".into(),
|
Action::ClearQueue => "Queue: clear".into(),
|
||||||
|
Action::GoToRelease => "Open the track's release".into(),
|
||||||
Action::ToggleHelp => "Show / hide keybindings".into(),
|
Action::ToggleHelp => "Show / hide keybindings".into(),
|
||||||
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
Action::ToggleViewMode => "Toggle tiles / table view".into(),
|
||||||
Action::OpenCommandLine => "Open command line (:/name searches)".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.playlists = state::PlaylistsTab::default();
|
||||||
state.playlist_views.clear();
|
state.playlist_views.clear();
|
||||||
state.queue_tab = state::QueueTab::default();
|
state.queue_tab = state::QueueTab::default();
|
||||||
|
state.pending_release_focus = None;
|
||||||
|
state.jump_origin = None;
|
||||||
state.likes.clear();
|
state.likes.clear();
|
||||||
state.likes_loaded = false;
|
state.likes_loaded = false;
|
||||||
state.search = state::SearchState::default();
|
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);
|
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 } => {
|
AppEvent::SearchLoaded { seq, result } => {
|
||||||
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
if seq != runtime.search_seq.load(std::sync::atomic::Ordering::SeqCst) {
|
||||||
|
|||||||
@@ -446,6 +446,13 @@ pub struct AppState {
|
|||||||
pub likes_loaded: bool,
|
pub likes_loaded: bool,
|
||||||
pub logs: LogsTab,
|
pub logs: LogsTab,
|
||||||
pub queue_tab: QueueTab,
|
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 cmdline: Cmdline,
|
||||||
pub search: SearchState,
|
pub search: SearchState,
|
||||||
/// Shared image cache keyed by `art::cache_key(url, w, h)`; reused by
|
/// 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::QueueAddNext => queue_add(state, true),
|
||||||
Action::QueueAddLast => queue_add(state, false),
|
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 => {
|
Action::ClearQueue => {
|
||||||
let had_tracks = !state.player.queue.is_empty();
|
let had_tracks = !state.player.queue.is_empty();
|
||||||
state.player.queue.clear();
|
state.player.queue.clear();
|
||||||
@@ -288,6 +296,40 @@ fn queue_add(state: &mut AppState, next: bool) -> Option<Effect> {
|
|||||||
None
|
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
|
/// Insert tracks after the playing one (`next`) or at the end. Keeps the
|
||||||
/// gapless prefetch index pointing at the same track if items shift.
|
/// gapless prefetch index pointing at the same track if items shift.
|
||||||
pub fn enqueue_tracks(state: &mut AppState, tracks: Vec<TrackItem>, next: bool) {
|
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;
|
state.playlists.opened = None;
|
||||||
}
|
}
|
||||||
Tab::Global => {
|
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 let Some(popped) = state.global.stack.pop() {
|
||||||
if matches!(popped, GlobalView::Search { .. }) {
|
if matches!(popped, GlobalView::Search { .. }) {
|
||||||
state.search = SearchState::default();
|
state.search = SearchState::default();
|
||||||
@@ -873,6 +925,8 @@ fn go_back(state: &mut AppState) {
|
|||||||
fn switch_tab(state: &mut AppState, tab: Tab) {
|
fn switch_tab(state: &mut AppState, tab: Tab) {
|
||||||
state.active_tab = tab;
|
state.active_tab = tab;
|
||||||
state.help_visible = false;
|
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) {
|
fn reset_tab(state: &mut AppState, tab: Tab) {
|
||||||
@@ -1261,6 +1315,74 @@ mod tests {
|
|||||||
assert!(state.player.original_order.is_none());
|
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]
|
#[test]
|
||||||
fn view_toggle() {
|
fn view_toggle() {
|
||||||
let mut state = AppState::default();
|
let mut state = AppState::default();
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ key_sequence = "shift-c"
|
|||||||
command = "ClearQueue"
|
command = "ClearQueue"
|
||||||
context = "queue"
|
context = "queue"
|
||||||
|
|
||||||
|
[[keymaps]]
|
||||||
|
key_sequence = "shift-j"
|
||||||
|
command = "GoToRelease"
|
||||||
|
|
||||||
[[keymaps]]
|
[[keymaps]]
|
||||||
key_sequence = "j"
|
key_sequence = "j"
|
||||||
command = "MoveDown"
|
command = "MoveDown"
|
||||||
|
|||||||
Reference in New Issue
Block a user