diff --git a/webapp/apps/recording-player/index.css b/webapp/apps/recording-player/index.css index b8fbcf612..d00a7055d 100644 --- a/webapp/apps/recording-player/index.css +++ b/webapp/apps/recording-player/index.css @@ -2,9 +2,18 @@ html, body { + width: 100%; + height: 100%; + margin: 0; background-color: black; } +shadow-player { + display: block; + width: 100%; + height: 100%; +} + #terminal { height: 100%; } diff --git a/webapp/apps/recording-player/public/locales/de/translation.json b/webapp/apps/recording-player/public/locales/de/translation.json index bd387d25a..2a6732f4c 100644 --- a/webapp/apps/recording-player/public/locales/de/translation.json +++ b/webapp/apps/recording-player/public/locales/de/translation.json @@ -6,6 +6,17 @@ "unknownError": "Unbekannter Fehler, bitte versuchen Sie es erneut", "protocolError": "Ein Fehler ist aufgetreten: {{error}}" }, + "controls": { + "play": "Wiedergabe", + "pause": "Pause", + "mute": "Stummschalten", + "unmute": "Stummschaltung aufheben", + "volume": "Lautstärke", + "timeline": "Aufzeichnungszeitachse", + "fullscreen": "Vollbild", + "exitFullscreen": "Vollbild beenden", + "clip": "Clip" + }, "ui": { "close": "Schließen" } diff --git a/webapp/apps/recording-player/public/locales/en/translation.json b/webapp/apps/recording-player/public/locales/en/translation.json index 9802ff8a1..33c645ece 100644 --- a/webapp/apps/recording-player/public/locales/en/translation.json +++ b/webapp/apps/recording-player/public/locales/en/translation.json @@ -6,6 +6,17 @@ "unknownError": "Unknown error, please try again", "protocolError": "An error occurred: {{error}}" }, + "controls": { + "play": "Play", + "pause": "Pause", + "mute": "Mute", + "unmute": "Unmute", + "volume": "Volume", + "timeline": "Recording timeline", + "fullscreen": "Fullscreen", + "exitFullscreen": "Exit fullscreen", + "clip": "Clip" + }, "ui": { "close": "Close" } diff --git a/webapp/apps/recording-player/public/locales/es/translation.json b/webapp/apps/recording-player/public/locales/es/translation.json index 9d12b8d06..811714f1e 100644 --- a/webapp/apps/recording-player/public/locales/es/translation.json +++ b/webapp/apps/recording-player/public/locales/es/translation.json @@ -6,6 +6,17 @@ "unknownError": "Error desconocido, por favor intente de nuevo", "protocolError": "Se produjo un error: {{error}}" }, + "controls": { + "play": "Reproducir", + "pause": "Pausar", + "mute": "Silenciar", + "unmute": "Activar sonido", + "volume": "Volumen", + "timeline": "Línea de tiempo de la grabación", + "fullscreen": "Pantalla completa", + "exitFullscreen": "Salir de pantalla completa", + "clip": "Clip" + }, "ui": { "close": "Cerrar" } diff --git a/webapp/apps/recording-player/public/locales/fr/translation.json b/webapp/apps/recording-player/public/locales/fr/translation.json index a6eb01a40..28cabf7d5 100644 --- a/webapp/apps/recording-player/public/locales/fr/translation.json +++ b/webapp/apps/recording-player/public/locales/fr/translation.json @@ -6,6 +6,17 @@ "unknownError": "Erreur inconnue, veuillez réessayer", "protocolError": "Une erreur s'est produite: {{error}}" }, + "controls": { + "play": "Lire", + "pause": "Pause", + "mute": "Couper le son", + "unmute": "Réactiver le son", + "volume": "Volume", + "timeline": "Chronologie de l'enregistrement", + "fullscreen": "Plein écran", + "exitFullscreen": "Quitter le plein écran", + "clip": "Séquence" + }, "ui": { "close": "Fermer" } diff --git a/webapp/apps/recording-player/src/i18n.ts b/webapp/apps/recording-player/src/i18n.ts index c2aac9b65..b1547be30 100644 --- a/webapp/apps/recording-player/src/i18n.ts +++ b/webapp/apps/recording-player/src/i18n.ts @@ -8,6 +8,15 @@ export type TranslationKeys = | 'notifications.unauthorized' | 'notifications.unknownError' | 'notifications.protocolError' + | 'controls.play' + | 'controls.pause' + | 'controls.mute' + | 'controls.unmute' + | 'controls.volume' + | 'controls.timeline' + | 'controls.fullscreen' + | 'controls.exitFullscreen' + | 'controls.clip' | 'ui.close'; /** diff --git a/webapp/apps/recording-player/src/streamers/webm.ts b/webapp/apps/recording-player/src/streamers/webm.ts index 9ddee0a4c..f889b522e 100644 --- a/webapp/apps/recording-player/src/streamers/webm.ts +++ b/webapp/apps/recording-player/src/streamers/webm.ts @@ -5,19 +5,25 @@ import { t } from '../i18n'; import { showNotification } from '../notification'; export async function handleWebm(gatewayAccessApi: GatewayAccessApi) { - // Create element with correct spelling const shadowPlayer = document.createElement('shadow-player') as ShadowPlayer; + shadowPlayer.setAttribute('controls', ''); + shadowPlayer.setControlLabels({ + play: t('controls.play'), + pause: t('controls.pause'), + mute: t('controls.mute'), + unmute: t('controls.unmute'), + volume: t('controls.volume'), + timeline: t('controls.timeline'), + fullscreen: t('controls.fullscreen'), + exitFullscreen: t('controls.exitFullscreen'), + clip: t('controls.clip'), + }); - // Append to DOM document.body.appendChild(shadowPlayer); - // Wait for element to be initialized await customElements.whenDefined('shadow-player'); - - // Wait for next microtask to ensure connectedCallback has run await new Promise((resolve) => setTimeout(resolve, 0)); - // Now safe to call methods shadowPlayer.srcChange(gatewayAccessApi.sessionShadowingUrl()); shadowPlayer.play(); diff --git a/webapp/packages/shadow-player/demo-src/apiClient.ts b/webapp/packages/shadow-player/demo-src/apiClient.ts index 9f3de2d1d..46950ad4f 100644 --- a/webapp/packages/shadow-player/demo-src/apiClient.ts +++ b/webapp/packages/shadow-player/demo-src/apiClient.ts @@ -1,6 +1,6 @@ // Base URL of the API -const TOKEN_SERVER_BASE_URL = 'http://localhost:8080'; -const GATEWAY_BASE_URL = 'http://localhost:7171'; +const TOKEN_SERVER_BASE_URL = import.meta.env.VITE_TOKEN_SERVER_BASE_URL ?? 'http://localhost:8080'; +const GATEWAY_BASE_URL = import.meta.env.VITE_GATEWAY_BASE_URL ?? 'http://localhost:7171'; // Common request fields interface CommonRequest { diff --git a/webapp/packages/shadow-player/index.html b/webapp/packages/shadow-player/index.html index 578f4abe2..541e75c8d 100644 --- a/webapp/packages/shadow-player/index.html +++ b/webapp/packages/shadow-player/index.html @@ -94,7 +94,7 @@ background-color: #e9ecef; } - webm-stream-player { + shadow-player { width: 80%; height: 80%; background-color: #000; @@ -120,7 +120,7 @@

Streaming Files

- +
diff --git a/webapp/packages/shadow-player/package.json b/webapp/packages/shadow-player/package.json index fddd1d8a6..d8ee79f78 100644 --- a/webapp/packages/shadow-player/package.json +++ b/webapp/packages/shadow-player/package.json @@ -8,13 +8,16 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "devDependencies": { + "jsdom": "^20.0.3", "ts-node": "^10.9.2", "typescript": "~5.6.2", "vite": "^5.4.9", "vite-plugin-dts": "^4.3.0", - "vite-plugin-static-copy": "^2.3.0" + "vite-plugin-static-copy": "^2.3.0", + "vitest": "^3.1.1" } } diff --git a/webapp/packages/shadow-player/src/playbackClip.test.ts b/webapp/packages/shadow-player/src/playbackClip.test.ts new file mode 100644 index 000000000..d636d9893 --- /dev/null +++ b/webapp/packages/shadow-player/src/playbackClip.test.ts @@ -0,0 +1,93 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { PlaybackClip } from './playbackClip'; + +class FakeSourceBuffer extends EventTarget { + updating = false; + + appendBuffer(): void { + if (this.updating) { + throw new Error('concurrent append'); + } + this.updating = true; + } + + completeAppend(): void { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + } +} + +class FakeMediaSource extends EventTarget { + static latest: FakeMediaSource | null = null; + + readyState: ReadyState = 'closed'; + readonly sourceBuffer = new FakeSourceBuffer(); + endOfStreamCalls = 0; + + constructor() { + super(); + FakeMediaSource.latest = this; + } + + addSourceBuffer(): SourceBuffer { + return this.sourceBuffer as unknown as SourceBuffer; + } + + open(): void { + this.readyState = 'open'; + this.dispatchEvent(new Event('sourceopen')); + } + + endOfStream(): void { + if (this.sourceBuffer.updating) { + throw new Error('endOfStream during append'); + } + this.endOfStreamCalls += 1; + this.readyState = 'ended'; + } +} + +describe('PlaybackClip', () => { + beforeEach(() => { + vi.stubGlobal('MediaSource', FakeMediaSource); + vi.stubGlobal('URL', { + createObjectURL: vi.fn(() => 'blob:test'), + revokeObjectURL: vi.fn(), + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + FakeMediaSource.latest = null; + }); + + it('waits for pending SourceBuffer work before ending the MediaSource', async () => { + const clip = new PlaybackClip({ + type: 'segment-started', + codec: 'vp8', + sequence: 0, + width: 640, + height: 480, + }); + const mediaSource = FakeMediaSource.latest; + expect(mediaSource).not.toBeNull(); + mediaSource?.open(); + await clip.open(); + + const append = clip.append(new Uint8Array([1])); + const finish = Promise.resolve().then(() => clip.finish()); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + expect(mediaSource?.sourceBuffer.updating).toBe(true); + expect(mediaSource?.endOfStreamCalls).toBe(0); + + mediaSource?.sourceBuffer.completeAppend(); + await append; + await finish; + expect(mediaSource?.endOfStreamCalls).toBe(1); + }); +}); diff --git a/webapp/packages/shadow-player/src/playbackClip.ts b/webapp/packages/shadow-player/src/playbackClip.ts new file mode 100644 index 000000000..5b08b5328 --- /dev/null +++ b/webapp/packages/shadow-player/src/playbackClip.ts @@ -0,0 +1,91 @@ +import type { SegmentStartedMessage } from './protocol'; +import { ReactiveSourceBuffer } from './sourceBuffer'; + +export class PlaybackClip { + readonly video = document.createElement('video'); + + private readonly mediaSource = new MediaSource(); + private readonly objectUrl = URL.createObjectURL(this.mediaSource); + private readonly opened: Promise; + private sourceBuffer: ReactiveSourceBuffer | null = null; + private debug = false; + private complete = false; + private finishing: Promise | null = null; + + constructor(readonly metadata: SegmentStartedMessage) { + this.video.src = this.objectUrl; + this.opened = new Promise((resolve, reject) => { + const cleanup = () => { + this.mediaSource.removeEventListener('sourceopen', onOpen); + this.mediaSource.removeEventListener('sourceclose', onClose); + }; + const onOpen = () => { + cleanup(); + try { + this.sourceBuffer = new ReactiveSourceBuffer(this.mediaSource, metadata.codec); + this.sourceBuffer.setDebug(this.debug); + resolve(); + } catch (error) { + reject(error); + } + }; + const onClose = () => { + cleanup(); + reject(new Error('MediaSource closed before it opened')); + }; + + this.mediaSource.addEventListener('sourceopen', onOpen); + this.mediaSource.addEventListener('sourceclose', onClose); + }); + } + + async open(): Promise { + await this.opened; + } + + async append(data: Uint8Array): Promise { + await this.opened; + if (this.complete || !this.sourceBuffer) { + throw new Error('Cannot append to a completed clip'); + } + await this.sourceBuffer.appendBuffer(data); + } + + async finish(): Promise { + await this.opened; + if (this.finishing) { + return this.finishing; + } + const sourceBuffer = this.sourceBuffer; + if (this.complete || !sourceBuffer) { + return; + } + + this.complete = true; + this.finishing = (async () => { + await sourceBuffer.whenIdle(); + if (this.mediaSource.readyState !== 'open') { + throw new Error('Cannot finish a MediaSource that is not open'); + } + this.mediaSource.endOfStream(); + })(); + return this.finishing; + } + + setDebug(debug: boolean): void { + this.debug = debug; + this.sourceBuffer?.setDebug(debug); + } + + downloadBufferedFile(): void { + this.sourceBuffer?.downloadBufferedFile(); + } + + dispose(): void { + this.video.pause(); + this.video.removeAttribute('src'); + this.video.load(); + this.video.remove(); + URL.revokeObjectURL(this.objectUrl); + } +} diff --git a/webapp/packages/shadow-player/src/playbackControls.css b/webapp/packages/shadow-player/src/playbackControls.css new file mode 100644 index 000000000..9c85d1555 --- /dev/null +++ b/webapp/packages/shadow-player/src/playbackControls.css @@ -0,0 +1,149 @@ +.control-bar { + position: absolute; + z-index: 3; + right: 0; + bottom: 0; + left: 0; + display: flex; + height: 30px; + align-items: stretch; + color: #fff; + background: rgba(43, 51, 63, 0.7); + font-family: Arial, Helvetica, sans-serif; +} + +:host(:not([controls])) .control-bar { + display: none; +} + +.control-button { + position: relative; + display: grid; + width: 40px; + min-width: 40px; + height: 30px; + padding: 7px 10px; + place-items: center; + color: inherit; + background: transparent; + border: 0; + cursor: pointer; +} + +.control-button:hover, +.control-button:focus-visible { + color: #fff; + background: rgba(255, 255, 255, 0.12); + outline: none; +} + +.control-button:focus-visible, +.timeline-segment:focus-visible, +.volume-input:focus-visible { + box-shadow: inset 0 0 0 2px #fff; +} + +.control-button svg { + width: 16px; + height: 16px; + fill: currentColor; +} + +.volume-control { + display: flex; + width: 40px; + min-width: 40px; + overflow: hidden; + align-items: center; + transition: width 120ms ease; +} + +.volume-control:hover, +.volume-control:focus-within { + width: 105px; +} + +.volume-input { + width: 0; + height: 3px; + margin: 0; + opacity: 0; + accent-color: #fff; + cursor: pointer; + transition: + width 120ms ease, + opacity 120ms ease; +} + +.volume-control:hover .volume-input, +.volume-control:focus-within .volume-input { + width: 58px; + opacity: 1; +} + +.timeline { + display: flex; + min-width: 4em; + flex: 1; + align-items: center; + touch-action: none; +} + +.timeline-segment { + position: relative; + height: 3px; + min-width: 3px; + margin-left: 3px; + flex-basis: 0; + overflow: visible; + background: rgba(115, 133, 159, 0.5); + cursor: pointer; + transition: height 80ms ease; +} + +.timeline-segment:hover, +.timeline-segment:focus-visible { + height: 10px; + outline: none; +} + +.timeline-segment[aria-disabled="true"] { + cursor: wait; +} + +.timeline-progress { + position: absolute; + inset: 0 auto 0 0; + width: 0; + background: #fff; + pointer-events: none; +} + +.time-tooltip { + position: absolute; + bottom: 15px; + left: 0; + visibility: hidden; + padding: 5px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.8); + border-radius: 2px; + font-size: 12px; + line-height: 1; + pointer-events: none; + transform: translateX(-50%); + white-space: nowrap; +} + +.timeline-segment:hover .time-tooltip, +.timeline-segment:focus-visible .time-tooltip { + visibility: visible; +} + +@media (prefers-reduced-motion: reduce) { + .volume-control, + .volume-input, + .timeline-segment { + transition: none; + } +} diff --git a/webapp/packages/shadow-player/src/playbackControls.ts b/webapp/packages/shadow-player/src/playbackControls.ts new file mode 100644 index 000000000..b116c033e --- /dev/null +++ b/webapp/packages/shadow-player/src/playbackControls.ts @@ -0,0 +1,306 @@ +import styles from './playbackControls.css?inline'; + +export interface PlaybackControlLabels { + play: string; + pause: string; + mute: string; + unmute: string; + volume: string; + timeline: string; + fullscreen: string; + exitFullscreen: string; + clip: string; +} + +export const defaultPlaybackControlLabels: PlaybackControlLabels = { + play: 'Play', + pause: 'Pause', + mute: 'Mute', + unmute: 'Unmute', + volume: 'Volume', + timeline: 'Recording timeline', + fullscreen: 'Fullscreen', + exitFullscreen: 'Exit fullscreen', + clip: 'Clip', +}; + +export type PlaybackControlsAction = + | { type: 'toggle-playback' } + | { type: 'toggle-muted' } + | { type: 'set-volume'; volume: number } + | { type: 'seek'; sequence: number; percentage: number } + | { type: 'toggle-fullscreen' }; + +export type PlaybackControlsSnapshot = + | { + type: 'player'; + playing: boolean; + muted: boolean; + volume: number; + fullscreen: boolean; + } + | { + type: 'segment'; + sequence: number; + startTime: number; + duration: number; + currentTime: number; + progress: number; + playable: boolean; + } + | { type: 'labels'; labels: PlaybackControlLabels } + | { type: 'reset' }; + +const icons = { + play: '', + pause: '', + muted: + '', + volume: + '', + fullscreen: + '', + exitFullscreen: + '', +} as const; + +interface SegmentView { + state: Extract; + track: HTMLDivElement; + fill: HTMLDivElement; + tooltip: HTMLSpanElement; +} + +export class PlaybackControls { + private readonly style: HTMLStyleElement; + private readonly controlBar: HTMLDivElement; + private readonly playButton: HTMLButtonElement; + private readonly muteButton: HTMLButtonElement; + private readonly volumeInput: HTMLInputElement; + private readonly timeline: HTMLDivElement; + private readonly fullscreenButton: HTMLButtonElement; + private readonly segments = new Map(); + private labels = defaultPlaybackControlLabels; + private player = { + playing: false, + muted: true, + volume: 1, + fullscreen: false, + }; + private actionCallback: ((action: PlaybackControlsAction) => void) | null = null; + + constructor(container: HTMLElement) { + this.style = document.createElement('style'); + this.style.textContent = styles; + container.appendChild(this.style); + + this.controlBar = document.createElement('div'); + this.controlBar.className = 'control-bar'; + + this.playButton = this.createControlButton(); + this.playButton.addEventListener('click', () => this.emit({ type: 'toggle-playback' })); + this.controlBar.appendChild(this.playButton); + + const volumeControl = document.createElement('div'); + volumeControl.className = 'volume-control'; + this.muteButton = this.createControlButton(); + this.muteButton.addEventListener('click', () => this.emit({ type: 'toggle-muted' })); + volumeControl.appendChild(this.muteButton); + + this.volumeInput = document.createElement('input'); + this.volumeInput.className = 'volume-input'; + this.volumeInput.type = 'range'; + this.volumeInput.min = '0'; + this.volumeInput.max = '1'; + this.volumeInput.step = '0.05'; + this.volumeInput.addEventListener('input', () => { + this.emit({ type: 'set-volume', volume: Number.parseFloat(this.volumeInput.value) }); + }); + volumeControl.appendChild(this.volumeInput); + this.controlBar.appendChild(volumeControl); + + this.timeline = document.createElement('div'); + this.timeline.className = 'timeline'; + this.timeline.setAttribute('role', 'group'); + this.controlBar.appendChild(this.timeline); + + this.fullscreenButton = this.createControlButton(); + this.fullscreenButton.addEventListener('click', () => this.emit({ type: 'toggle-fullscreen' })); + this.controlBar.appendChild(this.fullscreenButton); + + container.appendChild(this.controlBar); + this.render({ type: 'labels', labels: this.labels }); + this.render({ type: 'player', ...this.player }); + } + + onAction(callback: (action: PlaybackControlsAction) => void): void { + this.actionCallback = callback; + } + + render(snapshot: PlaybackControlsSnapshot): void { + if (snapshot.type === 'player') { + this.renderPlayer(snapshot); + return; + } + if (snapshot.type === 'segment') { + this.renderSegment(snapshot); + return; + } + if (snapshot.type === 'labels') { + this.renderLabels(snapshot.labels); + return; + } + this.segments.clear(); + this.timeline.replaceChildren(); + } + + dispose(): void { + this.actionCallback = null; + this.segments.clear(); + this.controlBar.remove(); + this.style.remove(); + } + + private createControlButton(): HTMLButtonElement { + const button = document.createElement('button'); + button.className = 'control-button'; + button.type = 'button'; + return button; + } + + private renderPlayer(snapshot: Extract): void { + this.player = snapshot; + this.setButton( + this.playButton, + snapshot.playing ? this.labels.pause : this.labels.play, + snapshot.playing ? icons.pause : icons.play, + ); + const silent = snapshot.muted || snapshot.volume === 0; + this.setButton( + this.muteButton, + silent ? this.labels.unmute : this.labels.mute, + silent ? icons.muted : icons.volume, + ); + this.volumeInput.value = String(snapshot.volume); + this.setButton( + this.fullscreenButton, + snapshot.fullscreen ? this.labels.exitFullscreen : this.labels.fullscreen, + snapshot.fullscreen ? icons.exitFullscreen : icons.fullscreen, + ); + } + + private renderLabels(labels: PlaybackControlLabels): void { + this.labels = labels; + this.volumeInput.setAttribute('aria-label', labels.volume); + this.timeline.setAttribute('aria-label', labels.timeline); + this.render({ type: 'player', ...this.player }); + for (const view of this.segments.values()) { + this.renderSegment(view.state); + } + } + + private renderSegment(snapshot: Extract): void { + const view = this.segments.get(snapshot.sequence) ?? this.createSegment(snapshot); + view.state = snapshot; + view.track.style.flexGrow = String(Math.max(1, snapshot.duration)); + view.track.setAttribute('aria-label', `${this.labels.clip} ${snapshot.sequence + 1}`); + view.track.setAttribute('aria-disabled', String(!snapshot.playable)); + view.track.setAttribute('aria-valuenow', String(Math.round(snapshot.progress * 100))); + view.track.setAttribute('aria-valuetext', formatTime(snapshot.startTime + snapshot.currentTime)); + view.fill.style.width = `${snapshot.progress * 100}%`; + } + + private createSegment(snapshot: Extract): SegmentView { + const track = document.createElement('div'); + track.className = 'timeline-segment'; + track.tabIndex = 0; + track.setAttribute('role', 'slider'); + track.setAttribute('aria-valuemin', '0'); + track.setAttribute('aria-valuemax', '100'); + + const fill = document.createElement('div'); + fill.className = 'timeline-progress'; + track.appendChild(fill); + + const tooltip = document.createElement('span'); + tooltip.className = 'time-tooltip'; + track.appendChild(tooltip); + + const view = { state: snapshot, track, fill, tooltip }; + track.addEventListener('click', (event) => this.seekFromPointer(view, event)); + track.addEventListener('pointermove', (event) => this.renderTooltip(view, event)); + track.addEventListener('keydown', (event) => this.seekFromKeyboard(view, event)); + + this.segments.set(snapshot.sequence, view); + this.timeline.appendChild(track); + return view; + } + + private seekFromPointer(view: SegmentView, event: MouseEvent | PointerEvent): void { + if (!view.state.playable) { + return; + } + this.emit({ + type: 'seek', + sequence: view.state.sequence, + percentage: pointerPercentage(view.track, event), + }); + } + + private renderTooltip(view: SegmentView, event: PointerEvent): void { + const percentage = pointerPercentage(view.track, event); + view.tooltip.style.left = `${percentage * 100}%`; + view.tooltip.textContent = formatTime(view.state.startTime + view.state.duration * percentage); + } + + private seekFromKeyboard(view: SegmentView, event: KeyboardEvent): void { + if (!view.state.playable) { + return; + } + let percentage: number | null = null; + if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') { + percentage = view.state.progress - 0.05; + } else if (event.key === 'ArrowRight' || event.key === 'ArrowUp') { + percentage = view.state.progress + 0.05; + } else if (event.key === 'Home') { + percentage = 0; + } else if (event.key === 'End') { + percentage = 1; + } + if (percentage === null) { + return; + } + event.preventDefault(); + this.emit({ + type: 'seek', + sequence: view.state.sequence, + percentage: Math.max(0, Math.min(1, percentage)), + }); + } + + private setButton(button: HTMLButtonElement, label: string, icon: string): void { + button.title = label; + button.setAttribute('aria-label', label); + button.innerHTML = icon; + } + + private emit(action: PlaybackControlsAction): void { + this.actionCallback?.(action); + } +} + +function pointerPercentage(element: HTMLElement, event: MouseEvent | PointerEvent): number { + const bounds = element.getBoundingClientRect(); + return Math.max(0, Math.min(1, (event.clientX - bounds.left) / bounds.width)); +} + +function formatTime(value: number): string { + const seconds = Math.max(0, Math.floor(value)); + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const remainder = seconds % 60; + if (hours > 0) { + return `${hours}:${String(minutes).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`; + } + return `${minutes}:${String(remainder).padStart(2, '0')}`; +} diff --git a/webapp/packages/shadow-player/src/protocol.test.ts b/webapp/packages/shadow-player/src/protocol.test.ts new file mode 100644 index 000000000..92fba7065 --- /dev/null +++ b/webapp/packages/shadow-player/src/protocol.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { parseServerMessage } from './protocol'; + +function encodedMessage(type: number, payload = ''): ArrayBuffer { + const encodedPayload = new TextEncoder().encode(payload); + const message = new Uint8Array(1 + encodedPayload.length); + message[0] = type; + message.set(encodedPayload, 1); + return message.buffer; +} + +describe('parseServerMessage', () => { + it('accepts legacy one-segment metadata', () => { + expect(parseServerMessage(encodedMessage(1, '{"codec":"vp9"}'))).toEqual({ + type: 'segment-started', + codec: 'vp9', + sequence: 0, + }); + }); + + it('parses independent segment metadata', () => { + expect(parseServerMessage(encodedMessage(1, '{"codec":"vp8","sequence":2,"width":1280,"height":720}'))).toEqual({ + type: 'segment-started', + codec: 'vp8', + sequence: 2, + width: 1280, + height: 720, + }); + }); + + it('requires stream-ended to have no payload', () => { + expect(parseServerMessage(encodedMessage(3))).toEqual({ type: 'stream-ended' }); + expect(() => parseServerMessage(encodedMessage(3, 'unexpected'))).toThrow('Invalid stream-ended message'); + }); + + it('rejects partially extended metadata', () => { + expect(() => parseServerMessage(encodedMessage(1, '{"codec":"vp8","sequence":0}'))).toThrow('Invalid width'); + }); +}); diff --git a/webapp/packages/shadow-player/src/protocol.ts b/webapp/packages/shadow-player/src/protocol.ts index f942f32a3..debe367f3 100644 --- a/webapp/packages/shadow-player/src/protocol.ts +++ b/webapp/packages/shadow-player/src/protocol.ts @@ -1,81 +1,110 @@ -// Define the message types -export type ServerMessage = ChunkMessage | MetaDataMessage | ErrorMessage | EndMessage; +export type ServerMessage = ChunkMessage | SegmentStartedMessage | ErrorMessage | StreamEndedMessage; export interface ChunkMessage { type: 'chunk'; data: Uint8Array; } -export interface ErrorMessage { - type: 'error'; - error: 'UnexpectedError' | 'UnexpectedEOF'; -} - -export interface MetaDataMessage { - type: 'metadata'; +export interface SegmentStartedMessage { + type: 'segment-started'; codec: 'vp8' | 'vp9'; + sequence: number; + width?: number; + height?: number; } -export interface EndMessage { - type: 'end'; +export interface ErrorMessage { + type: 'error'; + error: 'UnexpectedError'; } -export type ClientMessageTypes = 'start' | 'pull'; +export interface StreamEndedMessage { + type: 'stream-ended'; +} export interface ClientMessage { - type: ClientMessageTypes; + type: 'start' | 'pull'; } -// Function to parse the message export function parseServerMessage(buffer: ArrayBuffer): ServerMessage { - const view = new DataView(buffer); - const typeCode = view.getUint8(0); // Read the first byte as the type code + if (buffer.byteLength === 0) { + throw new Error('Empty server message'); + } + const typeCode = new DataView(buffer).getUint8(0); if (typeCode === 0) { - // Chunk message - const chunkData = new Uint8Array(buffer, 1); // The rest is the chunk data return { type: 'chunk', - data: chunkData, + data: new Uint8Array(buffer, 1), }; } + if (typeCode === 1) { - // Metadata message (JSON) - const jsonString = new TextDecoder().decode(new Uint8Array(buffer, 1)); // Decode the rest as a string - const json = JSON.parse(jsonString); + const metadata = parseJsonPayload(buffer); + if (metadata.sequence === undefined && metadata.width === undefined && metadata.height === undefined) { + if (metadata.codec !== 'vp8' && metadata.codec !== 'vp9') { + throw new Error('Unsupported stream codec'); + } + return { + type: 'segment-started', + codec: metadata.codec, + sequence: 0, + }; + } + + if (metadata.codec !== 'vp8') { + throw new Error('Unsupported stream codec'); + } return { - type: 'metadata', - codec: json.codec === 'vp8' ? 'vp8' : 'vp9', + type: 'segment-started', + codec: metadata.codec, + sequence: readInteger(metadata.sequence, 'sequence', 0), + width: readInteger(metadata.width, 'width', 1), + height: readInteger(metadata.height, 'height', 1), }; } if (typeCode === 2) { - // Metadata message (JSON) - const jsonString = new TextDecoder().decode(new Uint8Array(buffer, 1)); // Decode the rest as a string - const json = JSON.parse(jsonString); - + const payload = parseJsonPayload(buffer); + if (payload.error !== 'UnexpectedError') { + throw new Error('Unknown server error'); + } return { type: 'error', - error: json.error, + error: payload.error, }; } if (typeCode === 3) { - return { - type: 'end', - }; + if (buffer.byteLength !== 1) { + throw new Error('Invalid stream-ended message'); + } + return { type: 'stream-ended' }; } - throw new Error('Unknown message type'); + throw new Error('Unknown server message type'); } export function parseClientMessage(message: ClientMessage): Uint8Array { if (message.type === 'start') { return new Uint8Array([0]); } - if (message.type === 'pull') { - return new Uint8Array([1]); + return new Uint8Array([1]); +} + +function parseJsonPayload(buffer: ArrayBuffer): Record { + const text = new TextDecoder('utf-8', { fatal: true }).decode(new Uint8Array(buffer, 1)); + const value: unknown = JSON.parse(text); + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error('Invalid server message payload'); + } + return value as Record; +} + +function readInteger(value: unknown, field: string, minimum: number): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < minimum) { + throw new Error(`Invalid ${field}`); } - throw new Error('Unknown message type'); + return value; } diff --git a/webapp/packages/shadow-player/src/sourceBuffer.test.ts b/webapp/packages/shadow-player/src/sourceBuffer.test.ts new file mode 100644 index 000000000..a98cd4c04 --- /dev/null +++ b/webapp/packages/shadow-player/src/sourceBuffer.test.ts @@ -0,0 +1,53 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; +import { ReactiveSourceBuffer } from './sourceBuffer'; + +class FakeSourceBuffer extends EventTarget { + updating = false; + readonly appended: Uint8Array[] = []; + + appendBuffer(buffer: BufferSource): void { + if (this.updating) { + throw new Error('concurrent append'); + } + this.updating = true; + const bytes = + buffer instanceof ArrayBuffer + ? new Uint8Array(buffer) + : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength); + this.appended.push(Uint8Array.from(bytes)); + } + + completeAppend(): void { + this.updating = false; + this.dispatchEvent(new Event('updateend')); + } +} + +describe('ReactiveSourceBuffer', () => { + it('serializes append operations', async () => { + const sourceBuffer = new FakeSourceBuffer(); + const mediaSource = { + addSourceBuffer: () => sourceBuffer, + } as unknown as MediaSource; + const reactive = new ReactiveSourceBuffer(mediaSource, 'vp8'); + + const first = reactive.appendBuffer(new Uint8Array([1])); + const secondResult = reactive.appendBuffer(new Uint8Array([2])).then( + () => null, + (error: unknown) => error, + ); + + await Promise.resolve(); + expect(sourceBuffer.appended).toEqual([new Uint8Array([1])]); + + sourceBuffer.completeAppend(); + await first; + await Promise.resolve(); + expect(sourceBuffer.appended).toEqual([new Uint8Array([1]), new Uint8Array([2])]); + + sourceBuffer.completeAppend(); + expect(await secondResult).toBeNull(); + }); +}); diff --git a/webapp/packages/shadow-player/src/sourceBuffer.ts b/webapp/packages/shadow-player/src/sourceBuffer.ts index 8f115c7bc..c86cfcd66 100644 --- a/webapp/packages/shadow-player/src/sourceBuffer.ts +++ b/webapp/packages/shadow-player/src/sourceBuffer.ts @@ -1,105 +1,69 @@ export class ReactiveSourceBuffer { - sourceBuffer: SourceBuffer; - bufferQueue: Uint8Array[] = []; - isAppending = false; - next = () => {}; - allBuffers: Blob[] = []; // Store all buffers for file creation - debug = false; + private readonly sourceBuffer: SourceBuffer; + private readonly allBuffers: Blob[] = []; + private pendingOperation = Promise.resolve(); + private debug = false; - private readonly onUpdateEnd: () => void; - - constructor( - mediaSource: MediaSource, - codec: string, - next: () => void, - onUpdateEnd?: () => void - ) { + constructor(mediaSource: MediaSource, codec: string) { this.sourceBuffer = mediaSource.addSourceBuffer(`video/webm; codecs="${codec}"`); - this.next = next; - this.onUpdateEnd = onUpdateEnd ?? (() => {}); + } - this.sourceBuffer.addEventListener('updateend', () => { - try { - this.onUpdateEnd(); - } finally { - this.tryAppendBuffer(); - } - }); + setDebug(debug: boolean): void { + this.debug = debug; + } - // Handle errors and trigger download of the file - this.sourceBuffer.addEventListener('error', (event) => { - this.logErrorDetails(event); - this.downloadBufferedFile(); - }); + appendBuffer(buffer: Uint8Array): Promise { + const operation = this.pendingOperation.then(() => this.append(buffer)); + this.pendingOperation = operation; + return operation; } - setDebug(debug: boolean) { - this.debug = debug; + whenIdle(): Promise { + return this.pendingOperation; } - appendBuffer(buffer: Uint8Array) { - this.bufferQueue.push(buffer); + private async append(buffer: Uint8Array): Promise { + if (this.sourceBuffer.updating) { + throw new Error('SourceBuffer is already updating'); + } + if (this.debug) { - this.allBuffers.push(new Blob([buffer], { type: 'video/webm' })); // Save each buffer - console.log( - `[sourceBuffer] appendBuffer: size=${buffer.length} queueLen=${this.bufferQueue.length} bufferedRanges=${this.getBufferedRanges() || '(empty)'}` - ); + this.allBuffers.push(new Blob([buffer], { type: 'video/webm' })); } - this.tryAppendBuffer(); - } - private tryAppendBuffer() { - if (!this.isAppending && !this.sourceBuffer.updating && this.bufferQueue.length > 0) { - this.isAppending = true; + await new Promise((resolve, reject) => { + const cleanup = () => { + this.sourceBuffer.removeEventListener('updateend', onUpdateEnd); + this.sourceBuffer.removeEventListener('error', onError); + }; + const onUpdateEnd = () => { + cleanup(); + resolve(); + }; + const onError = () => { + cleanup(); + reject(new Error('SourceBuffer append failed')); + }; + + this.sourceBuffer.addEventListener('updateend', onUpdateEnd); + this.sourceBuffer.addEventListener('error', onError); try { - const buffer = this.bufferQueue.shift() as Uint8Array; this.sourceBuffer.appendBuffer(buffer); } catch (error) { - this.logErrorDetails(error); - } finally { - this.next(); - this.isAppending = false; + cleanup(); + reject(error); } - } + }); } - public downloadBufferedFile() { - const completeBlob = new Blob(this.allBuffers, { type: 'video/webm' }); - const url = URL.createObjectURL(completeBlob); - - // Create a download link + downloadBufferedFile(): void { + const url = URL.createObjectURL(new Blob(this.allBuffers, { type: 'video/webm' })); const link = document.createElement('a'); link.href = url; link.download = 'buffered-video.webm'; document.body.appendChild(link); link.click(); - - // Cleanup - document.body.removeChild(link); + link.remove(); URL.revokeObjectURL(url); - console.log('Buffered file downloaded.'); - } - - private logErrorDetails(error: unknown) { - console.error('Error encountered in ReactiveSourceBuffer:'); - - // Log the error object with stack trace - console.error('Error object:', error); - - // Log the state of the bufferQueue - console.log('Current bufferQueue length:', this.bufferQueue.length); - - // Log the sourceBuffer state - console.log('SourceBuffer updating:', this.sourceBuffer.updating); - console.log('SourceBuffer buffered ranges:', this.getBufferedRanges()); - } - - private getBufferedRanges(): string { - const ranges = this.sourceBuffer.buffered; - let rangeStr = ''; - for (let i = 0; i < ranges.length; i++) { - rangeStr += `[${ranges.start(i)} - ${ranges.end(i)}] `; - } - return rangeStr.trim(); } } diff --git a/webapp/packages/shadow-player/src/streamer.css b/webapp/packages/shadow-player/src/streamer.css index 1712cc44e..41f50ca9b 100644 --- a/webapp/packages/shadow-player/src/streamer.css +++ b/webapp/packages/shadow-player/src/streamer.css @@ -1,16 +1,32 @@ +:host { + display: block; + background: #000; +} + .container { position: relative; width: 100%; height: 100%; + overflow: hidden; + background: #000; } video { + position: absolute; + inset: 0; + display: none; width: 100%; height: 100%; + object-fit: contain; +} + +video.active { + display: block; } .replay-button { position: absolute; + z-index: 2; top: 50%; left: 50%; transform: translate(-50%, -50%); @@ -22,7 +38,9 @@ video { border-radius: 50%; cursor: pointer; display: none; - transition: transform 0.2s, background-color 0.2s; + transition: + transform 0.2s, + background-color 0.2s; } .replay-button:hover { @@ -39,3 +57,9 @@ video { .replay-button.visible { display: block; } + +@media (prefers-reduced-motion: reduce) { + .replay-button { + transition: none; + } +} diff --git a/webapp/packages/shadow-player/src/streamer.test.ts b/webapp/packages/shadow-player/src/streamer.test.ts new file mode 100644 index 000000000..5a077aa53 --- /dev/null +++ b/webapp/packages/shadow-player/src/streamer.test.ts @@ -0,0 +1,512 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClientMessage, SegmentStartedMessage, ServerMessage } from './protocol'; + +interface MockServerWebSocket { + sent: ClientMessage[]; + emitOpen: () => void; + emitMessage: (message: ServerMessage) => Promise; + emitClose: (code?: number, reason?: string) => void; + emitError: () => void; +} + +interface MockPlaybackClip { + metadata: SegmentStartedMessage; + video: HTMLVideoElement; + play: ReturnType; + pause: ReturnType; + open: ReturnType; + append: ReturnType; + finish: ReturnType; + resolveOpen: () => void; + resolveAppend: () => void; + resolveFinish: () => void; + setDuration: (duration: number) => void; + loaded: () => void; + end: () => void; +} + +const mocks = vi.hoisted(() => ({ + sockets: [] as MockServerWebSocket[], + clips: [] as MockPlaybackClip[], +})); + +vi.mock('./websocket', () => ({ + ServerWebSocket: class { + readonly sent: ClientMessage[] = []; + private openCallback: (() => void) | null = null; + private messageCallback: ((message: ServerMessage) => Promise | void) | null = null; + private closeCallback: ((event: CloseEvent) => void) | null = null; + private errorCallback: ((event: Event) => void) | null = null; + private failureCallback: ((error: unknown) => void) | null = null; + + constructor(_url: string) { + mocks.sockets.push(this); + } + + onopen(callback: () => void): void { + this.openCallback = callback; + } + + onmessage(callback: (message: ServerMessage) => Promise | void, onFailure: (error: unknown) => void): void { + this.messageCallback = callback; + this.failureCallback = onFailure; + } + + onclose(callback: (event: CloseEvent) => void): void { + this.closeCallback = callback; + } + + onerror(callback: (event: Event) => void): void { + this.errorCallback = callback; + } + + send(message: ClientMessage): void { + this.sent.push(message); + } + + isOpen(): boolean { + return true; + } + + close(): void {} + + emitOpen(): void { + this.openCallback?.(); + } + + async emitMessage(message: ServerMessage): Promise { + try { + await this.messageCallback?.(message); + } catch (error) { + this.failureCallback?.(error); + throw error; + } + } + + emitClose(code = 1006, reason = ''): void { + this.closeCallback?.(new CloseEvent('close', { code, reason, wasClean: false })); + } + + emitError(): void { + this.errorCallback?.(new Event('error')); + } + }, +})); + +vi.mock('./playbackClip', () => ({ + PlaybackClip: class { + readonly video = document.createElement('video'); + readonly play = vi.fn(async () => undefined); + readonly pause = vi.fn(); + readonly open: ReturnType; + readonly append: ReturnType; + readonly finish: ReturnType; + private readonly openPromise: Promise; + private readonly appendPromise: Promise; + private readonly finishPromise: Promise; + private openResolver!: () => void; + private appendResolver!: () => void; + private finishResolver!: () => void; + private duration = 0; + private ended = false; + + constructor(readonly metadata: SegmentStartedMessage) { + this.openPromise = new Promise((resolve) => { + this.openResolver = resolve; + }); + this.appendPromise = new Promise((resolve) => { + this.appendResolver = resolve; + }); + this.finishPromise = new Promise((resolve) => { + this.finishResolver = resolve; + }); + this.open = vi.fn(() => this.openPromise); + this.append = vi.fn(() => this.appendPromise); + this.finish = vi.fn(() => this.finishPromise); + Object.defineProperties(this.video, { + play: { configurable: true, value: this.play }, + pause: { configurable: true, value: this.pause }, + load: { configurable: true, value: vi.fn() }, + duration: { configurable: true, get: () => this.duration }, + ended: { configurable: true, get: () => this.ended }, + }); + mocks.clips.push(this); + } + + resolveOpen(): void { + this.openResolver(); + } + + resolveAppend(): void { + this.appendResolver(); + } + + resolveFinish(): void { + this.finishResolver(); + } + + setDuration(duration: number): void { + this.duration = duration; + this.video.dispatchEvent(new Event('durationchange')); + } + + loaded(): void { + this.video.dispatchEvent(new Event('loadeddata')); + } + + end(): void { + this.ended = true; + this.video.dispatchEvent(new Event('ended')); + } + + setDebug(): void {} + + downloadBufferedFile(): void {} + + dispose(): void { + this.video.remove(); + } + }, +})); + +import { ShadowPlayer } from './streamer'; + +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); +} + +function createPlayer(attributes: string[] = []): { player: ShadowPlayer; socket: MockServerWebSocket } { + const player = new ShadowPlayer(); + for (const attribute of attributes) { + player.setAttribute(attribute, ''); + } + player.setAttribute('src', 'ws://example.test'); + document.body.appendChild(player); + const socket = mocks.sockets.at(-1); + if (!socket) { + throw new Error('ShadowPlayer did not create a websocket'); + } + socket.emitOpen(); + return { player, socket }; +} + +const firstMetadata: SegmentStartedMessage = { + type: 'segment-started', + codec: 'vp8', + sequence: 0, + width: 640, + height: 480, +}; + +const secondMetadata: SegmentStartedMessage = { + type: 'segment-started', + codec: 'vp8', + sequence: 1, + width: 1280, + height: 720, +}; + +describe('ShadowPlayer', () => { + beforeEach(() => { + mocks.sockets.length = 0; + mocks.clips.length = 0; + }); + + afterEach(() => { + document.body.replaceChildren(); + }); + + it('pulls only after segment and append work completes', async () => { + const { player, socket } = createPlayer(); + const onEnd = vi.fn(); + player.onEnd(onEnd); + expect(socket.sent).toEqual([{ type: 'start' }]); + + const firstStart = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const firstClip = mocks.clips[0]; + expect(firstClip).toBeDefined(); + expect(socket.sent).toEqual([{ type: 'start' }]); + + firstClip.resolveOpen(); + await firstStart; + expect(socket.sent).toEqual([{ type: 'start' }, { type: 'pull' }]); + + const chunk = socket.emitMessage({ type: 'chunk', data: new Uint8Array([1]) }); + await flushMicrotasks(); + expect(socket.sent).toHaveLength(2); + firstClip.resolveAppend(); + await chunk; + expect(socket.sent).toHaveLength(3); + + const secondStart = socket.emitMessage(secondMetadata); + await flushMicrotasks(); + expect(firstClip.finish).toHaveBeenCalledOnce(); + expect(mocks.clips).toHaveLength(1); + + firstClip.resolveFinish(); + await flushMicrotasks(); + const secondClip = mocks.clips[1]; + expect(secondClip).toBeDefined(); + secondClip.resolveOpen(); + await secondStart; + expect(socket.sent).toHaveLength(4); + + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + expect(secondClip.finish).toHaveBeenCalledOnce(); + expect(onEnd).not.toHaveBeenCalled(); + + secondClip.resolveFinish(); + await streamEnd; + expect(onEnd).toHaveBeenCalledOnce(); + expect(socket.sent).toHaveLength(4); + }); + + it('does not turn an abrupt close into a clean stream end', async () => { + const { player, socket } = createPlayer(); + const onEnd = vi.fn(); + player.onEnd(onEnd); + + const start = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const clip = mocks.clips[0]; + clip.resolveOpen(); + await start; + + socket.emitClose(); + expect(clip.finish).not.toHaveBeenCalled(); + expect(onEnd).not.toHaveBeenCalled(); + }); + + it.each([4002, 4003, 1011])('surfaces unexpected close code %i', (code) => { + const { player, socket } = createPlayer(); + const onError = vi.fn(); + player.onError(onError); + + socket.emitClose(code, `close ${code}`); + + expect(onError).toHaveBeenCalledOnce(); + expect(onError).toHaveBeenCalledWith({ + type: 'websocket-close', + code, + reason: `close ${code}`, + wasClean: false, + }); + }); + + it('reports only the clean End when a socket error follows it', async () => { + const { player, socket } = createPlayer(); + const onEnd = vi.fn(); + const onError = vi.fn(); + player.onEnd(onEnd); + player.onError(onError); + + const start = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const clip = mocks.clips[0]; + clip.resolveOpen(); + await start; + + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + clip.resolveFinish(); + await streamEnd; + socket.emitError(); + + expect(onEnd).toHaveBeenCalledOnce(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('rejects a noncontiguous segment sequence', async () => { + const { socket } = createPlayer(); + + await expect(socket.emitMessage({ ...firstMetadata, sequence: 1 })).rejects.toThrow( + 'Expected segment 0, received 1', + ); + expect(mocks.clips).toHaveLength(0); + }); + + it('does not loop from stream completion while the next segment is not yet playable', async () => { + const { player, socket } = createPlayer(['autoplay', 'loop']); + + const firstStart = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const firstClip = mocks.clips[0]; + firstClip.resolveOpen(); + await firstStart; + firstClip.loaded(); + expect(firstClip.play).toHaveBeenCalledOnce(); + + const secondStart = socket.emitMessage(secondMetadata); + await flushMicrotasks(); + firstClip.resolveFinish(); + await flushMicrotasks(); + const secondClip = mocks.clips[1]; + secondClip.resolveOpen(); + await secondStart; + + firstClip.end(); + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + secondClip.resolveFinish(); + await streamEnd; + + expect(player._videoElement).toBe(firstClip.video); + expect(firstClip.play).toHaveBeenCalledOnce(); + expect(secondClip.play).not.toHaveBeenCalled(); + + secondClip.loaded(); + expect(player._videoElement).toBe(secondClip.video); + expect(firstClip.play).toHaveBeenCalledOnce(); + expect(secondClip.play).toHaveBeenCalledOnce(); + }); + + it('does not show replay from the ended handler while the next segment is not yet playable', async () => { + const { player, socket } = createPlayer(['autoplay']); + + const firstStart = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const firstClip = mocks.clips[0]; + firstClip.resolveOpen(); + await firstStart; + firstClip.loaded(); + + const secondStart = socket.emitMessage(secondMetadata); + await flushMicrotasks(); + firstClip.resolveFinish(); + await flushMicrotasks(); + const secondClip = mocks.clips[1]; + secondClip.resolveOpen(); + await secondStart; + + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + secondClip.resolveFinish(); + await streamEnd; + firstClip.end(); + + const replayButton = player.shadowRoot?.querySelector('.replay-button'); + expect(player._videoElement).toBe(firstClip.video); + expect(replayButton?.classList.contains('visible')).toBe(false); + expect(secondClip.play).not.toHaveBeenCalled(); + + secondClip.loaded(); + expect(player._videoElement).toBe(secondClip.video); + expect(secondClip.play).toHaveBeenCalledOnce(); + expect(replayButton?.classList.contains('visible')).toBe(false); + }); + + it('coordinates autoplay and loop across the full segment sequence', async () => { + const { player, socket } = createPlayer(['autoplay', 'loop']); + + const firstStart = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const firstClip = mocks.clips[0]; + firstClip.resolveOpen(); + await firstStart; + + const secondStart = socket.emitMessage(secondMetadata); + await flushMicrotasks(); + firstClip.resolveFinish(); + await flushMicrotasks(); + const secondClip = mocks.clips[1]; + secondClip.resolveOpen(); + await secondStart; + + expect(firstClip.video.hasAttribute('autoplay')).toBe(false); + expect(firstClip.video.hasAttribute('loop')).toBe(false); + expect(secondClip.video.hasAttribute('autoplay')).toBe(false); + expect(secondClip.video.hasAttribute('loop')).toBe(false); + + secondClip.loaded(); + expect(secondClip.play).not.toHaveBeenCalled(); + firstClip.loaded(); + expect(firstClip.play).toHaveBeenCalledOnce(); + + firstClip.end(); + expect(player._videoElement).toBe(secondClip.video); + expect(secondClip.play).toHaveBeenCalledOnce(); + + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + secondClip.resolveFinish(); + await streamEnd; + secondClip.end(); + + expect(player._videoElement).toBe(firstClip.video); + expect(firstClip.play).toHaveBeenCalledTimes(2); + expect(player.shadowRoot?.querySelector('.replay-button')?.classList.contains('visible')).toBe(false); + }); + + it('keeps segment playback chronological while preserving pause, seek, and replay intent', async () => { + const { player, socket } = createPlayer(); + + const firstStart = socket.emitMessage(firstMetadata); + await flushMicrotasks(); + const firstClip = mocks.clips[0]; + firstClip.resolveOpen(); + await firstStart; + + const secondStart = socket.emitMessage(secondMetadata); + await flushMicrotasks(); + firstClip.resolveFinish(); + await flushMicrotasks(); + const secondClip = mocks.clips[1]; + secondClip.resolveOpen(); + await secondStart; + + firstClip.setDuration(10); + secondClip.setDuration(20); + secondClip.loaded(); + expect(player._videoElement).toBeNull(); + firstClip.loaded(); + expect(player._videoElement).toBe(firstClip.video); + + player.play(); + expect(firstClip.play).toHaveBeenCalledOnce(); + player.pause(); + firstClip.end(); + expect(player._videoElement).toBe(secondClip.video); + expect(secondClip.play).not.toHaveBeenCalled(); + + player.play(); + expect(secondClip.play).toHaveBeenCalledOnce(); + + const firstTimelineSegment = player.shadowRoot?.querySelector('.timeline-segment'); + expect(firstTimelineSegment).not.toBeNull(); + vi.spyOn(firstTimelineSegment as HTMLElement, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 10, + top: 0, + right: 100, + bottom: 10, + left: 0, + toJSON: () => ({}), + }); + firstTimelineSegment?.dispatchEvent(new MouseEvent('click', { clientX: 25 })); + expect(player._videoElement).toBe(firstClip.video); + expect(firstClip.video.currentTime).toBe(2.5); + expect(secondClip.video.currentTime).toBe(0); + + const streamEnd = socket.emitMessage({ type: 'stream-ended' }); + await flushMicrotasks(); + secondClip.resolveFinish(); + await streamEnd; + + firstClip.end(); + secondClip.end(); + const replayButton = player.shadowRoot?.querySelector('.replay-button'); + expect(replayButton?.classList.contains('visible')).toBe(true); + replayButton?.click(); + expect(player._videoElement).toBe(firstClip.video); + expect(firstClip.video.currentTime).toBe(0); + expect(secondClip.video.currentTime).toBe(0); + }); +}); diff --git a/webapp/packages/shadow-player/src/streamer.ts b/webapp/packages/shadow-player/src/streamer.ts index 9bfd29da8..046b366c5 100644 --- a/webapp/packages/shadow-player/src/streamer.ts +++ b/webapp/packages/shadow-player/src/streamer.ts @@ -1,13 +1,27 @@ -import { ErrorMessage } from './protocol'; -import { ReactiveSourceBuffer } from './sourceBuffer'; +import { PlaybackClip } from './playbackClip'; +import { + defaultPlaybackControlLabels, + type PlaybackControlLabels, + PlaybackControls, + type PlaybackControlsAction, +} from './playbackControls'; +import type { ErrorMessage, SegmentStartedMessage, ServerMessage } from './protocol'; import styles from './streamer.css?inline'; import { ServerWebSocket } from './websocket'; +export type { PlaybackControlLabels } from './playbackControls'; + export type ShadowPlayerError = | { type: 'websocket'; inner: ErrorEvent; } + | { + type: 'websocket-close'; + code: number; + reason: string; + wasClean: boolean; + } | { type: 'protocol'; inner: ErrorMessage; @@ -15,324 +29,652 @@ export type ShadowPlayerError = | { type: 'session-not-found'; message: string; + } + | { + type: 'player'; + inner: Error; }; type ShadowPlayerErrorCallback = (error: ShadowPlayerError) => void; - -const LIVE_EDGE_THRESHOLD_SECONDS = 5; -const LIVE_EDGE_SAFETY_MARGIN_SECONDS = 0.25; +type TerminalOutcome = 'none' | 'end' | 'error' | 'closed'; export class ShadowPlayer extends HTMLElement { - shadowRoot: ShadowRoot | null = null; _videoElement: HTMLVideoElement | null = null; _src: string | null = null; - _buffer: ReactiveSourceBuffer | null = null; onErrorCallback: ShadowPlayerErrorCallback | null = null; onEndCallback: (() => void) | null = null; debug = false; _container: HTMLDivElement | null = null; _replayButton: HTMLButtonElement | null = null; + private root: ShadowRoot | null = null; private websocket: ServerWebSocket | null = null; - private isDisconnecting = false; - - static get observedAttributes() { - return ['src', 'autoplay', 'loop', 'muted', 'poster', 'preload', 'style', 'width', 'height']; + private readonly clips: PlaybackClip[] = []; + private readonly playableClips = new Set(); + private receivingClip: PlaybackClip | null = null; + private activeClip: PlaybackClip | null = null; + private awaitingResponse = false; + private shouldPlay = false; + private streamEnded = false; + private terminalOutcome: TerminalOutcome = 'closed'; + private muted = true; + private volume = 1; + private controls: PlaybackControls | null = null; + private controlLabels = defaultPlaybackControlLabels; + private readonly segmentStartTimes = new Map(); + private readonly onFullscreenChange = () => this.renderPlayerControls(); + + static get observedAttributes(): string[] { + return ['src', 'autoplay', 'controls', 'loop', 'muted', 'poster', 'preload', 'style', 'width', 'height']; } - setDebug(debug: boolean) { + setDebug(debug: boolean): void { this.debug = debug; - if (this._buffer) { - this._buffer.setDebug(debug); + for (const clip of this.clips) { + clip.setDebug(debug); } } - onError(callback: ShadowPlayerErrorCallback) { + onError(callback: ShadowPlayerErrorCallback): void { this.onErrorCallback = callback; } - onEnd(callback: () => void) { - if (this._videoElement) { - this._videoElement.controls = true; - } + onEnd(callback: () => void): void { this.onEndCallback = callback; } - attributeChangedCallback(name: string, _oldValue: string, newValue: string) { + setControlLabels(labels: Partial): void { + this.controlLabels = { ...this.controlLabels, ...labels }; + this.controls?.render({ type: 'labels', labels: this.controlLabels }); + } + + attributeChangedCallback(name: string, _oldValue: string | null, newValue: string | null): void { if (name === 'src') { - this.srcChange(newValue); + if (newValue === null) { + this.disconnect(); + this._src = null; + } else if (this._container) { + this.srcChange(newValue); + } else { + this._src = newValue; + } return; } - if (this._videoElement && Object.prototype.hasOwnProperty.call(this._videoElement, name)) { - this._videoElement.setAttribute(name, newValue !== null ? newValue : ''); + if (name === 'autoplay') { + if (newValue !== null) { + this.shouldPlay = true; + } + return; + } + if (name === 'controls' || name === 'loop') { + return; + } + if (name === 'muted') { + this.setMuted(newValue !== null); + return; + } + for (const clip of this.clips) { + this.applyVideoAttribute(clip.video, name, newValue); } } - connectedCallback() { + connectedCallback(): void { this.init(); + document.addEventListener('fullscreenchange', this.onFullscreenChange); + const src = this.getAttribute('src'); + if (src !== null && !this.websocket) { + this.srcChange(src); + } } - init() { - this.shadowRoot = this.attachShadow({ mode: 'open' }); - - // Add styles - const style = document.createElement('style'); - style.textContent = styles; - this.shadowRoot.appendChild(style); + disconnectedCallback(): void { + document.removeEventListener('fullscreenchange', this.onFullscreenChange); + this.disconnect(); + this.controls?.dispose(); + this.controls = null; + } - this._container = document.createElement('div'); - this._container.className = 'container'; + init(): void { + if (!this.root) { + this.root = this.attachShadow({ mode: 'open' }); + const style = document.createElement('style'); + style.textContent = styles; + this.root.appendChild(style); - this.videoElement = document.createElement('video'); - // Set muted to true so that the browser security policy will allow autoplay. - this.videoElement.muted = true; - this._container.appendChild(this.videoElement); + this._container = document.createElement('div'); + this._container.className = 'container'; - this._replayButton = document.createElement('button'); - this._replayButton.className = 'replay-button'; - this._replayButton.innerHTML = ` + this._replayButton = document.createElement('button'); + this._replayButton.className = 'replay-button'; + this._replayButton.innerHTML = ` `; - this._replayButton.onclick = () => this.replay(); - this._container.appendChild(this._replayButton); + this._replayButton.onclick = () => this.replay(); + this._container.appendChild(this._replayButton); + this.root.appendChild(this._container); + } - this.shadowRoot.appendChild(this._container); - this.syncAttributes(); + if (!this.controls && this._container) { + this.controls = new PlaybackControls(this._container); + this.controls.onAction((action) => this.handleControlsAction(action)); + this.controls.render({ type: 'labels', labels: this.controlLabels }); + } + this.shouldPlay = this.hasAttribute('autoplay'); + this.renderPlayerControls(); } - syncAttributes() { - for (const attr of ShadowPlayer.observedAttributes) { - const value = this.getAttribute(attr); - if (attr === 'src' && value !== null) { - this.srcChange(value); + private handleControlsAction(action: PlaybackControlsAction): void { + if (action.type === 'toggle-playback') { + if (this.shouldPlay) { + this.pause(); + } else { + this.play(); } - if (value !== null && this._videoElement) { - this._videoElement.setAttribute(attr, value); + return; + } + if (action.type === 'toggle-muted') { + if (this.volume === 0) { + this.setVolume(1); } + this.setMuted(!this.muted); + return; + } + if (action.type === 'set-volume') { + this.setVolume(action.volume); + this.setMuted(this.volume === 0); + return; } + if (action.type === 'seek') { + const clip = this.clips[action.sequence]; + if (clip?.metadata.sequence === action.sequence) { + this.seekToClip(clip, action.percentage); + } + return; + } + void this.toggleFullscreen().catch((error: unknown) => this.reportPlayerError(error)); } - private get videoElement() { - return this._videoElement as HTMLVideoElement; + private setMuted(muted: boolean): void { + this.muted = muted; + for (const clip of this.clips) { + clip.video.muted = muted; + } + this.renderPlayerControls(); } - private set videoElement(value: HTMLVideoElement) { - this._videoElement = value; + private setVolume(volume: number): void { + this.volume = Math.max(0, Math.min(1, volume)); + for (const clip of this.clips) { + clip.video.volume = this.volume; + } + this.renderPlayerControls(); } - public play() { - if (this._videoElement) { - this._videoElement.play(); + private async toggleFullscreen(): Promise { + if (document.fullscreenElement === this) { + await document.exitFullscreen(); + } else { + await this.requestFullscreen(); } } - private replay() { - if (this._replayButton) { - this._replayButton.classList.remove('visible'); + public play(): void { + this.shouldPlay = true; + this.renderPlayerControls(); + if (this.activeClip && !this.activeClip.video.ended) { + void this.activeClip.video.play(); + return; } - this._videoElement?.play(); + if (this.activateNextClip()) { + return; + } + if (this.isSequencePlaybackComplete()) { + this.replay(); + } + } + + public pause(): void { + this.shouldPlay = false; + this.activeClip?.video.pause(); + this.renderPlayerControls(); } - public srcChange(value: string) { - if (!this._videoElement) { + private replay(): void { + this._replayButton?.classList.remove('visible'); + const firstClip = this.clips[0]; + if (!firstClip) { return; } - this.isDisconnecting = false; - const mediaSource = new MediaSource(); - this._src = value; - this._videoElement.src = URL.createObjectURL(mediaSource); - mediaSource.addEventListener('sourceopen', () => { - this.handleSourceOpen(mediaSource); - }); + for (const clip of this.clips) { + clip.video.currentTime = 0; + } + this.shouldPlay = true; + this.activateClip(firstClip); + this.renderAllSegments(); + this.renderPlayerControls(); } - private async handleSourceOpen(mediaSource: MediaSource) { - this.websocket = new ServerWebSocket(this._src as string); - let reactiveSourceBuffer: ReactiveSourceBuffer | null = null; + public srcChange(value: string): void { + this.closeSession(); + this._src = value; + if (!this._container) { + return; + } - this.websocket.onopen(() => { - this.websocket!.send({ type: 'start' }); - this.websocket!.send({ type: 'pull' }); + this.terminalOutcome = 'none'; + this.streamEnded = false; + this._replayButton?.classList.remove('visible'); + this.renderPlayerControls(); + const websocket = new ServerWebSocket(value); + this.websocket = websocket; - this._videoElement?.addEventListener('ended', () => { - this.showReplayButton(); - }); + websocket.onopen(() => { + if (this.websocket === websocket) { + this.sendRequest(websocket, 'start'); + } }); + websocket.onmessage( + async (message) => this.handleServerMessage(websocket, message), + (error) => this.handlePlayerFailure(websocket, error), + ); + websocket.onclose((event) => this.handleSocketClose(websocket, event)); + websocket.onerror((event) => this.handleSocketError(websocket, event)); + } - this.websocket.onmessage((ev) => { - if (mediaSource.readyState === 'closed') { - return; - } - if (ev.type === 'metadata') { - const codec = ev.codec; - reactiveSourceBuffer = new ReactiveSourceBuffer( - mediaSource, - codec, - () => { - this.websocket?.send({ type: 'pull' }); - }, - () => this.catchUpToLiveEdge() - ); - this._buffer = reactiveSourceBuffer; - } + private async handleServerMessage(websocket: ServerWebSocket, message: ServerMessage): Promise { + if (this.websocket !== websocket || this.terminalOutcome !== 'none') { + return; + } + if (!this.awaitingResponse) { + throw new Error('Received a server message without a pending request'); + } + this.awaitingResponse = false; - if (ev.type === 'chunk') { - if (!reactiveSourceBuffer) { - return; - } - - reactiveSourceBuffer.appendBuffer(ev.data); - - if (!this._videoElement) { - return; - } - - if (this.debug) { - const v = this._videoElement; - const buffered = v.buffered.length > 0 - ? `[${v.buffered.start(0).toFixed(2)}-${v.buffered.end(0).toFixed(2)}]` - : '(empty)'; - console.log( - `[shadow-player] chunk appended: duration=${v.duration.toFixed(2)} currentTime=${v.currentTime.toFixed(2)} buffered=${buffered} readyState=${v.readyState}` - ); - } + if (message.type === 'segment-started') { + await this.startSegment(websocket, message); + this.sendRequest(websocket, 'pull'); + return; + } + if (message.type === 'chunk') { + const clip = this.receivingClip; + if (!clip) { + throw new Error('Received a chunk before a segment started'); } + await clip.append(message.data); + this.sendRequest(websocket, 'pull'); + return; + } + if (message.type === 'error') { + this.reportTerminalError({ type: 'protocol', inner: message }); + return; + } - if (ev.type === 'error') { - this.onErrorCallback?.({ - type: 'protocol', - inner: ev, - }); - } + await this.finishReceivingClip(); + if (this.websocket !== websocket || this.terminalOutcome !== 'none') { + return; + } + this.completeStream(); + } - if (ev.type === 'end') { - this.onEndCallback?.(); + private async startSegment(websocket: ServerWebSocket, metadata: SegmentStartedMessage): Promise { + if (metadata.sequence !== this.clips.length) { + throw new Error(`Expected segment ${this.clips.length}, received ${metadata.sequence}`); + } + + await this.finishReceivingClip(); + if (this.websocket !== websocket || this.terminalOutcome !== 'none') { + return; + } + const clip = new PlaybackClip(metadata); + clip.setDebug(this.debug); + this.configureVideo(clip); + this.clips.push(clip); + this.receivingClip = clip; + this._container?.insertBefore(clip.video, this._replayButton); + this.renderAllSegments(); + await clip.open(); + } + + private async finishReceivingClip(): Promise { + const clip = this.receivingClip; + if (!clip) { + return; + } + this.receivingClip = null; + await clip.finish(); + this.renderAllSegments(); + } + + private configureVideo(clip: PlaybackClip): void { + const video = clip.video; + video.className = 'clip'; + video.muted = this.muted; + video.volume = this.volume; + for (const attribute of ShadowPlayer.observedAttributes) { + if ( + attribute !== 'src' && + attribute !== 'autoplay' && + attribute !== 'controls' && + attribute !== 'loop' && + attribute !== 'muted' + ) { + this.applyVideoAttribute(video, attribute, this.getAttribute(attribute)); + } + } + video.addEventListener( + 'loadeddata', + () => { + this.playableClips.add(clip); + this.activateNextClip(); + this.renderAllSegments(); + }, + { once: true }, + ); + video.addEventListener('play', () => { + if (this.activeClip === clip) { + this.shouldPlay = true; + this.renderPlayerControls(); } }); - - this.websocket.onclose((ev) => { - if (this.isDisconnecting) { - this.websocket = null; - return; + video.addEventListener('pause', () => { + if (this.activeClip === clip && !video.ended) { + this.shouldPlay = false; + this.renderPlayerControls(); } - - if (ev.code === 4001) { - this.onErrorCallback?.({ - type: 'session-not-found', - message: 'Recording session is no longer active', - }); + }); + video.addEventListener('ended', () => { + if (this.activeClip !== clip) { + return; } - - this.videoElement.controls = true; - if (reactiveSourceBuffer && mediaSource.readyState === 'open') { - try { - if (this.debug && this._videoElement) { - const v = this._videoElement; - const buffered = v.buffered.length > 0 - ? `[${v.buffered.start(0).toFixed(2)}-${v.buffered.end(0).toFixed(2)}]` - : '(empty)'; - console.log( - `[shadow-player] BEFORE endOfStream: duration=${v.duration} currentTime=${v.currentTime.toFixed(2)} buffered=${buffered} mediaSource.readyState=${mediaSource.readyState}` - ); - } - mediaSource.endOfStream(); - if (this.debug && this._videoElement) { - const v = this._videoElement; - const buffered = v.buffered.length > 0 - ? `[${v.buffered.start(0).toFixed(2)}-${v.buffered.end(0).toFixed(2)}]` - : '(empty)'; - console.log( - `[shadow-player] AFTER endOfStream: duration=${v.duration} currentTime=${v.currentTime.toFixed(2)} buffered=${buffered} mediaSource.readyState=${mediaSource.readyState}` - ); - } - } catch (error) { - if (this.debug) { - console.error('[shadow-player] endOfStream error:', error); - } - } + if (!this.activateNextClip()) { + this.handleSequencePlaybackEnd(); } - this.websocket = null; + this.renderClipControls(clip); + this.renderPlayerControls(); }); + video.addEventListener('timeupdate', () => this.renderClipControls(clip)); + video.addEventListener('durationchange', () => this.renderAllSegments()); + video.addEventListener('progress', () => this.renderClipControls(clip)); + video.addEventListener('click', () => this.handleControlsAction({ type: 'toggle-playback' })); + } - this.websocket.onerror((ev) => { - if (this.isDisconnecting) { - return; + private activateNextClip(): boolean { + const sequence = this.activeClip ? this.activeClip.metadata.sequence + 1 : 0; + const next = this.clips[sequence]; + if (!next || !this.playableClips.has(next)) { + return false; + } + if (this.activeClip && !this.activeClip.video.ended) { + return false; + } + this.activateClip(next); + return true; + } + + private activateClip(clip: PlaybackClip): void { + if (this.activeClip === clip) { + if (this.shouldPlay) { + void clip.video.play(); } + this.renderClipControls(clip); + this.renderPlayerControls(); + return; + } + const previous = this.activeClip; + this.activeClip = clip; + if (previous) { + previous.video.pause(); + previous.video.classList.remove('active'); + } + this._videoElement = clip.video; + clip.video.classList.add('active'); + if (this.shouldPlay) { + void clip.video.play(); + } + if (previous) { + this.renderClipControls(previous); + } + this.renderClipControls(clip); + this.renderPlayerControls(); + } - this.onErrorCallback?.({ - type: 'websocket', - inner: ev as unknown as ErrorEvent, - }); - - if (reactiveSourceBuffer && mediaSource.readyState === 'open') { - try { - mediaSource.endOfStream(); - } catch (error) { - console.error('endOfStream error:', error); - } + private seekToClip(clip: PlaybackClip, percentage: number): void { + if (!this.playableClips.has(clip)) { + return; + } + const duration = this.clipDuration(clip); + if (duration <= 0) { + return; + } + + for (const laterClip of this.clips) { + if (laterClip.metadata.sequence > clip.metadata.sequence && this.playableClips.has(laterClip)) { + laterClip.video.currentTime = 0; } + } + clip.video.currentTime = duration * Math.max(0, Math.min(1, percentage)); + this._replayButton?.classList.remove('visible'); + this.activateClip(clip); + this.renderAllSegments(); + } + + private clipDuration(clip: PlaybackClip): number { + if (Number.isFinite(clip.video.duration) && clip.video.duration > 0) { + return clip.video.duration; + } + const buffered = clip.video.buffered; + return buffered.length > 0 ? buffered.end(buffered.length - 1) : 0; + } + + private clipProgress(clip: PlaybackClip): number { + if (!this.activeClip) { + return 0; + } + if (clip.metadata.sequence < this.activeClip.metadata.sequence) { + return 1; + } + if (clip !== this.activeClip) { + return 0; + } + const duration = this.clipDuration(clip); + return duration > 0 ? Math.max(0, Math.min(1, clip.video.currentTime / duration)) : 0; + } + + private renderPlayerControls(): void { + this.controls?.render({ + type: 'player', + playing: this.shouldPlay, + muted: this.muted, + volume: this.volume, + fullscreen: document.fullscreenElement === this, }); } - public downloadBUfferAsFile() { - if (this._buffer && this.debug) { - this._buffer.downloadBufferedFile(); + private renderClipControls(clip: PlaybackClip): void { + const startTime = this.segmentStartTimes.get(clip); + if (startTime === undefined) { + return; } + const duration = this.clipDuration(clip); + const progress = this.clipProgress(clip); + this.controls?.render({ + type: 'segment', + sequence: clip.metadata.sequence, + startTime, + duration, + currentTime: duration * progress, + progress, + playable: this.playableClips.has(clip), + }); } - private showReplayButton() { - if (this._replayButton) { - this._replayButton.classList.add('visible'); + private renderAllSegments(): void { + let startTime = 0; + for (const clip of this.clips) { + this.segmentStartTimes.set(clip, startTime); + this.renderClipControls(clip); + startTime += this.clipDuration(clip); } } - private catchUpToLiveEdge() { - const video = this._videoElement; - if (!video || video.buffered.length === 0) { + private applyVideoAttribute(video: HTMLVideoElement, name: string, value: string | null): void { + if (value === null) { + video.removeAttribute(name); + } else { + video.setAttribute(name, value); + } + } + + private sendRequest(websocket: ServerWebSocket, type: 'start' | 'pull'): void { + if (this.websocket !== websocket || this.terminalOutcome !== 'none') { + return; + } + if (!websocket.isOpen()) { return; } + if (this.awaitingResponse) { + throw new Error('A stream request is already pending'); + } + this.awaitingResponse = true; + websocket.send({ type }); + } - const latestRangeIndex = video.buffered.length - 1; - const latestRangeStart = video.buffered.start(latestRangeIndex); - const latestRangeEnd = video.buffered.end(latestRangeIndex); - const isOutsideLatestRange = - video.currentTime < latestRangeStart || video.currentTime >= latestRangeEnd; + private handleSocketClose(websocket: ServerWebSocket, event: CloseEvent): void { + if (this.websocket !== websocket) { + return; + } + this.awaitingResponse = false; + this.websocket = null; + if (this.terminalOutcome === 'none') { + const error: ShadowPlayerError = + event.code === 4001 + ? { + type: 'session-not-found', + message: 'Recording session is no longer active', + } + : { + type: 'websocket-close', + code: event.code, + reason: event.reason, + wasClean: event.wasClean, + }; + this.reportTerminalError(error); + } + this.renderPlayerControls(); + } + private handleSocketError(websocket: ServerWebSocket, event: Event): void { + if (this.websocket !== websocket) { + return; + } + this.reportTerminalError({ + type: 'websocket', + inner: event as ErrorEvent, + }); + } + + private handlePlayerFailure(websocket: ServerWebSocket, value: unknown): void { + if (this.websocket !== websocket) { + return; + } + const error = value instanceof Error ? value : new Error(String(value)); if ( - isOutsideLatestRange || - latestRangeEnd - video.currentTime > LIVE_EDGE_THRESHOLD_SECONDS + !this.reportTerminalError({ + type: 'player', + inner: error, + }) ) { - video.currentTime = Math.max( - latestRangeStart, - latestRangeEnd - LIVE_EDGE_SAFETY_MARGIN_SECONDS - ); + return; } + this.awaitingResponse = false; + websocket.close(1000, 'Player failure'); + this.websocket = null; + this.renderPlayerControls(); } - public disconnect(): void { - this.isDisconnecting = true; + private completeStream(): void { + if (this.terminalOutcome !== 'none') { + return; + } + this.terminalOutcome = 'end'; + this.streamEnded = true; + this.renderPlayerControls(); + this.handleSequencePlaybackEnd(); + this.onEndCallback?.(); + } - if (this.websocket) { - try { - this.websocket.ws.close(1000, 'Component cleanup'); - } catch (error) { - // Intentionally ignored: WebSocket may already be closed - } - this.websocket = null; + private reportTerminalError(error: ShadowPlayerError): boolean { + if (this.terminalOutcome !== 'none') { + return false; } + this.terminalOutcome = 'error'; + this.onErrorCallback?.(error); + this.renderPlayerControls(); + return true; + } - if (this._videoElement) { - try { - this._videoElement.pause(); - this._videoElement.src = ''; - this._videoElement.load(); - } catch (error) { - // Intentionally ignored: Video element may already be in an invalid state - } + private handleSequencePlaybackEnd(): void { + if (!this.isSequencePlaybackComplete()) { + return; + } + if (this.hasAttribute('loop') && this.shouldPlay) { + this.replay(); + } else { + this.showReplayButton(); + } + } + + private isSequencePlaybackComplete(): boolean { + const activeClip = this.activeClip; + return ( + this.streamEnded && + activeClip !== null && + activeClip.video.ended && + activeClip.metadata.sequence === this.clips.length - 1 + ); + } + + private reportPlayerError(value: unknown): void { + const error = value instanceof Error ? value : new Error(String(value)); + this.onErrorCallback?.({ type: 'player', inner: error }); + } + + public downloadBUfferAsFile(): void { + if (this.debug) { + (this.receivingClip ?? this.activeClip)?.downloadBufferedFile(); + } + } + + private showReplayButton(): void { + this._replayButton?.classList.add('visible'); + this.renderPlayerControls(); + } + + public disconnect(): void { + this.closeSession(); + } + + private closeSession(): void { + const websocket = this.websocket; + this.websocket = null; + this.awaitingResponse = false; + this.terminalOutcome = 'closed'; + this.streamEnded = false; + websocket?.close(1000, 'Component cleanup'); + for (const clip of this.clips) { + clip.dispose(); } + this.clips.length = 0; + this.playableClips.clear(); + this.segmentStartTimes.clear(); + this.receivingClip = null; + this.activeClip = null; + this._videoElement = null; + this.controls?.render({ type: 'reset' }); + this.renderPlayerControls(); } } diff --git a/webapp/packages/shadow-player/src/websocket.test.ts b/webapp/packages/shadow-player/src/websocket.test.ts new file mode 100644 index 000000000..f72372c4d --- /dev/null +++ b/webapp/packages/shadow-player/src/websocket.test.ts @@ -0,0 +1,142 @@ +// @vitest-environment jsdom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { ServerWebSocket } from './websocket'; + +interface Deferred { + promise: Promise; + resolve: (value: T | PromiseLike) => void; +} + +function deferred(): Deferred { + let resolve!: Deferred['resolve']; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} + +function encodedMessage(type: number, payload = ''): ArrayBuffer { + const encodedPayload = new TextEncoder().encode(payload); + const message = new Uint8Array(1 + encodedPayload.length); + message[0] = type; + message.set(encodedPayload, 1); + return message.buffer; +} + +class FakeWebSocket { + static readonly OPEN = 1; + static latest: FakeWebSocket | null = null; + + binaryType: BinaryType = 'blob'; + readyState = FakeWebSocket.OPEN; + onopen: ((event: Event) => void) | null = null; + onmessage: ((event: MessageEvent) => void) | null = null; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + + constructor(readonly url: string) { + FakeWebSocket.latest = this; + } + + send(): void {} + + close(): void {} + + emitMessage(data: ArrayBuffer): void { + this.onmessage?.(new MessageEvent('message', { data })); + } + + emitClose(): void { + this.onclose?.(new CloseEvent('close', { code: 1006 })); + } + + emitError(): void { + this.onerror?.(new Event('error')); + } +} + +describe('ServerWebSocket', () => { + beforeEach(() => { + vi.stubGlobal('WebSocket', FakeWebSocket); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + FakeWebSocket.latest = null; + }); + + it('serializes messages and dispatches close after pending message work', async () => { + const websocket = new ServerWebSocket('ws://example.test'); + const socket = FakeWebSocket.latest; + expect(socket).not.toBeNull(); + + const firstStarted = deferred(); + const releaseFirst = deferred(); + const secondStarted = deferred(); + const closed = deferred(); + const calls: string[] = []; + + websocket.onmessage(async (message) => { + calls.push(message.type); + if (message.type === 'segment-started') { + firstStarted.resolve(); + await releaseFirst.promise; + } else { + secondStarted.resolve(); + } + }, vi.fn()); + websocket.onclose(() => closed.resolve()); + + socket?.emitMessage(encodedMessage(1, '{"codec":"vp8","sequence":0,"width":640,"height":480}')); + socket?.emitMessage(encodedMessage(0, 'chunk')); + socket?.emitClose(); + + await firstStarted.promise; + await Promise.resolve(); + expect(calls).toEqual(['segment-started']); + + let closeDispatched = false; + void closed.promise.then(() => { + closeDispatched = true; + }); + await Promise.resolve(); + expect(closeDispatched).toBe(false); + + releaseFirst.resolve(); + await secondStarted.promise; + await closed.promise; + expect(calls).toEqual(['segment-started', 'chunk']); + }); + + it('serializes an error after a queued stream end', async () => { + const websocket = new ServerWebSocket('ws://example.test'); + const socket = FakeWebSocket.latest; + expect(socket).not.toBeNull(); + + const endStarted = deferred(); + const releaseEnd = deferred(); + const errorDispatched = deferred(); + + websocket.onmessage(async (message) => { + expect(message).toEqual({ type: 'stream-ended' }); + endStarted.resolve(); + await releaseEnd.promise; + }, vi.fn()); + websocket.onerror(() => errorDispatched.resolve()); + + socket?.emitMessage(encodedMessage(3)); + socket?.emitError(); + + await endStarted.promise; + let errorObserved = false; + void errorDispatched.promise.then(() => { + errorObserved = true; + }); + await Promise.resolve(); + expect(errorObserved).toBe(false); + + releaseEnd.resolve(); + await errorDispatched.promise; + }); +}); diff --git a/webapp/packages/shadow-player/src/websocket.ts b/webapp/packages/shadow-player/src/websocket.ts index 0d690b26c..91e1d698f 100644 --- a/webapp/packages/shadow-player/src/websocket.ts +++ b/webapp/packages/shadow-player/src/websocket.ts @@ -1,41 +1,62 @@ -import { ClientMessage, ServerMessage, parseClientMessage, parseServerMessage } from './protocol'; +import { ClientMessage, parseClientMessage, parseServerMessage, ServerMessage } from './protocol'; export class ServerWebSocket { - ws: WebSocket; + private readonly socket: WebSocket; + private pendingEvent = Promise.resolve(); + private closed = false; + constructor(url: string) { - this.ws = new WebSocket(url); + this.socket = new WebSocket(url); + this.socket.binaryType = 'arraybuffer'; } - onopen(callback: (ev: Event) => unknown) { - this.ws.onopen = callback; + onopen(callback: (event: Event) => void): void { + this.socket.onopen = callback; } - onmessage(callback: (ev: ServerMessage) => unknown) { - this.ws.onmessage = (ev) => { - const reader = new FileReader(); - reader.onload = () => { - const arrayBuffer = reader.result as ArrayBuffer; - const serverResponse = parseServerMessage(arrayBuffer); - callback(serverResponse); - }; + onmessage(callback: (message: ServerMessage) => Promise | void, onFailure: (error: unknown) => void): void { + this.socket.onmessage = (event) => { + this.enqueueEvent(async () => { + try { + if (!(event.data instanceof ArrayBuffer)) { + throw new Error('Server sent a non-binary message'); + } + await callback(parseServerMessage(event.data)); + } catch (error) { + onFailure(error); + } + }); + }; + } - reader.readAsArrayBuffer(ev.data); + onclose(callback: (event: CloseEvent) => void): void { + this.socket.onclose = (event) => { + this.closed = true; + this.enqueueEvent(() => callback(event)); }; } - onclose(callback: (ev: CloseEvent) => unknown) { - this.ws.onclose = callback; + onerror(callback: (event: Event) => void): void { + this.socket.onerror = (event) => this.enqueueEvent(() => callback(event)); + } + + send(message: ClientMessage): void { + if (!this.isOpen()) { + throw new Error('WebSocket is not open'); + } + this.socket.send(parseClientMessage(message)); } - onerror(callback: (ev: Event) => unknown) { - this.ws.onerror = callback; + isOpen(): boolean { + return !this.closed && this.socket.readyState === WebSocket.OPEN; } - send(data: T) { - this.ws.send(parseClientMessage(data)); + close(code: number, reason: string): void { + this.socket.close(code, reason); } - isClosed() { - return this.ws.readyState === WebSocket.CLOSED; + private enqueueEvent(callback: () => Promise | void): void { + const event = this.pendingEvent.then(callback); + this.pendingEvent = event.catch(() => undefined); } } diff --git a/webapp/packages/shadow-player/vite.config.ts b/webapp/packages/shadow-player/vite.config.ts index 632a03336..10e685ef8 100644 --- a/webapp/packages/shadow-player/vite.config.ts +++ b/webapp/packages/shadow-player/vite.config.ts @@ -1,5 +1,5 @@ import path from 'node:path'; -import { UserConfig, defineConfig } from 'vite'; +import { defineConfig, UserConfig } from 'vite'; import dts from 'vite-plugin-dts'; import { viteStaticCopy } from 'vite-plugin-static-copy'; @@ -51,12 +51,14 @@ const staticCopyPlugin = viteStaticCopy({ const Plugins = { debug: [ dts({ + exclude: ['src/**/*.test.ts'], insertTypesEntry: true, }), staticCopyPlugin, ], release: [ dts({ + exclude: ['src/**/*.test.ts'], insertTypesEntry: true, }), staticCopyPlugin, diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index 295ff1c75..28cd4e092 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -268,6 +268,9 @@ importers: packages/shadow-player: devDependencies: + jsdom: + specifier: ^20.0.3 + version: 20.0.3 ts-node: specifier: ^10.9.2 version: 10.9.2(@types/node@22.19.3)(typescript@5.6.3) @@ -283,6 +286,9 @@ importers: vite-plugin-static-copy: specifier: ^2.3.0 version: 2.3.2(vite@5.4.21(@types/node@22.19.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1)) + vitest: + specifier: ^3.1.1 + version: 3.2.7(@types/node@20.19.27)(jiti@2.6.1)(jsdom@20.0.3)(less@4.4.0)(lightningcss@1.30.2)(sass@1.90.0)(terser@5.43.1) packages/web-recorder: devDependencies: