feat(web): events dispatcher posts to /api/events on player transitions
useEventsDispatcher subscribes to player.current/state via \$effect: on first transition to playing for a new track, POSTs play_started and caches the id; on track-id change while a play is open, closes the prior row as play_skipped; on natural completion (state playing→paused with position >= duration > 0), POSTs play_ended; on pagehide, navigator.sendBeacon fires a final play_skipped. Stable per-tab client_id from sessionStorage.
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
import { player } from './store.svelte';
|
||||
import { api } from '$lib/api/client';
|
||||
import { getOrCreateClientId } from './clientId';
|
||||
import type { PlayStartedResponse, EventOkResponse } from '$lib/api/types';
|
||||
|
||||
// useEventsDispatcher installs $effect-based watchers that emit play events
|
||||
// to /api/events. Call once from +layout.svelte's mount.
|
||||
//
|
||||
// The dispatcher is a small state machine over (current track id, state).
|
||||
// Per spec data-flow:
|
||||
// - First transition into 'playing' for a new track → POST play_started.
|
||||
// - Track id changes while a play is open → POST play_skipped on the prior.
|
||||
// - Natural completion (state moves from 'playing' to 'paused' AND
|
||||
// position >= duration > 0) → POST play_ended on the open row.
|
||||
// - pagehide with a live row → navigator.sendBeacon a play_skipped.
|
||||
export function useEventsDispatcher(): void {
|
||||
let openPlayEventId: string | null = null;
|
||||
let openTrackId: string | null = null;
|
||||
let lastPositionMs = 0;
|
||||
let prevTrackId: string | null = null;
|
||||
let prevState: string | null = null;
|
||||
const clientId = getOrCreateClientId();
|
||||
|
||||
// Track the latest position for use by close events. Cheap; no API call.
|
||||
$effect(() => {
|
||||
lastPositionMs = Math.round(player.position * 1000);
|
||||
});
|
||||
|
||||
// Main state machine. Reacts only to (current track id, state) transitions.
|
||||
$effect(() => {
|
||||
const t = player.current;
|
||||
const s = player.state;
|
||||
const tid = t?.id ?? null;
|
||||
|
||||
// Track changed: close any prior open play as a skip.
|
||||
if (tid !== prevTrackId && openPlayEventId) {
|
||||
const id = openPlayEventId;
|
||||
const pos = lastPositionMs;
|
||||
openPlayEventId = null;
|
||||
openTrackId = null;
|
||||
api.post<EventOkResponse>('/api/events', {
|
||||
type: 'play_skipped',
|
||||
play_event_id: id,
|
||||
position_ms: pos
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
// Entered playing for a new track (no open row yet for this track).
|
||||
if (tid && s === 'playing' && openTrackId !== tid) {
|
||||
void startNew(tid);
|
||||
}
|
||||
|
||||
// Natural end: state went playing -> paused while at/past duration.
|
||||
if (
|
||||
prevState === 'playing' &&
|
||||
s === 'paused' &&
|
||||
openPlayEventId &&
|
||||
player.duration > 0 &&
|
||||
player.position >= player.duration
|
||||
) {
|
||||
const id = openPlayEventId;
|
||||
const dur = lastPositionMs;
|
||||
openPlayEventId = null;
|
||||
openTrackId = null;
|
||||
api.post<EventOkResponse>('/api/events', {
|
||||
type: 'play_ended',
|
||||
play_event_id: id,
|
||||
duration_played_ms: dur
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
prevTrackId = tid;
|
||||
prevState = s;
|
||||
});
|
||||
|
||||
// Page close: sendBeacon a play_skipped if an open play exists.
|
||||
if (typeof window !== 'undefined') {
|
||||
const onPageHide = () => {
|
||||
if (!openPlayEventId) return;
|
||||
const payload = JSON.stringify({
|
||||
type: 'play_skipped',
|
||||
play_event_id: openPlayEventId,
|
||||
position_ms: lastPositionMs
|
||||
});
|
||||
navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' }));
|
||||
};
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
}
|
||||
|
||||
async function startNew(trackId: string): Promise<void> {
|
||||
try {
|
||||
const res = await api.post<PlayStartedResponse>('/api/events', {
|
||||
type: 'play_started',
|
||||
track_id: trackId,
|
||||
client_id: clientId
|
||||
});
|
||||
// The user may have moved on by the time the response arrives. Only
|
||||
// adopt the id if we're still on the same track and still playing.
|
||||
if (player.current?.id === trackId && player.state === 'playing') {
|
||||
openPlayEventId = res.play_event_id;
|
||||
openTrackId = trackId;
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; missed events are OK in v1.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user