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:
parent
956a490606
commit
0d08c765a1
|
|
@ -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:
|
||||
|
||||
- **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
|
||||
track-level control and stays that way. Crossing tracks would need the
|
||||
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`
|
||||
|
|
@ -49,8 +49,8 @@ position.
|
|||
received, adds ±15 s, clamps against the duration it was told, and sends
|
||||
an absolute target.
|
||||
|
||||
- The wire is a plain absolute seek, which a click-on-the-progress-bar
|
||||
gesture also wants.
|
||||
- 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
|
||||
|
|
@ -76,8 +76,8 @@ position, clamps, and seeks.
|
|||
- Clamping policy lives in one place, next to the duration the engine
|
||||
already tracks.
|
||||
- Clients get simpler, not more complex: a constant and one RPC call.
|
||||
- Cost: absolute seek is not on the wire. It is a compatible proto3
|
||||
addition when a click-to-seek gauge wants it (deferred, below).
|
||||
- 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.
|
||||
|
|
@ -124,23 +124,47 @@ wrong in the ordinary case of pressing the key twice.
|
|||
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: `Ctrl-b` back, `Ctrl-f` forward**, in the **global**
|
||||
scope of both the TUI and the web client (the user's choice; an earlier
|
||||
round used `,`/`.`). They join the existing control-chord family for
|
||||
playback and movement — `Ctrl-n`/`Ctrl-p` for tracks, `Ctrl-d`/`Ctrl-u`
|
||||
for paging — and read as vim's back/forward-a-screen pair. Neither chord
|
||||
was bound in any scope of either client, and plain `f` (spectrum) is
|
||||
untouched because the TUI's `lookup` compares every modifier except
|
||||
`SHIFT` exactly. In the browser, `Ctrl-f` would otherwise open the find
|
||||
bar; the web keydown handler already calls `prevent_default` on any
|
||||
chord that resolves to an action, so binding it is enough to claim it.
|
||||
The web transport bar also gets `⏪`/`⏩` buttons, and the CLI gets
|
||||
`cbd global seek <SECONDS>`, which accepts negatives.
|
||||
- **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
|
||||
|
|
@ -215,9 +239,10 @@ the only place that knows the live sink position and the track duration.
|
|||
|
||||
Recorded, not dropped:
|
||||
|
||||
- **Absolute seek** (`position_millis`) and a click-to-seek progress
|
||||
gauge in the web client. The gauge is already rendered; only the wire
|
||||
field and a click handler are missing. A compatible proto3 addition.
|
||||
- **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
|
||||
|
|
|
|||
|
|
@ -237,20 +237,39 @@ 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::CONTROL,
|
||||
code: KeyCode::Char('b'),
|
||||
mods: KeyModifiers::NONE,
|
||||
code: KeyCode::Char(','),
|
||||
action: Action::SeekBackward,
|
||||
description: "Seek back 15 seconds",
|
||||
},
|
||||
Binding {
|
||||
scope: Scope::Global,
|
||||
mods: KeyModifiers::CONTROL,
|
||||
code: KeyCode::Char('f'),
|
||||
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,
|
||||
|
|
@ -880,28 +899,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Seek rides the control chords in every focus, and taking `Ctrl-f` must
|
||||
/// not disturb plain `f` — the modifier comparison is exact for anything
|
||||
/// but `SHIFT`.
|
||||
/// 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_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] {
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::CONTROL)),
|
||||
Some(Action::SeekForward)
|
||||
);
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('b'), KeyModifiers::CONTROL)),
|
||||
lookup(focus, false, key(KeyCode::Char(','), KeyModifiers::NONE)),
|
||||
Some(Action::SeekBackward)
|
||||
);
|
||||
// Unmodified, these keep their old meanings (or none at all).
|
||||
assert_eq!(
|
||||
lookup(focus, false, key(KeyCode::Char('f'), KeyModifiers::NONE)),
|
||||
Some(Action::ToggleSpectrum)
|
||||
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('b'), KeyModifiers::NONE)),
|
||||
None
|
||||
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.
|
||||
|
|
@ -909,7 +934,7 @@ mod tests {
|
|||
lookup(
|
||||
UiFocus::Library,
|
||||
true,
|
||||
key(KeyCode::Char('f'), KeyModifiers::CONTROL)
|
||||
key(KeyCode::Char('.'), KeyModifiers::NONE)
|
||||
),
|
||||
None
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ 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,8 +15,8 @@ use leptos::task::spawn_local;
|
|||
use crate::keymap::{self, Action};
|
||||
use crate::rpc::Rpc;
|
||||
use crate::state::{
|
||||
format_seconds, is_cacheable, track_label, CaptureBoard, Dialog, Focus, LibraryPane,
|
||||
NamePurpose, QueueCursor, Register, UiItemKind,
|
||||
format_seconds, is_cacheable, seek_offset_for_fraction, track_label, CaptureBoard, Dialog,
|
||||
Focus, LibraryPane, NamePurpose, QueueCursor, Register, UiItemKind,
|
||||
};
|
||||
|
||||
const VOLUME_STEP: f32 = 0.1;
|
||||
|
|
@ -1151,6 +1151,32 @@ 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();
|
||||
|
|
@ -1160,15 +1186,15 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
view! {
|
||||
<footer class="transport">
|
||||
<div class="controls">
|
||||
<button class="ghost" title="previous (Ctrl-p)"
|
||||
<button class="ghost" title="previous track (<)"
|
||||
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>
|
||||
<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 (Ctrl-f)"
|
||||
<button class="ghost" title="forward 15 seconds (.)"
|
||||
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>
|
||||
<button class="ghost" title="restart track (r)"
|
||||
on:click=move |_| store.dispatch(Action::RestartTrack)>"↺"</button>
|
||||
|
|
@ -1206,7 +1232,7 @@ fn Transport(store: Store) -> impl IntoView {
|
|||
<span class="time">
|
||||
{move || format_seconds(store.position.get().position / 1000)}
|
||||
</span>
|
||||
<div class="gauge">
|
||||
<div class="gauge" title="click to seek" on:click=on_seek>
|
||||
<div
|
||||
class="gauge-fill"
|
||||
style:width=move || {
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ pub const HELP: &[HelpEntry] = &[
|
|||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-b / Ctrl-f",
|
||||
key: ", / .",
|
||||
description: "Seek back / forward 15 seconds",
|
||||
},
|
||||
HelpEntry {
|
||||
|
|
@ -128,13 +128,8 @@ pub const HELP: &[HelpEntry] = &[
|
|||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-n",
|
||||
description: "Next track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Global",
|
||||
key: "Ctrl-p",
|
||||
description: "Previous track",
|
||||
key: "< / >",
|
||||
description: "Previous / next track",
|
||||
},
|
||||
HelpEntry {
|
||||
scope: "Library",
|
||||
|
|
@ -299,6 +294,10 @@ 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 {
|
||||
|
|
@ -309,10 +308,6 @@ pub fn lookup(focus: Focus, help_open: bool, key: &str, ctrl: bool) -> Option<Ac
|
|||
Focus::Library => Action::LibraryJumpUp,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
|
@ -321,8 +316,13 @@ 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),
|
||||
|
|
@ -400,8 +400,8 @@ mod tests {
|
|||
#[test]
|
||||
fn seek_is_on_the_control_chords_in_both_panes() {
|
||||
for focus in [Focus::Library, Focus::Queue] {
|
||||
assert_eq!(lookup(focus, false, "f", true), Some(Action::SeekForward));
|
||||
assert_eq!(lookup(focus, false, "b", true), Some(Action::SeekBackward));
|
||||
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);
|
||||
|
|
@ -413,6 +413,23 @@ mod tests {
|
|||
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] {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
/// clock so minutes zero-pad and roll into hours instead of counting past 60.
|
||||
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]
|
||||
fn listings_order_tracks_before_children_and_remember_positions() {
|
||||
let mut pane = LibraryPane::default();
|
||||
|
|
|
|||
|
|
@ -333,6 +333,10 @@ 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%;
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ pane).
|
|||
| Global | `Tab` | Switch between library and queue |
|
||||
| Global | `Space` | Play/pause |
|
||||
| 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 | `J` | Volume down |
|
||||
| Global | `m` | Toggle mute |
|
||||
|
|
@ -164,6 +164,7 @@ 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 |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 —
|
||||
`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
|
||||
the transport bar. The `/` live filter is TUI-only for now.
|
||||
|
||||
|
|
|
|||
|
|
@ -160,26 +160,35 @@ Two consequences worth knowing:
|
|||
|
||||
## Seeking inside a track
|
||||
|
||||
`Ctrl-b` and `Ctrl-f` in either client (and `cbd global seek <SECONDS>`) move the
|
||||
playing position by 15 seconds. 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 the target from a position
|
||||
update that is already up to a tick old.
|
||||
`,` 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 `Ctrl-p`.
|
||||
- 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.
|
||||
- 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,
|
||||
which are 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.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -39,13 +39,17 @@ would leave a dead RPC.
|
|||
|
||||
## C — Clients
|
||||
|
||||
- [x] **C1 — TUI**: `Action::SeekForward`/`SeekBackward`, `Ctrl-f`/`Ctrl-b`
|
||||
bindings in `Scope::Global` with help text, `MessageFromUi::Seek(i64)`,
|
||||
- [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.
|
||||
|
||||
|
|
|
|||
|
|
@ -61,12 +61,21 @@ clamping arithmetic, which is where the interesting behaviour is),
|
|||
|
||||
## Clients
|
||||
|
||||
- [ ] **G13 — `Ctrl-b` and `Ctrl-f` are global in both the TUI and the
|
||||
web client** and collide with nothing in any scope — in particular plain
|
||||
`f` still toggles the spectrum, and the TUI's binding-table uniqueness
|
||||
invariant still holds. In the browser the chord must reach the client
|
||||
rather than the find bar. *(tests: the existing `bindings`/`keymap`
|
||||
tables and their uniqueness tests, extended.)*
|
||||
- [ ] **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).
|
||||
|
|
|
|||
Loading…
Reference in New Issue