seek: put seek and track-skip on two keys, and make the web gauge clickable

Two physical keys now carry all four moves in both clients: `,`/`.` seek 15
seconds, and their shifted forms `<`/`>` skip a whole track. Self-teaching
(same key, shift = bigger jump), and `<`/`>` are the marks engraved on them.

The reason it is these keys and not control chords: they are plain printable
characters, so nothing a browser reserves can swallow them. Ctrl-n — the
long-standing next-track chord — cannot be claimed in a browser at all,
because Chrome and Firefox handle it as "new window" above the page where
preventDefault cannot reach (unlike Ctrl-f, Ctrl-p or Ctrl-b, which the
keydown handler does claim). So the web client had no working next-track key.
Ctrl-n/Ctrl-p stay bound as the terminal's primary chords; the web help
documents the pair that always works.

Click-to-seek on the web progress bar comes with it, and needed no new rpc:
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 lives in state.rs as a pure function, so it is tested on the
native target rather than only in a browser: 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. Geometry comes from current_target, since the click may land on
the fill rather than the track.

Also enables the web-sys DomRect feature, without which
Element::get_bounding_client_rect does not exist — caught only by the wasm
build, since cbd-web's `mod app` is cfg'd to wasm32 and native clippy never
sees it.

Verified: 119 cbd-tui, 24 cbd-web (3 new), workspace clippy clean under
-D warnings, fmt clean, wasm bundle and book build. Not exercised: an actual
click in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Test User 2026-07-26 14:29:30 +02:00
parent 956a490606
commit 0d08c765a1
12 changed files with 283 additions and 87 deletions

View File

@ -22,8 +22,8 @@ Goal: a step of about 15 seconds forward and backward, from every client.
Stated explicitly and settled by reading the code rather than by asking: 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 - **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` is the 0, it does not step into the previous track; `<` / `Ctrl-p` are the
track-level control and stays that way. Crossing tracks would need 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 queue lock and the previous track's duration, for a gesture nobody
expects to do that. expects to do that.
- **A2 — The server owns the position.** Clients render `TrackPosition` - **A2 — The server owns the position.** Clients render `TrackPosition`
@ -49,8 +49,8 @@ position.
received, adds ±15 s, clamps against the duration it was told, and sends received, adds ±15 s, clamps against the duration it was told, and sends
an absolute target. an absolute target.
- The wire is a plain absolute seek, which a click-on-the-progress-bar - The wire is a plain absolute seek, which a click on the progress bar
gesture also wants. 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 - 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 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 problem — **repetition** is. Press `.` three times quickly and all
@ -76,8 +76,8 @@ position, clamps, and seeks.
- Clamping policy lives in one place, next to the duration the engine - Clamping policy lives in one place, next to the duration the engine
already tracks. already tracks.
- Clients get simpler, not more complex: a constant and one RPC call. - Clients get simpler, not more complex: a constant and one RPC call.
- Cost: absolute seek is not on the wire. It is a compatible proto3 - Cost: absolute seek is not on the wire. A compatible proto3 addition if
addition when a click-to-seek gauge wants it (deferred, below). something ever needs it; click-to-seek did not (D10).
**Decision: Option B.** The composition argument decides it — Option A is **Decision: Option B.** The composition argument decides it — Option A is
wrong in the ordinary case of pressing the key twice. wrong in the ordinary case of pressing the key twice.
@ -124,23 +124,47 @@ wrong in the ordinary case of pressing the key twice.
they never moved it (A2). Consistent with how `pause` and they never moved it (A2). Consistent with how `pause` and
`set_volume` failures are handled — no new status codes, no new `set_volume` failures are handled — no new status codes, no new
client-side error path. client-side error path.
- **D8 — Bindings: `Ctrl-b` back, `Ctrl-f` forward**, in the **global** - **D8 — Bindings: two physical keys carry all four moves.** `,`/`.` seek
scope of both the TUI and the web client (the user's choice; an earlier 15 seconds back/forward and their shifted forms `<`/`>` skip a whole
round used `,`/`.`). They join the existing control-chord family for track, in the **global** scope of both clients. The pair is
playback and movement — `Ctrl-n`/`Ctrl-p` for tracks, `Ctrl-d`/`Ctrl-u` self-teaching (same key, shift = bigger jump), `<`/`>` are the marks
for paging — and read as vim's back/forward-a-screen pair. Neither chord engraved on those keys, and they match mpv's playlist controls. Settled
was bound in any scope of either client, and plain `f` (spectrum) is after two rounds: `,`/`.`, then `Ctrl-b`/`Ctrl-f` at the user's request,
untouched because the TUI's `lookup` compares every modifier except then back once `<`/`>` earned their place for a separate reason (below).
`SHIFT` exactly. In the browser, `Ctrl-f` would otherwise open the find
bar; the web keydown handler already calls `prevent_default` on any The deciding property is that these are **plain printable characters**,
chord that resolves to an action, so binding it is enough to claim it. so they collide with nothing a browser reserves. `Ctrl-n` — the
The web transport bar also gets `⏪`/`⏩` buttons, and the CLI gets long-standing "next track" — cannot be claimed in a browser at all:
`cbd global seek <SECONDS>`, which accepts negatives. 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 - **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 arm; it carries no dependency of its own, so by the rule in
`architecture/build-features.md` ("a feature must pay for itself in `architecture/build-features.md` ("a feature must pay for itself in
dependencies") it does not earn one. 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 ## Structure
```d2 ```d2
@ -215,9 +239,10 @@ the only place that knows the live sink position and the track duration.
Recorded, not dropped: Recorded, not dropped:
- **Absolute seek** (`position_millis`) and a click-to-seek progress - **Absolute seek** (`position_millis`). Click-to-seek shipped without it
gauge in the web client. The gauge is already rendered; only the wire (D10), so the only remaining use would be a client that wants to name a
field and a click handler are missing. A compatible proto3 addition. 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 - **Configurable step size** in the client configs, and a larger step on
`<`/`>` (same physical keys, shifted). Client-only once wanted. `<`/`>` (same physical keys, shifted). Client-only once wanted.
- **Chapter-aware seek** for podcasts and audiobooks. No provider exposes - **Chapter-aware seek** for podcasts and audiobooks. No provider exposes

View File

@ -237,20 +237,39 @@ pub const BINDINGS: &[Binding] = &[
action: Action::PrevTrack, action: Action::PrevTrack,
description: "Previous track", 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 { Binding {
scope: Scope::Global, scope: Scope::Global,
mods: KeyModifiers::CONTROL, mods: KeyModifiers::NONE,
code: KeyCode::Char('b'), code: KeyCode::Char(','),
action: Action::SeekBackward, action: Action::SeekBackward,
description: "Seek back 15 seconds", description: "Seek back 15 seconds",
}, },
Binding { Binding {
scope: Scope::Global, scope: Scope::Global,
mods: KeyModifiers::CONTROL, mods: KeyModifiers::NONE,
code: KeyCode::Char('f'), code: KeyCode::Char('.'),
action: Action::SeekForward, action: Action::SeekForward,
description: "Seek forward 15 seconds", 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 { Binding {
scope: Scope::Global, scope: Scope::Global,
mods: KeyModifiers::NONE, mods: KeyModifiers::NONE,
@ -880,28 +899,34 @@ mod tests {
); );
} }
/// Seek rides the control chords in every focus, and taking `Ctrl-f` must /// Two physical keys carry all four moves in every focus: unshifted seeks
/// not disturb plain `f` — the modifier comparison is exact for anything /// 15 seconds, shifted skips a whole track. The shifted forms must resolve
/// but `SHIFT`. /// whether or not the terminal reports `SHIFT` (they are `Char` codes).
#[test] #[test]
fn seek_is_on_the_control_chords_in_any_focus() { fn seek_and_skip_share_two_keys_in_any_focus() {
for focus in [UiFocus::Library, UiFocus::Queue] { for focus in [UiFocus::Library, UiFocus::Queue] {
assert_eq!( assert_eq!(
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::CONTROL)), lookup(focus, false, key(KeyCode::Char(','), KeyModifiers::NONE)),
Some(Action::SeekForward)
);
assert_eq!(
lookup(focus, false, key(KeyCode::Char('b'), KeyModifiers::CONTROL)),
Some(Action::SeekBackward) Some(Action::SeekBackward)
); );
// Unmodified, these keep their old meanings (or none at all).
assert_eq!( assert_eq!(
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)), lookup(focus, false, key(KeyCode::Char('.'), KeyModifiers::NONE)),
Some(Action::ToggleSpectrum) 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!( assert_eq!(
lookup(focus, false, key(KeyCode::Char('b'), KeyModifiers::NONE)), lookup(focus, false, key(KeyCode::Char('n'), KeyModifiers::CONTROL)),
None Some(Action::NextTrack)
); );
} }
// And the help modal still swallows them. // And the help modal still swallows them.
@ -909,7 +934,7 @@ mod tests {
lookup( lookup(
UiFocus::Library, UiFocus::Library,
true, true,
key(KeyCode::Char('f'), KeyModifiers::CONTROL) key(KeyCode::Char('.'), KeyModifiers::NONE)
), ),
None None
); );

View File

@ -20,6 +20,10 @@ wasm-bindgen.workspace = true
wasm-bindgen-futures.workspace = true wasm-bindgen-futures.workspace = true
web-sys = { workspace = true, features = [ web-sys = { workspace = true, features = [
"Document", "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", "Element",
"HtmlInputElement", "HtmlInputElement",
"KeyboardEvent", "KeyboardEvent",

View File

@ -15,8 +15,8 @@ use leptos::task::spawn_local;
use crate::keymap::{self, Action}; use crate::keymap::{self, Action};
use crate::rpc::Rpc; use crate::rpc::Rpc;
use crate::state::{ use crate::state::{
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane, format_seconds, is_cacheable, seek_offset_for_fraction, track_label, CaptureBoard, Dialog,
NamePurpose, QueueCursor, Register, UiItemKind, Focus, LibraryPane, NamePurpose, QueueCursor, Register, UiItemKind,
}; };
const VOLUME_STEP: f32 = 0.1; const VOLUME_STEP: f32 = 0.1;
@ -1151,6 +1151,32 @@ fn Transport(store: Store) -> impl IntoView {
PlayState::Loading => "", 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| { let on_volume = move |ev: leptos::ev::Event| {
if let Ok(target) = event_target_value(&ev).parse::<f32>() { if let Ok(target) = event_target_value(&ev).parse::<f32>() {
let delta = target - store.volume.get_untracked(); let delta = target - store.volume.get_untracked();
@ -1160,15 +1186,15 @@ fn Transport(store: Store) -> impl IntoView {
view! { view! {
<footer class="transport"> <footer class="transport">
<div class="controls"> <div class="controls">
<button class="ghost" title="previous (Ctrl-p)" <button class="ghost" title="previous track (<)"
on:click=move |_| store.dispatch(Action::PrevTrack)>""</button> on:click=move |_| store.dispatch(Action::PrevTrack)>""</button>
<button class="ghost" title="back 15 seconds (Ctrl-b)" <button class="ghost" title="back 15 seconds (,)"
on:click=move |_| store.dispatch(Action::SeekBackward)>""</button> on:click=move |_| store.dispatch(Action::SeekBackward)>""</button>
<button class="ghost big" title="play/pause (Space)" <button class="ghost big" title="play/pause (Space)"
on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button> on:click=move |_| store.dispatch(Action::TogglePlay)>{state_symbol}</button>
<button class="ghost" title="forward 15 seconds (Ctrl-f)" <button class="ghost" title="forward 15 seconds (.)"
on:click=move |_| store.dispatch(Action::SeekForward)>""</button> on:click=move |_| store.dispatch(Action::SeekForward)>""</button>
<button class="ghost" title="next (Ctrl-n)" <button class="ghost" title="next track (>)"
on:click=move |_| store.dispatch(Action::NextTrack)>""</button> on:click=move |_| store.dispatch(Action::NextTrack)>""</button>
<button class="ghost" title="restart track (r)" <button class="ghost" title="restart track (r)"
on:click=move |_| store.dispatch(Action::RestartTrack)>""</button> on:click=move |_| store.dispatch(Action::RestartTrack)>""</button>
@ -1206,7 +1232,7 @@ fn Transport(store: Store) -> impl IntoView {
<span class="time"> <span class="time">
{move || format_seconds(store.position.get().position / 1000)} {move || format_seconds(store.position.get().position / 1000)}
</span> </span>
<div class="gauge"> <div class="gauge" title="click to seek" on:click=on_seek>
<div <div
class="gauge-fill" class="gauge-fill"
style:width=move || { style:width=move || {

View File

@ -98,7 +98,7 @@ pub const HELP: &[HelpEntry] = &[
}, },
HelpEntry { HelpEntry {
scope: "Global", scope: "Global",
key: "Ctrl-b / Ctrl-f", key: ", / .",
description: "Seek back / forward 15 seconds", description: "Seek back / forward 15 seconds",
}, },
HelpEntry { HelpEntry {
@ -128,13 +128,8 @@ pub const HELP: &[HelpEntry] = &[
}, },
HelpEntry { HelpEntry {
scope: "Global", scope: "Global",
key: "Ctrl-n", key: "< / >",
description: "Next track", description: "Previous / next track",
},
HelpEntry {
scope: "Global",
key: "Ctrl-p",
description: "Previous track",
}, },
HelpEntry { HelpEntry {
scope: "Library", scope: "Library",
@ -299,6 +294,10 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
} }
if ctrl { if ctrl {
return match key { 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), "n" => Some(Action::NextTrack),
"p" => Some(Action::PrevTrack), "p" => Some(Action::PrevTrack),
"d" => Some(match focus { "d" => Some(match focus {
@ -309,10 +308,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
Focus::Library => Action::LibraryJumpUp, Focus::Library => Action::LibraryJumpUp,
Focus::Queue => Action::QueueJumpUp, Focus::Queue => Action::QueueJumpUp,
}), }),
// The caller `prevent_default`s a resolved chord, so these take
// precedence over the browser's own Ctrl-f / Ctrl-b.
"f" => Some(Action::SeekForward),
"b" => Some(Action::SeekBackward),
_ => None, _ => None,
}; };
} }
@ -321,8 +316,13 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
"Tab" => Some(Action::CycleFocus), "Tab" => Some(Action::CycleFocus),
" " => Some(Action::TogglePlay), " " => Some(Action::TogglePlay),
"r" => Some(Action::RestartTrack), "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::SeekBackward),
"." => Some(Action::SeekForward), "." => Some(Action::SeekForward),
"<" => Some(Action::PrevTrack),
">" => Some(Action::NextTrack),
"K" => Some(Action::VolumeUp), "K" => Some(Action::VolumeUp),
"J" => Some(Action::VolumeDown), "J" => Some(Action::VolumeDown),
"m" => Some(Action::ToggleMute), "m" => Some(Action::ToggleMute),
@ -400,8 +400,8 @@ mod tests {
#[test] #[test]
fn seek_is_on_the_control_chords_in_both_panes() { fn seek_is_on_the_control_chords_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] { for focus in [Focus::Library, Focus::Queue] {
assert_eq!(lookup(focus, false, "f", true), Some(Action::SeekForward)); assert_eq!(lookup(focus, false, ".", false), Some(Action::SeekForward));
assert_eq!(lookup(focus, false, "b", true), Some(Action::SeekBackward)); assert_eq!(lookup(focus, false, ",", false), Some(Action::SeekBackward));
} }
// Unmodified `b` is unbound; unmodified `f` is not seek. // Unmodified `b` is unbound; unmodified `f` is not seek.
assert_eq!(lookup(Focus::Library, false, "b", false), None); assert_eq!(lookup(Focus::Library, false, "b", false), None);
@ -413,6 +413,23 @@ mod tests {
assert_eq!(lookup(Focus::Library, true, "f", true), None); 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] #[test]
fn globals_win_in_both_panes() { fn globals_win_in_both_panes() {
for focus in [Focus::Library, Focus::Queue] { for focus in [Focus::Library, Focus::Queue] {

View File

@ -561,6 +561,37 @@ 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 /// `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. /// clock so minutes zero-pad and roll into hours instead of counting past 60.
pub fn format_seconds(total: u32) -> String { pub fn format_seconds(total: u32) -> String {
@ -617,6 +648,46 @@ 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] #[test]
fn listings_order_tracks_before_children_and_remember_positions() { fn listings_order_tracks_before_children_and_remember_positions() {
let mut pane = LibraryPane::default(); let mut pane = LibraryPane::default();

View File

@ -333,6 +333,10 @@ input {
border-radius: 3px; border-radius: 3px;
background: var(--accent-soft); background: var(--accent-soft);
overflow: hidden; overflow: hidden;
/* Click-to-seek: `manipulation` drops the touch double-tap delay so a
tap seeks immediately. */
cursor: pointer;
touch-action: manipulation;
& .gauge-fill { & .gauge-fill {
block-size: 100%; block-size: 100%;

View File

@ -156,7 +156,7 @@ pane).
| Global | `Tab` | Switch between library and queue | | Global | `Tab` | Switch between library and queue |
| Global | `Space` | Play/pause | | Global | `Space` | Play/pause |
| Global | `r` | Restart current track | | Global | `r` | Restart current track |
| Global | `Ctrl-b`/`Ctrl-f` | Seek back / forward 15 seconds | | Global | `,` / `.` | Seek back / forward 15 seconds |
| Global | `K` | Volume up | | Global | `K` | Volume up |
| Global | `J` | Volume down | | Global | `J` | Volume down |
| Global | `m` | Toggle mute | | Global | `m` | Toggle mute |
@ -164,6 +164,7 @@ pane).
| Global | `x` | Toggle repeat | | Global | `x` | Toggle repeat |
| Global | `Ctrl-n` | Next track | | Global | `Ctrl-n` | Next track |
| Global | `Ctrl-p` | Previous track | | Global | `Ctrl-p` | Previous track |
| Global | `<` / `>` | Previous / next track |
| Global | `f` | Toggle the frequency spectrum | | Global | `f` | Toggle the frequency spectrum |
| Library | `j` / `k` | Select next / previous item | | Library | `j` / `k` | Select next / previous item |
| Library | `g` / `G` | Select first / last item | | Library | `g` / `G` | Select first / last item |

View File

@ -15,7 +15,8 @@ yanks, `d`/`c`/`C` fill it as they remove, and `p`/`P` paste it after or
before the cursor (see [the TUI's register before the cursor (see [the TUI's register
section](./tui.md#the-register-y-d-and-pp), which behaves identically here; 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 — the queue toolbar shows how many entries are waiting). Seek is there too —
`Ctrl-b` and `Ctrl-f` move 15 seconds back and forward, as do the `⏪`/`⏩` `,` and `.` move 15 seconds back and forward (`<` and `>` skip a whole
track), as do the `⏪`/`⏩`
buttons in buttons in
the transport bar. The `/` live filter is TUI-only for now. the transport bar. The `/` live filter is TUI-only for now.

View File

@ -160,26 +160,35 @@ Two consequences worth knowing:
## Seeking inside a track ## Seeking inside a track
`Ctrl-b` and `Ctrl-f` in either client (and `cbd global seek <SECONDS>`) move the `,` and `.` in either client (and `cbd global seek <SECONDS>`) move the playing
playing position by 15 seconds. The wire carries the **offset**, not a position by 15 seconds; the shifted forms of the same two keys, `<` and `>`,
target position: the server adds it to the live position, so pressing the skip a whole track.
key three times moves 45 seconds rather than three times to the same spot —
which is what would happen if a client computed the target from a position The wire carries the **offset**, not a target position: the server adds it to
update that is already up to a tick old. 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: The step stays inside the current track:
- Backwards it saturates at the start. It never steps into the previous - Backwards it saturates at the start. It never steps into the previous track;
track; that is `Ctrl-p`. that is `<`.
- Forwards it stops a second short of the end, so an overshooting seek lets - Forwards it stops a second short of the end, so an overshooting seek lets the
the track run out through the normal end-of-track path — which advances track run out through the normal end-of-track path — which advances the
the queue, obeying shuffle and repeat like any other track change. queue, obeying shuffle and repeat like any other track change.
- A track whose duration the source never reported has no upper bound to - A track whose duration the source never reported has no upper bound to clamp
clamp to, so the decoder decides whether it can honour the seek. to, so the decoder decides whether it can honour the seek.
- **SoundCloud tracks cannot seek at all.** They arrive as HLS playlists, - **SoundCloud tracks cannot seek at all.** They arrive as HLS playlists,
which are decoded as a forward-only stream on purpose; a seek there is decoded as a forward-only stream on purpose; a seek there is logged by the
logged by the server and leaves the position where it was. The position server and leaves the position where it was. The position you see is always
you see is always the truth — clients never move it themselves. 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 ## Persistence

View File

@ -39,13 +39,17 @@ would leave a dead RPC.
## C — Clients ## C — Clients
- [x] **C1 — TUI**: `Action::SeekForward`/`SeekBackward`, `Ctrl-f`/`Ctrl-b` - [x] **C1 — TUI**: `Action::SeekForward`/`SeekBackward`, `,`/`.` bindings
bindings in `Scope::Global` with help text, `MessageFromUi::Seek(i64)`, (plus `<`/`>` for prev/next) in `Scope::Global` with help text,
`MessageFromUi::Seek(i64)`,
the `lib.rs` arm, `rpc.rs` wrapper, `SEEK_STEP` constant, dispatch the `lib.rs` arm, `rpc.rs` wrapper, `SEEK_STEP` constant, dispatch
tests. *Verifies:* G13, G14, G15. tests. *Verifies:* G13, G14, G15.
- [x] **C2 — Web**: the same two actions in `keymap.rs` (global, with - [x] **C2 — Web**: the same two actions in `keymap.rs` (global, with
`HELP` rows), `Rpc::seek`, the `app.rs` dispatch arms, and `⏪`/`⏩` `HELP` rows), `Rpc::seek`, the `app.rs` dispatch arms, and `⏪`/`⏩`
buttons in the transport bar. *Verifies:* G13, G14, G15. 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 - [x] **C3 — CLI**: `GlobalCmd::Seek { seconds: i32 }` with
`allow_negative_numbers`, and its executor arm. *Verifies:* G14. `allow_negative_numbers`, and its executor arm. *Verifies:* G14.

View File

@ -61,12 +61,21 @@ clamping arithmetic, which is where the interesting behaviour is),
## Clients ## Clients
- [ ] **G13 — `Ctrl-b` and `Ctrl-f` are global in both the TUI and the - [ ] **G13 — `,`/`.` seek and `<`/`>` skip tracks, global in both
web client** and collide with nothing in any scope — in particular plain clients**, colliding with nothing in any scope, with the TUI's
`f` still toggles the spectrum, and the TUI's binding-table uniqueness binding-table uniqueness invariant intact and the shifted forms
invariant still holds. In the browser the chord must reach the client resolving whether or not the terminal reports `SHIFT`. Being plain
rather than the find bar. *(tests: the existing `bindings`/`keymap` printable characters, none of them can be swallowed by a browser — which
tables and their uniqueness tests, extended.)* `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` - [ ] **G14 — Every client reaches it**: TUI binding → `MessageFromUi`
→ RPC; web binding + `⏪`/`⏩` buttons → RPC; `cbd global seek → RPC; web binding + `⏪`/`⏩` buttons → RPC; `cbd global seek
<SECONDS>` accepting a negative value (D8). <SECONDS>` accepting a negative value (D8).