Media
Audio Player code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/audio-player.blade.php
@props([
'src',
'title' => null,
'artist' => null,
'artwork' => null,
'name' => 'default',
'autoplay' => false,
'muted' => false,
'loop' => false,
'preload' => 'metadata',
])
@php
/*
| Compact HTML5 audio player matching the Harbour video chrome — play/pause,
| scrubbing, mute/volume and keyboard shortcuts (Space, ←/→, M).
|
| Event bus (scoped by `name`):
| listen audio-source / video-playback-rate
|
| Usage:
| <x-ui.audio-player
| name="radio"
| src="/audio/podcast.mp3"
| title="Episode 12"
| artist="Harbour Radio"
| artwork="/images/cover.jpg"
| />
*/
$startMuted = (bool) $muted || (bool) $autoplay;
@endphp
<div
x-data="{
player: @js((string) $name),
source: @js((string) $src),
heading: @js($title),
subtitle: @js($artist),
artworkUrl: @js($artwork),
playing: false,
muted: @js($startMuted),
volume: @js($startMuted ? 0 : 1),
lastVolume: 1,
current: 0,
duration: 0,
seeking: false,
get progress() {
return this.duration > 0 ? (this.current / this.duration) * 100 : 0;
},
matchesPlayer(detail) {
const target = detail?.player ?? detail?.name ?? null;
return ! target || target === this.player;
},
init() {
const audio = this.$refs.audio;
audio.volume = this.volume;
audio.muted = this.muted;
if (@js((bool) $autoplay)) {
audio.play().then(() => { this.playing = true; }).catch(() => {});
}
},
applySource({ src, title = null, artist = null, artwork = null, play = true } = {}) {
if (! src) { return; }
const audio = this.$refs.audio;
this.source = src;
if (title !== null) { this.heading = title; }
if (artist !== null) { this.subtitle = artist; }
if (artwork !== null) { this.artworkUrl = artwork; }
const onReady = () => {
audio.removeEventListener('loadedmetadata', onReady);
if (play) {
audio.play().then(() => { this.playing = true; }).catch(() => {});
}
};
audio.addEventListener('loadedmetadata', onReady);
audio.src = src;
audio.load();
},
setPlaybackRate(rate) {
const audio = this.$refs.audio;
const next = Number(rate);
if (! audio || ! Number.isFinite(next) || next <= 0) { return; }
audio.playbackRate = next;
},
formatTime(seconds) {
if (! Number.isFinite(seconds) || seconds < 0) { return '0:00'; }
const total = Math.floor(seconds);
const m = Math.floor(total / 60);
const s = String(total % 60).padStart(2, '0');
return `${m}:${s}`;
},
togglePlay() {
const audio = this.$refs.audio;
if (audio.paused || audio.ended) { audio.play(); } else { audio.pause(); }
},
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.audio.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 audio = this.$refs.audio;
audio.muted = this.muted;
audio.volume = this.muted ? 0 : this.volume;
},
skip(delta) {
const audio = this.$refs.audio;
audio.currentTime = Math.min(this.duration, Math.max(0, audio.currentTime + delta));
},
onKeydown(event) {
const key = event.key.toLowerCase();
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(); }
},
}"
@keydown="onKeydown($event)"
@audio-source.window="if (matchesPlayer($event.detail)) applySource($event.detail)"
@video-playback-rate.window="if (matchesPlayer($event.detail)) setPlaybackRate($event.detail.rate)"
tabindex="0"
role="region"
aria-label="{{ $title ?: 'Audio player' }}"
{{ $attributes->merge(['class' => 'min-w-0 max-w-full rounded-2xl border border-gray-200 bg-white p-4 shadow-sm outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-900 sm:p-5']) }}
>
<audio
x-ref="audio"
class="hidden"
src="{{ $src }}"
x-bind:src="source"
preload="{{ $preload }}"
@if ($loop) loop @endif
@if ($startMuted) muted @endif
@play="playing = true"
@pause="playing = false"
@timeupdate="if (! seeking) current = $refs.audio.currentTime"
@loadedmetadata="duration = $refs.audio.duration || 0"
@ended="playing = false"
></audio>
<div class="flex flex-col gap-4 sm:flex-row sm:items-center">
<div class="flex min-w-0 items-center gap-3 sm:flex-1">
<div class="relative h-14 w-14 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 sm:h-16 sm:w-16">
<template x-if="artworkUrl">
<img :src="artworkUrl" alt="" class="h-full w-full object-cover">
</template>
<template x-if="! artworkUrl">
<div class="flex h-full w-full items-center justify-center text-gray-400">
<x-ui.icon name="music-notes" weight="fill" size="xl" />
</div>
</template>
</div>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-gray-900 dark:text-white" x-text="heading || 'Audio'"></p>
<p class="truncate text-xs text-gray-500 dark:text-gray-400" x-show="subtitle" x-text="subtitle"></p>
<p class="mt-1 font-mono text-[11px] tabular-nums text-gray-400 dark:text-gray-500">
<span x-text="formatTime(current)"></span>
<span> / </span>
<span x-text="formatTime(duration)"></span>
</p>
</div>
</div>
<div class="flex items-center gap-1.5 sm:shrink-0">
<button
type="button"
@click="skip(-10)"
class="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-100 hover:text-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Back 10 seconds"
>
<x-ui.icon name="arrow-counter-clockwise" weight="bold" size="lg" />
</button>
<button
type="button"
@click="togglePlay()"
class="inline-flex h-11 w-11 cursor-pointer items-center justify-center rounded-full bg-brand-600 text-white shadow-sm transition hover:bg-brand-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500"
: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="skip(10)"
class="inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-100 hover:text-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label="Forward 10 seconds"
>
<x-ui.icon name="arrow-clockwise" weight="bold" size="lg" />
</button>
<button
type="button"
@click="toggleMute()"
class="ms-1 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-lg text-gray-500 transition hover:bg-gray-100 hover:text-gray-800 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-white"
: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-gray-200 outline-none sm:block dark:bg-gray-800
[&::-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-brand-600
[&::-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-brand-600"
>
</div>
</div>
<div
class="group/scrub mt-4 h-2 cursor-pointer rounded-full bg-gray-100 dark:bg-gray-800"
role="slider"
tabindex="0"
aria-label="Seek"
:aria-valuemin="0"
:aria-valuemax="Math.floor(duration)"
:aria-valuenow="Math.floor(current)"
@pointerdown.prevent="startSeek($event)"
@keydown.arrow-left.prevent="skip(-5)"
@keydown.arrow-right.prevent="skip(5)"
>
<div class="relative h-full overflow-hidden rounded-full">
<div class="absolute inset-y-0 start-0 rounded-full bg-brand-500" :style="`width: ${progress}%`"></div>
</div>
</div>
</div>