import type { EventKind, SessionEvent } from "@a4anthony/proctorkit-types"; /** * Shared core behind `playAudioFile()` and `playVideoFile()`. Everything * here operates on `HTMLMediaElement` — playback lifecycle, terminal-state * tracking, audio output (sink) routing with devicechange monitoring, and * privacy-safe payloads. The thin `AudioFilePlayer` / `VideoFilePlayer` * subclasses pin down the element type, the emitted event-kind prefix, and * user-facing error wording. */ export type MediaPlaybackKindPrefix = "audio-playback" | "video-playback"; export interface MediaFilePlaybackOptions< TElement extends HTMLMediaElement = HTMLMediaElement, > { /** URL of the media file to play. Query strings are omitted from emitted events. */ url: string | URL; /** Optional reviewer-friendly label, for example "Question 5 listening audio". */ label?: string; /** * Optional existing media element. Pass this when your app renders native * controls and wants the SDK helper to attach telemetry to that element. */ element?: TElement; /** Start playback immediately. Default: true. */ autoplay?: boolean; loop?: boolean; volume?: number; playbackRate?: number; /** * Optional audio output device id. When supported, the helper calls * HTMLMediaElement.setSinkId() before playback so prompts route * through the candidate's selected speaker/headphones. */ sinkId?: string; onStarted?: (payload: SessionEvent["payload"]) => void; onPaused?: (payload: SessionEvent["payload"]) => void; onEnded?: (payload: SessionEvent["payload"]) => void; onStopped?: (payload: SessionEvent["payload"]) => void; onError?: (error: Error, payload: SessionEvent["payload"]) => void; } export interface MediaFilePlaybackHandle< TElement extends HTMLMediaElement = HTMLMediaElement, > { readonly url: string; readonly element: TElement; play(): Promise; pause(): void; stop(): void; seek(seconds: number): void; setSinkId(sinkId?: string | null): Promise; dispose(): void; } /** Client-internal hooks threaded in by ProctoringClient. */ export interface MediaFilePlayerHooks< TElement extends HTMLMediaElement = HTMLMediaElement, > { emit?: (kind: EventKind, payload: SessionEvent["payload"]) => void; onDisposed?: (player: MediaFilePlaybackHandle) => void; } export interface MediaFilePlayerConfig { kindPrefix: MediaPlaybackKindPrefix; mediaNoun: "Audio" | "Video"; createElement: () => TElement; } export class MediaFilePlayer implements MediaFilePlaybackHandle { readonly url: string; readonly element: TElement; private readonly kindPrefix: MediaPlaybackKindPrefix; private readonly mediaNoun: "Audio" | "Video"; private readonly safeUrl: string; private readonly label: string | undefined; private readonly createdElement: boolean; private readonly emit: MediaFilePlayerHooks["emit"]; private readonly onStarted: MediaFilePlaybackOptions["onStarted"]; private readonly onPaused: MediaFilePlaybackOptions["onPaused"]; private readonly onEnded: MediaFilePlaybackOptions["onEnded"]; private readonly onStopped: MediaFilePlaybackOptions["onStopped"]; private readonly onError: MediaFilePlaybackOptions["onError"]; private readonly onDisposed: MediaFilePlayerHooks["onDisposed"]; private sinkId: string | undefined; private sinkReady: Promise | null = null; private monitoringDeviceChanges = false; private deviceChangeCheck: Promise | null = null; private disposed = false; private suppressNextPause = false; private terminalState: "stopped" | "ended" | null = null; private hasStarted = false; constructor( options: MediaFilePlaybackOptions & MediaFilePlayerHooks, config: MediaFilePlayerConfig, ) { this.kindPrefix = config.kindPrefix; this.mediaNoun = config.mediaNoun; this.url = String(options.url); this.safeUrl = safeMediaUrl(options.url); this.label = options.label; this.emit = options.emit; this.onStarted = options.onStarted; this.onPaused = options.onPaused; this.onEnded = options.onEnded; this.onStopped = options.onStopped; this.onError = options.onError; this.onDisposed = options.onDisposed; this.sinkId = options.sinkId || undefined; this.createdElement = !options.element; this.element = options.element ?? config.createElement(); this.element.src = this.url; this.element.preload = this.element.preload || "metadata"; this.element.loop = options.loop ?? this.element.loop; if (options.volume !== undefined) { this.element.volume = Math.max(0, Math.min(1, options.volume)); } if (options.playbackRate !== undefined) { this.element.playbackRate = Math.max(0.25, Math.min(4, options.playbackRate)); } this.element.addEventListener("play", this.handlePlay); this.element.addEventListener("pause", this.handlePause); this.element.addEventListener("ended", this.handleEnded); this.element.addEventListener("error", this.handleElementError); this.syncDeviceChangeMonitor(); } async play(): Promise { this.assertActive(); try { await this.ensureSinkReady(); await this.element.play(); } catch (err) { const error = err instanceof Error ? err : new Error(String(err)); this.emitError(error); throw error; } } pause(): void { if (this.disposed) return; this.element.pause(); } stop(): void { if (this.disposed) return; if (this.terminalState) return; if (!this.hasStarted) return; this.suppressNextPause = true; this.element.pause(); this.seek(0); this.terminalState = "stopped"; const payload = this.payload(); this.emit?.(`${this.kindPrefix}.stopped`, payload); this.onStopped?.(payload); } seek(seconds: number): void { if (this.disposed) return; if (!Number.isFinite(seconds)) return; this.element.currentTime = Math.max(0, seconds); } async setSinkId(sinkId?: string | null): Promise { this.assertActive(); const nextSinkId = sinkId || undefined; if (nextSinkId === this.sinkId && this.sinkReady) { await this.sinkReady; return; } const previousSinkId = this.sinkId; this.sinkId = nextSinkId; this.syncDeviceChangeMonitor(); this.sinkReady = this.setAudioSink(nextSinkId, { routeDefault: !nextSinkId && Boolean(previousSinkId), previousSinkId, }); await this.sinkReady; } dispose(): void { if (this.disposed) return; this.stop(); this.element.removeEventListener("play", this.handlePlay); this.element.removeEventListener("pause", this.handlePause); this.element.removeEventListener("ended", this.handleEnded); this.element.removeEventListener("error", this.handleElementError); this.stopDeviceChangeMonitor(); if (this.createdElement) { this.element.removeAttribute("src"); this.element.load(); } this.disposed = true; this.onDisposed?.(this); } private assertActive(): void { if (this.disposed) { throw new Error(`${this.mediaNoun} playback handle has been disposed`); } } private readonly handlePlay = (): void => { this.hasStarted = true; this.terminalState = null; const payload = this.payload(); this.emit?.(`${this.kindPrefix}.started`, payload); this.onStarted?.(payload); }; private readonly handlePause = (): void => { if (this.suppressNextPause) { this.suppressNextPause = false; return; } if (!this.hasStarted) return; if (this.terminalState) return; const payload = this.payload(); this.emit?.(`${this.kindPrefix}.paused`, payload); this.onPaused?.(payload); }; private readonly handleEnded = (): void => { this.terminalState = "ended"; const payload = this.payload(); this.emit?.(`${this.kindPrefix}.ended`, payload); this.onEnded?.(payload); }; private readonly handleElementError = (): void => { this.emitError(new Error(`${this.mediaNoun} file could not be played`)); }; private emitError(error: Error): void { const payload = this.payload({ message: error.message }); this.emit?.(`${this.kindPrefix}.error`, payload); this.onError?.(error, payload); } private ensureSinkReady(): Promise { if (!this.sinkReady) { this.sinkReady = this.setAudioSink(this.sinkId); } return this.sinkReady; } private async setAudioSink( sinkId: string | undefined, options: { routeDefault?: boolean; previousSinkId?: string } = {}, ): Promise { const targetSinkId = sinkId ?? (options.routeDefault ? "" : undefined); if (targetSinkId === undefined) return; const target = this.element as SinkCapableMediaElement; if (typeof target.setSinkId !== "function") { this.emit?.( `${this.kindPrefix}.sink-unsupported`, this.payload({ sinkId: sinkId ?? "default", ...(options.previousSinkId ? { previousSinkId: options.previousSinkId } : {}), message: "Audio output routing is not supported by this browser.", }), ); return; } try { await target.setSinkId(targetSinkId); this.emit?.( `${this.kindPrefix}.sink-applied`, this.payload({ sinkId: sinkId ?? "default", ...(options.previousSinkId ? { previousSinkId: options.previousSinkId } : {}), }), ); } catch (value) { const error = value instanceof Error ? value : new Error(String(value)); this.emit?.( `${this.kindPrefix}.sink-failed`, this.payload({ sinkId: sinkId ?? "default", ...(options.previousSinkId ? { previousSinkId: options.previousSinkId } : {}), message: error.message, }), ); } } private syncDeviceChangeMonitor(): void { if (this.disposed) return; if (!this.sinkId || this.sinkId === "default") { this.stopDeviceChangeMonitor(); return; } const mediaDevices = getMediaDevices(); if (!mediaDevices) return; if (this.monitoringDeviceChanges) return; mediaDevices.addEventListener("devicechange", this.handleDeviceChange); this.monitoringDeviceChanges = true; } private stopDeviceChangeMonitor(): void { if (!this.monitoringDeviceChanges) return; getMediaDevices()?.removeEventListener( "devicechange", this.handleDeviceChange, ); this.monitoringDeviceChanges = false; } private readonly handleDeviceChange = (): void => { if (this.deviceChangeCheck) return; this.deviceChangeCheck = this.checkSelectedSinkAvailability().finally(() => { this.deviceChangeCheck = null; }); }; private async checkSelectedSinkAvailability(): Promise { if (this.disposed) return; const checkedSinkId = this.sinkId; if (!checkedSinkId || checkedSinkId === "default") return; const mediaDevices = getMediaDevices(); if (!mediaDevices) return; let devices: MediaDeviceInfo[]; try { devices = await mediaDevices.enumerateDevices(); } catch (value) { const error = value instanceof Error ? value : new Error(String(value)); this.emit?.( `${this.kindPrefix}.sink-failed`, this.payload({ sinkId: checkedSinkId, message: `Could not verify audio output devices: ${error.message}`, }), ); return; } const stillAvailable = devices.some( (device) => device.kind === "audiooutput" && device.deviceId === checkedSinkId, ); if (stillAvailable || this.sinkId !== checkedSinkId) return; this.emit?.( `${this.kindPrefix}.sink-disconnected`, this.payload({ sinkId: checkedSinkId, fallbackSinkId: "default", message: "Selected audio output device is no longer available.", }), ); this.sinkId = undefined; this.syncDeviceChangeMonitor(); this.sinkReady = this.setAudioSink(undefined, { routeDefault: true, previousSinkId: checkedSinkId, }); await this.sinkReady; } private payload(extra: Record = {}): Record { return { url: this.safeUrl, ...(this.label ? { label: this.label } : {}), currentTime: finiteNumber(this.element.currentTime), duration: finiteNumber(this.element.duration), playbackRate: finiteNumber(this.element.playbackRate), ...extra, }; } } type SinkCapableMediaElement = HTMLMediaElement & { setSinkId?: (sinkId: string) => Promise; }; function getMediaDevices(): MediaDevices | null { if (typeof navigator === "undefined") return null; const mediaDevices = navigator.mediaDevices; if ( !mediaDevices || typeof mediaDevices.addEventListener !== "function" || typeof mediaDevices.removeEventListener !== "function" || typeof mediaDevices.enumerateDevices !== "function" ) { return null; } return mediaDevices; } function finiteNumber(value: number): number | null { return Number.isFinite(value) ? value : null; } function safeMediaUrl(input: string | URL): string { const raw = String(input); try { const base = typeof window !== "undefined" && window.location?.href ? window.location.href : "http://localhost/"; const url = new URL(raw, base); url.search = ""; url.hash = ""; if (typeof window !== "undefined" && url.origin === window.location.origin) { return url.pathname; } return `${url.origin}${url.pathname}`; } catch { return raw.split(/[?#]/, 1)[0] ?? raw; } }