Skip to main content

Media

Volume Meter code

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

resources/views/components/ui/volume-meter.blade.php

@props([
    'level' => 0,
    'peaks' => null,
    'bars' => 16,
    'orientation' => 'horizontal',
    'label' => 'Levels',
    'player' => null,
    'live' => false,
])

@php
    /*
    | Audio level / volume meter with brand-coloured bars. Bind `level` (0–1)
    | or pass `peaks`. When `live` is true, animates a soft idle pulse.
    |
    | Usage:
    |   <x-ui.volume-meter :level="0.62" />
    |   <x-ui.volume-meter orientation="vertical" :bars="20" live />
    */
    $orientation = in_array($orientation, ['horizontal', 'vertical'], true) ? $orientation : 'horizontal';
    $barCount = max(8, min(48, (int) $bars));
    $normalizedPeaks = collect($peaks ?? [])
        ->map(fn ($peak) => max(0, min(1, (float) $peak)))
        ->values()
        ->all();

    if ($normalizedPeaks === []) {
        $base = max(0, min(1, (float) $level));
        for ($i = 0; $i < $barCount; $i++) {
            $falloff = 1 - abs(($i / max(1, $barCount - 1)) - $base);
            $normalizedPeaks[] = max(0.08, min(1, $base * (0.35 + $falloff * 0.65)));
        }
    }
@endphp

<div
    x-data="{
        level: @js(max(0, min(1, (float) $level))),
        peaks: @js($normalizedPeaks),
        live: @js((bool) $live),
        player: @js($player),
        tick: 0,
        timer: null,
        get displayPeaks() {
            if (! this.live) { return this.peaks; }
            const t = this.tick / 8;
            return this.peaks.map((peak, index) => {
                const wave = 0.55 + 0.45 * Math.sin(t + index * 0.55);
                return Math.max(0.08, Math.min(1, Math.max(this.level, peak * 0.35) * wave));
            });
        },
        onVolume(detail) {
            if (this.player && detail?.player && detail.player !== this.player) { return; }
            if (detail?.volume != null) {
                this.level = Math.max(0, Math.min(1, Number(detail.volume)));
            }
            if (detail?.muted) { this.level = 0; }
        },
        init() {
            if (! this.live) { return; }
            this.timer = setInterval(() => { this.tick += 1; }, 80);
        },
        destroy() {
            if (this.timer) { clearInterval(this.timer); }
        },
    }"
    @media-volume.window="onVolume($event.detail)"
    {{ $attributes->merge([
        'class' => $orientation === 'vertical'
            ? 'inline-flex min-h-24 min-w-0 flex-col items-center gap-2 rounded-2xl border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900'
            : 'min-w-0 max-w-full rounded-2xl border border-gray-200 bg-white p-3 shadow-sm dark:border-gray-800 dark:bg-gray-900 sm:p-4',
    ]) }}
    role="meter"
    aria-label="{{ $label }}"
    :aria-valuemin="0"
    :aria-valuemax="100"
    :aria-valuenow="Math.round(level * 100)"
>
    <div class="flex w-full items-center justify-between gap-2">
        <p class="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{{ $label }}</p>
        <p class="font-mono text-[11px] tabular-nums text-gray-400 dark:text-gray-500">
            <span x-text="Math.round(level * 100)"></span>%
        </p>
    </div>

    <div @class([
        'mt-2 flex w-full',
        'h-28 items-end justify-center gap-1' => $orientation === 'vertical',
        'h-12 items-end gap-0.5 sm:h-14 sm:gap-1' => $orientation === 'horizontal',
    ])>
        <template x-for="(peak, index) in displayPeaks" :key="index">
            <span
                @class([
                    'rounded-full bg-brand-500/90 transition-[height] duration-75 dark:bg-brand-400/90',
                    'w-2.5' => $orientation === 'vertical',
                    'min-w-[3px] flex-1' => $orientation === 'horizontal',
                ])
                :style="`height: ${Math.max(8, peak * 100)}%`"
            ></span>
        </template>
    </div>
</div>