Timeline

A standalone trim / playhead / ruler primitive. Composes with Media Player but doesn’t require one — useful for any “set in/out over a duration” UI (audio editor, video trimmer, range slice picker, transcript scrubber). Per the letbe-ds pluggable engines rule, we ship the chrome and state machine; bring your own waveform renderer (WaveSurfer.js, Peaks.js) or frame-accurate scrub engine (requestVideoFrameCallback) and attach.

a11y (built in): the two trim handles and the playhead are each role="slider" with tabindex="0", aria-orientation="horizontal", an aria-label (Trim start / Trim end / Playhead) and live aria-valuemin/max/now — plus aria-valuetext written as the same formatted clock string the readout shows, so screen readers announce a time rather than a raw seconds count. Keyboard mirrors the drag: ←/→ nudge one second, Shift makes it ten, Home/End jump to the bounds, all clamped by the same per-handle rules as pointer drag (in ≤ out ≤ duration). Zoom is a native input[type=range] with its own aria-label, and its multiplier readout is an aria-live="polite" region.

Usage: Load js/lb.js first, then js/components/lb-timeline.js — the module self-registers via LB.register and every <div data-lb-timeline> auto-inits, rendering its own readout, ruler, lane and zoom control (instance at el._lbTimeline, class at LB.Timeline; lb.js console-warns if it finds the attribute without the module). Seed state declaratively, all in seconds — data-lb-duration, data-lb-in, data-lb-out, data-lb-playhead — plus opt-in data-lb-timeline-wheel-zoom for Ctrl+scroll zoom. Three bubbling events: lb-timeline-change (detail.inSec / outSec / playheadSec / duration) on every edit, lb-timeline-selection (adds detail.rect — the on-screen rect of the in–out span, for anchoring a popover composer), and lb-timeline-zoom (detail.zoom). The getter/setter surface (getRange / setRange, setPlayhead, setDuration) is spelled out under Public API below, and setZoom(z) rounds out the instance; binding to a media clock stays consumer code — see the composition demo.

Opt in with data-lb-timeline on a wrapper. The controller renders a readout row (in / out / duration), a time-axis ruler with auto-scaled ticks, and a lane with trim handles + a playhead marker. Time format auto-scales by total duration: M:SS.mmm below a minute, MM:SS below an hour, HH:MM:SS beyond.

Pointer drag + keyboard nudge on all three sliders, click-on-lane scrubbing, role="slider" ARIA throughout, 1×–10× horizontal zoom with adaptive ruler tick density, and an end-to-end Media Player composition example at the bottom showing the audio <-> timeline binding pattern (rAF-driven playhead, trim-region loop, no feedback loops between the two clocks).


Default

A 2-minute clip with an in/out window selected and the playhead parked partway through. The auto-scale picks MM:SS since the total duration sits between a minute and an hour.

Short clip — sub-second precision

When the total duration is under a minute, the readout switches to M:SS.mmm for sub-second precision — the right format for clipping voice notes, drum loops, or animation frames. The ruler densifies its ticks the same way.

Long form — podcast or interview

Above an hour, the readout uses HH:MM:SS and the ruler caps major-tick density at ~6–10 across the visible width so the labels stay legible.

Empty — no media attached

Without a duration, the lane reads as a dashed placeholder — a ready-to-receive state rather than a broken one. Call el._lbTimeline.setDuration(seconds) to populate it from a consumer hook (the media’s loadedmetadata event, for instance).

Composing with Media Player

Timeline doesn’t depend on Media Player — but composing the two is the most common use case. The pattern: subscribe Timeline’s playhead to the media’s clock (via requestAnimationFrame for a smooth playhead, not just the timeupdate event which ticks ~4×/sec), push currentTime back when the user drags, and loop between the trim handles when playback reaches the out point.

Try it: press Play, then drag the trim handles to clip the loop region. Drag the playhead to scrub; click the lane to jump.

0:00
0:00
// Sketch of the wiring used in this demo. Substitute your own
// element ids and tune the loop / scrubbing rules to taste.
(function () {
  var media = document.getElementById('tl-compose-media');
  var tl    = document.getElementById('tl-compose-tl');
  var audio = media.querySelector('audio');
  var ph    = tl.querySelector('[data-lb-timeline-playhead]');

  function init() {
    // 1. Once metadata loads, populate Timeline duration + open the
    //    trim window across the full clip.
    var apply = function () {
      if (!isFinite(audio.duration)) return;
      tl._lbTimeline.setDuration(audio.duration);
      tl._lbTimeline.setRange({ inSec: 0, outSec: audio.duration });
    };
    if (audio.readyState >= 1) apply();
    else audio.addEventListener('loadedmetadata', apply);

    // 2. While playing, drive the playhead via rAF so it tracks
    //    smoothly (timeupdate is ~4×/sec, choppy for editing).
    //    Suspend loop bounds while the user is adjusting in/out so
    //    OUT < currentTime doesn't yank audio back every frame mid-drag.
    var fromAudio = false;
    var rafId = null;
    var lastPushedSec = null;
    var inH  = tl.querySelector('[data-lb-timeline-handle="in"]');
    var outH = tl.querySelector('[data-lb-timeline-handle="out"]');
    function userAdjustingTrim() {
      return (inH  && inH.classList.contains('lb-timeline__handle--dragging'))
          || (outH && outH.classList.contains('lb-timeline__handle--dragging'));
    }
    function tick() {
      if (audio.paused) { rafId = null; return; }
      if (!userAdjustingTrim()) {
        var r = tl._lbTimeline.getRange();
        if (audio.currentTime >= r.outSec - 0.02) audio.currentTime = r.inSec;
        if (audio.currentTime < r.inSec)          audio.currentTime = r.inSec;
      }
      // Don't fight the user if they're dragging the playhead.
      if (!ph.classList.contains('lb-timeline__playhead--dragging')) {
        fromAudio = true;
        tl._lbTimeline.setPlayhead(audio.currentTime);
        fromAudio = false;
      }
      rafId = requestAnimationFrame(tick);
    }
    audio.addEventListener('play',  function () { if (!rafId) rafId = requestAnimationFrame(tick); });
    audio.addEventListener('pause', function () { if (rafId) cancelAnimationFrame(rafId); rafId = null; });

    // 3. Timeline → audio. Skip our own audio→playhead push (fromAudio
    //    guard) AND trim-handle events that keep playheadSec unchanged
    //    (dedup) — both would otherwise glitch audio.currentTime during
    //    in/out adjustment.
    tl.addEventListener('lb-timeline-change', function (e) {
      if (fromAudio) return;
      if (!isFinite(e.detail.playheadSec)) return;
      if (lastPushedSec === e.detail.playheadSec) return;
      lastPushedSec = e.detail.playheadSec;
      audio.currentTime = e.detail.playheadSec;
    });
  }

  if (media._lbMedia && tl._lbTimeline) init();
  else document.addEventListener('DOMContentLoaded', init);
})();

Public API

Read and write Timeline state from consumer code; writes repaint the lane and emit lb-timeline-change.

// Read
el._lbTimeline.getRange();        // { inSec, outSec }
el._lbTimeline.getPlayhead();     // number (seconds)
el._lbTimeline.getDuration();     // number (seconds)

// Write — paints + emits lb-timeline-change
el._lbTimeline.setRange({ inSec: 5, outSec: 30 });
el._lbTimeline.setPlayhead(12.5);
el._lbTimeline.setDuration(180);  // call after loadedmetadata

// Subscribe
el.addEventListener('lb-timeline-change', (e) => {
  const { inSec, outSec, playheadSec, duration } = e.detail;
});