Skip to main content

Media

Thumbnail Chooser code

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

resources/views/components/ui/thumbnail-chooser.blade.php

@props([
    'items' => [],
    'selected' => null,
    'title' => 'Choose a thumbnail',
    'description' => null,
    'columns' => 4,
    'src' => null,
    'times' => null,
    'player' => 'default',
    'name' => null,
    'capture' => true,
])

@php
    /*
    | Pick a poster/thumbnail from a gallery of candidates. Optional video `src`
    | + `times` samples frames into the grid (requires a CORS-friendly source).
    | When `capture` is true and a player is paired, “Use current frame” grabs
    | the live playhead via canvas.
    |
    | Selecting an item dispatches `thumbnail-selected`.
    |
    | Usage:
    |   <x-ui.thumbnail-chooser
    |       :selected="'mid'"
    |       :items="[
    |           ['id' => 'mid', 'src' => '...', 'label' => 'Mid shot'],
    |       ]"
    |   />
    */
    $normalized = collect($items)
        ->map(function ($row, $index) {
            if (is_string($row)) {
                return [
                    'id' => (string) $index,
                    'src' => $row,
                    'label' => null,
                    'time' => null,
                ];
            }

            $src = data_get($row, 'src') ?? data_get($row, 'poster') ?? data_get($row, 'url');

            if (blank($src)) {
                return null;
            }

            return [
                'id' => (string) (data_get($row, 'id') ?? $index),
                'src' => (string) $src,
                'label' => data_get($row, 'label') ?? data_get($row, 'title'),
                'time' => data_get($row, 'time'),
            ];
        })
        ->filter()
        ->values()
        ->all();

    $sampleTimes = collect($times ?? [])
        ->map(function ($time) {
            if (is_numeric($time)) {
                return (float) $time;
            }

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

            return ((int) $matches[1] * 60) + (int) $matches[2] + ((isset($matches[3]) ? ((float) ('0.'.$matches[3])) : 0));
        })
        ->filter(fn ($time) => $time !== null)
        ->values()
        ->all();

    $columnClass = match ((int) $columns) {
        2 => 'grid-cols-2',
        3 => 'grid-cols-2 sm:grid-cols-3',
        5 => 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-5',
        6 => 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-6',
        default => 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4',
    };
@endphp

<div
    x-data="{
        items: @js($normalized),
        selectedId: @js($selected !== null ? (string) $selected : null),
        src: @js($src),
        sampleTimes: @js($sampleTimes),
        player: @js((string) $player),
        captureEnabled: @js((bool) $capture),
        current: 0,
        duration: 0,
        sampling: false,
        captureError: null,
        get selectedItem() {
            return this.items.find((item) => item.id === this.selectedId) || null;
        },
        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}`;
        },
        select(item) {
            this.selectedId = item.id;
            this.$dispatch('thumbnail-selected', {
                id: item.id,
                src: item.src,
                label: item.label,
                time: item.time,
                player: this.player,
            });
        },
        onTime(detail) {
            if (detail?.player && detail.player !== this.player) { return; }
            this.current = Number(detail?.time ?? this.current);
            this.duration = Number(detail?.duration ?? this.duration);
        },
        async ensureVideo() {
            if (! this.src) { return null; }
            const video = this.$refs.probe;
            if (! video) { return null; }
            if (video.src !== this.src) {
                video.crossOrigin = 'anonymous';
                video.src = this.src;
            }
            if (! Number.isFinite(video.duration) || video.duration === 0) {
                await new Promise((resolve, reject) => {
                    const onLoaded = () => { cleanup(); resolve(); };
                    const onError = () => { cleanup(); reject(new Error('Unable to load video')); };
                    const cleanup = () => {
                        video.removeEventListener('loadedmetadata', onLoaded);
                        video.removeEventListener('error', onError);
                    };
                    video.addEventListener('loadedmetadata', onLoaded, { once: true });
                    video.addEventListener('error', onError, { once: true });
                    video.load();
                });
            }
            this.duration = Number(video.duration || this.duration || 0);
            return video;
        },
        async captureAt(time, id = null, label = null) {
            const video = await this.ensureVideo();
            if (! video) { throw new Error('No video source'); }

            await new Promise((resolve, reject) => {
                const onSeeked = () => { cleanup(); resolve(); };
                const onError = () => { cleanup(); reject(new Error('Seek failed')); };
                const cleanup = () => {
                    video.removeEventListener('seeked', onSeeked);
                    video.removeEventListener('error', onError);
                };
                video.addEventListener('seeked', onSeeked, { once: true });
                video.addEventListener('error', onError, { once: true });
                video.currentTime = Math.min(Math.max(0, time), Math.max(0, (video.duration || time) - 0.05));
            });

            const canvas = document.createElement('canvas');
            canvas.width = video.videoWidth || 640;
            canvas.height = video.videoHeight || 360;
            const context = canvas.getContext('2d');
            context.drawImage(video, 0, 0, canvas.width, canvas.height);

            let dataUrl;
            try {
                dataUrl = canvas.toDataURL('image/jpeg', 0.86);
            } catch (error) {
                throw new Error('Frame capture blocked by CORS');
            }

            const item = {
                id: id || `frame-${Math.round(time * 1000)}`,
                src: dataUrl,
                label: label || this.formatTime(time),
                time: Number(time),
            };

            const existing = this.items.findIndex((row) => row.id === item.id);
            if (existing >= 0) {
                this.items.splice(existing, 1, item);
            } else {
                this.items.push(item);
            }

            return item;
        },
        async sampleFrames() {
            if (! this.src || this.sampling) { return; }
            this.sampling = true;
            this.captureError = null;

            try {
                const video = await this.ensureVideo();
                const duration = Number(video?.duration || 0);
                let times = this.sampleTimes.length
                    ? [...this.sampleTimes]
                    : (duration > 0
                        ? [0, duration * 0.25, duration * 0.5, duration * 0.75, Math.max(0, duration - 0.1)]
                        : [0, 1, 2, 3]);

                times = [...new Set(times.map((time) => Math.max(0, Number(time))))];

                for (const time of times) {
                    await this.captureAt(time);
                }
            } catch (error) {
                this.captureError = error?.message || 'Could not sample frames';
            } finally {
                this.sampling = false;
            }
        },
        async captureCurrent() {
            this.captureError = null;
            try {
                const item = await this.captureAt(this.current, `live-${Math.round(this.current * 1000)}`, `Current · ${this.formatTime(this.current)}`);
                this.select(item);
            } catch (error) {
                this.captureError = error?.message || 'Could not capture frame';
            }
        },
        init() {
            if (this.src && this.items.length === 0) {
                this.sampleFrames();
            }
        },
    }"
    @video-time.window="onTime($event.detail)"
    {{ $attributes->merge(['class' => 'relative min-w-0 max-w-full overflow-hidden rounded-2xl border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-5']) }}
>
    <div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
        <div class="min-w-0 flex-1">
            <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</h3>
            @if (filled($description))
                <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-400">{{ $description }}</p>
            @else
                <p class="mt-1 text-sm leading-5 text-gray-500 dark:text-gray-400">Select a frame to use as the video poster.</p>
            @endif
        </div>

        <div class="flex w-full flex-col gap-2 sm:w-auto sm:flex-row sm:flex-wrap sm:items-center">
            <template x-if="src">
                <button
                    type="button"
                    @click="sampleFrames()"
                    :disabled="sampling"
                    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 disabled:cursor-not-allowed disabled:opacity-50 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="film-strip" size="sm" />
                    <span x-text="sampling ? 'Sampling…' : 'Sample frames'"></span>
                </button>
            </template>

            <template x-if="captureEnabled && src">
                <button
                    type="button"
                    @click="captureCurrent()"
                    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="camera" size="sm" />
                    Use current frame
                </button>
            </template>
        </div>
    </div>

    <p x-show="captureError" x-cloak x-text="captureError" class="mt-3 text-sm text-amber-700 dark:text-amber-300"></p>

    <div class="mt-4 grid gap-2 sm:gap-3 {{ $columnClass }}">
        <template x-for="item in items" :key="item.id">
            <button
                type="button"
                @click="select(item)"
                class="group relative min-w-0 overflow-hidden rounded-xl border bg-gray-100 text-start transition focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:bg-gray-800"
                :class="selectedId === item.id
                    ? 'border-brand-500 ring-1 ring-brand-500/30 dark:border-brand-400'
                    : 'border-gray-200 hover:border-brand-300 dark:border-gray-700 dark:hover:border-brand-700'"
                :aria-pressed="(selectedId === item.id).toString()"
            >
                <div class="aspect-video overflow-hidden">
                    <img :src="item.src" alt="" class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.02]" loading="lazy">
                </div>

                <span
                    x-show="selectedId === item.id"
                    class="absolute start-1.5 top-1.5 inline-flex items-center gap-1 rounded-md bg-brand-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white sm:start-2 sm:top-2"
                >
                    <x-ui.icon name="check" weight="bold" size="xs" />
                    <span class="hidden min-[380px]:inline">Selected</span>
                </span>

                <span
                    x-show="item.label || item.time != null"
                    class="absolute inset-x-0 bottom-0 truncate bg-gradient-to-t from-black/70 to-transparent px-2 pb-2 pt-6 text-xs font-medium text-white"
                    x-text="item.label || formatTime(item.time)"
                ></span>
            </button>
        </template>
    </div>

    <template x-if="items.length === 0">
        <div class="mt-4 flex flex-col items-center justify-center rounded-xl border border-dashed border-gray-300 px-4 py-8 text-center dark:border-gray-700 sm:py-10">
            <x-ui.icon name="image" size="xl" class="text-gray-400 dark:text-gray-500" />
            <p class="mt-2 text-sm font-medium text-gray-700 dark:text-gray-300">No thumbnails yet</p>
            <p class="mt-1 max-w-xs text-xs leading-5 text-gray-500 dark:text-gray-400">Provide items, or sample frames from a video source.</p>
        </div>
    </template>

    <template x-if="selectedItem">
        <div class="mt-4 flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-950/60">
            <img :src="selectedItem.src" alt="" class="h-12 w-20 shrink-0 rounded-lg object-cover sm:h-14 sm:w-24">
            <div class="min-w-0 flex-1">
                <p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Selected thumbnail</p>
                <p class="truncate text-sm font-medium text-gray-900 dark:text-white" x-text="selectedItem.label || selectedItem.id"></p>
            </div>
        </div>
    </template>

    @if (filled($name))
        <input type="hidden" name="{{ $name }}" :value="selectedId || ''">
    @endif

    <video x-ref="probe" class="pointer-events-none absolute h-0 w-0 opacity-0" muted playsinline preload="metadata" crossorigin="anonymous"></video>
</div>