Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- Seek a few seconds back or forward with the left and right arrow keys, or the buttons beside
play/pause. The jump is 5, 10 or 30 seconds, set under Settings > Playback.

## [0.31.0] - 2026-09-05

### Added
Expand Down
7 changes: 7 additions & 0 deletions assets/i18n/en-US/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,8 @@ player-mute = Mute
player-unmute = Unmute
player-previous = Previous track
player-next = Next track
player-seek-back = Seek back
player-seek-forward = Seek forward
player-fullscreen = Fullscreen
player-fullscreen-leave = Leave fullscreen
fullscreen-artwork = Artwork
Expand Down Expand Up @@ -478,6 +480,11 @@ settings-normalisation = Normalize loudness
settings-normalisation-detail = Keeps tracks at a consistent volume
settings-gapless = Gapless playback
settings-gapless-detail = Runs one track into the next without a pause, the way an album was sequenced
settings-seek-step = Seek step
settings-seek-step-detail = How far the seek buttons and arrow keys jump
seek-step-5 = 5 seconds
seek-step-10 = 10 seconds
seek-step-30 = 30 seconds
settings-panel-lyrics-size = Lyrics size (panel)
settings-panel-lyrics-size-detail = Size of the lyrics text in the side panel, on top of the base font size
settings-fullscreen-lyrics-size = Lyrics size (fullscreen)
Expand Down
7 changes: 7 additions & 0 deletions assets/i18n/it/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ player-mute = Silenzia
player-unmute = Riattiva audio
player-previous = Brano precedente
player-next = Brano successivo
player-seek-back = Torna indietro
player-seek-forward = Vai avanti
player-fullscreen = Schermo intero
player-fullscreen-leave = Esci dallo schermo intero
fullscreen-artwork = Copertina
Expand Down Expand Up @@ -403,6 +405,11 @@ settings-normalisation = Normalizza volume
settings-normalisation-detail = Mantiene le tracce a un volume uniforme
settings-gapless = Riproduzione senza pause
settings-gapless-detail = Fa scorrere un brano nell'altro senza pausa, come è stato sequenziato l'album
settings-seek-step = Passo di scorrimento
settings-seek-step-detail = Di quanto saltano i pulsanti e i tasti freccia
seek-step-5 = 5 secondi
seek-step-10 = 10 secondi
seek-step-30 = 30 secondi
settings-karaoke-lyrics = Testo karaoke
settings-karaoke-lyrics-detail = Evidenzia il testo parola per parola quando la sincronizzazione è disponibile
settings-romanized-lyrics = Testo romanizzato
Expand Down
7 changes: 7 additions & 0 deletions assets/i18n/pl/main.ftl
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,8 @@ player-mute = Wycisz
player-unmute = Wyłącz wyciszenie
player-previous = Poprzedni utwór
player-next = Następny utwór
player-seek-back = Przewiń wstecz
player-seek-forward = Przewiń do przodu
player-fullscreen = Pełny ekran
player-fullscreen-leave = Wyjdź z trybu pełnoekranowego
fullscreen-artwork = Okładka
Expand Down Expand Up @@ -504,6 +506,11 @@ settings-normalisation = Normalizacja głośności
settings-normalisation-detail = Utrzymuje stałą głośność utworów
settings-gapless = Odtwarzanie bez przerw
settings-gapless-detail = Przechodzi z utworu do następnego bez pauzy, zgodnie z układem albumu
settings-seek-step = Skok przewijania
settings-seek-step-detail = O ile przeskakują przyciski i klawisze strzałek
seek-step-5 = 5 sekund
seek-step-10 = 10 sekund
seek-step-30 = 30 sekund
settings-panel-lyrics-size = Rozmiar tekstu utworu (panel)
settings-panel-lyrics-size-detail = Rozmiar wierszy tekstu utworu w panelu bocznym ponad bazowy rozmiar czcionki
settings-fullscreen-lyrics-size = Rozmiar tekstu utworu (pełny ekran)
Expand Down
1 change: 1 addition & 0 deletions assets/icons/iconoir/rotate-ccw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/iconoir/rotate-cw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions assets/icons/lucide/rotate-ccw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
14 changes: 14 additions & 0 deletions assets/icons/lucide/rotate-cw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/remix/rotate-ccw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/remix/rotate-cw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/solar/rotate-ccw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions assets/icons/solar/rotate-cw.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions crates/input/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ actions!(
TogglePlayback,
SongPrevious,
SongNext,
SeekBack,
SeekForward,
NavigateBack,
NavigateForward,
OpenFilter,
Expand Down Expand Up @@ -56,6 +58,8 @@ pub fn bindings() -> Vec<KeyBinding> {
KeyBinding::new("enter", Activate, Some(&browsing)),
KeyBinding::new("delete", Remove, Some(&browsing)),
KeyBinding::new("escape", Deselect, Some(&browsing)),
KeyBinding::new("left", SeekBack, None),
KeyBinding::new("right", SeekForward, None),
KeyBinding::new("down", SelectNext, search),
KeyBinding::new("up", SelectPrevious, search),
KeyBinding::new("left", SelectLeft, Some(&results)),
Expand Down
14 changes: 13 additions & 1 deletion crates/sonora/src/actions.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
use gpui::{App, Menu, MenuItem};
use i18n::t;
use input::{Quit, RefreshLibrary, SignOut, SongNext, SongPrevious, TogglePlayback};
use input::{
Quit, RefreshLibrary, SeekBack, SeekForward, SignOut, SongNext, SongPrevious, TogglePlayback,
};
use router::Destination;
use state::Sonora;

Expand Down Expand Up @@ -54,6 +56,16 @@ pub fn register(lingers: bool, cx: &mut App) {
playback.update(cx, |playback, cx| playback.next(cx));
});

cx.on_action(|_: &SeekBack, cx: &mut App| {
let playback = Sonora::global(cx).playback.clone();
playback.update(cx, |playback, cx| playback.seek_back(cx));
});

cx.on_action(|_: &SeekForward, cx: &mut App| {
let playback = Sonora::global(cx).playback.clone();
playback.update(cx, |playback, cx| playback.seek_forward(cx));
});

cx.set_menus(vec![Menu {
name: "Sonora".into(),
disabled: false,
Expand Down
2 changes: 1 addition & 1 deletion crates/state/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub use history::{History, HistoryState};
pub use home::Home;
pub use library::{Library, LibraryEvent, LibraryPart, LibraryState, Problem};
pub use lyrics::{Lyrics, LyricsState};
pub use playback::{Origin, Playback, PlaybackState, Repeat, Whence};
pub use playback::{Origin, Playback, PlaybackState, Repeat, SeekStep, Whence};
pub use profile::Profile;
pub use queue::{Named, Queue, Resume, Stub};
pub use remote::{Remote, attach as attach_remote};
Expand Down
85 changes: 85 additions & 0 deletions crates/state/src/playback.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const SKIP_DEBOUNCE: Duration = Duration::from_millis(250);
const RESTART_WINDOW: Duration = Duration::from_secs(3);
const KEY_COOLDOWN: Duration = Duration::from_secs(6);
const RESUME_STEP: Duration = Duration::from_secs(5);
const SEEK_CATCHUP: Duration = Duration::from_millis(400);
const SEEK_HOLD: Duration = Duration::from_secs(2);
const TAPER_DB: f32 = 50.;
const LOCAL_FAVORITES: &str = "favorites";
const SIMILAR_LIMIT: usize = 20;
Expand Down Expand Up @@ -120,6 +122,13 @@ impl LiveClock {
}
}

fn away(left: Duration, right: Duration) -> Duration {
match left >= right {
true => left - right,
false => right - left,
}
}

fn signed_gap(to: Duration, from: Duration) -> f64 {
match to >= from {
true => (to - from).as_secs_f64(),
Expand Down Expand Up @@ -164,6 +173,46 @@ pub enum Repeat {
One,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum SeekStep {
Five,
#[default]
Ten,
Thirty,
}

impl SeekStep {
pub const ALL: [Self; 3] = [Self::Five, Self::Ten, Self::Thirty];

pub fn id(self) -> &'static str {
match self {
Self::Five => "5",
Self::Ten => "10",
Self::Thirty => "30",
}
}

pub fn secs(self) -> u16 {
match self {
Self::Five => 5,
Self::Ten => 10,
Self::Thirty => 30,
}
}

pub fn duration(self) -> Duration {
Duration::from_secs(u64::from(self.secs()))
}

pub fn from_secs(secs: u16) -> Self {
match secs {
5 => Self::Five,
30 => Self::Thirty,
_ => Self::Ten,
}
}
}

#[derive(Clone, Copy, PartialEq, Eq, Debug, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Whence {
Expand Down Expand Up @@ -276,6 +325,7 @@ pub struct Playback {
refused: Option<Refusal>,
resume_at: Option<Duration>,
seek_on_play: Option<Duration>,
sought: Option<(Duration, Instant)>,
resume_ready: bool,
awaiting_reconnect: bool,
stored: Duration,
Expand Down Expand Up @@ -352,6 +402,7 @@ impl Playback {
refused: None,
resume_at: None,
seek_on_play: None,
sought: None,
resume_ready: false,
awaiting_reconnect: false,
stored: Duration::ZERO,
Expand Down Expand Up @@ -1217,6 +1268,7 @@ impl Playback {
}
self.position = position;
self.clock.reset(position, false);
self.sought = Some((position, Instant::now()));
self.remember(true, cx);
cx.notify();
return;
Expand All @@ -1226,6 +1278,7 @@ impl Playback {
self.position = position;
self.clock
.reset(position, self.state == PlaybackState::Playing);
self.sought = Some((position, Instant::now()));
cx.notify();
}
}
Expand All @@ -1244,6 +1297,35 @@ impl Playback {
self.seek(position, cx);
}

pub fn seek_by(&mut self, step: Duration, forward: bool, cx: &mut Context<Self>) {
let Some(end) = self.track.as_ref().map(|track| track.duration) else {
return;
};
let at = self.live_position();
let target = match forward {
true => at.saturating_add(step).min(end),
false => at.saturating_sub(step),
};
self.seek(target, cx);
}

pub fn seek_back(&mut self, cx: &mut Context<Self>) {
let step = self.settings.read(cx).seek_step().duration();
self.seek_by(step, false, cx);
}

pub fn seek_forward(&mut self, cx: &mut Context<Self>) {
let step = self.settings.read(cx).seek_step().duration();
self.seek_by(step, true, cx);
}

fn holding_seek(&self, reported: Duration) -> bool {
let Some((target, since)) = self.sought else {
return false;
};
since.elapsed() < SEEK_HOLD && away(reported, target) > SEEK_CATCHUP
}

pub fn state(&self) -> &PlaybackState {
&self.state
}
Expand Down Expand Up @@ -1504,7 +1586,9 @@ impl Playback {
self.clock.reset(position, false);
self.remember(true, cx);
}
BackendEvent::Position(position) if self.holding_seek(position) => {}
BackendEvent::Position(position) => {
self.sought = None;
self.position = position;
match self.state == PlaybackState::Playing {
true => self.clock.correct(position),
Expand Down Expand Up @@ -1581,6 +1665,7 @@ impl Playback {
self.clock.reset(Duration::ZERO, false);
self.resume_at = None;
self.seek_on_play = None;
self.sought = None;
self.resume_ready = false;
self.awaiting_reconnect = false;
self.stored = Duration::ZERO;
Expand Down
29 changes: 8 additions & 21 deletions crates/state/src/remote.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ use crate::{Playback, PlaybackState, Sonora};

const BUS_NAME: &str = "sonora";
const DISPLAY_NAME: &str = "Sonora";
const SEEK_STEP: Duration = Duration::from_secs(5);

struct Attached {
_remote: Entity<Remote>,
Expand Down Expand Up @@ -94,8 +93,14 @@ impl Remote {
MediaControlEvent::Next => playback.next(cx),
MediaControlEvent::Previous => playback.previous(cx),
MediaControlEvent::SetPosition(MediaPosition(at)) => playback.seek(at, cx),
MediaControlEvent::Seek(direction) => shift(playback, direction, SEEK_STEP, cx),
MediaControlEvent::SeekBy(direction, step) => shift(playback, direction, step, cx),
MediaControlEvent::Seek(SeekDirection::Forward) => playback.seek_forward(cx),
MediaControlEvent::Seek(SeekDirection::Backward) => playback.seek_back(cx),
MediaControlEvent::SeekBy(SeekDirection::Forward, step) => {
playback.seek_by(step, true, cx)
}
MediaControlEvent::SeekBy(SeekDirection::Backward, step) => {
playback.seek_by(step, false, cx)
}
MediaControlEvent::SetVolume(level) => playback.set_volume(level as f32, cx),
MediaControlEvent::OpenUri(_)
| MediaControlEvent::Raise
Expand Down Expand Up @@ -144,21 +149,3 @@ impl Remote {
}
}
}

fn shift(
playback: &mut Playback,
direction: SeekDirection,
step: Duration,
cx: &mut Context<Playback>,
) {
let at = playback.position();
let target = match direction {
SeekDirection::Forward => at.saturating_add(step),
SeekDirection::Backward => at.saturating_sub(step),
};
let end = playback
.track()
.map(|track| track.duration)
.unwrap_or(target);
playback.seek(target.min(end), cx);
}
Loading