diff --git a/e2e-tests/livestream-client/.gitignore b/e2e-tests/livestream-client/.gitignore
new file mode 100644
index 00000000..6c3ed0df
--- /dev/null
+++ b/e2e-tests/livestream-client/.gitignore
@@ -0,0 +1,29 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?
+
+# Playwright
+/test-results/
+/playwright-report/
+/playwright/.cache/
diff --git a/e2e-tests/livestream-client/README.md b/e2e-tests/livestream-client/README.md
new file mode 100644
index 00000000..d42ef223
--- /dev/null
+++ b/e2e-tests/livestream-client/README.md
@@ -0,0 +1,42 @@
+# Livestream E2E Tests
+
+This is a lightweight React app for testing WHIP/WHEP livestreaming functionality using the `@fishjam-cloud/ts-client` package.
+
+## Features
+
+The React app provides two main sections. There are no manual URL or token inputs — tokens are fetched automatically via `useSandbox()` and the room name is taken from the `?room=` query parameter (defaulting to `"livestream-e2e"`).
+
+### Receive (WHEP)
+
+- Start/Stop Receiving button
+- Video player to display the received stream
+- Connection status and error display
+
+### Publish (WHIP)
+
+- Start/Stop Publishing button
+- Video preview showing animated emoji (no camera required!)
+- Connection status and error display
+
+## Usage
+
+### Install dependencies
+
+```bash
+yarn install
+```
+
+### Run dev server
+
+```bash
+yarn dev
+```
+
+The app will be available at `http://localhost:5174`
+
+### Run tests
+
+```bash
+yarn e2e # Run tests
+yarn e2e:ui # Run tests with Playwright UI
+```
diff --git a/e2e-tests/livestream-client/config.ts b/e2e-tests/livestream-client/config.ts
new file mode 100644
index 00000000..7c66a50e
--- /dev/null
+++ b/e2e-tests/livestream-client/config.ts
@@ -0,0 +1,2 @@
+export const FISHJAM_URL =
+ import.meta.env.VITE_FISHJAM_URL || "http://localhost:4000";
diff --git a/e2e-tests/livestream-client/index.html b/e2e-tests/livestream-client/index.html
new file mode 100644
index 00000000..99463d63
--- /dev/null
+++ b/e2e-tests/livestream-client/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ Livestream E2E Tests
+
+
+
+
+
+
+
diff --git a/e2e-tests/livestream-client/package.json b/e2e-tests/livestream-client/package.json
new file mode 100644
index 00000000..36f50ae5
--- /dev/null
+++ b/e2e-tests/livestream-client/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@fishjam-e2e/livestream-client",
+ "private": true,
+ "version": "0.1.0",
+ "type": "module",
+ "license": "Apache-2.0",
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc && vite build",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "lint": "eslint . --ext .ts,.tsx --fix",
+ "lint:check": "eslint . --ext .ts,.tsx",
+ "e2e": "playwright test",
+ "e2e:ui": "playwright test --ui",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@fishjam-cloud/react-client": "workspace:*",
+ "@fishjam-cloud/ts-client": "workspace:*",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.51.1",
+ "@types/node": "^22.14.0",
+ "@types/react": "19.1.0",
+ "@types/react-dom": "19.1.0",
+ "@typescript-eslint/eslint-plugin": "^8.29.1",
+ "@typescript-eslint/parser": "^8.29.1",
+ "@vitejs/plugin-react": "^4.3.4",
+ "eslint": "^8.57.1",
+ "eslint-plugin-react-hooks": "^5.2.0",
+ "eslint-plugin-react-refresh": "^0.4.19",
+ "prettier": "^3.8.1",
+ "typescript": "^5.8.3",
+ "vite": "^6.2.6"
+ }
+}
diff --git a/e2e-tests/livestream-client/playwright.config.ts b/e2e-tests/livestream-client/playwright.config.ts
new file mode 100644
index 00000000..35d4bc6a
--- /dev/null
+++ b/e2e-tests/livestream-client/playwright.config.ts
@@ -0,0 +1,58 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+ testDir: "./scenarios",
+ /* Run tests in files in parallel */
+ fullyParallel: true,
+ /* Fail the build on CI if you accidentally left test.only in the source code. */
+ forbidOnly: !!process.env.CI,
+ /* Retry on CI only */
+ retries: process.env.CI ? 2 : 0,
+ /* Opt out of parallel tests on CI. */
+ workers: process.env.CI ? 1 : undefined,
+ /* Reporter to use. See https://playwright.dev/docs/test-reporters */
+ reporter: [
+ ["list"],
+ [
+ "html",
+ {
+ outputFolder: "../../playwright-report/livestream-e2e",
+ open: "never",
+ },
+ ],
+ ],
+ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+ use: {
+ /* Base URL to use in actions like `await page.goto('/')`. */
+ baseURL: "http://localhost:5174",
+
+ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+ trace: "on-first-retry",
+ },
+
+ /* Configure projects for major browsers */
+ projects: [
+ {
+ name: "chromium",
+ use: {
+ ...devices["Desktop Chrome"],
+ launchOptions: {
+ args: [
+ "--use-fake-ui-for-media-stream",
+ "--use-fake-device-for-media-stream",
+ ],
+ },
+ },
+ },
+ ],
+
+ /* Run your local dev server before starting the tests */
+ webServer: {
+ command: `VITE_FISHJAM_URL=${process.env.VITE_FISHJAM_URL ?? ""} yarn dev`,
+ url: "http://localhost:5174",
+ reuseExistingServer: !process.env.CI,
+ },
+});
diff --git a/e2e-tests/livestream-client/scenarios/basic.spec.ts b/e2e-tests/livestream-client/scenarios/basic.spec.ts
new file mode 100644
index 00000000..9a3e40a6
--- /dev/null
+++ b/e2e-tests/livestream-client/scenarios/basic.spec.ts
@@ -0,0 +1,61 @@
+import { expect, test } from "@playwright/test";
+
+import {
+ assertThatVideoIsPlaying,
+ assertThatVideoStopped,
+ startPublishing,
+ startReceiving,
+ stopPublishing,
+ stopReceiving,
+} from "./utils";
+
+test("Displays basic UI", async ({ page }) => {
+ await page.goto("/");
+
+ await expect(
+ page.getByRole("button", { name: "Start Publishing", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Start Receiving", exact: true }),
+ ).toBeVisible();
+ await expect(page.locator("#receive-video")).toBeVisible();
+ await expect(page.locator("#publish-video")).toBeVisible();
+});
+
+test("Viewer receives a published stream", async ({ page }) => {
+ await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`);
+
+ await startPublishing(page);
+ await startReceiving(page);
+ await assertThatVideoIsPlaying(page);
+});
+
+test("Viewer stops receiving when disconnected", async ({ page }) => {
+ await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`);
+ await startPublishing(page);
+ await startReceiving(page);
+ await assertThatVideoIsPlaying(page);
+ await stopReceiving(page);
+ await assertThatVideoStopped(page);
+});
+
+test("Viewer can reconnect after disconnecting", async ({ page }) => {
+ await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`);
+
+ await startPublishing(page);
+ await startReceiving(page);
+ await assertThatVideoIsPlaying(page);
+ await stopReceiving(page);
+ await startReceiving(page);
+ await assertThatVideoIsPlaying(page);
+});
+
+test("Stream ends when streamer stops publishing", async ({ page }) => {
+ await page.goto(`/?room=${encodeURIComponent(crypto.randomUUID())}`);
+
+ await startPublishing(page);
+ await startReceiving(page);
+ await assertThatVideoIsPlaying(page);
+ await stopPublishing(page);
+ await assertThatVideoStopped(page);
+});
diff --git a/e2e-tests/livestream-client/scenarios/utils.ts b/e2e-tests/livestream-client/scenarios/utils.ts
new file mode 100644
index 00000000..675382a3
--- /dev/null
+++ b/e2e-tests/livestream-client/scenarios/utils.ts
@@ -0,0 +1,84 @@
+import type { Page } from "@playwright/test";
+import { expect, test } from "@playwright/test";
+
+const TO_PASS_TIMEOUT_MILLIS = 15 * 1000;
+
+const expectWithLongerTimeout = expect.configure({
+ timeout: TO_PASS_TIMEOUT_MILLIS,
+});
+
+type ViewerWindow = typeof window & {
+ viewer?: {
+ getStatistics: () => Promise;
+ };
+};
+
+const getDecodedFrames = (page: Page): Promise =>
+ page.evaluate(async () => {
+ const viewer = (window as ViewerWindow)?.viewer;
+ if (!viewer) return -1;
+ const stats = await viewer.getStatistics();
+ if (!stats) return -1;
+ for (const stat of stats.values()) {
+ if (stat.type === "inbound-rtp") {
+ return stat.framesDecoded ?? 0;
+ }
+ }
+ return 0;
+ });
+
+export const startPublishing = async (page: Page) =>
+ await test.step("Start publishing", async () => {
+ await page
+ .getByRole("button", { name: "Start Publishing", exact: true })
+ .click();
+ await expectWithLongerTimeout(
+ page.locator("#publish-status"),
+ ).toContainText("Publishing");
+ });
+
+export const stopPublishing = async (page: Page) =>
+ await test.step("Stop publishing", async () => {
+ await page
+ .getByRole("button", { name: "Stop Publishing", exact: true })
+ .click();
+ await expectWithLongerTimeout(
+ page.locator("#publish-status"),
+ ).toContainText("Not publishing");
+ });
+
+export const startReceiving = async (page: Page) =>
+ await test.step("Start receiving", async () => {
+ await page
+ .getByRole("button", { name: "Start Receiving", exact: true })
+ .click();
+ await expectWithLongerTimeout(
+ page.locator("#receive-status"),
+ ).toContainText("Receiving");
+ });
+
+export const stopReceiving = async (page: Page) =>
+ await test.step("Stop receiving", async () => {
+ await page
+ .getByRole("button", { name: "Stop Receiving", exact: true })
+ .click();
+ await expectWithLongerTimeout(
+ page.locator("#receive-status"),
+ ).toContainText("Not receiving");
+ });
+
+export const assertThatVideoIsPlaying = async (page: Page) =>
+ await test.step("Assert that video is playing", async () => {
+ const firstMeasure = await getDecodedFrames(page);
+ await expectWithLongerTimeout(async () =>
+ expect(await getDecodedFrames(page)).toBeGreaterThan(firstMeasure),
+ ).toPass();
+ });
+
+export const assertThatVideoStopped = async (page: Page) =>
+ await test.step("Assert that video stopped", async () => {
+ const firstMeasure = await getDecodedFrames(page);
+ await page.waitForTimeout(500);
+ const secondMeasure = await getDecodedFrames(page);
+ expect(secondMeasure - firstMeasure).toBeLessThanOrEqual(0);
+ });
diff --git a/e2e-tests/livestream-client/src/App.tsx b/e2e-tests/livestream-client/src/App.tsx
new file mode 100644
index 00000000..b3541759
--- /dev/null
+++ b/e2e-tests/livestream-client/src/App.tsx
@@ -0,0 +1,209 @@
+import {
+ useLivestreamStreamer,
+ useLivestreamViewer,
+ useSandbox,
+} from "@fishjam-cloud/react-client";
+import { LivestreamError } from "@fishjam-cloud/ts-client";
+import { useEffect, useRef, useState } from "react";
+
+import { createCanvasStream, stopCanvasStream } from "./canvasStream";
+
+const roomName =
+ new URLSearchParams(window.location.search).get("room") ?? "livestream-e2e";
+
+export function App() {
+ const { getSandboxLivestream, getSandboxViewerToken } = useSandbox();
+ const viewer = useLivestreamViewer();
+ const streamer = useLivestreamStreamer();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ (window as any).viewer = viewer;
+
+ // Receive (WHEP) state
+ const [receiveError, setReceiveError] = useState(null);
+ const receiveVideoRef = useRef(null);
+
+ // Publish (WHIP) state
+ const [publishError, setPublishError] = useState(null);
+ const publishVideoRef = useRef(null);
+ const localStreamRef = useRef(null);
+
+ useEffect(() => {
+ if (!receiveVideoRef.current) return;
+ receiveVideoRef.current.srcObject = viewer.stream;
+ }, [viewer.stream]);
+
+ const handleStartReceiving = async () => {
+ setReceiveError(null);
+ try {
+ const viewerToken = await getSandboxViewerToken(roomName);
+ await viewer.connect({ token: viewerToken });
+ } catch (error) {
+ const errorMessage =
+ error === LivestreamError.UNAUTHORIZED
+ ? "Unauthorized: Invalid or missing token"
+ : error === LivestreamError.STREAM_NOT_FOUND
+ ? "Stream not found"
+ : error === LivestreamError.UNKNOWN_ERROR
+ ? "Unknown error occurred"
+ : String(error);
+
+ setReceiveError(errorMessage);
+ console.error("Failed to start receiving:", error);
+ }
+ };
+
+ const handleStopReceiving = async () => {
+ viewer.disconnect();
+ if (receiveVideoRef.current) {
+ receiveVideoRef.current.srcObject = null;
+ }
+ };
+
+ const handleStartPublishing = async () => {
+ setPublishError(null);
+ try {
+ const { streamerToken } = await getSandboxLivestream(roomName);
+
+ // Create canvas stream with animated emoji
+ const stream = createCanvasStream();
+
+ localStreamRef.current = stream;
+
+ if (publishVideoRef.current) {
+ publishVideoRef.current.srcObject = stream;
+ }
+
+ await streamer.connect({
+ inputs: { video: stream },
+ token: streamerToken,
+ });
+ } catch (error) {
+ const errorMessage =
+ error === LivestreamError.UNAUTHORIZED
+ ? "Unauthorized: Invalid or missing token"
+ : error === LivestreamError.STREAM_NOT_FOUND
+ ? "Stream not found"
+ : error === LivestreamError.STREAMER_ALREADY_CONNECTED
+ ? "Streamer already connected"
+ : error === LivestreamError.UNKNOWN_ERROR
+ ? "Unknown error occurred"
+ : String(error);
+
+ setPublishError(errorMessage);
+ console.error("Failed to start publishing:", error);
+
+ // Clean up local stream if it was created
+ if (localStreamRef.current) {
+ stopCanvasStream(localStreamRef.current);
+ localStreamRef.current = null;
+ }
+ }
+ };
+
+ const handleStopPublishing = async () => {
+ streamer.disconnect();
+
+ if (localStreamRef.current) {
+ stopCanvasStream(localStreamRef.current);
+ localStreamRef.current = null;
+ }
+
+ if (publishVideoRef.current) {
+ publishVideoRef.current.srcObject = null;
+ }
+ };
+
+ return (
+
+
Livestream E2E Tests
+
+
+ {/* Receive Section */}
+
+
Receive (WHEP)
+
+
+ {viewer.isConnected ? "Stop Receiving" : "Start Receiving"}
+
+ {receiveError && (
+
+ Error: {receiveError}
+
+ )}
+
+ Status: {viewer.isConnected ? "Receiving" : "Not receiving"}
+
+
+
+
+
+
+ {/* Publish Section */}
+
+
Publish (WHIP)
+
+
+ {streamer.isConnected ? "Stop Publishing" : "Start Publishing"}
+
+ {publishError && (
+
+ Error: {publishError}
+
+ )}
+
+ Status: {streamer.isConnected ? "Publishing" : "Not publishing"}
+
+
+
+
+
+
+
+ );
+}
diff --git a/e2e-tests/livestream-client/src/canvasStream.ts b/e2e-tests/livestream-client/src/canvasStream.ts
new file mode 100644
index 00000000..4b91e1eb
--- /dev/null
+++ b/e2e-tests/livestream-client/src/canvasStream.ts
@@ -0,0 +1,92 @@
+/**
+ * Creates a MediaStream from a canvas with an animated emoji.
+ * This works reliably in CI environments like GitHub runners.
+ */
+export function createCanvasStream(): MediaStream {
+ const canvas = document.createElement("canvas");
+ canvas.width = 640;
+ canvas.height = 480;
+ const ctx = canvas.getContext("2d")!;
+
+ const emojis = ["🎥", "📹", "🎬", "🎞️", "📽️"];
+ let frame = 0;
+ let emojiIndex = 0;
+
+ // Animate the canvas
+ const animate = () => {
+ // Background with gradient
+ const gradient = ctx.createLinearGradient(
+ 0,
+ 0,
+ canvas.width,
+ canvas.height,
+ );
+ gradient.addColorStop(0, `hsl(${frame % 360}, 70%, 50%)`);
+ gradient.addColorStop(1, `hsl(${(frame + 120) % 360}, 70%, 50%)`);
+ ctx.fillStyle = gradient;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+
+ // Draw emoji
+ ctx.font = "120px Arial";
+ ctx.textAlign = "center";
+ ctx.textBaseline = "middle";
+
+ // Bounce effect
+ const bounce = Math.sin(frame * 0.1) * 30;
+ const rotation = Math.sin(frame * 0.05) * 0.2;
+
+ ctx.save();
+ ctx.translate(canvas.width / 2, canvas.height / 2 + bounce);
+ ctx.rotate(rotation);
+ ctx.fillText(emojis[emojiIndex], 0, 0);
+ ctx.restore();
+
+ // Draw frame counter
+ ctx.fillStyle = "white";
+ ctx.font = "20px monospace";
+ ctx.textAlign = "left";
+ ctx.fillText(`Frame: ${frame}`, 10, 30);
+ ctx.fillText(`Time: ${(frame / 30).toFixed(1)}s`, 10, 60);
+
+ frame++;
+
+ // Change emoji every 60 frames (~2 seconds at 30fps)
+ if (frame % 60 === 0) {
+ emojiIndex = (emojiIndex + 1) % emojis.length;
+ }
+ };
+
+ // Start animation loop
+ const intervalId = setInterval(animate, 1000 / 30); // 30 FPS
+
+ // Initial draw
+ animate();
+
+ // Create stream from canvas
+ const stream = canvas.captureStream(30); // 30 FPS
+
+ // Store interval ID so we can clean it up
+ (
+ stream as MediaStream & { _intervalId?: ReturnType }
+ )._intervalId = intervalId;
+
+ return stream;
+}
+
+/**
+ * Stops the canvas stream and cleans up the animation interval.
+ */
+export function stopCanvasStream(stream: MediaStream | null) {
+ if (!stream) return;
+
+ // Clean up animation interval if it exists
+ const intervalId = (
+ stream as MediaStream & { _intervalId?: ReturnType }
+ )._intervalId;
+ if (intervalId) {
+ clearInterval(intervalId);
+ }
+
+ // Stop all tracks
+ stream.getTracks().forEach((track) => track.stop());
+}
diff --git a/e2e-tests/livestream-client/src/main.tsx b/e2e-tests/livestream-client/src/main.tsx
new file mode 100644
index 00000000..686cd572
--- /dev/null
+++ b/e2e-tests/livestream-client/src/main.tsx
@@ -0,0 +1,14 @@
+import { FishjamProvider } from "@fishjam-cloud/react-client";
+import React from "react";
+import ReactDOM from "react-dom/client";
+
+import { FISHJAM_URL } from "../config";
+import { App } from "./App";
+
+ReactDOM.createRoot(document.getElementById("root")!).render(
+
+
+
+
+ ,
+);
diff --git a/e2e-tests/livestream-client/src/vite-env.d.ts b/e2e-tests/livestream-client/src/vite-env.d.ts
new file mode 100644
index 00000000..4b778ed8
--- /dev/null
+++ b/e2e-tests/livestream-client/src/vite-env.d.ts
@@ -0,0 +1,10 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_WHEP_URL?: string;
+ readonly VITE_WHIP_URL?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/e2e-tests/livestream-client/tsconfig.json b/e2e-tests/livestream-client/tsconfig.json
new file mode 100644
index 00000000..df9af620
--- /dev/null
+++ b/e2e-tests/livestream-client/tsconfig.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "lib": ["dom", "dom.iterable", "es2022"],
+ "module": "esnext",
+ "moduleResolution": "bundler",
+ "target": "es2022",
+ "jsx": "react-jsx",
+ "strict": true,
+ "incremental": true,
+ "skipLibCheck": true,
+ "esModuleInterop": true,
+ "declaration": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "outDir": "dist"
+ },
+ "include": ["src/**/*"]
+}
diff --git a/e2e-tests/livestream-client/tsconfig.node.json b/e2e-tests/livestream-client/tsconfig.node.json
new file mode 100644
index 00000000..91931218
--- /dev/null
+++ b/e2e-tests/livestream-client/tsconfig.node.json
@@ -0,0 +1,10 @@
+{
+ "compilerOptions": {
+ "composite": true,
+ "skipLibCheck": true,
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "allowSyntheticDefaultImports": true
+ },
+ "include": ["vite.config.ts", "playwright.config.ts", "config.ts"]
+}
diff --git a/e2e-tests/livestream-client/vite.config.ts b/e2e-tests/livestream-client/vite.config.ts
new file mode 100644
index 00000000..5a9f993a
--- /dev/null
+++ b/e2e-tests/livestream-client/vite.config.ts
@@ -0,0 +1,8 @@
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+// https://vitejs.dev/config/
+export default defineConfig({
+ plugins: [react()],
+ server: { port: 5174 },
+});
diff --git a/package.json b/package.json
index 02d8f799..cbf67d37 100644
--- a/package.json
+++ b/package.json
@@ -14,13 +14,14 @@
"examples/mobile-client/*",
"examples/mobile-client/common/plugins",
"e2e-tests/webrtc-client",
- "e2e-tests/react-client"
+ "e2e-tests/react-client",
+ "e2e-tests/livestream-client"
],
"packageManager": "yarn@4.12.0",
"scripts": {
"build": "yarn workspaces foreach -Ap --topological-dev run build",
"test:unit": "yarn workspaces foreach -Ap run test",
- "test:e2e": "yarn workspace @fishjam-e2e/webrtc-client e2e && yarn workspace @fishjam-e2e/react-client e2e",
+ "test:e2e": "yarn workspaces foreach -A --include '@fishjam-e2e/*' run e2e",
"gen:proto": "yarn workspace @fishjam-cloud/protobufs gen:proto",
"tsc": "yarn workspaces foreach -Ap run tsc || echo '❌ Type errors! ❌' ",
"format:root": "prettier . --write",
diff --git a/packages/react-client/src/hooks/useLivestreamViewer.ts b/packages/react-client/src/hooks/useLivestreamViewer.ts
index c6086ea5..238c79b5 100644
--- a/packages/react-client/src/hooks/useLivestreamViewer.ts
+++ b/packages/react-client/src/hooks/useLivestreamViewer.ts
@@ -24,6 +24,7 @@ export interface UseLivestreamViewerResult {
error: LivestreamError | null;
/** Utility flag which indicates the current connection status */
isConnected: boolean;
+ getStatistics: () => Promise;
}
const isLivestreamError = (err: unknown): err is LivestreamError =>
@@ -79,5 +80,7 @@ export const useLivestreamViewer = (): UseLivestreamViewerResult => {
[disconnect, onConnectionStateChange, fishjamId],
);
- return { stream, connect, disconnect, error, isConnected };
+ const getStatistics = useCallback(async () => resultRef.current?.getStatistics(), []);
+
+ return { stream, connect, disconnect, error, isConnected, getStatistics };
};
diff --git a/packages/ts-client/src/livestream.ts b/packages/ts-client/src/livestream.ts
index a00ab1dc..b43db8eb 100644
--- a/packages/ts-client/src/livestream.ts
+++ b/packages/ts-client/src/livestream.ts
@@ -1,10 +1,12 @@
export type ReceiveLivestreamResult = {
stream: MediaStream;
stop: () => Promise;
+ getStatistics: () => Promise;
};
export type PublishLivestreamResult = {
stopPublishing: () => Promise;
+ getStatistics: () => Promise;
};
export enum LivestreamError {
@@ -33,6 +35,7 @@ export async function receiveLivestream(url: string, token?: string, callbacks?:
const stream = event.streams[0];
if (stream) {
resolve({
+ getStatistics: () => pc.getStats(),
stream,
stop: async () => {
await whep.stop();
@@ -89,6 +92,7 @@ export async function publishLivestream(
}
return {
+ getStatistics: () => pc.getStats(),
stopPublishing: async () => {
await whip.stop();
callbacks?.onConnectionStateChange?.(pc);
diff --git a/yarn.lock b/yarn.lock
index ae12863a..31cc74dc 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3949,6 +3949,30 @@ __metadata:
languageName: unknown
linkType: soft
+"@fishjam-e2e/livestream-client@workspace:e2e-tests/livestream-client":
+ version: 0.0.0-use.local
+ resolution: "@fishjam-e2e/livestream-client@workspace:e2e-tests/livestream-client"
+ dependencies:
+ "@fishjam-cloud/react-client": "workspace:*"
+ "@fishjam-cloud/ts-client": "workspace:*"
+ "@playwright/test": "npm:^1.51.1"
+ "@types/node": "npm:^22.14.0"
+ "@types/react": "npm:19.1.0"
+ "@types/react-dom": "npm:19.1.0"
+ "@typescript-eslint/eslint-plugin": "npm:^8.29.1"
+ "@typescript-eslint/parser": "npm:^8.29.1"
+ "@vitejs/plugin-react": "npm:^4.3.4"
+ eslint: "npm:^8.57.1"
+ eslint-plugin-react-hooks: "npm:^5.2.0"
+ eslint-plugin-react-refresh: "npm:^0.4.19"
+ prettier: "npm:^3.8.1"
+ react: "npm:19.1.0"
+ react-dom: "npm:19.1.0"
+ typescript: "npm:^5.8.3"
+ vite: "npm:^6.2.6"
+ languageName: unknown
+ linkType: soft
+
"@fishjam-e2e/react-client@workspace:e2e-tests/react-client":
version: 0.0.0-use.local
resolution: "@fishjam-e2e/react-client@workspace:e2e-tests/react-client"
@@ -16373,6 +16397,15 @@ __metadata:
languageName: node
linkType: hard
+"prettier@npm:^3.8.1":
+ version: 3.8.1
+ resolution: "prettier@npm:3.8.1"
+ bin:
+ prettier: bin/prettier.cjs
+ checksum: 10c0/33169b594009e48f570471271be7eac7cdcf88a209eed39ac3b8d6d78984039bfa9132f82b7e6ba3b06711f3bfe0222a62a1bfb87c43f50c25a83df1b78a2c42
+ languageName: node
+ linkType: hard
+
"pretty-bytes@npm:^5.6.0":
version: 5.6.0
resolution: "pretty-bytes@npm:5.6.0"