{
  "$schema": "https://uipkge.dev/schema/registry-item.json",
  "name": "video",
  "title": "Video",
  "type": "registry:ui",
  "description": "Video player wrapper with a custom controls overlay on a native video element. Supports src, poster, autoplay, loop, muted, custom or native controls, playback rate, fullscreen, time display, a progress bar, play/pause, skip, and volume control.",
  "categories": [
    "media",
    "display"
  ],
  "files": [
    {
      "path": "packages/elements-lit/src/components/video/video.ts",
      "content": "import { LitElement, css, html, isServer, nothing } from 'lit'\nimport { styleMap } from 'lit/directives/style-map.js'\nimport { Maximize, Minimize, Pause, Play, RotateCcw, RotateCw, Volume2, VolumeX } from 'lucide'\nimport { cn } from '../../lib/utils'\nimport { tailwind } from '../../lib/styles'\nimport { ThemeController } from '../../lib/theme'\nimport { icon } from '../../lib/icon'\n\nconst trueByDefault = { fromAttribute: (v: string | null) => v !== 'false', toAttribute: () => null }\n\nfunction formatTime(s: number): string {\n  if (!s || !isFinite(s)) return '0:00'\n  const m = Math.floor(s / 60)\n  const sec = Math.floor(s % 60)\n  return `${m}:${sec.toString().padStart(2, '0')}`\n}\n\n/**\n * <uip-video> — Video player with custom overlay controls, keyboard shortcuts, and fullscreen support.\n */\nexport class UipVideo extends LitElement {\n  static styles = [tailwind, css`:host { display: block; position: relative; width: 100%; }`]\n\n  static properties = {\n    src: { type: String },\n    poster: { type: String },\n    autoplay: { type: Boolean },\n    loop: { type: Boolean },\n    muted: { type: Boolean },\n    nativeControls: { type: Boolean, attribute: 'native-controls' },\n    playbackRate: { type: Number, attribute: 'playback-rate' },\n    aspectRatio: { attribute: 'aspect-ratio' },\n    playing: { state: true },\n    current: { state: true },\n    duration: { state: true },\n    volume: { state: true },\n    isMuted: { state: true },\n    isFullscreen: { state: true },\n    showControls: { state: true },\n    playbackFailed: { state: true },\n  }\n\n  src = ''\n  poster?: string\n  autoplay = false\n  loop = false\n  muted = false\n  nativeControls = false\n  playbackRate = 1\n  aspectRatio = '16/9'\n\n  private playing = false\n  private current = 0\n  private duration = 0\n  private volume = 1\n  private isMuted = false\n  private isFullscreen = false\n  private showControls = true\n  private playbackFailed = false\n\n  private hideTimer?: ReturnType<typeof setTimeout>\n\n  constructor() {\n    super()\n    new ThemeController(this)\n  }\n\n  connectedCallback() {\n    super.connectedCallback()\n    this.setAttribute('data-uipkge', '')\n    this.setAttribute('data-slot', 'video')\n    this.setAttribute('role', 'region')\n    this.setAttribute('aria-label', 'Video player')\n    this.setAttribute('tabindex', '0')\n\n    this.isMuted = this.muted\n    this.volume = this.muted ? 0 : 1\n\n    document.addEventListener('fullscreenchange', this.onFullscreenChange.bind(this))\n    this.addEventListener('keydown', this.onKeyDown.bind(this))\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback()\n    document.removeEventListener('fullscreenchange', this.onFullscreenChange.bind(this))\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n  }\n\n  private get videoEl(): HTMLVideoElement | null {\n    return this.renderRoot?.querySelector('video') ?? null\n  }\n\n  private onFullscreenChange() {\n    this.isFullscreen = !!document.fullscreenElement\n  }\n\n  togglePlay() {\n    const v = this.videoEl\n    if (!v) return\n    if (v.paused) {\n      v.play().catch(() => {\n        this.playing = false\n        this.playbackFailed = true\n      })\n    } else {\n      v.pause()\n    }\n  }\n\n  private onPlay() {\n    this.playbackFailed = false\n    this.playing = true\n    this.dispatchEvent(new CustomEvent('play', { bubbles: true, composed: true }))\n  }\n\n  private onPause() {\n    this.playing = false\n    this.dispatchEvent(new CustomEvent('pause', { bubbles: true, composed: true }))\n  }\n\n  private onTimeUpdate() {\n    const v = this.videoEl\n    if (!v) return\n    this.current = v.currentTime\n  }\n\n  private onLoadedMetadata() {\n    const v = this.videoEl\n    if (!v) return\n    this.duration = v.duration || 0\n    v.playbackRate = this.playbackRate\n    v.volume = this.volume\n  }\n\n  private onVolumeChange() {\n    const v = this.videoEl\n    if (!v) return\n    this.volume = v.volume\n    this.isMuted = v.muted\n  }\n\n  private seek(e: Event) {\n    const input = e.target as HTMLInputElement\n    const v = this.videoEl\n    if (!v) return\n    v.currentTime = Number(input.value)\n  }\n\n  private setVolumeInput(e: Event) {\n    const input = e.target as HTMLInputElement\n    const v = this.videoEl\n    if (!v) return\n    v.volume = Number(input.value)\n    v.muted = Number(input.value) === 0\n  }\n\n  toggleMute() {\n    const v = this.videoEl\n    if (!v) return\n    v.muted = !v.muted\n  }\n\n  skip(seconds: number) {\n    const v = this.videoEl\n    if (!v) return\n    v.currentTime = Math.min(Math.max(v.currentTime + seconds, 0), v.duration || 0)\n  }\n\n  toggleFullscreen() {\n    const el = this.renderRoot?.querySelector('[data-slot=\"video-container\"]') as HTMLElement | null\n    if (!el) return\n    if (document.fullscreenElement) {\n      document.exitFullscreen()\n    } else {\n      el.requestFullscreen?.()\n    }\n  }\n\n  private onMouseMove() {\n    this.showControls = true\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n    this.hideTimer = setTimeout(() => {\n      if (this.playing) this.showControls = false\n    }, 2500)\n  }\n\n  private onMouseLeave() {\n    if (this.hideTimer) clearTimeout(this.hideTimer)\n    if (this.playing) this.showControls = false\n  }\n\n  private onKeyDown(e: KeyboardEvent) {\n    if (this.nativeControls) return\n    const target = e.target as HTMLElement | null\n    if ((e.key === ' ' || e.key === 'Enter') && target?.closest('button')) return\n    if (target instanceof HTMLInputElement && target.type === 'range' && e.key.startsWith('Arrow')) return\n\n    if (e.key === ' ' || e.key === 'k' || e.key === 'K') {\n      e.preventDefault()\n      this.togglePlay()\n    } else if (e.key === 'm' || e.key === 'M') {\n      e.preventDefault()\n      this.toggleMute()\n    } else if (e.key === 'f' || e.key === 'F') {\n      e.preventDefault()\n      this.toggleFullscreen()\n    } else if (e.key === 'ArrowLeft') {\n      e.preventDefault()\n      this.skip(-5)\n    } else if (e.key === 'ArrowRight') {\n      e.preventDefault()\n      this.skip(5)\n    }\n  }\n\n  render() {\n    return html`\n      <div\n        data-slot=\"video-container\"\n        class=\"group focus-visible:ring-ring relative overflow-hidden rounded-lg bg-black outline-none focus-visible:ring-2 w-full\"\n        style=${styleMap({ aspectRatio: this.aspectRatio })}\n        @mousemove=${this.onMouseMove}\n        @mouseleave=${this.onMouseLeave}\n      >\n        <video\n          data-slot=\"video-element\"\n          class=\"size-full object-contain\"\n          src=${this.src}\n          poster=${this.poster ?? nothing}\n          ?autoplay=${this.autoplay}\n          ?loop=${this.loop}\n          ?muted=${this.muted}\n          ?controls=${this.nativeControls}\n          playsinline\n          @play=${this.onPlay}\n          @pause=${this.onPause}\n          @timeupdate=${this.onTimeUpdate}\n          @loadedmetadata=${this.onLoadedMetadata}\n          @volumechange=${this.onVolumeChange}\n          @click=${() => this.togglePlay()}\n        ></video>\n\n        ${this.playbackFailed\n          ? html`\n              <p\n                role=\"alert\"\n                class=\"bg-background text-foreground absolute inset-x-3 top-3 z-10 rounded-md p-3 text-sm\"\n              >\n                Unable to play this video. Check the source or try again.\n              </p>\n            `\n          : nothing}\n\n        ${!this.nativeControls\n          ? html`\n              <div\n                part=\"controls\"\n                data-slot=\"video-controls\"\n                class=${cn(\n                  'absolute inset-0 flex flex-col justify-between transition-opacity duration-200 motion-reduce:transition-none',\n                  this.showControls || !this.playing ? 'opacity-100' : 'pointer-events-none opacity-0',\n                )}\n              >\n                <!-- Top spacer / click target -->\n                <div class=\"flex-1\" @click=${() => this.togglePlay()}></div>\n\n                <!-- Bottom controls bar -->\n                <div class=\"bg-gradient-to-t from-black/80 to-transparent px-3 pt-6 pb-2\">\n                  <!-- Progress bar -->\n                  <div class=\"mb-2 flex items-center gap-2\">\n                    <span class=\"text-xs text-white/80 tabular-nums\">${formatTime(this.current)}</span>\n                    <input\n                      type=\"range\"\n                      min=\"0\"\n                      max=${this.duration || 0}\n                      step=\"0.1\"\n                      .value=${String(this.current)}\n                      aria-label=\"Seek\"\n                      class=\"accent-primary h-1 flex-1 cursor-pointer appearance-none rounded-full bg-white/30 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary\"\n                      @input=${this.seek}\n                    />\n                    <span class=\"text-xs text-white/80 tabular-nums\">${formatTime(this.duration)}</span>\n                  </div>\n\n                  <!-- Buttons row -->\n                  <div class=\"flex items-center gap-2\">\n                    <button\n                      type=\"button\"\n                      class=\"text-white/90 transition-colors hover:text-white cursor-pointer\"\n                      aria-label=${this.playing ? 'Pause' : 'Play'}\n                      @click=${() => this.togglePlay()}\n                    >\n                      ${this.playing ? icon(Pause, 'pause', 'size-5') : icon(Play, 'play', 'size-5')}\n                    </button>\n                    <button\n                      type=\"button\"\n                      class=\"text-white/90 transition-colors hover:text-white cursor-pointer\"\n                      aria-label=\"Rewind 10s\"\n                      @click=${() => this.skip(-10)}\n                    >\n                      ${icon(RotateCcw, 'rotate-ccw', 'size-4')}\n                    </button>\n                    <button\n                      type=\"button\"\n                      class=\"text-white/90 transition-colors hover:text-white cursor-pointer\"\n                      aria-label=\"Forward 10s\"\n                      @click=${() => this.skip(10)}\n                    >\n                      ${icon(RotateCw, 'rotate-cw', 'size-4')}\n                    </button>\n\n                    <!-- Volume -->\n                    <div class=\"group/volume flex items-center gap-1.5\">\n                      <button\n                        type=\"button\"\n                        class=\"text-white/90 transition-colors hover:text-white cursor-pointer\"\n                        aria-label=${this.isMuted || this.volume === 0 ? 'Unmute' : 'Mute'}\n                        @click=${() => this.toggleMute()}\n                      >\n                        ${this.isMuted || this.volume === 0\n                          ? icon(VolumeX, 'volume-x', 'size-4')\n                          : icon(Volume2, 'volume-2', 'size-4')}\n                      </button>\n                      <input\n                        type=\"range\"\n                        min=\"0\"\n                        max=\"1\"\n                        step=\"0.05\"\n                        .value=${String(this.isMuted ? 0 : this.volume)}\n                        aria-label=\"Volume\"\n                        class=\"accent-primary h-1 w-0 cursor-pointer appearance-none rounded-full bg-white/30 transition-[width] duration-200 group-hover/volume:w-16 motion-reduce:transition-none [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary\"\n                        @input=${this.setVolumeInput}\n                      />\n                    </div>\n\n                    <div class=\"flex-1\"></div>\n\n                    <button\n                      type=\"button\"\n                      class=\"text-white/90 transition-colors hover:text-white cursor-pointer\"\n                      aria-label=${this.isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}\n                      @click=${() => this.toggleFullscreen()}\n                    >\n                      ${this.isFullscreen ? icon(Minimize, 'minimize', 'size-4') : icon(Maximize, 'maximize', 'size-4')}\n                    </button>\n                  </div>\n                </div>\n              </div>\n            `\n          : nothing}\n      </div>\n    `\n  }\n}\n\ncustomElements.get('uip-video') || customElements.define('uip-video', UipVideo)\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'uip-video': UipVideo\n  }\n}\n",
      "type": "registry:file",
      "target": "src/uip/components/video/video.ts"
    }
  ],
  "dependencies": [
    "lit",
    "lucide"
  ],
  "registryDependencies": [
    "https://uipkge.dev/r/lit/lit-core.json"
  ]
}
