Skip to main content

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>