Compare commits
No commits in common. "1e8afd5f41c22a84e7c0eb8cf1a7b18a0db87c70" and "378066470ea0c0990ee4b3185b89b7085695fbd1" have entirely different histories.
1e8afd5f41
...
378066470e
|
|
@ -1,249 +0,0 @@
|
|||
# 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-p` are the
|
||||
track-level controls and stay 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 `TrackPosition`
|
||||
from 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, `Seek` returns
|
||||
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. `TogglePlay` and `RestartTrack` are 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
|
||||
maps onto directly. (That turned out not to need it either — see D10.)
|
||||
- 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_seek` updates 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. A compatible proto3 addition if
|
||||
something ever needs it; click-to-seek did not (D10).
|
||||
|
||||
**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) -> SeekResponse`
|
||||
with `sint32 delta_millis = 1`. Milliseconds because `TrackPosition`
|
||||
already speaks milliseconds, so a future sub-second step needs no wire
|
||||
change; `sint32` because 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 s` when 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 does
|
||||
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
|
||||
`min <= max`, and `duration()` 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 calls `seek_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::Elapsed` rather 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 = false` precisely so symphonia never seeks
|
||||
it, and `try_seek` reports `NotSupported`. The server logs a warning
|
||||
and broadcasts nothing; clients keep showing the true position because
|
||||
they never moved it (A2). Consistent with how `pause` and
|
||||
`set_volume` failures are handled — no new status codes, no new
|
||||
client-side error path.
|
||||
- **D8 — Bindings: two physical keys carry all four moves.** `,`/`.` seek
|
||||
15 seconds back/forward and their shifted forms `<`/`>` skip a whole
|
||||
track, in the **global** scope of both clients. The pair is
|
||||
self-teaching (same key, shift = bigger jump), `<`/`>` are the marks
|
||||
engraved on those keys, and they match mpv's playlist controls. Settled
|
||||
after two rounds: `,`/`.`, then `Ctrl-b`/`Ctrl-f` at the user's request,
|
||||
then back once `<`/`>` earned their place for a separate reason (below).
|
||||
|
||||
The deciding property is that these are **plain printable characters**,
|
||||
so they collide with nothing a browser reserves. `Ctrl-n` — the
|
||||
long-standing "next track" — cannot be claimed in a browser at all:
|
||||
Chrome and Firefox handle it as "new window" above the page, where
|
||||
`preventDefault` cannot reach, unlike `Ctrl-f`, `Ctrl-p` or `Ctrl-b`.
|
||||
Reaching for the reserved-chord list is a trap the alphabetic keys
|
||||
avoid entirely. `Ctrl-n`/`Ctrl-p` stay bound as the terminal's primary
|
||||
chords (and `Ctrl-p` works in the browser too); the web help documents
|
||||
`<`/`>`, the ones that always work.
|
||||
|
||||
- **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.
|
||||
|
||||
- **D10 — The web progress bar is clickable, still as an offset.** A
|
||||
click maps the pointer's x within the gauge to a fraction of the
|
||||
duration and sends `target - position`. Relative is *right* here even
|
||||
though the gesture is absolute: the position it subtracts is the one
|
||||
drawn on the bar the user just aimed at, and it is at most one 250 ms
|
||||
tick stale — far under one pixel of the bar for any track worth seeking
|
||||
in. (The same staleness is fatal for a repeated *key*, which is why
|
||||
keys send a fixed step; see the Options section.) So click-to-seek
|
||||
needs no absolute field on the wire after all. The arithmetic lives in
|
||||
`state.rs` as a pure function, so it is unit-tested on the native
|
||||
target rather than only in a browser; geometry comes from
|
||||
`current_target` because the click may land on the fill rather than the
|
||||
track; a duration of 0 declines the click, since a bar with no scale
|
||||
has no position to click at.
|
||||
|
||||
The web transport bar also gets `⏪`/`⏩` buttons, and the CLI gets
|
||||
`cbd global seek <SECONDS>`, which accepts negatives.
|
||||
|
||||
## Structure
|
||||
|
||||
```d2
|
||||
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-player`** gains `Player::seek_by(delta_millis: i64)` and
|
||||
`PlayerEngineCommand::SeekBy`. The clamping helper is private; both
|
||||
`seek_to` (absolute, kept for the deferred extension) and `seek_by` go
|
||||
through it, so there is one clamping policy, not two.
|
||||
- **`crabidy-core`** gains the `Seek` RPC and its two messages.
|
||||
- **`crabidy-server`** gains `PlaybackCommand::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_seek` blocks 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_access` sitting
|
||||
*outside* `pausable` in 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`). Click-to-seek shipped without it
|
||||
(D10), so the only remaining use would be a client that wants to name a
|
||||
position without knowing the current one. A compatible proto3 addition
|
||||
if that ever appears.
|
||||
- **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.
|
||||
|
|
@ -109,22 +109,6 @@ impl Player {
|
|||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
/// Seeks `delta_millis` from the current position — negative seeks back —
|
||||
/// and resolves to the position actually reached, clamped to the track.
|
||||
///
|
||||
/// Relative rather than absolute on purpose: the engine adds the offset to
|
||||
/// the live position, so pressing a seek key several times in a row
|
||||
/// composes instead of repeatedly targeting the same place
|
||||
/// (architecture/seek.md D1). Fails when nothing is loaded, and when the
|
||||
/// source cannot seek at all (HLS).
|
||||
pub async fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
.send_async(PlayerEngineCommand::SeekBy(delta_millis, tx))
|
||||
.await?;
|
||||
rx.recv_async().await?
|
||||
}
|
||||
|
||||
pub async fn volume(&self) -> Result<f32> {
|
||||
let (tx, rx) = flume::bounded(1);
|
||||
self.tx_engine
|
||||
|
|
|
|||
|
|
@ -38,44 +38,6 @@ fn display_source(source_str: &str) -> String {
|
|||
/// Interval between elapsed-position updates while playing.
|
||||
const TICK_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// How close to the end of a track a forward seek may land.
|
||||
///
|
||||
/// Seeking to the exact end is decoder-dependent — symphonia, the Opus
|
||||
/// reader and a windowed http stream all disagree about whether that is an
|
||||
/// error — so an overshooting forward seek stops here instead and lets the
|
||||
/// track run out through the ordinary end-of-stream path, which advances the
|
||||
/// queue (architecture/seek.md D4).
|
||||
const SEEK_END_MARGIN: Duration = Duration::from_secs(1);
|
||||
|
||||
/// The absolute position a seek of `delta_millis` from `position` should land
|
||||
/// on, given the track's `duration` if it is known.
|
||||
///
|
||||
/// Pure arithmetic, and deliberately free of assertions: it is driven
|
||||
/// straight from user input, so every input — a delta larger than the track,
|
||||
/// `i64::MIN`, a duration of zero, a track shorter than the step — has to
|
||||
/// produce a position rather than a panic. (The previous implementation used
|
||||
/// `clamp(Duration::from_secs(1), duration)`, which asserts `min <= max` and
|
||||
/// therefore panicked on every duration-less stream.)
|
||||
///
|
||||
/// Saturates at 0 going back — a backward seek near the start means "go to
|
||||
/// the beginning", not "go to the previous track". Going forward it stops
|
||||
/// [`SEEK_END_MARGIN`] short of the end when the duration is known; a
|
||||
/// length-less stream reports no duration, and then there is nothing to clamp
|
||||
/// against, so the target passes through and the decoder decides whether it
|
||||
/// can honour it.
|
||||
fn seek_target(position: Duration, delta_millis: i64, duration: Option<Duration>) -> Duration {
|
||||
let step = Duration::from_millis(delta_millis.unsigned_abs());
|
||||
let target = if delta_millis < 0 {
|
||||
position.saturating_sub(step)
|
||||
} else {
|
||||
position.saturating_add(step)
|
||||
};
|
||||
match duration {
|
||||
Some(total) if !total.is_zero() => target.min(total.saturating_sub(SEEK_END_MARGIN)),
|
||||
_ => target,
|
||||
}
|
||||
}
|
||||
|
||||
pub enum PlayerEngineCommand {
|
||||
Play(String, Sender<Result<MediaInfo>>),
|
||||
SetVolume(f32, Sender<f32>),
|
||||
|
|
@ -87,10 +49,6 @@ pub enum PlayerEngineCommand {
|
|||
GetDuration(Sender<Result<Duration>>),
|
||||
GetElapsed(Sender<Result<Duration>>),
|
||||
SeekTo(Duration, Sender<Result<Duration>>),
|
||||
/// Seek by a signed offset in milliseconds from the live position
|
||||
/// (negative seeks back). Relative rather than absolute so repeated
|
||||
/// presses compose (architecture/seek.md D1).
|
||||
SeekBy(i64, Sender<Result<Duration>>),
|
||||
GetVolume(Sender<f32>),
|
||||
ToggleMute(Sender<bool>),
|
||||
GetPaused(Sender<Result<bool>>),
|
||||
|
|
@ -285,9 +243,6 @@ impl PlayerEngine {
|
|||
PlayerEngineCommand::GetDuration(tx) => send_reply(tx, self.duration()),
|
||||
PlayerEngineCommand::GetElapsed(tx) => send_reply(tx, self.elapsed()),
|
||||
PlayerEngineCommand::SeekTo(time, tx) => send_reply(tx, self.seek_to(time)),
|
||||
PlayerEngineCommand::SeekBy(delta_millis, tx) => {
|
||||
send_reply(tx, self.seek_by(delta_millis));
|
||||
}
|
||||
PlayerEngineCommand::SetVolume(volume, tx) => {
|
||||
send_reply(tx, self.set_volume(volume));
|
||||
}
|
||||
|
|
@ -586,57 +541,13 @@ impl PlayerEngine {
|
|||
Ok(self.sink.get_pos())
|
||||
}
|
||||
|
||||
/// Seeks to an absolute position, clamped by [`seek_target`]. Returns the
|
||||
/// position actually reached.
|
||||
pub fn seek_to(&self, time: Duration) -> Result<Duration> {
|
||||
if self.is_stopped() {
|
||||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
self.seek_sink(seek_target(time, 0, self.known_duration()))
|
||||
}
|
||||
|
||||
/// Seeks `delta_millis` from the **live** playback position (a negative
|
||||
/// delta seeks back), clamped by [`seek_target`]. Returns the position
|
||||
/// actually reached.
|
||||
///
|
||||
/// The offset is applied here rather than by the caller so that repeated
|
||||
/// presses compose: each seek starts from where the previous one landed,
|
||||
/// not from a position update that was broadcast up to a tick ago
|
||||
/// (architecture/seek.md D1). Seeking with nothing loaded is an error, not
|
||||
/// a way to start playback.
|
||||
pub fn seek_by(&self, delta_millis: i64) -> Result<Duration> {
|
||||
if self.is_stopped() {
|
||||
return Err(PlayerEngineError::NotPlaying.into());
|
||||
}
|
||||
let target = seek_target(self.sink.get_pos(), delta_millis, self.known_duration());
|
||||
self.seek_sink(target)
|
||||
}
|
||||
|
||||
/// Seeks the sink to an already-clamped absolute position and reports the
|
||||
/// position reached.
|
||||
///
|
||||
/// Reporting from here rather than leaving it to [`Self::tick`] is not
|
||||
/// only about latency: the tick returns early for a paused sink, so
|
||||
/// without this a seek while paused would leave every client showing the
|
||||
/// pre-seek position until playback resumed (architecture/seek.md D6).
|
||||
fn seek_sink(&self, target: Duration) -> Result<Duration> {
|
||||
let duration = self.duration().unwrap_or_else(|_| self.sink.get_pos());
|
||||
let time = time.clamp(Duration::from_secs(1), duration);
|
||||
self.sink
|
||||
.try_seek(target)
|
||||
.try_seek(time)
|
||||
.map_err(|err| anyhow!("seek failed: {err}"))?;
|
||||
let elapsed = self.sink.get_pos();
|
||||
debug!(?target, ?elapsed, "seeked");
|
||||
self.notify(PlayerMessage::Elapsed {
|
||||
duration: self.known_duration().unwrap_or_default(),
|
||||
elapsed,
|
||||
});
|
||||
Ok(elapsed)
|
||||
}
|
||||
|
||||
/// The current track's total duration when the source reported one.
|
||||
/// `None` covers both "no track loaded" and "a length-less stream", which
|
||||
/// clamping treats alike.
|
||||
fn known_duration(&self) -> Option<Duration> {
|
||||
self.media_info.as_ref().and_then(|info| info.duration)
|
||||
Ok(self.sink.get_pos())
|
||||
}
|
||||
|
||||
/// The user's intended volume — the level playback would resume at,
|
||||
|
|
@ -726,107 +637,6 @@ fn read_header<R: Read>(reader: &mut R, buf: &mut [u8]) -> Result<usize> {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 10-minute track, the ordinary case.
|
||||
fn ten_minutes() -> Option<Duration> {
|
||||
Some(Duration::from_secs(600))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_target_moves_by_the_delta() {
|
||||
let at = Duration::from_secs(100);
|
||||
assert_eq!(
|
||||
seek_target(at, 15_000, ten_minutes()),
|
||||
Duration::from_secs(115)
|
||||
);
|
||||
assert_eq!(
|
||||
seek_target(at, -15_000, ten_minutes()),
|
||||
Duration::from_secs(85)
|
||||
);
|
||||
}
|
||||
|
||||
/// Successive seeks compose because each one starts from the position the
|
||||
/// previous one produced — the reason the delta is applied here and not by
|
||||
/// the client (architecture/seek.md D1).
|
||||
#[test]
|
||||
fn successive_seeks_compose() {
|
||||
let mut at = Duration::from_secs(100);
|
||||
for _ in 0..3 {
|
||||
at = seek_target(at, -15_000, ten_minutes());
|
||||
}
|
||||
assert_eq!(at, Duration::from_secs(55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seek_target_saturates_at_the_start() {
|
||||
// Back past the beginning lands at 0 — not below, and not in the
|
||||
// previous track.
|
||||
assert_eq!(
|
||||
seek_target(Duration::from_secs(3), -15_000, ten_minutes()),
|
||||
Duration::ZERO
|
||||
);
|
||||
assert_eq!(
|
||||
seek_target(Duration::ZERO, -15_000, ten_minutes()),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
/// Forward past the end stops a second short of it, so the track finishes
|
||||
/// through the ordinary end-of-stream path and the queue advances.
|
||||
#[test]
|
||||
fn seek_target_saturates_near_the_end() {
|
||||
assert_eq!(
|
||||
seek_target(Duration::from_secs(595), 15_000, ten_minutes()),
|
||||
Duration::from_secs(599)
|
||||
);
|
||||
}
|
||||
|
||||
/// A length-less stream reports no duration. The naive clamp turned that
|
||||
/// into "seek to zero" (or panicked); the target has to pass through so
|
||||
/// the decoder can decide.
|
||||
#[test]
|
||||
fn an_unknown_duration_does_not_clamp_forward() {
|
||||
let at = Duration::from_secs(100);
|
||||
assert_eq!(seek_target(at, 15_000, None), Duration::from_secs(115));
|
||||
// `MediaInfo` carries `Some(0)` for some sources; same meaning.
|
||||
assert_eq!(
|
||||
seek_target(at, 15_000, Some(Duration::ZERO)),
|
||||
Duration::from_secs(115)
|
||||
);
|
||||
}
|
||||
|
||||
/// Every extreme has to yield a position rather than a panic: the deltas
|
||||
/// come from user input, and `duration` from whatever a feed claimed.
|
||||
#[test]
|
||||
fn seek_target_never_panics_on_extremes() {
|
||||
let positions = [Duration::ZERO, Duration::from_secs(1), Duration::MAX];
|
||||
let deltas = [i64::MIN, -1, 0, 1, i64::MAX];
|
||||
let durations = [
|
||||
None,
|
||||
Some(Duration::ZERO),
|
||||
// A track shorter than the end margin: the ceiling saturates to 0.
|
||||
Some(Duration::from_millis(400)),
|
||||
Some(Duration::from_secs(600)),
|
||||
Some(Duration::MAX),
|
||||
];
|
||||
for position in positions {
|
||||
for delta in deltas {
|
||||
for duration in durations {
|
||||
let _ = seek_target(position, delta, duration);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The case that used to panic outright: unknown duration, and a
|
||||
// sub-second track where the old floor exceeded the old ceiling.
|
||||
assert_eq!(
|
||||
seek_target(Duration::ZERO, 15_000, None),
|
||||
Duration::from_secs(15)
|
||||
);
|
||||
assert_eq!(
|
||||
seek_target(Duration::ZERO, 15_000, Some(Duration::from_millis(400))),
|
||||
Duration::ZERO
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn logged_sources_never_carry_url_tokens() {
|
||||
// Stream URLs embed access tokens; only scheme and host may be
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@ use crabidy_core::proto::crabidy::{
|
|||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, InitRequest, InsertRequest, LibraryNode, NextRequest, PrevRequest,
|
||||
Queue, RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest,
|
||||
SaveQueueRequest, SeekRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest,
|
||||
TogglePlayRequest, ToggleRepeatRequest, ToggleShuffleRequest, Track,
|
||||
SaveQueueRequest, SetCurrentRequest, StopRequest, ToggleMuteRequest, TogglePlayRequest,
|
||||
ToggleRepeatRequest, ToggleShuffleRequest, Track,
|
||||
};
|
||||
|
||||
use crate::{Connection, GlobalCmd, LibraryCmd, QueueCmd, RemoteCmd};
|
||||
|
|
@ -281,16 +281,6 @@ async fn run_global(client: &mut Client, cmd: GlobalCmd) -> Result<(), Box<dyn s
|
|||
.map_err(rpc_error)?;
|
||||
println!("restarted the current track");
|
||||
}
|
||||
GlobalCmd::Seek { seconds } => {
|
||||
// Milliseconds on the wire; the server clamps to the track.
|
||||
client
|
||||
.seek(SeekRequest {
|
||||
delta_millis: seconds.saturating_mul(1_000),
|
||||
})
|
||||
.await
|
||||
.map_err(rpc_error)?;
|
||||
println!("seeked {seconds:+} seconds");
|
||||
}
|
||||
GlobalCmd::Mute => {
|
||||
client
|
||||
.toggle_mute(ToggleMuteRequest {})
|
||||
|
|
|
|||
|
|
@ -113,13 +113,6 @@ pub enum GlobalCmd {
|
|||
Prev,
|
||||
/// Restart the current track.
|
||||
Restart,
|
||||
/// Seek inside the current track by a number of seconds (negative seeks
|
||||
/// back, e.g. `-15`). Stays within the track: it saturates at the start,
|
||||
/// and overshooting the end lets the track finish and the queue advance.
|
||||
Seek {
|
||||
#[arg(allow_negative_numbers = true)]
|
||||
seconds: i32,
|
||||
},
|
||||
/// Toggle mute.
|
||||
Mute,
|
||||
/// Change the volume by a delta in [-1.0, 1.0] (e.g. `0.05`, `-0.1`).
|
||||
|
|
|
|||
|
|
@ -42,14 +42,6 @@ pub enum Action {
|
|||
ToggleRepeat,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
/// Move the playing position back inside the current track by
|
||||
/// [`super::SEEK_STEP_MILLIS`]. Never leaves the track: near the start it
|
||||
/// simply lands at 0 (architecture/seek.md A1).
|
||||
SeekBackward,
|
||||
/// Move the playing position forward inside the current track by
|
||||
/// [`super::SEEK_STEP_MILLIS`]. Overshooting the end lets the track
|
||||
/// finish, which advances the queue.
|
||||
SeekForward,
|
||||
/// Show or hide the frequency-spectrum visualizer in the now-playing
|
||||
/// pane (client-side only; the server keeps streaming the bars).
|
||||
ToggleSpectrum,
|
||||
|
|
@ -237,39 +229,6 @@ pub const BINDINGS: &[Binding] = &[
|
|||
action: Action::PrevTrack,
|
||||
description: "Previous track",
|
||||
},
|
||||
// Two physical keys carry all four moves: unshifted seeks 15 seconds,
|
||||
// shifted skips a whole track, and `<`/`>` are the marks engraved on them.
|
||||
// They also give the browser client working track-skip keys, which it
|
||||
// otherwise lacks — Chrome and Firefox reserve `Ctrl-n` for "new window"
|
||||
// at a level `preventDefault` cannot reach.
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char(','),
|
||||
action: Action::SeekBackward,
|
||||
description: "Seek back 15 seconds",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char('.'),
|
||||
action: Action::SeekForward,
|
||||
description: "Seek forward 15 seconds",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('<'),
|
||||
action: Action::PrevTrack,
|
||||
description: "Previous track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::SHIFT,
|
||||
code: KeyCode::Char('>'),
|
||||
action: Action::NextTrack,
|
||||
description: "Next track",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::NONE,
|
||||
|
|
@ -899,47 +858,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Two physical keys carry all four moves in every focus: unshifted seeks
|
||||
/// 15 seconds, shifted skips a whole track. The shifted forms must resolve
|
||||
/// whether or not the terminal reports `SHIFT` (they are `Char` codes).
|
||||
#[test]
|
||||
fn seek_and_skip_share_two_keys_in_any_focus() {
|
||||
for focus in [UiFocus::Library, UiFocus::Queue] {
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char(','), KeyModifiers::NONE)),
|
||||
Some(Action::SeekBackward)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('.'), KeyModifiers::NONE)),
|
||||
Some(Action::SeekForward)
|
||||
);
|
||||
for mods in [KeyModifiers::NONE, KeyModifiers::SHIFT] {
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('<'), mods)),
|
||||
Some(Action::PrevTrack)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('>'), mods)),
|
||||
Some(Action::NextTrack)
|
||||
);
|
||||
}
|
||||
// The long-standing control chords still work in a terminal.
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('n'), KeyModifiers::CONTROL)),
|
||||
Some(Action::NextTrack)
|
||||
);
|
||||
}
|
||||
// And the help modal still swallows them.
|
||||
assert_eq!(
|
||||
lookup(
|
||||
UiFocus::Library,
|
||||
true,
|
||||
key(KeyCode::Char('.'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spectrum_moved_off_v_to_f() {
|
||||
// The frequency-spectrum toggle now lives on Global `f`, in any focus.
|
||||
|
|
|
|||
|
|
@ -79,15 +79,6 @@ pub const COLOR_GREEN: Color = Color::Rgb(163, 190, 140);
|
|||
/// provider).
|
||||
const CURRENT_QUEUE_PATH: &str = "/crabidy/current";
|
||||
|
||||
/// How far one press of a seek key moves the playing position, in
|
||||
/// milliseconds — the unit the wire speaks, so any step is expressible
|
||||
/// (architecture/seek.md D3).
|
||||
///
|
||||
/// The step lives here, in the client: the server is handed an offset and adds
|
||||
/// it to the live position, which is what makes repeated presses compose. The
|
||||
/// help text and the docs spell the number out, so change those too.
|
||||
pub(crate) const SEEK_STEP_MILLIS: i64 = 15_000;
|
||||
|
||||
// FIXME: Rename this
|
||||
pub enum MessageToUi {
|
||||
Init(InitialData),
|
||||
|
|
@ -136,11 +127,6 @@ pub enum MessageFromUi {
|
|||
NextTrack,
|
||||
PrevTrack,
|
||||
RestartTrack,
|
||||
/// Move the position inside the playing track by a signed millisecond
|
||||
/// offset. Relative: the server adds it to the live position, so holding
|
||||
/// the key composes instead of aiming at the same spot every time
|
||||
/// (architecture/seek.md D1).
|
||||
Seek(i64),
|
||||
SetCurrentTrack(usize),
|
||||
TogglePlay,
|
||||
ChangeVolume(f32),
|
||||
|
|
@ -539,12 +525,6 @@ impl App {
|
|||
}
|
||||
Action::NextTrack => self.queue.play_next(),
|
||||
Action::PrevTrack => self.queue.play_prev(),
|
||||
Action::SeekBackward => {
|
||||
let _ = self.tx.send(MessageFromUi::Seek(-SEEK_STEP_MILLIS));
|
||||
}
|
||||
Action::SeekForward => {
|
||||
let _ = self.tx.send(MessageFromUi::Seek(SEEK_STEP_MILLIS));
|
||||
}
|
||||
Action::ToggleSpectrum => self.now_playing.toggle_spectrum(),
|
||||
Action::LibraryFirst => self.library_move(|l| l.first()),
|
||||
Action::LibraryLast => self.library_move(|l| l.last()),
|
||||
|
|
@ -854,19 +834,6 @@ mod tests {
|
|||
let _ = app.dispatch(Action::RestartTrack);
|
||||
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::RestartTrack)));
|
||||
|
||||
// Seek sends the *step*, signed — never a target position: the server
|
||||
// adds it to the live position (architecture/seek.md D1).
|
||||
let _ = app.dispatch(Action::SeekForward);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == SEEK_STEP_MILLIS),
|
||||
"forward seek must send +one step"
|
||||
);
|
||||
let _ = app.dispatch(Action::SeekBackward);
|
||||
assert!(
|
||||
matches!(rx.try_recv(), Ok(MessageFromUi::Seek(d)) if d == -SEEK_STEP_MILLIS),
|
||||
"backward seek must send -one step"
|
||||
);
|
||||
|
||||
let _ = app.dispatch(Action::ToggleMute);
|
||||
assert!(matches!(rx.try_recv(), Ok(MessageFromUi::ToggleMute)));
|
||||
|
||||
|
|
|
|||
|
|
@ -146,9 +146,6 @@ async fn poll(
|
|||
MessageFromUi::RestartTrack => {
|
||||
rpc_client.restart_track().await?
|
||||
}
|
||||
MessageFromUi::Seek(delta_millis) => {
|
||||
rpc_client.seek(delta_millis).await?
|
||||
}
|
||||
MessageFromUi::SetCurrentTrack(pos) => {
|
||||
rpc_client.set_current_track(pos).await?
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ use crabidy_core::proto::crabidy::{
|
|||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InitResponse, InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest,
|
||||
RemoveRequest, RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||
SeekRequest, SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
ToggleShuffleRequest,
|
||||
};
|
||||
|
||||
|
|
@ -325,17 +325,6 @@ impl RpcClient {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves the playing position by a signed millisecond offset. The server
|
||||
/// applies it to the live position and clamps it to the track; a source
|
||||
/// that cannot seek leaves the position untouched.
|
||||
pub async fn seek(&mut self, delta_millis: i64) -> Result<(), Box<dyn Error>> {
|
||||
let seek_request = Request::new(SeekRequest {
|
||||
delta_millis: delta_millis as i32,
|
||||
});
|
||||
self.client.seek(seek_request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn set_current_track(&mut self, pos: usize) -> Result<(), Box<dyn Error>> {
|
||||
let set_current_request = Request::new(SetCurrentRequest {
|
||||
position: pos as u32,
|
||||
|
|
|
|||
|
|
@ -20,10 +20,6 @@ wasm-bindgen.workspace = true
|
|||
wasm-bindgen-futures.workspace = true
|
||||
web-sys = { workspace = true, features = [
|
||||
"Document",
|
||||
# `DomRect` is what `Element::get_bounding_client_rect` returns; the web-sys
|
||||
# method only exists with the feature on. Click-to-seek needs the gauge's
|
||||
# geometry to turn a click into a position.
|
||||
"DomRect",
|
||||
"Element",
|
||||
"HtmlInputElement",
|
||||
"KeyboardEvent",
|
||||
|
|
|
|||
|
|
@ -15,17 +15,11 @@ use leptos::task::spawn_local;
|
|||
use crate::keymap::{self, Action};
|
||||
use crate::rpc::Rpc;
|
||||
use crate::state::{
|
||||
format_seconds, is_cacheable, seek_offset_for_fraction, track_label, CaptureBoard, Dialog,
|
||||
Focus, LibraryPane, NamePurpose, QueueCursor, Register, UiItemKind,
|
||||
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
|
||||
NamePurpose, QueueCursor, Register, UiItemKind,
|
||||
};
|
||||
|
||||
const VOLUME_STEP: f32 = 0.1;
|
||||
|
||||
/// How far one seek press moves the playing position, in milliseconds — the
|
||||
/// unit the wire speaks (architecture/seek.md D3). The step lives in the
|
||||
/// client; the server only ever receives an offset and applies it to the live
|
||||
/// position, which is what makes repeated presses compose.
|
||||
pub(crate) const SEEK_STEP_MILLIS: i32 = 15_000;
|
||||
const JUMP: isize = 15;
|
||||
/// Stream reconnect backoff bounds, milliseconds.
|
||||
const BACKOFF_MIN_MS: u32 = 1_000;
|
||||
|
|
@ -403,10 +397,6 @@ impl Store {
|
|||
Action::RestartTrack => self.call(async |mut rpc: Rpc| rpc.restart_track().await),
|
||||
Action::NextTrack => self.call(async |mut rpc: Rpc| rpc.next().await),
|
||||
Action::PrevTrack => self.call(async |mut rpc: Rpc| rpc.prev().await),
|
||||
Action::SeekBackward => {
|
||||
self.call(async |mut rpc: Rpc| rpc.seek(-SEEK_STEP_MILLIS).await)
|
||||
}
|
||||
Action::SeekForward => self.call(async |mut rpc: Rpc| rpc.seek(SEEK_STEP_MILLIS).await),
|
||||
Action::VolumeUp => {
|
||||
self.call(async |mut rpc: Rpc| rpc.change_volume(VOLUME_STEP).await)
|
||||
}
|
||||
|
|
@ -1151,32 +1141,6 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
PlayState::Loading => "…",
|
||||
_ => "▶",
|
||||
};
|
||||
// Click-to-seek on the progress bar. The click may land on the fill rather
|
||||
// than the track, so the geometry comes from `current_target` — always the
|
||||
// element the listener is on — not from the event target.
|
||||
let on_seek = move |ev: leptos::ev::MouseEvent| {
|
||||
use wasm_bindgen::JsCast;
|
||||
let Some(gauge) = ev
|
||||
.current_target()
|
||||
.and_then(|target| target.dyn_into::<web_sys::Element>().ok())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let rect = gauge.get_bounding_client_rect();
|
||||
if rect.width() <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let fraction = (f64::from(ev.client_x()) - rect.left()) / rect.width();
|
||||
let position = store.position.get_untracked();
|
||||
let Some(delta) = seek_offset_for_fraction(position.position, position.duration, fraction)
|
||||
else {
|
||||
// No duration: the bar has no scale, so the click has no meaning.
|
||||
return;
|
||||
};
|
||||
if delta != 0 {
|
||||
store.call(async move |mut rpc: Rpc| rpc.seek(delta).await);
|
||||
}
|
||||
};
|
||||
let on_volume = move |ev: leptos::ev::Event| {
|
||||
if let Ok(target) = event_target_value(&ev).parse::<f32>() {
|
||||
let delta = target - store.volume.get_untracked();
|
||||
|
|
@ -1186,15 +1150,11 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
view! {
|
||||
<footer class="transport">
|
||||
<div class="controls">
|
||||
<button class="ghost" title="previous track (<)"
|
||||
<button class="ghost" title="previous (Ctrl-p)"
|
||||
on:click=move |_| store.dispatch(Action::PrevTrack)>"⏮"</button>
|
||||
<button class="ghost" title="back 15 seconds (,)"
|
||||
on:click=move |_| store.dispatch(Action::SeekBackward)>"⏪"</button>
|
||||
<button class="ghost big" title="play/pause (Space)"
|
||||
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
|
||||
<button class="ghost" title="forward 15 seconds (.)"
|
||||
on:click=move |_| store.dispatch(Action::SeekForward)>"⏩"</button>
|
||||
<button class="ghost" title="next track (>)"
|
||||
<button class="ghost" title="next (Ctrl-n)"
|
||||
on:click=move |_| store.dispatch(Action::NextTrack)>"⏭"</button>
|
||||
<button class="ghost" title="restart track (r)"
|
||||
on:click=move |_| store.dispatch(Action::RestartTrack)>"↺"</button>
|
||||
|
|
@ -1232,7 +1192,7 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
<span class="time">
|
||||
{move || format_seconds(store.position.get().position / 1000)}
|
||||
</span>
|
||||
<div class="gauge" title="click to seek" on:click=on_seek>
|
||||
<div class="gauge">
|
||||
<div
|
||||
class="gauge-fill"
|
||||
style:width=move || {
|
||||
|
|
|
|||
|
|
@ -21,13 +21,6 @@ pub enum Action {
|
|||
ToggleRepeat,
|
||||
NextTrack,
|
||||
PrevTrack,
|
||||
/// Move the playing position back inside the current track by
|
||||
/// [`crate::app::SEEK_STEP_MILLIS`]. Near the start it lands at 0 rather
|
||||
/// than stepping into the previous track (architecture/seek.md A1).
|
||||
SeekBackward,
|
||||
/// Move the playing position forward by the same step; overshooting the
|
||||
/// end lets the track finish, which advances the queue.
|
||||
SeekForward,
|
||||
LibraryFirst,
|
||||
LibraryLast,
|
||||
LibraryNext,
|
||||
|
|
@ -96,11 +89,6 @@ pub const HELP: &[HelpEntry] = &[
|
|||
key: "r",
|
||||
description: "Restart current track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: ", / .",
|
||||
description: "Seek back / forward 15 seconds",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "K",
|
||||
|
|
@ -128,8 +116,13 @@ pub const HELP: &[HelpEntry] = &[
|
|||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "< / >",
|
||||
description: "Previous / next track",
|
||||
key: "Ctrl-n",
|
||||
description: "Next track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-p",
|
||||
description: "Previous track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
|
|
@ -294,10 +287,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
|||
}
|
||||
if ctrl {
|
||||
return match key {
|
||||
// Mirrors the TUI, where these are the primary track-skip chords.
|
||||
// `Ctrl-p` resolves and is `prevent_default`ed; `Ctrl-n` is reserved
|
||||
// by Chrome and Firefox for "new window" above the page and never
|
||||
// reaches us, which is why `<`/`>` exist.
|
||||
"n" => Some(Action::NextTrack),
|
||||
"p" => Some(Action::PrevTrack),
|
||||
"d" => Some(match focus {
|
||||
|
|
@ -316,13 +305,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
|||
"Tab" => Some(Action::CycleFocus),
|
||||
" " => Some(Action::TogglePlay),
|
||||
"r" => Some(Action::RestartTrack),
|
||||
// Two physical keys, all four moves: unshifted seeks 15 seconds,
|
||||
// shifted skips a track. Plain printable characters, so unlike the
|
||||
// control chords they collide with nothing the browser reserves.
|
||||
"," => Some(Action::SeekBackward),
|
||||
"." => Some(Action::SeekForward),
|
||||
"<" => Some(Action::PrevTrack),
|
||||
">" => Some(Action::NextTrack),
|
||||
"K" => Some(Action::VolumeUp),
|
||||
"J" => Some(Action::VolumeDown),
|
||||
"m" => Some(Action::ToggleMute),
|
||||
|
|
@ -395,41 +377,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Seek is a control chord in either pane, and claiming `Ctrl-f` leaves
|
||||
/// plain `f` alone (the ctrl branch is checked first and separately).
|
||||
#[test]
|
||||
fn seek_is_on_the_control_chords_in_both_panes() {
|
||||
for focus in [Focus::Library, Focus::Queue] {
|
||||
assert_eq!(lookup(focus, false, ".", false), Some(Action::SeekForward));
|
||||
assert_eq!(lookup(focus, false, ",", false), Some(Action::SeekBackward));
|
||||
}
|
||||
// Unmodified `b` is unbound; unmodified `f` is not seek.
|
||||
assert_eq!(lookup(Focus::Library, false, "b", false), None);
|
||||
assert_ne!(
|
||||
lookup(Focus::Library, false, "f", false),
|
||||
Some(Action::SeekForward)
|
||||
);
|
||||
// Help is modal for control chords too.
|
||||
assert_eq!(lookup(Focus::Library, true, "f", true), None);
|
||||
}
|
||||
|
||||
/// `Ctrl-n` cannot be claimed in a browser — Chrome and Firefox reserve it
|
||||
/// above the page — so track skipping must also be reachable without it.
|
||||
#[test]
|
||||
fn track_skipping_does_not_depend_on_ctrl_n() {
|
||||
for focus in [Focus::Library, Focus::Queue] {
|
||||
assert_eq!(lookup(focus, false, ">", false), Some(Action::NextTrack));
|
||||
assert_eq!(lookup(focus, false, "<", false), Some(Action::PrevTrack));
|
||||
}
|
||||
// Ctrl-p is claimable and stays, so TUI habits still work.
|
||||
assert_eq!(
|
||||
lookup(Focus::Library, false, "p", true),
|
||||
Some(Action::PrevTrack)
|
||||
);
|
||||
// And the help overlay documents the chords that actually work.
|
||||
assert!(HELP.iter().any(|h| h.key == "< / >"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globals_win_in_both_panes() {
|
||||
for focus in [Focus::Library, Focus::Queue] {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ use crabidy_core::proto::crabidy::{
|
|||
ChangeVolumeRequest, ClearQueueRequest, CreateLibraryNodeRequest, DeleteLibraryNodeRequest,
|
||||
GetLibraryNodeRequest, GetUpdateStreamRequest, GetUpdateStreamResponse, InitRequest,
|
||||
InsertRequest, LibraryNode, NextRequest, PrevRequest, QueueRequest, RemoveRequest,
|
||||
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest, SeekRequest,
|
||||
RenameLibraryNodeRequest, ReplaceRequest, RestartTrackRequest, SaveQueueRequest,
|
||||
SetCurrentRequest, ToggleMuteRequest, TogglePlayRequest, ToggleRepeatRequest,
|
||||
ToggleShuffleRequest,
|
||||
};
|
||||
|
|
@ -260,15 +260,6 @@ impl Rpc {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Moves the playing position by a signed millisecond offset. The server
|
||||
/// applies it to the live position and clamps it to the track, so this
|
||||
/// carries the *step*, never a target (architecture/seek.md D1).
|
||||
pub async fn seek(&mut self, delta_millis: i32) -> Result<(), Status> {
|
||||
let request = Request::new(SeekRequest { delta_millis });
|
||||
let _ = self.client.seek(request).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn next(&mut self) -> Result<(), Status> {
|
||||
let _ = self.client.next(Request::new(NextRequest {})).await?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -561,37 +561,6 @@ impl CaptureBoard {
|
|||
}
|
||||
}
|
||||
|
||||
/// The seek offset in milliseconds that a click `fraction` of the way along
|
||||
/// the progress bar means, given where playback currently is.
|
||||
///
|
||||
/// Seeking is relative on the wire (`architecture/seek.md` D1), so a click on
|
||||
/// an absolute position becomes "the difference between there and here". That
|
||||
/// is fine for a one-shot gesture: the position it subtracts is the one drawn
|
||||
/// on the bar the user just aimed at, and it is at most one 250 ms tick old —
|
||||
/// far below one pixel of a progress bar for any track worth seeking in. (It
|
||||
/// would *not* be fine for a repeated key, which is why the keys send a fixed
|
||||
/// step and let the server accumulate.)
|
||||
///
|
||||
/// `None` when the click cannot mean anything: a track whose duration the
|
||||
/// source never reported has no scale, so the bar has no position to click at.
|
||||
/// The fraction is clamped, so a click on the very edge of the element — or a
|
||||
/// rounding error past it — cannot ask for a position outside the track.
|
||||
pub fn seek_offset_for_fraction(
|
||||
position_millis: u32,
|
||||
duration_millis: u32,
|
||||
fraction: f64,
|
||||
) -> Option<i32> {
|
||||
if duration_millis == 0 || !fraction.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let target = f64::from(duration_millis) * fraction.clamp(0.0, 1.0);
|
||||
// Both terms are millisecond offsets inside one track, so the difference is
|
||||
// nowhere near `i32`; the clamp is belt-and-braces for a `duration` a feed
|
||||
// could have lied about.
|
||||
let delta = target.round() - f64::from(position_millis);
|
||||
Some(delta.clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32)
|
||||
}
|
||||
|
||||
/// `mm:ss`, or `h:mm:ss` once past an hour — matching the TUI's now-playing
|
||||
/// clock so minutes zero-pad and roll into hours instead of counting past 60.
|
||||
pub fn format_seconds(total: u32) -> String {
|
||||
|
|
@ -648,46 +617,6 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
/// Clicking the progress bar has to become an *offset*, because that is
|
||||
/// what the wire carries — and it must stay inside the track however the
|
||||
/// pointer geometry comes out.
|
||||
#[test]
|
||||
fn a_progress_bar_click_becomes_an_offset() {
|
||||
// Half way through a 10-minute track, currently at 1:00 → +4:00.
|
||||
assert_eq!(
|
||||
seek_offset_for_fraction(60_000, 600_000, 0.5),
|
||||
Some(240_000)
|
||||
);
|
||||
// Clicking behind the playhead seeks backwards.
|
||||
assert_eq!(
|
||||
seek_offset_for_fraction(300_000, 600_000, 0.25),
|
||||
Some(-150_000)
|
||||
);
|
||||
// The far edges are exactly the ends of the track, and a fraction that
|
||||
// overshoots (rounding, a click on the border) is clamped, never
|
||||
// extrapolated past it.
|
||||
assert_eq!(seek_offset_for_fraction(0, 600_000, 0.0), Some(0));
|
||||
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.0), Some(600_000));
|
||||
assert_eq!(seek_offset_for_fraction(0, 600_000, 1.7), Some(600_000));
|
||||
assert_eq!(seek_offset_for_fraction(0, 600_000, -0.4), Some(0));
|
||||
}
|
||||
|
||||
/// A bar with no scale cannot be clicked into a position: a length-less
|
||||
/// stream reports duration 0, and a non-finite fraction means the element
|
||||
/// had no width. Both must decline rather than seek somewhere arbitrary.
|
||||
#[test]
|
||||
fn a_click_without_a_scale_is_declined() {
|
||||
assert_eq!(seek_offset_for_fraction(5_000, 0, 0.5), None);
|
||||
assert_eq!(seek_offset_for_fraction(5_000, 600_000, f64::NAN), None);
|
||||
assert_eq!(
|
||||
seek_offset_for_fraction(5_000, 600_000, f64::INFINITY),
|
||||
None
|
||||
);
|
||||
// And nothing panics on absurd inputs.
|
||||
assert!(seek_offset_for_fraction(u32::MAX, u32::MAX, 1.0).is_some());
|
||||
assert!(seek_offset_for_fraction(0, u32::MAX, 1.0).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_order_tracks_before_children_and_remember_positions() {
|
||||
let mut pane = LibraryPane::default();
|
||||
|
|
|
|||
|
|
@ -333,10 +333,6 @@ input {
|
|||
border-radius: 3px;
|
||||
background: var(--accent-soft);
|
||||
overflow: hidden;
|
||||
/* Click-to-seek: `manipulation` drops the touch double-tap delay so a
|
||||
tap seeks immediately. */
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
|
||||
& .gauge-fill {
|
||||
block-size: 100%;
|
||||
|
|
|
|||
|
|
@ -55,11 +55,6 @@ service CrabidyService {
|
|||
rpc Next(NextRequest) returns (NextResponse);
|
||||
rpc Prev(PrevRequest) returns (PrevResponse);
|
||||
rpc RestartTrack(RestartTrackRequest) returns (RestartTrackResponse);
|
||||
// Moves the playing position inside the current track. Relative, never
|
||||
// absolute: the server adds the offset to the live position, so repeated
|
||||
// presses of a seek key compose instead of all computing from the same
|
||||
// (already stale) position update.
|
||||
rpc Seek(SeekRequest) returns (SeekResponse);
|
||||
}
|
||||
|
||||
// System
|
||||
|
|
@ -254,24 +249,6 @@ message PrevResponse {}
|
|||
message RestartTrackRequest {}
|
||||
message RestartTrackResponse {}
|
||||
|
||||
message SeekRequest {
|
||||
// Signed offset from the current position; negative seeks backwards.
|
||||
// Milliseconds, like TrackPosition, so a client is free to pick any step.
|
||||
//
|
||||
// Clamped by the server: backwards saturates at the start of the track (it
|
||||
// never steps into the previous one), and forwards stops just short of the
|
||||
// end, so an overshooting seek lets the track finish and the queue advance.
|
||||
// A track whose duration is unknown (a length-less stream) has no upper
|
||||
// clamp and the decoder may refuse the seek.
|
||||
//
|
||||
// Fire-and-forget, like the other playback rpcs: the response says the
|
||||
// command was accepted, not that the seek happened. A source that cannot
|
||||
// seek at all (SoundCloud's HLS streams) leaves the position unchanged; the
|
||||
// position updates on the stream remain the truth.
|
||||
sint32 delta_millis = 1;
|
||||
}
|
||||
message SeekResponse {}
|
||||
|
||||
// Data types
|
||||
message LibraryNodeChild {
|
||||
string path = 1;
|
||||
|
|
|
|||
|
|
@ -1075,13 +1075,6 @@ pub enum PlaybackCommand {
|
|||
Next,
|
||||
Prev,
|
||||
RestartTrack,
|
||||
/// Moves the position inside the playing track by a signed offset in
|
||||
/// milliseconds. The offset is passed straight to the player, which adds
|
||||
/// it to the live position and clamps it — the playback loop keeps no
|
||||
/// position of its own to go stale (architecture/seek.md D1).
|
||||
Seek {
|
||||
delta_millis: i64,
|
||||
},
|
||||
StateChanged {
|
||||
state: PlayState,
|
||||
},
|
||||
|
|
@ -1121,7 +1114,6 @@ impl PlaybackCommand {
|
|||
Self::Next => "next",
|
||||
Self::Prev => "prev",
|
||||
Self::RestartTrack => "restart_track",
|
||||
Self::Seek { .. } => "seek",
|
||||
Self::StateChanged { .. } => "state_changed",
|
||||
Self::VolumeChanged { .. } => "volume_changed",
|
||||
Self::MuteChanged { .. } => "mute_changed",
|
||||
|
|
|
|||
|
|
@ -463,20 +463,6 @@ impl Playback {
|
|||
}
|
||||
}
|
||||
|
||||
PlaybackCommand::Seek { delta_millis } => {
|
||||
debug!(delta_millis, "seeking");
|
||||
// The player owns both the live position and the clamping, so
|
||||
// this arm only forwards. A source that cannot seek (HLS) or a
|
||||
// player with nothing loaded is a warning: clients render the
|
||||
// position from the update stream and never moved it
|
||||
// themselves, so there is nothing to correct
|
||||
// (architecture/seek.md D7).
|
||||
match self.player.seek_by(delta_millis).await {
|
||||
Ok(position) => trace!(?position, "seeked"),
|
||||
Err(err) => warn!("seek failed: {err:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
PlaybackCommand::VolumeChanged { volume } => {
|
||||
trace!(volume, "volume changed");
|
||||
self.broadcast(StreamUpdate::Volume(volume));
|
||||
|
|
@ -1139,15 +1125,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
// Seek is not covered here on purpose. The handler's only job is to call
|
||||
// `player.seek_by`, and every test in this module builds a real `Player`
|
||||
// whose engine thread opens an audio device — so a test that *awaits* a
|
||||
// player reply passes or hangs depending on whether the machine running it
|
||||
// has working audio output, which is not a property of this code. The
|
||||
// clamping arithmetic is tested as a pure function in `audio-player`, and
|
||||
// the mapping from the RPC to the command (the layer the paste bug lived
|
||||
// in) is tested in `rpc.rs`.
|
||||
|
||||
/// And position 0 reaches the very front — what `P` on the first row needs.
|
||||
#[tokio::test]
|
||||
async fn insert_command_at_zero_reaches_the_front() {
|
||||
|
|
|
|||
|
|
@ -11,10 +11,10 @@ use crabidy_core::proto::crabidy::{
|
|||
InsertResponse, NextRequest, NextResponse, PrevRequest, PrevResponse, QueueRequest,
|
||||
QueueResponse, RemoveRequest, RemoveResponse, RenameLibraryNodeRequest,
|
||||
RenameLibraryNodeResponse, ReplaceRequest, ReplaceResponse, RestartTrackRequest,
|
||||
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SeekRequest, SeekResponse,
|
||||
SetCurrentRequest, SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest,
|
||||
ToggleMuteResponse, TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest,
|
||||
ToggleRepeatResponse, ToggleShuffleRequest, ToggleShuffleResponse,
|
||||
RestartTrackResponse, SaveQueueRequest, SaveQueueResponse, SetCurrentRequest,
|
||||
SetCurrentResponse, StopRequest, StopResponse, ToggleMuteRequest, ToggleMuteResponse,
|
||||
TogglePlayRequest, TogglePlayResponse, ToggleRepeatRequest, ToggleRepeatResponse,
|
||||
ToggleShuffleRequest, ToggleShuffleResponse,
|
||||
};
|
||||
use crabidy_core::ProviderError;
|
||||
use std::pin::Pin;
|
||||
|
|
@ -589,66 +589,4 @@ impl CrabidyService for RpcService {
|
|||
self.send_playback(PlaybackCommand::RestartTrack).await?;
|
||||
Ok(Response::new(RestartTrackResponse {}))
|
||||
}
|
||||
|
||||
/// Moves the position inside the playing track by a signed millisecond
|
||||
/// offset. The offset is forwarded verbatim: the player adds it to the
|
||||
/// live position and clamps it to the track (architecture/seek.md D1).
|
||||
///
|
||||
/// Fire-and-forget like the other playback rpcs — an unseekable source or
|
||||
/// an idle player is logged, not reported, and the position updates on the
|
||||
/// stream stay the single truth.
|
||||
#[instrument(skip(self, request), fields(delta_millis))]
|
||||
async fn seek(&self, request: Request<SeekRequest>) -> Result<Response<SeekResponse>, Status> {
|
||||
let delta_millis = request.into_inner().delta_millis;
|
||||
tracing::Span::current().record("delta_millis", delta_millis);
|
||||
debug!("received seek request");
|
||||
self.send_playback(PlaybackCommand::Seek {
|
||||
delta_millis: delta_millis as i64,
|
||||
})
|
||||
.await?;
|
||||
Ok(Response::new(SeekResponse {}))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn service() -> (RpcService, flume::Receiver<PlaybackMessage>) {
|
||||
let (update_tx, _) = tokio::sync::broadcast::channel(16);
|
||||
let (playback_tx, playback_rx) = flume::bounded(16);
|
||||
let (provider_tx, _provider_rx) = flume::bounded(16);
|
||||
(
|
||||
RpcService::new(update_tx, playback_tx, provider_tx, false),
|
||||
playback_rx,
|
||||
)
|
||||
}
|
||||
|
||||
/// The seek offset must reach the playback loop **unchanged**, sign
|
||||
/// included: it is a signed `sint32` on the wire widened to `i64`, and a
|
||||
/// wrong widening (via `u32`, say) would turn every backward seek into a
|
||||
/// forward jump of ~50 days.
|
||||
///
|
||||
/// This layer is worth pinning because it is exactly where the queue-paste
|
||||
/// bug lived: the primitive and the ops were tested, the command mapping
|
||||
/// was not. Nothing here touches the player, so no audio device is needed.
|
||||
#[tokio::test]
|
||||
async fn seek_forwards_the_signed_delta_unchanged() {
|
||||
let (service, playback_rx) = service();
|
||||
for delta in [-15_000, 15_000, i32::MIN, i32::MAX, 0] {
|
||||
service
|
||||
.seek(Request::new(SeekRequest {
|
||||
delta_millis: delta,
|
||||
}))
|
||||
.await
|
||||
.expect("seek is accepted");
|
||||
let message = playback_rx.recv_async().await.expect("command sent");
|
||||
match message.command {
|
||||
PlaybackCommand::Seek { delta_millis } => {
|
||||
assert_eq!(delta_millis, delta as i64, "delta must survive the hop");
|
||||
}
|
||||
other => panic!("expected a seek command, got {}", other.name()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,9 +47,8 @@ cbd global volume -- -0.1 # lower the volume
|
|||
`replace <PATH>…`, `queue remove <POS>…`, `queue clear
|
||||
[--keep-current]`, `queue set-current <POS>`, `queue save`/`capture
|
||||
<NAME>`, `queue shuffle`, and `queue repeat` change it.
|
||||
- `global play`/`stop`/`next`/`prev`/`restart`/`mute`, `global
|
||||
volume <DELTA>`, and `global seek <SECONDS>` (negative seeks back, e.g.
|
||||
`global seek -15`) control playback.
|
||||
- `global play`/`stop`/`next`/`prev`/`restart`/`mute` and `global
|
||||
volume <DELTA>` control playback.
|
||||
|
||||
## Server commands
|
||||
|
||||
|
|
|
|||
|
|
@ -156,7 +156,6 @@ pane).
|
|||
| Global | `Tab` | Switch between library and queue |
|
||||
| Global | `Space` | Play/pause |
|
||||
| Global | `r` | Restart current track |
|
||||
| Global | `,` / `.` | Seek back / forward 15 seconds |
|
||||
| Global | `K` | Volume up |
|
||||
| Global | `J` | Volume down |
|
||||
| Global | `m` | Toggle mute |
|
||||
|
|
@ -164,7 +163,6 @@ pane).
|
|||
| Global | `x` | Toggle repeat |
|
||||
| Global | `Ctrl-n` | Next track |
|
||||
| Global | `Ctrl-p` | Previous track |
|
||||
| Global | `<` / `>` | Previous / next track |
|
||||
| Global | `f` | Toggle the frequency spectrum |
|
||||
| Library | `j` / `k` | Select next / previous item |
|
||||
| Library | `g` / `G` | Select first / last item |
|
||||
|
|
|
|||
|
|
@ -14,11 +14,8 @@ marks (`s`), visual mode (`v`/`V`) in both panes, and the register — `y`
|
|||
yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or
|
||||
before the cursor (see [the TUI's register
|
||||
section](./tui.md#the-register-y-d-and-pp), which behaves identically here;
|
||||
the queue toolbar shows how many entries are waiting). Seek is there too —
|
||||
`,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole
|
||||
track), as do the `⏪`/`⏩`
|
||||
buttons in
|
||||
the transport bar. The `/` live filter is TUI-only for now.
|
||||
the queue toolbar shows how many entries are waiting). The `/` live filter
|
||||
is TUI-only for now.
|
||||
|
||||
## How it is served
|
||||
|
||||
|
|
|
|||
|
|
@ -102,7 +102,7 @@ url = "https://feeds.example.org/cautionary-tales"
|
|||
# Optional, defaults shown.
|
||||
episodes_per_feed = 200 # episodes listed per feed
|
||||
call_timeout_secs = 30 # per-request timeout
|
||||
max_feed_bytes = 33554432 # 32 MiB cap on a feed body
|
||||
max_feed_bytes = 8388608 # 8 MiB cap on a feed body
|
||||
```
|
||||
|
||||
`%` appends to this file, so subscriptions made from a client persist. An
|
||||
|
|
@ -111,28 +111,3 @@ entry with no url is skipped with a warning, and no feeds at all is fine —
|
|||
|
||||
Feeds are read as RSS 2.0/1.0/0.x, Atom, or JSON Feed, and a malformed
|
||||
episode is skipped rather than failing the listing.
|
||||
|
||||
## Subscribe to the podcast feed, not the site feed
|
||||
|
||||
A blog and its podcast usually have **different** feeds, and subscribing to
|
||||
the wrong one gives you a subscription that lists nothing. That is by
|
||||
design: an entry only becomes an episode if it carries a playable audio
|
||||
enclosure, and a blog feed's enclosures are the posts' featured *images*.
|
||||
WordPress feeds in particular look exactly like podcast feeds apart from
|
||||
that one attribute — so `https://netzpolitik.org/feed/` yields no
|
||||
episodes, while `https://logbuch-netzpolitik.de/feed/mp3` yields the
|
||||
podcast.
|
||||
|
||||
When a feed has entries but none of them carry audio, the server says so:
|
||||
|
||||
```text
|
||||
WARN feed has entries but none carry playable audio; this looks like a blog
|
||||
feed rather than a podcast feed entries=25
|
||||
```
|
||||
|
||||
Look for a "Podcast" or "Subscribe" link on the show's page, or a
|
||||
`/feed/mp3`-style URL. An enclosure is accepted when its type says audio,
|
||||
when the feed omits the type (many hand-rolled feeds do), or when a generic
|
||||
`application/octet-stream` is backed up by an audio file extension. Video
|
||||
enclosures are left alone; a show that publishes both video and audio plays
|
||||
as audio.
|
||||
|
|
|
|||
|
|
@ -158,38 +158,6 @@ Two consequences worth knowing:
|
|||
node expands it to tracks at paste time, and a path that no longer
|
||||
resolves simply does not come back.
|
||||
|
||||
## Seeking inside a track
|
||||
|
||||
`,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
|
||||
position by 15 seconds; the shifted forms of the same two keys, `<` and `>`,
|
||||
skip a whole track.
|
||||
|
||||
The wire carries the **offset**, not a target position: the server adds it to
|
||||
the live position, so pressing the key three times moves 45 seconds rather than
|
||||
three times to the same spot — which is what would happen if a client computed
|
||||
a target from a position update that is already up to a tick old.
|
||||
|
||||
The step stays inside the current track:
|
||||
|
||||
- Backwards it saturates at the start. It never steps into the previous track;
|
||||
that is `<`.
|
||||
- Forwards it stops a second short of the end, so an overshooting seek lets the
|
||||
track run out through the normal end-of-track path — which advances the
|
||||
queue, obeying shuffle and repeat like any other track change.
|
||||
- A track whose duration the source never reported has no upper bound to clamp
|
||||
to, so the decoder decides whether it can honour the seek.
|
||||
- **SoundCloud tracks cannot seek at all.** They arrive as HLS playlists,
|
||||
decoded as a forward-only stream on purpose; a seek there is logged by the
|
||||
server and leaves the position where it was. The position you see is always
|
||||
the truth — clients never move it themselves.
|
||||
|
||||
In the web client the progress bar is **clickable**: a click seeks to that
|
||||
point in the track. It is still an offset on the wire — the client sends the
|
||||
difference between where you clicked and where playback is — which is accurate
|
||||
enough for a one-shot gesture aimed at a position you can see, and means
|
||||
click-to-seek needed no new rpc. A bar with no scale (a track of unknown
|
||||
duration) declines the click.
|
||||
|
||||
## Persistence
|
||||
|
||||
The live queue survives a server restart. It is mirrored to the reserved
|
||||
|
|
|
|||
72
plan/seek.md
72
plan/seek.md
|
|
@ -1,72 +0,0 @@
|
|||
# Plan — seek
|
||||
|
||||
Executes `architecture/seek.md` against `quality/seek.md`. One commit:
|
||||
the change is a single thin path from key to sink, and half-wiring it
|
||||
would leave a dead RPC.
|
||||
|
||||
## A — The engine (where the only real logic is)
|
||||
|
||||
- [x] **A1 — `PlayerEngine::seek_target`** — a pure function of
|
||||
(current position, delta millis, known duration) returning the absolute
|
||||
target: saturating on both ends, 0 as the floor, `duration - 1 s` as
|
||||
the ceiling when the duration is known, no ceiling when it is 0.
|
||||
Extracted as a free function so the arithmetic is testable without an
|
||||
audio device. *Verifies:* G1, G5, G6, G7.
|
||||
- [x] **A2 — Rewrite `PlayerEngine::seek_to`** to route through the same
|
||||
helper, dropping the `clamp(1s, duration)` that panics whenever the
|
||||
duration is unknown or under a second. *Verifies:* G1.
|
||||
- [x] **A3 — `PlayerEngine::seek_by`** — reads `sink.get_pos()`, applies
|
||||
A1, calls `try_seek`, and on success notifies
|
||||
`PlayerMessage::Elapsed` with the new position so a paused seek is
|
||||
visible. Refuses when stopped. *Verifies:* G4, G8, G9.
|
||||
- [x] **A4 — `PlayerEngineCommand::SeekBy` + `Player::seek_by`** on the
|
||||
existing bounded reply pattern. *Verifies:* G3.
|
||||
- [x] **A5 — Engine tests**: extremes (`i64::MIN`/`MAX`), zero duration,
|
||||
short track, composition, near-end saturation. *Verifies:* G1, G4–G7.
|
||||
|
||||
## B — Wire and server
|
||||
|
||||
- [x] **B1 — Proto**: `rpc Seek(SeekRequest) returns (SeekResponse)` with
|
||||
`sint32 delta_millis`, documented with the clamping contract.
|
||||
*Verifies:* G10.
|
||||
- [x] **B2 — `PlaybackCommand::Seek { delta_millis }`** + its `name()`
|
||||
arm, and the playback handler that forwards to `player.seek_by` and
|
||||
warns on failure. No state. *Verifies:* G2, G12.
|
||||
- [x] **B3 — `RpcService::seek`** following the `change_volume` shape
|
||||
(record the field, send, return empty). *Verifies:* G10.
|
||||
- [x] **B4 — Server test** that the command reaches the player with the
|
||||
delta unchanged. *Verifies:* G11.
|
||||
|
||||
## C — Clients
|
||||
|
||||
- [x] **C1 — TUI**: `Action::SeekForward`/`SeekBackward`, `,`/`.` bindings
|
||||
(plus `<`/`>` for prev/next) in `Scope::Global` with help text,
|
||||
`MessageFromUi::Seek(i64)`,
|
||||
the `lib.rs` arm, `rpc.rs` wrapper, `SEEK_STEP` constant, dispatch
|
||||
tests. *Verifies:* G13, G14, G15.
|
||||
- [x] **C2 — Web**: the same two actions in `keymap.rs` (global, with
|
||||
`HELP` rows), `Rpc::seek`, the `app.rs` dispatch arms, and `⏪`/`⏩`
|
||||
buttons in the transport bar. *Verifies:* G13, G14, G15.
|
||||
- [x] **C4 — Web click-to-seek**: `state::seek_offset_for_fraction` (pure,
|
||||
native-testable) plus an `on:click` on the gauge reading its geometry
|
||||
from `current_target`, and `cursor: pointer` on the bar. *Verifies:* G18.
|
||||
- [x] **C3 — CLI**: `GlobalCmd::Seek { seconds: i32 }` with
|
||||
`allow_negative_numbers`, and its executor arm. *Verifies:* G14.
|
||||
|
||||
## D — Docs and gates
|
||||
|
||||
- [x] **D1 — Docs**: key tables (`docs/src/clients/tui.md`,
|
||||
`docs/src/clients/web.md`), the CLI page's `global` list, and the
|
||||
within-track/end-of-track/HLS sentences in `docs/src/queue.md`.
|
||||
*Verifies:* G16.
|
||||
- [x] **D2 — Gates**: workspace tests, `check-features`, `cargo fmt`, a
|
||||
wasm build of `cbd-web`, and the book build. *Verifies:* G17.
|
||||
- [x] **D3 — `plan/summary.md`** entry, then commit.
|
||||
|
||||
## Deferred (recorded, not dropped)
|
||||
|
||||
- Absolute seek + click-to-seek on the web progress gauge (the gauge is
|
||||
already rendered; needs one proto field and a click handler).
|
||||
- Configurable step size, and a larger step on `<`/`>`.
|
||||
- Chapter-aware seek for podcasts/audiobooks — no provider exposes
|
||||
chapter marks through the library model.
|
||||
119
plan/summary.md
119
plan/summary.md
|
|
@ -1393,122 +1393,3 @@ whole `check-features` matrix (now including `rss` alone) clippy-clean under
|
|||
`-D warnings`, fmt clean, book builds, and a live fetch of the user's own
|
||||
premium Economist feed. Not exercised: playing an episode through an audio
|
||||
device.
|
||||
|
||||
## seek within a track (2026-07-26)
|
||||
|
||||
`architecture/seek.md` → `quality/seek.md` → `plan/seek.md`. `Ctrl-b` /
|
||||
`Ctrl-f` move the playing position 15 seconds, from the TUI, the browser, and
|
||||
`cbd global seek <SECONDS>`.
|
||||
|
||||
Almost all of it was wiring: `PlayerEngine::seek_to` and its command already
|
||||
existed and **nothing called them** — no RPC, no playback command, no binding.
|
||||
|
||||
*The one real decision was where the arithmetic lives.* A seek is relative
|
||||
("15 seconds back") but the engine seeks to an absolute position, so either the
|
||||
client computes the target from the last `TrackPosition` it received, or it
|
||||
sends a delta and the engine adds it to the live position. The delta wins on
|
||||
the ordinary case of pressing the key twice: positions are broadcast on a
|
||||
250 ms tick and then cross the network, so three quick presses all read the
|
||||
*same* stale base and jump 15 s instead of 45. It also keeps the clamping
|
||||
policy in one place instead of three clients, and matters more while paused,
|
||||
where no position updates arrive at all. So the wire carries
|
||||
`sint32 delta_millis` and the step (15 s) is a client constant.
|
||||
|
||||
*It also uncovered a live panic.* `seek_to` did
|
||||
`time.clamp(Duration::from_secs(1), duration)`. `Ord::clamp` asserts
|
||||
`min <= max`, and `duration()` returns **0** whenever the source reported no
|
||||
length (HLS, some streams) — so that call panicked the engine thread, killing
|
||||
audio, on any duration-less track. Unreachable only because nothing called it;
|
||||
wiring seek made it reachable from user input, which the hard rules forbid. It
|
||||
is now saturating arithmetic in a pure, exhaustively-tested function.
|
||||
|
||||
Boundaries: backwards saturates at 0 and never enters the previous track;
|
||||
forwards stops 1 s short of the end so the track finishes through the ordinary
|
||||
end-of-stream path (which advances the queue) rather than relying on
|
||||
seek-to-exact-end, which every decoder treats differently; an unknown duration
|
||||
has no upper clamp and the decoder decides. The engine emits `Elapsed` from the
|
||||
seek path itself, because `tick()` skips a paused sink and a paused seek would
|
||||
otherwise show the old position until playback resumed. An unseekable source
|
||||
(SoundCloud's HLS) warns server-side and changes nothing — clients never move
|
||||
the position themselves, so there is nothing to correct.
|
||||
|
||||
Bindings landed on `Ctrl-b`/`Ctrl-f` at the user's request (a first round used
|
||||
`,`/`.`). They join the existing control-chord family (`Ctrl-n`/`Ctrl-p`,
|
||||
`Ctrl-d`/`Ctrl-u`); plain `f` still toggles the spectrum because the TUI's
|
||||
`lookup` compares every modifier but `SHIFT` exactly. In the browser `Ctrl-f`
|
||||
would open the find bar, but the keydown handler already `prevent_default`s any
|
||||
chord that resolves — binding it is enough to claim it.
|
||||
|
||||
Deferred: absolute seek plus click-to-seek on the web progress gauge (a
|
||||
compatible proto3 field addition; the gauge is already rendered), a
|
||||
configurable step, and chapter-aware seek.
|
||||
|
||||
Verified: 20 audio-player tests (5 new, covering `i64::MIN`/`MAX`, zero
|
||||
duration, sub-second tracks, composition, near-end saturation), 120 cbd-tui,
|
||||
21 cbd-web, crabidy-server, fmt clean, the wasm bundle builds. Not exercised:
|
||||
an actual seek through an audio device.
|
||||
|
||||
### Fixed alongside: image enclosures listed as episodes (`/rss`)
|
||||
|
||||
Reported from a real subscription: an episode failed with "the format of the
|
||||
data has not been recognized" on a `cdn.netzpolitik.org/…jpg` URL.
|
||||
|
||||
`audio_url` preferred an audio-typed enclosure but then fell back to *any*
|
||||
media URL — and a WordPress blog feed attaches each post's featured **image**
|
||||
as an `<enclosure>`, structurally identical to a podcast enclosure apart from
|
||||
`type="image/jpeg"`. `https://netzpolitik.org/feed/` is 25 items, 25 JPEG
|
||||
enclosures, and no audio reference of any kind, so every post became an
|
||||
episode that could not play.
|
||||
|
||||
An enclosure is now accepted when its type says audio, when the feed omits the
|
||||
type (many hand-rolled feeds do), or when a generic `application/octet-stream`
|
||||
is backed by an audio file extension — and rejected otherwise, so images and
|
||||
video are skipped. Audio-typed still wins, so a show publishing both plays as
|
||||
audio. When a feed has entries but none carry audio, the server says so
|
||||
("this looks like a blog feed rather than a podcast feed"): an empty listing
|
||||
explains nothing on its own, and no URL goes in the message.
|
||||
|
||||
Also raised `DEFAULT_MAX_FEED_BYTES` 8 MiB → 32 MiB. Logbuch:Netzpolitik, 559
|
||||
episodes in, is a healthy 6.8 MiB — feeds carry their whole back catalogue with
|
||||
full show notes, so the first cap would have started refusing real feeds within
|
||||
a year or two. Still bounded, still enforced while reading, still lowerable via
|
||||
`max_feed_bytes`.
|
||||
|
||||
Verified: 30 rssdy tests (5 new, over feed-rs's real entry shapes: image-only,
|
||||
mixed image+audio, untyped, generic-with-extension, generic-without and video),
|
||||
and the two live feeds — netzpolitik.org/feed/ now yields 0 episodes with the
|
||||
warning, logbuch-netzpolitik.de/feed/mp3 (→ feeds.metaebene.me/lnp/mp3) yields
|
||||
559 `audio/mpeg` episodes with `HH:MM:SS` durations.
|
||||
|
||||
### Follow-up: the keys settled on `,` `.` `<` `>`, and the gauge is clickable
|
||||
|
||||
Three rounds on the bindings, and the last one had a reason beyond taste.
|
||||
`Ctrl-b`/`Ctrl-f` work fine in a browser — the keydown handler already
|
||||
`prevent_default`s any chord it resolves — but `Ctrl-n`, the long-standing
|
||||
next-track chord, does **not**: Chrome and Firefox handle it as "new window"
|
||||
above the page, where `preventDefault` cannot reach. The web client therefore
|
||||
had no working next-track key at all.
|
||||
|
||||
Plain printable characters dodge the whole reserved-chord question, so seek and
|
||||
track-skip now share two keys in both clients: `,`/`.` seek 15 s, `<`/`>` skip a
|
||||
track. Same key, shift = bigger jump — self-teaching, and `<`/`>` are the marks
|
||||
engraved on those keys (mpv uses them for the same thing). `Ctrl-n`/`Ctrl-p`
|
||||
stay bound as the terminal's primary chords; the web help documents the pair
|
||||
that always works.
|
||||
|
||||
**Click-to-seek shipped without absolute seek**, which had been the deferred
|
||||
item. The click maps the pointer's x within the gauge to a fraction of the
|
||||
duration and sends `target - position`. Relative is right here even though the
|
||||
gesture is absolute: the position it subtracts is the one drawn on the bar the
|
||||
user just aimed at, at most one 250 ms tick old — far under one pixel of the
|
||||
bar. That staleness is only fatal for a *repeated* key, which is why keys still
|
||||
send a fixed step and let the server accumulate. The arithmetic is a pure
|
||||
function in `state.rs`, tested on the native target: clicks behind the playhead
|
||||
seek back, the edges are exactly the track's ends, a fraction outside `[0, 1]`
|
||||
is clamped rather than extrapolated, and a duration of 0 declines.
|
||||
|
||||
Also: enabling the web-sys `DomRect` feature, without which
|
||||
`Element::get_bounding_client_rect` does not exist. Only the wasm build catches
|
||||
that — `cbd-web`'s `mod app` is `#[cfg(target_arch = "wasm32")]`, so native
|
||||
clippy never compiles it. Second time this session that the wasm build was the
|
||||
only gate that would have caught a web-client mistake.
|
||||
|
|
|
|||
|
|
@ -1,96 +0,0 @@
|
|||
# Quality gates — seek
|
||||
|
||||
Criteria an implementation of `architecture/seek.md` must satisfy.
|
||||
Automated coverage lives in `audio-player/src/player_engine.rs` (the
|
||||
clamping arithmetic, which is where the interesting behaviour is),
|
||||
`crabidy-server/src/playback.rs`, and the two clients' binding tests.
|
||||
|
||||
## Hard rules (highest priority)
|
||||
|
||||
- [ ] **G1 — No panic on any delta, from any state.** The clamp is
|
||||
saturating arithmetic with no `assert`/`clamp(min, max)` where
|
||||
`min > max` is reachable. Specifically: an unknown duration (reported
|
||||
as 0), a track shorter than the step, a delta larger than the track,
|
||||
`i64::MIN`/`i64::MAX` as the delta, and a stopped or empty sink must
|
||||
all be handled without panicking. This closes the existing bug in
|
||||
`seek_to` (D5). *(tests: `seek_target_never_panics_on_extremes`,
|
||||
`seek_target_saturates_at_the_start`,
|
||||
`an_unknown_duration_does_not_clamp_forward`.)*
|
||||
- [ ] **G2 — A failed seek stays server-side.** `Seek` never returns a
|
||||
`color-eyre`/`anyhow` report to a client; an unseekable source produces
|
||||
a `warn!` and an unchanged position, not an RPC error (D7).
|
||||
- [ ] **G3 — No new unbounded channel and no new blocking call on an
|
||||
async task.** The seek reply rides the existing bounded reply pattern;
|
||||
`try_seek` is called only from the engine thread.
|
||||
|
||||
## Behaviour
|
||||
|
||||
- [ ] **G4 — Deltas compose.** Two successive backward seeks from the
|
||||
same starting position land at `start - 2 × step`, not
|
||||
`start - step` — the engine reads the live position each time (D1).
|
||||
*(test: `successive_seeks_compose`.)*
|
||||
- [ ] **G5 — Backward saturates at zero**, never below, and never into
|
||||
the previous track (A1).
|
||||
- [ ] **G6 — Forward past the end lands one second before the end**
|
||||
when the duration is known, so the track finishes through the normal
|
||||
end-of-stream path and the queue advances — the engine never seeks to
|
||||
the exact end (D4). *(test: `seek_target_saturates_near_the_end`.)*
|
||||
- [ ] **G7 — An unknown duration does not clamp forward.** A
|
||||
length-less stream reports duration 0; that must not turn every
|
||||
forward seek into "seek to 0" (the naive clamp) — it must pass the
|
||||
target through and let the decoder refuse it.
|
||||
- [ ] **G8 — The new position is broadcast immediately**, including
|
||||
while **paused** — `tick()` returns early for a paused sink, so the
|
||||
engine emits `Elapsed` from the seek path itself (D6).
|
||||
- [ ] **G9 — Seeking while stopped does nothing** and does not start
|
||||
playback (A4).
|
||||
|
||||
## Wire and plumbing
|
||||
|
||||
- [ ] **G10 — One RPC, relative only**: `Seek(SeekRequest{sint32
|
||||
delta_millis}) -> SeekResponse`, fire-and-forget like the other
|
||||
playback RPCs (D2, A3). No absolute field, no oneof — the deferred
|
||||
extension stays compatible.
|
||||
- [ ] **G11 — The delta is never re-derived client-side.** No client
|
||||
computes a target position from a broadcast `TrackPosition`; grep for
|
||||
arithmetic on `position` in the clients and find none (D1, A2).
|
||||
*(test: `seek_command_forwards_the_delta_unchanged`.)*
|
||||
- [ ] **G12 — The playback loop holds no seek state.** `PlaybackCommand::Seek`
|
||||
forwards to the player and does nothing else — no position cache to go
|
||||
stale.
|
||||
|
||||
## Clients
|
||||
|
||||
- [ ] **G13 — `,`/`.` seek and `<`/`>` skip tracks, global in both
|
||||
clients**, colliding with nothing in any scope, with the TUI's
|
||||
binding-table uniqueness invariant intact and the shifted forms
|
||||
resolving whether or not the terminal reports `SHIFT`. Being plain
|
||||
printable characters, none of them can be swallowed by a browser — which
|
||||
`Ctrl-n` is, and which is why `<`/`>` exist. *(tests: the
|
||||
`bindings`/`keymap` tables and their uniqueness tests, extended.)*
|
||||
- [ ] **G18 — A progress-bar click becomes an offset inside the track.**
|
||||
The mapping is a pure function tested on the native target: a click
|
||||
behind the playhead seeks back, the edges are exactly the track's ends, a
|
||||
fraction outside `[0, 1]` is clamped rather than extrapolated, and a
|
||||
duration of 0 (or a non-finite fraction, meaning a zero-width element)
|
||||
declines instead of seeking somewhere arbitrary. *(tests:
|
||||
`a_progress_bar_click_becomes_an_offset`,
|
||||
`a_click_without_a_scale_is_declined`.)*
|
||||
- [ ] **G14 — Every client reaches it**: TUI binding → `MessageFromUi`
|
||||
→ RPC; web binding + `⏪`/`⏩` buttons → RPC; `cbd global seek
|
||||
<SECONDS>` accepting a negative value (D8).
|
||||
- [ ] **G15 — Both help surfaces list it** — the TUI help modal (from
|
||||
`BINDINGS`) and the web help overlay (`HELP`) — so the two tables stay
|
||||
in lockstep with dispatch, which their tests enforce.
|
||||
|
||||
## Documentation
|
||||
|
||||
- [ ] **G16 — Documented where a user looks**: the key tables in
|
||||
`docs/src/clients/tui.md` and the web page, the CLI page's `global`
|
||||
list, and a sentence in `docs/src/queue.md` saying seek is
|
||||
within-track, that a forward seek past the end advances, and that
|
||||
SoundCloud's HLS streams cannot seek.
|
||||
- [ ] **G17 — Clippy clean under `-D warnings`** across the feature
|
||||
matrix (`check-features`), fmt clean, and the wasm target builds — the
|
||||
web client's `mod app` is `#[cfg(target_arch = "wasm32")]` and native
|
||||
clippy does not see it.
|
||||
|
|
@ -74,7 +74,7 @@ url = "https://feeds.example.org/cautionary-tales"
|
|||
# Optional, defaults shown.
|
||||
# episodes_per_feed = 200 # episodes listed per feed
|
||||
# call_timeout_secs = 30 # per-request timeout
|
||||
# max_feed_bytes = 33554432 # 32 MiB cap on a feed body
|
||||
# max_feed_bytes = 8388608 # 8 MiB cap on a feed body
|
||||
```
|
||||
|
||||
A feed entry with no url is skipped with a warning. No feeds at all is fine:
|
||||
|
|
|
|||
215
rssdy/src/api.rs
215
rssdy/src/api.rs
|
|
@ -17,7 +17,6 @@ use std::time::Duration;
|
|||
|
||||
use async_trait::async_trait;
|
||||
use thiserror::Error;
|
||||
use tracing::warn;
|
||||
|
||||
/// A typed feed failure. Carries only non-secret context — never a feed URL,
|
||||
/// which would put a subscriber token in the logs.
|
||||
|
|
@ -175,7 +174,6 @@ fn episodes_from(
|
|||
limit: usize,
|
||||
durations: &HashMap<String, u32>,
|
||||
) -> Vec<Episode> {
|
||||
let entry_count = entries.len();
|
||||
let mut episodes: Vec<Episode> = entries
|
||||
.into_iter()
|
||||
.filter_map(|entry| {
|
||||
|
|
@ -220,106 +218,36 @@ fn episodes_from(
|
|||
// sorting newest first.
|
||||
episodes.sort_by_key(|e| std::cmp::Reverse(e.published));
|
||||
episodes.truncate(limit);
|
||||
// A feed full of entries and empty of audio is almost always a blog feed
|
||||
// subscribed by mistake (its enclosures are the posts' featured images).
|
||||
// Say so: the listing itself can only show nothing, which explains nothing.
|
||||
// No URL in the message — a feed URL is a credential.
|
||||
if episodes.is_empty() && entry_count > 0 {
|
||||
warn!(
|
||||
entries = entry_count,
|
||||
"feed has entries but none carry playable audio; this looks like a blog feed rather \
|
||||
than a podcast feed"
|
||||
);
|
||||
}
|
||||
episodes
|
||||
}
|
||||
|
||||
/// Media types that some feeds use for audio files without saying so.
|
||||
const GENERIC_TYPES: [&str; 2] = ["application/octet-stream", "binary/octet-stream"];
|
||||
|
||||
/// File extensions we treat as audio when the declared type is generic or
|
||||
/// missing. Deliberately excludes `.mp4`, which is as often video as audio.
|
||||
const AUDIO_EXTENSIONS: [&str; 13] = [
|
||||
"mp3", "m4a", "m4b", "aac", "ogg", "oga", "opus", "flac", "wav", "aiff", "aif", "mp2", "mpga",
|
||||
];
|
||||
|
||||
/// Whether a declared media type names audio.
|
||||
fn is_audio_type(content_type: &str) -> bool {
|
||||
let normalized = content_type.trim().to_ascii_lowercase();
|
||||
normalized.starts_with("audio/") || normalized == "audio"
|
||||
}
|
||||
|
||||
/// Whether an enclosure is plausibly something we can decode.
|
||||
///
|
||||
/// The permissive cases exist because real podcast feeds are sloppy: plenty
|
||||
/// omit the `type` attribute entirely, and a few serve mp3s as
|
||||
/// `application/octet-stream`. So a missing type is accepted, and a generic one
|
||||
/// is accepted when the URL's extension backs it up.
|
||||
///
|
||||
/// What is *not* accepted is a type that names something else. Blog feeds —
|
||||
/// WordPress ones especially — attach the post's featured **image** as an
|
||||
/// `<enclosure>`, which is structurally identical to a podcast enclosure and
|
||||
/// distinguishable only by its type. Accepting those turned every blog post
|
||||
/// into an episode that failed to decode at play time.
|
||||
fn plausibly_audio(content_type: Option<&str>, url: &str) -> bool {
|
||||
match content_type {
|
||||
None => true,
|
||||
Some(ct) if is_audio_type(ct) => true,
|
||||
Some(ct) => {
|
||||
let normalized = ct.trim().to_ascii_lowercase();
|
||||
GENERIC_TYPES.contains(&normalized.as_str()) && has_audio_extension(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the URL's path ends in a known audio extension. Query strings and
|
||||
/// fragments are ignored — enclosure URLs routinely carry tracking parameters.
|
||||
fn has_audio_extension(url: &str) -> bool {
|
||||
let path = url
|
||||
.split(['?', '#'])
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.trim_end_matches('/');
|
||||
let Some((_, extension)) = path.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
let extension = extension.to_ascii_lowercase();
|
||||
AUDIO_EXTENSIONS.contains(&extension.as_str())
|
||||
}
|
||||
|
||||
/// The playable audio URL of an entry: a media enclosure, else a link that
|
||||
/// advertises audio (feed dialects disagree about where it goes).
|
||||
///
|
||||
/// An entry whose only enclosures are images or video yields `None` and is
|
||||
/// skipped — better an episode that never appears than one that appears and
|
||||
/// cannot be played.
|
||||
/// The first playable audio URL of an entry: a media enclosure, else a link
|
||||
/// that advertises audio (feed dialects disagree about where it goes).
|
||||
fn audio_url(entry: &feed_rs::model::Entry) -> Option<String> {
|
||||
let enclosures: Vec<(Option<String>, String)> = entry
|
||||
.media
|
||||
let media = entry.media.iter().flat_map(|m| m.content.iter());
|
||||
// Prefer something explicitly typed as audio, then any media url at all.
|
||||
let typed = media.clone().find(|c| {
|
||||
c.url.is_some()
|
||||
&& c.content_type
|
||||
.as_ref()
|
||||
.is_some_and(|ct| ct.to_string().starts_with("audio"))
|
||||
});
|
||||
if let Some(url) = typed.and_then(|c| c.url.as_ref()) {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
if let Some(url) = media.filter_map(|c| c.url.as_ref()).next() {
|
||||
return Some(url.to_string());
|
||||
}
|
||||
entry
|
||||
.links
|
||||
.iter()
|
||||
.flat_map(|m| m.content.iter())
|
||||
.filter_map(|content| {
|
||||
let url = content.url.as_ref()?.to_string();
|
||||
Some((content.content_type.as_ref().map(ToString::to_string), url))
|
||||
.find(|l| {
|
||||
l.rel.as_deref() == Some("enclosure")
|
||||
|| l.media_type
|
||||
.as_deref()
|
||||
.is_some_and(|ct| ct.starts_with("audio"))
|
||||
})
|
||||
.chain(entry.links.iter().filter_map(|link| {
|
||||
let candidate = link.rel.as_deref() == Some("enclosure")
|
||||
|| link.media_type.as_deref().is_some_and(is_audio_type);
|
||||
candidate.then(|| (link.media_type.clone(), link.href.clone()))
|
||||
}))
|
||||
.collect();
|
||||
// An explicitly audio-typed enclosure wins; a video podcast that also
|
||||
// ships an audio version therefore plays as audio. Only then do the
|
||||
// permissive cases (no type, generic type) get a turn.
|
||||
enclosures
|
||||
.iter()
|
||||
.find(|(content_type, _)| content_type.as_deref().is_some_and(is_audio_type))
|
||||
.or_else(|| {
|
||||
enclosures
|
||||
.iter()
|
||||
.find(|(content_type, url)| plausibly_audio(content_type.as_deref(), url))
|
||||
})
|
||||
.map(|(_, url)| url.clone())
|
||||
.map(|l| l.href.clone())
|
||||
}
|
||||
|
||||
/// `itunes:duration` (or a media duration) in whole seconds.
|
||||
|
|
@ -452,101 +380,6 @@ mod tests {
|
|||
assert!(!found.contains_key("no-duration"));
|
||||
}
|
||||
|
||||
/// Parses an RSS body the way [`FeedFetcher::fetch`] does and returns the
|
||||
/// episodes, so these tests exercise the real feed-rs entry shapes rather
|
||||
/// than hand-built model structs.
|
||||
fn episodes_of(body: &str) -> Vec<Episode> {
|
||||
let feed = feed_rs::parser::parse(body.as_bytes()).expect("parses");
|
||||
episodes_from(feed.entries, 100, &HashMap::new())
|
||||
}
|
||||
|
||||
/// The netzpolitik.org case: a WordPress blog feed whose `<enclosure>` is
|
||||
/// the post's featured **image**. Accepting it produced episodes that
|
||||
/// failed to decode at play time ("format of the data has not been
|
||||
/// recognized"), which is worse than not listing them at all.
|
||||
#[test]
|
||||
fn image_enclosures_are_not_episodes() {
|
||||
let episodes = episodes_of(
|
||||
r#"<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>A blog</title>
|
||||
<item>
|
||||
<title>A post</title>
|
||||
<guid>post-1</guid>
|
||||
<enclosure url="https://cdn.example/featured-scaled.jpg"
|
||||
length="272428" type="image/jpeg" />
|
||||
</item>
|
||||
</channel></rss>"#,
|
||||
);
|
||||
assert!(
|
||||
episodes.is_empty(),
|
||||
"an image enclosure is not a playable episode"
|
||||
);
|
||||
}
|
||||
|
||||
/// A real podcast entry still resolves, and an entry that ships both an
|
||||
/// image and audio picks the audio.
|
||||
#[test]
|
||||
fn audio_enclosures_win_over_other_media() {
|
||||
let episodes = episodes_of(
|
||||
r#"<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>A podcast</title>
|
||||
<item>
|
||||
<title>Episode</title>
|
||||
<guid>ep-1</guid>
|
||||
<enclosure url="https://cdn.example/cover.jpg" type="image/jpeg" />
|
||||
<enclosure url="https://cdn.example/ep1.mp3" type="audio/mpeg" />
|
||||
</item>
|
||||
</channel></rss>"#,
|
||||
);
|
||||
assert_eq!(episodes.len(), 1);
|
||||
assert_eq!(episodes[0].enclosure_url, "https://cdn.example/ep1.mp3");
|
||||
}
|
||||
|
||||
/// Sloppy-but-real feeds: no `type` at all, or a generic one backed by the
|
||||
/// extension. Both stay playable — the decoder is the final judge.
|
||||
#[test]
|
||||
fn untyped_and_generic_enclosures_are_accepted() {
|
||||
let episodes = episodes_of(
|
||||
r#"<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>A podcast</title>
|
||||
<item><guid>a</guid>
|
||||
<enclosure url="https://cdn.example/a.mp3" /></item>
|
||||
<item><guid>b</guid>
|
||||
<enclosure url="https://cdn.example/b.mp3?tk=X"
|
||||
type="application/octet-stream" /></item>
|
||||
</channel></rss>"#,
|
||||
);
|
||||
assert_eq!(episodes.len(), 2);
|
||||
}
|
||||
|
||||
/// A generic type with no audible extension is not taken on faith, and
|
||||
/// video enclosures are left alone.
|
||||
#[test]
|
||||
fn generic_and_video_enclosures_without_audio_are_skipped() {
|
||||
let episodes = episodes_of(
|
||||
r#"<?xml version="1.0"?><rss version="2.0"><channel>
|
||||
<title>Mixed</title>
|
||||
<item><guid>a</guid>
|
||||
<enclosure url="https://cdn.example/a.bin"
|
||||
type="application/octet-stream" /></item>
|
||||
<item><guid>b</guid>
|
||||
<enclosure url="https://cdn.example/b.mp4" type="video/mp4" /></item>
|
||||
</channel></rss>"#,
|
||||
);
|
||||
assert!(episodes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_extensions_ignore_query_strings() {
|
||||
assert!(has_audio_extension("https://cdn.example/a.mp3"));
|
||||
assert!(has_audio_extension("https://cdn.example/a.MP3?tk=X#f"));
|
||||
assert!(has_audio_extension("https://cdn.example/a.opus?x=.jpg"));
|
||||
assert!(!has_audio_extension("https://cdn.example/a.jpg"));
|
||||
assert!(!has_audio_extension("https://cdn.example/a.mp4"));
|
||||
assert!(!has_audio_extension("https://cdn.example/no-extension"));
|
||||
assert!(!has_audio_extension(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_text_handles_cdata_and_attributes() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -37,15 +37,9 @@ pub const PROVIDER_ROOT: &str = "/rss";
|
|||
pub const DEFAULT_EPISODES_PER_FEED: usize = 200;
|
||||
/// Default per-request timeout in seconds.
|
||||
pub const DEFAULT_CALL_TIMEOUT_SECS: u64 = 30;
|
||||
/// Default cap on a feed body, in bytes (32 MiB) — the bound that keeps a
|
||||
/// runaway response from being read into memory (D6).
|
||||
///
|
||||
/// Generous on purpose: podcast feeds routinely carry their *whole* back
|
||||
/// catalogue with full show notes in every item. Logbuch:Netzpolitik, 559
|
||||
/// episodes in, is 6.8 MiB — so the first cap of 8 MiB would have started
|
||||
/// refusing real, healthy feeds within a year or two of publishing. Lower it in
|
||||
/// `rss.toml` (`max_feed_bytes`) if a tighter bound is wanted.
|
||||
pub const DEFAULT_MAX_FEED_BYTES: u64 = 32 * 1024 * 1024;
|
||||
/// Default cap on a feed body, in bytes (8 MiB). A feed larger than this is a
|
||||
/// publishing bug, not something to load into memory (D6).
|
||||
pub const DEFAULT_MAX_FEED_BYTES: u64 = 8 * 1024 * 1024;
|
||||
/// How many feeds' episode lists the memo keeps (D3).
|
||||
pub const MEMO_CAPACITY: usize = 8;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue