feat(audio-player): scrub interaction rework — free-flow drag, pixel magnetism, touch pull-away precision (plan §9)

Mouse: drag is free-flow 1:1 (rail only on Shift+drag, now with
window from/to edge labels); clicks land with pixel magnetism
(MAGNET_RADIUS_PX=10) — the old 5-second threshold was sub-pixel on
long books and never fired, which was the 'chapter scrub isn't
accurate' bug. Hover inside the radius locks the tooltip to the line
with a highlight. Shift+click jumps to the nearest line from anywhere.

Touch: drag is an incremental cursor (applyDrag) whose scale tightens
continuously as the finger pulls off the bar (dragScale, exponential
10^(-|dy|/80px)) — the rail is the feedback: window shrinks with the
zoom (touchRailSpan) and lifts toward the finger (railLiftPx).

Dialog retitled 'Shortcuts & interactions' with a Mouse & touch
section driven by POINTER_GUIDE (audio-keys.ts). Old seconds-threshold
snapToChapter deleted. 133 unit tests; 19 Playwright checks on the
KJV (1189ch) and Gardens (34ch) books.
This commit is contained in:
John O'Keefe committed 2026-10-22 13:05:10 -04:00
1 parent 1f30602c23
commit 96fd2aad42
6 files changed
+386 -69

No files matched your search

+30 -9
View File
@@ -91,8 +91,8 @@ templ AudioReader(user User, metadata ReaderMetadata) {
@click="helpOpen = !helpOpen"
class="rounded-full p-2 hover:bg-surface-hover transition-colors"
style="color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);"
aria-label="Keyboard shortcuts"
title="Keyboard shortcuts (?)"
aria-label="Shortcuts & interactions"
title="Shortcuts & interactions (?)"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<rect x="2" y="4" width="20" height="16" rx="2"></rect>
@@ -203,6 +203,14 @@ templ AudioReader(user User, metadata ReaderMetadata) {
></div>
</template>
</div>
<!-- §9 magnet: the line a click would land on (hover
inside the pixel radius locks the tooltip here) -->
<div
x-show="hoverSnapped && !scrubbing"
x-cloak
class="absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-4 w-px rounded-full pointer-events-none"
:style="'left:' + hoverPct + '%;background-color:var(--accent);'"
></div>
<input
id="audio-scrub"
type="range"
@@ -225,14 +233,16 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<span x-text="hoverSeconds !== null ? fmtTime(hoverSeconds) : ''"></span>
<span style="color: var(--text-secondary);" x-text="hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''"></span>
</div>
<!-- Precision rail: while dragging, ±5 minutes at
minutes-per-pixel (floats over the transports,
page never shifts) -->
<!-- Precision rail (§9): Shift+drag on mouse, or a touch
drag pulled off the bar — ±5 min or tighter, free
mapping inside. Edge labels self-document the window
(floats over the transports, page never shifts) -->
<div
x-show="scrubbing"
x-show="railVisible"
x-cloak
class="absolute top-full mt-1 inset-x-0 h-10 rounded-lg shadow-lg p-1.5 pointer-events-none"
class="absolute top-full mt-1 inset-x-0 h-12 rounded-lg shadow-lg p-1.5 pointer-events-none"
style="background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;"
:style="railLiftY ? 'transform: translateY(' + railLiftY + 'px);' : ''"
>
<div class="relative h-full">
<div class="absolute inset-x-0 top-1/2 h-px" style="background-color: var(--border);"></div>
@@ -240,7 +250,9 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<div class="absolute top-1 bottom-1 w-px" :style="'left:' + ((tick.start - railFrom) / Math.max(1, railTo - railFrom) * 100) + '%;background-color:var(--text-secondary);opacity:0.5;'"></div>
</template>
<div class="absolute top-0 bottom-0 w-0.5 -translate-x-1/2 rounded-full" :style="'left:' + railMarkerPct() + '%;background-color:var(--accent);'"></div>
<div class="absolute left-1 -bottom-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-secondary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
<div class="absolute left-1 top-0.5 text-[10px] tabular-nums whitespace-nowrap" style="color: var(--text-primary);" x-text="fmtTime(scrubValue) + ' — ' + chapterTitleAt(scrubValue)"></div>
<div class="absolute left-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railFrom)"></div>
<div class="absolute right-1 bottom-0.5 text-[9px] tabular-nums" style="color: var(--text-secondary);" x-text="fmtTime(railTo)"></div>
</div>
</div>
</div>
@@ -643,7 +655,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
<div x-show="helpOpen" x-cloak x-transition class="fixed inset-0 z-50 grid place-items-center p-4" @click.outside="helpOpen = false">
<div class="w-full max-w-md rounded-2xl shadow-2xl p-4 max-h-[80vh] overflow-y-auto" style="background-color: var(--bg-secondary); border: 1px solid var(--border);">
<div class="flex items-center justify-between mb-3 px-1">
<span class="text-sm font-semibold" style="color: var(--text-primary);">Keyboard shortcuts</span>
<span class="text-sm font-semibold" style="color: var(--text-primary);">Shortcuts &amp; interactions</span>
<button type="button" @click="helpOpen = false" class="rounded-full p-1.5 hover:bg-surface-hover" style="color: var(--text-secondary);" aria-label="Close shortcuts">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 6 6 18"></path>
@@ -661,6 +673,15 @@ templ AudioReader(user User, metadata ReaderMetadata) {
</span>
</div>
</template>
<!-- Pointer gestures (audio-keys.ts POINTER_GUIDE): same
dialog, so every scrub interaction is discoverable -->
<div class="mt-3 mb-1 px-2 text-xs font-semibold uppercase tracking-wide" style="color: var(--text-secondary);">Mouse &amp; touch</div>
<template x-for="row in pointerGuide" :key="row.label">
<div class="flex items-center justify-between gap-3 px-2 py-1.5 rounded-lg">
<span class="text-sm" style="color: var(--text-secondary);" x-text="row.description"></span>
<kbd class="text-xs px-1.5 py-0.5 rounded tabular-nums shrink-0" style="background-color: var(--surface-hover); color: var(--text-primary); border: 1px solid var(--border);" x-text="row.label"></kbd>
</div>
</template>
</div>
</div>