Skip to content
Brok UI

Loading…

No results

Audio Player

Open source

A working audio player around a native <audio> element: play/pause, a seekable scrub-bar with time read-outs, playback-rate cycling and a mute toggle. Localised labels, RTL-aware, reduced-motion safe.

Version
v1.1.3
Stability
stable
License
MIT
Related
Bar Visualizer

Preview

Sample track

ElevenLabs UI

0:00
0:00
previews.components.audio-player.default.blade.php Blade
<div class="mx-auto w-full max-w-md">
    <x-ui.audio-player
        src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
        :title="__('Sample track')"
        :artist="__('ElevenLabs UI')"
    />
</div>

Installation

terminal
php artisan ui:add audio-player

Note

This component ships an Alpine behavior module at resources/js/ui/audio-player.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './audio-player.js';

Registry contract

php artisan ui:add audio-player writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/audio-player.blade.php
  • js resources/js/ui/audio-player.js
Registry dependencies
button dropdown scrub-bar
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

audio-player.md
# Brok UI: Audio Player (`audio-player`)

A working audio player around a native <audio> element: play/pause, a seekable scrub-bar with time read-outs, playback-rate cycling and a mute toggle. Localised labels, RTL-aware, reduced-motion safe.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add audio-player
```

## Usage

```blade
<div class="mx-auto w-full max-w-md">
    <x-ui.audio-player
        src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
        :title="__('Sample track')"
        :artist="__('ElevenLabs UI')"
    />
</div>
```

## Props

- `src` (string, default ``)
- `title` (mixed|null, default `null`)
- `artist` (mixed|null, default `null`)
- `rates` (array, default `[0.5, 1, 1.5, 2]`)
- `autoplay` (bool, default `false`)

## Use when

- Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
- Playing a single track, podcast episode or voice note with real transport controls.
- Giving a native audio element a token-styled surface with a title and artist.

## Avoid when

- Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
- Provide keyboard controls, visible playback state, time information, and a transcript or equivalent where practical.
- Building a playlist or queue; compose several players or a media block instead.
- Seeking without playback; scrub-bar is the standalone control.

## Anti-patterns

- Autoplay with sound
- Audio as the only access to essential information

## Rules

- Use the `<brok:audio-player>` tag (or `<x-ui.audio-player>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/audio-player
- Registry JSON (files, props, contract): https://brokui.dev/r/open/audio-player.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
src string
title mixed | null null
artist mixed | null null
rates array [0.5, 1, 1.5, 2]
autoplay bool false

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

audio-player audio-player-artist audio-player-current audio-player-duration audio-player-title audio-player-volume

Behavior

  • Play, pause, seek, playback-rate cycling and mute drive the wrapped native audio element, so the browser keeps its own controls and media session.
  • The scrub-bar follows playback time and seeks on drag or keyboard; the current and total time read out beside it.
  • rates lists the playback rates the speed control cycles through.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, reducedMotion.

Guidance

Audio and voice interaction

Play, inspect, record, or dictate temporal audio.

Use when

  • Use when listening, dictation, or audio feedback is central to the task and users benefit from temporal media controls.
  • Playing a single track, podcast episode or voice note with real transport controls.
  • Giving a native audio element a token-styled surface with a title and artist.

Avoid when

  • Do not default to voice or audio UI when silent, scannable controls are faster or more accessible.
  • Provide keyboard controls, visible playback state, time information, and a transcript or equivalent where practical.
  • Building a playlist or queue; compose several players or a media block instead.
  • Seeking without playback; scrub-bar is the standalone control.

Use instead

  • Silent scannable controls
  • Transcript or text input

Anti-patterns

  • Autoplay with sound
  • Audio as the only access to essential information
Anatomy
root audio-player-title audio-player-artist audio-player-current audio-player-duration audio-player-volume
Theming hooks
audio-player audio-player-artist audio-player-current audio-player-duration audio-player-title audio-player-volume

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
native
Focus
native
  • Every transport control is a button with a translated label that changes with state, and mute reports aria-pressed.
  • The volume slider exposes its value as a percentage through aria-valuetext.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="audio-player-{{ $record->id }}">
    <div class="mx-auto w-full max-w-md">
        <x-ui.audio-player
            src="https://upload.wikimedia.org/wikipedia/commons/c/c8/Example.ogg"
            :title="__('Sample track')"
            :artist="__('ElevenLabs UI')"
        />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:audio-player
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/audio-player.blade.php Blade
@props([
    'src' => '',
    'title' => null,
    'artist' => null,
    'rates' => [0.5, 1, 1.5, 2],
    'autoplay' => false,
])

@php
    // A working audio player: a native <audio> element driven by Alpine, with a
    // play/pause control (x-ui.button), a seek <x-ui.scrub-bar>, time read-outs,
    // a playback-rate menu and a mute toggle with a hover volume slider. State of truth is the media
    // element; the scrubber is a controlled view wired two-ways by events —
    // `scrub` (up) seeks the audio, `scrubset` (down) reflects timeupdate.
    //
    // User-facing labels are localised here and passed into the Alpine scope so
    // the dynamic aria-labels stay translatable without leaking Blade into JS.
    $rates = collect(is_array($rates) ? $rates : [1])
        ->map(fn ($r) => (float) $r)
        ->filter(fn ($r) => $r > 0)
        ->values();
    $rates = $rates->isEmpty() ? collect([1.0]) : $rates;

    $config = [
        'src' => (string) $src,
        'rates' => $rates->all(),
        'autoplay' => (bool) $autoplay,
        'labels' => [
            'play' => __('Play'),
            'pause' => __('Pause'),
            'mute' => __('Mute'),
            'unmute' => __('Unmute'),
            'speed' => __('Playback speed'),
        ],
    ];
@endphp

<div
    data-slot="audio-player"
    x-data="uiAudioPlayer(@js($config))"
    @scrub="seek($event.detail.time)"
    @scrubstart="seeking = true"
    @scrubend="seeking = false"
    {{ $attributes->merge(['class' => 'bg-card text-card-foreground flex w-full max-w-md flex-col gap-3 rounded-lg border border-border p-6 shadow-sm']) }}
>
    <audio
        x-ref="audio"
        preload="metadata"
        class="sr-only"
        @loadedmetadata="onLoaded()"
        @durationchange="onLoaded()"
        @timeupdate="onTimeUpdate()"
        @play="playing = true"
        @pause="playing = false"
        @ended="onEnded()"
        @waiting="buffering = true"
        @playing="buffering = false"
        @ratechange="rate = $event.target.playbackRate"
    ></audio>

    @if ($title || $artist)
        <div class="flex flex-col gap-0.5 overflow-hidden">
            @if ($title)
                <p data-slot="audio-player-title" class="truncate text-sm font-medium text-foreground">{{ $title }}</p>
            @endif
            @if ($artist)
                <p data-slot="audio-player-artist" class="truncate text-xs text-muted-foreground">{{ $artist }}</p>
            @endif
        </div>
    @endif

    <div class="flex items-center gap-3">
        <x-ui.button
            size="icon"
            variant="default"
            class="shrink-0"
            @click="toggle()"
            ::aria-label="playing ? labels.pause : labels.play"
            ::data-state="playing ? 'playing' : 'paused'"
        >
            {{-- Buffering spinner --}}
            <svg x-show="buffering" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" class="size-5 motion-safe:animate-spin">
                <circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="2" class="opacity-25" />
                <path d="M21 12a9 9 0 0 0-9-9" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
            </svg>
            {{-- Play --}}
            <svg x-show="!playing && !buffering" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-5">
                <path d="M8 5.14v13.72a1 1 0 0 0 1.54.84l10.29-6.86a1 1 0 0 0 0-1.68L9.54 4.3A1 1 0 0 0 8 5.14Z" />
            </svg>
            {{-- Pause --}}
            <svg x-show="playing && !buffering" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" class="size-5">
                <path d="M7 5a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1H7Zm8 0a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V6a1 1 0 0 0-1-1h-2Z" />
            </svg>
        </x-ui.button>

        <span data-slot="audio-player-current" class="w-10 shrink-0 text-end text-xs tabular-nums text-muted-foreground" x-text="format(current)">0:00</span>

        <x-ui.scrub-bar x-ref="scrub" class="grow" :label="__('Seek')" />

        <span data-slot="audio-player-duration" class="w-10 shrink-0 text-xs tabular-nums text-muted-foreground" x-text="format(duration)">0:00</span>

        {{-- Playback speed: a menu of the configured rates, the active one checked. --}}
        <x-ui.dropdown class="shrink-0">
            <x-ui.dropdown.trigger
                class="h-9 min-w-11 rounded-md px-2 text-xs font-medium tabular-nums text-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-[length:var(--ring-width)] focus-visible:ring-ring"
                ::aria-label="`${labels.speed}: ${rate}×`"
                x-text="`${rate}×`"
            >1×</x-ui.dropdown.trigger>
            <x-ui.dropdown.content align="end" class="min-w-32">
                <x-ui.dropdown.label>{{ __('Speed') }}</x-ui.dropdown.label>
                <x-ui.dropdown.radio-group>
                    @foreach ($rates as $r)
                        <x-ui.dropdown.radio-item
                            ::aria-checked="rate === {{ $r }}"
                            ::data-state="rate === {{ $r }} ? 'checked' : 'unchecked'"
                            @click="setRate({{ $r }})"
                            class="tabular-nums"
                        >{{ rtrim(rtrim(number_format($r, 2), '0'), '.') }}×</x-ui.dropdown.radio-item>
                    @endforeach
                </x-ui.dropdown.radio-group>
            </x-ui.dropdown.content>
        </x-ui.dropdown>

        {{-- Volume: the mute toggle, with a level slider that slides open on
             hover or focus (YouTube-style) so the row stays compact. --}}
        <div data-slot="audio-player-volume" class="group/volume flex shrink-0 items-center">
            <x-ui.button
                size="icon"
                variant="ghost"
                class="shrink-0"
                @click="toggleMute()"
                ::aria-label="muted ? labels.unmute : labels.mute"
                ::aria-pressed="muted"
            >
                <svg x-show="!muted && volume > 0.5" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
                    <path d="M11 5 6 9H2v6h4l5 4V5Z" />
                    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
                    <path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
                </svg>
                <svg x-show="!muted && volume <= 0.5" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
                    <path d="M11 5 6 9H2v6h4l5 4V5Z" />
                    <path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
                </svg>
                <svg x-show="muted" x-cloak aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-5">
                    <path d="M11 5 6 9H2v6h4l5 4V5Z" />
                    <line x1="22" y1="9" x2="16" y2="15" />
                    <line x1="16" y1="9" x2="22" y2="15" />
                </svg>
            </x-ui.button>
            <div class="flex w-0 items-center overflow-hidden transition-[width] duration-200 ease-out group-hover/volume:w-24 group-focus-within/volume:w-24 motion-reduce:transition-none">
                <div class="shrink-0 ps-1 pe-2">
                    <input
                        type="range"
                        min="0"
                        max="1"
                        step="0.05"
                        aria-label="{{ __('Volume') }}"
                        ::aria-valuetext="`${Math.round(volume * 100)}%`"
                        x-bind:value="muted ? 0 : volume"
                        x-bind:style="{ '--v': Math.round((muted ? 0 : volume) * 100) + '%' }"
                        @input="setVolume($event.target.value)"
                        {{-- A styled native range: a 4px track whose fill stops at --v and a
                             12px round thumb centred on it, so the two line up in every
                             browser (the stock control draws them on different baselines). --}}
                        class="block h-3 w-20 cursor-pointer appearance-none rounded-full bg-transparent focus-visible:outline-none
                            [&::-webkit-slider-runnable-track]:h-1 [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--color-primary)_var(--v),var(--color-secondary)_var(--v))]
                            [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-background [&::-webkit-slider-thumb]:bg-primary [&::-webkit-slider-thumb]:shadow-sm
                            [&::-moz-range-track]:h-1 [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-secondary [&::-moz-range-progress]:h-1 [&::-moz-range-progress]:rounded-full [&::-moz-range-progress]:bg-primary
                            [&::-moz-range-thumb]:size-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-background [&::-moz-range-thumb]:bg-primary
                            focus-visible:[&::-webkit-slider-thumb]:ring-2 focus-visible:[&::-webkit-slider-thumb]:ring-ring"
                    />
                </div>
            </div>
        </div>
    </div>
</div>
resources/js/ui/audio-player.js JS
/**
 * Audio Player behaviour.
 *
 * Wraps a native <audio> element (the single source of truth) and exposes the
 * reactive state the chrome binds to: playing / buffering / muted / current /
 * duration / rate. The <x-ui.scrub-bar> child is a controlled view wired by
 * events:
 *   • scrub-bar → player: `scrub` seeks the audio (handled in Blade).
 *   • player → scrub-bar: every timeupdate we dispatch `scrubset` onto the
 *     scrub-bar root so its fill/thumb mirror playback. While the user is
 *     actively dragging (`seeking`), we stop pushing so we don't fight them.
 *
 * The media element's own listeners are declared in the template (x-on), so
 * Alpine attaches and removes them automatically; destroy() just pauses so a
 * unit unmounting mid-playback goes quiet.
 *
 * Self-registers on `alpine:init`. Plain JS — no Blade syntax.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiAudioPlayer', (config = {}) => ({
        src: config.src || '',
        rates: Array.isArray(config.rates) && config.rates.length ? config.rates : [1],
        autoplay: !!config.autoplay,
        labels: config.labels || {},

        playing: false,
        buffering: false,
        muted: false,
        seeking: false,
        current: 0,
        duration: 0,
        rate: 1,
        rateIndex: 0,
        volume: 1,
        _lastVolume: 1,

        init() {
            const audio = this.$refs.audio;
            if (!audio) return;
            // Pick the index whose rate is 1× (the natural default), else first.
            const oneIdx = this.rates.indexOf(1);
            this.rateIndex = oneIdx >= 0 ? oneIdx : 0;
            this.rate = this.rates[this.rateIndex];
            audio.playbackRate = this.rate;
            audio.muted = this.muted;
            audio.volume = this.volume;

            if (this.src) {
                audio.src = this.src;
                audio.load();
            }
            if (this.autoplay && this.src) {
                // Autoplay may be blocked; swallow the rejection.
                this.$nextTick(() => audio.play().catch(() => {}));
            }
        },

        get isFiniteDuration() {
            return Number.isFinite(this.duration) && this.duration > 0;
        },

        format(seconds) {
            if (!Number.isFinite(seconds) || seconds < 0) seconds = 0;
            const total = Math.floor(seconds);
            const h = Math.floor(total / 3600);
            const m = Math.floor((total % 3600) / 60);
            const s = total % 60;
            const mm = h > 0 ? String(m).padStart(2, '0') : String(m);
            const ss = String(s).padStart(2, '0');
            return h > 0 ? `${h}:${mm}:${ss}` : `${mm}:${ss}`;
        },

        // The scrub-bar starts its OWN Alpine scope, so its `x-ref` is not visible
        // in this component's `$refs`. Resolve it by data-slot within our root
        // instead (cached) — robust regardless of Alpine scope boundaries.
        scrubEl() {
            if (!this._scrubEl) {
                this._scrubEl = this.$root.querySelector('[data-slot="scrub-bar"]');
            }
            return this._scrubEl;
        },

        // Reflect playback position + duration down to the scrub-bar (no re-emit).
        pushScrub() {
            const scrub = this.scrubEl();
            if (!scrub) return;
            scrub.dispatchEvent(
                new CustomEvent('scrubset', {
                    detail: { time: this.current, duration: this.isFiniteDuration ? this.duration : 0 },
                })
            );
        },

        onLoaded() {
            const audio = this.$refs.audio;
            this.duration = Number.isFinite(audio?.duration) ? audio.duration : 0;
            this.pushScrub();
        },

        onTimeUpdate() {
            const audio = this.$refs.audio;
            if (!audio) return;
            this.current = audio.currentTime || 0;
            if (!this.seeking) this.pushScrub();
        },

        onEnded() {
            this.playing = false;
        },

        toggle() {
            const audio = this.$refs.audio;
            if (!audio || !this.src) return;
            if (audio.paused) {
                audio.play().catch(() => {});
            } else {
                audio.pause();
            }
        },

        seek(time) {
            const audio = this.$refs.audio;
            if (!audio || !this.isFiniteDuration) return;
            const t = Math.min(this.duration, Math.max(0, Number(time) || 0));
            audio.currentTime = t;
            this.current = t;
        },

        cycleRate() {
            this.setRate(this.rates[(this.rateIndex + 1) % this.rates.length]);
        },

        setRate(rate) {
            const audio = this.$refs.audio;
            const idx = this.rates.indexOf(Number(rate));
            this.rateIndex = idx >= 0 ? idx : 0;
            this.rate = this.rates[this.rateIndex];
            if (audio) audio.playbackRate = this.rate;
        },

        toggleMute() {
            const audio = this.$refs.audio;
            this.muted = !this.muted;
            if (audio) audio.muted = this.muted;
            // Unmuting at zero volume would stay silent: restore the last level.
            if (!this.muted && this.volume === 0) this.setVolume(this._lastVolume || 1);
        },

        // 0..1. Sliding to zero mutes; sliding back up unmutes.
        setVolume(level) {
            const audio = this.$refs.audio;
            this.volume = Math.min(1, Math.max(0, Number(level) || 0));
            if (this.volume > 0) this._lastVolume = this.volume;
            if (audio) {
                audio.volume = this.volume;
                audio.muted = this.volume === 0;
            }
            this.muted = this.volume === 0;
        },

        destroy() {
            const audio = this.$refs.audio;
            if (audio && !audio.paused) audio.pause();
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.1.3
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0