Skip to main content

Media

Video Player code

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

resources/views/components/ui/video-player.blade.php

@props([
    'src',
    'poster' => null,
    'title' => null,
    'name' => 'default',
    'autoplay' => false,
    'muted' => false,
    'loop' => false,
    'playsinline' => true,
    'controls' => true,
    'preload' => 'metadata',
    'type' => null,
    'qualities' => [],
    'quality' => null,
])

@php
    /*
    | Custom HTML5 video player with Alpine-powered controls — play/pause,
    | scrubbing, mute/volume, fullscreen and keyboard shortcuts (Space, ←/→,
    | M, F). Pass `src` (and optional `poster` / `title`). Set `:controls="false"`
    | to hide the chrome and rely on native click-to-toggle only.
    |
    | When `qualities` is provided, a quality selector overlays the control bar.
    | Each quality: label (e.g. 1080p), src, description?
    |
    | Event bus (scoped by `name`):
    |   listen  video-seek / video-source / video-quality /
    |           video-playback-rate / video-text-track
    |   emit    video-time { player, time, duration, playing }
    |
    | Usage:
    |   <x-ui.video-player
    |       name="watch"
    |       src="https://example.com/demo.mp4"
    |       poster="/images/demo.jpg"
    |       title="Product walkthrough"
    |       quality="720p"
    |       :qualities="[
    |           ['label' => '1080p', 'src' => '...'],
    |           ['label' => '720p', 'src' => '...'],
    |       ]"
    |   />
    */
    $startMuted = (bool) $muted || (bool) $autoplay;

    $normalizedQualities = collect($qualities)->map(function ($item, $index) {
        return [
            'id' => (string) ($item['id'] ?? $item['label'] ?? $index),
            'label' => $item['label'] ?? 'Source',
            'src' => $item['src'] ?? null,
            'description' => $item['description'] ?? null,
        ];
    })->filter(fn (array $item) => filled($item['src']))->values()->all();

    $initialQuality = null;

    if (filled($quality)) {
        $match = collect($normalizedQualities)->first(
            fn (array $item) => $item['label'] === $quality || $item['id'] === (string) $quality
        );
        $initialQuality = $match['id'] ?? null;
    }

    if ($initialQuality === null && filled($src)) {
        $match = collect($normalizedQualities)->first(
            fn (array $item) => $item['src'] === $src
        );
        $initialQuality = $match['id'] ?? null;
    }

    $initialQuality ??= $normalizedQualities[0]['id'] ?? null;
    $hasQualities = $normalizedQualities !== [];
@endphp

<div
    x-data="{
        player: @js((string) $name),
        source: @js((string) $src),
        posterUrl: @js($poster),
        heading: @js($title),
        playing: false,
        muted: @js($startMuted),
        volume: @js($startMuted ? 0 : 1),
        lastVolume: 1,
        current: 0,
        duration: 0,
        buffered: 0,
        seeking: false,
        showControls: true,
        fullscreen: false,
        hideTimer: null,
        qualityOpen: false,
        qualities: @js($normalizedQualities),
        selectedQuality: @js($initialQuality),
        get progress() {
            return this.duration > 0 ? (this.current / this.duration) * 100 : 0;
        },
        get bufferedPercent() {
            return this.duration > 0 ? (this.buffered / this.duration) * 100 : 0;
        },
        get currentQuality() {
            return this.qualities.find((item) => item.id === this.selectedQuality) ?? this.qualities[0] ?? null;
        },
        matchesPlayer(detail) {
            const target = detail?.player ?? detail?.name ?? null;
            return ! target || target === this.player;
        },
        init() {
            const video = this.$refs.video;
            video.volume = this.volume;
            video.muted = this.muted;

            if (@js((bool) $autoplay)) {
                video.play().then(() => { this.playing = true; }).catch(() => {});
            }
        },
        applySource({ src, poster = null, title = null, play = true, resume = true, label = null } = {}) {
            if (! src) { return; }
            const video = this.$refs.video;
            const resumeAt = resume ? video.currentTime : 0;
            const wasPlaying = play || this.playing;
            this.source = src;
            if (poster !== null) { this.posterUrl = poster; }
            if (title !== null) { this.heading = title; }

            const matched = this.qualities.find((item) => item.src === src || item.label === label || item.id === label);
            if (matched) { this.selectedQuality = matched.id; }

            const onReady = () => {
                video.removeEventListener('loadedmetadata', onReady);
                if (resumeAt > 0 && Number.isFinite(video.duration)) {
                    video.currentTime = Math.min(resumeAt, video.duration || resumeAt);
                    this.current = video.currentTime;
                }
                if (wasPlaying) {
                    video.play().then(() => { this.playing = true; }).catch(() => {});
                }
            };

            video.addEventListener('loadedmetadata', onReady);
            video.src = src;
            if (this.posterUrl) { video.poster = this.posterUrl; }
            video.load();
        },
        chooseQuality(quality) {
            if (! quality?.src || quality.id === this.selectedQuality) {
                this.qualityOpen = false;
                return;
            }
            this.qualityOpen = false;
            this.applySource({ src: quality.src, label: quality.label, play: true, resume: true });
            this.$dispatch('video-quality', {
                player: this.player,
                src: quality.src,
                label: quality.label,
            });
        },
        seekTo(time) {
            const video = this.$refs.video;
            const next = Math.min(this.duration || Number.MAX_SAFE_INTEGER, Math.max(0, Number(time) || 0));
            video.currentTime = next;
            this.current = next;
        },
        setPlaybackRate(rate) {
            const video = this.$refs.video;
            const next = Number(rate);
            if (! video || ! Number.isFinite(next) || next <= 0) { return; }
            video.playbackRate = next;
        },
        applyTextTrack(detail = {}) {
            const video = this.$refs.video;
            if (! video) { return; }

            const mode = detail.mode === 'showing' ? 'showing' : 'disabled';
            const tracks = Array.from(video.textTracks || []);

            tracks.forEach((track) => { track.mode = 'disabled'; });

            if (mode === 'disabled') { return; }

            const match = tracks.find((track) =>
                track.label === detail.label
                || track.language === detail.lang
                || String(track.id || '') === String(detail.trackId || '')
            );

            if (match) {
                match.mode = 'showing';
                return;
            }

            if (detail.src) {
                Array.from(video.querySelectorAll('track[data-harbour-dynamic=true]')).forEach((node) => node.remove());
                const trackEl = document.createElement('track');
                trackEl.kind = detail.kind || 'subtitles';
                trackEl.label = detail.label || 'Subtitles';
                if (detail.lang) { trackEl.srclang = detail.lang; }
                trackEl.src = detail.src;
                trackEl.default = true;
                trackEl.dataset.harbourDynamic = 'true';
                video.appendChild(trackEl);
                const enable = () => {
                    Array.from(video.textTracks || []).forEach((track) => {
                        track.mode = (track.label === trackEl.label || track.language === trackEl.srclang) ? 'showing' : 'disabled';
                    });
                };
                trackEl.addEventListener('load', enable, { once: true });
                setTimeout(enable, 120);
            }
        },
        emitTime() {
            this.$dispatch('video-time', {
                player: this.player,
                time: this.current,
                duration: this.duration,
                playing: this.playing,
            });
        },
        formatTime(seconds) {
            if (! Number.isFinite(seconds) || seconds < 0) { return '0:00'; }
            const total = Math.floor(seconds);
            const h = Math.floor(total / 3600);
            const m = Math.floor((total % 3600) / 60);
            const s = String(total % 60).padStart(2, '0');
            return h > 0 ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
        },
        togglePlay() {
            const video = this.$refs.video;
            if (video.paused || video.ended) {
                video.play();
            } else {
                video.pause();
            }
        },
        onPlay() { this.playing = true; this.armHide(); },
        onPause() { this.playing = false; this.showControls = true; this.clearHide(); },
        onTimeUpdate() {
            if (this.seeking) { return; }
            this.current = this.$refs.video.currentTime;
            this.updateBuffered();
            this.emitTime();
        },
        onLoadedMetadata() {
            this.duration = this.$refs.video.duration || 0;
            this.updateBuffered();
        },
        updateBuffered() {
            const video = this.$refs.video;
            if (video.buffered.length > 0) {
                this.buffered = video.buffered.end(video.buffered.length - 1);
            }
        },
        seek(event) {
            const rect = event.currentTarget.getBoundingClientRect();
            const ratio = Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width));
            const time = ratio * this.duration;
            this.current = time;
            this.$refs.video.currentTime = time;
        },
        startSeek(event) {
            this.seeking = true;
            this.seek(event);
            const move = (e) => this.seek(e);
            const up = () => {
                this.seeking = false;
                window.removeEventListener('pointermove', move);
                window.removeEventListener('pointerup', up);
            };
            window.addEventListener('pointermove', move);
            window.addEventListener('pointerup', up);
        },
        toggleMute() {
            if (this.muted || this.volume === 0) {
                this.volume = this.lastVolume || 1;
                this.muted = false;
            } else {
                this.lastVolume = this.volume || 1;
                this.muted = true;
            }
            this.syncAudio();
        },
        setVolume(event) {
            const value = Number(event.target.value);
            this.volume = value;
            this.muted = value === 0;
            if (value > 0) { this.lastVolume = value; }
            this.syncAudio();
        },
        syncAudio() {
            const video = this.$refs.video;
            video.muted = this.muted;
            video.volume = this.muted ? 0 : this.volume;
        },
        async toggleFullscreen() {
            const root = this.$refs.root;
            if (! document.fullscreenElement) {
                await root.requestFullscreen?.();
                this.fullscreen = true;
            } else {
                await document.exitFullscreen?.();
                this.fullscreen = false;
            }
        },
        onFullscreenChange() {
            this.fullscreen = document.fullscreenElement === this.$refs.root;
        },
        armHide() {
            this.clearHide();
            if (! this.playing || this.qualityOpen) { return; }
            this.hideTimer = setTimeout(() => { this.showControls = false; }, 2500);
        },
        clearHide() {
            if (this.hideTimer) { clearTimeout(this.hideTimer); this.hideTimer = null; }
        },
        onPointerMove() {
            this.showControls = true;
            this.armHide();
        },
        skip(delta) {
            const video = this.$refs.video;
            video.currentTime = Math.min(this.duration, Math.max(0, video.currentTime + delta));
        },
        onKeydown(event) {
            const key = event.key.toLowerCase();
            if (key === 'escape' && this.qualityOpen) {
                event.preventDefault();
                this.qualityOpen = false;
                return;
            }
            if (key === ' ' || key === 'k') {
                event.preventDefault();
                this.togglePlay();
            } else if (key === 'arrowleft' || key === 'j') {
                event.preventDefault();
                this.skip(-5);
            } else if (key === 'arrowright' || key === 'l') {
                event.preventDefault();
                this.skip(5);
            } else if (key === 'm') {
                event.preventDefault();
                this.toggleMute();
            } else if (key === 'f') {
                event.preventDefault();
                this.toggleFullscreen();
            }
        },
    }"
    x-ref="root"
    @keydown="onKeydown($event)"
    @mousemove="onPointerMove()"
    @mouseleave="playing && ! qualityOpen && (showControls = false)"
    @fullscreenchange.window="onFullscreenChange()"
    @video-seek.window="if (matchesPlayer($event.detail)) seekTo($event.detail.time)"
    @video-source.window="if (matchesPlayer($event.detail)) applySource($event.detail)"
    @video-quality.window="if (matchesPlayer($event.detail)) applySource({ src: $event.detail.src, label: $event.detail.label, play: true, resume: true })"
    @video-playback-rate.window="if (matchesPlayer($event.detail)) setPlaybackRate($event.detail.rate)"
    @video-text-track.window="if (matchesPlayer($event.detail)) applyTextTrack($event.detail)"
    @click.outside="qualityOpen = false"
    tabindex="0"
    role="region"
    aria-label="{{ $title ?: 'Video player' }}"
    {{ $attributes->merge(['class' => 'group/video-player relative isolate overflow-hidden rounded-2xl border border-gray-200 bg-black shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800']) }}
>
    <video
        x-ref="video"
        class="aspect-video w-full bg-black object-contain"
        src="{{ $src }}"
        x-bind:src="source"
        @if ($poster) poster="{{ $poster }}" @endif
        x-bind:poster="posterUrl || undefined"
        @if ($type) type="{{ $type }}" @endif
        preload="{{ $preload }}"
        @if ($loop) loop @endif
        @if ($playsinline) playsinline @endif
        @if ($startMuted) muted @endif
        @play="onPlay()"
        @pause="onPause()"
        @timeupdate="onTimeUpdate()"
        @loadedmetadata="onLoadedMetadata()"
        @progress="updateBuffered()"
        @volumechange="muted = $refs.video.muted; volume = $refs.video.muted ? 0 : $refs.video.volume"
        @ended="playing = false; showControls = true; emitTime()"
        @click="togglePlay()"
    ></video>

    {{-- Large centered play affordance when paused --}}
    <button
        type="button"
        @click="togglePlay()"
        x-show="! playing"
        x-transition.opacity.duration.200ms
        class="absolute inset-0 z-10 flex cursor-pointer items-center justify-center bg-black/25"
        aria-label="Play video"
    >
        <span class="flex h-14 w-14 items-center justify-center rounded-full bg-white/95 text-gray-900 shadow-lg ring-1 ring-black/5 transition hover:scale-105 sm:h-16 sm:w-16 dark:bg-gray-900/95 dark:text-white">
            <x-ui.icon name="play" weight="fill" size="3xl" class="ms-0.5" />
        </span>
    </button>

    <div
        x-show="heading && (showControls || ! playing || qualityOpen)"
        x-cloak
        x-transition.opacity.duration.200ms
        class="pointer-events-none absolute inset-x-0 top-0 z-20 bg-gradient-to-b from-black/70 to-transparent px-3 pb-10 pt-3 sm:px-4 sm:pt-4"
    >
        <p class="truncate text-sm font-medium text-white" x-text="heading">{{ $title }}</p>
    </div>

    @if ($controls)
        <div
            x-show="showControls || ! playing || qualityOpen"
            x-transition.opacity.duration.200ms
            class="absolute inset-x-0 bottom-0 z-20 bg-gradient-to-t from-black/80 via-black/50 to-transparent px-3 pb-3 pt-10"
            @click.stop
        >
            {{-- Progress --}}
            <div
                class="group/scrub relative mb-2.5 h-1.5 cursor-pointer rounded-full bg-white/25"
                role="slider"
                tabindex="0"
                :aria-valuemin="0"
                :aria-valuemax="Math.floor(duration)"
                :aria-valuenow="Math.floor(current)"
                aria-label="Seek"
                @pointerdown.prevent="startSeek($event)"
                @keydown.arrow-left.prevent="skip(-5)"
                @keydown.arrow-right.prevent="skip(5)"
            >
                <div
                    class="absolute inset-y-0 left-0 rounded-full bg-white/35"
                    :style="`width: ${bufferedPercent}%`"
                ></div>
                <div
                    class="absolute inset-y-0 left-0 rounded-full bg-brand-500"
                    :style="`width: ${progress}%`"
                ></div>
                <div
                    class="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow opacity-0 transition group-hover/scrub:opacity-100"
                    :style="`left: ${progress}%`"
                ></div>
            </div>

            <div class="flex items-center gap-1.5 text-white">
                <button
                    type="button"
                    @click="togglePlay()"
                    class="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-white transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
                    :aria-label="playing ? 'Pause' : 'Play'"
                >
                    <x-ui.icon x-show="! playing" name="play" weight="fill" size="lg" class="ms-0.5" />
                    <x-ui.icon x-cloak x-show="playing" name="pause" weight="fill" size="lg" />
                </button>

                <button
                    type="button"
                    @click="toggleMute()"
                    class="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-white transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
                    :aria-label="muted || volume === 0 ? 'Unmute' : 'Mute'"
                >
                    <x-ui.icon x-show="! muted && volume > 0" name="speaker-high" weight="fill" size="lg" />
                    <x-ui.icon x-cloak x-show="muted || volume === 0" name="speaker-slash" weight="fill" size="lg" />
                </button>

                <input
                    type="range"
                    min="0"
                    max="1"
                    step="0.05"
                    :value="muted ? 0 : volume"
                    @input="setVolume($event)"
                    aria-label="Volume"
                    class="hidden h-1.5 w-20 cursor-pointer appearance-none rounded-full bg-white/25 outline-none sm:block
                           [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
                           [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-moz-range-track]:bg-transparent"
                    :style="`background: linear-gradient(to right, #fff ${(muted ? 0 : volume) * 100}%, rgba(255,255,255,0.25) ${(muted ? 0 : volume) * 100}%)`"
                >

                <p class="ms-1 min-w-0 flex-1 truncate font-mono text-xs tabular-nums text-white/90">
                    <span x-text="formatTime(current)"></span>
                    <span class="text-white/50"> / </span>
                    <span x-text="formatTime(duration)"></span>
                </p>

                @if ($hasQualities)
                    <div class="relative">
                        <button
                            type="button"
                            @click="qualityOpen = ! qualityOpen; showControls = true; clearHide()"
                            class="inline-flex h-9 items-center gap-1 rounded-lg px-2 text-xs font-semibold uppercase tracking-wide text-white transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
                            :aria-expanded="qualityOpen.toString()"
                            aria-haspopup="listbox"
                            aria-label="Quality"
                        >
                            <x-ui.icon name="sliders-horizontal" weight="bold" size="sm" class="opacity-80" />
                            <span x-text="currentQuality?.label || 'Auto'"></span>
                        </button>

                        <div
                            x-show="qualityOpen"
                            x-cloak
                            x-transition.opacity.duration.150ms
                            class="absolute bottom-full end-0 z-30 mb-2 w-44 overflow-hidden rounded-xl border border-white/10 bg-gray-950/95 p-1.5 shadow-xl backdrop-blur-md sm:w-52"
                            role="listbox"
                            aria-label="Quality"
                        >
                            <template x-for="item in qualities" :key="item.id">
                                <button
                                    type="button"
                                    role="option"
                                    class="flex w-full items-start gap-2 rounded-lg px-2.5 py-2 text-start text-sm transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/50"
                                    :class="selectedQuality === item.id ? 'bg-white/10 text-white' : 'text-white/80'"
                                    :aria-selected="selectedQuality === item.id"
                                    @click="chooseQuality(item)"
                                >
                                    <span class="min-w-0 flex-1">
                                        <span class="block font-medium" x-text="item.label"></span>
                                        <span x-show="item.description" x-cloak class="mt-0.5 block text-[11px] text-white/50" x-text="item.description"></span>
                                    </span>
                                    <x-ui.icon x-show="selectedQuality === item.id" x-cloak name="check" weight="bold" size="sm" class="mt-0.5 text-brand-400" />
                                </button>
                            </template>
                        </div>
                    </div>
                @endif

                <button
                    type="button"
                    @click="toggleFullscreen()"
                    class="inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg text-white transition hover:bg-white/15 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
                    :aria-label="fullscreen ? 'Exit fullscreen' : 'Enter fullscreen'"
                >
                    <x-ui.icon x-show="! fullscreen" name="corners-out" weight="bold" size="lg" />
                    <x-ui.icon x-cloak x-show="fullscreen" name="corners-in" weight="bold" size="lg" />
                </button>
            </div>
        </div>
    @endif
</div>