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/src/playbackClip.ts b/webapp/packages/shadow-player/src/playbackClip.ts
new file mode 100644
index 000000000..3844bae72
--- /dev/null
+++ b/webapp/packages/shadow-player/src/playbackClip.ts
@@ -0,0 +1,80 @@
+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;
+
+ 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);
+ }
+
+ finish(): void {
+ if (this.complete) {
+ return;
+ }
+ if (this.mediaSource.readyState !== 'open') {
+ throw new Error('Cannot finish a MediaSource that is not open');
+ }
+ this.mediaSource.endOfStream();
+ this.complete = true;
+ }
+
+ 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.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.ts b/webapp/packages/shadow-player/src/sourceBuffer.ts
index 8f115c7bc..3daa39f5a 100644
--- a/webapp/packages/shadow-player/src/sourceBuffer.ts
+++ b/webapp/packages/shadow-player/src/sourceBuffer.ts
@@ -1,105 +1,58 @@
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 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();
- }
- });
-
- // Handle errors and trigger download of the file
- this.sourceBuffer.addEventListener('error', (event) => {
- this.logErrorDetails(event);
- this.downloadBufferedFile();
- });
}
- setDebug(debug: boolean) {
+ setDebug(debug: boolean): void {
this.debug = debug;
}
- appendBuffer(buffer: Uint8Array) {
- this.bufferQueue.push(buffer);
+ async appendBuffer(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.ts b/webapp/packages/shadow-player/src/streamer.ts
index 9bfd29da8..ac5488dd9 100644
--- a/webapp/packages/shadow-player/src/streamer.ts
+++ b/webapp/packages/shadow-player/src/streamer.ts
@@ -1,8 +1,16 @@
-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';
@@ -15,324 +23,574 @@ 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;
-
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 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' && newValue !== null) {
+ this.shouldPlay = true;
+ }
+ if (name === 'controls') {
+ 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.streamEnded && this.activeClip?.video.ended) {
+ 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.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) {
+ 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') {
+ this.sendRequest(websocket, 'pull');
+ await this.startSegment(message);
+ return;
+ }
+ if (message.type === 'chunk') {
+ if (!this.receivingClip) {
+ throw new Error('Received a chunk before a segment started');
}
+ this.sendRequest(websocket, 'pull');
+ await this.receivingClip.append(message.data);
+ return;
+ }
+ if (message.type === 'error') {
+ this.onErrorCallback?.({ type: 'protocol', inner: message });
+ return;
+ }
- if (ev.type === 'error') {
- this.onErrorCallback?.({
- type: 'protocol',
- inner: ev,
- });
- }
+ this.finishReceivingClip();
+ this.streamEnded = true;
+ this.renderPlayerControls();
+ if (this.activeClip?.video.ended) {
+ this.showReplayButton();
+ }
+ this.onEndCallback?.();
+ }
+
+ private async startSegment(metadata: SegmentStartedMessage): Promise {
+ if (metadata.sequence !== this.clips.length) {
+ throw new Error(`Expected segment ${this.clips.length}, received ${metadata.sequence}`);
+ }
+
+ this.finishReceivingClip();
+ 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 finishReceivingClip(): void {
+ const clip = this.receivingClip;
+ if (!clip) {
+ return;
+ }
+ clip.finish();
+ this.renderAllSegments();
+ }
- if (ev.type === 'end') {
- this.onEndCallback?.();
+ 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 !== 'controls' && 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.streamEnded) {
+ this.showReplayButton();
}
- 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,
- });
+ private seekToClip(clip: PlaybackClip, percentage: number): void {
+ if (!this.playableClips.has(clip)) {
+ return;
+ }
+ const duration = this.clipDuration(clip);
+ if (duration <= 0) {
+ return;
+ }
- if (reactiveSourceBuffer && mediaSource.readyState === 'open') {
- try {
- mediaSource.endOfStream();
- } catch (error) {
- console.error('endOfStream error:', error);
- }
+ 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,
+ });
+ }
+
+ 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),
});
}
- public downloadBUfferAsFile() {
- if (this._buffer && this.debug) {
- this._buffer.downloadBufferedFile();
+ 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 showReplayButton() {
- if (this._replayButton) {
- this._replayButton.classList.add('visible');
+ private applyVideoAttribute(video: HTMLVideoElement, name: string, value: string | null): void {
+ if (value === null) {
+ video.removeAttribute(name);
+ } else {
+ video.setAttribute(name, value);
}
}
- private catchUpToLiveEdge() {
- const video = this._videoElement;
- if (!video || video.buffered.length === 0) {
+ private sendRequest(websocket: ServerWebSocket, type: 'start' | 'pull'): void {
+ if (this.websocket !== websocket) {
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 (event.code === 4001) {
+ this.onErrorCallback?.({
+ type: 'session-not-found',
+ message: 'Recording session is no longer active',
+ });
+ }
+ this.renderPlayerControls();
+ }
- if (
- isOutsideLatestRange ||
- latestRangeEnd - video.currentTime > LIVE_EDGE_THRESHOLD_SECONDS
- ) {
- video.currentTime = Math.max(
- latestRangeStart,
- latestRangeEnd - LIVE_EDGE_SAFETY_MARGIN_SECONDS
- );
+ private handleSocketError(websocket: ServerWebSocket, event: Event): void {
+ if (this.websocket !== websocket) {
+ return;
}
+ this.onErrorCallback?.({
+ type: 'websocket',
+ inner: event as ErrorEvent,
+ });
}
- public disconnect(): void {
- this.isDisconnecting = true;
+ private handlePlayerFailure(websocket: ServerWebSocket, value: unknown): void {
+ if (this.websocket !== websocket) {
+ return;
+ }
+ const error = value instanceof Error ? value : new Error(String(value));
+ this.awaitingResponse = false;
+ this.onErrorCallback?.({ type: 'player', inner: error });
+ websocket.close(1000, 'Player failure');
+ this.websocket = null;
+ this.renderPlayerControls();
+ }
- if (this.websocket) {
- try {
- this.websocket.ws.close(1000, 'Component cleanup');
- } catch (error) {
- // Intentionally ignored: WebSocket may already be closed
- }
- this.websocket = null;
+ 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();
}
+ }
- 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 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;
+ 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.ts b/webapp/packages/shadow-player/src/websocket.ts
index 0d690b26c..99211c712 100644
--- a/webapp/packages/shadow-player/src/websocket.ts
+++ b/webapp/packages/shadow-player/src/websocket.ts
@@ -1,41 +1,46 @@
-import { ClientMessage, ServerMessage, parseClientMessage, parseServerMessage } from './protocol';
+import { ClientMessage, parseClientMessage, parseServerMessage, ServerMessage } from './protocol';
export class ServerWebSocket {
- ws: WebSocket;
+ private readonly socket: WebSocket;
+
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);
- };
-
- reader.readAsArrayBuffer(ev.data);
+ onmessage(callback: (message: ServerMessage) => Promise | void, onFailure: (error: unknown) => void): void {
+ this.socket.onmessage = (event) => {
+ try {
+ if (!(event.data instanceof ArrayBuffer)) {
+ throw new Error('Server sent a non-binary message');
+ }
+ Promise.resolve(callback(parseServerMessage(event.data))).catch(onFailure);
+ } catch (error) {
+ onFailure(error);
+ }
};
}
- onclose(callback: (ev: CloseEvent) => unknown) {
- this.ws.onclose = callback;
+ onclose(callback: (event: CloseEvent) => void): void {
+ this.socket.onclose = callback;
}
- onerror(callback: (ev: Event) => unknown) {
- this.ws.onerror = callback;
+ onerror(callback: (event: Event) => void): void {
+ this.socket.onerror = callback;
}
- send(data: T) {
- this.ws.send(parseClientMessage(data));
+ send(message: ClientMessage): void {
+ if (this.socket.readyState !== WebSocket.OPEN) {
+ throw new Error('WebSocket is not open');
+ }
+ this.socket.send(parseClientMessage(message));
}
- isClosed() {
- return this.ws.readyState === WebSocket.CLOSED;
+ close(code: number, reason: string): void {
+ this.socket.close(code, reason);
}
}