Media
Video Chapters code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/video-chapters.blade.php
@props([
'chapters' => [],
'title' => 'Chapters',
'player' => 'default',
'duration' => null,
])
@php
/*
| Chapter list / timeline markers. Clicking a chapter dispatches
| `video-seek` for the paired player. Listens to `video-time` to highlight
| the active chapter while playback advances.
|
| Each chapter: title, time (seconds or "m:ss" / "h:mm:ss"), description?
|
| Usage:
| <x-ui.video-chapters player="watch" :chapters="[
| ['title' => 'Intro', 'time' => 0],
| ['title' => 'Demo', 'time' => '1:20'],
| ]" />
*/
$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($chapters)->map(function ($chapter, $index) use ($parseTime, $formatTime) {
$seconds = $parseTime($chapter['time'] ?? 0);
return [
'id' => (string) ($chapter['id'] ?? $index),
'title' => $chapter['title'] ?? 'Chapter '.($index + 1),
'description' => $chapter['description'] ?? null,
'time' => $seconds,
'label' => $chapter['label'] ?? $formatTime($seconds),
];
})->sortBy('time')->values()->all();
$totalDuration = is_numeric($duration) ? (float) $duration : null;
@endphp
<section
x-data="{
player: @js((string) $player),
chapters: @js($normalized),
current: 0,
duration: @js($totalDuration),
get activeIndex() {
let index = 0;
this.chapters.forEach((chapter, i) => {
if (this.current >= chapter.time) { index = i; }
});
return index;
},
seek(chapter) {
this.$dispatch('video-seek', { player: this.player, time: chapter.time });
},
onTime(detail) {
if (detail?.player && detail.player !== this.player) { return; }
this.current = Number(detail?.time ?? 0);
if (detail?.duration) { this.duration = Number(detail.duration); }
},
markerLeft(chapter) {
const total = this.duration || (this.chapters.at(-1)?.time || 1);
return Math.min(100, Math.max(0, (chapter.time / total) * 100)) + '%';
},
}"
@video-time.window="onTime($event.detail)"
{{ $attributes->merge(['class' => '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">
<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="chapters.length"></span> chapters</p>
</div>
<div class="relative mx-4 mt-4 hidden h-2 rounded-full bg-gray-100 sm:block dark:bg-gray-800">
<div
class="absolute inset-y-0 start-0 rounded-full bg-brand-500/80"
:style="`width: ${duration ? Math.min(100, (current / duration) * 100) : 0}%`"
></div>
<template x-for="(chapter, index) in chapters" :key="chapter.id">
<button
type="button"
class="absolute top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white bg-brand-600 shadow dark:border-gray-900"
:style="`left: ${markerLeft(chapter)}`"
:aria-label="chapter.title"
@click="seek(chapter)"
></button>
</template>
</div>
<ol class="divide-y divide-gray-100 p-2 dark:divide-gray-800/80">
<template x-for="(chapter, index) in chapters" :key="chapter.id">
<li>
<button
type="button"
class="flex w-full items-start gap-3 rounded-xl px-3 py-3 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="activeIndex === index ? 'bg-brand-50/80 dark:bg-brand-950/30' : ''"
@click="seek(chapter)"
>
<span class="mt-0.5 w-12 shrink-0 font-mono text-xs tabular-nums text-brand-600 dark:text-brand-400" x-text="chapter.label"></span>
<span class="min-w-0 flex-1">
<span class="block text-sm font-semibold text-gray-900 dark:text-white" x-text="chapter.title"></span>
<span x-show="chapter.description" x-cloak class="mt-0.5 block text-xs leading-5 text-gray-500 dark:text-gray-400" x-text="chapter.description"></span>
</span>
<x-ui.icon name="play" weight="fill" size="sm" class="mt-1 shrink-0 text-gray-300 dark:text-gray-600" />
</button>
</li>
</template>
</ol>
</section>