Skip to main content

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>