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:
@@ -56,3 +56,11 @@ export type SearchResponse = {
|
|||||||
export type RadioResponse = {
|
export type RadioResponse = {
|
||||||
tracks: TrackRef[];
|
tracks: TrackRef[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type EventRequest =
|
||||||
|
| { type: 'play_started'; track_id: string; client_id?: string }
|
||||||
|
| { type: 'play_ended'; play_event_id: string; duration_played_ms: number }
|
||||||
|
| { type: 'play_skipped'; play_event_id: string; position_ms: number };
|
||||||
|
|
||||||
|
export type PlayStartedResponse = { play_event_id: string; session_id: string };
|
||||||
|
export type EventOkResponse = { ok: true };
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
|
||||||
|
import { getOrCreateClientId } from './clientId';
|
||||||
|
|
||||||
|
beforeEach(() => sessionStorage.clear());
|
||||||
|
afterEach(() => sessionStorage.clear());
|
||||||
|
|
||||||
|
describe('getOrCreateClientId', () => {
|
||||||
|
test('generates a UUID on first call and persists it in sessionStorage', () => {
|
||||||
|
const id = getOrCreateClientId();
|
||||||
|
expect(id).toMatch(/^[0-9a-f-]{36}$/);
|
||||||
|
expect(sessionStorage.getItem('minstrel.client_id')).toBe(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the same id on subsequent calls', () => {
|
||||||
|
const a = getOrCreateClientId();
|
||||||
|
const b = getOrCreateClientId();
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
const KEY = 'minstrel.client_id';
|
||||||
|
|
||||||
|
export function getOrCreateClientId(): string {
|
||||||
|
try {
|
||||||
|
const existing = sessionStorage.getItem(KEY);
|
||||||
|
if (existing) return existing;
|
||||||
|
} catch {
|
||||||
|
// sessionStorage unavailable (non-browser env). Generate ephemerally.
|
||||||
|
}
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
try { sessionStorage.setItem(KEY, id); } catch { /* ignore */ }
|
||||||
|
return id;
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||||
|
import { flushSync } from 'svelte';
|
||||||
|
|
||||||
|
vi.mock('$lib/api/client', () => ({
|
||||||
|
api: {
|
||||||
|
post: vi.fn().mockResolvedValue({ play_event_id: 'pe-1', session_id: 's-1' })
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./clientId', () => ({
|
||||||
|
getOrCreateClientId: () => 'test-client'
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useEventsDispatcher } from './events.svelte';
|
||||||
|
import {
|
||||||
|
playQueue,
|
||||||
|
reportStateFromAudio,
|
||||||
|
reportTimeUpdate,
|
||||||
|
reportDuration,
|
||||||
|
skipNext,
|
||||||
|
} from './store.svelte';
|
||||||
|
import { api } from '$lib/api/client';
|
||||||
|
import type { TrackRef } from '$lib/api/types';
|
||||||
|
|
||||||
|
function track(id: string, dur = 200): TrackRef {
|
||||||
|
return {
|
||||||
|
id, title: `T ${id}`,
|
||||||
|
album_id: 'a', album_title: 'A',
|
||||||
|
artist_id: 'ar', artist_name: 'Ar',
|
||||||
|
track_number: Number(id), disc_number: 1,
|
||||||
|
duration_sec: dur,
|
||||||
|
stream_url: `/api/tracks/${id}/stream`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
// Reset the player store to idle.
|
||||||
|
playQueue([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// sendBeacon spies are restored automatically per test.
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useEventsDispatcher', () => {
|
||||||
|
test('first transition into playing for a new track POSTs play_started', async () => {
|
||||||
|
const cleanup = $effect.root(() => useEventsDispatcher());
|
||||||
|
|
||||||
|
playQueue([track('1')]);
|
||||||
|
flushSync();
|
||||||
|
reportStateFromAudio('playing');
|
||||||
|
flushSync();
|
||||||
|
// Microtask drain so the api.post promise settles.
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
const startedCalls = calls.filter((c) => (c[1] as { type: string }).type === 'play_started');
|
||||||
|
expect(startedCalls.length).toBe(1);
|
||||||
|
expect(startedCalls[0][0]).toBe('/api/events');
|
||||||
|
expect(startedCalls[0][1]).toMatchObject({
|
||||||
|
type: 'play_started',
|
||||||
|
track_id: '1',
|
||||||
|
client_id: 'test-client'
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('track end (duration reached, paused) POSTs play_ended', async () => {
|
||||||
|
const cleanup = $effect.root(() => useEventsDispatcher());
|
||||||
|
|
||||||
|
playQueue([track('1', 200)]);
|
||||||
|
flushSync();
|
||||||
|
reportDuration(200);
|
||||||
|
reportStateFromAudio('playing');
|
||||||
|
flushSync();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
// Reach end of track, then natural pause.
|
||||||
|
reportTimeUpdate(200);
|
||||||
|
flushSync();
|
||||||
|
reportStateFromAudio('ended');
|
||||||
|
flushSync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
const types = calls.map((c) => (c[1] as { type: string }).type);
|
||||||
|
expect(types).toContain('play_started');
|
||||||
|
expect(types).toContain('play_ended');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user-initiated skip mid-track POSTs play_skipped', async () => {
|
||||||
|
const cleanup = $effect.root(() => useEventsDispatcher());
|
||||||
|
|
||||||
|
playQueue([track('1'), track('2')]);
|
||||||
|
flushSync();
|
||||||
|
reportStateFromAudio('playing');
|
||||||
|
flushSync();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
skipNext();
|
||||||
|
flushSync();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
const calls = (api.post as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
const types = calls.map((c) => (c[1] as { type: string }).type);
|
||||||
|
expect(types).toContain('play_skipped');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('pagehide fires sendBeacon when a play is open', async () => {
|
||||||
|
// jsdom doesn't ship navigator.sendBeacon — install a stub before spy.
|
||||||
|
if (typeof navigator.sendBeacon !== 'function') {
|
||||||
|
Object.defineProperty(navigator, 'sendBeacon', {
|
||||||
|
value: () => true,
|
||||||
|
configurable: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const cleanup = $effect.root(() => useEventsDispatcher());
|
||||||
|
const beacon = vi.spyOn(navigator, 'sendBeacon').mockReturnValue(true);
|
||||||
|
|
||||||
|
playQueue([track('1')]);
|
||||||
|
flushSync();
|
||||||
|
reportStateFromAudio('playing');
|
||||||
|
flushSync();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event('pagehide'));
|
||||||
|
|
||||||
|
expect(beacon).toHaveBeenCalled();
|
||||||
|
expect(beacon.mock.calls[0][0]).toBe('/api/events');
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
beacon.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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