Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
9fe06cb
wip. camera source button and progress indicator working
jdreetz Nov 24, 2025
c3e9187
all control buttons working. todo - cleanup. add tests
jdreetz Nov 24, 2025
33dd744
cleanup hang-publish to remove old dom code
jdreetz Nov 24, 2025
c0a1b1d
wip. invert hang-publish to hang-publish-ui relationship. expose hang…
jdreetz Nov 27, 2025
6f35008
render hang-publish in vite template, and attach as required
jdreetz Nov 27, 2025
58b3bab
wip migrate watch controls ui to solidjs
jdreetz Nov 27, 2025
9ff000a
watch ui component migrated
jdreetz Nov 27, 2025
f001671
cleanup
jdreetz Nov 27, 2025
a62ec0e
use rimraf for clean
jdreetz Nov 27, 2025
9934618
Merge branch 'main' into package-hang-ui
jdreetz Nov 30, 2025
d82773d
add buffering control to watch view and file input source to publish …
jdreetz Nov 30, 2025
1501401
if hang instance is available when element is provided, run signal se…
jdreetz Nov 30, 2025
8b23c5b
clean up formatting to satisfy biome. remove dist dependency changes …
jdreetz Nov 30, 2025
c187923
fix webcomponent name
jdreetz Nov 30, 2025
d2384ee
revert removal of hang-ui from main package.json
jdreetz Nov 30, 2025
3ec936a
rename providers from controls to ui
jdreetz Nov 30, 2025
6cf9471
fix formatting
jdreetz Nov 30, 2025
8fb9b9d
update lock files
jdreetz Nov 30, 2025
3650f45
ensure hang-ui is always built after installing
jdreetz Nov 30, 2025
4978644
add missing dependency
jdreetz Nov 30, 2025
eeecb01
only run biome on src dir
jdreetz Nov 30, 2025
f954a89
add hang-ui to js packages in readme
jdreetz Nov 30, 2025
e3b7aea
make hang-ui package compatible with dev / vite
jdreetz Nov 30, 2025
12906f8
add hang-ui readme
jdreetz Nov 30, 2025
0d2e6b8
update lock file
jdreetz Nov 30, 2025
a5721cf
use global biome for formatting hang-ui files. update formatting on v…
jdreetz Nov 30, 2025
93f474f
apply updated formatting. remove hang-ui biome
jdreetz Nov 30, 2025
e77c316
only handle hang instance available once
jdreetz Nov 30, 2025
839764d
handle wrapped elements added after initial rendering
jdreetz Nov 30, 2025
e54b14e
fix typo with hangwatchel
jdreetz Nov 30, 2025
a2a843f
update ts emit settings
jdreetz Nov 30, 2025
f7a0c4c
remove extra separator
jdreetz Nov 30, 2025
cbb5dfb
reset button styles differently
jdreetz Nov 30, 2025
2f7de8b
remove redundant click handler
jdreetz Nov 30, 2025
a480d5e
make volume slider title more accurate
jdreetz Nov 30, 2025
3f41d25
format event handlers
jdreetz Nov 30, 2025
814326b
update deno lock
jdreetz Nov 30, 2025
f31063e
fix dropdown menu styling
jdreetz Nov 30, 2025
e90166b
fix incorrect name for watch web component definition function
jdreetz Nov 30, 2025
79e796a
do cleanup on event correctly
jdreetz Nov 30, 2025
61e2660
use SolidJS For for media source selector
jdreetz Nov 30, 2025
3796fe2
remove redundant setFileActive call
jdreetz Nov 30, 2025
1e7b7e0
tweak source selector
jdreetz Nov 30, 2025
3a9d9e4
Merge branch 'main' into package-hang-ui
jdreetz Dec 1, 2025
4c14759
move video container styles to solidjs watch container styles
jdreetz Dec 1, 2025
e8d97e4
wip move latency slider to hang-ui
jdreetz Dec 1, 2025
092a163
fix formatting
jdreetz Dec 1, 2025
48b18a9
fix latency slider display styles
jdreetz Dec 1, 2025
b3fe482
fix typo. fix font-size mistake
jdreetz Dec 1, 2025
cc111d5
Merge branch 'main' into package-hang-ui
jdreetz Dec 1, 2025
e16c241
sync latency slider value with value from signal/context
jdreetz Dec 1, 2025
a5b8178
address accesibility concern with latency slider
jdreetz Dec 1, 2025
0a2f34e
check for actual value on element.active in conditional
jdreetz Dec 1, 2025
d1af166
be more defensive when accessing element.active
jdreetz Dec 2, 2025
fa6f555
update muted handling
jdreetz Dec 3, 2025
49ce3cf
tidy up hangInstance availability handling
jdreetz Dec 3, 2025
15c3705
remove unncessary optional designations
jdreetz Dec 3, 2025
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ This repository provides both [Rust](/rs) and [TypeScript](/js) libraries with s
| **[@kixelated/moq](js/moq)** | The core pub/sub transport protocol. Intended for browsers, but can be run server side using [Deno](https://deno.com/). | [![npm](https://img.shields.io/npm/v/@kixelated/moq)](https://www.npmjs.com/package/@kixelated/moq) |
| **[@kixelated/hang](js/hang)** | Media-specific encoding/streaming layered on top of `moq-lite`. Provides both a Javascript API and Web Components. | [![npm](https://img.shields.io/npm/v/@kixelated/hang)](https://www.npmjs.com/package/@kixelated/hang) |
| **[@kixelated/hang-demo](js/hang-demo)** | Examples using `@kixelated/hang`. | |
| **[@kixelated/hang-ui](js/hang-ui)**. | UI Components that interact with the Hang Web Components using SolidJS. | [![npm](https://img.shields.io/npm/v/@kixelated/hang-ui)](https://www.npmjs.com/package/@kixelated/hang-ui) |


## Documentation
Expand Down
253 changes: 226 additions & 27 deletions js/bun.lock

Large diffs are not rendered by default.

Binary file added js/bun.lockb
Binary file not shown.
15 changes: 15 additions & 0 deletions js/deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions js/hang-demo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,11 @@
"fix": "biome check --fix"
},
"dependencies": {
"@kixelated/hang": "workspace:^"
"@kixelated/hang": "workspace:^",
"@kixelated/hang-ui": "workspace:^"
},
"devDependencies": {
"@biomejs/biome": "^2.2.2",
"@tailwindcss/postcss": "^4.1.13",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.13",
Expand All @@ -23,6 +25,6 @@
"typescript": "^5.9.2",
"vite": "^6.3.6",
"vite-plugin-html": "^3.2.2",
"@biomejs/biome": "^2.2.2"
"vite-plugin-solid": "^2.11.10"
}
}
8 changes: 4 additions & 4 deletions js/hang-demo/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@
TODO: There's a bug with Big Buck Bunny causing audio to stutter, so we need to increase the latency to 100ms.

-->
<hang-watch url="%VITE_RELAY_URL%" path="bbb" muted controls>
<div id="watch-container" style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto; position: relative">
<hang-watch-ui>
<hang-watch url="%VITE_RELAY_URL%" path="bbb" muted>
<canvas style="width: 100%; height: auto;"></canvas>
</div>
</hang-watch>
</hang-watch>
</hang-watch-ui>

<h3>Other demos:</h3>
<ul>
Expand Down
1 change: 1 addition & 0 deletions js/hang-demo/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "./highlight";
import "@kixelated/hang-ui/watch/element";

import HangSupport from "@kixelated/hang/support/element";
import HangWatch from "@kixelated/hang/watch/element";
Expand Down
13 changes: 9 additions & 4 deletions js/hang-demo/src/publish.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,15 @@
Feel free to hard-code it if you have public access configured, like `url="https://relay.moq.dev/anon"`
NOTE: `http` performs an insecure certificate check. You must use `https` in production.
-->
<hang-publish url="%VITE_RELAY_URL%" path="me" controls>
<!-- It's optional to provide a video element to preview the outgoing media. -->
<video style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;" muted autoplay></video>
</hang-publish>
<hang-publish-ui>
<hang-publish url="%VITE_RELAY_URL%" path="me">
<video
style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;"
muted
autoplay
></video>
</hang-publish>
</hang-publish-ui>

<h3>Other demos:</h3>
<ul>
Expand Down
1 change: 1 addition & 0 deletions js/hang-demo/src/publish.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import "./highlight";
import "@kixelated/hang-ui/publish/element";

// We need to import Web Components with fully-qualified paths because of tree-shaking.
import HangPublish from "@kixelated/hang/publish/element";
Expand Down
13 changes: 12 additions & 1 deletion js/hang-demo/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import path from "node:path";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
import solidPlugin from "vite-plugin-solid";

export default defineConfig({
root: "src",
plugins: [tailwindcss()],
plugins: [tailwindcss(), solidPlugin()],
build: {
target: "esnext",
sourcemap: process.env.NODE_ENV === "production" ? false : "inline",
Expand All @@ -16,6 +18,15 @@ export default defineConfig({
},
},
},
resolve: {
alias: {
"@kixelated/hang-ui/publish/element": path.resolve(
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

kinda gross, but harmless I guess. You should leave a comment that this isn't needed when using the package from NPM.

This wasn't needed when package.json pointed to src instead of dist.

__dirname,
"../hang-ui/src/Components/publish/element.tsx",
),
"@kixelated/hang-ui/watch/element": path.resolve(__dirname, "../hang-ui/src/Components/watch/element.tsx"),
},
},
server: {
// TODO: properly support HMR
hmr: false,
Expand Down
49 changes: 49 additions & 0 deletions js/hang-ui/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<p align="center">
<img height="128px" src="https://github.com/kixelated/moq/blob/main/.github/logo.svg" alt="Media over QUIC">
</p>

# @kixelated/hang-ui

[![npm version](https://img.shields.io/npm/v/@kixelated/hang-ui)](https://www.npmjs.com/package/@kixelated/hang-ui)
[![TypeScript](https://img.shields.io/badge/TypeScript-ready-blue.svg)](https://www.typescriptlang.org/)

A TypeScript library for interacting with @kixelated/hang Web Components. Provides methods to control playback and publish sources, as well as status of the connection.

## Installation

```bash
npm add @kixelated/hang-ui
# or
pnpm add @kixelated/hang-ui
yarn add @kixelated/hang-ui
bun add @kixelated/hang-ui
```

## Web Components

Currently, there are two Web Components provided by @kixelated/hang-ui:

- `<hang-watch-ui>`
- `<hang-publish-ui>`

Here's how you can use them (see also @kixelated/hang-demo for a complete example):

```html
<hang-watch-ui>
<hang-watch url="<MOQ relay URL>" path="<relay path>" muted>
<canvas style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;"></canvas>
</hang-watch>
</hang-watch-ui>
```

```html
<hang-publish-ui>
<hang-publish url="<MOQ relay URL>" path="<relay path>">
<video
style="width: 100%; height: auto; border-radius: 4px; margin: 0 auto;"
muted
autoplay
></video>
</hang-publish>
</hang-publish-ui>
```
Comment on lines +1 to +49
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Fix indentation to use spaces instead of tabs.

The file contains hard tabs on multiple lines (2, 40-48), which conflicts with the project's Biome configuration that specifies indentStyle: "space" with indentWidth: 4.

Run the following command to automatically fix the formatting:

#!/bin/bash
# Fix formatting for the hang-ui README
cd js/hang-ui && bun run fix
🧰 Tools
🪛 markdownlint-cli2 (0.18.1)

2-2: Hard tabs
Column: 1

(MD010, no-hard-tabs)


40-40: Hard tabs
Column: 1

(MD010, no-hard-tabs)


41-41: Hard tabs
Column: 1

(MD010, no-hard-tabs)


42-42: Hard tabs
Column: 1

(MD010, no-hard-tabs)


43-43: Hard tabs
Column: 1

(MD010, no-hard-tabs)


44-44: Hard tabs
Column: 1

(MD010, no-hard-tabs)


45-45: Hard tabs
Column: 1

(MD010, no-hard-tabs)


46-46: Hard tabs
Column: 1

(MD010, no-hard-tabs)


47-47: Hard tabs
Column: 1

(MD010, no-hard-tabs)


48-48: Hard tabs
Column: 1

(MD010, no-hard-tabs)

🤖 Prompt for AI Agents
js/hang-ui/README.md lines 1-49: the file contains hard tabs (notably line 2 and
lines 40-48) which violate the project's Biome indentStyle="space" and
indentWidth=4; replace all tabs with four spaces (or run the repo formatter) and
ensure indentation matches 4-space width, then run the project's formatting
command from the package directory to apply and verify fixes: cd js/hang-ui &&
bun run fix (or run the equivalent project formatter) and commit the updated
file.

32 changes: 32 additions & 0 deletions js/hang-ui/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "@kixelated/hang-ui",
"type": "module",
"version": "0.1.0",
"description": "Media over QUIC library UI components",
"license": "(MIT OR Apache-2.0)",
"repository": "github:kixelated/moq",
"main": "dist/publish-controls.esm.js",
"module": "dist/publish-controls.esm.js",
"exports": {
"./publish/element": "./dist/publish-controls.esm.js",
"./watch/element": "./dist/watch-controls.esm.js"
},
"sideEffects": true,
"scripts": {
"build": "npm run clean && rollup -c",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And yeah, the other packages use a script to rewrite package.json to change src to dist for publishing. Your alias approach works too.

"check": "biome check src",
"clean": "rimraf dist",
"fix": "biome check src --fix"
},
"devDependencies": {
"@biomejs/biome": "^2.2.2",
"@kixelated/hang": "workspace:^0.7.0",
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be a peerDependency?

"@rollup/plugin-node-resolve": "^16.0.3",
"rimraf": "^6.0.1",
"rollup": "^4.53.3",
"rollup-plugin-esbuild": "^6.2.1",
"solid-element": "^1.9.1",
"solid-js": "^1.9.10",
"unplugin-solid": "^1.0.0"
}
}
57 changes: 57 additions & 0 deletions js/hang-ui/rollup.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// rollup.config.mjs / rollup.config.js
import { readFileSync } from 'node:fs';
import nodeResolve from '@rollup/plugin-node-resolve';
import esbuild from 'rollup-plugin-esbuild';
import solid from 'unplugin-solid/rollup';

function inlineCss() {
return {
name: 'inline-css',
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like the most simple solution for now to include the CSS without having the user include a separate file, but as the UI grows, this made lead to issues with larger bundle sizes.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We might want to do this anyway for @kixelated/hang because of worklets. I left some thoughts in #677

load(id) {
if (id.endsWith('.css?inline')) {
const realId = id.replace(/\?inline$/, '');
const css = readFileSync(realId, 'utf8');
return `export default ${JSON.stringify(css)};`;
}
},
};
}

export default [
{
input: 'src/Components/publish/element.tsx',
output: {
file: 'dist/publish-controls.esm.js',
format: 'es',
sourcemap: true,
},
plugins: [
inlineCss(),
solid({ dev: false, hydratable: false }),
esbuild({
include: /\.[jt]sx?$/,
jsx: 'preserve',
tsconfig: 'tsconfig.json',
}),
nodeResolve({ extensions: ['.js', '.ts', '.tsx'] }),
],
},
{
input: 'src/Components/watch/element.tsx',
output: {
file: 'dist/watch-controls.esm.js',
format: 'es',
sourcemap: true,
},
plugins: [
inlineCss(),
solid({ dev: false, hydratable: false }),
esbuild({
include: /\.[jt]sx?$/,
jsx: 'preserve',
tsconfig: 'tsconfig.json',
}),
nodeResolve({ extensions: ['.js', '.ts', '.tsx'] }),
],
},
];
46 changes: 46 additions & 0 deletions js/hang-ui/src/Components/publish/CameraSourceButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Show, useContext } from "solid-js";
import MediaSourceSourceSelector from "./MediaSourceSelector";
import { PublishUIContext } from "./PublishUIContextProvider";

export default function CameraSourceButton() {
const context = useContext(PublishUIContext);
const onClick = () => {
const hangPublishEl = context?.hangPublish();
if (!hangPublishEl) return;

if (hangPublishEl.source === "camera") {
// Camera already selected, toggle video.
hangPublishEl.video = !hangPublishEl.video;
} else {
hangPublishEl.source = "camera";
hangPublishEl.video = true;
}
};

const onSourceSelected = (sourceId: MediaDeviceInfo["deviceId"]) => {
const hangPublishEl = context?.hangPublish();
if (!hangPublishEl) return;

hangPublishEl.videoDevice = sourceId;
};

return (
<div class="publishSourceButtonContainer">
<button
type="button"
title="Camera"
class={`publishButton publishSourceButton ${context?.cameraActive?.() ? "active" : ""}`}
onClick={onClick}
>
📷
</button>
<Show when={context?.cameraActive?.() && context?.cameraDevices().length}>
<MediaSourceSourceSelector
sources={context?.cameraDevices()}
selectedSource={context?.selectedCameraSource?.()}
onSelected={onSourceSelected}
/>
</Show>
</div>
);
}
37 changes: 37 additions & 0 deletions js/hang-ui/src/Components/publish/FileSourceButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createSignal, useContext } from "solid-js";
import { PublishUIContext } from "./PublishUIContextProvider";

export default function FileSourceButton() {
const [fileInputRef, setFileInputRef] = createSignal<HTMLInputElement | undefined>();
const context = useContext(PublishUIContext);
const onClick = () => fileInputRef()?.click();
const onChange = (event: Event) => {
const castedInputEl = event.target as HTMLInputElement;
const file = castedInputEl.files?.[0];

if (file) {
context?.setFile(file);
castedInputEl.value = "";
}
};

return (
<>
<input
ref={setFileInputRef}
onChange={onChange}
type="file"
class="hidden"
accept="video/*,audio/*,image/*"
/>
<button
type="button"
title="Upload File"
onClick={onClick}
class={`publishButton publishSourceButton ${context?.fileActive?.() ? "active" : ""}`}
>
📁
</button>
</>
);
}
37 changes: 37 additions & 0 deletions js/hang-ui/src/Components/publish/MediaSourceSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createSignal, For, Show } from "solid-js";

type MediaSourceSelectorProps = {
sources?: MediaDeviceInfo[];
selectedSource?: MediaDeviceInfo["deviceId"];
onSelected?: (sourceId: MediaDeviceInfo["deviceId"]) => void;
};

export default function MediaSourceSelector(props: MediaSourceSelectorProps) {
const [sourcesVisible, setSourcesVisible] = createSignal(false);

const toggleSourcesVisible = () => setSourcesVisible((visible) => !visible);

return (
<>
<button
type="button"
onClick={toggleSourcesVisible}
class="publishButton mediaSourceVisibilityToggle"
title={sourcesVisible() ? "Hide Sources" : "Show Sources"}
>
{sourcesVisible() ? "▲" : "▼"}
</button>
<Show when={sourcesVisible()}>
<select
value={props.selectedSource}
class="mediaSourceSelector"
onChange={(e) => props.onSelected?.(e.currentTarget.value as MediaDeviceInfo["deviceId"])}
>
<For each={props.sources}>
{(source) => <option value={source.deviceId}>{source.label}</option>}
</For>
</select>
</Show>
</>
);
}
Loading
Loading