Skip to main content

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.positionAnchor is seconds{ position, timestamp, rate } — because seconds is mpv's unit.
  • state.positionAnchorMs is milliseconds{ value, at, rate } — structurally the shape @afkcodes/timbre-media-session broadcasts, 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 eventsseekStarted / seekCompleted, carrying reason: '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 Player feature (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 rides useProgress'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.