Skip to main content

Media

Audio Wave Card code

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

resources/views/components/ui/audio-wave-card.blade.php

@props([
    'src',
    'title' => null,
    'artist' => null,
    'artwork' => null,
    'duration' => null,
    'peaks' => null,
    'bars' => 48,
    'autoplay' => false,
    'muted' => false,
    'loop' => false,
    'preload' => 'metadata',
    'href' => null,
])

@php
    /*
    | Audio card with a scrubbable waveform. Pass `peaks` as 0–1 heights, or let
    | Harbour generate a deterministic wave from the track identity.
    |
    | Usage:
    |   <x-ui.audio-wave-card
    |       src="/audio/podcast.mp3"
    |       title="Episode 12"
    |       artist="Harbour Radio"
    |       artwork="/images/cover.jpg"
    |   />
    */
    $startMuted = (bool) $muted || (bool) $autoplay;
    $barCount = max(16, min(96, (int) $bars));

    $normalizedPeaks = collect($peaks ?? [])
        ->map(fn ($peak) => max(0.08, min(1, (float) $peak)))
        ->filter()
        ->values()
        ->all();

    if ($normalizedPeaks === []) {
        $seed = crc32((string) ($src.'|'.$title.'|'.$artist));
        $normalizedPeaks = [];

        for ($i = 0; $i < $barCount; $i++) {
            $seed = ($seed * 1103515245 + 12345) & 0x7fffffff;
            $wave = abs(sin(($i / $barCount) * M_PI * 3.2 + ($seed % 100) / 40));
            $noise = (($seed % 1000) / 1000) * 0.45;
            $normalizedPeaks[] = max(0.12, min(1, 0.2 + $wave * 0.55 + $noise));
        }
    } else {
        $normalizedPeaks = array_slice($normalizedPeaks, 0, $barCount);

        while (count($normalizedPeaks) < $barCount) {
            $normalizedPeaks[] = $normalizedPeaks[count($normalizedPeaks) % max(1, count($normalizedPeaks))] ?? 0.35;
        }
    }

    $initialDuration = is_numeric($duration) ? (float) $duration : null;
@endphp

<div
    x-data="{
        playing: false,
        muted: @js($startMuted),
        volume: @js($startMuted ? 0 : 1),
        lastVolume: 1,
        current: 0,
        duration: @js($initialDuration ?? 0),
        seeking: false,
        peaks: @js($normalizedPeaks),
        get progress() {
            return this.duration > 0 ? (this.current / this.duration) * 100 : 0;
        },
        init() {
            const audio = this.$refs.audio;
            if (! audio) { return; }
            audio.volume = this.volume;
            audio.muted = this.muted;
            if (@js((bool) $autoplay)) {
                audio.play().then(() => { this.playing = true; }).catch(() => {});
            }
        },
        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) { return; }
            if (audio.paused || audio.ended) { audio.play(); } else { audio.pause(); }
        },
        seek(event) {
            const audio = this.$refs.audio;
            const wave = this.$refs.wave;
            if (! audio || ! wave || this.duration <= 0) { return; }
            const rect = wave.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)));
            const time = ratio * this.duration;
            this.current = time;
            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.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);
        },
        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();
        },
        syncAudio() {
            const audio = this.$refs.audio;
            if (! audio) { return; }
            audio.muted = this.muted;
            audio.volume = this.muted ? 0 : this.volume;
        },
        barPlayed(index) {
            if (this.duration <= 0) { return false; }
            return ((index + 0.5) / this.peaks.length) * 100 <= this.progress;
        },
        onKeydown(event) {
            const key = event.key.toLowerCase();
            if (key === ' ' || key === 'k') { event.preventDefault(); this.togglePlay(); }
            else if (key === 'm') { event.preventDefault(); this.toggleMute(); }
            else if (key === 'arrowleft' || key === 'j') {
                event.preventDefault();
                const audio = this.$refs.audio;
                if (audio) { audio.currentTime = Math.max(0, audio.currentTime - 5); }
            } else if (key === 'arrowright' || key === 'l') {
                event.preventDefault();
                const audio = this.$refs.audio;
                if (audio) { audio.currentTime = Math.min(this.duration, audio.currentTime + 5); }
            }
        },
    }"
    @keydown="onKeydown($event)"
    tabindex="0"
    role="region"
    aria-label="{{ $title ?: 'Audio wave card' }}"
    {{ $attributes->merge(['class' => 'group/audio-wave min-w-0 max-w-full overflow-hidden rounded-2xl border border-gray-200 bg-white p-3 shadow-sm outline-none transition hover:border-brand-300 focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-brand-700 sm:p-4']) }}
>
    <audio
        x-ref="audio"
        class="hidden"
        src="{{ $src }}"
        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 || duration || 0"
        @ended="playing = false"
    ></audio>

    <div class="flex min-w-0 items-start gap-3">
        @if (filled($href))
            <a href="{{ $href }}" class="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:bg-gray-800 sm:h-14 sm:w-14">
                @if (filled($artwork))
                    <img src="{{ $artwork }}" alt="" class="h-full w-full object-cover">
                @else
                    <span class="flex h-full w-full items-center justify-center text-gray-400 dark:text-gray-500">
                        <x-ui.icon name="music-notes" weight="fill" size="xl" />
                    </span>
                @endif
            </a>
        @else
            <div class="relative h-12 w-12 shrink-0 overflow-hidden rounded-xl bg-gray-100 dark:bg-gray-800 sm:h-14 sm:w-14">
                @if (filled($artwork))
                    <img src="{{ $artwork }}" alt="" class="h-full w-full object-cover">
                @else
                    <div class="flex h-full w-full items-center justify-center text-gray-400 dark:text-gray-500">
                        <x-ui.icon name="music-notes" weight="fill" size="xl" />
                    </div>
                @endif
            </div>
        @endif

        <div class="min-w-0 flex-1">
            <div class="flex min-w-0 items-start justify-between gap-2">
                <div class="min-w-0 flex-1">
                    @if (filled($title))
                        @if (filled($href))
                            <a href="{{ $href }}" class="block truncate text-sm font-semibold text-gray-900 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:text-white dark:hover:text-brand-400">{{ $title }}</a>
                        @else
                            <p class="truncate text-sm font-semibold text-gray-900 dark:text-white">{{ $title }}</p>
                        @endif
                    @endif
                    @if (filled($artist))
                        <p class="truncate text-xs text-gray-500 dark:text-gray-400">{{ $artist }}</p>
                    @endif
                </div>

                <div class="flex shrink-0 items-center gap-1">
                    <button
                        type="button"
                        @click="toggleMute()"
                        class="inline-flex h-9 w-9 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="sm" />
                        <x-ui.icon x-cloak x-show="muted || volume === 0" name="speaker-slash" weight="fill" size="sm" />
                    </button>

                    <button
                        type="button"
                        @click="togglePlay()"
                        class="inline-flex h-10 w-10 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="base" class="ms-0.5" />
                        <x-ui.icon x-cloak x-show="playing" name="pause" weight="fill" size="base" />
                    </button>
                </div>
            </div>

            <div
                x-ref="wave"
                class="mt-3 flex h-12 cursor-pointer touch-none items-end gap-px rounded-lg bg-gray-50 px-1.5 py-1.5 dark:bg-gray-950/60 sm:h-14 sm:gap-0.5"
                role="slider"
                tabindex="0"
                aria-label="Seek waveform"
                :aria-valuemin="0"
                :aria-valuemax="Math.floor(duration)"
                :aria-valuenow="Math.floor(current)"
                @pointerdown.prevent="startSeek($event)"
                @keydown.arrow-left.prevent="$refs.audio && ($refs.audio.currentTime = Math.max(0, $refs.audio.currentTime - 5))"
                @keydown.arrow-right.prevent="$refs.audio && ($refs.audio.currentTime = Math.min(duration, $refs.audio.currentTime + 5))"
            >
                <template x-for="(peak, index) in peaks" :key="index">
                    <span
                        class="min-w-[2px] flex-1 rounded-full transition-colors duration-75"
                        :class="barPlayed(index)
                            ? 'bg-brand-500 dark:bg-brand-400'
                            : 'bg-gray-300 dark:bg-gray-600'"
                        :style="`height: ${Math.max(12, peak * 100)}%`"
                    ></span>
                </template>
            </div>

            <div class="mt-2 flex items-center justify-between gap-3 font-mono text-[11px] tabular-nums text-gray-400 dark:text-gray-500">
                <span x-text="formatTime(current)">0:00</span>
                <span class="inline-flex items-center gap-1">
                    <x-ui.icon name="waveform" size="xs" class="text-gray-300 dark:text-gray-600" />
                    <span x-text="formatTime(duration)">{{ is_numeric($duration) ? gmdate('i:s', (int) $duration) : '0:00' }}</span>
                </span>
            </div>
        </div>
    </div>
</div>