{
  "$schema": "https://shadcn-vue.com/schema/registry-item.json",
  "name": "audio-waveform-transcript",
  "title": "Audio Waveform Transcript",
  "type": "registry:block",
  "files": [
    {
      "path": "packages/registry-vue/blocks/audio-waveform-transcript/AudioWaveformTranscript.vue",
      "content": "<script setup lang=\"ts\">\nimport type { HTMLAttributes } from 'vue'\nimport { computed, onUnmounted, ref, watch } from 'vue'\nimport {\n  Check,\n  ChevronDown,\n  Clock,\n  Copy,\n  Download,\n  FileCode2,\n  FileText,\n  Link,\n  Mic,\n  Pause,\n  Play,\n  Quote,\n  Radio,\n  RotateCcw,\n  RotateCw,\n  Search,\n  Volume2,\n  VolumeX,\n  X,\n} from 'lucide-vue-next'\nimport { cn } from '@/lib/utils'\nimport { Avatar, AvatarFallback } from '@/components/ui/avatar'\nimport { Badge } from '@/components/ui/badge'\nimport { Button } from '@/components/ui/button'\nimport { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'\nimport { Input } from '@/components/ui/input'\nimport { Separator } from '@/components/ui/separator'\n\ninterface Props {\n  class?: HTMLAttributes['class']\n}\n\nconst props = defineProps<Props>()\n\nexport interface SpeakerTurn {\n  id: string\n  speakerId: 'elena' | 'marcus'\n  speakerName: string\n  speakerRole: 'Host' | 'Guest'\n  speakerHandle: string\n  avatarFallback: string\n  startTime: number // in seconds\n  endTime: number // in seconds\n  timecode: string // e.g. '00:15'\n  duration: string // e.g. '04:07'\n  text: string\n}\n\nexport interface SpeakerProfile {\n  id: 'elena' | 'marcus'\n  name: string\n  role: 'Host' | 'Guest'\n  handle: string\n  avatarFallback: string\n  ratioPercent: number\n  totalSpeakingTime: string\n  totalSeconds: number\n  turnsCount: number\n  wordCount: number\n  wpm: number\n  accentColor: string\n}\n\nconst TOTAL_DURATION_SECONDS = 2912 // 48:32\n\nconst speakers: Record<'elena' | 'marcus', SpeakerProfile> = {\n  elena: {\n    id: 'elena',\n    name: 'Elena Rostova',\n    role: 'Host',\n    handle: '@erostova',\n    avatarFallback: 'ER',\n    ratioPercent: 45,\n    totalSpeakingTime: '21:50',\n    totalSeconds: 1310,\n    turnsCount: 3,\n    wordCount: 4120,\n    wpm: 142,\n    accentColor: 'bg-primary text-primary-foreground',\n  },\n  marcus: {\n    id: 'marcus',\n    name: 'Marcus Vance',\n    role: 'Guest',\n    handle: '@marcusvance',\n    avatarFallback: 'MV',\n    ratioPercent: 55,\n    totalSpeakingTime: '26:42',\n    totalSeconds: 1602,\n    turnsCount: 2,\n    wordCount: 4860,\n    wpm: 156,\n    accentColor: 'bg-info text-info-foreground',\n  },\n}\n\nconst turns: SpeakerTurn[] = [\n  {\n    id: 'turn-1',\n    speakerId: 'elena',\n    speakerName: 'Elena Rostova',\n    speakerRole: 'Host',\n    speakerHandle: '@erostova',\n    avatarFallback: 'ER',\n    startTime: 15,\n    endTime: 262,\n    timecode: '00:15',\n    duration: '04:07',\n    text: 'Welcome back to the Design Systems Podcast. Today we are diving into one of the most consequential shifts happening in frontend architecture: moving away from monolithic UI component libraries published on npm toward unbundled, copy-and-paste registries where developers own the code. I am joined by Marcus Vance, principal architect at Component Lab. Marcus, when you look at teams maintaining enterprise design systems today, why has the traditional npm package model started to show its limits?',\n  },\n  {\n    id: 'turn-2',\n    speakerId: 'marcus',\n    speakerName: 'Marcus Vance',\n    speakerRole: 'Guest',\n    speakerHandle: '@marcusvance',\n    avatarFallback: 'MV',\n    startTime: 262,\n    endTime: 765,\n    timecode: '04:22',\n    duration: '08:23',\n    text: 'Thanks for having me, Elena. The fundamental challenge with versioned npm component libraries is the tight coupling between visual tokens, DOM accessibility primitives, and release cycles. When a consumer app needs a minor adjustment to an accessibility attribute or a custom animation spring, they end up blocked on a semver release or forced into fragile CSS overrides. With the registry model pioneered by shadcn, components are delivered as transparent source code. You get the benefit of world-class headless defaults—like Reka UI and Radix—while retaining complete ownership to tailor and extend the implementation directly inside your repository.',\n  },\n  {\n    id: 'turn-3',\n    speakerId: 'elena',\n    speakerName: 'Elena Rostova',\n    speakerRole: 'Host',\n    speakerHandle: '@erostova',\n    avatarFallback: 'ER',\n    startTime: 765,\n    endTime: 1450,\n    timecode: '12:45',\n    duration: '11:25',\n    text: 'That distinction between primitives and blocks is so critical. In our work, we insist that primitives like Button, Card, and Dialog strictly encapsulate mechanics—focus trapping, keyboard navigation, and theme tokens—while blocks compose those primitives into layouts top-to-bottom without baking in rigid schemas. What is your take on multi-framework parity? How should teams bridge the gap when supporting both Vue and React without duplicating tokens or drift in visual behavior?',\n  },\n  {\n    id: 'turn-4',\n    speakerId: 'marcus',\n    speakerName: 'Marcus Vance',\n    speakerRole: 'Guest',\n    speakerHandle: '@marcusvance',\n    avatarFallback: 'MV',\n    startTime: 1450,\n    endTime: 2295,\n    timecode: '24:10',\n    duration: '14:05',\n    text: 'Single source of truth at the CSS and token layer is non-negotiable. If you define your OKLCH color palettes, semantic typography scales, and spring easing curves in standard Tailwind tokens, both the Vue SFC and React JSX implementations consume the identical styling contracts. Headless primitives in Vue via Reka UI and in React via Radix share nearly 1:1 state machines. When the code is unbundled, automated visual regression tools can diff both frameworks side-by-side on every commit, ensuring zero drift.',\n  },\n  {\n    id: 'turn-5',\n    speakerId: 'elena',\n    speakerName: 'Elena Rostova',\n    speakerRole: 'Host',\n    speakerHandle: '@erostova',\n    avatarFallback: 'ER',\n    startTime: 2295,\n    endTime: 2912,\n    timecode: '38:15',\n    duration: '10:17',\n    text: 'That clarity is empowering. For engineering teams looking to adopt this architecture, we have published the complete registry manifests, storybook-grade island demos, and cross-framework components at uipkge.dev. Marcus, thank you so much for joining us and breaking down the future of modern component engineering. Check the episode description below for the full transcript files, timecode markers, and code examples.',\n  },\n]\n\n// 72-bar simulated waveform amplitude dataset\nconst waveformBars = [\n  28, 42, 60, 82, 68, 45, 32, 54, 76, 92, 98, 75, 50, 35, 58, 70, 88, 96, 90, 64, 48, 62, 80, 94, 86, 55, 40, 66, 84,\n  98, 100, 88, 62, 42, 54, 76, 90, 94, 80, 58, 45, 68, 85, 96, 90, 70, 52, 36, 56, 78, 90, 92, 82, 60, 48, 70, 88, 96,\n  90, 72, 50, 35, 52, 70, 86, 92, 80, 58, 42, 30, 22, 16,\n]\n\n// Player and interactive states\nconst currentTime = ref(262) // starts at 04:22\nconst isPlaying = ref(false)\nconst playbackSpeed = ref<1 | 1.25 | 1.5 | 2>(1)\nconst isMuted = ref(false)\nconst searchQuery = ref('')\nconst selectedSpeakerFilter = ref<'all' | 'elena' | 'marcus'>('all')\nconst onlyMatchingTurns = ref(false)\nconst copiedLinkId = ref<string | null>(null)\nconst copiedQuoteId = ref<string | null>(null)\nconst toastMessage = ref<string | null>(null)\nconst isExportMenuOpen = ref(false)\nconst hoverTimecode = ref<string | null>(null)\nconst hoverPositionPercent = ref<number | null>(null)\n\nlet playInterval: ReturnType<typeof setInterval> | null = null\nlet toastTimeout: ReturnType<typeof setTimeout> | null = null\n\nfunction formatTime(seconds: number): string {\n  const mins = Math.floor(seconds / 60)\n  const secs = Math.floor(seconds % 60)\n  return `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`\n}\n\nfunction showToast(msg: string) {\n  toastMessage.value = msg\n  if (toastTimeout) clearTimeout(toastTimeout)\n  toastTimeout = setTimeout(() => {\n    toastMessage.value = null\n  }, 2500)\n}\n\nfunction togglePlay() {\n  isPlaying.value = !isPlaying.value\n}\n\nfunction seekTo(seconds: number) {\n  currentTime.value = Math.max(0, Math.min(TOTAL_DURATION_SECONDS, seconds))\n}\n\nfunction skip(deltaSeconds: number) {\n  seekTo(currentTime.value + deltaSeconds)\n}\n\nfunction setSpeed(speed: 1 | 1.25 | 1.5 | 2) {\n  playbackSpeed.value = speed\n}\n\nfunction toggleMute() {\n  isMuted.value = !isMuted.value\n}\n\n// Active turn computed based on current playback time\nconst activeTurnId = computed(() => {\n  const current = turns.find((t) => currentTime.value >= t.startTime && currentTime.value < t.endTime)\n  return current ? current.id : turns[0].id\n})\n\n// Highlight text helper\ninterface TextSegment {\n  text: string\n  match: boolean\n}\n\nfunction highlightSegments(text: string, query: string): TextSegment[] {\n  const trimmed = query.trim()\n  if (!trimmed) return [{ text, match: false }]\n  const escaped = trimmed.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n  const regex = new RegExp(`(${escaped})`, 'gi')\n  const parts = text.split(regex)\n  return parts.map((part) => ({\n    text: part,\n    match: part.toLowerCase() === trimmed.toLowerCase(),\n  }))\n}\n\n// Total search matches count across all turns\nconst totalSearchMatches = computed(() => {\n  const query = searchQuery.value.trim().toLowerCase()\n  if (!query) return 0\n  let count = 0\n  turns.forEach((turn) => {\n    const text = turn.text.toLowerCase()\n    let pos = 0\n    while ((pos = text.indexOf(query, pos)) !== -1) {\n      count++\n      pos += query.length\n    }\n  })\n  return count\n})\n\n// Filtered turns list\nconst displayedTurns = computed(() => {\n  return turns.filter((turn) => {\n    if (selectedSpeakerFilter.value !== 'all' && turn.speakerId !== selectedSpeakerFilter.value) {\n      return false\n    }\n    if (onlyMatchingTurns.value && searchQuery.value.trim()) {\n      return turn.text.toLowerCase().includes(searchQuery.value.trim().toLowerCase())\n    }\n    return true\n  })\n})\n\n// Copy timestamp link\nasync function copyTimestampLink(turn: SpeakerTurn) {\n  const link = `https://uipkge.dev/podcast/ep48#t=${turn.timecode}`\n  try {\n    await navigator.clipboard.writeText(link)\n    copiedLinkId.value = turn.id\n    showToast(`Copied timecode link for ${turn.timecode}`)\n    setTimeout(() => {\n      if (copiedLinkId.value === turn.id) copiedLinkId.value = null\n    }, 2000)\n  } catch {\n    showToast(`Timecode: ${link}`)\n  }\n}\n\n// Quote snippet\nasync function copyQuoteSnippet(turn: SpeakerTurn) {\n  const quote = `\"[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}): ${turn.text}\"`\n  try {\n    await navigator.clipboard.writeText(quote)\n    copiedQuoteId.value = turn.id\n    showToast(`Quote from ${turn.speakerName} copied to clipboard`)\n    setTimeout(() => {\n      if (copiedQuoteId.value === turn.id) copiedQuoteId.value = null\n    }, 2000)\n  } catch {\n    showToast('Quote snippet copied')\n  }\n}\n\n// Export VTT\nfunction downloadVtt() {\n  let vttContent = `WEBVTT - Design Systems Podcast · Episode 48 Transcript\\n\\n`\n  turns.forEach((turn, idx) => {\n    const startHour = '00:' + turn.timecode + '.000'\n    const endMins = Math.floor(turn.endTime / 60)\n    const endSecs = Math.floor(turn.endTime % 60)\n    const endTimecode = `00:${String(endMins).padStart(2, '0')}:${String(endSecs).padStart(2, '0')}.000`\n\n    vttContent += `${idx + 1}\\n`\n    vttContent += `${startHour} --> ${endTimecode}\\n`\n    vttContent += `<v ${turn.speakerName}>${turn.text}\\n\\n`\n  })\n\n  const blob = new Blob([vttContent], { type: 'text/vtt;charset=utf-8;' })\n  const url = URL.createObjectURL(blob)\n  const a = document.createElement('a')\n  a.href = url\n  a.download = 'design-systems-podcast-ep48.vtt'\n  a.click()\n  URL.revokeObjectURL(url)\n  showToast('Downloaded WebVTT transcript file')\n  isExportMenuOpen.value = false\n}\n\n// Export TXT\nfunction downloadTxt() {\n  let txtContent = `Design Systems Podcast · Episode 48 Transcript\\n`\n  txtContent += `Total Duration: 48:32 | Elena Rostova (Host) · Marcus Vance (Guest)\\n\\n`\n  txtContent += `============================================================\\n\\n`\n\n  turns.forEach((turn) => {\n    txtContent += `[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}):\\n`\n    txtContent += `${turn.text}\\n\\n`\n  })\n\n  const blob = new Blob([txtContent], { type: 'text/plain;charset=utf-8;' })\n  const url = URL.createObjectURL(blob)\n  const a = document.createElement('a')\n  a.href = url\n  a.download = 'design-systems-podcast-ep48.txt'\n  a.click()\n  URL.revokeObjectURL(url)\n  showToast('Downloaded Plain Text transcript file')\n  isExportMenuOpen.value = false\n}\n\n// Copy full transcript to clipboard\nasync function copyFullTranscript() {\n  let fullTxt = `Design Systems Podcast · Episode 48 Transcript (48:32)\\n\\n`\n  turns.forEach((turn) => {\n    fullTxt += `[${turn.timecode}] ${turn.speakerName} (${turn.speakerRole}):\\n${turn.text}\\n\\n`\n  })\n  try {\n    await navigator.clipboard.writeText(fullTxt)\n    showToast('Full transcript copied to clipboard')\n  } catch {\n    showToast('Transcript copied')\n  }\n  isExportMenuOpen.value = false\n}\n\nfunction playTurn(startTime: number) {\n  seekTo(startTime)\n  isPlaying.value = true\n}\n\nfunction resetFilters() {\n  searchQuery.value = ''\n  selectedSpeakerFilter.value = 'all'\n}\n\n// Waveform interaction\nfunction handleWaveformClick(e: MouseEvent) {\n  const target = e.currentTarget as HTMLElement\n  const rect = target.getBoundingClientRect()\n  const clickX = e.clientX - rect.left\n  const ratio = Math.max(0, Math.min(1, clickX / rect.width))\n  seekTo(ratio * TOTAL_DURATION_SECONDS)\n}\n\nfunction handleWaveformMouseMove(e: MouseEvent) {\n  const target = e.currentTarget as HTMLElement\n  const rect = target.getBoundingClientRect()\n  const mouseX = e.clientX - rect.left\n  const ratio = Math.max(0, Math.min(1, mouseX / rect.width))\n  hoverPositionPercent.value = ratio * 100\n  hoverTimecode.value = formatTime(ratio * TOTAL_DURATION_SECONDS)\n}\n\nfunction handleWaveformMouseLeave() {\n  hoverPositionPercent.value = null\n  hoverTimecode.value = null\n}\n\n// Playback interval management\nwatch([isPlaying, playbackSpeed], ([playing, speed]) => {\n  if (playInterval) {\n    clearInterval(playInterval)\n    playInterval = null\n  }\n  if (playing) {\n    const intervalTime = 1000 / speed\n    playInterval = setInterval(() => {\n      if (currentTime.value >= TOTAL_DURATION_SECONDS) {\n        currentTime.value = TOTAL_DURATION_SECONDS\n        isPlaying.value = false\n      } else {\n        currentTime.value += 1\n      }\n    }, intervalTime)\n  }\n})\n\nonUnmounted(() => {\n  if (playInterval) clearInterval(playInterval)\n  if (toastTimeout) clearTimeout(toastTimeout)\n})\n</script>\n\n<template>\n  <div data-slot=\"audio-waveform-transcript\" :class=\"cn('text-foreground w-full space-y-6', props.class)\">\n    <!-- Top Audio Bar Card -->\n    <Card class=\"border-border bg-card overflow-hidden shadow-xs\">\n      <CardHeader class=\"pb-4\">\n        <div class=\"flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between\">\n          <div class=\"space-y-1\">\n            <div class=\"flex flex-wrap items-center gap-2\">\n              <Badge wrap variant=\"secondary\" class=\"gap-1 font-mono text-xs\">\n                <Radio class=\"text-primary size-3 animate-pulse\" />\n                Episode 48\n              </Badge>\n              <Badge wrap variant=\"outline\" class=\"text-muted-foreground text-xs\"> Audio Transcript </Badge>\n              <span class=\"text-muted-foreground font-mono text-xs tabular-nums\"> MP3 · 320 kbps · Stereo </span>\n            </div>\n            <CardTitle class=\"text-xl font-bold tracking-tight sm:text-2xl\">\n              Design Systems Podcast · Episode 48 Transcript\n            </CardTitle>\n            <CardDescription class=\"text-muted-foreground text-xs sm:text-sm\">\n              Elena Rostova with Marcus Vance on unbundled registries, token architecture, and headless Vue & React\n              parity.\n            </CardDescription>\n          </div>\n\n          <!-- Export Transcript Menu Action -->\n          <div class=\"relative shrink-0\">\n            <Button\n              aria-label=\"Download attachment\"\n              variant=\"outline\"\n              size=\"sm\"\n              class=\"gap-1.5 font-medium shadow-xs\"\n              @click=\"isExportMenuOpen = !isExportMenuOpen\"\n            >\n              <Download class=\"text-muted-foreground size-3.5\" />\n              <span>Export Transcript</span>\n              <ChevronDown class=\"text-muted-foreground size-3\" />\n            </Button>\n\n            <!-- Export Dropdown Menu Modal -->\n            <div\n              v-if=\"isExportMenuOpen\"\n              class=\"border-border bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 absolute top-full right-0 z-50 mt-2 w-56 rounded-lg border p-1 shadow-md\"\n            >\n              <div class=\"text-muted-foreground px-2 py-1.5 text-xs font-semibold\">Download Formats</div>\n              <button\n                type=\"button\"\n                class=\"hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs\"\n                @click=\"downloadVtt\"\n              >\n                <FileCode2 class=\"text-primary size-3.5\" />\n                <div>\n                  <div class=\"font-medium\">Export as .VTT</div>\n                  <div class=\"text-muted-foreground text-xs\">WebVTT subtitle captions</div>\n                </div>\n              </button>\n              <button\n                type=\"button\"\n                class=\"hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs\"\n                @click=\"downloadTxt\"\n              >\n                <FileText class=\"text-muted-foreground size-3.5\" />\n                <div>\n                  <div class=\"font-medium\">Export as .TXT</div>\n                  <div class=\"text-muted-foreground text-xs\">Plain text with timestamps</div>\n                </div>\n              </button>\n              <Separator class=\"my-1\" />\n              <button\n                type=\"button\"\n                class=\"hover:bg-accent hover:text-accent-foreground flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs\"\n                @click=\"copyFullTranscript\"\n              >\n                <Copy class=\"text-muted-foreground size-3.5\" />\n                <span class=\"font-medium\">Copy Full Text to Clipboard</span>\n              </button>\n            </div>\n          </div>\n        </div>\n      </CardHeader>\n\n      <CardContent class=\"space-y-4\">\n        <!-- Interactive Waveform Scrubber -->\n        <div class=\"space-y-2\">\n          <div class=\"text-muted-foreground flex items-center justify-between gap-x-2 font-mono text-xs tabular-nums\">\n            <span class=\"text-foreground font-semibold\">{{ formatTime(currentTime) }}</span>\n            <span class=\"text-xs\">{{ formatTime(TOTAL_DURATION_SECONDS) }}</span>\n          </div>\n\n          <div\n            class=\"group bg-muted/40 border-border/60 hover:bg-muted/60 focus-visible:ring-ring relative h-16 w-full cursor-pointer rounded-lg border p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n            role=\"slider\"\n            tabindex=\"0\"\n            aria-label=\"Audio scrubber\"\n            :aria-valuenow=\"currentTime\"\n            aria-valuemin=\"0\"\n            :aria-valuemax=\"TOTAL_DURATION_SECONDS\"\n            @click=\"handleWaveformClick\"\n            @mousemove=\"handleWaveformMouseMove\"\n            @mouseleave=\"handleWaveformMouseLeave\"\n            @keydown.left.prevent=\"skip(-5)\"\n            @keydown.right.prevent=\"skip(5)\"\n            @keydown.home.prevent=\"seekTo(0)\"\n            @keydown.end.prevent=\"seekTo(TOTAL_DURATION_SECONDS)\"\n          >\n            <!-- Hover timestamp pill indicator -->\n            <div\n              v-if=\"hoverPositionPercent !== null && hoverTimecode !== null\"\n              class=\"bg-foreground text-background pointer-events-none absolute -top-7 z-20 -translate-x-1/2 rounded px-1.5 py-0.5 font-mono text-xs tabular-nums shadow-xs\"\n              :style=\"{ left: `${hoverPositionPercent}%` }\"\n            >\n              {{ hoverTimecode }}\n            </div>\n\n            <!-- Waveform bars -->\n            <div class=\"flex h-full w-full items-center justify-between gap-[2px]\">\n              <div\n                v-for=\"(amplitude, idx) in waveformBars\"\n                :key=\"idx\"\n                class=\"flex-1 rounded-full transition-colors duration-100\"\n                :style=\"{ height: `${Math.max(12, amplitude)}%` }\"\n                :class=\"[\n                  (idx / waveformBars.length) * TOTAL_DURATION_SECONDS <= currentTime\n                    ? 'bg-primary'\n                    : 'bg-muted-foreground/30 group-hover:bg-muted-foreground/45',\n                ]\"\n              />\n            </div>\n\n            <!-- Playhead cursor line -->\n            <div\n              class=\"bg-foreground pointer-events-none absolute top-0 bottom-0 w-0.5 shadow-xs\"\n              :style=\"{ left: `${(currentTime / TOTAL_DURATION_SECONDS) * 100}%` }\"\n            >\n              <div class=\"bg-foreground absolute -top-1 -left-1 size-2.5 rounded-full\" />\n            </div>\n\n            <!-- Speaker distribution ribbon beneath waveform -->\n            <div class=\"absolute right-0 -bottom-1 left-0 flex h-1 overflow-hidden rounded-b\">\n              <div class=\"bg-primary/70 w-[8.9%]\" title=\"Elena (00:15 - 04:22)\" />\n              <div class=\"bg-info/70 w-[17.3%]\" title=\"Marcus (04:22 - 12:45)\" />\n              <div class=\"bg-primary/70 w-[23.6%]\" title=\"Elena (12:45 - 24:10)\" />\n              <div class=\"bg-info/70 w-[29.0%]\" title=\"Marcus (24:10 - 38:15)\" />\n              <div class=\"bg-primary/70 w-[21.2%]\" title=\"Elena (38:15 - 48:32)\" />\n            </div>\n          </div>\n        </div>\n\n        <!-- Audio Player Controls Bar -->\n        <div class=\"flex flex-wrap items-center justify-between gap-3 pt-1\">\n          <!-- Playback buttons -->\n          <div class=\"flex flex-wrap items-center gap-2\">\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              title=\"Rewind 15 seconds\"\n              @click=\"skip(-15)\"\n              aria-label=\"Action\"\n            >\n              <RotateCcw class=\"size-4\" />\n            </Button>\n\n            <Button\n              size=\"icon\"\n              variant=\"default\"\n              class=\"size-10 rounded-full shadow-sm\"\n              :title=\"isPlaying ? 'Pause audio' : 'Play audio'\"\n              @click=\"togglePlay\"\n            >\n              <Pause v-if=\"isPlaying\" class=\"size-5\" />\n              <Play v-else class=\"ml-0.5 size-5 fill-current\" />\n            </Button>\n\n            <Button\n              variant=\"outline\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              title=\"Skip forward 15 seconds\"\n              @click=\"skip(15)\"\n              aria-label=\"Rotate\"\n            >\n              <RotateCw class=\"size-4\" />\n            </Button>\n\n            <div class=\"text-muted-foreground ml-2 hidden items-center gap-1.5 font-mono text-xs tabular-nums sm:flex\">\n              <Clock class=\"text-muted-foreground size-3.5\" />\n              <span>{{ formatTime(currentTime) }} / {{ formatTime(TOTAL_DURATION_SECONDS) }}</span>\n            </div>\n          </div>\n\n          <!-- Speed and Volume controls -->\n          <div class=\"flex flex-wrap items-center gap-2\">\n            <!-- Speed Toggles -->\n            <div class=\"border-border bg-muted/30 flex items-center rounded-lg border p-0.5 text-xs\">\n              <button\n                v-for=\"speed in [1, 1.25, 1.5, 2]\"\n                :key=\"speed\"\n                type=\"button\"\n                class=\"cursor-pointer rounded px-2 py-1 font-mono font-medium transition-colors\"\n                :class=\"[\n                  playbackSpeed === speed\n                    ? 'bg-background text-foreground font-semibold shadow-xs'\n                    : 'text-muted-foreground hover:text-foreground',\n                ]\"\n                @click=\"setSpeed(speed)\"\n              >\n                {{ speed }}x\n              </button>\n            </div>\n\n            <!-- Volume Mute Button -->\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-8\"\n              :title=\"isMuted ? 'Unmute' : 'Mute'\"\n              @click=\"toggleMute\"\n            >\n              <VolumeX v-if=\"isMuted\" class=\"text-destructive size-4\" />\n              <Volume2 v-else class=\"size-4\" />\n            </Button>\n          </div>\n        </div>\n      </CardContent>\n    </Card>\n\n    <!-- Speaker Talking Time Ratio Bar Card -->\n    <Card class=\"border-border bg-card shadow-xs\">\n      <CardHeader class=\"pb-3\">\n        <div class=\"flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between\">\n          <div class=\"flex flex-wrap items-center gap-2\">\n            <Mic class=\"text-primary size-4\" />\n            <CardTitle class=\"text-base font-semibold\">Speaker Diarization & Talking Time</CardTitle>\n          </div>\n          <div class=\"text-muted-foreground text-xs\">2 speakers identified · 48m 32s total recorded speech</div>\n        </div>\n      </CardHeader>\n\n      <CardContent class=\"space-y-4\">\n        <!-- Segmented Ratio Bar -->\n        <div class=\"space-y-1.5\">\n          <div class=\"flex justify-between font-mono text-xs tabular-nums\">\n            <span class=\"text-primary flex items-center gap-1 font-medium\">\n              <span class=\"bg-primary inline-block size-2 rounded-full\" />\n              Elena Rostova (Host): 45% (21:50)\n            </span>\n            <span class=\"text-info flex items-center gap-1 font-medium\">\n              Marcus Vance (Guest): 55% (26:42)\n              <span class=\"bg-info inline-block size-2 rounded-full\" />\n            </span>\n          </div>\n\n          <div class=\"bg-muted/60 border-border/40 flex h-3 w-full gap-0.5 overflow-hidden rounded-full border p-0.5\">\n            <div\n              class=\"bg-primary h-full rounded-l-full transition-[width] duration-300\"\n              style=\"width: 45%\"\n              title=\"Elena Rostova: 45% talking time\"\n            />\n            <div\n              class=\"bg-info h-full rounded-r-full transition-[width] duration-300\"\n              style=\"width: 55%\"\n              title=\"Marcus Vance: 55% talking time\"\n            />\n          </div>\n        </div>\n\n        <!-- Speaker Metadata Cards Grid -->\n        <div class=\"grid grid-cols-1 gap-3 pt-1 md:grid-cols-2\">\n          <!-- Elena Profile -->\n          <button\n            type=\"button\"\n            class=\"focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n            :class=\"[\n              selectedSpeakerFilter === 'elena'\n                ? 'border-primary bg-primary/5 ring-primary/30 ring-1'\n                : 'border-border bg-muted/20 hover:bg-muted/40',\n            ]\"\n            :aria-pressed=\"selectedSpeakerFilter === 'elena'\"\n            @click=\"selectedSpeakerFilter = selectedSpeakerFilter === 'elena' ? 'all' : 'elena'\"\n          >\n            <div class=\"flex items-center gap-3\">\n              <Avatar class=\"border-border size-9 border\">\n                <AvatarFallback class=\"bg-primary/10 text-primary text-xs font-semibold\">\n                  {{ speakers.elena.avatarFallback }}\n                </AvatarFallback>\n              </Avatar>\n              <div>\n                <div class=\"flex items-center gap-1.5\">\n                  <span class=\"text-sm font-semibold\">{{ speakers.elena.name }}</span>\n                  <Badge wrap variant=\"default\" class=\"px-1.5 py-0 text-xs\">Host</Badge>\n                </div>\n                <div class=\"text-muted-foreground text-xs\">{{ speakers.elena.handle }}</div>\n              </div>\n            </div>\n            <div class=\"space-y-0.5 text-right font-mono text-xs tabular-nums\">\n              <div class=\"text-foreground font-semibold\">{{ speakers.elena.totalSpeakingTime }}</div>\n              <div class=\"text-muted-foreground text-xs\">\n                {{ speakers.elena.wordCount.toLocaleString() }} words · {{ speakers.elena.wpm }} wpm\n              </div>\n            </div>\n          </button>\n\n          <!-- Marcus Profile -->\n          <button\n            type=\"button\"\n            class=\"focus-visible:ring-ring flex w-full cursor-pointer items-center justify-between rounded-lg border p-3 text-left transition-colors focus-visible:ring-2 focus-visible:outline-none\"\n            :class=\"[\n              selectedSpeakerFilter === 'marcus'\n                ? 'border-info bg-info/5 ring-info/30 ring-1'\n                : 'border-border bg-muted/20 hover:bg-muted/40',\n            ]\"\n            :aria-pressed=\"selectedSpeakerFilter === 'marcus'\"\n            @click=\"selectedSpeakerFilter = selectedSpeakerFilter === 'marcus' ? 'all' : 'marcus'\"\n          >\n            <div class=\"flex items-center gap-3\">\n              <Avatar class=\"border-border size-9 border\">\n                <AvatarFallback class=\"bg-info/10 text-info text-xs font-semibold\">\n                  {{ speakers.marcus.avatarFallback }}\n                </AvatarFallback>\n              </Avatar>\n              <div>\n                <div class=\"flex items-center gap-1.5\">\n                  <span class=\"text-sm font-semibold\">{{ speakers.marcus.name }}</span>\n                  <Badge wrap variant=\"secondary\" class=\"bg-info/10 text-info px-1.5 py-0 text-xs\"> Guest </Badge>\n                </div>\n                <div class=\"text-muted-foreground text-xs\">{{ speakers.marcus.handle }}</div>\n              </div>\n            </div>\n            <div class=\"space-y-0.5 text-right font-mono text-xs tabular-nums\">\n              <div class=\"text-foreground font-semibold\">{{ speakers.marcus.totalSpeakingTime }}</div>\n              <div class=\"text-muted-foreground text-xs\">\n                {{ speakers.marcus.wordCount.toLocaleString() }} words · {{ speakers.marcus.wpm }} wpm\n              </div>\n            </div>\n          </button>\n        </div>\n      </CardContent>\n    </Card>\n\n    <!-- Transcript Search and Filter Controls -->\n    <div class=\"flex flex-col items-stretch justify-between gap-3 sm:flex-row sm:items-center\">\n      <!-- Search input -->\n      <div class=\"relative max-w-md flex-1\">\n        <Search class=\"text-muted-foreground pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2\" />\n        <Input\n          v-model=\"searchQuery\"\n          type=\"text\"\n          placeholder=\"Search keywords in transcript (e.g. registry, token, Reka UI)...\"\n          class=\"bg-card border-border pr-20 pl-9 text-xs shadow-xs sm:text-sm\"\n        />\n        <div class=\"absolute top-1/2 right-2.5 flex -translate-y-1/2 items-center gap-1\">\n          <span\n            v-if=\"searchQuery.trim()\"\n            class=\"bg-muted text-muted-foreground rounded px-1.5 py-0.5 font-mono text-xs tabular-nums\"\n          >\n            {{ totalSearchMatches }} {{ totalSearchMatches === 1 ? 'match' : 'matches' }}\n          </span>\n          <button\n            v-if=\"searchQuery.trim()\"\n            type=\"button\"\n            class=\"text-muted-foreground hover:text-foreground cursor-pointer rounded p-0.5\"\n            title=\"Clear search\"\n            @click=\"searchQuery = ''\"\n          >\n            <X class=\"size-3.5\" />\n          </button>\n        </div>\n      </div>\n\n      <!-- Speaker Filter Pills -->\n      <div class=\"flex items-center gap-1.5 overflow-x-auto pb-1 sm:pb-0\">\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"h-8 cursor-pointer text-xs\"\n          :class=\"[\n            selectedSpeakerFilter === 'all'\n              ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'\n              : '',\n          ]\"\n          @click=\"selectedSpeakerFilter = 'all'\"\n        >\n          All Speakers (5)\n        </Button>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"h-8 cursor-pointer text-xs\"\n          :class=\"[\n            selectedSpeakerFilter === 'elena'\n              ? 'bg-primary text-primary-foreground border-primary hover:bg-primary/90'\n              : '',\n          ]\"\n          @click=\"selectedSpeakerFilter = 'elena'\"\n        >\n          Elena Rostova · Host (3)\n        </Button>\n        <Button\n          variant=\"outline\"\n          size=\"sm\"\n          class=\"h-8 cursor-pointer text-xs\"\n          :class=\"[\n            selectedSpeakerFilter === 'marcus' ? 'border-info bg-info text-info-foreground hover:bg-info/90' : '',\n          ]\"\n          @click=\"selectedSpeakerFilter = 'marcus'\"\n        >\n          Marcus Vance · Guest (2)\n        </Button>\n      </div>\n    </div>\n\n    <!-- Active Search Filter Summary if active -->\n    <div\n      v-if=\"searchQuery.trim()\"\n      class=\"border-warning/30 bg-warning/10 text-warning flex items-center justify-between gap-x-2 rounded-lg border p-2.5 text-xs\"\n    >\n      <div class=\"flex flex-wrap items-center gap-2\">\n        <Search class=\"text-warning size-4\" />\n        <span>\n          Found <strong>{{ totalSearchMatches }}</strong> occurrences of \"<strong>{{ searchQuery }}</strong\n          >\" across transcript\n        </span>\n      </div>\n      <button\n        aria-label=\"Clear search\"\n        type=\"button\"\n        class=\"cursor-pointer text-xs font-semibold underline underline-offset-2 hover:opacity-80\"\n        @click=\"searchQuery = ''\"\n      >\n        Clear filter\n      </button>\n    </div>\n\n    <!-- Interactive Speech-to-Text Transcript Feed -->\n    <div class=\"space-y-4\">\n      <div\n        v-for=\"turn in displayedTurns\"\n        :key=\"turn.id\"\n        :id=\"turn.id\"\n        class=\"group relative rounded-xl border p-4 transition-colors duration-200 sm:p-5\"\n        :class=\"[\n          activeTurnId === turn.id\n            ? 'border-primary bg-primary/[0.03] dark:bg-primary/[0.06] ring-primary/30 shadow-sm ring-1'\n            : 'border-border bg-card hover:border-border/80 shadow-xs',\n        ]\"\n      >\n        <!-- Top Turn Header -->\n        <div class=\"mb-3 flex flex-wrap items-center justify-between gap-2\">\n          <!-- Speaker Info -->\n          <div class=\"flex items-center gap-3\">\n            <Avatar class=\"border-border/80 size-8 border sm:size-9\">\n              <AvatarFallback\n                class=\"text-xs font-semibold\"\n                :class=\"[turn.speakerId === 'elena' ? 'bg-primary/10 text-primary' : 'bg-info/10 text-info']\"\n              >\n                {{ turn.avatarFallback }}\n              </AvatarFallback>\n            </Avatar>\n\n            <div class=\"flex flex-wrap items-center gap-2\">\n              <span class=\"text-foreground text-sm font-semibold\">{{ turn.speakerName }}</span>\n              <Badge\n                :variant=\"turn.speakerRole === 'Host' ? 'default' : 'secondary'\"\n                class=\"px-2 py-0 text-xs whitespace-normal\"\n                :class=\"[turn.speakerRole === 'Guest' ? 'bg-info/10 text-info border-transparent' : '']\"\n              >\n                {{ turn.speakerRole }}\n              </Badge>\n\n              <!-- Active Turn Badge -->\n              <span\n                v-if=\"activeTurnId === turn.id\"\n                class=\"bg-primary/10 text-primary inline-flex items-center gap-1 rounded-full px-2 py-0.5 font-mono text-xs font-medium\"\n              >\n                <span class=\"bg-primary size-1.5 rounded-full\" />\n                NOW PLAYING\n              </span>\n            </div>\n          </div>\n\n          <!-- Timecode Pill & Turn Actions -->\n          <div class=\"flex items-center gap-1.5 sm:gap-2\">\n            <!-- Clickable Timecode Pill -->\n            <Button\n              variant=\"outline\"\n              size=\"sm\"\n              class=\"text-muted-foreground hover:text-foreground hover:border-primary h-7 cursor-pointer gap-1 px-2 font-mono text-xs tabular-nums\"\n              title=\"Seek audio to this timestamp\"\n              @click=\"playTurn(turn.startTime)\"\n            >\n              <Clock class=\"text-primary size-3\" />\n              <span>[{{ turn.timecode }}]</span>\n            </Button>\n\n            <!-- Copy Timestamp Link -->\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-7\"\n              title=\"Copy timestamp link\"\n              @click=\"copyTimestampLink(turn)\"\n            >\n              <Check v-if=\"copiedLinkId === turn.id\" class=\"text-success size-3.5\" />\n              <Link v-else class=\"size-3.5\" />\n            </Button>\n\n            <!-- Quote Snippet Button -->\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-foreground size-7\"\n              title=\"Quote this turn\"\n              @click=\"copyQuoteSnippet(turn)\"\n            >\n              <Check v-if=\"copiedQuoteId === turn.id\" class=\"text-success size-3.5\" />\n              <Quote v-else class=\"size-3.5\" />\n            </Button>\n\n            <!-- Play Turn Button -->\n            <Button\n              variant=\"ghost\"\n              size=\"icon\"\n              class=\"text-muted-foreground hover:text-primary size-7\"\n              title=\"Play from here\"\n              @click=\"playTurn(turn.startTime)\"\n              aria-label=\"Play\"\n            >\n              <Play class=\"size-3.5 fill-current\" />\n            </Button>\n          </div>\n        </div>\n\n        <!-- Transcribed Paragraph Text with Search Highlighting -->\n        <p class=\"text-foreground/90 selection:bg-primary/20 text-sm leading-relaxed font-normal sm:text-base\">\n          <template v-for=\"(seg, i) in highlightSegments(turn.text, searchQuery)\" :key=\"i\">\n            <mark\n              v-if=\"seg.match\"\n              class=\"bg-warning/25 text-foreground bg-warning/30 dark:text-foreground rounded px-1 py-0.5 font-medium transition-colors\"\n            >\n              {{ seg.text }}\n            </mark>\n            <template v-else>{{ seg.text }}</template>\n          </template>\n        </p>\n\n        <!-- Turn Footer Meta -->\n        <div\n          class=\"text-muted-foreground/80 border-border/40 mt-3 flex items-center justify-between gap-x-2 border-t pt-2 font-mono text-xs\"\n        >\n          <span>Turn duration: {{ turn.duration }}</span>\n          <span class=\"text-xs\">{{ turn.speakerHandle }}</span>\n        </div>\n      </div>\n\n      <!-- Empty state when no turns match filter -->\n      <div\n        v-if=\"displayedTurns.length === 0\"\n        class=\"border-border bg-muted/20 space-y-2 rounded-xl border border-dashed px-4 py-12 text-center\"\n      >\n        <p class=\"text-foreground text-sm font-semibold\">No matching transcript segments found</p>\n        <p class=\"text-muted-foreground text-xs\">\n          No dialogue matched query \"{{ searchQuery }}\". Try adjusting your search term or clearing speaker filters.\n        </p>\n        <Button variant=\"outline\" size=\"sm\" class=\"mt-2 text-xs\" @click=\"resetFilters\"> Reset Search & Filters </Button>\n      </div>\n    </div>\n\n    <!-- Toast Notification Banner -->\n    <div\n      v-if=\"toastMessage\"\n      class=\"border-border bg-popover text-popover-foreground animate-in slide-in-from-bottom-2 fade-in-0 fixed right-6 bottom-6 z-50 flex flex-wrap items-center gap-2 rounded-lg border px-4 py-2.5 text-xs font-medium shadow-lg sm:text-sm\"\n    >\n      <Check class=\"text-success size-4 shrink-0\" />\n      <span>{{ toastMessage }}</span>\n    </div>\n  </div>\n</template>\n",
      "type": "registry:block",
      "target": "~/app/components/blocks/AudioWaveformTranscript.vue"
    }
  ],
  "dependencies": [
    "lucide-vue-next"
  ],
  "devDependencies": [],
  "registryDependencies": [
    "https://uipkge.dev/r/vue/avatar.json",
    "https://uipkge.dev/r/vue/badge.json",
    "https://uipkge.dev/r/vue/button.json",
    "https://uipkge.dev/r/vue/card.json",
    "https://uipkge.dev/r/vue/input.json",
    "https://uipkge.dev/r/vue/separator.json"
  ],
  "description": "Descript and Otter style interactive podcast speech-to-text transcript with speaker diarization tags, interactive audio waveform scrubber, keyword search highlighting, talking time ratio analytics, clickable timecodes, and VTT/TXT export.",
  "categories": [
    "media",
    "content",
    "ai"
  ]
}