Position anchors
Position is never streamed across the bridge. time-pos is never observed.
Instead the state carries an anchor — a known-good position, the timestamp it
held at, and the rate it advances at — updated only on discontinuities (seek,
pause, rate change, track change). Every consumer projects the live position
locally as position + elapsed × rate.
The payoff: the lock-screen scrubber, the notification and your own UI all advance in real time while nothing crosses the bridge between two discontinuities. On Android the anchor is converted wall→monotonic once at broadcast receipt (NTP-skew clamped) and media3's position supplier projects by pure subtraction.
The anchor, in two units
The state carries the anchor twice, and that duplication is a deliberate fix for a real bug:
state.positionAnchoris seconds —{ position, timestamp, rate }— because seconds is mpv's unit.state.positionAnchorMsis milliseconds —{ value, at, rate }— structurally the shape@afkcodes/timbre-media-sessionbroadcasts, matched by shape and never imported, so neither package depends on the other.
The conversion used to be the app's, and it is a factor of a thousand and a
rate: 0 waiting to be typed wrong. The README once carried "the lock-screen
scrubber is off by 1000×" as the user's pitfall — which it never was. The
reducer now derives the millisecond anchor next to the one it mirrors, keeping
its object identity across every unrelated event so nothing subscribed to the
scrubber wakes for an irrelevant snapshot.
The rate difference is the load-bearing part: positionAnchor.rate is mpv's
speed and stays 1 while paused (the local projection gates on status
separately), while a remote surface projects value + elapsed × rate and
nothing else — so the millisecond anchor reports 0 whenever the position is
not advancing.
Projecting it
projectPosition does the seconds-side projection for you; the millisecond
anchor is the exact shape a remote surface projects from.
import { projectPosition, type PlayerState, type PositionAnchorMs } from '@afkcodes/timbre-player'
// A snapshot the reducer produced.
declare const state: PlayerState
// The scrubber projects locally — no per-frame bridge traffic.
const seconds: number = projectPosition(state, Date.now())
// The millisecond anchor is exactly what media-session broadcasts.
const anchor: PositionAnchorMs = state.positionAnchorMs
const projectedMs =
anchor.rate === 0 ? anchor.value : anchor.value + (Date.now() - anchor.at) * anchor.rate
void seconds
void projectedMs
Two consequences that are API decisions
- Discontinuities are events —
seekStarted/seekCompleted, carryingreason: 'seek' | 'auto-advance'. They are what makes accurate listening analytics possible without polling: everything between two of them was heard in real time. - Playback milestones (25/50/75/90 %) are a hook, not a
Playerfeature (useMilestones). A mid-track time event needs a tick, and this design has none by design: a timer inside the player would freeze with the screen off and fire a burst on unlock. The hook ridesuseProgress's existing tick and adds no timer of its own.
The full evidence — the monotonic conversion, the object-identity rule, and why there is no JS timer anywhere in the player — is in ARCHITECTURE §7. This page links it rather than repeating it.