{"$schema":"https://ui.shadcn.com/schema/registry-item.json","author":"Eduardo Calvo <educlopez93@gmail.com>","css":{},"dependencies":["motion"],"description":"A video with a two-pass Ambilight-style glow sampled live from its own frames, with a CORS-safe static fallback.","devDependencies":[],"files":[{"content":"\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useReducedMotion } from \"motion/react\";\nimport { useCallback, useEffect, useRef, useState } from \"react\";\n\n/* -------------------------------------------------------------------------- */\n/* Constants                                                                  */\n/* -------------------------------------------------------------------------- */\n\nconst DEFAULT_INTENSITY = 0.9;\nconst DEFAULT_BLUR_PX = 34;\nconst DEFAULT_SAMPLE_RATE_FPS = 12;\nconst DEFAULT_SCALE = 1.3;\nconst DEFAULT_SATURATION = 1.9;\nconst DEFAULT_GAIN = 1.7;\nconst GLOW_BRIGHTNESS = 1.12;\nconst BLOOM_BRIGHTNESS = 1.2;\nconst DEFAULT_ROUNDED_PX = 20;\n\n/**\n * The sampled canvas is deliberately tiny. The glow is a colour field, not a\n * picture — a low-resolution grab left to the browser's own bilinear upscaling\n * is already a soft gradient, so the CSS blur only has to finish the job.\n */\nconst SAMPLE_WIDTH = 24;\nconst SAMPLE_HEIGHT = 14;\n\n/** The outer pass is bigger, softer and more saturated than the inner one. */\nconst BLOOM_SCALE_FACTOR = 1.55;\nconst BLOOM_BLUR_FACTOR = 1.9;\nconst BLOOM_SATURATION_FACTOR = 1.2;\nconst BLOOM_OPACITY_FACTOR = 0.6;\n\nconst MS_PER_SECOND = 1000;\nconst MIN_SAMPLE_RATE = 1;\nconst MAX_SAMPLE_RATE = 60;\nconst GLOW_FADE_MS = 400;\nconst STATIC_GRADIENT_OPACITY_FACTOR = 0.55;\nconst HAVE_CURRENT_DATA = 2;\n\n/* -------------------------------------------------------------------------- */\n/* Public types                                                               */\n/* -------------------------------------------------------------------------- */\n\nexport type VideoAmbientProps = {\n  /** Describes the video content for assistive technology. */\n  alt: string;\n  autoPlay?: boolean;\n  /** Blur radius in pixels on the inner glow. The outer bloom doubles it. */\n  blur?: number;\n  className?: string;\n  controls?: boolean;\n  /**\n   * Extra light pushed into the glow. The sampled frame is composited onto\n   * itself with `lighter`, so 1 is the video's own brightness and anything\n   * above it reads as emitted light rather than a colour wash. Values past\n   * ~2.2 clip the highlights to white.\n   */\n  gain?: number;\n  /** Turn the ambient glow off entirely — useful for an A/B toggle. */\n  glow?: boolean;\n  /** Glow opacity, 0–1. */\n  intensity?: number;\n  loop?: boolean;\n  muted?: boolean;\n  poster?: string;\n  /** Border radius in pixels applied to the video itself. */\n  rounded?: number;\n  /** How many times per second the glow re-samples the current frame. */\n  sampleRate?: number;\n  /** Colour boost on the glow, 1 = the video's own saturation. */\n  saturation?: number;\n  /** How far past the video's edges the inner glow bleeds, 1 = no bleed. */\n  scale?: number;\n  src: string;\n};\n\n/* -------------------------------------------------------------------------- */\n/* Component                                                                  */\n/* -------------------------------------------------------------------------- */\n\n/**\n * A video with an ambient glow sampled from its own frames.\n *\n * Two passes make the effect read: an inner glow scaled just past the player's\n * edges, and a much larger, softer, more saturated bloom behind it. Both are\n * drawn from the same 24×14 grab, so the bleed costs two `drawImage` calls per\n * sample regardless of the video's real resolution.\n *\n * No pixel data is ever read back, so the effect survives a cross-origin source\n * with no CORS headers. The one thing that can still fail is `drawImage` itself,\n * which a handful of strict configurations throw on for a tainted source; that\n * failure is caught and the glow falls back to a blurred poster or a\n * theme-coloured gradient.\n */\nconst VideoAmbient = ({\n  alt,\n  autoPlay = true,\n  blur = DEFAULT_BLUR_PX,\n  className,\n  controls = false,\n  gain = DEFAULT_GAIN,\n  glow = true,\n  intensity = DEFAULT_INTENSITY,\n  loop = true,\n  muted = true,\n  poster,\n  rounded = DEFAULT_ROUNDED_PX,\n  sampleRate = DEFAULT_SAMPLE_RATE_FPS,\n  saturation = DEFAULT_SATURATION,\n  scale = DEFAULT_SCALE,\n  src,\n}: VideoAmbientProps) => {\n  const shouldReduceMotion = Boolean(useReducedMotion());\n\n  const containerRef = useRef<HTMLDivElement>(null);\n  const videoRef = useRef<HTMLVideoElement>(null);\n  const innerCanvasRef = useRef<HTMLCanvasElement>(null);\n  const bloomCanvasRef = useRef<HTMLCanvasElement>(null);\n  /** Mirrors `hasFrame` so the sample loop never sets state per frame. */\n  const hasFrameRef = useRef(false);\n\n  const [isPlaying, setIsPlaying] = useState(false);\n  const [isOnScreen, setIsOnScreen] = useState(true);\n  const [isTabVisible, setIsTabVisible] = useState(true);\n  const [isBlocked, setIsBlocked] = useState(false);\n  const [hasFrame, setHasFrame] = useState(false);\n\n  // Autoplay never fights reduced motion — a moving glow behind a page that\n  // asked to stay still is the exact thing the preference exists to prevent.\n  const effectiveAutoPlay = autoPlay && !shouldReduceMotion;\n  const showSampledGlow = glow && hasFrame && !isBlocked;\n  const showFallbackGlow = glow && !showSampledGlow;\n\n  const sampleFrame = useCallback(() => {\n    const video = videoRef.current;\n    if (!video || video.readyState < HAVE_CURRENT_DATA) {\n      return;\n    }\n    const extraGain = Math.max(0, gain - 1);\n    const canvases = [innerCanvasRef.current, bloomCanvasRef.current];\n    for (const canvas of canvases) {\n      const ctx = canvas?.getContext(\"2d\");\n      if (!(canvas && ctx)) {\n        continue;\n      }\n      try {\n        ctx.globalCompositeOperation = \"source-over\";\n        ctx.globalAlpha = 1;\n        ctx.drawImage(video, 0, 0, canvas.width, canvas.height);\n        if (extraGain > 0) {\n          // Composited onto itself with `lighter`, the frame gains light rather\n          // than paint. Without this the glow reads as a tint over whatever it\n          // sits on — the difference between a lamp and a coloured filter.\n          ctx.globalCompositeOperation = \"lighter\";\n          ctx.globalAlpha = extraGain;\n          ctx.drawImage(video, 0, 0, canvas.width, canvas.height);\n          ctx.globalCompositeOperation = \"source-over\";\n          ctx.globalAlpha = 1;\n        }\n      } catch {\n        // A few strict configurations throw on drawImage for a tainted\n        // cross-origin source — the static fallback below takes over.\n        setIsBlocked(true);\n        return;\n      }\n    }\n    if (!hasFrameRef.current) {\n      hasFrameRef.current = true;\n      setHasFrame(true);\n    }\n  }, [gain]);\n\n  // Playback state is read from the element rather than assumed, so a video\n  // that was already playing before this effect ran still drives the loop.\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    const sync = () => setIsPlaying(!(video.paused || video.ended));\n    sync();\n    const events = [\"play\", \"playing\", \"pause\", \"ended\", \"loadeddata\"];\n    for (const event of events) {\n      video.addEventListener(event, sync);\n    }\n    return () => {\n      for (const event of events) {\n        video.removeEventListener(event, sync);\n      }\n    };\n  }, []);\n\n  // Some browsers reject a programmatic-looking autoplay even when muted; a\n  // rejected promise here just means the user presses play themselves.\n  useEffect(() => {\n    const video = videoRef.current;\n    if (!(video && effectiveAutoPlay)) {\n      return;\n    }\n    const attempt = () => {\n      video.play().catch(() => {\n        /* Autoplay refused — controls remain available. */\n      });\n    };\n    if (video.readyState >= HAVE_CURRENT_DATA) {\n      attempt();\n      return;\n    }\n    video.addEventListener(\"loadeddata\", attempt, { once: true });\n    return () => video.removeEventListener(\"loadeddata\", attempt);\n  }, [effectiveAutoPlay]);\n\n  useEffect(() => {\n    const container = containerRef.current;\n    if (!container) {\n      return;\n    }\n    const observer = new IntersectionObserver(([entry]) =>\n      setIsOnScreen(entry.isIntersecting)\n    );\n    observer.observe(container);\n    return () => observer.disconnect();\n  }, []);\n\n  useEffect(() => {\n    const handleVisibility = () => setIsTabVisible(!document.hidden);\n    document.addEventListener(\"visibilitychange\", handleVisibility);\n    return () =>\n      document.removeEventListener(\"visibilitychange\", handleVisibility);\n  }, []);\n\n  // Reduced motion draws exactly one frame and stops there: the glow stays\n  // present, it just never moves again.\n  useEffect(() => {\n    if (!(glow && shouldReduceMotion)) {\n      return;\n    }\n    const video = videoRef.current;\n    if (!video) {\n      return;\n    }\n    if (video.readyState >= HAVE_CURRENT_DATA) {\n      sampleFrame();\n      return;\n    }\n    video.addEventListener(\"loadeddata\", sampleFrame, { once: true });\n    return () => video.removeEventListener(\"loadeddata\", sampleFrame);\n  }, [glow, sampleFrame, shouldReduceMotion]);\n\n  // Sampling only runs while there is something worth sampling: playing, on\n  // screen, and the tab actually visible. The loop is rAF-driven and throttled\n  // rather than interval-driven so it never fires on a frame the browser is\n  // already dropping.\n  useEffect(() => {\n    const isWorthSampling =\n      glow &&\n      !(isBlocked || shouldReduceMotion) &&\n      isPlaying &&\n      isOnScreen &&\n      isTabVisible;\n    if (!isWorthSampling) {\n      return;\n    }\n    const minDelta =\n      MS_PER_SECOND /\n      Math.min(MAX_SAMPLE_RATE, Math.max(MIN_SAMPLE_RATE, sampleRate));\n    let frameId = 0;\n    let lastSampleAt = 0;\n    const tick = (now: number) => {\n      frameId = requestAnimationFrame(tick);\n      if (now - lastSampleAt < minDelta) {\n        return;\n      }\n      lastSampleAt = now;\n      sampleFrame();\n    };\n    frameId = requestAnimationFrame(tick);\n    return () => cancelAnimationFrame(frameId);\n  }, [\n    glow,\n    isBlocked,\n    isOnScreen,\n    isPlaying,\n    isTabVisible,\n    sampleFrame,\n    sampleRate,\n    shouldReduceMotion,\n  ]);\n\n  const glowTransition = `opacity ${GLOW_FADE_MS}ms cubic-bezier(0.23, 1, 0.32, 1)`;\n\n  return (\n    <div className={cn(\"relative isolate\", className)} ref={containerRef}>\n      {glow ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-0\"\n        >\n          {/* Outer bloom: bigger, softer, more saturated. */}\n          <canvas\n            className=\"absolute inset-0 size-full\"\n            height={SAMPLE_HEIGHT}\n            ref={bloomCanvasRef}\n            style={{\n              filter: `blur(${blur * BLOOM_BLUR_FACTOR}px) saturate(${saturation * BLOOM_SATURATION_FACTOR}) brightness(${BLOOM_BRIGHTNESS})`,\n              opacity: showSampledGlow ? intensity * BLOOM_OPACITY_FACTOR : 0,\n              transform: `scale(${scale * BLOOM_SCALE_FACTOR})`,\n              transition: glowTransition,\n            }}\n            width={SAMPLE_WIDTH}\n          />\n          {/* Inner glow: hugs the player, carries the colour. */}\n          <canvas\n            className=\"absolute inset-0 size-full\"\n            height={SAMPLE_HEIGHT}\n            ref={innerCanvasRef}\n            style={{\n              filter: `blur(${blur}px) saturate(${saturation}) brightness(${GLOW_BRIGHTNESS})`,\n              opacity: showSampledGlow ? intensity : 0,\n              transform: `scale(${scale})`,\n              transition: glowTransition,\n            }}\n            width={SAMPLE_WIDTH}\n          />\n        </div>\n      ) : null}\n\n      {/* Stays mounted so the first sampled frame crossfades in rather than cutting. */}\n      {glow ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute inset-0 z-0\"\n          style={{\n            opacity: showFallbackGlow ? 1 : 0,\n            transition: glowTransition,\n          }}\n        >\n          {poster ? (\n            <img\n              alt=\"\"\n              className=\"absolute inset-0 size-full object-cover\"\n              src={poster}\n              style={{\n                filter: `blur(${blur}px) saturate(${saturation})`,\n                opacity: intensity * STATIC_GRADIENT_OPACITY_FACTOR,\n                transform: `scale(${scale})`,\n              }}\n            />\n          ) : (\n            <div\n              className=\"absolute inset-0 rounded-full\"\n              style={{\n                background:\n                  \"radial-gradient(circle, var(--color-brand, currentColor) 0%, transparent 70%)\",\n                filter: `blur(${blur}px)`,\n                opacity: intensity * STATIC_GRADIENT_OPACITY_FACTOR,\n                transform: `scale(${scale})`,\n              }}\n            />\n          )}\n        </div>\n      ) : null}\n\n      <video\n        aria-label={alt}\n        autoPlay={effectiveAutoPlay}\n        className=\"relative z-10 block size-full object-cover\"\n        controls={controls}\n        loop={loop}\n        muted={muted}\n        playsInline\n        poster={poster}\n        ref={videoRef}\n        src={src}\n        style={{ borderRadius: rounded }}\n      />\n    </div>\n  );\n};\n\nexport default VideoAmbient;\n","path":"index.tsx","target":"components/smoothui/video-ambient/index.tsx","type":"registry:ui"}],"name":"video-ambient","registryDependencies":["https://smoothui.dev/r/tokens.json"],"title":"Video Ambient","type":"registry:ui"}