{
	"$schema": "https://shadcn-svelte.com/schema/registry-item.json",
	"name": "transcript-viewer",
	"title": "Transcript Viewer",
	"type": "registry:ui",
	"description": "A word-synced transcript viewer with integrated audio playback and scrub bar.",
	"dependencies": [
		"@lucide/svelte"
	],
	"devDependencies": [
		"@lucide/svelte@^1.7.0"
	],
	"registryDependencies": [
		"button"
	],
	"files": [
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\timport type { Snippet } from \"svelte\";\n\timport type { AudioType, CharacterAlignment, SegmentComposer } from \"./context.svelte.js\";\n\n\texport type TranscriptViewerProps = Omit<HTMLAttributes<HTMLDivElement>, \"children\"> & {\n\t\t/** URL of the audio file that backs the transcript. */\n\t\taudioSrc: string;\n\t\t/**\n\t\t * MIME type emitted on the inner `<source>` element. Set this when\n\t\t * serving non-MP3 audio so the browser picks the right decoder.\n\t\t * @default \"audio/mpeg\"\n\t\t */\n\t\taudioType?: AudioType;\n\t\t/**\n\t\t * Character-level alignment used to compute word boundaries and drive\n\t\t * highlighting. Shape matches ElevenLabs' `CharacterAlignmentResponseModel`;\n\t\t * reshape other providers' output to the same structure.\n\t\t */\n\t\talignment: CharacterAlignment;\n\t\t/**\n\t\t * Override the default word/gap segmentation. Receives the raw\n\t\t * alignment and returns the composed `segments` and `words` arrays.\n\t\t */\n\t\tsegmentComposer?: SegmentComposer;\n\t\t/**\n\t\t * When `true`, ElevenLabs-style tags like `[excited]` are stripped\n\t\t * from the rendered transcript.\n\t\t * @default true\n\t\t */\n\t\thideAudioTags?: boolean;\n\t\t/** Called when the audio starts playing. */\n\t\tonPlay?: () => void;\n\t\t/** Called when the audio is paused. */\n\t\tonPause?: () => void;\n\t\t/** Called with the current playback time (in seconds) on every audio `timeupdate`. */\n\t\tonTimeUpdate?: (time: number) => void;\n\t\t/** Called when playback reaches the end of the track. */\n\t\tonEnded?: () => void;\n\t\t/** Called with the total duration (in seconds) once metadata is available. */\n\t\tonDurationChange?: (duration: number) => void;\n\t\t/**\n\t\t * Sub-components composed inside the root (e.g. `<TranscriptViewerAudio />`,\n\t\t * `<TranscriptViewerWords />`, `<TranscriptViewerScrubBar />`).\n\t\t */\n\t\tchildren?: Snippet;\n\t\t/** Bind to the underlying wrapper `<div>` element. */\n\t\tref?: HTMLDivElement | null;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\timport { setTranscriptViewer } from \"./context.svelte.js\";\n\timport { composeSegments, guessedDurationFrom } from \"./utils.js\";\n\n\tlet {\n\t\taudioSrc,\n\t\taudioType = \"audio/mpeg\",\n\t\talignment,\n\t\tsegmentComposer,\n\t\thideAudioTags = true,\n\t\tonPlay,\n\t\tonPause,\n\t\tonTimeUpdate,\n\t\tonEnded,\n\t\tonDurationChange,\n\t\tclass: className,\n\t\tchildren,\n\t\tref = $bindable(null),\n\t\t...restProps\n\t}: TranscriptViewerProps = $props();\n\n\tconst state = setTranscriptViewer();\n\n\t// Sync audio src/type props → state so <TranscriptViewerAudio> can read them.\n\t$effect(() => {\n\t\tstate.audioSrc = audioSrc;\n\t\tstate.audioType = audioType;\n\t});\n\n\t// Recompute segments and reset playback whenever the alignment changes.\n\t$effect(() => {\n\t\tconst composed = segmentComposer\n\t\t\t? segmentComposer(alignment)\n\t\t\t: composeSegments(alignment, { hideAudioTags });\n\t\tstate.segments = composed.segments;\n\t\tstate.words = composed.words;\n\t\tstate.currentTime = 0;\n\t\tstate.duration = guessedDurationFrom(alignment, composed.words);\n\t\tstate.isPlaying = false;\n\t\tstate.currentWordIndex = composed.words.length ? 0 : -1;\n\t});\n\n\t// Bind audio lifecycle: listeners + RAF sync loop. Re-runs whenever the\n\t// audio element (bound by <TranscriptViewerAudio>) appears or changes.\n\t$effect(() => {\n\t\tconst audio = state.audio;\n\t\tif (!audio) return;\n\n\t\tlet rafId: number | null = null;\n\t\tconst stopRaf = () => {\n\t\t\tif (rafId !== null) {\n\t\t\t\tcancelAnimationFrame(rafId);\n\t\t\t\trafId = null;\n\t\t\t}\n\t\t};\n\t\tconst startRaf = () => {\n\t\t\tif (rafId !== null) return;\n\t\t\tconst tick = () => {\n\t\t\t\tconst node = state.audio;\n\t\t\t\tif (!node) {\n\t\t\t\t\trafId = null;\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (!state.isScrubbing) {\n\t\t\t\t\tconst t = node.currentTime;\n\t\t\t\t\tstate.currentTime = t;\n\t\t\t\t\tstate.handleTimeUpdate(t);\n\t\t\t\t\t// Opportunistically pick up duration when metadata arrives.\n\t\t\t\t\tif (Number.isFinite(node.duration) && node.duration > 0 && !state.duration) {\n\t\t\t\t\t\tstate.duration = node.duration;\n\t\t\t\t\t\tonDurationChange?.(node.duration);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\trafId = requestAnimationFrame(tick);\n\t\t\t};\n\t\t\trafId = requestAnimationFrame(tick);\n\t\t};\n\n\t\tconst handlePlay = () => {\n\t\t\tstate.isPlaying = true;\n\t\t\tstartRaf();\n\t\t\tonPlay?.();\n\t\t};\n\t\tconst handlePause = () => {\n\t\t\tstate.isPlaying = false;\n\t\t\tstate.currentTime = audio.currentTime;\n\t\t\tstopRaf();\n\t\t\tonPause?.();\n\t\t};\n\t\tconst handleEnded = () => {\n\t\t\tstate.isPlaying = false;\n\t\t\tstate.currentTime = audio.currentTime;\n\t\t\tstopRaf();\n\t\t\tonEnded?.();\n\t\t};\n\t\tconst handleTimeUpdateEvent = () => {\n\t\t\tstate.currentTime = audio.currentTime;\n\t\t\tonTimeUpdate?.(audio.currentTime);\n\t\t};\n\t\tconst handleSeeked = () => {\n\t\t\tstate.currentTime = audio.currentTime;\n\t\t\tstate.handleTimeUpdate(audio.currentTime);\n\t\t};\n\t\tconst handleDuration = () => {\n\t\t\tstate.duration = Number.isFinite(audio.duration) ? audio.duration : 0;\n\t\t\tonDurationChange?.(audio.duration);\n\t\t};\n\n\t\t// Sync initial state\n\t\tstate.isPlaying = !audio.paused;\n\t\tstate.currentTime = audio.currentTime;\n\t\tif (Number.isFinite(audio.duration)) state.duration = audio.duration;\n\t\tif (!audio.paused) startRaf();\n\n\t\taudio.addEventListener(\"play\", handlePlay);\n\t\taudio.addEventListener(\"pause\", handlePause);\n\t\taudio.addEventListener(\"ended\", handleEnded);\n\t\taudio.addEventListener(\"timeupdate\", handleTimeUpdateEvent);\n\t\taudio.addEventListener(\"seeked\", handleSeeked);\n\t\taudio.addEventListener(\"durationchange\", handleDuration);\n\t\taudio.addEventListener(\"loadedmetadata\", handleDuration);\n\n\t\treturn () => {\n\t\t\tstopRaf();\n\t\t\taudio.removeEventListener(\"play\", handlePlay);\n\t\t\taudio.removeEventListener(\"pause\", handlePause);\n\t\t\taudio.removeEventListener(\"ended\", handleEnded);\n\t\t\taudio.removeEventListener(\"timeupdate\", handleTimeUpdateEvent);\n\t\t\taudio.removeEventListener(\"seeked\", handleSeeked);\n\t\t\taudio.removeEventListener(\"durationchange\", handleDuration);\n\t\t\taudio.removeEventListener(\"loadedmetadata\", handleDuration);\n\t\t};\n\t});\n</script>\n\n<div\n\tbind:this={ref}\n\tclass={cn(\"space-y-4 p-4\", className)}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-root\"\n>\n\t{@render children?.()}\n</div>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAudioAttributes } from \"svelte/elements\";\n\n\texport type TranscriptViewerAudioProps = Omit<HTMLAudioAttributes, \"src\" | \"children\"> & {\n\t\tref?: HTMLAudioElement | null;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { useTranscriptViewer } from \"./context.svelte.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tref = $bindable(null),\n\t\t...restProps\n\t}: TranscriptViewerAudioProps = $props();\n\n\tconst state = useTranscriptViewer();\n\n\t// Keep both the local `ref` and the shared state.audio pointing at the\n\t// same element so external consumers can still `bind:ref` if desired.\n\t$effect(() => {\n\t\tstate.audio = ref;\n\t});\n</script>\n\n<audio\n\tbind:this={ref}\n\tcontrols={false}\n\tpreload=\"metadata\"\n\tsrc={state.audioSrc}\n\tclass={className}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-audio\"\n>\n\t<source src={state.audioSrc} type={state.audioType} />\n</audio>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer-audio.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { Snippet } from \"svelte\";\n\timport type { ButtonProps } from \"$UI$/button/index.js\";\n\n\texport type TranscriptViewerPlayPauseButtonProps = Omit<ButtonProps, \"children\"> & {\n\t\tchildren?: Snippet<[{ isPlaying: boolean }]>;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport PlayIcon from \"@lucide/svelte/icons/play\";\n\timport PauseIcon from \"@lucide/svelte/icons/pause\";\n\timport { cn } from \"$UTILS$.js\";\n\timport { Button } from \"$UI$/button/index.js\";\n\timport { useTranscriptViewer } from \"./context.svelte.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tchildren,\n\t\tonclick,\n\t\tvariant = \"outline\",\n\t\tsize = \"icon\",\n\t\t...restProps\n\t}: TranscriptViewerPlayPauseButtonProps = $props();\n\n\tconst state = useTranscriptViewer();\n\n\tfunction handleClick(event: MouseEvent) {\n\t\tif (state.isPlaying) state.pause();\n\t\telse state.play();\n\t\tif (typeof onclick === \"function\") {\n\t\t\t// svelte's HTMLButtonAttributes types `onclick` as EventHandler<MouseEvent, HTMLButtonElement>\n\t\t\t(onclick as (e: MouseEvent) => void)(event);\n\t\t}\n\t}\n</script>\n\n<Button\n\ttype=\"button\"\n\t{variant}\n\t{size}\n\taria-label={state.isPlaying ? \"Pause audio\" : \"Play audio\"}\n\tdata-playing={state.isPlaying}\n\tclass={cn(\"cursor-pointer\", className)}\n\tonclick={handleClick}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-play-pause-button\"\n>\n\t{#if children}\n\t\t{@render children({ isPlaying: state.isPlaying })}\n\t{:else if state.isPlaying}\n\t\t<PauseIcon class=\"size-5\" />\n\t{:else}\n\t\t<PlayIcon class=\"size-5\" />\n\t{/if}\n</Button>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer-play-pause-button.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\n\texport type TranscriptViewerScrubBarProps = Omit<HTMLAttributes<HTMLDivElement>, \"children\"> & {\n\t\tshowTimeLabels?: boolean;\n\t\tlabelsClassName?: string;\n\t\ttrackClassName?: string;\n\t\tprogressClassName?: string;\n\t\tthumbClassName?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\timport {\n\t\tScrubBar,\n\t\tScrubBarTrack,\n\t\tScrubBarProgress,\n\t\tScrubBarThumb,\n\t\tScrubBarTimeLabel,\n\t} from \"$UI$/scrub-bar/index.js\";\n\timport { useTranscriptViewer } from \"./context.svelte.js\";\n\n\tlet {\n\t\tclass: className,\n\t\tshowTimeLabels = true,\n\t\tlabelsClassName,\n\t\ttrackClassName,\n\t\tprogressClassName,\n\t\tthumbClassName,\n\t\t...restProps\n\t}: TranscriptViewerScrubBarProps = $props();\n\n\tconst state = useTranscriptViewer();\n</script>\n\n<ScrubBar\n\tduration={state.duration}\n\tvalue={state.currentTime}\n\tonScrub={state.seekToTime}\n\tonScrubStart={state.startScrubbing}\n\tonScrubEnd={state.endScrubbing}\n\tclass={className}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-scrub-bar\"\n>\n\t<div class=\"flex flex-1 flex-col gap-1\">\n\t\t<ScrubBarTrack class={trackClassName}>\n\t\t\t<ScrubBarProgress class={progressClassName} />\n\t\t\t<ScrubBarThumb class={thumbClassName} />\n\t\t</ScrubBarTrack>\n\t\t{#if showTimeLabels}\n\t\t\t<div\n\t\t\t\tclass={cn(\n\t\t\t\t\t\"text-muted-foreground flex items-center justify-between text-xs\",\n\t\t\t\t\tlabelsClassName\n\t\t\t\t)}\n\t\t\t>\n\t\t\t\t<ScrubBarTimeLabel time={state.currentTime} />\n\t\t\t\t<ScrubBarTimeLabel time={state.duration - state.currentTime} />\n\t\t\t</div>\n\t\t{/if}\n\t</div>\n</ScrubBar>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer-scrub-bar.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\timport type { Snippet } from \"svelte\";\n\timport type { TranscriptWord } from \"./utils.js\";\n\timport type { TranscriptViewerWordStatus } from \"./context.svelte.js\";\n\n\texport type TranscriptViewerWordProps = Omit<HTMLAttributes<HTMLSpanElement>, \"children\"> & {\n\t\tword: TranscriptWord;\n\t\tstatus: TranscriptViewerWordStatus;\n\t\tchildren?: Snippet;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\n\tlet {\n\t\tword,\n\t\tstatus,\n\t\tclass: className,\n\t\tchildren,\n\t\t...restProps\n\t}: TranscriptViewerWordProps = $props();\n</script>\n\n<span\n\tdata-kind=\"word\"\n\tdata-status={status}\n\tclass={cn(\n\t\t\"rounded-sm px-0.5 transition-colors\",\n\t\tstatus === \"spoken\" && \"text-foreground\",\n\t\tstatus === \"unspoken\" && \"text-muted-foreground\",\n\t\tstatus === \"current\" && \"bg-primary text-primary-foreground\",\n\t\tclassName\n\t)}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-word\"\n>\n\t{#if children}{@render children()}{:else}{word.text}{/if}\n</span>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer-word.svelte"
		},
		{
			"content": "<script lang=\"ts\" module>\n\timport type { HTMLAttributes } from \"svelte/elements\";\n\timport type { Snippet } from \"svelte\";\n\timport type { GapSegment, TranscriptSegment, TranscriptWord } from \"./utils.js\";\n\timport type { TranscriptViewerWordStatus } from \"./context.svelte.js\";\n\n\texport type TranscriptViewerWordsProps = HTMLAttributes<HTMLDivElement> & {\n\t\trenderWord?: Snippet<[{ word: TranscriptWord; status: TranscriptViewerWordStatus }]>;\n\t\trenderGap?: Snippet<[{ segment: GapSegment; status: TranscriptViewerWordStatus }]>;\n\t\twordClassNames?: string;\n\t\tgapClassNames?: string;\n\t};\n</script>\n\n<script lang=\"ts\">\n\timport { cn } from \"$UTILS$.js\";\n\timport TranscriptViewerWord from \"./transcript-viewer-word.svelte\";\n\timport { useTranscriptViewer } from \"./context.svelte.js\";\n\n\tlet {\n\t\tclass: className,\n\t\trenderWord,\n\t\trenderGap,\n\t\twordClassNames,\n\t\tgapClassNames,\n\t\t...restProps\n\t}: TranscriptViewerWordsProps = $props();\n\n\tconst state = useTranscriptViewer();\n\n\tconst nearEnd = $derived(state.duration ? state.currentTime >= state.duration - 0.01 : false);\n\n\tconst segmentsWithStatus = $derived.by<\n\t\tArray<{ segment: TranscriptSegment; status: TranscriptViewerWordStatus }>\n\t>(() => {\n\t\tif (nearEnd) {\n\t\t\treturn state.segments.map((segment) => ({ segment, status: \"spoken\" as const }));\n\t\t}\n\n\t\tconst entries: Array<{ segment: TranscriptSegment; status: TranscriptViewerWordStatus }> = [];\n\t\tfor (const segment of state.spokenSegments) {\n\t\t\tentries.push({ segment, status: \"spoken\" });\n\t\t}\n\t\tif (state.currentWord) {\n\t\t\tentries.push({ segment: state.currentWord, status: \"current\" });\n\t\t}\n\t\tfor (const segment of state.unspokenSegments) {\n\t\t\tentries.push({ segment, status: \"unspoken\" });\n\t\t}\n\t\treturn entries;\n\t});\n</script>\n\n<div\n\tclass={cn(\"text-xl leading-relaxed\", className)}\n\t{...restProps}\n\tdata-slot=\"transcript-viewer-words\"\n>\n\t{#each segmentsWithStatus as entry (entry.segment.segmentIndex)}\n\t\t{#if entry.segment.kind === \"gap\"}\n\t\t\t<span data-kind=\"gap\" data-status={entry.status} class={cn(gapClassNames)}>\n\t\t\t\t{#if renderGap}\n\t\t\t\t\t{@render renderGap({ segment: entry.segment, status: entry.status })}\n\t\t\t\t{:else}\n\t\t\t\t\t{entry.segment.text}\n\t\t\t\t{/if}\n\t\t\t</span>\n\t\t{:else if renderWord}\n\t\t\t<span data-kind=\"word\" data-status={entry.status} class={cn(wordClassNames)}>\n\t\t\t\t{@render renderWord({ word: entry.segment, status: entry.status })}\n\t\t\t</span>\n\t\t{:else}\n\t\t\t<TranscriptViewerWord word={entry.segment} status={entry.status} class={wordClassNames} />\n\t\t{/if}\n\t{/each}\n</div>\n",
			"type": "registry:ui",
			"target": "transcript-viewer/transcript-viewer-words.svelte"
		},
		{
			"content": "import { getContext, setContext } from \"svelte\";\nimport { findWordIndex } from \"./utils.js\";\nimport type { TranscriptSegment, TranscriptWord } from \"./utils.js\";\n\nexport type TranscriptViewerWordStatus = \"spoken\" | \"unspoken\" | \"current\";\n\nexport type AudioType =\n\t| \"audio/mpeg\"\n\t| \"audio/wav\"\n\t| \"audio/ogg\"\n\t| \"audio/mp3\"\n\t| \"audio/m4a\"\n\t| \"audio/aac\"\n\t| \"audio/webm\";\n\nconst TRANSCRIPT_VIEWER_CONTEXT_KEY = Symbol(\"sv11-transcript-viewer\");\n\n/**\n * Reactive state shared between a `<TranscriptViewer>` root and its sub-components\n * via Svelte context. The root creates and registers the instance; sub-components\n * consume it via `useTranscriptViewer()`.\n *\n * The audio element is bound by `<TranscriptViewerAudio>` into `this.audio`;\n * the root's `$effect` observes that and wires up playback event listeners and\n * the `requestAnimationFrame` sync loop.\n */\nexport class TranscriptViewerState {\n\taudio: HTMLAudioElement | null = $state(null);\n\taudioSrc = $state(\"\");\n\taudioType = $state<AudioType>(\"audio/mpeg\");\n\n\tsegments: TranscriptSegment[] = $state([]);\n\twords: TranscriptWord[] = $state([]);\n\n\tisPlaying = $state(false);\n\tisScrubbing = $state(false);\n\tduration = $state(0);\n\tcurrentTime = $state(0);\n\tcurrentWordIndex = $state(-1);\n\n\tcurrentWord = $derived(\n\t\tthis.currentWordIndex >= 0 && this.currentWordIndex < this.words.length\n\t\t\t? this.words[this.currentWordIndex]\n\t\t\t: null\n\t);\n\n\tcurrentSegmentIndex = $derived(this.currentWord?.segmentIndex ?? -1);\n\n\tspokenSegments = $derived(\n\t\tthis.segments.length && this.currentSegmentIndex > 0\n\t\t\t? this.segments.slice(0, this.currentSegmentIndex)\n\t\t\t: []\n\t);\n\n\tunspokenSegments: TranscriptSegment[] = $derived.by(() => {\n\t\tif (!this.segments.length) return [];\n\t\tif (this.currentSegmentIndex === -1) return this.segments;\n\t\tif (this.currentSegmentIndex + 1 >= this.segments.length) return [];\n\t\treturn this.segments.slice(this.currentSegmentIndex + 1);\n\t});\n\n\t/**\n\t * Updates `currentWordIndex` based on the new playback time. Mirrors the\n\t * forward-walk optimization from React's use-transcript-viewer hook:\n\t * cheap advancement for normal playback, binary-search fallback for seeks\n\t * and edge cases.\n\t */\n\thandleTimeUpdate = (time: number): void => {\n\t\tif (!this.words.length) return;\n\n\t\tconst current =\n\t\t\tthis.currentWordIndex >= 0 && this.currentWordIndex < this.words.length\n\t\t\t\t? this.words[this.currentWordIndex]\n\t\t\t\t: undefined;\n\n\t\tif (!current) {\n\t\t\tconst found = findWordIndex(this.words, time);\n\t\t\tif (found !== -1) this.currentWordIndex = found;\n\t\t\treturn;\n\t\t}\n\n\t\tlet next = this.currentWordIndex;\n\t\tif (time >= current.endTime && this.currentWordIndex + 1 < this.words.length) {\n\t\t\twhile (next + 1 < this.words.length && time >= this.words[next + 1].startTime) {\n\t\t\t\tnext++;\n\t\t\t}\n\t\t\t// If we landed inside the next word's window, pick it. Otherwise snap to\n\t\t\t// the latest word that started at or before `time` (i.e. we're in a\n\t\t\t// timing gap with no word actively speaking).\n\t\t\tthis.currentWordIndex = next;\n\t\t\treturn;\n\t\t}\n\n\t\tif (time < current.startTime) {\n\t\t\tconst found = findWordIndex(this.words, time);\n\t\t\tif (found !== -1) this.currentWordIndex = found;\n\t\t\treturn;\n\t\t}\n\n\t\tconst found = findWordIndex(this.words, time);\n\t\tif (found !== -1 && found !== this.currentWordIndex) {\n\t\t\tthis.currentWordIndex = found;\n\t\t}\n\t};\n\n\tseekToTime = (time: number): void => {\n\t\tconst node = this.audio;\n\t\tif (!node) return;\n\t\t// Optimistically update UI time immediately to reflect the seek, since\n\t\t// some browsers coalesce timeupdate/seeked events under rapid seeks.\n\t\tthis.currentTime = time;\n\t\tnode.currentTime = time;\n\t\tthis.handleTimeUpdate(time);\n\t};\n\n\tseekToWord = (word: number | TranscriptWord): void => {\n\t\tconst target = typeof word === \"number\" ? this.words[word] : word;\n\t\tif (!target) return;\n\t\tthis.seekToTime(target.startTime);\n\t};\n\n\tplay = (): void => {\n\t\tconst audio = this.audio;\n\t\tif (!audio) return;\n\t\tif (audio.paused) {\n\t\t\tvoid audio.play().catch(() => {\n\t\t\t\t/* autoplay may be blocked; surface via audio error events */\n\t\t\t});\n\t\t}\n\t};\n\n\tpause = (): void => {\n\t\tconst audio = this.audio;\n\t\tif (audio && !audio.paused) audio.pause();\n\t};\n\n\tstartScrubbing = (): void => {\n\t\tthis.isScrubbing = true;\n\t};\n\n\tendScrubbing = (): void => {\n\t\tthis.isScrubbing = false;\n\t};\n}\n\nexport function setTranscriptViewer(): TranscriptViewerState {\n\tconst state = new TranscriptViewerState();\n\tsetContext(TRANSCRIPT_VIEWER_CONTEXT_KEY, state);\n\treturn state;\n}\n\nexport function useTranscriptViewer(): TranscriptViewerState {\n\tconst ctx = getContext<TranscriptViewerState | undefined>(TRANSCRIPT_VIEWER_CONTEXT_KEY);\n\tif (!ctx) {\n\t\tthrow new Error(\"useTranscriptViewer must be called within a <TranscriptViewer>\");\n\t}\n\treturn ctx;\n}\n\nexport type {\n\tCharacterAlignment,\n\tCharacterAlignmentResponseModel,\n\tTranscriptSegment,\n\tTranscriptWord,\n\tGapSegment,\n\tSegmentComposer,\n\tComposeSegmentsOptions,\n\tComposeSegmentsResult,\n} from \"./utils.js\";\n",
			"type": "registry:ui",
			"target": "transcript-viewer/context.svelte.ts"
		},
		{
			"content": "/**\n * Character-level alignment data. Each index `i` in the three arrays corresponds\n * to a single character of the spoken transcript, with its start/end time in seconds.\n *\n * This shape matches ElevenLabs' `CharacterAlignmentResponseModel` exactly, so\n * ElevenLabs users can pass their API response directly. For other providers\n * (OpenAI, Deepgram, custom), reshape your data to this structure.\n */\nexport interface CharacterAlignment {\n\tcharacters: string[];\n\tcharacterStartTimesSeconds: number[];\n\tcharacterEndTimesSeconds: number[];\n}\n\n/** Alias for users importing from ElevenLabs conventions. */\nexport type CharacterAlignmentResponseModel = CharacterAlignment;\n\ntype BaseSegment = {\n\tsegmentIndex: number;\n\ttext: string;\n};\n\nexport type TranscriptWord = BaseSegment & {\n\tkind: \"word\";\n\twordIndex: number;\n\tstartTime: number;\n\tendTime: number;\n};\n\nexport type GapSegment = BaseSegment & {\n\tkind: \"gap\";\n};\n\nexport type TranscriptSegment = TranscriptWord | GapSegment;\n\nexport type ComposeSegmentsOptions = {\n\thideAudioTags?: boolean;\n};\n\nexport type ComposeSegmentsResult = {\n\tsegments: TranscriptSegment[];\n\twords: TranscriptWord[];\n};\n\nexport type SegmentComposer = (alignment: CharacterAlignment) => ComposeSegmentsResult;\n\n/**\n * Walks the character arrays and groups them into word segments (contiguous\n * non-whitespace runs) and gap segments (whitespace). If `hideAudioTags` is\n * true, any content inside `[...]` is stripped from the output entirely.\n */\nexport function composeSegments(\n\talignment: CharacterAlignment,\n\toptions: ComposeSegmentsOptions = {}\n): ComposeSegmentsResult {\n\tconst {\n\t\tcharacters,\n\t\tcharacterStartTimesSeconds: starts,\n\t\tcharacterEndTimesSeconds: ends,\n\t} = alignment;\n\n\tconst segments: TranscriptSegment[] = [];\n\tconst words: TranscriptWord[] = [];\n\n\tlet wordBuffer = \"\";\n\tlet whitespaceBuffer = \"\";\n\tlet wordStart = 0;\n\tlet wordEnd = 0;\n\tlet segmentIndex = 0;\n\tlet wordIndex = 0;\n\tlet insideAudioTag = false;\n\n\tconst hideAudioTags = options.hideAudioTags ?? false;\n\n\tconst flushWhitespace = () => {\n\t\tif (!whitespaceBuffer) return;\n\t\tsegments.push({\n\t\t\tkind: \"gap\",\n\t\t\tsegmentIndex: segmentIndex++,\n\t\t\ttext: whitespaceBuffer,\n\t\t});\n\t\twhitespaceBuffer = \"\";\n\t};\n\n\tconst flushWord = () => {\n\t\tif (!wordBuffer) return;\n\t\tconst word: TranscriptWord = {\n\t\t\tkind: \"word\",\n\t\t\tsegmentIndex: segmentIndex++,\n\t\t\twordIndex: wordIndex++,\n\t\t\ttext: wordBuffer,\n\t\t\tstartTime: wordStart,\n\t\t\tendTime: wordEnd,\n\t\t};\n\t\tsegments.push(word);\n\t\twords.push(word);\n\t\twordBuffer = \"\";\n\t};\n\n\tfor (let i = 0; i < characters.length; i++) {\n\t\tconst char = characters[i];\n\t\tconst start = starts[i] ?? 0;\n\t\tconst end = ends[i] ?? start;\n\n\t\tif (hideAudioTags) {\n\t\t\tif (char === \"[\") {\n\t\t\t\tflushWord();\n\t\t\t\twhitespaceBuffer = \"\";\n\t\t\t\tinsideAudioTag = true;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (insideAudioTag) {\n\t\t\t\tif (char === \"]\") insideAudioTag = false;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t}\n\n\t\tif (/\\s/.test(char)) {\n\t\t\tflushWord();\n\t\t\twhitespaceBuffer += char;\n\t\t\tcontinue;\n\t\t}\n\n\t\tif (whitespaceBuffer) {\n\t\t\tflushWhitespace();\n\t\t}\n\n\t\tif (!wordBuffer) {\n\t\t\twordBuffer = char;\n\t\t\twordStart = start;\n\t\t\twordEnd = end;\n\t\t} else {\n\t\t\twordBuffer += char;\n\t\t\twordEnd = end;\n\t\t}\n\t}\n\n\tflushWord();\n\tflushWhitespace();\n\n\treturn { segments, words };\n}\n\n/**\n * Binary search: finds the index of the word whose [startTime, endTime) window\n * contains `time`. Returns -1 if no word matches (e.g., `time` is in a timing gap\n * between words, or outside the transcript's bounds).\n */\nexport function findWordIndex(words: TranscriptWord[], time: number): number {\n\tif (!words.length) return -1;\n\tlet lo = 0;\n\tlet hi = words.length - 1;\n\tlet answer = -1;\n\twhile (lo <= hi) {\n\t\tconst mid = Math.floor((lo + hi) / 2);\n\t\tconst word = words[mid];\n\t\tif (time >= word.startTime && time < word.endTime) {\n\t\t\tanswer = mid;\n\t\t\tbreak;\n\t\t}\n\t\tif (time < word.startTime) {\n\t\t\thi = mid - 1;\n\t\t} else {\n\t\t\tlo = mid + 1;\n\t\t}\n\t}\n\treturn answer;\n}\n\n/**\n * Best-effort duration guess from alignment data, used while audio metadata\n * is still loading. Returns the last character end time, or the last word end\n * time, or 0.\n */\nexport function guessedDurationFrom(\n\talignment: CharacterAlignment,\n\twords: TranscriptWord[]\n): number {\n\tconst ends = alignment?.characterEndTimesSeconds;\n\tif (Array.isArray(ends) && ends.length) {\n\t\tconst last = ends[ends.length - 1];\n\t\treturn Number.isFinite(last) ? last : 0;\n\t}\n\tif (words.length) {\n\t\tconst lastWord = words[words.length - 1];\n\t\treturn Number.isFinite(lastWord.endTime) ? lastWord.endTime : 0;\n\t}\n\treturn 0;\n}\n",
			"type": "registry:ui",
			"target": "transcript-viewer/utils.ts"
		},
		{
			"content": "import Root from \"./transcript-viewer.svelte\";\nimport Words from \"./transcript-viewer-words.svelte\";\nimport Word from \"./transcript-viewer-word.svelte\";\nimport Audio from \"./transcript-viewer-audio.svelte\";\nimport PlayPauseButton from \"./transcript-viewer-play-pause-button.svelte\";\nimport ScrubBar from \"./transcript-viewer-scrub-bar.svelte\";\n\nexport {\n\tRoot,\n\tWords,\n\tWord,\n\tAudio,\n\tPlayPauseButton,\n\tScrubBar,\n\t//\n\tRoot as TranscriptViewer,\n\tRoot as TranscriptViewerContainer,\n\tWords as TranscriptViewerWords,\n\tWord as TranscriptViewerWord,\n\tAudio as TranscriptViewerAudio,\n\tPlayPauseButton as TranscriptViewerPlayPauseButton,\n\tScrubBar as TranscriptViewerScrubBar,\n};\n\nexport {\n\tsetTranscriptViewer,\n\tuseTranscriptViewer,\n\tTranscriptViewerState,\n} from \"./context.svelte.js\";\n\nexport type { TranscriptViewerProps } from \"./transcript-viewer.svelte\";\nexport type { TranscriptViewerWordsProps } from \"./transcript-viewer-words.svelte\";\nexport type { TranscriptViewerWordProps } from \"./transcript-viewer-word.svelte\";\nexport type { TranscriptViewerAudioProps } from \"./transcript-viewer-audio.svelte\";\nexport type { TranscriptViewerPlayPauseButtonProps } from \"./transcript-viewer-play-pause-button.svelte\";\nexport type { TranscriptViewerScrubBarProps } from \"./transcript-viewer-scrub-bar.svelte\";\nexport type { AudioType, TranscriptViewerWordStatus } from \"./context.svelte.js\";\nexport type {\n\tCharacterAlignment,\n\tCharacterAlignmentResponseModel,\n\tTranscriptSegment,\n\tTranscriptWord,\n\tGapSegment,\n\tSegmentComposer,\n\tComposeSegmentsOptions,\n\tComposeSegmentsResult,\n} from \"./utils.js\";\n",
			"type": "registry:ui",
			"target": "transcript-viewer/index.ts"
		}
	]
}