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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions js/hang-demo/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,12 @@
- `paused`: Video and audio are paused.
- `muted`: Audio is muted.
- `volume`: The audio volume (0-1).
- `buffer`: The (additional) buffer size in milliseconds.
- `jitter`: The target jitter buffer size in milliseconds.
- `reload`: Whether to automatically reconnect when the broadcast goes offline/online.

NOTE: Cloudflare doesn't support reload yet.
-->
<hang-watch id="watch" url="%VITE_RELAY_URL%" path="bbb" muted buffer="100" reload>
<hang-watch id="watch" url="%VITE_RELAY_URL%" path="bbb" muted jitter="100" reload>
<!-- Provide a canvas element to render the video via WebCodecs. -->
<canvas style="width: 100%; height: auto;"></canvas>
<!-- Or provide a video element to use the (higher latency) MSE backend. -->
Expand Down
2 changes: 1 addition & 1 deletion js/hang-demo/src/mse.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<hang-support mode="watch" show="partial"></hang-support>

<hang-watch-ui>
<hang-watch id="watch" url="%VITE_RELAY_URL%" path="bbb" muted buffer="150" reload>
<hang-watch id="watch" url="%VITE_RELAY_URL%" path="bbb" muted jitter="200" reload>
<video style="width: 100%; height: auto;" autoplay muted></video>
</hang-watch>
</hang-watch-ui>
Expand Down
33 changes: 14 additions & 19 deletions js/hang-ui/src/shared/components/stats/providers/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@ export class VideoProvider extends BaseProvider {
/** Bound callback for display updates */
/** Previous frame count for FPS calculation */
private previousFrameCount = 0;
/** Previous timestamp for FPS calculation */
private previousTimestamp = 0;
/** Previous bytes received for bitrate calculation */
private previousBytesReceived = 0;
/** Previous timestamp for accurate elapsed time calculation in bitrate */
Expand Down Expand Up @@ -50,14 +48,15 @@ export class VideoProvider extends BaseProvider {
const stats = this.props.video.stats.peek();
const now = performance.now();

const elapsedMs = now - this.previousWhen;

// Calculate FPS from frame count delta and timestamp delta
let fps: number | undefined;
if (stats && this.previousTimestamp > 0) {
if (stats && this.previousFrameCount > 0) {
const frameCountDelta = stats.frameCount - this.previousFrameCount;
const timestampDeltaUs = stats.timestamp - this.previousTimestamp;

if (timestampDeltaUs > 0 && frameCountDelta > 0) {
const elapsedSeconds = timestampDeltaUs / 1_000_000;
if (elapsedMs > 0 && frameCountDelta > 0) {
const elapsedSeconds = elapsedMs / 1_000;
fps = frameCountDelta / elapsedSeconds;
}
}
Expand All @@ -66,26 +65,22 @@ export class VideoProvider extends BaseProvider {
if (stats && this.previousBytesReceived > 0) {
const bytesDelta = stats.bytesReceived - this.previousBytesReceived;
// Only calculate bitrate if there's actual data change
if (bytesDelta > 0) {
const elapsedMs = now - this.previousWhen;
if (elapsedMs > 0) {
const bitsPerSecond = bytesDelta * 8 * (1000 / elapsedMs);

if (bitsPerSecond >= 1_000_000) {
bitrate = `${(bitsPerSecond / 1_000_000).toFixed(1)}Mbps`;
} else if (bitsPerSecond >= 1_000) {
bitrate = `${(bitsPerSecond / 1_000).toFixed(0)}kbps`;
} else {
bitrate = `${bitsPerSecond.toFixed(0)}bps`;
}
if (bytesDelta > 0 && elapsedMs > 0) {
const bitsPerSecond = bytesDelta * 8 * (1000 / elapsedMs);

if (bitsPerSecond >= 1_000_000) {
bitrate = `${(bitsPerSecond / 1_000_000).toFixed(1)}Mbps`;
} else if (bitsPerSecond >= 1_000) {
bitrate = `${(bitsPerSecond / 1_000).toFixed(0)}kbps`;
} else {
bitrate = `${bitsPerSecond.toFixed(0)}bps`;
}
}
}

// Always update previous values for next calculation, even on first call
if (stats) {
this.previousFrameCount = stats.frameCount;
this.previousTimestamp = stats.timestamp;
this.previousBytesReceived = stats.bytesReceived;
this.previousWhen = now;
}
Expand Down
164 changes: 164 additions & 0 deletions js/hang-ui/src/watch/components/BufferControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { Moq } from "@moq/hang";
import type { BufferedRange } from "@moq/hang/watch";
import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
import useWatchUIContext from "../hooks/use-watch-ui";

const MIN_RANGE = 0 as Moq.Time.Milli;
const RANGE_STEP = 100 as Moq.Time.Milli;

type BufferControlProps = {
/** Maximum buffer range in milliseconds (default: 5000ms = 5s) */
max?: Moq.Time.Milli;
};

export default function BufferControl(props: BufferControlProps) {
const context = useWatchUIContext();
const maxRange = (): Moq.Time.Milli => props.max ?? (5000 as Moq.Time.Milli);
const [isDragging, setIsDragging] = createSignal(false);

// Compute range style and overflow info relative to current timestamp
const computeRange = (range: BufferedRange, timestamp: Moq.Time.Milli, color: string) => {
const startMs = (range.start - timestamp) as Moq.Time.Milli;
const endMs = (range.end - timestamp) as Moq.Time.Milli;
const visibleStartMs = Math.max(0, startMs) as Moq.Time.Milli;
const visibleEndMs = Math.min(endMs, maxRange()) as Moq.Time.Milli;
const leftPct = (visibleStartMs / maxRange()) * 100;
const widthPct = Math.max(0.5, ((visibleEndMs - visibleStartMs) / maxRange()) * 100);
const isOverflow = endMs > maxRange();
const overflowSec = isOverflow
? Moq.Time.Milli.toSecond((endMs - visibleStartMs) as Moq.Time.Milli).toFixed(1)
: null;
return {
style: `left: ${leftPct}%; width: ${widthPct}%; background: ${color};`,
isOverflow,
overflowSec,
};
};

// Determine color based on gap detection and buffering state
const rangeColor = (index: number, isBuffering: boolean) => {
if (isBuffering) return "#f87171"; // red
if (index > 0) return "#facc15"; // yellow
return "#4ade80"; // green
};

const bufferTargetPct = createMemo(() => (context.jitter() / maxRange()) * 100);

// Handle mouse interaction to set buffer via clicking/dragging on the visualization
let containerRef: HTMLDivElement | undefined;

const LABEL_WIDTH = 48; // px reserved for track labels

const updateBufferFromMouseX = (clientX: number) => {
if (!containerRef) return;
const rect = containerRef.getBoundingClientRect();
const trackWidth = rect.width - LABEL_WIDTH;
const x = Math.max(0, Math.min(clientX - rect.left - LABEL_WIDTH, trackWidth));
const ms = (x / trackWidth) * maxRange();
const snapped = (Math.round(ms / RANGE_STEP) * RANGE_STEP) as Moq.Time.Milli;
const clamped = Math.max(MIN_RANGE, Math.min(maxRange(), snapped)) as Moq.Time.Milli;
context.setJitter(clamped);
};

const onMouseDown = (e: MouseEvent) => {
setIsDragging(true);
updateBufferFromMouseX(e.clientX);
document.addEventListener("mousemove", onMouseMove);
document.addEventListener("mouseup", onMouseUp);
};

const onMouseMove = (e: MouseEvent) => {
if (isDragging()) {
updateBufferFromMouseX(e.clientX);
}
};

const onMouseUp = () => {
setIsDragging(false);
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
};
Comment on lines +63 to +80
Copy link
Contributor

@coderabbitai coderabbitai bot Jan 31, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing cleanup for document event listeners on component unmount.

If the component unmounts while the user is dragging (e.g., navigation or conditional rendering), the mousemove and mouseup listeners will remain attached to the document. Consider using SolidJS's onCleanup to remove listeners when the component unmounts.

🛠️ Proposed fix
+import { createMemo, createSignal, For, onCleanup, Show } from "solid-js";
 ...

 	const onMouseUp = () => {
 		setIsDragging(false);
 		document.removeEventListener("mousemove", onMouseMove);
 		document.removeEventListener("mouseup", onMouseUp);
 	};
+
+	// Cleanup listeners on unmount
+	onCleanup(() => {
+		document.removeEventListener("mousemove", onMouseMove);
+		document.removeEventListener("mouseup", onMouseUp);
+	});
🤖 Prompt for AI Agents
In `@js/hang-ui/src/watch/components/BufferControl.tsx` around lines 63 - 80, The
document-level "mousemove" and "mouseup" listeners added in onMouseDown (and
removed in onMouseUp) are not cleaned up if the component unmounts; add SolidJS
onCleanup in the component to always remove
document.removeEventListener("mousemove", onMouseMove) and
document.removeEventListener("mouseup", onMouseUp") and call
setIsDragging(false) on unmount; import onCleanup if not present and place the
cleanup so it references the existing onMouseDown, onMouseMove, onMouseUp,
isDragging, and updateBufferFromMouseX handlers to ensure listeners are removed
regardless of component lifecycle.

Copy link
Collaborator

Choose a reason for hiding this comment

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

I would add these.

Copy link
Contributor

Choose a reason for hiding this comment

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

@jdreetz, great! Adding the event listener cleanup will prevent memory leaks and other issues if the component unmounts during a drag.

Would you like me to help implement the change? I can prepare the code to add onCleanup that removes the document event listeners when the component unmounts.


// Cleanup listeners on unmount
onCleanup(() => {
document.removeEventListener("mousemove", onMouseMove);
document.removeEventListener("mouseup", onMouseUp);
});

return (
<div class="buffer__container">
{/* Buffer Visualization - interactive, click/drag to set buffer */}
<div
class={`buffer__visualization ${isDragging() ? "buffer__visualization--dragging" : ""}`}
ref={containerRef}
onMouseDown={onMouseDown}
role="slider"
tabIndex={0}
aria-valuenow={context.jitter()}
aria-valuemin={MIN_RANGE}
aria-valuemax={maxRange()}
aria-label="Buffer jitter"
>
Comment on lines 88 to 101
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing keyboard support for slider accessibility.

The element has role="slider" and tabIndex={0} but lacks keyboard event handling. Users navigating with keyboard cannot adjust the jitter value. Consider adding onKeyDown to handle arrow keys.

♿ Proposed fix for keyboard accessibility
+	const onKeyDown = (e: KeyboardEvent) => {
+		const current = context.jitter();
+		let newValue = current;
+		if (e.key === "ArrowRight" || e.key === "ArrowUp") {
+			newValue = Math.min(maxRange(), current + RANGE_STEP) as Moq.Time.Milli;
+		} else if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
+			newValue = Math.max(MIN_RANGE, current - RANGE_STEP) as Moq.Time.Milli;
+		} else {
+			return;
+		}
+		e.preventDefault();
+		context.setJitter(newValue);
+	};

 	return (
 		<div class="bufferControlContainer">
 			<div
 				class={`bufferVisualization ${isDragging() ? "dragging" : ""}`}
 				ref={containerRef}
 				onMouseDown={onMouseDown}
+				onKeyDown={onKeyDown}
 				role="slider"

{/* Playhead (left edge = current time) */}
<div class="buffer__playhead" />

{/* Video buffer track */}
<div class="buffer__track buffer__track--video">
<span class="buffer__track-label">Video</span>
<For each={context.videoBuffered()}>
{(range, i) => {
const info = () => {
const timestamp = context.timestamp();
if (timestamp === undefined) return null;
return computeRange(range, timestamp, rangeColor(i(), context.buffering()));
};
return (
<Show when={info()}>
{(rangeInfo) => (
<div class="buffer__range" style={rangeInfo().style}>
<Show when={rangeInfo().isOverflow}>
<span class="buffer__overflow-label">{rangeInfo().overflowSec}s</span>
</Show>
</div>
)}
</Show>
);
}}
</For>
</div>

{/* Audio buffer track */}
<div class="buffer__track buffer__track--audio">
<span class="buffer__track-label">Audio</span>
<For each={context.audioBuffered()}>
{(range, i) => {
const info = () => {
const timestamp = context.timestamp();
if (timestamp === undefined) return null;
return computeRange(range, timestamp, rangeColor(i(), context.buffering()));
};
return (
<Show when={info()}>
{(rangeInfo) => (
<div class="buffer__range" style={rangeInfo().style}>
<Show when={rangeInfo().isOverflow}>
<span class="buffer__overflow-label">{rangeInfo().overflowSec}s</span>
</Show>
</div>
)}
</Show>
);
}}
</For>
</div>

{/* Buffer target line (draggable) - wrapped in track-area container */}
<div class="buffer__target-area">
<div class="buffer__target-line" style={{ left: `${bufferTargetPct()}%` }}>
<span class="buffer__target-label">{`${Math.round(context.jitter())}ms`}</span>
</div>
</div>
</div>
</div>
);
}
9 changes: 5 additions & 4 deletions js/hang-ui/src/watch/components/LatencySlider.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import type { Moq } from "@moq/hang";
import useWatchUIContext from "../hooks/use-watch-ui";

const MIN_RANGE = 0;
const MAX_RANGE = 5_000;
const RANGE_STEP = 100;
const MIN_RANGE = 0 as Moq.Time.Milli;
const MAX_RANGE = 5_000 as Moq.Time.Milli;
const RANGE_STEP = 100 as Moq.Time.Milli;

export default function LatencySlider() {
const context = useWatchUIContext();
const onInputChange = (event: Event) => {
const target = event.currentTarget as HTMLInputElement;
const latency = parseFloat(target.value);
const latency = parseFloat(target.value) as Moq.Time.Milli;
context.setJitter(latency);
};

Expand Down
4 changes: 2 additions & 2 deletions js/hang-ui/src/watch/components/WatchControls.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import BufferControl from "./BufferControl";
import FullscreenButton from "./FullscreenButton";
import LatencySlider from "./LatencySlider";
import PlayPauseButton from "./PlayPauseButton";
import QualitySelector from "./QualitySelector";
import StatsButton from "./StatsButton";
Expand All @@ -17,7 +17,7 @@ export default function WatchControls() {
<FullscreenButton />
</div>
<div class="latencyControlsRow">
<LatencySlider />
<BufferControl />
<QualitySelector />
</div>
</div>
Expand Down
18 changes: 9 additions & 9 deletions js/hang-ui/src/watch/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export type Rendition = {
height?: number;
};

type WatchUIContextValues = {
export type WatchUIContextValues = {
hangWatch: HangWatch;
watchStatus: () => WatchStatus;
isPlaying: () => boolean;
Expand All @@ -28,16 +28,16 @@ type WatchUIContextValues = {
togglePlayback: () => void;
toggleMuted: () => void;
buffering: () => boolean;
jitter: () => number;
setJitter: (value: number) => void;
jitter: () => Moq.Time.Milli;
setJitter: (value: Moq.Time.Milli) => void;
availableRenditions: () => Rendition[];
activeRendition: () => string | undefined;
setActiveRendition: (name: string | undefined) => void;
isStatsPanelVisible: () => boolean;
setIsStatsPanelVisible: (visible: boolean) => void;
isFullscreen: () => boolean;
toggleFullscreen: () => void;
timestamp: () => number;
timestamp: () => Moq.Time.Milli | undefined;
videoBuffered: () => BufferedRanges;
audioBuffered: () => BufferedRanges;
};
Expand Down Expand Up @@ -76,8 +76,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp
props.hangWatch.audio.muted.update((muted) => !muted);
};

const setJitter = (latency: number) => {
props.hangWatch.jitter.set(latency as Moq.Time.Milli);
const setJitter = (latency: Moq.Time.Milli) => {
props.hangWatch.jitter.set(latency);
};

const setActiveRenditionValue = (name: string | undefined) => {
Expand All @@ -88,7 +88,7 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp
};

// Use solid helper for the new signals
const timestamp = solid(props.hangWatch.timestamp);
const timestamp = solid(props.hangWatch.video.timestamp);
const videoBuffered = solid(props.hangWatch.video.buffered);
const audioBuffered = solid(props.hangWatch.audio.buffered);

Expand Down Expand Up @@ -157,8 +157,8 @@ export default function WatchUIContextProvider(props: WatchUIContextProviderProp
});

signals.effect((effect) => {
const buffering = effect.get(watch.buffering);
setBuffering(buffering);
const stalled = effect.get(watch.video.stalled);
setBuffering(stalled);
});

signals.effect((effect) => {
Expand Down
4 changes: 2 additions & 2 deletions js/hang-ui/src/watch/hooks/use-watch-ui.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useContext } from "solid-js";
import { WatchUIContext } from "../context";
import { WatchUIContext, type WatchUIContextValues } from "../context";

export default function useWatchUIContext() {
export default function useWatchUIContext(): WatchUIContextValues {
const context = useContext(WatchUIContext);

if (!context) {
Expand Down
Loading
Loading