Media
Media Trimmer code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/media-trimmer.blade.php
@props([
'src' => null,
'poster' => null,
'duration' => null,
'start' => 0,
'end' => null,
'player' => 'default',
'minGap' => 0.5,
'title' => 'Trim clip',
'description' => null,
'preview' => true,
])
@php
/*
| Dual-handle media trimmer. Drag start/end handles on the timeline, scrub
| the selection, and optionally preview the range. Syncs duration from a
| paired player via `video-time`, seeks with `video-seek`, and emits
| `media-trim` whenever the range changes.
|
| Usage:
| <x-ui.media-trimmer
| src="https://example.com/clip.mp4"
| :duration="5"
| :start="0.5"
| :end="4"
| player="watch"
| />
*/
$initialDuration = is_numeric($duration) ? (float) $duration : 0.0;
$initialStart = is_numeric($start) ? (float) $start : 0.0;
$initialEnd = is_numeric($end) ? (float) $end : ($initialDuration > 0 ? $initialDuration : 10.0);
@endphp
<div
x-data="{
player: @js((string) $player),
src: @js($src),
poster: @js($poster),
duration: @js($initialDuration > 0 ? $initialDuration : max($initialEnd, 10)),
start: @js($initialStart),
end: @js($initialEnd),
minGap: @js(max(0.1, (float) $minGap)),
current: @js($initialStart),
dragging: null,
previewing: false,
previewTimer: null,
get rangeDuration() {
return Math.max(0, this.end - this.start);
},
get startPercent() {
return this.duration > 0 ? (this.start / this.duration) * 100 : 0;
},
get endPercent() {
return this.duration > 0 ? (this.end / this.duration) * 100 : 100;
},
get playheadPercent() {
return this.duration > 0 ? (this.current / this.duration) * 100 : 0;
},
formatTime(seconds) {
const totalMs = Math.max(0, Math.round((Number(seconds) || 0) * 10));
const total = Math.floor(totalMs / 10);
const tenths = totalMs % 10;
const m = Math.floor(total / 60);
const s = String(total % 60).padStart(2, '0');
return tenths ? `${m}:${s}.${tenths}` : `${m}:${s}`;
},
clampRange() {
const max = Math.max(this.duration, this.minGap);
this.start = Math.min(Math.max(0, this.start), max - this.minGap);
this.end = Math.max(this.start + this.minGap, Math.min(max, this.end));
},
emitTrim() {
this.$dispatch('media-trim', {
player: this.player,
start: this.start,
end: this.end,
duration: this.rangeDuration,
});
},
seek(time, play = false) {
this.current = time;
this.$dispatch('video-seek', { player: this.player, time });
if (this.$refs.preview) {
this.$refs.preview.currentTime = time;
if (play) {
this.$refs.preview.play?.();
}
}
},
setStart(value) {
this.start = Number(value);
this.clampRange();
this.seek(this.start);
this.emitTrim();
},
setEnd(value) {
this.end = Number(value);
this.clampRange();
this.seek(this.end);
this.emitTrim();
},
timeFromEvent(event) {
const track = this.$refs.track;
if (! track || this.duration <= 0) { return 0; }
const rect = track.getBoundingClientRect();
const clientX = event.touches?.[0]?.clientX ?? event.clientX;
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / Math.max(1, rect.width)));
return ratio * this.duration;
},
beginDrag(which, event) {
event.preventDefault();
this.dragging = which;
const move = (moveEvent) => this.onDrag(moveEvent);
const up = () => {
this.endDrag();
window.removeEventListener('pointermove', move);
window.removeEventListener('pointerup', up);
window.removeEventListener('touchmove', move);
window.removeEventListener('touchend', up);
};
window.addEventListener('pointermove', move);
window.addEventListener('pointerup', up);
window.addEventListener('touchmove', move, { passive: false });
window.addEventListener('touchend', up);
this.onDrag(event);
},
onDrag(event) {
if (! this.dragging) { return; }
if (event.cancelable) { event.preventDefault(); }
const time = this.timeFromEvent(event);
if (this.dragging === 'start') {
this.setStart(Math.min(time, this.end - this.minGap));
} else if (this.dragging === 'end') {
this.setEnd(Math.max(time, this.start + this.minGap));
} else if (this.dragging === 'range') {
const width = this.rangeDuration;
let nextStart = time - (width / 2);
nextStart = Math.min(Math.max(0, nextStart), this.duration - width);
this.start = nextStart;
this.end = nextStart + width;
this.seek(this.start);
this.emitTrim();
}
},
endDrag() {
this.dragging = null;
},
scrub(event) {
if (this.dragging) { return; }
const time = this.timeFromEvent(event);
if (time < this.start) {
this.setStart(time);
} else if (time > this.end) {
this.setEnd(time);
} else {
this.seek(time);
}
},
async togglePreview() {
if (this.previewing) {
this.stopPreview();
return;
}
this.previewing = true;
this.seek(this.start, true);
clearTimeout(this.previewTimer);
const tick = () => {
if (! this.previewing) { return; }
const video = this.$refs.preview;
const time = video ? video.currentTime : this.current;
this.current = time;
if (time >= this.end - 0.05) {
this.stopPreview();
return;
}
this.previewTimer = setTimeout(tick, 80);
};
this.previewTimer = setTimeout(tick, 80);
},
stopPreview() {
this.previewing = false;
clearTimeout(this.previewTimer);
this.previewTimer = null;
this.$refs.preview?.pause?.();
},
onTime(detail) {
if (detail?.player && detail.player !== this.player) { return; }
if (Number(detail?.duration) > 0 && (! this.duration || Math.abs(this.duration - detail.duration) > 0.05)) {
this.duration = Number(detail.duration);
if (this.end > this.duration || this.end <= this.start) {
this.end = this.duration;
}
this.clampRange();
}
if (! this.dragging && ! this.previewing) {
this.current = Number(detail?.time ?? this.current);
}
},
init() {
this.clampRange();
this.emitTrim();
},
destroy() {
this.stopPreview();
},
}"
@video-time.window="onTime($event.detail)"
{{ $attributes->merge(['class' => '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">Drag the handles to set in and out points.</p>
@endif
</div>
<div class="flex w-full flex-wrap items-center gap-2 text-xs tabular-nums text-gray-500 dark:text-gray-400 sm:w-auto sm:justify-end">
<span class="inline-flex min-w-0 items-center rounded-md bg-gray-100 px-2 py-1 font-medium text-gray-700 dark:bg-gray-800 dark:text-gray-200">
<span x-text="formatTime(start)"></span>
<span class="mx-1 text-gray-400">→</span>
<span x-text="formatTime(end)"></span>
</span>
<span class="rounded-md bg-brand-50 px-2 py-1 font-semibold text-brand-700 dark:bg-brand-950/50 dark:text-brand-300">
<span x-text="formatTime(rangeDuration)"></span> selected
</span>
</div>
</div>
@if ($preview && filled($src))
<div class="relative mt-4 overflow-hidden rounded-xl border border-gray-200 bg-black dark:border-gray-800">
<video
x-ref="preview"
class="aspect-video w-full max-w-full object-contain"
src="{{ $src }}"
@if ($poster) poster="{{ $poster }}" @endif
playsinline
preload="metadata"
@timeupdate="if (previewing) { current = $refs.preview.currentTime }"
></video>
</div>
@endif
<div class="mt-4 space-y-3">
<div class="px-3">
<div
x-ref="track"
class="relative h-14 cursor-pointer touch-none rounded-xl bg-gray-100 dark:bg-gray-800 sm:h-12"
@pointerdown="scrub($event)"
role="group"
aria-label="Trim range"
>
<div class="pointer-events-none absolute inset-y-3 start-0 end-0 rounded-full bg-gray-200 dark:bg-gray-700"></div>
<div
class="absolute inset-y-3 rounded-full bg-brand-500/35 dark:bg-brand-400/30"
:style="`left: ${startPercent}%; width: ${Math.max(0, endPercent - startPercent)}%`"
@pointerdown.stop="beginDrag('range', $event)"
></div>
<div
class="pointer-events-none absolute inset-y-2 w-0.5 rounded-full bg-white shadow sm:inset-y-1"
:style="`left: calc(${playheadPercent}% - 1px)`"
></div>
<button
type="button"
class="absolute top-1/2 z-10 flex h-11 w-5 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-md border border-brand-600 bg-white shadow before:absolute before:-inset-x-3 before:-inset-y-2 before:content-[''] focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:bg-gray-950 sm:h-10 sm:w-4"
:style="`left: ${startPercent}%`"
@pointerdown.stop="beginDrag('start', $event)"
aria-label="Trim start"
>
<span class="h-5 w-0.5 rounded-full bg-brand-600"></span>
</button>
<button
type="button"
class="absolute top-1/2 z-10 flex h-11 w-5 -translate-x-1/2 -translate-y-1/2 cursor-ew-resize touch-none items-center justify-center rounded-md border border-brand-600 bg-white shadow before:absolute before:-inset-x-3 before:-inset-y-2 before:content-[''] focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:bg-gray-950 sm:h-10 sm:w-4"
:style="`left: ${endPercent}%`"
@pointerdown.stop="beginDrag('end', $event)"
aria-label="Trim end"
>
<span class="h-5 w-0.5 rounded-full bg-brand-600"></span>
</button>
</div>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<label class="block text-xs font-medium text-gray-600 dark:text-gray-300">
Start
<input
type="number"
inputmode="decimal"
min="0"
step="0.1"
:max="Math.max(0, end - minGap)"
:value="start.toFixed(1)"
@change="setStart($event.target.value)"
class="mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 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:py-2 sm:text-sm"
>
</label>
<label class="block text-xs font-medium text-gray-600 dark:text-gray-300">
End
<input
type="number"
inputmode="decimal"
step="0.1"
:min="start + minGap"
:max="duration"
:value="end.toFixed(1)"
@change="setEnd($event.target.value)"
class="mt-1 w-full rounded-lg border border-gray-200 bg-white px-3 py-2.5 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:py-2 sm:text-sm"
>
</label>
</div>
<div class="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center">
@if ($preview && filled($src))
<button
type="button"
@click="togglePreview()"
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 x-show="! previewing" name="play" weight="fill" size="sm" />
<x-ui.icon x-show="previewing" x-cloak name="pause" weight="fill" size="sm" />
<span x-text="previewing ? 'Stop preview' : 'Preview selection'"></span>
</button>
@endif
<button
type="button"
@click="seek(start)"
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="scissors" size="sm" />
Jump to in
</button>
</div>
</div>
</div>