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>