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>