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>