Skip to content
Brok UI

Loading…

No results

Music Player

Open source

Audio players — a spinning vinyl record, a transport-control card, horizontal and vertical Spotify-style cards, and an album crate.

Preview

2 of 5

resources/views/blocks/music-player-card.blade.php Blade
<div
    x-data="uiMusicPlayer()"
    data-slot="music-player-01"
    {{ $attributes->merge(['class' => 'mx-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm']) }}
>
    {{-- Album art --}}
    <div
        class="relative flex h-48 w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br"
        :class="track.art"
        aria-hidden="true"
    >
        <svg class="size-16 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
            <path d="M9 18V5l12-2v13" />
            <circle cx="6" cy="18" r="3" />
            <circle cx="18" cy="16" r="3" />
        </svg>

        {{-- Optional equalizer overlay: purely decorative, motion-safe only. --}}
        <div
            class="absolute bottom-3 end-3 hidden items-end gap-0.5 motion-safe:flex"
            x-show="playing"
            aria-hidden="true"
        >
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.75rem; animation-delay: 0ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.25rem; animation-delay: 150ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.9rem; animation-delay: 300ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.1rem; animation-delay: 450ms"></span>
        </div>
    </div>

    {{-- Track metadata --}}
    <div class="mt-4 min-w-0">
        <p class="truncate font-semibold text-foreground" x-text="track.title">Neon Skyline</p>
        <p class="truncate text-sm text-muted-foreground" x-text="track.artist">The Aurora Set</p>
    </div>

    {{-- Seek bar --}}
    <div class="mt-4">
        <div
            x-ref="seek"
            role="slider"
            aria-label="{{ __('Seek') }}"
            aria-valuemin="0"
            :aria-valuemax="duration"
            :aria-valuenow="Math.round(currentTime)"
            :aria-valuetext="fmt(currentTime) + ' of ' + fmt(duration)"
            tabindex="0"
            class="group relative h-4 w-full cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            @pointerdown="seek(fractionFromEvent($event, $refs.seek))"
            @keydown.left.prevent="nudge(-5)"
            @keydown.right.prevent="nudge(5)"
            @keydown.home.prevent="seek(0)"
            @keydown.end.prevent="seek(1)"
        >
            <div class="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-muted"></div>
            <div
                class="absolute start-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary"
                :style="`width: ${progress * 100}%`"
            ></div>
            <div
                class="absolute top-1/2 size-3 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
                :style="`inset-inline-start: ${progress * 100}%`"
            ></div>
        </div>
        <div class="mt-2 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
            <span x-text="fmt(currentTime)">0:00</span>
            <span x-text="fmt(duration)">3:34</span>
        </div>
    </div>

    {{-- Transport controls --}}
    <div class="mt-4 flex items-center justify-center gap-2">
        <x-ui.button variant="ghost" size="icon" aria-label="{{ __('Previous') }}" x-on:click="prev()">
            <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M6 5a1 1 0 0 1 2 0v5.4l8.5-5.3A1 1 0 0 1 18 6v12a1 1 0 0 1-1.5.86L8 13.6V19a1 1 0 1 1-2 0V5Z" />
            </svg>
        </x-ui.button>

        <x-ui.button variant="default" size="icon-lg" class="rounded-full" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
            <svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" x-show="!playing">
                <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>
            <svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" x-show="playing" x-cloak>
                <path d="M7 4a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Z" />
            </svg>
        </x-ui.button>

        <x-ui.button variant="ghost" size="icon" aria-label="{{ __('Next') }}" x-on:click="next()">
            <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M18 5a1 1 0 0 0-2 0v5.4L7.5 5.1A1 1 0 0 0 6 6v12a1 1 0 0 0 1.5.86L16 13.6V19a1 1 0 1 0 2 0V5Z" />
            </svg>
        </x-ui.button>
    </div>

    {{-- Volume control --}}
    <div class="mt-4 flex items-center gap-2">
        <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path x-show="volume === 0" d="M16 9l4 4m0-4l-4 4" x-cloak />
            <path x-show="volume > 0" d="M15.5 8.5a5 5 0 0 1 0 7" />
            <path x-show="volume > 0.5" d="M18 6a8 8 0 0 1 0 12" x-cloak />
            <path d="M4 9v6h4l5 4V5L8 9H4Z" />
        </svg>
        <div
            x-ref="vol"
            role="slider"
            aria-label="{{ __('Volume') }}"
            aria-valuemin="0"
            aria-valuemax="100"
            :aria-valuenow="Math.round(volume * 100)"
            :aria-valuetext="Math.round(volume * 100) + '%'"
            tabindex="0"
            class="group relative h-4 flex-1 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            @pointerdown="setVolumeAt(fractionFromEvent($event, $refs.vol))"
            @keydown.left.prevent="nudgeVolume(-0.05)"
            @keydown.right.prevent="nudgeVolume(0.05)"
            @keydown.home.prevent="setVolumeAt(0)"
            @keydown.end.prevent="setVolumeAt(1)"
        >
            <div class="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted"></div>
            <div
                class="absolute start-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary"
                :style="`width: ${volume * 100}%`"
            ></div>
            <div
                class="absolute top-1/2 size-3 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
                :style="`inset-inline-start: ${volume * 100}%`"
            ></div>
        </div>
    </div>
</div>
resources/js/ui/music-player-card.js JS
/**
 * Music player behavior (UI demo, no real <audio>).
 *
 * A self-contained transport: a list of demo tracks with title/artist/duration
 * and a token-based album-art gradient class. State tracks the current index,
 * play/pause, elapsed time and volume. A 1s ticker advances `currentTime` while
 * playing and auto-advances to the next track at the end. The seek bar and
 * volume bar are operated by `seek(fraction)` / `nudge()` / `setVolumeAt()`,
 * which the Blade markup wires to pointer and keyboard events.
 *
 * Self-registers on `alpine:init` so import order does not matter. The ticker is
 * cleared in `destroy()`.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiMusicPlayer', (config = {}) => ({
        tracks: config.tracks ?? [
            { title: 'Neon Skyline', artist: 'The Aurora Set', durationSeconds: 214, art: 'from-primary to-accent' },
            { title: 'Paper Lanterns', artist: 'Hazel Quartz', durationSeconds: 178, art: 'from-accent to-secondary' },
            { title: 'Slow Tide', artist: 'Marlow', durationSeconds: 245, art: 'from-secondary to-primary' },
        ],
        index: 0,
        playing: false,
        currentTime: 0,
        volume: 0.7,
        timer: null,

        init() {
            this.timer = setInterval(() => this.tick(), 1000);
        },

        destroy() {
            if (this.timer) clearInterval(this.timer);
        },

        get track() {
            return this.tracks[this.index] ?? { title: '', artist: '', durationSeconds: 0, art: 'from-primary to-accent' };
        },

        get duration() {
            return this.track.durationSeconds || 0;
        },

        // Played fraction (0..1) used to size the filled portion of the seek bar.
        get progress() {
            return this.duration ? Math.min(1, this.currentTime / this.duration) : 0;
        },

        toggle() {
            this.playing = !this.playing;
        },

        load(i) {
            const len = this.tracks.length;
            this.index = ((i % len) + len) % len; // wrap around both directions
            this.currentTime = 0;
        },

        next() {
            this.load(this.index + 1);
        },

        prev() {
            // Restart the current track first if we're past the intro, like real players.
            if (this.currentTime > 3) {
                this.currentTime = 0;
                return;
            }
            this.load(this.index - 1);
        },

        // Seek to an absolute fraction (0..1) of the track.
        seek(fraction) {
            const f = Math.max(0, Math.min(1, fraction));
            this.currentTime = Math.round(f * this.duration);
        },

        // Arrow-key seeking: nudge elapsed time by `step` seconds.
        nudge(step) {
            this.currentTime = Math.max(0, Math.min(this.duration, this.currentTime + step));
        },

        setVolumeAt(fraction) {
            this.volume = Math.max(0, Math.min(1, fraction));
        },

        nudgeVolume(step) {
            this.setVolumeAt(this.volume + step);
        },

        // Resolve a pointer event to a 0..1 fraction along a horizontal track,
        // honouring RTL where the visual origin is on the right.
        fractionFromEvent(event, el) {
            const rect = el.getBoundingClientRect();
            if (!rect.width) return 0;
            let f = (event.clientX - rect.left) / rect.width;
            if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
            return Math.max(0, Math.min(1, f));
        },

        tick() {
            if (!this.playing) return;
            if (this.currentTime + 1 >= this.duration) {
                this.next(); // auto-advance; keeps playing
                return;
            }
            this.currentTime += 1;
        },

        // Format seconds as m:ss.
        fmt(seconds) {
            const s = Math.max(0, Math.floor(seconds || 0));
            const m = Math.floor(s / 60);
            return m + ':' + String(s % 60).padStart(2, '0');
        },
    }));
});

Installation

terminal
php artisan ui:add blocks/music-player-card

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/music-player-card writes only the generated targets below. The CLI validates each file hash before writing and prompts before replacing local changes unless --force is used.

Version
1.0.4
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/music-player-card.blade.php
js resources/js/ui/music-player-card.js

Registry dependencies

button

Package dependencies

composer: jml/brok:^0.2

npm: alpinejs

Use with AI

A brief for your coding agent: what the block is, the install command, how to render it, its props and the rules. Copy it, or open a prompt about this block in an assistant.

music-player-card.md
# Brok UI block: Music Player Card (`music-player-card`)

A polished music player card with album art, track metadata, a seekable progress bar with elapsed/total times, prev/play-pause/next transport controls, and a volume control.

Brok UI is a Laravel Blade registry. `ui:add` copies this block into the app as plain Blade the app owns; it composes installed `<brok:*>` primitives and semantic design tokens.

## Install

```bash
php artisan ui:add blocks/music-player-card
```

## Render it

```blade
<x-blocks.music-player-card />
```

## Use when

- Presenting a self-contained demo music player with full transport (previous, play/pause, next), a seekable progress bar, and a volume slider.
- Wanting a square album-art card that cycles through its own built-in demo playlist.

## Avoid when

- Needing a real audio backend or a vinyl-record visual; use music-player-vinyl for a minimal vinyl toggle, or music-player-spotify/music-player-spotify-vertical for a Spotify-style card.

## Rules

- Render the installed block with `<x-blocks.music-player-card />` and pass data through its props; edit the copied file only for structural changes.
- Keep the semantic design tokens (`bg-background`, `text-muted-foreground`); never swap in raw colour utilities.
- Keep the `data-slot` attributes and the logical (start/end) spacing so the markup still mirrors under `dir="rtl"`.

## Links

- Docs: https://brokui.dev/blocks/music-player-card
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/music-player-card.json

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

Guidance

Use when

  • Presenting a self-contained demo music player with full transport (previous, play/pause, next), a seekable progress bar, and a volume slider.
  • Wanting a square album-art card that cycles through its own built-in demo playlist.

Avoid when

  • Needing a real audio backend or a vinyl-record visual; use music-player-vinyl for a minimal vinyl toggle, or music-player-spotify/music-player-spotify-vertical for a Spotify-style card.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
album art panel with equalizer overlay track title and artist seek bar with elapsed/total time previous/play-pause/next transport volume slider

Usage

Render the block as a component, passing data where useful:

resources/views/welcome.blade.php Blade
<x-blocks.music-player-card />

Built from primitives

Composes <brok:*> Semantic tokens Re-themeable RTL-ready

This block composes installed <brok:*> primitives and semantic design tokens only — it does not reimplement any primitive. Re-theme it (light, dark, admin, customer) by editing CSS variables; flip the preview to RTL to confirm it mirrors.

Source

The exact, editable files ui:add writes into your app. The preview above renders this same source — there are no preview-only blocks.

resources/views/blocks/music-player-card.blade.php Blade
<div
    x-data="uiMusicPlayer()"
    data-slot="music-player-01"
    {{ $attributes->merge(['class' => 'mx-auto w-full max-w-sm rounded-lg border border-border bg-card p-6 text-card-foreground shadow-sm']) }}
>
    {{-- Album art --}}
    <div
        class="relative flex h-48 w-full items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br"
        :class="track.art"
        aria-hidden="true"
    >
        <svg class="size-16 text-primary-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
            <path d="M9 18V5l12-2v13" />
            <circle cx="6" cy="18" r="3" />
            <circle cx="18" cy="16" r="3" />
        </svg>

        {{-- Optional equalizer overlay: purely decorative, motion-safe only. --}}
        <div
            class="absolute bottom-3 end-3 hidden items-end gap-0.5 motion-safe:flex"
            x-show="playing"
            aria-hidden="true"
        >
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.75rem; animation-delay: 0ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.25rem; animation-delay: 150ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 0.9rem; animation-delay: 300ms"></span>
            <span class="block w-1 rounded-full bg-primary-foreground/80 motion-safe:animate-[ui-music-eq_900ms_ease-in-out_infinite]" style="height: 1.1rem; animation-delay: 450ms"></span>
        </div>
    </div>

    {{-- Track metadata --}}
    <div class="mt-4 min-w-0">
        <p class="truncate font-semibold text-foreground" x-text="track.title">Neon Skyline</p>
        <p class="truncate text-sm text-muted-foreground" x-text="track.artist">The Aurora Set</p>
    </div>

    {{-- Seek bar --}}
    <div class="mt-4">
        <div
            x-ref="seek"
            role="slider"
            aria-label="{{ __('Seek') }}"
            aria-valuemin="0"
            :aria-valuemax="duration"
            :aria-valuenow="Math.round(currentTime)"
            :aria-valuetext="fmt(currentTime) + ' of ' + fmt(duration)"
            tabindex="0"
            class="group relative h-4 w-full cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            @pointerdown="seek(fractionFromEvent($event, $refs.seek))"
            @keydown.left.prevent="nudge(-5)"
            @keydown.right.prevent="nudge(5)"
            @keydown.home.prevent="seek(0)"
            @keydown.end.prevent="seek(1)"
        >
            <div class="absolute inset-x-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-muted"></div>
            <div
                class="absolute start-0 top-1/2 h-2 -translate-y-1/2 rounded-full bg-primary"
                :style="`width: ${progress * 100}%`"
            ></div>
            <div
                class="absolute top-1/2 size-3 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
                :style="`inset-inline-start: ${progress * 100}%`"
            ></div>
        </div>
        <div class="mt-2 flex items-center justify-between text-xs tabular-nums text-muted-foreground">
            <span x-text="fmt(currentTime)">0:00</span>
            <span x-text="fmt(duration)">3:34</span>
        </div>
    </div>

    {{-- Transport controls --}}
    <div class="mt-4 flex items-center justify-center gap-2">
        <x-ui.button variant="ghost" size="icon" aria-label="{{ __('Previous') }}" x-on:click="prev()">
            <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M6 5a1 1 0 0 1 2 0v5.4l8.5-5.3A1 1 0 0 1 18 6v12a1 1 0 0 1-1.5.86L8 13.6V19a1 1 0 1 1-2 0V5Z" />
            </svg>
        </x-ui.button>

        <x-ui.button variant="default" size="icon-lg" class="rounded-full" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
            <svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" x-show="!playing">
                <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>
            <svg class="size-6" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" x-show="playing" x-cloak>
                <path d="M7 4a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Zm10 0a1 1 0 0 1 1 1v14a1 1 0 1 1-2 0V5a1 1 0 0 1 1-1Z" />
            </svg>
        </x-ui.button>

        <x-ui.button variant="ghost" size="icon" aria-label="{{ __('Next') }}" x-on:click="next()">
            <svg class="size-5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M18 5a1 1 0 0 0-2 0v5.4L7.5 5.1A1 1 0 0 0 6 6v12a1 1 0 0 0 1.5.86L16 13.6V19a1 1 0 1 0 2 0V5Z" />
            </svg>
        </x-ui.button>
    </div>

    {{-- Volume control --}}
    <div class="mt-4 flex items-center gap-2">
        <svg class="size-4 shrink-0 text-muted-foreground" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
            <path x-show="volume === 0" d="M16 9l4 4m0-4l-4 4" x-cloak />
            <path x-show="volume > 0" d="M15.5 8.5a5 5 0 0 1 0 7" />
            <path x-show="volume > 0.5" d="M18 6a8 8 0 0 1 0 12" x-cloak />
            <path d="M4 9v6h4l5 4V5L8 9H4Z" />
        </svg>
        <div
            x-ref="vol"
            role="slider"
            aria-label="{{ __('Volume') }}"
            aria-valuemin="0"
            aria-valuemax="100"
            :aria-valuenow="Math.round(volume * 100)"
            :aria-valuetext="Math.round(volume * 100) + '%'"
            tabindex="0"
            class="group relative h-4 flex-1 cursor-pointer rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            @pointerdown="setVolumeAt(fractionFromEvent($event, $refs.vol))"
            @keydown.left.prevent="nudgeVolume(-0.05)"
            @keydown.right.prevent="nudgeVolume(0.05)"
            @keydown.home.prevent="setVolumeAt(0)"
            @keydown.end.prevent="setVolumeAt(1)"
        >
            <div class="absolute inset-x-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-muted"></div>
            <div
                class="absolute start-0 top-1/2 h-1 -translate-y-1/2 rounded-full bg-primary"
                :style="`width: ${volume * 100}%`"
            ></div>
            <div
                class="absolute top-1/2 size-3 -translate-x-1/2 rtl:translate-x-1/2 -translate-y-1/2 rounded-full border border-border bg-primary opacity-0 shadow-sm transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100"
                :style="`inset-inline-start: ${volume * 100}%`"
            ></div>
        </div>
    </div>
</div>
resources/js/ui/music-player-card.js JS
/**
 * Music player behavior (UI demo, no real <audio>).
 *
 * A self-contained transport: a list of demo tracks with title/artist/duration
 * and a token-based album-art gradient class. State tracks the current index,
 * play/pause, elapsed time and volume. A 1s ticker advances `currentTime` while
 * playing and auto-advances to the next track at the end. The seek bar and
 * volume bar are operated by `seek(fraction)` / `nudge()` / `setVolumeAt()`,
 * which the Blade markup wires to pointer and keyboard events.
 *
 * Self-registers on `alpine:init` so import order does not matter. The ticker is
 * cleared in `destroy()`.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiMusicPlayer', (config = {}) => ({
        tracks: config.tracks ?? [
            { title: 'Neon Skyline', artist: 'The Aurora Set', durationSeconds: 214, art: 'from-primary to-accent' },
            { title: 'Paper Lanterns', artist: 'Hazel Quartz', durationSeconds: 178, art: 'from-accent to-secondary' },
            { title: 'Slow Tide', artist: 'Marlow', durationSeconds: 245, art: 'from-secondary to-primary' },
        ],
        index: 0,
        playing: false,
        currentTime: 0,
        volume: 0.7,
        timer: null,

        init() {
            this.timer = setInterval(() => this.tick(), 1000);
        },

        destroy() {
            if (this.timer) clearInterval(this.timer);
        },

        get track() {
            return this.tracks[this.index] ?? { title: '', artist: '', durationSeconds: 0, art: 'from-primary to-accent' };
        },

        get duration() {
            return this.track.durationSeconds || 0;
        },

        // Played fraction (0..1) used to size the filled portion of the seek bar.
        get progress() {
            return this.duration ? Math.min(1, this.currentTime / this.duration) : 0;
        },

        toggle() {
            this.playing = !this.playing;
        },

        load(i) {
            const len = this.tracks.length;
            this.index = ((i % len) + len) % len; // wrap around both directions
            this.currentTime = 0;
        },

        next() {
            this.load(this.index + 1);
        },

        prev() {
            // Restart the current track first if we're past the intro, like real players.
            if (this.currentTime > 3) {
                this.currentTime = 0;
                return;
            }
            this.load(this.index - 1);
        },

        // Seek to an absolute fraction (0..1) of the track.
        seek(fraction) {
            const f = Math.max(0, Math.min(1, fraction));
            this.currentTime = Math.round(f * this.duration);
        },

        // Arrow-key seeking: nudge elapsed time by `step` seconds.
        nudge(step) {
            this.currentTime = Math.max(0, Math.min(this.duration, this.currentTime + step));
        },

        setVolumeAt(fraction) {
            this.volume = Math.max(0, Math.min(1, fraction));
        },

        nudgeVolume(step) {
            this.setVolumeAt(this.volume + step);
        },

        // Resolve a pointer event to a 0..1 fraction along a horizontal track,
        // honouring RTL where the visual origin is on the right.
        fractionFromEvent(event, el) {
            const rect = el.getBoundingClientRect();
            if (!rect.width) return 0;
            let f = (event.clientX - rect.left) / rect.width;
            if (getComputedStyle(el).direction === 'rtl') f = 1 - f;
            return Math.max(0, Math.min(1, f));
        },

        tick() {
            if (!this.playing) return;
            if (this.currentTime + 1 >= this.duration) {
                this.next(); // auto-advance; keeps playing
                return;
            }
            this.currentTime += 1;
        },

        // Format seconds as m:ss.
        fmt(seconds) {
            const s = Math.max(0, Math.floor(seconds || 0));
            const m = Math.floor(s / 60);
            return m + ':' + String(s % 60).padStart(2, '0');
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.4
Status
Stable
License
open
Deprecation
Not deprecated
Contract
v1
Foundation
≥ 1.0.0