Function: useMilestones()
function useMilestones(
player: Player | undefined,
onMilestone: (milestone: Milestone) => void,
options?: {
intervalMs?: number;
marks?: readonly number[];
}
): void;
Defined in: packages/player/src/hooks/useMilestones.ts:82
Fire a callback once when playback passes 25 % / 50 % / 75 % / 90 % of an entry — the scrobbling primitive, forward-only and once per playthrough.
Parameters
| Parameter | Type | Description |
|---|---|---|
player | Player | undefined | The player, or undefined before it has been created. |
onMilestone | (milestone: Milestone) => void | Called once per mark per playthrough. Kept in a ref, so it does not need to be memoised. |
options | { intervalMs?: number; marks?: readonly number[]; } | marks (defaults to DEFAULT_MILESTONES, must be percentages in 0 … 100) and intervalMs, forwarded to useProgress. |
options.intervalMs? | number | - |
options.marks? | readonly number[] | - |
Returns
void
Example
useMilestones(player, ({ percent }) => {
if (percent === 50) scrobble(currentTrack) // Last.fm's "now played"
})
Remarks
Why this is a hook and not a Player method — the honest version.
A milestone is a time event: nothing in mpv fires at 50 % of a track. This
library deliberately never streams position across the bridge and never runs
a native timer for it (ARCHITECTURE §7); state changes only on
discontinuities. So a player.onMilestone(...) would have exactly two
possible implementations, and both are worse than this one:
- A timer inside the player. JS timers freeze with the screen off (ARCHITECTURE, "Platform truths"), so a background playthrough would fire its milestones in a burst when the user next unlocks the phone — or never. A player that pretends to have a clock it does not have is the kind of quiet lie this project refuses elsewhere.
- Checking only at discontinuities. That is genuinely free, and it can honestly report a milestone at a seek, a pause, a track end — but a track played straight through produces no events between its start and its end, which is precisely the case milestones exist for. It would fire every mark at once at the track's end, which is not what "reached 50 %" means.
So the tick has to come from somewhere, and the only place that has one
already — and only while a UI is actually mounted and playback is actually
advancing — is useProgress. This hook adds no timer of its own:
it derives from the same projection your progress bar is already rendering.
A screen-off playthrough with no mounted UI produces no milestones, and that
is stated rather than papered over. An app that needs background-accurate
scrobbling should log trackEnded plus the seekStarted/seekCompleted
pair and reconstruct listened time from those, which are delivered with the
screen off.
The rules, once the tick exists:
- Forward-only, once per playthrough. Passing 50 % fires once; seeking back and passing it again does not fire again.
- Seeking past a mark consumes it silently. Jumping from 10 % to 80 %
marks 25 %, 50 % and 75 % as spent without calling back — you did not
listen to them. A seek is recognised from the player's own
seekStartedevent, never guessed from the size of a position delta, which cannot tell a scrub from a dropped render. - A new entry, or a restart of the same one, resets everything. The playthrough is keyed on the playlist index plus a backwards jump, so a repeat of the same track earns its milestones again.
- Live streams are skipped, having no duration to be a percentage of.