Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions webapp/apps/recording-player/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
Expand Down
11 changes: 11 additions & 0 deletions webapp/apps/recording-player/public/locales/de/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
11 changes: 11 additions & 0 deletions webapp/apps/recording-player/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
11 changes: 11 additions & 0 deletions webapp/apps/recording-player/public/locales/es/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
11 changes: 11 additions & 0 deletions webapp/apps/recording-player/public/locales/fr/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
9 changes: 9 additions & 0 deletions webapp/apps/recording-player/src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down
18 changes: 12 additions & 6 deletions webapp/apps/recording-player/src/streamers/webm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions webapp/packages/shadow-player/demo-src/apiClient.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions webapp/packages/shadow-player/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@
background-color: #e9ecef;
}

webm-stream-player {
shadow-player {
width: 80%;
height: 80%;
background-color: #000;
Expand All @@ -120,7 +120,7 @@ <h2>Streaming Files</h2>

<!-- Main Content -->
<div class="player-container">
<shadow-player id="shadowPlayer"></shadow-player>
<shadow-player id="shadowPlayer" controls></shadow-player>
</div>
</div>

Expand Down
7 changes: 5 additions & 2 deletions webapp/packages/shadow-player/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run"
},
"devDependencies": {
"jsdom": "^20.0.3",
"ts-node": "^10.9.2",
"typescript": "~5.6.2",
"vite": "^5.4.9",
"vite-plugin-dts": "^4.3.0",
"vite-plugin-static-copy": "^2.3.0"
"vite-plugin-static-copy": "^2.3.0",
"vitest": "^3.1.1"
}
}
93 changes: 93 additions & 0 deletions webapp/packages/shadow-player/src/playbackClip.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// @vitest-environment jsdom

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { PlaybackClip } from './playbackClip';

class FakeSourceBuffer extends EventTarget {
updating = false;

appendBuffer(): void {
if (this.updating) {
throw new Error('concurrent append');
}
this.updating = true;
}

completeAppend(): void {
this.updating = false;
this.dispatchEvent(new Event('updateend'));
}
}

class FakeMediaSource extends EventTarget {
static latest: FakeMediaSource | null = null;

readyState: ReadyState = 'closed';
readonly sourceBuffer = new FakeSourceBuffer();
endOfStreamCalls = 0;

constructor() {
super();
FakeMediaSource.latest = this;
}

addSourceBuffer(): SourceBuffer {
return this.sourceBuffer as unknown as SourceBuffer;
}

open(): void {
this.readyState = 'open';
this.dispatchEvent(new Event('sourceopen'));
}

endOfStream(): void {
if (this.sourceBuffer.updating) {
throw new Error('endOfStream during append');
}
this.endOfStreamCalls += 1;
this.readyState = 'ended';
}
}

describe('PlaybackClip', () => {
beforeEach(() => {
vi.stubGlobal('MediaSource', FakeMediaSource);
vi.stubGlobal('URL', {
createObjectURL: vi.fn(() => 'blob:test'),
revokeObjectURL: vi.fn(),
});
});

afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
FakeMediaSource.latest = null;
});

it('waits for pending SourceBuffer work before ending the MediaSource', async () => {
const clip = new PlaybackClip({
type: 'segment-started',
codec: 'vp8',
sequence: 0,
width: 640,
height: 480,
});
const mediaSource = FakeMediaSource.latest;
expect(mediaSource).not.toBeNull();
mediaSource?.open();
await clip.open();

const append = clip.append(new Uint8Array([1]));
const finish = Promise.resolve().then(() => clip.finish());
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(mediaSource?.sourceBuffer.updating).toBe(true);
expect(mediaSource?.endOfStreamCalls).toBe(0);

mediaSource?.sourceBuffer.completeAppend();
await append;
await finish;
expect(mediaSource?.endOfStreamCalls).toBe(1);
});
});
91 changes: 91 additions & 0 deletions webapp/packages/shadow-player/src/playbackClip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import type { SegmentStartedMessage } from './protocol';
import { ReactiveSourceBuffer } from './sourceBuffer';

export class PlaybackClip {
readonly video = document.createElement('video');

private readonly mediaSource = new MediaSource();
private readonly objectUrl = URL.createObjectURL(this.mediaSource);
private readonly opened: Promise<void>;
private sourceBuffer: ReactiveSourceBuffer | null = null;
private debug = false;
private complete = false;
private finishing: Promise<void> | null = null;

constructor(readonly metadata: SegmentStartedMessage) {
this.video.src = this.objectUrl;
this.opened = new Promise<void>((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<void> {
await this.opened;
}

async append(data: Uint8Array): Promise<void> {
await this.opened;
if (this.complete || !this.sourceBuffer) {
throw new Error('Cannot append to a completed clip');
}
await this.sourceBuffer.appendBuffer(data);
}

async finish(): Promise<void> {
await this.opened;
if (this.finishing) {
return this.finishing;
}
const sourceBuffer = this.sourceBuffer;
if (this.complete || !sourceBuffer) {
return;
}

this.complete = true;
this.finishing = (async () => {
await sourceBuffer.whenIdle();
if (this.mediaSource.readyState !== 'open') {
throw new Error('Cannot finish a MediaSource that is not open');
}
this.mediaSource.endOfStream();
})();
return this.finishing;
}

setDebug(debug: boolean): void {
this.debug = debug;
this.sourceBuffer?.setDebug(debug);
}

downloadBufferedFile(): void {
this.sourceBuffer?.downloadBufferedFile();
}

dispose(): void {
this.video.pause();
this.video.removeAttribute('src');
this.video.load();
this.video.remove();
URL.revokeObjectURL(this.objectUrl);
}
}
Loading
Loading