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); } }