Skip to main content

Media

Caption Editor code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/caption-editor.blade.php

@props([
    'cues' => [],
    'title' => 'Caption editor',
    'player' => 'default',
    'duration' => null,
])

@php
    /*
    | Editable caption/subtitle cue list synced to a paired video player.
    | Emits `caption-change` and seeks via `video-seek`.
    |
    | Usage:
    |   <x-ui.caption-editor player="watch" :cues="[
    |       ['start' => 0, 'end' => 2, 'text' => 'Hello'],
    |   ]" />
    */
    $parseTime = function ($value): float {
        if (is_numeric($value)) {
            return (float) $value;
        }

        if (! is_string($value) || ! preg_match('/^(\d+):(\d{1,2})(?:\.(\d+))?$/', $value, $matches)) {
            return 0.0;
        }

        return ((int) $matches[1] * 60) + (int) $matches[2] + (isset($matches[3]) ? (float) ('0.'.$matches[3]) : 0);
    };

    $normalized = collect($cues)->map(function ($cue, $index) use ($parseTime) {
        $start = $parseTime($cue['start'] ?? 0);
        $end = array_key_exists('end', $cue ?? []) ? $parseTime($cue['end']) : $start + 2;

        return [
            'id' => (string) ($cue['id'] ?? $index),
            'start' => $start,
            'end' => max($start + 0.2, $end),
            'text' => (string) ($cue['text'] ?? ''),
        ];
    })->values()->all();
@endphp

<section
    x-data="{
        player: @js((string) $player),
        cues: @js($normalized),
        current: 0,
        duration: @js(is_numeric($duration) ? (float) $duration : 0),
        formatTime(seconds) {
            const total = Math.max(0, Math.floor(Number(seconds) || 0));
            const m = Math.floor(total / 60);
            const s = String(total % 60).padStart(2, '0');
            return `${m}:${s}`;
        },
        parseInput(value) {
            if (typeof value === 'number') { return value; }
            const text = String(value || '').trim();
            if (/^\d+(\.\d+)?$/.test(text)) { return Number(text); }
            const match = text.match(/^(\d+):(\d{1,2})(?:\.(\d+))?$/);
            if (! match) { return 0; }
            return (Number(match[1]) * 60) + Number(match[2]) + (match[3] ? Number(`0.${match[3]}`) : 0);
        },
        emit() {
            this.$dispatch('caption-change', { player: this.player, cues: this.cues });
        },
        seek(time) {
            this.$dispatch('video-seek', { player: this.player, time: Number(time) || 0 });
        },
        addCue() {
            const start = this.current || (this.cues.at(-1)?.end ?? 0);
            this.cues.push({
                id: `cue-${Date.now()}`,
                start,
                end: start + 2,
                text: '',
            });
            this.emit();
        },
        removeCue(id) {
            this.cues = this.cues.filter((cue) => cue.id !== id);
            this.emit();
        },
        updateCue(cue, field, value) {
            if (field === 'text') {
                cue.text = value;
            } else {
                cue[field] = this.parseInput(value);
                if (cue.end < cue.start + 0.2) { cue.end = cue.start + 0.2; }
            }
            this.emit();
        },
        onTime(detail) {
            if (detail?.player && detail.player !== this.player) { return; }
            this.current = Number(detail?.time ?? this.current);
            if (Number(detail?.duration) > 0) { this.duration = Number(detail.duration); }
        },
        isActive(cue) {
            return this.current >= cue.start && this.current < cue.end;
        },
    }"
    @video-time.window="onTime($event.detail)"
    {{ $attributes->merge(['class' => 'min-w-0 max-w-full rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
    aria-label="{{ $title }}"
>
    <div class="flex flex-col gap-3 border-b border-gray-200 px-3 py-3 sm:flex-row sm:items-center sm:justify-between sm:px-4 dark:border-gray-800">
        <div class="min-w-0">
            <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 · playhead <span class="font-mono tabular-nums" x-text="formatTime(current)"></span>
            </p>
        </div>
        <button
            type="button"
            @click="addCue()"
            class="inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-gray-200 bg-white px-3 text-sm font-medium text-gray-700 shadow-sm transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200 dark:hover:bg-gray-800 sm:h-9 sm:w-auto"
        >
            <x-ui.icon name="plus" weight="bold" size="sm" />
            Add cue
        </button>
    </div>

    <div class="max-h-[28rem] space-y-3 overflow-y-auto p-3 sm:max-h-[36rem] sm:p-4">
        <template x-for="cue in cues" :key="cue.id">
            <div
                class="rounded-xl border p-3 transition dark:border-gray-800"
                :class="isActive(cue) ? 'border-brand-300 bg-brand-50/60 dark:border-brand-800 dark:bg-brand-950/30' : 'border-gray-200 bg-gray-50/80 dark:bg-gray-950/40'"
            >
                <div class="grid grid-cols-2 gap-2 sm:grid-cols-[6.5rem_6.5rem_minmax(0,1fr)_auto] sm:items-end">
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300">
                        Start
                        <input
                            type="text"
                            inputmode="decimal"
                            class="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2.5 py-2 text-base tabular-nums text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-gray-700 dark:bg-gray-950 dark:text-white sm:text-sm"
                            :value="formatTime(cue.start)"
                            @change="updateCue(cue, 'start', $event.target.value)"
                            @focus="$event.target.select()"
                        >
                    </label>
                    <label class="block text-xs font-medium text-gray-600 dark:text-gray-300">
                        End
                        <input
                            type="text"
                            inputmode="decimal"
                            class="mt-1 w-full rounded-lg border border-gray-200 bg-white px-2.5 py-2 text-base tabular-nums text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-gray-700 dark:bg-gray-950 dark:text-white sm:text-sm"
                            :value="formatTime(cue.end)"
                            @change="updateCue(cue, 'end', $event.target.value)"
                            @focus="$event.target.select()"
                        >
                    </label>
                    <label class="col-span-2 block text-xs font-medium text-gray-600 dark:text-gray-300 sm:col-span-1">
                        Text
                        <textarea
                            rows="2"
                            class="mt-1 w-full resize-y rounded-lg border border-gray-200 bg-white px-2.5 py-2 text-base text-gray-900 shadow-sm focus:border-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-500/30 dark:border-gray-700 dark:bg-gray-950 dark:text-white sm:text-sm"
                            :value="cue.text"
                            @input="updateCue(cue, 'text', $event.target.value)"
                            placeholder="Caption text"
                        ></textarea>
                    </label>
                    <div class="col-span-2 flex gap-2 sm:col-span-1 sm:flex-col">
                        <button
                            type="button"
                            @click="seek(cue.start)"
                            class="inline-flex h-10 flex-1 items-center justify-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 text-xs font-medium text-gray-700 transition hover:bg-gray-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-200 sm:h-9 sm:flex-none"
                        >
                            <x-ui.icon name="play" weight="fill" size="xs" />
                            Jump
                        </button>
                        <button
                            type="button"
                            @click="removeCue(cue.id)"
                            class="inline-flex h-10 flex-1 items-center justify-center gap-1.5 rounded-lg border border-gray-200 bg-white px-2 text-xs font-medium text-red-600 transition hover:bg-red-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-950 dark:text-red-400 dark:hover:bg-red-950/30 sm:h-9 sm:flex-none"
                        >
                            <x-ui.icon name="trash" size="xs" />
                            Remove
                        </button>
                    </div>
                </div>
            </div>
        </template>

        <template x-if="cues.length === 0">
            <p class="py-10 text-center text-sm text-gray-500 dark:text-gray-400">No captions yet. Add a cue to get started.</p>
        </template>
    </div>
</section>