Rewrites the web app's comments to rule 32. Task, milestone, rule and spec ids are gone; regression stories are restated as the trap they guard (public auth routes, the menu's stopPropagation, universal loads under adapter-static, metric deltas with their margin). Flutter and Tauri mentions now name the Android app or a native shell, operator quotes and dates are dropped, and one library's counts no longer appear outside test fixtures. Verified with a TypeScript-scanner token comparison (template literals and regex literals re-scanned): only comment text changed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
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<EventOkResponse>('/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<EventOkResponse>('/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<void> {
|
|
try {
|
|
const res = await api.post<PlayStartedResponse>('/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.
|
|
}
|
|
}
|
|
}
|