{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "media-rail", "title": "Media Rail", "description": "A frosted tray of thumbnails with one frame live. Give a frame a YouTube id and it plays in place, with the ring around it doubling as the progress track. No third-party script; the embed is driven over postMessage and nothing is fetched until first press.", "dependencies": [ "class-variance-authority", "lucide-react" ], "registryDependencies": [ "http://localhost:5173/r/liquid-theme.json" ], "files": [ { "path": "src/components/ui/media-rail.tsx", "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { cva, type VariantProps } from \"class-variance-authority\"\nimport { Pause, Play } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\n\n/**\n * The thumbnail tray from the reference hero: a frosted rail of frames with\n * one of them live. Give a frame a `video` and the live one plays it in\n * place, with the ring around it turned into the transport.\n *\n * Two things about the ring are deliberate:\n *\n * Idle, it is a *broken* ring rather than a closed border. A closed border\n * reads as a fifth edge and boxes the thumbnail in; an interrupted one reads\n * as a bracket around something already there.\n *\n * Playing, the same stroke becomes the progress track — so the frame gains a\n * scrubber without gaining any furniture, which is the whole reason the\n * bracket was drawn as a partial stroke to begin with.\n *\n * `pathLength={100}` normalises the rounded rectangle's perimeter to 100\n * units, so both the gap and the progress are percentages and hold their\n * geometry at any frame size.\n *\n * ---\n *\n * There is no YouTube SDK here, and that is not incidental.\n *\n * `YT.Player` creates its iframe a tick after you call the constructor. That\n * tick is enough to end the user activation from the press, and a\n * cross-origin frame created without activation is refused autoplay *and*\n * refused an explicit playVideo() — the frame mounts and then simply never\n * starts. Rendering the iframe ourselves, synchronously, keeps the press and\n * the frame in the same turn, which is the only arrangement that plays.\n *\n * Control and progress then go over the embed's postMessage protocol, which\n * is what the SDK wraps anyway. The component loads no third-party script,\n * and nothing is fetched from youtube.com until someone presses play.\n */\nconst tray = cva(\"inline-flex items-center lq-squircle\", {\n variants: {\n material: {\n glass: \"lq-glass text-white\",\n solid: \"lq-solid text-ink dark:text-white\",\n ink: \"bg-ink text-white shadow-[var(--lq-cast)]\",\n bare: \"\",\n },\n size: {\n sm: \"gap-1.5 p-1.5 [--lq-corner:18px]\",\n md: \"gap-2 p-2 [--lq-corner:22px]\",\n lg: \"gap-2.5 p-2.5 [--lq-corner:26px]\",\n },\n },\n defaultVariants: { material: \"glass\", size: \"md\" },\n})\n\nconst FRAME = {\n sm: { w: 64, h: 44, r: 12 },\n md: { w: 88, h: 60, r: 16 },\n lg: { w: 112, h: 76, r: 20 },\n} as const\n\n/** How far outside the frame the ring is drawn. */\nconst OUTSET = 3\n\n/**\n * The player is always built at this size and then scaled down to the frame.\n *\n * This is not a nicety. A YouTube embed sized to an 88px thumbnail does not\n * play — below a few hundred pixels the player decides there is no room for\n * itself and shows its branding poster instead, forever. Giving it a real\n * 640×360 viewport and shrinking the result with a transform is the only way\n * to get moving pictures inside something thumbnail-sized.\n */\nconst VIDEO_W = 640\nconst VIDEO_H = 360\n\nconst YT_ORIGIN = \"https://www.youtube-nocookie.com\"\n\n/** Accepts a bare 11-character id, a watch URL, a share link or an embed URL. */\nfunction youTubeId(value: string): string | null {\n if (/^[\\w-]{11}$/.test(value)) return value\n const m = value.match(/(?:youtu\\.be\\/|[?&]v=|\\/embed\\/|\\/shorts\\/)([\\w-]{11})/)\n return m ? m[1] : null\n}\n\n/**\n * Where along the perimeter the top-centre of a rounded rectangle falls.\n *\n * An SVG `rect` starts its path at the end of the top-left corner arc, so a\n * progress stroke left at the default offset appears to begin from the\n * corner. Progress that starts anywhere but twelve o'clock reads as broken,\n * and the correction is only worth making because it is exact.\n */\nfunction topCentreOffset(w: number, h: number, r: number): number {\n const straight = 2 * (w - 2 * r) + 2 * (h - 2 * r)\n const perimeter = straight + 2 * Math.PI * r\n return ((w / 2 - r) / perimeter) * 100\n}\n\n/** YouTube's own state codes, from the embed's postMessage payloads. */\nconst STATE = { ENDED: 0, PLAYING: 1, PAUSED: 2 } as const\n\nexport type MediaRailItem = {\n /** Poster image, shown until the video is playing. */\n src: string\n alt?: string\n label?: React.ReactNode\n /**\n * YouTube video id or URL. The frame becomes playable: press once to\n * start, again to pause. Omit for a still frame.\n */\n video?: string\n}\n\nexport interface MediaRailProps\n extends Omit, \"onChange\">,\n VariantProps {\n items: MediaRailItem[]\n /** Controlled active index. */\n value?: number\n defaultValue?: number\n onValueChange?: (index: number) => void\n /** Fraction of the idle ring that is drawn, 0–1. The rest is the gap. */\n ringCoverage?: number\n /**\n * Start muted. Defaults to true, and think hard before turning it off:\n * browsers refuse to autoplay unmuted media in a cross-origin frame\n * however the press is delivered, so `muted={false}` buys a first press\n * that loads the video and does not start it.\n */\n muted?: boolean\n label?: string\n}\n\nexport function MediaRail({\n items,\n value,\n defaultValue = 0,\n onValueChange,\n material,\n size = \"md\",\n ringCoverage = 0.62,\n muted = true,\n label = \"Media\",\n className,\n ...props\n}: MediaRailProps) {\n const [uncontrolled, setUncontrolled] = React.useState(defaultValue)\n const active = value ?? uncontrolled\n\n const [started, setStarted] = React.useState(false)\n const [playing, setPlaying] = React.useState(false)\n const [progress, setProgress] = React.useState(0)\n\n const iframeRef = React.useRef(null)\n\n const frame = FRAME[size ?? \"md\"]\n const box = { w: frame.w + OUTSET * 2, h: frame.h + OUTSET * 2 }\n const drawn = Math.round(Math.min(1, Math.max(0, ringCoverage)) * 100)\n\n // Moving to another frame abandons the current one, and so does unmounting.\n // Leaving a playing iframe behind keeps the audio going, which is the worst\n // possible outcome for a component whose whole job is a preview.\n React.useEffect(() => {\n setStarted(false)\n setPlaying(false)\n setProgress(0)\n }, [active])\n\n const command = React.useCallback((func: string) => {\n iframeRef.current?.contentWindow?.postMessage(\n JSON.stringify({ event: \"command\", func, args: [] }),\n \"*\",\n )\n }, [])\n\n // The embed only starts reporting once it has been told someone is\n // listening, and it does not retry — so the handshake is repeated until the\n // first payload arrives rather than fired once and hoped over.\n React.useEffect(() => {\n if (!started) return\n\n const onMessage = (e: MessageEvent) => {\n if (!e.origin.includes(\"youtube\")) return\n let payload: { event?: string; info?: Record }\n try {\n payload = typeof e.data === \"string\" ? JSON.parse(e.data) : e.data\n } catch {\n return\n }\n const info = payload?.info\n if (!info) return\n if (typeof info.playerState === \"number\") {\n setPlaying(info.playerState === STATE.PLAYING)\n if (info.playerState === STATE.ENDED) setProgress(1)\n }\n if (typeof info.currentTime === \"number\" && info.duration) {\n setProgress(Math.min(1, info.currentTime / info.duration))\n }\n }\n\n window.addEventListener(\"message\", onMessage)\n const handshake = window.setInterval(() => {\n iframeRef.current?.contentWindow?.postMessage(\n JSON.stringify({ event: \"listening\", channel: \"widget\" }),\n \"*\",\n )\n }, 400)\n\n return () => {\n window.removeEventListener(\"message\", onMessage)\n window.clearInterval(handshake)\n }\n }, [started])\n\n const select = (i: number) => {\n if (value === undefined) setUncontrolled(i)\n onValueChange?.(i)\n }\n\n const toggle = () => {\n // The first press must do nothing but render the iframe. Anything that\n // defers that render past this turn loses the activation and the video\n // never starts; `autoplay=1` in the src then does the work.\n if (!started) {\n setStarted(true)\n return\n }\n command(playing ? \"pauseVideo\" : \"playVideo\")\n }\n\n return (\n \n {items.map((item, i) => {\n const on = i === active\n const videoId = item.video ? youTubeId(item.video) : null\n const live = on && videoId !== null\n const showProgress = live && started\n\n return (\n \n \n \n\n {live && started ? (\n \n ) : null}\n \n\n {on ? (\n \n \n \n ) : null}\n\n (live ? toggle() : select(i))}\n className=\"absolute inset-0 grid cursor-pointer place-items-center outline-none focus-visible:ring-3 focus-visible:ring-current/40\"\n style={{ borderRadius: frame.r }}\n >\n {live ? (\n \n {playing ? (\n \n ) : (\n \n )}\n \n ) : null}\n \n \n )\n })}\n \n )\n}\n", "type": "registry:ui" } ], "type": "registry:ui" }