11 KiB
Seek within the playing track
Context
Playback exposes track-level controls only: TogglePlay, Next, Prev,
RestartTrack. There is no way to move inside a track, which hurts most
where tracks are longest — the podcast providers (/rss, /fyyd) and
audiobooks (/abs), where "I missed that sentence" and "skip the ad" are
the two most common wishes.
The audio engine can already do it: PlayerEngine::seek_to and
PlayerEngineCommand::SeekTo exist and are wired through
Player::seek_to. Nothing calls them. There is no RPC, no playback
command, no binding — and the one implementation that exists carries a
panic (D5). So this feature is almost entirely wiring, plus a decision
about where the arithmetic lives.
Goal: a step of about 15 seconds forward and backward, from every client.
Assumptions
Stated explicitly and settled by reading the code rather than by asking:
- A1 — Within the current track only. A backward seek at 3 s lands at
0, it does not step into the previous track;
Ctrl-pis the track-level control and stays that way. Crossing tracks would need the queue lock and the previous track's duration, for a gesture nobody expects to do that. - A2 — The server owns the position. Clients render
TrackPositionfrom the update stream and never predict it, exactly as they do for volume and play state. A seek therefore needs no optimistic UI and no rollback when it is refused. - A3 — Fire-and-forget. Like every other playback RPC,
Seekreturns as soon as the command is queued. A refused seek is a server-side warning, not a client-visible error (D7). - A4 — No autoplay. Seeking with nothing loaded does nothing; it does
not start the queue.
TogglePlayandRestartTrackare the controls that resume an idle player.
Options
The only real question is where the arithmetic lives: a seek is relative ("15 seconds back"), but the engine seeks to an absolute position.
Option A — the client computes the target
Seek(position_millis). Each client takes the last TrackPosition it
received, adds ±15 s, clamps against the duration it was told, and sends
an absolute target.
- The wire is a plain absolute seek, which a click-on-the-progress-bar gesture also wants.
- But the base is stale: positions are broadcast on a 250 ms tick and
then cross the network. The step is 15 s, so 250 ms of drift is not the
problem — repetition is. Press
.three times quickly and all three presses read the same broadcast position, compute the same target, and the track advances 15 s instead of 45. That is the normal way people use a seek key. - Every client re-implements clamping, so the end-of-track and unknown-duration edges have to be right in three places (TUI, web, CLI) instead of one.
- While paused no position updates arrive at all (the tick loop skips a paused sink), so a client's base position goes stale the moment you pause, and a paused seek would be computed from wherever playback stopped rather than from where the last seek left it.
Option B — the client sends a delta (chosen)
Seek(delta_millis), signed. The engine adds it to the live sink
position, clamps, and seeks.
- Repeated presses compose exactly: each one reads the position the
previous one produced, because the engine is a single-threaded command
loop and
try_seekupdates the position before returning. - Clamping policy lives in one place, next to the duration the engine already tracks.
- Clients get simpler, not more complex: a constant and one RPC call.
- Cost: absolute seek is not on the wire. It is a compatible proto3 addition when a click-to-seek gauge wants it (deferred, below).
Decision: Option B. The composition argument decides it — Option A is wrong in the ordinary case of pressing the key twice.
Decisions
- D1 — The delta crosses the wire; the engine accumulates. Clients send a signed offset, never a target.
- D2 — One new RPC, relative only.
Seek(SeekRequest) -> SeekResponsewithsint32 delta_millis = 1. Milliseconds becauseTrackPositionalready speaks milliseconds, so a future sub-second step needs no wire change;sint32because zigzag encoding keeps negatives at one byte. Absolute seek stays deferred — adding a field later is compatible. - D3 — The step size is a client constant.
SEEK_STEP= 15 s, one named constant per client. The wire carries milliseconds, so making the step configurable later is a client-only change. - D4 — Clamping. Backward past the start lands at 0. Forward past the
end clamps to
duration - 1 swhen the duration is known, so the track runs out through the ordinary end-of-stream path and advances to the next one; the engine never has to seek to the exact end, whose behaviour differs per decoder. With an unknown duration (a length-less stream reports 0) there is no upper clamp — the decoder decides, and a refusal is just a warning. - D5 — Fix the panic in
seek_to. It currently doestime.clamp(Duration::from_secs(1), duration).Ord::clampassertsmin <= max, andduration()yields 0 whenever the duration is unknown (HLS, some network streams) — so that call panics the engine thread, taking audio down with it, on any duration-less source. It is unreachable today only because nothing callsseek_to; wiring seek makes it reachable from user input, which the hard rules forbid. Replaced by saturating arithmetic with no assertions. The 1-second floor also went: landing at 0 is exactly what a backward seek near the start means. - D6 — The engine reports the new position immediately. After a
successful seek it emits
PlayerMessage::Elapsedrather than waiting for the next 250 ms tick. This is not just about latency:tick()returns early for a paused or empty sink, so without this a seek while paused would leave every client showing the old position until playback resumed. - D7 — Unseekable sources warn and change nothing. HLS (SoundCloud)
is decoded with
seekable = falseprecisely so symphonia never seeks it, andtry_seekreportsNotSupported. The server logs a warning and broadcasts nothing; clients keep showing the true position because they never moved it (A2). Consistent with howpauseandset_volumefailures are handled — no new status codes, no new client-side error path. - D8 — Bindings:
Ctrl-bback,Ctrl-fforward, in the global scope of both the TUI and the web client (the user's choice; an earlier round used,/.). They join the existing control-chord family for playback and movement —Ctrl-n/Ctrl-pfor tracks,Ctrl-d/Ctrl-ufor paging — and read as vim's back/forward-a-screen pair. Neither chord was bound in any scope of either client, and plainf(spectrum) is untouched because the TUI'slookupcompares every modifier exceptSHIFTexactly. In the browser,Ctrl-fwould otherwise open the find bar; the web keydown handler already callsprevent_defaulton any chord that resolves to an action, so binding it is enough to claim it. The web transport bar also gets⏪/⏩buttons, and the CLI getscbd global seek <SECONDS>, which accepts negatives. - D9 — No feature flag. Seek is a few lines in the engine and one RPC
arm; it carries no dependency of its own, so by the rule in
architecture/build-features.md("a feature must pay for itself in dependencies") it does not earn one.
Structure
direction: right
clients: Clients {
tui: cbd-tui\n`,` / `.`
web: cbd-web\n`,` / `.` / ⏪⏩
cli: cbd-cli\nglobal seek N
}
server: crabidy-server {
rpc: RpcService::seek
loop: playback loop\nPlaybackCommand::Seek
fwd: poll_play_bus
}
engine: audio-player {
player: Player::seek_by
eng: PlayerEngine\nseek_by -> seek_clamped
sink: rodio Player\ntry_seek
}
clients.tui -> server.rpc: Seek{delta_millis}
clients.web -> server.rpc: Seek{delta_millis}
clients.cli -> server.rpc: Seek{delta_millis}
server.rpc -> server.loop: bounded channel
server.loop -> engine.player: seek_by(delta)
engine.player -> engine.eng: SeekBy(delta, reply)
engine.eng -> engine.sink: try_seek(clamped target)
engine.eng -> server.fwd: PlayerMessage::Elapsed
server.fwd -> server.loop: PositionChanged
server.loop -> clients: TrackPosition broadcast
The delta stays a delta all the way down to the engine; the only place an
absolute position is computed is PlayerEngine::seek_by, which is also
the only place that knows the live sink position and the track duration.
Boundaries
audio-playergainsPlayer::seek_by(delta_millis: i64)andPlayerEngineCommand::SeekBy. The clamping helper is private; bothseek_to(absolute, kept for the deferred extension) andseek_bygo through it, so there is one clamping policy, not two.crabidy-coregains theSeekRPC and its two messages.crabidy-servergainsPlaybackCommand::Seek { delta_millis }and the RPC arm. The playback loop only forwards; it holds no seek state.- Clients gain an action, a binding, an RPC wrapper, and a constant each. No client-side position arithmetic anywhere (A2, D1).
Risks
try_seekblocks the engine thread for up to ~5 ms (it waits for the audio callback to pick the order up). That thread already blocks for up to 30 s opening a network stream, so this is not a new class of stall — but it does mean seek is serialized behind an in-flight track open, which is correct anyway.- Seeking while paused relies on rodio's
periodic_accesssitting outsidepausablein the chain: a paused sink keeps being polled for silence, so the seek order is still picked up. Verified in rodio 0.22.2 (src/player.rs); if a future rodio inverts that order, a paused seek would block until unpause. Worth re-checking on a rodio bump. - Network-backed sources seek inside
stream_download's temp storage. A seek outside the downloaded window triggers a fresh range request, so a long forward seek can stall audio briefly. Bounded by the existing HTTP timeouts; no new failure mode. - A 15-second step on a very short track always lands in the clamp, which is why the clamp has to be arithmetic that cannot assert (D5).
Deferred
Recorded, not dropped:
- Absolute seek (
position_millis) and a click-to-seek progress gauge in the web client. The gauge is already rendered; only the wire field and a click handler are missing. A compatible proto3 addition. - Configurable step size in the client configs, and a larger step on
</>(same physical keys, shifted). Client-only once wanted. - Chapter-aware seek for podcasts and audiobooks. No provider exposes chapter marks through the library model today.