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
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
257 lines
7.2 KiB
TypeScript
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>
|
|
)
|
|
}
|