-
-
{(() => {
const context = useContext(WatchUIContext);
if (!context) return null;
diff --git a/js/hang-ui/src/watch/index.tsx b/js/hang-ui/src/watch/index.tsx
index c33937e00..0eb12d45c 100644
--- a/js/hang-ui/src/watch/index.tsx
+++ b/js/hang-ui/src/watch/index.tsx
@@ -1,26 +1,77 @@
import type HangWatch from "@moq/hang/watch/element";
-import { customElement } from "solid-element";
-import { createSignal, onMount, Show } from "solid-js";
+import { render } from "solid-js/web";
import { WatchUI } from "./element.tsx";
+import styles from "./styles/index.css?inline";
-customElement("hang-watch-ui", (_, { element }) => {
- const [nested, setNested] = createSignal();
+/**
+ * @tag hang-watch-ui
+ * @summary Watch video stream with full UI controls
+ * @description A custom element that provides a complete user interface for watching live or on-demand video streams
+ * over Media over QUIC (MOQ). Includes playback controls, quality selection, stats panel, and automatic buffering.
+ *
+ * @slot default - Container for the hang-watch element and canvas
+ *
+ * @example HTML
+ * ```html
+ *
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * @example React
+ * ```tsx
+ * import '@moq/hang/watch/element';
+ * import '@moq/hang-ui/watch';
+ * import { HangWatchUI } from '@moq/hang-ui/react';
+ *
+ * export function HangWatchComponent({ url, path }) {
+ * return (
+ *
+ *
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
+class HangWatchComponent extends HTMLElement {
+ #root?: ShadowRoot;
+ #dispose?: () => void;
+ #connected = false;
- onMount(async () => {
- await customElements.whenDefined("hang-watch");
- const watchEl = element.querySelector("hang-watch");
- setNested(watchEl ? (watchEl as HangWatch) : undefined);
- });
+ connectedCallback() {
+ this.#connected = true;
- return (
-
- {(watch: HangWatch) => }
-
- );
-});
+ // Reuse existing shadow root on reconnect (attachShadow throws if called twice)
+ this.#root ??= this.attachShadow({ mode: "open" });
-declare global {
- interface HTMLElementTagNameMap {
- "hang-watch-ui": HTMLElement;
+ // Defer render to allow frameworks to append children first
+ queueMicrotask(() => {
+ if (!this.#connected || !this.#root) return;
+
+ const watch = this.querySelector("hang-watch") as HangWatch | null;
+ this.#dispose = render(
+ () => (
+ <>
+
+
+ {watch ? : null}
+ >
+ ),
+ this.#root,
+ );
+ });
+ }
+
+ disconnectedCallback() {
+ this.#connected = false;
+ this.#dispose?.();
+ this.#dispose = undefined;
}
}
+
+customElements.define("hang-watch-ui", HangWatchComponent);
+export { HangWatchComponent };
diff --git a/js/hang-ui/src/watch/styles/index.css b/js/hang-ui/src/watch/styles/index.css
index 995c63553..6981f5979 100644
--- a/js/hang-ui/src/watch/styles/index.css
+++ b/js/hang-ui/src/watch/styles/index.css
@@ -3,6 +3,12 @@
@import "../../shared/components/button/button.css";
@import "../../shared/components/stats/styles/index.css";
+/* Host element default styles */
+:host {
+ position: relative;
+ display: block;
+}
+
/* Color variables for buffer states */
:root {
--buffer-green: #4ade80;
@@ -12,9 +18,11 @@
.watchVideoContainer {
display: block;
- position: relative;
+ position: absolute;
+ top: 0;
+ left: 0;
width: 100%;
- height: auto;
+ height: 100%;
border-radius: 4px;
margin: 0 auto;
pointer-events: none;
@@ -89,6 +97,7 @@
0% {
transform: rotate(0deg);
}
+
100% {
transform: rotate(360deg);
}
diff --git a/js/hang-ui/vite.config.ts b/js/hang-ui/vite.config.ts
index 33ab016c9..398427083 100644
--- a/js/hang-ui/vite.config.ts
+++ b/js/hang-ui/vite.config.ts
@@ -1,19 +1,29 @@
import { resolve } from "path";
import { defineConfig } from "vite";
+import dts from "vite-plugin-dts";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
- plugins: [solidPlugin()],
+ plugins: [
+ solidPlugin(),
+ dts({
+ include: ["src"],
+ outDir: "dist",
+ entryRoot: "src",
+ }),
+ ],
build: {
lib: {
entry: {
"publish/index": resolve(__dirname, "src/publish/index.tsx"),
"watch/index": resolve(__dirname, "src/watch/index.tsx"),
+ // Auto-generated by scripts/generate-wrappers.ts during prebuild phase
+ "wrappers/react/index": resolve(__dirname, "src/wrappers/react/index.ts"),
},
formats: ["es"],
},
rollupOptions: {
- external: ["@moq/hang", "@moq/lite", "@moq/signals"],
+ external: ["@moq/hang", "@moq/lite", "@moq/signals", "react"],
},
sourcemap: true,
target: "esnext",
diff --git a/js/hang/README.md b/js/hang/README.md
index 61ae82a5c..df825a969 100644
--- a/js/hang/README.md
+++ b/js/hang/README.md
@@ -218,6 +218,38 @@ useEffect(() => {
}, [media]);
```
+## Build System & Code Generation
+
+This package uses Custom Elements Manifest (CEM) to automatically generate framework-specific wrappers.
+
+### React Wrappers
+
+Auto-generated from CEM, exported from `@moq/hang/react`:
+
+```tsx
+import { HangWatch, HangPublish } from '@moq/hang/react';
+
+
+
+
+
+
+
+
+```
+
+### Generating Wrappers for Other Frameworks
+
+To add Vue, Angular, or other frameworks, see the [element-wrappers guide](../scripts/element-wrappers/README.md) for step-by-step instructions.
+
+The process:
+1. Create a generator in `../scripts/element-wrappers/generators/.ts`
+2. Register it in `../scripts/element-wrappers/index.ts`
+3. Add package export in `package.json`
+4. Run `bun run prebuild` to generate wrappers
+
+Full details: [`../scripts/element-wrappers/README.md`](../scripts/element-wrappers/README.md)
+
## License
Licensed under either:
diff --git a/js/hang/cem.config.js b/js/hang/cem.config.js
new file mode 100644
index 000000000..34f9674c5
--- /dev/null
+++ b/js/hang/cem.config.js
@@ -0,0 +1,4 @@
+export default {
+ globs: ["src/publish/element.ts", "src/watch/element.ts"],
+ outdir: ".",
+};
diff --git a/js/hang/package.json b/js/hang/package.json
index 77ae34d23..51a8f39c9 100644
--- a/js/hang/package.json
+++ b/js/hang/package.json
@@ -13,7 +13,11 @@
"./watch/element": "./src/watch/element.ts",
"./catalog": "./src/catalog/index.ts",
"./support": "./src/support/index.ts",
- "./support/element": "./src/support/element.ts"
+ "./support/element": "./src/support/element.ts",
+ "./react": {
+ "types": "./wrappers/react/index.d.ts",
+ "default": "./src/wrappers/react/index.ts"
+ }
},
"sideEffects": [
"./src/publish/element.ts",
@@ -21,7 +25,8 @@
"./src/support/element.ts"
],
"scripts": {
- "build": "rimraf dist && tsc -b && bun ../scripts/package.ts",
+ "prebuild": "bunx cem analyze --config cem.config.js && bun ../scripts/element-wrappers/index.ts",
+ "build": "rimraf dist && tsc -b && cp custom-elements.json dist/ && bun ../scripts/package.ts",
"check": "tsc --noEmit",
"test": "bun test --only-failures",
"release": "bun ../scripts/release.ts"
@@ -37,10 +42,12 @@
"zod": "^4.1.5"
},
"devDependencies": {
+ "@custom-elements-manifest/analyzer": "^0.11.0",
"@types/audioworklet": "^0.0.77",
"@typescript/lib-dom": "npm:@types/web@^0.0.241",
"fast-glob": "^3.3.3",
"rimraf": "^6.0.1",
"typescript": "^5.9.2"
- }
+ },
+ "customElements": "custom-elements.json"
}
diff --git a/js/hang/src/publish/element.ts b/js/hang/src/publish/element.ts
index cc209cd90..9957508eb 100644
--- a/js/hang/src/publish/element.ts
+++ b/js/hang/src/publish/element.ts
@@ -14,11 +14,54 @@ type SourceType = "camera" | "screen" | "file";
// There's no destructor for web components so this is the best we can do.
const cleanup = new FinalizationRegistry((signals) => signals.close());
+/**
+ * @tag hang-publish
+ * @summary Publish live video streams over Media over QUIC (MOQ)
+ * @description A custom element that captures and publishes video/audio streams over the Media over QUIC protocol.
+ * Supports multiple input sources including camera, screen sharing, and file playback. Handles encoding,
+ * transmission, and connection management to MOQ relay servers. Includes optional preview via video element.
+ *
+ * @attr {string} url - The WebTransport URL of the MOQ relay server (e.g., "https://relay.example.com")
+ * @attr {string} path - The broadcast path to publish to (e.g., "/live/mystream")
+ * @attr {string} source - Input source type: "camera", "screen", or "file"
+ * @attr {boolean} muted - Whether audio is muted (disables audio track)
+ * @attr {boolean} invisible - Whether video is disabled (disables video track)
+ *
+ * @slot default - Optional video element for local preview of the published stream
+ *
+ * @example HTML with camera
+ * ```html
+ *
+ *
+ *
+ * ```
+ *
+ * @example HTML with screen sharing
+ * ```html
+ *
+ *
+ *
+ * ```
+ *
+ * @example React
+ * ```tsx
+ * import '@moq/hang/publish/element';
+ * import { HangPublish } from '@moq/hang/react';
+ *
+ * export function HangPublishComponent({ url, path }) {
+ * return (
+ *
+ *
+ *
+ * );
+ * }
+ * ```
+ */
export default class HangPublish extends HTMLElement {
static observedAttributes = OBSERVED;
- url = new Signal(undefined);
- path = new Signal(undefined);
+ #url = new Signal(undefined);
+ #path = new Signal(undefined);
source = new Signal(undefined);
// Controls whether audio/video is enabled.
@@ -50,7 +93,7 @@ export default class HangPublish extends HTMLElement {
cleanup.register(this, this.signals);
this.connection = new Moq.Connection.Reload({
- url: this.url,
+ url: this.#url,
enabled: this.#enabled,
});
this.signals.cleanup(() => this.connection.close());
@@ -72,7 +115,7 @@ export default class HangPublish extends HTMLElement {
this.broadcast = new Broadcast({
connection: this.connection.established,
enabled: this.#enabled,
- path: this.path,
+ path: this.#path,
audio: {
enabled: this.#audioEnabled,
@@ -146,6 +189,22 @@ export default class HangPublish extends HTMLElement {
}
}
+ get url(): Signal {
+ return this.#url;
+ }
+
+ set url(value: string | URL | undefined) {
+ value ? this.setAttribute("url", String(value)) : this.removeAttribute("url");
+ }
+
+ get path(): Signal {
+ return this.#path;
+ }
+
+ set path(value: string | Moq.Path.Valid | undefined) {
+ value ? this.setAttribute("path", String(value)) : this.removeAttribute("path");
+ }
+
#runSource(effect: Effect) {
const source = effect.get(this.source);
if (!source) return;
@@ -225,8 +284,4 @@ export default class HangPublish extends HTMLElement {
customElements.define("hang-publish", HangPublish);
-declare global {
- interface HTMLElementTagNameMap {
- "hang-publish": HangPublish;
- }
-}
+export { HangPublish };
diff --git a/js/hang/src/watch/element.ts b/js/hang/src/watch/element.ts
index 7834b706f..d8ff9d28a 100644
--- a/js/hang/src/watch/element.ts
+++ b/js/hang/src/watch/element.ts
@@ -17,7 +17,52 @@ type Observed = (typeof OBSERVED)[number];
// There's no destructor for web components so this is the best we can do.
const cleanup = new FinalizationRegistry((signals) => signals.close());
-// An optional web component that wraps a