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: