API
Architecture
Layer Diagram
TimelineClock Pure time source (tick + seek events). No playlist knowledge.
↓
Playlist Parses m3u8, loads segments, LRU cache, auto-prefetch, live poll.
↓
useSegment Raw decoded current segment (JSONL events, VTT — discrete data).
useSegmentTrack Current segment → columnar tracks (no cross-segment merge).
useMergedTrack Current + contiguous neighbors merged (IMU, joints, pose).
useTrackSample Interpolated query of a track at a precise time.
↓
ClockProvider Hands the clock down the component tree via React context.
↓
View components VideoPlayer, SubtitleView, ActionLabelView,
ImuChartView, JointAngleView, PoseView.Each layer has one job. No circular dependencies.
Component Hierarchy
App
├── useTimeline()
│ ├── clock: TimelineClock (plain JS, owns RAF loop)
│ └── state: TimelineState (playing/rate/loop/duration — seek events only)
│
├── <ClockProvider clock={clock}> (injects clock into context)
│
├── VideoPlayer { src }
│ ├── hls.js handles m3u8 + segment loading + video buffering
│ ├── <video>.durationchange → clock.extendDuration()
│ └── clock seek events → video.play/pause/seek/playbackRate
│
├── ActionLabelView { src }
│ ├── usePlaylist(options) → engine + clock.extendDuration()
│ ├── useSegment(engine) → useClockValue(10) + resolveSegment()
│ └── useClockValue(4) → highlight active entry
│
├── ImuChartView { src } / JointAngleView { src } / PoseView { src }
│ ├── usePlaylist(options)
│ ├── useMergedTrack(engine[, options]) → merge Float32Arrays
│ ├── useClockValue(fps) → React render rate
│ └── useTrackSample(track, time[, interp]) → interpolated sample
│
├── SubtitleView { src }
│ ├── usePlaylist(options)
│ ├── useSegment(engine)
│ └── useClockValue(10)
│
└── TimelineController { state, onPlay, onPause, onSeek, ... }
└── useClockValue(30) → scrubber positionEvery view accepts an optional clock={…} prop that overrides the context value when present.
Data Flow
Discrete data (ActionLabelView, SubtitleView)
1. usePlaylist({ url })
├─ engine.init() → fetch + parse m3u8
└─ clock.extendDuration(totalDuration)
2. useSegment(engine)
├─ useClockValue(10) → time at ~10fps
└─ resolveSegment(playlist.segments, time)
segment index changed?
yes → engine.getDataAtTime(time) → fetch + decode + LRU cache
+ prefetchAhead() → background fetch next N segments
no → skip (no React re-render)
3. useClockValue(4) → highlight active entry at ~4fpsContinuous data (ImuChartView, JointAngleView, PoseView)
1. usePlaylist + clock.extendDuration — same as above
2. useMergedTrack(engine[, { normalize }])
├─ On segment change → engine.getDataAtTime(time) for N neighbors
│ → decoder returns [{ts, data:[…]}, …]
│ → normalize() → { trackName: {times, values, stride} }
│ → rebuildMerged() → contiguous Float32Arrays (gap-safe)
└─ Returns: Map<trackName, TrackSamples>
3. React render path (ImuChartView / JointAngleView / PoseView)
├─ useClockValue(fps) → time at N fps
└─ useTrackSample(track, time, interp) → reused Float32Array[stride]
(Optional 60fps Canvas path — see custom-views)
├─ clock.on('tick')
├─ sampleTrack(track, clock.time, lerp, hint, out)
└─ ctx.* → imperative draw, no ReactClock Events
Two events. No segment tracking on the clock.
TimelineClock
├─ play() → seek{source:'play'} → starts RAF → tick at ~60fps
├─ RAF → tick{time, playing, rate}
├─ seek(t) → seek{source:'seek'} + tick{time}
├─ pause() → seek{source:'pause'} → stops RAF
└─ end → tick + seek{source:'pause'}Who Subscribes to What
| tick (~60fps) | seek (explicit) | |
|---|---|---|
useTimeline | state: playing/rate/loop/duration | |
TimelineController | via useClockValue(30) | via useTimeline state |
VideoPlayer | play/pause/seek/rate | |
ActionLabelView | via useClockValue(10+4) | force reload via useSegment |
SubtitleView | via useClockValue(10) | force reload via useSegment |
ImuChartView / JointAngleView / PoseView | via useClockValue(fps) | cache reset in useMergedTrack |
| Custom Canvas views | direct clock.on('tick') | — |
Performance Model
| Component | React re-renders/sec | Canvas draws/sec |
|---|---|---|
| App | 0 | — |
| useTimeline state | ~0 (seek events only) | — |
| TimelineController | ~30 | — |
| VideoPlayer | 0 | — |
| ActionLabelView | ~4 | — |
| SubtitleView | ~10 | — |
| ImuChartView / JointAngleView | ~15 | — |
| PoseView | ~30 | — |
| Custom Canvas view | ~0 | ~60 |
Key insight: useTimeline state does NOT contain currentTime. Consumers choose their own render frequency via useClockValue(fps).
Duration Auto-Detection
useTimeline() → clock.duration = 0
usePlaylist(jointsUrl) → clock.extendDuration(30) → duration = 30
usePlaylist(actionsUrl) → clock.extendDuration(25) → max(30, 25) = 30
VideoPlayer (via durationchange) → clock.extendDuration(1800) → duration = 1800
live poll discovers new segments → clock.extendDuration(1810) → duration = 1810Multiple playlists with different durations on the same clock — clock always extends to the maximum.
Clock Resolution
Every consumer accepts an optional clock prop and calls useClockContext(clock) internally:
explicit prop → context value (from <ClockProvider>) → throwThe throw is intentional — it catches trees where a view was added without a provider, rather than failing silently at first interaction.