Media
Video Transcript code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/video-transcript.blade.php
@props([
'cues' => [],
'title' => 'Transcript',
'player' => 'default',
'searchable' => true,
])
@php
/*
| Searchable transcript / captions panel. Each cue:
| text, start (seconds or timestamp), end?
|
| Listens to `video-time` and highlights the active cue. Clicking a cue
| dispatches `video-seek`.
|
| Usage:
| <x-ui.video-transcript player="watch" :cues="[
| ['start' => 0, 'text' => 'Welcome to Harbour.'],
| ['start' => '0:04', 'text' => 'Here is the player.'],
| ]" />
*/
$parseTime = function ($value): float {
if (is_numeric($value)) {
return (float) $value;
}
$parts = array_reverse(array_map('floatval', explode(':', (string) $value)));
$seconds = 0.0;
foreach ($parts as $index => $part) {
$seconds += $part * (60 ** $index);
}
return $seconds;
};
$formatTime = function (float $seconds): string {
$total = (int) max(0, floor($seconds));
$h = intdiv($total, 3600);
$m = intdiv($total % 3600, 60);
$s = $total % 60;
return $h > 0
? sprintf('%d:%02d:%02d', $h, $m, $s)
: sprintf('%d:%02d', $m, $s);
};
$normalized = collect($cues)->map(function ($cue, $index) use ($parseTime, $formatTime) {
$start = $parseTime($cue['start'] ?? 0);
$end = array_key_exists('end', $cue) ? $parseTime($cue['end']) : null;
return [
'id' => (string) ($cue['id'] ?? $index),
'text' => $cue['text'] ?? '',
'start' => $start,
'end' => $end,
'label' => $cue['label'] ?? $formatTime($start),
];
})->filter(fn ($cue) => filled($cue['text']))->values()->all();
@endphp
<section
x-data="{
player: @js((string) $player),
cues: @js($normalized),
query: '',
current: 0,
get filtered() {
const q = this.query.trim().toLowerCase();
if (! q) { return this.cues; }
return this.cues.filter((cue) => cue.text.toLowerCase().includes(q) || cue.label.includes(q));
},
get activeId() {
let active = this.cues[0]?.id ?? null;
this.cues.forEach((cue, index) => {
const next = this.cues[index + 1];
const end = cue.end ?? next?.start ?? Number.POSITIVE_INFINITY;
if (this.current >= cue.start && this.current < end) {
active = cue.id;
}
});
return active;
},
seek(cue) {
this.$dispatch('video-seek', { player: this.player, time: cue.start });
},
onTime(detail) {
if (detail?.player && detail.player !== this.player) { return; }
this.current = Number(detail?.time ?? 0);
this.$nextTick(() => {
const el = this.$refs.list?.querySelector('[data-active=true]');
el?.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
});
},
}"
@video-time.window="onTime($event.detail)"
{{ $attributes->merge(['class' => 'flex min-h-0 flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
aria-label="{{ $title }}"
>
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<div class="flex flex-wrap items-center justify-between gap-2">
<div>
<h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
<p class="text-xs text-gray-500 dark:text-gray-400"><span x-text="cues.length"></span> cues</p>
</div>
</div>
@if ($searchable)
<div class="relative mt-3">
<x-ui.icon name="magnifying-glass" size="sm" class="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
type="search"
x-model="query"
placeholder="Search transcript"
class="w-full rounded-xl border border-gray-200 bg-white py-2.5 ps-9 pe-3 text-sm text-gray-900 shadow-sm placeholder:text-gray-400 focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/20 dark:border-gray-800 dark:bg-gray-950 dark:text-white dark:placeholder:text-gray-500"
>
</div>
@endif
</div>
<div x-ref="list" class="max-h-80 space-y-1 overflow-y-auto p-2 sm:max-h-96">
<template x-for="cue in filtered" :key="cue.id">
<button
type="button"
class="flex w-full items-start gap-3 rounded-xl px-3 py-2.5 text-start transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:hover:bg-gray-950/50"
:class="activeId === cue.id ? 'bg-brand-50 dark:bg-brand-950/40' : ''"
:data-active="activeId === cue.id"
@click="seek(cue)"
>
<span class="mt-0.5 w-12 shrink-0 font-mono text-xs tabular-nums text-gray-400 dark:text-gray-500" x-text="cue.label"></span>
<span class="min-w-0 flex-1 text-sm leading-6 text-gray-700 dark:text-gray-200" :class="activeId === cue.id ? 'font-medium text-gray-900 dark:text-white' : ''" x-text="cue.text"></span>
</button>
</template>
<div x-show="filtered.length === 0" x-cloak class="px-3 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
No matching lines.
</div>
</div>
</section>