supervision-js
    Preparing search index...

    Timeline Scrubbing

    Use frameNavigation for indexed sources. It handles latest-wins scrubbing and exact landing without a host debounce or timeout. This installer accepts your UI's pointer-to-time and key-to-time mappings; return null when the source lacks indexed capabilities and keep your time-based controls as a fallback. The timeline must be focusable and use touch-action: none for touch dragging.

    import type { LiveMediaSession } from "supervision";

    export function installTimelineScrubber(
    session: LiveMediaSession,
    timeline: HTMLElement,
    secondsForPointer: (event: PointerEvent) => number,
    secondsForKey: (event: KeyboardEvent) => number,
    showUnsupported: () => void,
    ) {
    const clock = session.frameClock;
    const navigation = session.frameNavigation;
    if (!clock || !navigation) {
    showUnsupported();
    return null;
    }
    let gesture = 0;
    let active: number | "keyboard" | null = null;
    let disposed = false;
    let lastTarget = clock.timeAt(0);
    let pendingTarget: number | null = null;
    const report = (error: unknown) => {
    if (error instanceof DOMException && error.name === "AbortError") return;
    console.error(error);
    };
    const finish = () => {
    if (active === null) return;
    const pointer = active;
    active = null;
    if (typeof pointer === "number" && timeline.hasPointerCapture(pointer)) {
    timeline.releasePointerCapture(pointer);
    }
    const finishingGesture = gesture;
    void navigation
    .moveToTime(lastTarget)
    .catch(report)
    .finally(() => {
    if (!disposed && finishingGesture === gesture) {
    pendingTarget = null;
    }
    });
    };
    const preview = (seconds: number) => {
    lastTarget = seconds;
    const scrub = navigation.scrubToTime(lastTarget);
    pendingTarget = scrub.target.mediaTime;
    void scrub.settled.catch(report);
    };
    const move = (event: PointerEvent) => {
    if (active === event.pointerId) preview(secondsForPointer(event));
    };
    const begin = (event: PointerEvent) => {
    if (active !== null || event.button !== 0) return;
    event.preventDefault();
    timeline.focus();
    active = event.pointerId;
    gesture += 1;
    timeline.setPointerCapture(event.pointerId);
    move(event);
    };
    const end = (event: PointerEvent) => {
    if (active !== event.pointerId) return;
    if (event.type === "pointerup") move(event);
    finish();
    };
    const movementKeys = new Set(["ArrowLeft", "ArrowRight", "Home", "End"]);
    const key = (event: KeyboardEvent) => {
    if (typeof active === "number") return;
    if (movementKeys.has(event.key)) {
    event.preventDefault();
    if (active === null) {
    active = "keyboard";
    gesture += 1;
    }
    preview(secondsForKey(event));
    } else if (event.key === "Enter" || event.key === "Escape") {
    event.preventDefault();
    finish();
    }
    };
    const keyEnd = (event: KeyboardEvent) => {
    if (active === "keyboard" && movementKeys.has(event.key)) finish();
    };
    timeline.addEventListener("pointerdown", begin);
    timeline.addEventListener("pointermove", move);
    timeline.addEventListener("pointerup", end);
    timeline.addEventListener("pointercancel", end);
    timeline.addEventListener("lostpointercapture", end);
    timeline.addEventListener("keydown", key);
    timeline.addEventListener("keyup", keyEnd);
    timeline.addEventListener("blur", finish);
    return {
    knobTime: () =>
    pendingTarget ?? session.getState().renderer?.presentedTime ?? 0,
    destroy() {
    finish();
    disposed = true;
    gesture += 1;
    pendingTarget = null;
    timeline.removeEventListener("pointerdown", begin);
    timeline.removeEventListener("pointermove", move);
    timeline.removeEventListener("pointerup", end);
    timeline.removeEventListener("pointercancel", end);
    timeline.removeEventListener("lostpointercapture", end);
    timeline.removeEventListener("keydown", key);
    timeline.removeEventListener("keyup", keyEnd);
    timeline.removeEventListener("blur", finish);
    },
    };
    }

    Pointer-up, cancellation, lost capture, keyboard release, Enter, Escape, and focus loss all commit the last target once. Cancellation commits the position already previewed; it does not restore the gesture's starting position. The pending target remains the knob value until the exact move settles, and an old completion cannot clear a newer gesture. Call destroy() when removing the control, before destroying its session.

    For exact frame steps, see Exact Frame Navigation. The local demo provides a runnable timeline; start it with npm run dev from the repository checkout.