Skip to main content

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>