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>