From 5aadfb25dc5a559200278a3c742bc7f227a5a05d Mon Sep 17 00:00:00 2001 From: kimsun721 Date: Fri, 23 Oct 2026 10:46:50 +0900 Subject: [PATCH 1/7] feat(sdk): add a browser viewer SDK --- .github/workflows/ci.yml | 24 ++ sdk/.gitignore | 2 + sdk/README.md | 78 ++++ sdk/build.mjs | 21 + sdk/package-lock.json | 894 +++++++++++++++++++++++++++++++++++++++ sdk/package.json | 40 ++ sdk/src/bundle.ts | 4 + sdk/src/emitter.ts | 26 ++ sdk/src/index.ts | 10 + sdk/src/layers.ts | 92 ++++ sdk/src/player.ts | 269 ++++++++++++ sdk/src/protocol.ts | 44 ++ sdk/src/stats.ts | 156 +++++++ sdk/src/viewer.ts | 499 ++++++++++++++++++++++ sdk/test/layers.test.ts | 102 +++++ sdk/test/stats.test.ts | 64 +++ sdk/test/tsconfig.json | 12 + sdk/tsconfig.build.json | 16 + sdk/tsconfig.json | 22 + static/stream-viewer.js | 726 +++++++++++++++++++++++++++++++ 20 files changed, 3101 insertions(+) create mode 100644 sdk/.gitignore create mode 100644 sdk/README.md create mode 100644 sdk/build.mjs create mode 100644 sdk/package-lock.json create mode 100644 sdk/package.json create mode 100644 sdk/src/bundle.ts create mode 100644 sdk/src/emitter.ts create mode 100644 sdk/src/index.ts create mode 100644 sdk/src/layers.ts create mode 100644 sdk/src/player.ts create mode 100644 sdk/src/protocol.ts create mode 100644 sdk/src/stats.ts create mode 100644 sdk/src/viewer.ts create mode 100644 sdk/test/layers.test.ts create mode 100644 sdk/test/stats.test.ts create mode 100644 sdk/test/tsconfig.json create mode 100644 sdk/tsconfig.build.json create mode 100644 sdk/tsconfig.json create mode 100644 static/stream-viewer.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c1f86d..ee97a57 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,3 +28,27 @@ jobs: # The load run and the tests that need ffmpeg are ignored, so this is the # unit suite plus every integration suite. - run: cargo test + + sdk: + runs-on: ubuntu-latest + + defaults: + run: + working-directory: sdk + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: sdk/package-lock.json + + - run: npm ci + + - run: npm run check + + # The server serves this file, so it has to match the sources it was + # built from. + - run: git diff --exit-code ../static/stream-viewer.js diff --git a/sdk/.gitignore b/sdk/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/sdk/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/sdk/README.md b/sdk/README.md new file mode 100644 index 0000000..c0c5518 --- /dev/null +++ b/sdk/README.md @@ -0,0 +1,78 @@ +# @kimsun721/stream-viewer + +The browser side of [stream](https://github.com/kimsun721/stream), a WebRTC SFU for live streaming. It joins a room, plays it, follows the simulcast layers the server offers, and reports link quality, so a page does not reimplement the viewer protocol. + +```sh +npm install @kimsun721/stream-viewer +``` + +## A ready-made player + +```js +import { createPlayer } from "@kimsun721/stream-viewer/player"; + +const player = createPlayer(document.getElementById("player"), { + room: "RM_0_...", + server: "https://stream.example.com:8443", +}); + +// later +player.destroy(); +``` + +The player draws the video, a stats readout, layer buttons, a mute toggle and a debug log. Its colors are custom properties on `.sv-player` (`--sv-bg`, `--sv-panel`, `--sv-ink`, `--sv-accent` and the rest), so a page restyles it without touching the rules. + +## The core, with your own UI + +```js +import { Viewer } from "@kimsun721/stream-viewer"; + +const viewer = new Viewer({ room: "RM_0_...", server: "https://stream.example.com:8443" }); +viewer.attach(document.querySelector("video")); + +viewer.on("state", ({ state, reason }) => console.log(state, reason)); +viewer.on("layers", (layers) => console.log(layers.layers, layers.current, layers.mode)); +viewer.on("stats", (stats) => console.log(stats.width, stats.mbps)); + +await viewer.connect(); +viewer.setLayer("l"); +viewer.setAutoLayer(); +viewer.close(); +``` + +| Option | Default | | +| --- | --- | --- | +| `room` | | The room id | +| `server` | the page's origin | Base URL of the server's client API, the HTTPS port | +| `reconnect` | `true` | Join again after a dropped connection, and keep asking while the room is not live | +| `maxRetryDelayMs` | `10000` | Ceiling for the wait between attempts, which doubles from one second | +| `statsIntervalMs` | `1000` | How often stats are read and link quality reported | +| `debug` | `false` | Emit `log` events, also switched with `setDebug` | +| `rtcConfiguration` | | Passed to `RTCPeerConnection` | + +| Event | Payload | +| --- | --- | +| `state` | `idle`, `connecting`, `connected`, `reconnecting`, `not-live`, `failed` or `closed`, with a `reason` | +| `layers` | The video track's layers, the one being sent, a pending target and the mode | +| `layerchange` | A switch, with how long it took when it answers a request | +| `stats` | Picture size, frame rate, bitrate, round trip, freezes, loss | +| `log` | Debug mode: switches, freezes, RTP timestamp jumps, connection changes | +| `error` | An error, retried or not | + +`connect()` resolves once connected. It rejects with `JoinError` for a refused offer that is not worth retrying, and when `close()` is called first. + +## Notes + +- A layer change takes effect on the next keyframe of the new layer. The server asks the publisher for one once, so a publisher has to send keyframes on an interval of its own, as OBS does. +- Browsers start video muted to allow autoplay. The player has a button for sound; with the core, unmute the element from a user gesture. +- Relaying to other viewers is not supported yet. The server never promotes a viewer that does not answer the relay protocol. +- The server serves the same code as one file at `/stream-viewer.js`, which its own `/watch` page uses. + +## Developing + +```sh +npm ci +npm run check # typecheck, unit tests, build +``` + +`npm run build` also rewrites `../static/stream-viewer.js`. Commit it with the sources; CI fails when it drifts. diff --git a/sdk/build.mjs b/sdk/build.mjs new file mode 100644 index 0000000..938d28e --- /dev/null +++ b/sdk/build.mjs @@ -0,0 +1,21 @@ +import { build } from "esbuild"; + +const common = { bundle: true, format: "esm", target: "es2022", logLevel: "warning" }; + +// The package: two entry points sharing one chunk, so a page importing both +// gets one Viewer class. +await build({ + ...common, + entryPoints: { index: "src/index.ts", player: "src/player.ts" }, + outdir: "dist", + splitting: true, +}); + +// The server's copy, one file. Checked in, and CI fails if it drifts from the +// sources. +await build({ + ...common, + entryPoints: ["src/bundle.ts"], + outfile: "../static/stream-viewer.js", + banner: { js: "// Built from sdk/ by `npm run build`. Do not edit." }, +}); diff --git a/sdk/package-lock.json b/sdk/package-lock.json new file mode 100644 index 0000000..bc11f9a --- /dev/null +++ b/sdk/package-lock.json @@ -0,0 +1,894 @@ +{ + "name": "@kimsun721/stream-viewer", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@kimsun721/stream-viewer", + "version": "0.1.0", + "license": "MIT OR Apache-2.0", + "devDependencies": { + "@types/node": "22.20.5", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.20.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz", + "integrity": "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/sdk/package.json b/sdk/package.json new file mode 100644 index 0000000..012092a --- /dev/null +++ b/sdk/package.json @@ -0,0 +1,40 @@ +{ + "name": "@kimsun721/stream-viewer", + "version": "0.1.0", + "description": "Browser viewer for the stream WebRTC SFU: playback, simulcast layers, perf reports and a ready-made player", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/kimsun721/stream.git", + "directory": "sdk" + }, + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./player": { + "types": "./dist/player.d.ts", + "default": "./dist/player.js" + } + }, + "files": [ + "dist" + ], + "sideEffects": false, + "scripts": { + "build": "node build.mjs && tsc -p tsconfig.build.json", + "typecheck": "tsc -p tsconfig.json && tsc -p test/tsconfig.json", + "test": "node --test \"test/*.test.ts\"", + "check": "npm run typecheck && npm test && npm run build" + }, + "publishConfig": { + "access": "public" + }, + "devDependencies": { + "@types/node": "22.20.5", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } +} diff --git a/sdk/src/bundle.ts b/sdk/src/bundle.ts new file mode 100644 index 0000000..e87c07c --- /dev/null +++ b/sdk/src/bundle.ts @@ -0,0 +1,4 @@ +// The single file the server serves at /stream-viewer.js, for pages without a +// bundler. The npm package ships the entry points separately instead. +export * from "./index.js"; +export { createPlayer, type Player, type PlayerOptions } from "./player.js"; diff --git a/sdk/src/emitter.ts b/sdk/src/emitter.ts new file mode 100644 index 0000000..67cf5d3 --- /dev/null +++ b/sdk/src/emitter.ts @@ -0,0 +1,26 @@ +export class Emitter> { + #listeners = new Map void>>(); + + /** Returns a function that removes the listener. */ + on(event: K, listener: (value: Events[K]) => void): () => void { + let set = this.#listeners.get(event); + if (!set) this.#listeners.set(event, (set = new Set())); + set.add(listener as (value: never) => void); + return () => this.off(event, listener); + } + + off(event: K, listener: (value: Events[K]) => void): void { + this.#listeners.get(event)?.delete(listener as (value: never) => void); + } + + protected emit(event: K, value: Events[K]): void { + for (const listener of this.#listeners.get(event) ?? []) { + // A listener that throws must not stop the others or the SDK's own work. + try { + (listener as (value: Events[K]) => void)(value); + } catch (error) { + console.error(error); + } + } + } +} diff --git a/sdk/src/index.ts b/sdk/src/index.ts new file mode 100644 index 0000000..f78e5d5 --- /dev/null +++ b/sdk/src/index.ts @@ -0,0 +1,10 @@ +export { + Viewer, + JoinError, + type ViewerEvents, + type ViewerOptions, + type ViewerState, +} from "./viewer.js"; +export type { LayerState } from "./layers.js"; +export type { ViewerStats } from "./stats.js"; +export type { AvailableLayer, LayerMode } from "./protocol.js"; diff --git a/sdk/src/layers.ts b/sdk/src/layers.ts new file mode 100644 index 0000000..7191582 --- /dev/null +++ b/sdk/src/layers.ts @@ -0,0 +1,92 @@ +import type { AvailableLayer, ClientMessage, LayerMode, ServerMessage } from "./protocol.js"; + +/** + * The simulcast layers of the video track. `current` is what the server is + * sending. `target` is what it will switch to once a keyframe of that layer + * arrives. + */ +export interface LayerState { + /** The video track's mid, or null while no track with layers is known. */ + mid: string | null; + layers: AvailableLayer[]; + current: string | null; + target: string | null; + mode: LayerMode; + /** Whether the server has described a track yet, layered or not. */ + described: boolean; +} + +export function initialLayers(): LayerState { + return { mid: null, layers: [], current: null, target: null, mode: "auto", described: false }; +} + +type LayerStatus = Extract; +type LayerChanged = Extract; + +/** Audio, and video from a publisher without simulcast, come with no layers. */ +export function applyLayerStatus(state: LayerState, message: LayerStatus): LayerState { + if (message.available_simulcast_layers.length === 0) { + return { ...state, described: true }; + } + + return { + mid: message.mid, + layers: message.available_simulcast_layers, + current: message.current_layer, + target: message.target_layer, + mode: message.layer_mode, + described: true, + }; +} + +export interface Switch { + from: string | null; + to: string; +} + +export function applyLayerChanged( + state: LayerState, + message: LayerChanged, +): { state: LayerState; switched: Switch | null } { + if (message.mid !== state.mid) return { state, switched: null }; + + // In automatic mode the server retargets without saying so, which leaves a + // target heard earlier stale. A switch always settles it. + const settled = state.mode === "auto" || state.target === message.rid; + + return { + state: { ...state, current: message.rid, target: settled ? null : state.target }, + switched: { from: state.current, to: message.rid }, + }; +} + +/** + * The messages that ask for a layer, or for automatic selection when `rid` is + * null. The server ignores a layer request in automatic mode, so asking for a + * layer switches to manual first. + */ +export function requestLayer( + state: LayerState, + rid: string | null, +): { state: LayerState; messages: ClientMessage[] } { + const mid = state.mid; + if (mid === null) return { state, messages: [] }; + + if (rid === null) { + return { + state: { ...state, mode: "auto" }, + messages: [{ type: "set_layer_mode", mid, layer_mode: "auto" }], + }; + } + + if (!state.layers.some((layer) => layer.rid === rid)) return { state, messages: [] }; + + const messages: ClientMessage[] = []; + if (state.mode !== "manual") messages.push({ type: "set_layer_mode", mid, layer_mode: "manual" }); + messages.push({ type: "set_layer", mid, rid }); + + return { + state: { ...state, mode: "manual", target: rid === state.current ? null : rid }, + messages, + }; +} diff --git a/sdk/src/player.ts b/sdk/src/player.ts new file mode 100644 index 0000000..1f38949 --- /dev/null +++ b/sdk/src/player.ts @@ -0,0 +1,269 @@ +import type { LayerState } from "./layers.js"; +import type { ViewerStats } from "./stats.js"; +import { Viewer, type ViewerOptions, type ViewerState } from "./viewer.js"; + +export interface PlayerOptions extends ViewerOptions { + /** + * Show the wall clock over the picture. Point a camera at a clock, or show + * one in the source, and the two side by side give the end to end delay. + */ + clock?: boolean; + /** Show the Debug button. Defaults to true. */ + debugToggle?: boolean; + /** Start muted, which browsers require to play without a click. Defaults to true. */ + muted?: boolean; + /** Called when the Debug button changes the mode. */ + onDebugChange?: (on: boolean) => void; +} + +export interface Player { + readonly viewer: Viewer; + readonly element: HTMLElement; + /** Closes the viewer and removes the player from the page. */ + destroy(): void; +} + +const STYLE_ID = "stream-viewer-player-style"; + +// Colors are custom properties on the player, so a page can restyle it with +// its own values without touching the rules. +const STYLE = ` +.sv-player { + --sv-bg: #0f1113; + --sv-panel: #181b1e; + --sv-line: #2b3035; + --sv-ink: #e7e9eb; + --sv-muted: #8d959c; + --sv-accent: #5cc2b4; + --sv-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + display: grid; + gap: 12px; + color: var(--sv-ink); + font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; +} +.sv-player * { box-sizing: border-box; } +.sv-stage { position: relative; aspect-ratio: 16 / 9; background: #000; border-radius: 8px; overflow: hidden; } +.sv-stage video { display: block; width: 100%; height: 100%; object-fit: contain; } +.sv-readout { + position: absolute; top: 12px; padding: 6px 10px; border-radius: 4px; + background: rgba(0, 0, 0, 0.62); font: 12px/1.5 var(--sv-mono); white-space: pre; +} +.sv-stats { left: 12px; } +.sv-clock { right: 12px; font-size: 18px; font-weight: 600; } +.sv-banner { + position: absolute; inset: 0; display: grid; place-items: center; padding: 16px; + color: var(--sv-muted); text-align: center; pointer-events: none; +} +.sv-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.sv-label { color: var(--sv-muted); margin-right: 4px; } +.sv-layers { display: flex; flex-wrap: wrap; gap: 6px; color: var(--sv-muted); } +.sv-player button { + padding: 8px 14px; background: var(--sv-panel); border: 1px solid var(--sv-line); border-radius: 6px; + color: var(--sv-ink); font: inherit; cursor: pointer; +} +.sv-player button:hover { border-color: var(--sv-muted); } +.sv-player button:focus-visible { outline: 2px solid var(--sv-accent); outline-offset: 1px; } +.sv-player button[aria-pressed="true"] { background: var(--sv-accent); border-color: var(--sv-accent); color: #0b0d0e; } +.sv-player button.sv-picked { border-color: var(--sv-accent); } +.sv-player button.sv-pending { border-color: var(--sv-accent); border-style: dashed; } +.sv-sound { margin-left: auto; } +.sv-player .sv-debug { background: none; border-color: transparent; color: var(--sv-muted); } +.sv-player .sv-debug:hover { border-color: var(--sv-line); } +.sv-player .sv-debug[aria-pressed="true"] { background: none; border-color: var(--sv-line); color: var(--sv-ink); } +.sv-status { margin: 0; color: var(--sv-muted); } +.sv-log { + margin: 0; max-height: 260px; overflow: auto; padding: 10px 12px; + background: var(--sv-panel); border: 1px solid var(--sv-line); border-radius: 6px; + font: 12px/1.6 var(--sv-mono); white-space: pre-wrap; +} +`; + +/** Builds a player inside `container` and starts watching. */ +export function createPlayer(container: HTMLElement, options: PlayerOptions): Player { + injectStyle(container.ownerDocument); + + const viewer = new Viewer(options); + const doc = container.ownerDocument; + const make = (tag: K, className: string, text = "") => { + const element = doc.createElement(tag); + element.className = className; + element.textContent = text; + return element; + }; + + const root = make("div", "sv-player"); + const stage = make("div", "sv-stage"); + const video = viewer.attach(make("video", "")); + video.muted = options.muted ?? true; + const stats = make("div", "sv-readout sv-stats", "no stream"); + const clock = make("div", "sv-readout sv-clock"); + const banner = make("div", "sv-banner"); + stage.append(video, stats, banner); + if (options.clock) stage.append(clock); + + const controls = make("div", "sv-controls"); + const layersBox = make("div", "sv-layers"); + const sound = make("button", "sv-sound", video.muted ? "Unmute" : "Mute"); + const debugButton = make("button", "sv-debug", "Debug"); + sound.type = "button"; + debugButton.type = "button"; + controls.append(make("span", "sv-label", "Layer"), layersBox, sound); + if (options.debugToggle ?? true) controls.append(debugButton); + + const status = make("p", "sv-status"); + const log = make("pre", "sv-log"); + + root.append(stage, controls, status, log); + container.append(root); + + let lastStats: ViewerStats | null = null; + + const note = (text: string, at = new Date()) => { + if (!viewer.debug) return; + log.textContent += `${clockText(at)} ${text}\n`; + log.scrollTop = log.scrollHeight; + }; + + const renderStatus = (state: ViewerState, reason?: string) => { + const text = statusText(state, options.room, reason); + status.textContent = text; + banner.textContent = state === "connected" ? "" : text; + }; + + const renderStats = () => { + const layers = viewer.layers; + let text = lastStats?.width ? `${lastStats.width}x${lastStats.height}` : "no video"; + if (lastStats?.framesPerSecond) text += ` ${Math.round(lastStats.framesPerSecond)} fps`; + if (lastStats?.mbps != null) text += `\n${lastStats.mbps.toFixed(2)} Mbps`; + if (layers.current) text += `\nlayer ${layers.current}, ${layers.mode}`; + if (layers.target) text += `\nwaiting for a keyframe of ${layers.target}`; + if (viewer.debug && lastStats) { + text += `\nfreezes ${lastStats.freezeCount}, ${lastStats.freezeSeconds.toFixed(1)} s`; + if (lastStats.rttMs !== null) text += `\nrtt ${Math.round(lastStats.rttMs)} ms`; + } + stats.textContent = text; + }; + + const renderLayers = (layers: LayerState) => { + layersBox.replaceChildren(); + + if (!layers.mid) { + layersBox.textContent = layers.described ? "single layer" : "none yet"; + return; + } + + const auto = make("button", "", "Auto"); + auto.type = "button"; + auto.setAttribute("aria-pressed", String(layers.mode === "auto")); + auto.onclick = () => viewer.setAutoLayer(); + layersBox.append(auto); + + for (const { rid, bitrate_estimate } of layers.layers) { + const button = make("button", "", bitrate_estimate ? `${rid} ${Math.round(bitrate_estimate / 1000)} kbps` : rid); + button.type = "button"; + button.setAttribute("aria-pressed", String(layers.mode === "manual" && layers.current === rid)); + button.classList.toggle("sv-picked", layers.mode === "auto" && layers.current === rid); + button.classList.toggle("sv-pending", layers.target === rid); + button.onclick = () => viewer.setLayer(rid); + layersBox.append(button); + } + }; + + const setDebug = (on: boolean) => { + viewer.setDebug(on); + debugButton.setAttribute("aria-pressed", String(on)); + log.hidden = !on; + renderStats(); + }; + + const unsubscribe = [ + viewer.on("state", ({ state, reason }) => renderStatus(state, reason)), + viewer.on("layers", (layers) => { + renderLayers(layers); + renderStats(); + }), + viewer.on("stats", (next) => { + lastStats = next; + renderStats(); + }), + viewer.on("log", ({ at, text }) => note(text, at)), + ]; + + sound.onclick = () => { + video.muted = !video.muted; + sound.textContent = video.muted ? "Unmute" : "Mute"; + if (!video.muted) void video.play().catch(() => {}); + }; + + debugButton.onclick = () => { + const on = !viewer.debug; + setDebug(on); + options.onDebugChange?.(on); + }; + + // Page events that explain a stall the stream itself does not. + const onVisibility = () => note(doc.hidden ? "page hidden" : "page visible"); + const onBlur = () => note("window lost focus"); + const onFocus = () => note("window focused"); + const win = doc.defaultView; + doc.addEventListener("visibilitychange", onVisibility); + win?.addEventListener("blur", onBlur); + win?.addEventListener("focus", onFocus); + + let frame = 0; + const tick = () => { + clock.textContent = clockText(new Date()); + frame = requestAnimationFrame(tick); + }; + if (options.clock) tick(); + + setDebug(options.debug ?? false); + renderLayers(viewer.layers); + renderStatus(viewer.state); + void viewer.connect().catch(() => {}); + + return { + viewer, + element: root, + destroy() { + cancelAnimationFrame(frame); + for (const off of unsubscribe) off(); + doc.removeEventListener("visibilitychange", onVisibility); + win?.removeEventListener("blur", onBlur); + win?.removeEventListener("focus", onFocus); + viewer.close(); + root.remove(); + }, + }; +} + +function statusText(state: ViewerState, room: string, reason?: string): string { + switch (state) { + case "idle": + case "connecting": + return "Connecting..."; + case "connected": + return `Watching ${room}`; + case "reconnecting": + return reason ? `${reason}. Reconnecting...` : "Reconnecting..."; + case "not-live": + return "This room is not live. Waiting for it to start..."; + case "failed": + return reason ?? "Could not connect."; + case "closed": + return "Stopped."; + } +} + +function clockText(now: Date): string { + const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + return `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`; +} + +function injectStyle(doc: Document): void { + if (doc.getElementById(STYLE_ID)) return; + const style = doc.createElement("style"); + style.id = STYLE_ID; + style.textContent = STYLE; + doc.head.append(style); +} diff --git a/sdk/src/protocol.ts b/sdk/src/protocol.ts new file mode 100644 index 0000000..8f82ca5 --- /dev/null +++ b/sdk/src/protocol.ts @@ -0,0 +1,44 @@ +export type LayerMode = "auto" | "manual"; + +export interface AvailableLayer { + rid: string; + /** Bits per second the server measured for this layer, once it has a figure. */ + bitrate_estimate: number | null; +} + +/** Messages the server sends over the DataChannel. */ +export type ServerMessage = + | { type: "offer"; sdp: string } + | { + type: "layer_status"; + mid: string; + available_simulcast_layers: AvailableLayer[]; + current_layer: string | null; + target_layer: string | null; + layer_mode: LayerMode; + } + | { type: "layer_changed"; mid: string; rid: string } + // The relay protocol. Not answered yet, so the server never promotes this viewer. + | { type: "probe_available_upload" } + | { type: "request_offer" } + | { type: "p2p_offer"; sdp: string } + | { type: "p2p_answer"; sdp: string } + | { type: "demote" }; + +/** Messages the SDK sends over the DataChannel. */ +export type ClientMessage = + | { type: "answer"; sdp: string } + | { type: "set_layer"; mid: string; rid: string } + | { type: "set_layer_mode"; mid: string; layer_mode: LayerMode } + | { type: "perf_report"; rtt_ms: number; loss_pct: number }; + +export interface OfferRequest { + type: "offer"; + sdp: string; + room_id: string; +} + +export interface OfferAnswer { + type: "answer"; + sdp: string; +} diff --git a/sdk/src/stats.ts b/sdk/src/stats.ts new file mode 100644 index 0000000..c33c703 --- /dev/null +++ b/sdk/src/stats.ts @@ -0,0 +1,156 @@ +/** The figures the SDK reads from one `getStats()` call. */ +export interface Snapshot { + /** Milliseconds, from the stats report's own clock. */ + at: number; + /** Round trip time of the selected candidate pair, in milliseconds. */ + rttMs: number | null; + /** Summed over every inbound stream, audio included. */ + packetsReceived: number; + packetsLost: number; + video: { + width: number; + height: number; + bytesReceived: number; + framesPerSecond: number | null; + framesDropped: number; + freezeCount: number; + freezeSeconds: number; + } | null; +} + +/** What a viewer sees, published once a second. */ +export interface ViewerStats { + width: number; + height: number; + framesPerSecond: number | null; + /** Video bitrate received since the previous figure, in megabits per second. */ + mbps: number | null; + rttMs: number | null; + freezeCount: number; + freezeSeconds: number; + packetsLost: number; + framesDropped: number; +} + +type Entry = Record & { type: string; id: string }; + +const num = (value: unknown): number => (typeof value === "number" ? value : 0); +const opt = (value: unknown): number | null => (typeof value === "number" ? value : null); + +/** Takes the entries of an `RTCStatsReport`, as `Array.from(report.values())`. */ +export function readSnapshot(entries: Iterable): Snapshot { + const all = Array.from(entries); + const byId = new Map(all.map((entry) => [entry.id, entry])); + + let at = 0; + let packetsReceived = 0; + let packetsLost = 0; + let video: Snapshot["video"] = null; + + for (const entry of all) { + at = Math.max(at, num(entry["timestamp"])); + if (entry.type !== "inbound-rtp") continue; + + packetsReceived += num(entry["packetsReceived"]); + packetsLost += Math.max(0, num(entry["packetsLost"])); + + if (entry["kind"] === "video") { + video = { + width: num(entry["frameWidth"]), + height: num(entry["frameHeight"]), + bytesReceived: num(entry["bytesReceived"]), + framesPerSecond: opt(entry["framesPerSecond"]), + framesDropped: num(entry["framesDropped"]), + freezeCount: num(entry["freezeCount"]), + freezeSeconds: num(entry["totalFreezesDuration"]), + }; + } + } + + return { at, rttMs: selectedRtt(all, byId), packetsReceived, packetsLost, video }; +} + +function selectedRtt(all: Entry[], byId: Map): number | null { + const transport = all.find((entry) => entry.type === "transport" && entry["selectedCandidatePairId"]); + const pair = + (transport && byId.get(String(transport["selectedCandidatePairId"]))) ?? + all.find((entry) => entry.type === "candidate-pair" && entry["nominated"] && entry["state"] === "succeeded"); + + const seconds = pair && opt(pair["currentRoundTripTime"]); + return seconds === null || seconds === undefined ? null : seconds * 1000; +} + +/** + * The link quality the server judges relay candidates by. Loss is over the + * packets that arrived or went missing since the previous snapshot. + */ +export function perfReport( + previous: Snapshot, + next: Snapshot, +): { rtt_ms: number; loss_pct: number } | null { + if (next.rttMs === null) return null; + + const received = next.packetsReceived - previous.packetsReceived; + const lost = next.packetsLost - previous.packetsLost; + const expected = received + lost; + const lossPct = expected > 0 && lost > 0 ? (lost / expected) * 100 : 0; + + return { rtt_ms: Math.round(next.rttMs), loss_pct: Math.round(lossPct * 100) / 100 }; +} + +export function viewerStats(previous: Snapshot | null, next: Snapshot): ViewerStats { + const video = next.video; + let mbps: number | null = null; + + if (previous?.video && video) { + const seconds = (next.at - previous.at) / 1000; + if (seconds > 0) mbps = ((video.bytesReceived - previous.video.bytesReceived) * 8) / seconds / 1e6; + } + + return { + width: video?.width ?? 0, + height: video?.height ?? 0, + framesPerSecond: video?.framesPerSecond ?? null, + mbps, + rttMs: next.rttMs, + freezeCount: video?.freezeCount ?? 0, + freezeSeconds: video?.freezeSeconds ?? 0, + packetsLost: next.packetsLost, + framesDropped: video?.framesDropped ?? 0, + }; +} + +/** + * Describes the freezes since the previous snapshot. Loss points at the + * network or the sender, drops at this browser, and neither at frames that + * simply arrived late. + */ +export function freezeNote(previous: Snapshot, next: Snapshot): string | null { + const before = previous.video; + const after = next.video; + if (!before || !after || after.freezeCount <= before.freezeCount) return null; + + const times = after.freezeCount - before.freezeCount; + const ms = Math.round((after.freezeSeconds - before.freezeSeconds) * 1000); + const froze = times === 1 ? `froze ${ms} ms` : `froze ${times} times, ${ms} ms in all`; + + return `${froze}, ${next.packetsLost - previous.packetsLost} packets lost, ${after.framesDropped - before.framesDropped} frames dropped`; +} + +/** The signed difference between two 32 bit RTP timestamps, across a wrap. */ +export function rtpStep(from: number, to: number): number { + return ((((to - from + 2 ** 31) % 2 ** 32) + 2 ** 32) % 2 ** 32) - 2 ** 31; +} + +/** + * How far the RTP timestamp moved past the wall clock between two readings, in + * milliseconds. Layers are separate RTP streams, so a switch can move the + * timestamp by far more than the time that passed. + */ +export function timestampDrift( + previous: { timestamp: number; rtpTimestamp: number }, + next: { timestamp: number; rtpTimestamp: number }, + clockRate = 90_000, +): number { + return (rtpStep(previous.rtpTimestamp, next.rtpTimestamp) / clockRate) * 1000 - (next.timestamp - previous.timestamp); +} diff --git a/sdk/src/viewer.ts b/sdk/src/viewer.ts new file mode 100644 index 0000000..ab9ca8f --- /dev/null +++ b/sdk/src/viewer.ts @@ -0,0 +1,499 @@ +import { Emitter } from "./emitter.js"; +import { + applyLayerChanged, + applyLayerStatus, + initialLayers, + requestLayer, + type LayerState, +} from "./layers.js"; +import { + type ClientMessage, + type OfferAnswer, + type OfferRequest, + type ServerMessage, +} from "./protocol.js"; +import { + freezeNote, + perfReport, + readSnapshot, + timestampDrift, + viewerStats, + type Snapshot, + type ViewerStats, +} from "./stats.js"; + +export type ViewerState = + /** Created, `connect()` not called yet. */ + | "idle" + | "connecting" + | "connected" + /** The connection dropped and the SDK is joining again. */ + | "reconnecting" + /** The room exists but is not live. With `reconnect`, the SDK keeps asking. */ + | "not-live" + /** Given up for good. `reason` says why. */ + | "failed" + /** `close()` was called. */ + | "closed"; + +export interface ViewerOptions { + /** The room to watch, `RM_...`. */ + room: string; + /** Base URL of the server's client API. Defaults to the page's own origin. */ + server?: string; + /** Join again after a dropped connection or a room that is not live yet. Defaults to true. */ + reconnect?: boolean; + /** Ceiling for the wait between attempts, which doubles from one second. Defaults to 10 s. */ + maxRetryDelayMs?: number; + /** How often stats are read and perf reports sent. Defaults to one second. */ + statsIntervalMs?: number; + /** Emit `log` events. Can be switched later with `setDebug`. */ + debug?: boolean; + /** Passed to `RTCPeerConnection`, for ICE servers and the like. */ + rtcConfiguration?: RTCConfiguration; +} + +export type ViewerEvents = { + state: { state: ViewerState; reason?: string }; + layers: LayerState; + /** The server switched layer. `waitedMs` is set when this switch answers a request. */ + layerchange: { from: string | null; to: string; waitedMs: number | null }; + stats: ViewerStats; + /** Debug mode only. */ + log: { at: Date; text: string }; + error: Error; +}; + +/** The server answered the offer with an error status. */ +export class JoinError extends Error { + override name = "JoinError"; + constructor( + readonly status: number, + message: string, + ) { + super(message); + } +} + +// How long a connection may sit in `disconnected` before it is given up. ICE +// often recovers from a short outage on its own. +const DISCONNECTED_GRACE_MS = 5000; +// The server takes candidates from the offer itself, so gathering has to +// finish before it is posted. Host candidates take milliseconds. +const GATHERING_TIMEOUT_MS = 2000; +const TIMESTAMP_PROBE_MS = 250; + +export class Viewer extends Emitter { + /** Carries the room's tracks across reconnects, so a `