Skip to content

Commit d7e3cb6

Browse files
program247365claude
andcommitted
feat(playback): seek 5s with arrow keys for local and cached tracks
Add Left/Right seek for file-backed sources (local files and cached downloads). seek_to reopens and re-decodes the input, skips to the target position, and clears the sample tap buffer; stream-first sources return false and ignore the keys. Seek target is clamped to track bounds and syncs elapsed time plus media-session playback state. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 838afd2 commit d7e3cb6

5 files changed

Lines changed: 169 additions & 1 deletion

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,12 +222,16 @@ Or just don't set it. Looper falls back to local-only without complaint.
222222
|-----|--------|
223223
| `Enter` | Replay the selected track from the default history browser |
224224
| `Space` | Pause / Resume |
225+
| `Left` / `Right` | Seek backward / forward 5 seconds |
225226
| `f` | Toggle fullscreen visualizer |
226227
| `s` | Toggle favorite for the currently playing track |
227228
| `p` | Toggle the played-songs panel |
228229
| `Cmd-P` | Attempt to toggle the played-songs panel when the terminal forwards the modifier |
229230
| `q` / `Ctrl-C` | Quit |
230231

232+
Seeking is available for local files and cached downloads. Live or stream-first
233+
sources ignore the arrow keys.
234+
231235
### Played-Songs Panel
232236

233237
Bare `looper` opens directly into playlist history. During playback, the played-songs panel is hidden by default and opens over the minimal UI.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Arrow-Key Seek Design
2+
3+
## Goal
4+
5+
During playback, pressing the right arrow seeks five seconds forward in the
6+
current track. Pressing the left arrow seeks five seconds backward.
7+
8+
## Behavior
9+
10+
- Arrow keys work on the normal playback screen.
11+
- The played-songs panel and default history browser keep their existing key
12+
behavior.
13+
- Playlist navigation remains on `n` and `b`; arrow keys seek within the
14+
current playlist track.
15+
- Seeking clamps at the start of the track and, when duration is known, the end
16+
of the track.
17+
- Seeking applies to local files and cached downloads. Live or stream-first
18+
sources without a local seekable file ignore the arrow keys.
19+
20+
## Implementation
21+
22+
Add seek-forward and seek-back commands to playback input handling, implement a
23+
small seek primitive in the audio player, and keep `AppState` elapsed-time
24+
tracking in sync with the new playback position.
25+
26+
## Verification
27+
28+
Add focused unit coverage for arrow-key command mapping and seek clamping.
29+
Run the Rust test suite after implementation.

src/audio.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,28 @@ impl AudioPlayer {
152152
self.sink.stop();
153153
}
154154

155+
pub fn seek_to(&self, position: Duration) -> Result<bool> {
156+
if !matches!(self.input, PlaybackInput::File(_)) {
157+
return Ok(false);
158+
}
159+
160+
let (reader, _, _) = open_input(&self.input)?;
161+
let source = decode_input(reader, &self.input)?
162+
.convert_samples::<f32>()
163+
.skip_duration(position);
164+
let tapped = SampleTap {
165+
inner: source,
166+
buf: self.sample_buf.clone(),
167+
};
168+
169+
self.sink.stop();
170+
self.sink.append(tapped);
171+
if let Ok(mut buf) = self.sample_buf.lock() {
172+
buf.clear();
173+
}
174+
Ok(true)
175+
}
176+
155177
/// If this player loops a file-backed source and the sink has drained,
156178
/// reopen the file, decode a fresh source, and queue it. Returns true
157179
/// when a refill happened so the caller can advance its loop counter.

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ PLAYBACK BEHAVIOR:
6060
TUI CONTROLS:
6161
q / Ctrl-C Quit
6262
Space Pause / resume
63+
Left / Right Seek backward / forward 5 seconds (local/cached)
6364
n Next track (playlist mode)
6465
b Previous track (playlist mode)
6566
f Toggle fullscreen visualizer

src/play_loop.rs

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ use crate::tui::{
3131
HistoryPanelState, StartupProgressState, StartupScreenState, N_BANDS,
3232
};
3333

34+
const SEEK_STEP: Duration = Duration::from_secs(5);
35+
3436
pub struct PlaybackContext<'a> {
3537
pub cmd_rx: &'a Receiver<KeyCommand>,
3638
pub media: Option<MediaSessionHandle>,
@@ -158,6 +160,8 @@ fn browse_history_session(
158160
| KeyCommand::TogglePause
159161
| KeyCommand::NextTrack
160162
| KeyCommand::PreviousTrack
163+
| KeyCommand::SeekForward
164+
| KeyCommand::SeekBackward
161165
| KeyCommand::ToggleFullscreen
162166
| KeyCommand::ToggleFavorite
163167
| KeyCommand::ToggleHistory => {}
@@ -348,7 +352,8 @@ fn run_loop(
348352
ctx: &PlaybackContext,
349353
) -> Result<LoopAction> {
350354
// Frame rate control: only render when needed
351-
const RENDER_FPS: u64 = 30; // Target 30 FPS while playing
355+
// Target 30 FPS while playing.
356+
const RENDER_FPS: u64 = 30;
352357
// The paused screensaver's slowest sinusoid cycles every ~13s, so a few
353358
// frames per second is indistinguishable to the eye and keeps idle CPU low.
354359
const RENDER_FPS_PAUSED: u64 = 4;
@@ -486,6 +491,16 @@ fn dispatch_command(
486491
Ok(None)
487492
}
488493
}
494+
KeyCommand::SeekForward => {
495+
seek_track(state, player, SeekDirection::Forward, media)?;
496+
*needs_render = true;
497+
Ok(None)
498+
}
499+
KeyCommand::SeekBackward => {
500+
seek_track(state, player, SeekDirection::Backward, media)?;
501+
*needs_render = true;
502+
Ok(None)
503+
}
489504
KeyCommand::ToggleFullscreen => {
490505
state.fullscreen = !state.fullscreen;
491506
*needs_render = true;
@@ -576,13 +591,63 @@ fn dispatch_command(
576591
}
577592
}
578593

594+
#[derive(Clone, Copy)]
595+
enum SeekDirection {
596+
Backward,
597+
Forward,
598+
}
599+
600+
fn seek_track(
601+
state: &mut AppState,
602+
player: &AudioPlayer,
603+
direction: SeekDirection,
604+
media: Option<&MediaSessionHandle>,
605+
) -> Result<()> {
606+
let target = seek_target(state.elapsed(), direction, state.duration);
607+
if !player.seek_to(target)? {
608+
return Ok(());
609+
}
610+
611+
set_elapsed(state, target);
612+
if let Some(media) = media {
613+
media.set_playback(state.paused, state.elapsed());
614+
}
615+
Ok(())
616+
}
617+
618+
fn seek_target(
619+
current: Duration,
620+
direction: SeekDirection,
621+
duration: Option<Duration>,
622+
) -> Duration {
623+
let target = match direction {
624+
SeekDirection::Backward => current.saturating_sub(SEEK_STEP),
625+
SeekDirection::Forward => current + SEEK_STEP,
626+
};
627+
628+
match duration {
629+
Some(duration) => target.min(duration),
630+
None => target,
631+
}
632+
}
633+
634+
fn set_elapsed(state: &mut AppState, elapsed: Duration) {
635+
if state.paused {
636+
state.pause_elapsed = elapsed;
637+
} else {
638+
state.loop_start = Instant::now() - elapsed;
639+
}
640+
}
641+
579642
#[derive(Debug, Eq, PartialEq)]
580643
pub(crate) enum KeyCommand {
581644
None,
582645
Quit,
583646
TogglePause,
584647
NextTrack,
585648
PreviousTrack,
649+
SeekForward,
650+
SeekBackward,
586651
ToggleFullscreen,
587652
ToggleFavorite,
588653
ToggleHistory,
@@ -619,6 +684,8 @@ fn handle_key_event(key: KeyEvent, state: &AppState) -> KeyCommand {
619684
(KeyCode::Char(' '), _) => KeyCommand::TogglePause,
620685
(KeyCode::Char('n'), _) => KeyCommand::NextTrack,
621686
(KeyCode::Char('b'), _) => KeyCommand::PreviousTrack,
687+
(KeyCode::Right, _) => KeyCommand::SeekForward,
688+
(KeyCode::Left, _) => KeyCommand::SeekBackward,
622689
(KeyCode::Char('f'), _) => KeyCommand::ToggleFullscreen,
623690
(KeyCode::Char('s'), _) => KeyCommand::ToggleFavorite,
624691
(KeyCode::Char('p'), modifiers) if modifiers.contains(KeyModifiers::SUPER) => {
@@ -1429,6 +1496,43 @@ mod tests {
14291496
);
14301497
}
14311498

1499+
#[test]
1500+
fn arrow_keys_seek_during_playback() {
1501+
let state = base_state();
1502+
assert_eq!(
1503+
handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &state),
1504+
KeyCommand::SeekForward
1505+
);
1506+
assert_eq!(
1507+
handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &state),
1508+
KeyCommand::SeekBackward
1509+
);
1510+
}
1511+
1512+
#[test]
1513+
fn seek_target_clamps_to_track_bounds() {
1514+
assert_eq!(
1515+
seek_target(
1516+
Duration::from_secs(2),
1517+
SeekDirection::Backward,
1518+
Some(Duration::from_secs(30))
1519+
),
1520+
Duration::from_secs(0)
1521+
);
1522+
assert_eq!(
1523+
seek_target(
1524+
Duration::from_secs(28),
1525+
SeekDirection::Forward,
1526+
Some(Duration::from_secs(30))
1527+
),
1528+
Duration::from_secs(30)
1529+
);
1530+
assert_eq!(
1531+
seek_target(Duration::from_secs(28), SeekDirection::Forward, None),
1532+
Duration::from_secs(33)
1533+
);
1534+
}
1535+
14321536
#[test]
14331537
fn history_uses_vim_keys() {
14341538
let mut state = base_state();
@@ -1478,6 +1582,14 @@ mod tests {
14781582
),
14791583
KeyCommand::HistorySortNext
14801584
);
1585+
assert_eq!(
1586+
handle_key_event(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE), &state),
1587+
KeyCommand::None
1588+
);
1589+
assert_eq!(
1590+
handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE), &state),
1591+
KeyCommand::None
1592+
);
14811593
}
14821594

14831595
#[test]

0 commit comments

Comments
 (0)