import { useEffect, useState } from "react"
import { OpenAPI } from "@/client"
import { apiToken } from "@/lib/portal"
import { cn } from "@/lib/utils"
import { useBoundValue } from "./dataContext"
import { config, text } from "./ui/core/config"
import type { WidgetProps } from "./widgets"
/** An artifact reference, as a message carries one. */
type MediaRef = {
digest?: string
media_type?: string
name?: string
size?: number
}
const isRef = (value: unknown): value is MediaRef =>
typeof value === "object" &&
value !== null &&
typeof (value as MediaRef).digest === "string" &&
(value as MediaRef).digest!.startsWith("sha256:")
/**
* A local URL for an artifact's bytes, refreshed whenever the digest changes.
*
* Not the endpoint itself: `/artifacts/{digest}` takes a bearer token, and no
* ` ` or `` can carry a header. So the bytes come through fetch and
* are handed to the element as an object URL — which also means playback never
* asks the server for a range, since the blob is already here.
*
* The URL is revoked when it is replaced, or the tab would hold every frame a
* camera has ever sent for as long as the page is open.
*/
function useArtifactUrl(ref: MediaRef | null): string {
const digest = ref?.digest ?? ""
const mediaType = ref?.media_type ?? ""
const [url, setUrl] = useState("")
useEffect(() => {
if (!digest) {
setUrl("")
return
}
let live = true
let made = ""
const token = apiToken()
const query = mediaType
? `?media_type=${encodeURIComponent(mediaType)}`
: ""
fetch(`${OpenAPI.BASE}/api/v1/artifacts/${digest}${query}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((answer) => (answer.ok ? answer.blob() : Promise.reject(answer)))
.then((blob) => {
if (!live) return
made = URL.createObjectURL(blob)
setUrl(made)
})
.catch(() => {
if (live) setUrl("")
})
return () => {
live = false
if (made) URL.revokeObjectURL(made)
}
}, [digest, mediaType])
return url
}
/**
* What a message's bytes look like: a frame, a clip, a segment.
*
* Media never travels as a message — the reference does, and the bytes are
* fetched from the artifact store. What that means for a wall panel is a
* refresh per published frame, which suits a camera glancing every few seconds
* rather than a live view; for that, point `stream_url` at whatever the camera
* already serves and the browser plays it directly.
*/
export function MediaWidget({ widget }: WidgetProps) {
const cfg = config(widget)
const message = text(cfg.message)
const stream = text(cfg.stream_url)
const live = useBoundValue(message || undefined)
const value = live?.value
const ref = isRef(value) ? value : null
const url = useArtifactUrl(ref)
const kind = (ref?.media_type ?? text(cfg.dtype)).split("/")[0]
const fit = text(cfg.fit) === "contain" ? "object-contain" : "object-cover"
const label = widget.title || ref?.name || message
// 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 (
)
}
if (!message && !stream) {
return Pick a message.
}
if (!ref) {
return Nothing published yet.
}
if (!url) {
return Loading…
}
if (kind === "audio") {
return (
)
}
if (kind === "video") {
return (
)
}
return (
)
}