diff --git a/templates/audio_reader.templ b/templates/audio_reader.templ index 2f15be9e..e902484a 100644 --- a/templates/audio_reader.templ +++ b/templates/audio_reader.templ @@ -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 (?)" >
- +
@@ -240,7 +250,9 @@ templ AudioReader(user User, metadata ReaderMetadata) {
-
+
+
+
@@ -643,7 +655,7 @@ templ AudioReader(user User, metadata ReaderMetadata) {
- Keyboard shortcuts + Shortcuts & interactions
+ +
Mouse & touch
+
diff --git a/templates/audio_reader_templ.go b/templates/audio_reader_templ.go index 03179a7b..7a10e9bf 100644 --- a/templates/audio_reader_templ.go +++ b/templates/audio_reader_templ.go @@ -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, "\">
Player settings
Autoplay on open
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "\">
Player settings
Autoplay on open
") 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, "

—

0:00
0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Keyboard shortcuts
") + templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "

—

0:00
0:00
Chapters

No chapters detected for this audiobook.

Bookmarks

No bookmarks yet — tap the button to save your position.

Bookmark at 0:00

Shortcuts & interactions
Mouse & touch
") if templ_7745c5c3_Err != nil { return templ_7745c5c3_Err } diff --git a/web/src/audio-keys.ts b/web/src/audio-keys.ts index 3db4bff0..9e7e0c7c 100644 --- a/web/src/audio-keys.ts +++ b/web/src/audio-keys.ts @@ -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 diff --git a/web/src/audio-scrubber.test.ts b/web/src/audio-scrubber.test.ts index 4c7cabd9..6dd7564a 100644 --- a/web/src/audio-scrubber.test.ts +++ b/web/src/audio-scrubber.test.ts @@ -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); }); }); diff --git a/web/src/audio-scrubber.ts b/web/src/audio-scrubber.ts index 3da16793..67fecf4b 100644 --- a/web/src/audio-scrubber.ts +++ b/web/src/audio-scrubber.ts @@ -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. diff --git a/web/src/audio.ts b/web/src/audio.ts index 14243271..e3f38a71 100644 --- a/web/src/audio.ts +++ b/web/src/audio.ts @@ -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.