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:
1 parent
1f30602c23
commit
96fd2aad42
6 files changed
+386
-69
No files matched your search
@@ -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 & 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 & 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>
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" 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=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><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\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <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\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @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 (?)\"><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> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><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\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" strokLine truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\"><div class=\"h-full flex flex-col items-center justify-center px-4 gap-6 select-none\"><!-- Header: back to the detail page (reader-style exit) and the\n\t\t\t chapter launcher. Floating drawer, never in-flow (A2b). --><div class=\"absolute top-0 inset-x-0 z-30 flex items-center justify-between px-4 py-3\"><button type=\"button\" @click=\"backToDetail()\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" style=\"color: var(--text-primary);\" aria-label=\"Back to book details\"><svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 12H5\"></path> <path d=\"m12 19-7-7 7-7\"></path></svg></button> <span class=\"inline-flex items-center gap-2\"><button type=\"button\" @click=\"bookmarksOpen = !bookmarksOpen\" 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=\"'Open bookmarks (' + bookmarks.length + ')'\" title=\"Bookmarks\"><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\"><path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z\"></path></svg></button> <button type=\"button\" @click=\"chaptersOpen = !chaptersOpen\" class=\"text-sm font-semibold px-4 py-2 rounded-full hover:bg-surface-hover transition-colors inline-flex items-center gap-1.5\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" :aria-label=\"'Open chapter list (currently chapter ' + (currentChapterIdx + 1) + ' of ' + (chapters.length || 1) + ')'\" title=\"Chapters\"><span>Chapter</span> <span class=\"tabular-nums\" x-text=\"(currentChapterIdx + 1) + '/' + (chapters.length || 1)\">1/1</span> <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\"><path d=\"m6 9 6 6 6-6\"></path></svg></button> <button type=\"button\" @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=\"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> <path d=\"M6 8h.01M10 8h.01M14 8h.01M18 8h.01M6 12h.01M10 12h.01M14 12h.01M18 12h.01M8 16h8\"></path></svg></button><div class=\"relative\" @click.outside=\"settingsOpen = false\"><button type=\"button\" @click=\"settingsOpen = !settingsOpen\" class=\"rounded-full p-2 hover:bg-surface-hover transition-colors\" :class=\"settingsOpen ? 'opacity-100' : 'opacity-90'\" style=\"color: var(--text-primary); border: 1px solid var(--border); background-color: color-mix(in srgb, var(--bg-secondary) 80%, transparent);\" aria-label=\"Player settings\" title=\"Settings\"><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\"><path d=\"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z\"></path> <circle cx=\"12\" cy=\"12\" r=\"3\"></circle></svg></button><div x-show=\"settingsOpen\" x-cloak x-transition class=\"absolute right-0 top-full mt-2 w-72 rounded-xl shadow-2xl p-3 z-50\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border);\"><div class=\"flex items-center justify-between mb-2 px-1\"><span class=\"text-sm font-semibold\" style=\"color: var(--text-primary);\">Player settings</span> <button type=\"button\" @click=\"settingsOpen = false\" class=\"rounded-full p-1.5 hover:bg-surface-hover\" style=\"color: var(--text-secondary);\" aria-label=\"Close settings\"><svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
@@ -194,7 +194,7 @@ func AudioReader(user User, metadata ReaderMetadata) templ.Component {
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><input id=\"audio-scrub\" type=\"range\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><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\n\t\t\t\t\t\t minutes-per-pixel (floats over the transports,\n\t\t\t\t\t\t page never shifts) --><div x-show=\"scrubbing\" x-cloak class=\"absolute top-full mt-1 inset-x-0 h-10 rounded-lg shadow-lg p-1.5 pointer-events-none\" style=\"background-color: var(--bg-secondary); border: 1px solid var(--border); z-index: 30;\"><div class=\"relative h-full\"><div class=\"absolute inset-x-0 top-1/2 h-px\" style=\"background-color: var(--border);\"></div><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><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></div></div><span class=\"inline-block min-w-[4.75rem] text-right\" x-text=\"fmtTime(chaptersDuration() || 1)\">0:00</span></div><div class=\"flex items-center justify-center gap-3 md:gap-5\"><button type=\"button\" @click=\"stepChapter(-1)\" class=\"rounded-full p-2.5 hover:bg-surface-hover transition-colors\" aria-label=\"Previous chapter\" title=\"Previous chapter\" style=\"color: var(--text-primary);\"><svg width=\"22\" height=\"22\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"1.75\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\"><path d=\"M19 20 9 12l10-8v16z\"></path> <path d=\"M5 19V5\"></path></svg></button> <button type=\"button\" @click=\"skip(-1)\" class=\"relative rounded-full h-14 w-14 grid place-items-center hover:bg-surface-hover transition-colors\" :aria-Line truncated
|
||||
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</h1><p class=\"text-sm mt-0.5 truncate\" style=\"color: var(--text-secondary);\" x-text=\"chapterTitle()\">—</p></div></div><!-- Scrubber + transports --><div class=\"w-full max-w-xl flex flex-col gap-2\"><div class=\"flex items-center gap-2 text-xs tabular-nums\" style=\"color: var(--text-secondary);\"><span class=\"inline-block min-w-[4.75rem]\" x-text=\"fmtTime(scrubbing ? scrubValue : currentTime)\">0:00</span><div id=\"scrub-area\" class=\"relative flex-1 h-6 flex items-center cursor-pointer\" style=\"touch-action: none;\" @pointerdown=\"scrubPointerDown($event)\" @pointermove=\"scrubPointerMove($event)\" @pointerup=\"scrubPointerUp($event)\" @pointercancel=\"scrubPointerUp($event)\" @pointerleave=\"scrubPointerLeave()\" @wheel.prevent=\"onScrubWheel($event)\"><!-- Base track --><div class=\"absolute inset-x-0 top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" style=\"background-color: var(--surface-hover);\"></div><!-- Elapsed fill --><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:0;width:' + scrubFillPct() + '%;background-color:var(--accent);'\"></div><!-- Buffered span: the ACTIVE track's element buffer only --><template x-if=\"bufferSpan()\"><div class=\"absolute top-1/2 -translate-y-1/2 h-1.5 rounded-full pointer-events-none\" :style=\"'left:' + bufferSpan().left + '%;width:' + bufferSpan().width + '%;background-color:var(--accent-muted);'\"></div></template><!-- Chapter ticks: density-decimated, current chapter tinted --><div id=\"scrub-ticks\" class=\"absolute inset-0 pointer-events-none\"><template x-for=\"idx in tickIndices\" :key=\"'tick' + idx\"><div class=\"absolute top-1/2 -translate-x-1/2 -translate-y-1/2 h-2 w-0.5 rounded-full\" :class=\"currentChapterIdx === idx ? 'opacity-100' : 'opacity-40'\" :style=\"'left:' + (chapters[idx].start / (chaptersDuration() || 1) * 100) + '%;background-color:' + (currentChapterIdx === idx ? 'var(--accent)' : 'var(--text-secondary)') + ';'\"></div></template></div><!-- §9 magnet: the line a click would land on (hover\n\t\t\t\t\t\t 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\" min=\"0\" step=\"0.1\" :max=\"chaptersDuration() || 1\" :value=\"scrubbing ? scrubValue : currentTime\" class=\"relative w-full\" @input=\"scrubbing = true; scrubValue = Number($event.target.value)\" @change=\"seekTo(scrubValue); scrubbing = false\" aria-label=\"Seek position\"><!-- Hover tooltip: time + chapter under the pointer --><div x-show=\"hoverSeconds !== null && !scrubbing\" x-cloak class=\"absolute bottom-full mb-1 -translate-x-1/2 whitespace-nowrap rounded-md px-2 py-1 text-[11px] shadow-lg pointer-events-none\" :style=\"'left:' + hoverPct + '%;background-color:var(--bg-secondary);border:1px solid var(--border);color:var(--text-primary);'\"><span x-text=\"hoverSeconds !== null ? fmtTime(hoverSeconds) : ''\"></span> <span style=\"color: var(--text-secondary);\" x-text=\"hoverSeconds !== null ? ' — ' + chapterTitleAt(hoverSeconds) : ''\"></span></div><!-- Precision rail (§9): Shift+drag on mouse, or a touch\n\t\t\t\t\t\t drag pulled off the bar — ±5 min or tighter, free\n\t\t\t\t\t\t mapping inside. Edge labels self-document the window\n\t\t\t\t\t\t (floats over the transports, page never shifts) --><div x-show=\"railVisible\" x-cloak 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><template x-for=\"tick in railTickList\" :key=\"'rtick' + tick.start\"><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 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><span claLine truncated
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ_7745c5c3_Err
|
||||
}
|
||||
|
||||
@@ -213,6 +213,26 @@ export function visibleKeymap(): KeyActionSpec[] {
|
||||
return KEYMAP.filter((spec) => !spec.hidden);
|
||||
}
|
||||
|
||||
// Pointer/wheel/touch interactions (plan §9) are not key events — the
|
||||
// dispatcher never sees them — but they belong in the same dialog so
|
||||
// every scrub gesture is discoverable in one place. templ x-fors this
|
||||
// table through the component's `pointerGuide` property (same binding
|
||||
// pattern as speeds/skipChoices).
|
||||
export interface PointerGuideRow {
|
||||
// Badge label, e.g. "Shift + drag".
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const POINTER_GUIDE: readonly PointerGuideRow[] = [
|
||||
{ label: "Click", description: "Seek — lands on a chapter line within a pixel's reach" },
|
||||
{ label: "Drag", description: "Free scrub across the whole bar" },
|
||||
{ label: "Shift + click", description: "Jump to the nearest chapter line" },
|
||||
{ label: "Shift + drag", description: "Precision rail (±5 min) around the nearest line" },
|
||||
{ label: "Wheel", description: "Nudge ±5s (Shift: ±60s)" },
|
||||
{ label: "Touch + pull away", description: "Scrub, then drag off the bar for fine control" },
|
||||
];
|
||||
|
||||
// resolveShortcut maps a keydown's (key, altKey) to an action, or null
|
||||
// when nothing claims it. Single characters match case-insensitively;
|
||||
// Alt-qualified bindings take precedence order for free: a plain key
|
||||
|
||||
+137
-14
@@ -1,16 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
applyDrag,
|
||||
bufferedAheadEnd,
|
||||
bufferedSpanPct,
|
||||
chapterIndexFor,
|
||||
denseTicks,
|
||||
dragScale,
|
||||
DRAG_REF_PX,
|
||||
DRAG_SCALE_MIN,
|
||||
nearestChapterLine,
|
||||
railLiftPx,
|
||||
railSecondsAtPointer,
|
||||
railTicks,
|
||||
railWindow,
|
||||
RAIL_SPAN,
|
||||
secondsAtPointer,
|
||||
snapToChapter,
|
||||
snapToChapterPx,
|
||||
TOUCH_RAIL_LIFT_MAX_PX,
|
||||
TOUCH_RAIL_MIN_DY_PX,
|
||||
touchRailSpan,
|
||||
} from "./audio-scrubber";
|
||||
|
||||
// An 86-hour book (KJV-shaped: 1189 even chapters) and a 26-hour
|
||||
@@ -66,28 +75,142 @@ describe("chapterIndexFor", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("snapToChapter", () => {
|
||||
it("snaps within the threshold and not beyond it", () => {
|
||||
const start = chapters1189[10].start;
|
||||
expect(snapToChapter(start + 3, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
|
||||
expect(snapToChapter(start + 4.9, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
|
||||
expect(snapToChapter(start + 5.1, chapters1189, 5).snapped).toBe(false);
|
||||
expect(snapToChapter(start - 5, chapters1189, 5)).toEqual({ seconds: start, snapped: true });
|
||||
describe("nearestChapterLine / snapToChapterPx (§9 pixel magnetism)", () => {
|
||||
// 34-chapter book on an 800px bar: ~23.5px between lines — the
|
||||
// radius discriminates. (On the KJV-shaped bar the 1189 lines sit
|
||||
// ~0.4px apart — magnetism lands on SOME line from anywhere, which
|
||||
// is correct for a book that dense.)
|
||||
const pxSec = 800 / duration26h; // bar px per second
|
||||
|
||||
it("reports the closest line and its distance in bar pixels", () => {
|
||||
const start = chapters34[5].start;
|
||||
const near = nearestChapterLine(start + 5 / pxSec, chapters34, duration26h, 800);
|
||||
expect(near!.index).toBe(5);
|
||||
expect(near!.seconds).toBe(start);
|
||||
expect(near!.distancePx).toBeCloseTo(5, 5);
|
||||
expect(nearestChapterLine(42, [], duration26h, 800)).toBeNull();
|
||||
});
|
||||
|
||||
it("picks the NEAREST line when two are in range", () => {
|
||||
it("finds sub-pixel lines on a book as dense as the KJV", () => {
|
||||
// 1px ≈ 633s — a seconds threshold could never fire here; a pixel
|
||||
// radius always can. This is the §9 root-cause fix.
|
||||
const dense = nearestChapterLine(duration86h / 2, chapters1189, duration86h, 489);
|
||||
expect(dense).not.toBeNull();
|
||||
expect(dense!.distancePx).toBeLessThan(0.25);
|
||||
});
|
||||
|
||||
it("snaps within the pixel radius and not beyond it", () => {
|
||||
const start = chapters34[5].start;
|
||||
expect(snapToChapterPx(start + 9 / pxSec, chapters34, duration26h, 800)).toEqual({
|
||||
seconds: start,
|
||||
snapped: true,
|
||||
});
|
||||
expect(snapToChapterPx(start + 11 / pxSec, chapters34, duration26h, 800).snapped).toBe(false);
|
||||
expect(snapToChapterPx(start - 10 / pxSec, chapters34, duration26h, 800)).toEqual({
|
||||
seconds: start,
|
||||
snapped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("force snaps to the nearest line regardless of radius (Shift+click)", () => {
|
||||
const a = chapters34[5].start;
|
||||
const b = chapters34[6].start;
|
||||
const mid = (a + b) / 2;
|
||||
// Chapter gap (~2759s) means both lines sit ~1380s from the
|
||||
// midpoint — the threshold must reach past it.
|
||||
const result = snapToChapter(mid + 1, chapters34, 1500);
|
||||
// Radius 0 + force must land where a wide radius lands: distance
|
||||
// is ignored, nearest wins (mid+1 → the later line).
|
||||
expect(snapToChapterPx(mid + 1, chapters34, duration26h, 800, 0, true)).toEqual({
|
||||
seconds: b,
|
||||
snapped: true,
|
||||
});
|
||||
// Past the book's last line, the nearest line is still reachable.
|
||||
const last = chapters34[33].start;
|
||||
expect(snapToChapterPx(last + 1000 / pxSec, chapters34, duration26h, 800, 0, true)).toEqual({
|
||||
seconds: last,
|
||||
snapped: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("picks the NEAREST line when two are in radius", () => {
|
||||
const a = chapters34[5].start;
|
||||
const b = chapters34[6].start;
|
||||
const mid = (a + b) / 2;
|
||||
// Both lines sit ~11.8px from the midpoint; inside a 13px radius
|
||||
// the closer (later) one must win.
|
||||
const result = snapToChapterPx(mid + 1, chapters34, duration26h, 800, 13);
|
||||
expect(result.snapped).toBe(true);
|
||||
expect(result.seconds).toBe(b);
|
||||
});
|
||||
|
||||
it("leaves the position alone with no chapters", () => {
|
||||
expect(snapToChapter(42, [], 5)).toEqual({ seconds: 42, snapped: false });
|
||||
it("leaves the position alone with no chapters or a dead bar", () => {
|
||||
expect(snapToChapterPx(42, [], duration26h, 800)).toEqual({ seconds: 42, snapped: false });
|
||||
expect(snapToChapterPx(42, chapters34, duration26h, 0).snapped).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dragScale / applyDrag (§9 touch progressive precision)", () => {
|
||||
it("is 1:1 at the bar and decays an order per reference distance", () => {
|
||||
expect(dragScale(0)).toBe(1);
|
||||
expect(dragScale(DRAG_REF_PX)).toBeCloseTo(0.1, 5);
|
||||
expect(dragScale(2 * DRAG_REF_PX)).toBeCloseTo(0.01, 5);
|
||||
});
|
||||
|
||||
it("is symmetric in pull direction and monotonic in |dy|", () => {
|
||||
expect(dragScale(-60)).toBe(dragScale(60));
|
||||
expect(dragScale(30)).toBeGreaterThan(dragScale(60));
|
||||
expect(dragScale(60)).toBeGreaterThan(dragScale(120));
|
||||
expect(dragScale(10_000)).toBe(DRAG_SCALE_MIN);
|
||||
});
|
||||
|
||||
it("degenerates to plain travel with a dead reference", () => {
|
||||
expect(dragScale(500, 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("advances the cursor 1:1 at dy=0 (px → seconds at bar scale)", () => {
|
||||
// 800px bar over 26h: 100px ≈ 11726s.
|
||||
expect(applyDrag(0, 100, 0, 800 / duration26h, duration26h)).toBeCloseTo(
|
||||
(100 / 800) * duration26h,
|
||||
5,
|
||||
);
|
||||
});
|
||||
|
||||
it("shrinks travel by the current zoom (incremental, not absolute)", () => {
|
||||
const pxPerSecond = 800 / duration26h;
|
||||
const onePxSeconds = 1 / pxPerSecond;
|
||||
// One reference-distance of pull = one order of magnitude finer.
|
||||
expect(applyDrag(0, 100, DRAG_REF_PX, pxPerSecond, duration26h)).toBeCloseTo(
|
||||
100 * onePxSeconds * 0.1,
|
||||
5,
|
||||
);
|
||||
// Scale changes only affect SUBSEQUENT travel: the cursor never
|
||||
// teleports when the finger pulls away mid-drag.
|
||||
expect(applyDrag(5000, 0, 3 * DRAG_REF_PX, pxPerSecond, duration26h)).toBe(5000);
|
||||
});
|
||||
|
||||
it("clamps to the book in both directions", () => {
|
||||
const pxPerSecond = 800 / duration26h;
|
||||
expect(applyDrag(duration26h, 100, 0, pxPerSecond, duration26h)).toBe(duration26h);
|
||||
expect(applyDrag(1, -100, 0, pxPerSecond, duration26h)).toBe(0);
|
||||
expect(applyDrag(50, 100, 0, 0, duration26h)).toBe(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe("touch rail feedback (span shrink + lift)", () => {
|
||||
it("starts at the full ±5-min span and shrinks with the zoom", () => {
|
||||
expect(touchRailSpan(0)).toBe(RAIL_SPAN);
|
||||
expect(touchRailSpan(DRAG_REF_PX)).toBeCloseTo(RAIL_SPAN * 0.1, 5);
|
||||
expect(touchRailSpan(5 * DRAG_REF_PX)).toBe(2); // zoomed past the floor
|
||||
expect(touchRailSpan(60)).toBeLessThan(RAIL_SPAN);
|
||||
});
|
||||
|
||||
it("shows the rail only past the pull threshold", () => {
|
||||
expect(TOUCH_RAIL_MIN_DY_PX).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("lifts toward the finger, capped", () => {
|
||||
expect(railLiftPx(20)).toBe(-20); // finger above the bar → rail rises
|
||||
expect(railLiftPx(-30)).toBe(30); // finger below → rail follows down
|
||||
expect(railLiftPx(500)).toBe(-TOUCH_RAIL_LIFT_MAX_PX);
|
||||
expect(railLiftPx(-500)).toBe(TOUCH_RAIL_LIFT_MAX_PX);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+96
-20
@@ -1,9 +1,9 @@
|
||||
// Scrubber math (player plan §1 — progress bar overhaul). Pure and
|
||||
// DOM-free except for passing rects in; the Alpine component owns the
|
||||
// element, the pointer events and the state. Everything here is
|
||||
// unit-tested — the snap threshold, the buffer→global mapping and the
|
||||
// precision-rail mapping are exactly the parts that must hold for an
|
||||
// 86-hour, 1189-track book AND a 2-hour single-file m4b.
|
||||
// Scrubber math (player plans §1 + §9). Pure and DOM-free except for
|
||||
// passing rects in; the Alpine component owns the element, the pointer
|
||||
// events and the state. Everything here is unit-tested — the pixel
|
||||
// magnetism, the pull-away drag scaling and the precision-rail mapping
|
||||
// are exactly the parts that must hold for an 86-hour, 1189-track book
|
||||
// AND a 26-hour single-file m4b.
|
||||
|
||||
export interface ScrubChapter {
|
||||
start: number;
|
||||
@@ -29,26 +29,102 @@ export function chapterIndexFor(chapters: ScrubChapter[], seconds: number): numb
|
||||
return found;
|
||||
}
|
||||
|
||||
// snapToChapter returns the chapter start when the position falls
|
||||
// within thresholdSeconds of one (click-to-snap), otherwise the
|
||||
// position unchanged.
|
||||
export function snapToChapter(
|
||||
// ---- §9 magnetism: pixel radius, not a seconds threshold ----
|
||||
// A seconds threshold goes sub-pixel on long books (1px ≈ 633s on the
|
||||
// KJV bar — 5s could never fire); magnetism must be measured in PIXELS.
|
||||
|
||||
// MAGNET_RADIUS_PX is how close a click/hover must be to a chapter line,
|
||||
// in bar pixels, for it to take over the landing position.
|
||||
export const MAGNET_RADIUS_PX = 10;
|
||||
|
||||
// nearestChapterLine finds the chapter start closest to a position and
|
||||
// reports its distance in bar pixels. Empty chapters → null.
|
||||
export function nearestChapterLine(
|
||||
seconds: number,
|
||||
chapters: ScrubChapter[],
|
||||
thresholdSeconds = 5,
|
||||
): { seconds: number; snapped: boolean } {
|
||||
let best: { seconds: number; snapped: boolean } = { seconds, snapped: false };
|
||||
let bestDistance = thresholdSeconds;
|
||||
for (const ch of chapters) {
|
||||
const distance = Math.abs(ch.start - seconds);
|
||||
if (distance <= bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = { seconds: ch.start, snapped: true };
|
||||
}
|
||||
duration: number,
|
||||
widthPx: number,
|
||||
): { index: number; seconds: number; distancePx: number } | null {
|
||||
if (chapters.length === 0 || duration <= 0 || widthPx <= 0) return null;
|
||||
const pxPerSecond = widthPx / duration;
|
||||
let best: { index: number; seconds: number; distancePx: number } | null = null;
|
||||
for (let i = 0; i < chapters.length; i += 1) {
|
||||
const distancePx = Math.abs(chapters[i].start - seconds) * pxPerSecond;
|
||||
if (best === null || distancePx < best.distancePx)
|
||||
best = { index: i, seconds: chapters[i].start, distancePx };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// snapToChapterPx applies the magnet: within radiusPx of a line the
|
||||
// position lands ON the line; force (Shift+click) extends the pull to
|
||||
// the nearest line regardless of distance. Otherwise unchanged.
|
||||
export function snapToChapterPx(
|
||||
seconds: number,
|
||||
chapters: ScrubChapter[],
|
||||
duration: number,
|
||||
widthPx: number,
|
||||
radiusPx = MAGNET_RADIUS_PX,
|
||||
force = false,
|
||||
): { seconds: number; snapped: boolean } {
|
||||
const near = nearestChapterLine(seconds, chapters, duration, widthPx);
|
||||
if (near === null) return { seconds, snapped: false };
|
||||
if (force || near.distancePx <= radiusPx) return { seconds: near.seconds, snapped: true };
|
||||
return { seconds, snapped: false };
|
||||
}
|
||||
|
||||
// ---- §9 touch precision: vertical pull-away = progressive zoom ----
|
||||
|
||||
// The scale factor decays an order of magnitude for every DRAG_REF_PX of
|
||||
// vertical distance from the bar: 1:1 at the bar, ~0.1× one screen-gap
|
||||
// away, ~0.01× further. Exponential keeps the zoom continuous — any
|
||||
// precision level is one gesture (plan §9 option C).
|
||||
export const DRAG_REF_PX = 80;
|
||||
export const DRAG_SCALE_MIN = 0.001;
|
||||
|
||||
export function dragScale(dyPx: number, refPx: number = DRAG_REF_PX): number {
|
||||
if (refPx <= 0) return 1;
|
||||
return Math.max(DRAG_SCALE_MIN, Math.pow(10, -Math.abs(dyPx) / refPx));
|
||||
}
|
||||
|
||||
// applyDrag advances an INCREMENTAL drag cursor by one pointermove:
|
||||
// horizontal travel (px) converts at the bar's scale, then shrinks by
|
||||
// the current vertical offset. Incremental, not absolute — changing the
|
||||
// scale mid-drag only affects subsequent travel, so pulling away never
|
||||
// teleports the position. Clamped to the book.
|
||||
export function applyDrag(
|
||||
position: number,
|
||||
dxPx: number,
|
||||
dyPx: number,
|
||||
pxPerSecond: number,
|
||||
duration: number,
|
||||
refPx: number = DRAG_REF_PX,
|
||||
): number {
|
||||
const clamped = Math.min(duration, Math.max(0, position));
|
||||
if (pxPerSecond <= 0 || duration <= 0) return clamped;
|
||||
return Math.min(duration, Math.max(0, clamped + (dxPx / pxPerSecond) * dragScale(dyPx, refPx)));
|
||||
}
|
||||
|
||||
// Touch feedback thresholds: the rail appears once the finger is at
|
||||
// least TOUCH_RAIL_MIN_DY_PX off the bar, and lifts toward the finger up
|
||||
// to TOUCH_RAIL_LIFT_MAX_PX while the window shrinks with the zoom.
|
||||
export const TOUCH_RAIL_MIN_DY_PX = 12;
|
||||
export const TOUCH_RAIL_LIFT_MAX_PX = 40;
|
||||
|
||||
// touchRailSpan shrinks the rail window with the zoom (full ±5-min rail
|
||||
// at 1:1, an order tighter per DRAG_REF_PX of pull) with a floor that
|
||||
// keeps the edge labels readable.
|
||||
export function touchRailSpan(dyPx: number, refPx: number = DRAG_REF_PX): number {
|
||||
return Math.max(2, Math.min(RAIL_SPAN, RAIL_SPAN * dragScale(dyPx, refPx)));
|
||||
}
|
||||
|
||||
// railLiftPx converts the vertical offset into the rail's translateY:
|
||||
// toward the finger (negative = up), capped so the rail never chases
|
||||
// the finger off the player.
|
||||
export function railLiftPx(dyPx: number): number {
|
||||
return -Math.max(-TOUCH_RAIL_LIFT_MAX_PX, Math.min(TOUCH_RAIL_LIFT_MAX_PX, dyPx));
|
||||
}
|
||||
|
||||
// denseTicks decides which chapter starts get a rendered tick: a
|
||||
// chapter earns a mark only when it is at least minGapPx from the last
|
||||
// kept one — an 1189-chapter book would otherwise paint the bar solid.
|
||||
|
||||
+101
-24
@@ -15,7 +15,7 @@
|
||||
import { Alpine } from "./alpine";
|
||||
import { renderNoteHtml } from "./markdown";
|
||||
import { showToast } from "./toast";
|
||||
import { resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
import { POINTER_GUIDE, resolveShortcut, visibleKeymap, type KeyAction } from "./audio-keys";
|
||||
import {
|
||||
SLEEP_CHOICES,
|
||||
SLEEP_OFF,
|
||||
@@ -29,15 +29,21 @@ import {
|
||||
type SleepState,
|
||||
} from "./audio-sleep";
|
||||
import {
|
||||
applyDrag,
|
||||
bufferedAheadEnd,
|
||||
bufferedSpanPct,
|
||||
chapterIndexFor,
|
||||
denseTicks,
|
||||
MAGNET_RADIUS_PX,
|
||||
nearestChapterLine,
|
||||
railLiftPx,
|
||||
railSecondsAtPointer,
|
||||
railTicks,
|
||||
railWindow,
|
||||
secondsAtPointer,
|
||||
snapToChapter,
|
||||
snapToChapterPx,
|
||||
TOUCH_RAIL_MIN_DY_PX,
|
||||
touchRailSpan,
|
||||
} from "./audio-scrubber";
|
||||
|
||||
type AudioChapter = { index: number; title: string; start: number; end: number };
|
||||
@@ -155,6 +161,8 @@ Alpine.data("audioReader", () => ({
|
||||
// Exposed for the templ dropdown's x-for — templ cannot import the
|
||||
// const directly; this property is the binding.
|
||||
speeds: SPEEDS,
|
||||
// Same binding pattern, for the dialog's Mouse & touch section.
|
||||
pointerGuide: POINTER_GUIDE,
|
||||
skipSeconds: Number(localStorage.getItem("audio_skip") || 10),
|
||||
// Scrub guard: while dragging, timeupdate must not fight the handle
|
||||
scrubbing: false,
|
||||
@@ -167,9 +175,19 @@ Alpine.data("audioReader", () => ({
|
||||
bufferedGlobal: null as number | null,
|
||||
hoverSeconds: null as number | null,
|
||||
hoverPct: 0,
|
||||
// §9: hover locks to a chapter line inside the magnet radius (the
|
||||
// highlight line + tooltip land where the click will).
|
||||
hoverSnapped: false,
|
||||
railFrom: 0,
|
||||
railTo: 0,
|
||||
railTickList: [] as Array<{ start: number; title: string }>,
|
||||
// §9 interaction model: "free" = 1:1 mouse drag (no rail), "rail" =
|
||||
// Shift+drag precision window, "touch" = incremental cursor with
|
||||
// pull-away precision. The rail is only visible for the last two.
|
||||
dragMode: null as null | "free" | "rail" | "touch",
|
||||
railVisible: false,
|
||||
railLiftY: 0,
|
||||
touchLastX: 0,
|
||||
|
||||
// Track manifest: one AudioTrack per playable file. Single-file books
|
||||
// (m4b/mp3) carry exactly one track whose URL is the whole file, so
|
||||
@@ -702,13 +720,16 @@ Alpine.data("audioReader", () => ({
|
||||
return Math.min(100, Math.max(0, ((this.scrubValue - this.railFrom) / span) * 100));
|
||||
},
|
||||
|
||||
// Click/drag on the bar: the FIRST press maps hours-per-pixel and
|
||||
// snaps to a chapter line within ~5s; while held, the precision rail
|
||||
// takes over at minutes-per-pixel (re-grab to travel far). The
|
||||
// native range stays underneath for keyboard/aria — its drag default
|
||||
// is prevented so the two mappings never fight.
|
||||
// Pointer interactions (plan §9): mouse drag is FREE FLOW (1:1, no
|
||||
// rail, never sticks at chapter lines); clicks land with pixel
|
||||
// magnetism on nearby chapter lines; Shift+click/drag opens the
|
||||
// precision rail (±5 min, free mapping inside); touch drags are an
|
||||
// incremental cursor whose scale tightens as the finger pulls off the
|
||||
// bar (progressive precision — the rail is its feedback). The native
|
||||
// range stays underneath for keyboard/aria — its drag default is
|
||||
// prevented so the mappings never fight.
|
||||
scrubPointerDown(e: PointerEvent) {
|
||||
if (e.button !== 0) return;
|
||||
if (e.button !== 0 || this.scrubbing) return;
|
||||
e.preventDefault();
|
||||
const area = e.currentTarget as HTMLElement;
|
||||
const rect = area.getBoundingClientRect();
|
||||
@@ -720,13 +741,32 @@ Alpine.data("audioReader", () => ({
|
||||
}
|
||||
document.getElementById("audio-scrub")?.focus({ preventScroll: true });
|
||||
const duration = this.chaptersDuration() || 1;
|
||||
const at = snapToChapter(secondsAtPointer(e.clientX, rect, duration), this.chapters, 5).seconds;
|
||||
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||
if (e.pointerType === "touch") {
|
||||
// Tap lands magnetically; the drag starts 1:1 and tightens as
|
||||
// the finger pulls away (scrubPointerMove's incremental cursor).
|
||||
this.dragMode = "touch";
|
||||
this.touchLastX = e.clientX;
|
||||
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||
} else if (e.shiftKey) {
|
||||
// Shift+click/drag: the precision rail, anchored to the NEAREST
|
||||
// chapter line (Shift+click = snap regardless of radius).
|
||||
this.dragMode = "rail";
|
||||
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||
this.scrubValue = near ? near.seconds : at;
|
||||
const window = railWindow(this.scrubValue, duration);
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
this.railVisible = true;
|
||||
this.railLiftY = 0;
|
||||
} else {
|
||||
// Mouse click/drag: the CLICK's landing is magnetic; drags stay
|
||||
// free-flow (scrubPointerMove overwrites with the raw position).
|
||||
this.dragMode = "free";
|
||||
this.scrubValue = snapToChapterPx(at, this.chapters, duration, rect.width).seconds;
|
||||
}
|
||||
this.scrubbing = true;
|
||||
this.scrubValue = at;
|
||||
const window = railWindow(at, duration);
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
},
|
||||
|
||||
scrubPointerMove(e: PointerEvent) {
|
||||
@@ -734,21 +774,54 @@ Alpine.data("audioReader", () => ({
|
||||
const rect = area.getBoundingClientRect();
|
||||
const duration = this.chaptersDuration() || 1;
|
||||
if (!this.scrubbing) {
|
||||
this.hoverSeconds = secondsAtPointer(e.clientX, rect, duration);
|
||||
this.hoverPct =
|
||||
rect.width > 0
|
||||
? Math.min(100, Math.max(0, ((e.clientX - rect.left) / rect.width) * 100))
|
||||
: 0;
|
||||
// Hover: time + chapter, locking to a chapter line inside the
|
||||
// magnet radius so the tooltip shows the click's true landing.
|
||||
const at = secondsAtPointer(e.clientX, rect, duration);
|
||||
const near = nearestChapterLine(at, this.chapters, duration, rect.width);
|
||||
const locked = near !== null && near.distancePx <= MAGNET_RADIUS_PX;
|
||||
const hoverAt = locked && near ? near.seconds : at;
|
||||
this.hoverSeconds = hoverAt;
|
||||
this.hoverPct = rect.width > 0 ? Math.min(100, Math.max(0, (hoverAt / duration) * 100)) : 0;
|
||||
this.hoverSnapped = locked;
|
||||
return;
|
||||
}
|
||||
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
|
||||
from: this.railFrom,
|
||||
to: this.railTo,
|
||||
});
|
||||
if (this.dragMode === "rail") {
|
||||
this.scrubValue = railSecondsAtPointer(e.clientX, rect, {
|
||||
from: this.railFrom,
|
||||
to: this.railTo,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (this.dragMode === "touch") {
|
||||
const dx = e.clientX - this.touchLastX;
|
||||
this.touchLastX = e.clientX;
|
||||
const dy = rect.top + rect.height / 2 - e.clientY;
|
||||
this.scrubValue = applyDrag(this.scrubValue, dx, dy, rect.width / duration, duration);
|
||||
if (Math.abs(dy) >= TOUCH_RAIL_MIN_DY_PX) {
|
||||
// Feedback: the rail follows the cursor, its window shrinking
|
||||
// with the zoom, lifting toward the finger.
|
||||
const window = railWindow(this.scrubValue, duration, touchRailSpan(dy));
|
||||
this.railFrom = window.from;
|
||||
this.railTo = window.to;
|
||||
this.railTickList = railTicks(window, this.chapters);
|
||||
this.railVisible = true;
|
||||
this.railLiftY = railLiftPx(dy);
|
||||
} else {
|
||||
this.railVisible = false;
|
||||
this.railLiftY = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Free flow: direct 1:1 mapping — dragging across chapter lines
|
||||
// never sticks.
|
||||
this.scrubValue = secondsAtPointer(e.clientX, rect, duration);
|
||||
},
|
||||
|
||||
scrubPointerLeave() {
|
||||
if (!this.scrubbing) this.hoverSeconds = null;
|
||||
if (!this.scrubbing) {
|
||||
this.hoverSeconds = null;
|
||||
this.hoverSnapped = false;
|
||||
}
|
||||
},
|
||||
|
||||
scrubPointerUp(e: PointerEvent) {
|
||||
@@ -758,7 +831,11 @@ Alpine.data("audioReader", () => ({
|
||||
area.releasePointerCapture(e.pointerId);
|
||||
this.seekTo(this.scrubValue);
|
||||
this.scrubbing = false;
|
||||
this.dragMode = null;
|
||||
this.railVisible = false;
|
||||
this.railLiftY = 0;
|
||||
this.hoverSeconds = null;
|
||||
this.hoverSnapped = false;
|
||||
},
|
||||
|
||||
// Wheel over the bar: ±5s nudge, Shift = ±60s. Scroll up = forward.
|
||||
|
||||
Reference in new issue
Block a user