Skip to main content

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>