import { player } from './store.svelte'; import { api } from '#lib/api/client.js'; import { getOrCreateClientId } from './clientId'; import type { PlayStartedResponse, EventOkResponse } from '#lib/api/types.js'; // 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): // - First transition into 'playing' for a new track → POST play_started. // - Track id changes while a play is open → close the prior row with // play_ended carrying the actual position played to. Server applies // its skip rule (skip_max_completion_ratio + skip_max_duration_played_ms, // default 50%/30s) and decides was_skipped from that. // - 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_ended with // the last position. The server's rule still classifies skip-vs-not. // // The skip-vs-ended call is the server's, as on Android: a client-side // "within 3s of duration" rule would leave the server's threshold dead and // treat every "next" tap as a skip even after most of the track played. export function useEventsDispatcher(): void { let openPlayEventId: string | null = null; let openTrackId: string | null = null; // openLastPositionMs tracks the *open row's* track and is updated ONLY while // that track is current — so a track change (which synchronously resets the // store's position to 0 and duration to 0) can't clobber it before the close // branch reads it. The pagehide + natural-end branches can read // player.position directly because their current track is still the open one // at fire time. let openLastPositionMs = 0; let prevTrackId: string | null = null; let prevState: string | null = null; const clientId = getOrCreateClientId(); $effect(() => { if (openTrackId && player.current?.id === openTrackId) { openLastPositionMs = 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 the prior open row. Always send play_ended // with the actual position played; the server's skip rule decides // whether to flag was_skipped. if (tid !== prevTrackId && openPlayEventId) { const id = openPlayEventId; const dur = openLastPositionMs; openPlayEventId = null; openTrackId = null; openLastPositionMs = 0; api.post('/api/events', { type: 'play_ended', play_event_id: id, duration_played_ms: dur }).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 = Math.round(player.position * 1000); openPlayEventId = null; openTrackId = null; openLastPositionMs = 0; api.post('/api/events', { type: 'play_ended', play_event_id: id, duration_played_ms: dur }).catch(() => {}); } prevTrackId = tid; prevState = s; }); // Page close: sendBeacon a play_ended if an open play exists. The // server's skip rule decides classification from the duration we // actually played. Reads player.position directly rather than the // tracked lastPositionMs — the open track is still current at // pagehide time so the live read gives the freshest value, and the // $effect-tracked path isn't reliably reached in test fixtures. if (typeof window !== 'undefined') { const onPageHide = () => { if (!openPlayEventId) return; const posMs = Math.round(player.position * 1000); const payload = JSON.stringify({ type: 'play_ended', play_event_id: openPlayEventId, duration_played_ms: posMs }); navigator.sendBeacon('/api/events', new Blob([payload], { type: 'application/json' })); }; window.addEventListener('pagehide', onPageHide); } async function startNew(trackId: string): Promise { try { const res = await api.post('/api/events', { type: 'play_started', track_id: trackId, client_id: clientId, // Tag the play with the system playlist it came from // (null for library / user-playlist / radio) so the server // can advance that playlist's rotation. source: player.queueSource ?? undefined, // Device class for context conditioning. The web app is a // browser session, so it reports 'web'. device_class: 'web' }); // 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. } } }