Files
app/frontend/src/components/Dashboard/MediaWidget.tsx
T
Melvin StroblandClaude Opus 5.5 02b736c9e8
Docs / docs (push) Successful in 22s
Playwright Tests / test-playwright (1, 2) (push) Failing after 4m37s
Playwright Tests / test-playwright (2, 2) (push) Successful in 2m19s
pre-commit / pre-commit (push) Failing after 2m25s
Test Backend / test-backend (3.12) (push) Successful in 4m57s
Test Backend / test-backend (3.14) (push) Successful in 4m41s
Compose Smoke Test / test-compose (push) Successful in 28s
Playwright Tests / merge-reports (push) Successful in 1m38s
Live view over WebRTC (openpanel L1, server and browser)
A device reporting a `live` port streams its camera to the browser's own
RTCPeerConnection. The flows socket carries the signalling as text, so it
crosses the portal: `live` messages on a person's socket, `signal` on a
device's (start, sdp, candidate, bye, busy). The server routes by session,
holds a device to two viewers, ends a session when either socket closes or
no answer comes within 10 s, and gives each socket an outbox of its own
that ends a session rather than drop a message. TURN credentials are
minted per session in coturn's REST scheme (STUN_URLS, TURN_URLS,
TURN_SECRET), the viewer's with the device's offer; GET /devices/{id}/ice
hands them out too. The tunnel keeps a stream's early frames instead of
dropping them past eight and forgets them when the stream or link goes.

A media tile on a device's camera, and the Devices tab's preview, play
live when tapped, pause frames meanwhile, and fall back to frames with a
short note on busy, failure or no picture within 10 s. coturn is an
optional compose service behind the `turn` profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0138N7k5PT4dcR1iEqXaaQ9k
2026-10-17 08:37:39 +02:00

257 lines
7.2 KiB
TypeScript

import { LoaderCircle, Radio, X } from "lucide-react"
import { type ReactNode, useEffect, useRef } from "react"
import { useOnScreen } from "@/hooks/useOnScreen"
import { isRef, useArtifactUrl } from "@/lib/media"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { config, text } from "./ui/core/config"
import { useLiveDevice, useLiveView } from "./useLiveView"
import type { WidgetProps } from "./widgets"
/**
* What a message's bytes look like: a frame, a clip, a segment.
*
* Media never travels as a message — the reference does. The bytes come down
* the socket in front of it where the engine is holding the frame in memory,
* and are fetched from the artifact store otherwise; either way this tile only
* asks for what it is drawing. A camera serving its own stream is still played
* from source: point `stream_url` at it and the browser does the work.
*/
export function MediaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
return (
<MediaView
message={text(cfg.message)}
stream={text(cfg.stream_url)}
dtype={text(cfg.dtype)}
fit={text(cfg.fit) === "contain" ? "contain" : "cover"}
autoplay={Boolean(cfg.autoplay)}
label={widget.title}
/>
)
}
/**
* The tile's body, for anywhere a message's media is shown.
*
* Frames are asked for only while the tile is on screen, in a tab being
* looked at: a camera is only sent while somebody watches it, and a tile
* scrolled away or behind another tab is nobody. A clip keeps playing either
* way.
*
* A camera whose device streams live is tapped for it. Frames pause while
* the live view plays, so the device encodes one picture rather than two,
* and come back when it ends.
*/
export function MediaView({
message,
stream = "",
dtype = "",
fit = "cover",
autoplay = false,
label,
}: {
message: string
stream?: string
dtype?: string
fit?: "cover" | "contain"
autoplay?: boolean
label?: string
}) {
const box = useRef<HTMLDivElement>(null)
const shown = useOnScreen(box)
const bound = useBoundValue(message || undefined)
const value = bound?.value
const ref = isRef(value) ? value : null
const kind = (ref?.media_type ?? dtype).split("/")[0]
const playing = kind === "audio" || kind === "video"
const camera = useLiveDevice(stream ? "" : message)
const live = useLiveView(camera)
const onAir = live.phase === "playing"
const active = (shown || playing) && !onAir
const url = useArtifactUrl(
active ? ref : null,
active ? message || undefined : undefined,
)
const objectFit = fit === "contain" ? "object-contain" : "object-cover"
const alt = label || ref?.name || message
// Scrolled away, or the tab hidden: nobody is watching.
const { stop } = live
useEffect(() => {
if (!shown) stop()
}, [shown, stop])
return (
<div
ref={box}
className="flex min-h-0 w-full flex-1 flex-col justify-center"
>
{camera ? (
<LiveFrame live={live} label={alt} fit={objectFit}>
{body()}
</LiveFrame>
) : (
body()
)}
</div>
)
function body() {
// A camera serving its own stream is played from source: the engine
// carries references at whatever rate the flow publishes them, which is
// not video.
if (stream && kind !== "audio") {
return (
<img
src={stream}
alt={alt}
className={cn("h-full w-full rounded-md", objectFit)}
/>
)
}
if (!message && !stream) {
return <p className="text-muted-foreground">Pick a message.</p>
}
if (!ref) {
return <p className="text-muted-foreground">Nothing published yet.</p>
}
if (!url) {
return <p className="text-muted-foreground">Loading…</p>
}
if (kind === "audio") {
return (
<audio
// Keyed on the digest so a new clip replaces the element rather than
// leaving the last one's playhead on it.
key={ref.digest}
src={url}
controls
autoPlay={autoplay}
className="w-full"
>
<track kind="captions" />
</audio>
)
}
if (kind === "video") {
return (
<video
key={ref.digest}
src={url}
controls
autoPlay={autoplay}
className={cn("h-full w-full rounded-md", objectFit)}
>
<track kind="captions" />
</video>
)
}
return (
<img
src={url}
alt={alt}
className={cn("h-full w-full rounded-md", objectFit)}
/>
)
}
}
/**
* A camera tile that streams live when tapped, and stops when tapped again.
*
* The whole tile is the control, so it is as easy to hit on a wall panel as
* on a desk. The video waits under the frames until its first picture, then
* takes their place.
*/
function LiveFrame({
live,
label,
fit,
children,
}: {
live: ReturnType<typeof useLiveView>
label: string
fit: string
children: ReactNode
}) {
const { phase, stream, note } = live
const onAir = phase === "playing"
const chip =
"pointer-events-none absolute z-20 flex items-center gap-1 rounded-full border border-border px-2.5 py-1 text-xs font-medium shadow-e2"
const frosted = "bg-card/80 text-foreground backdrop-blur-md"
return (
<button
type="button"
onClick={phase === "off" ? live.start : live.stop}
aria-label={
phase === "off" ? `Watch ${label} live` : `Stop watching ${label} live`
}
aria-pressed={phase !== "off"}
data-testid="media-live"
data-live={phase}
className="relative isolate flex min-h-0 w-full flex-1 cursor-pointer flex-col justify-center rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<span
className={cn(
"relative z-10 flex min-h-0 w-full flex-1 flex-col justify-center",
onAir && "invisible",
)}
>
{children}
</span>
{stream ? (
<video
ref={(element) => {
if (element && element.srcObject !== stream)
element.srcObject = stream
}}
autoPlay
muted
playsInline
onPlaying={live.played}
className={cn("absolute inset-0 h-full w-full rounded-md", fit)}
/>
) : null}
{onAir ? (
<span
className={cn(
chip,
"right-2 top-2 border-transparent bg-primary text-primary-foreground",
)}
>
Live
<X className="size-3.5" aria-hidden />
</span>
) : (
<span className={cn(chip, frosted, "right-2 top-2")}>
{phase === "connecting" ? (
<LoaderCircle
className="size-3.5 motion-safe:animate-spin"
aria-hidden
/>
) : (
<Radio className="size-3.5" aria-hidden />
)}
{phase === "connecting" ? "Connecting" : "Live"}
</span>
)}
{note ? (
<output
className={cn(chip, frosted, "bottom-2 left-2 text-muted-foreground")}
data-testid="media-live-note"
>
{note}
</output>
) : null}
</button>
)
}