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

3 of 5

resources/views/blocks/music-player-spotify.blade.php Blade
@props([
    'heading' => null,
    'title' => 'Neon Skyline',
    'artist' => 'The Aurora Set',
    'art' => 'from-primary to-accent',
    'durationSeconds' => 214,
    'size' => 'md',
])

@php
    // Whole, JIT-visible class strings keyed by size — never splice the size var into a class.
    $sizes = [
        'md' => [
            'card' => 'max-w-md p-4',
            'cover' => 'size-24',
            'note' => 'size-9',
            'disc' => 'size-24',
            'title' => 'text-base',
            'shift' => 'data-[playing=true]:translate-x-12 rtl:data-[playing=true]:-translate-x-12',
        ],
        'lg' => [
            'card' => 'max-w-lg p-6',
            'cover' => 'size-28',
            'note' => 'size-11',
            'disc' => 'size-28',
            'title' => 'text-lg',
            'shift' => 'data-[playing=true]:translate-x-14 rtl:data-[playing=true]:-translate-x-14',
        ],
        'xl' => [
            'card' => 'max-w-xl p-6',
            'cover' => 'size-36',
            'note' => 'size-14',
            'disc' => 'size-36',
            'title' => 'text-xl',
            'shift' => 'data-[playing=true]:translate-x-16 rtl:data-[playing=true]:-translate-x-16',
        ],
    ];
    $s = $sizes[$size] ?? $sizes['md'];
@endphp

<section
    x-data="uiSpotifyCard({ title: @js($title), artist: @js($artist), art: @js($art), durationSeconds: {{ (int) $durationSeconds }} })"
    data-slot="spotify-card"
    @if ($heading) aria-labelledby="spotify-card-heading" @else aria-label="{{ __('Music player') }}" @endif
    {{ $attributes->merge(['class' => 'relative mx-auto w-full overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm ' . $s['card']]) }}
>
    {{-- (a) Full-bleed blurred album-art backdrop + readability overlay --}}
    <div class="pointer-events-none absolute inset-0 -z-10" aria-hidden="true">
        <div class="absolute inset-0 scale-110 bg-gradient-to-br blur-2xl" :class="track.art"></div>
        <div class="absolute inset-0 bg-gradient-to-r from-background/90 to-background/40"></div>
    </div>

    @if ($heading)
        <h2 id="spotify-card-heading" class="mb-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
            {{ $heading }}
        </h2>
    @endif

    {{-- (b) Foreground row --}}
    <div class="relative flex items-center gap-4">
        {{-- Cover + vinyl cluster --}}
        <div class="relative shrink-0 {{ $s['cover'] }}">
            {{-- (c) Vinyl disc behind the cover: slides out to the end side and spins while playing --}}
            <div
                class="absolute top-0 start-0 flex {{ $s['disc'] }} items-center justify-center rounded-full bg-gradient-to-br from-foreground/80 to-foreground/40 shadow-md transition-transform duration-500 ease-out motion-reduce:transition-none {{ $s['shift'] }}"
                :data-playing="playing"
                :class="playing ? 'motion-safe:animate-spin' : ''"
                style="animation-duration: 4s"
                aria-hidden="true"
            >
                {{-- concentric grooves --}}
                <div class="absolute inset-2 rounded-full border border-background/20"></div>
                <div class="absolute inset-5 rounded-full border border-background/20"></div>
                {{-- center label + hole --}}
                <div class="flex size-1/3 items-center justify-center rounded-full bg-gradient-to-br" :class="track.art">
                    <div class="size-1/4 rounded-full bg-card"></div>
                </div>
            </div>

            {{-- Album cover (token gradient + music note) --}}
            <div
                class="relative flex {{ $s['cover'] }} items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br shadow-sm ring-1 ring-border"
                :class="track.art"
                aria-hidden="true"
            >
                <svg class="{{ $s['note'] }} 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>
            </div>
        </div>

        {{-- Metadata + seek + controls --}}
        <div class="min-w-0 flex-1">
            <p class="truncate font-semibold text-foreground {{ $s['title'] }}" x-text="track.title">{{ $title }}</p>
            <p class="truncate text-sm text-muted-foreground" x-text="track.artist">{{ $artist }}</p>

            {{-- 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-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}%; transform: translate(-50%, -50%)`"
                    ></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-2 flex items-center justify-start 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" class="rounded-full" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
                    <svg class="size-5" 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-5" 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>
        </div>
    </div>
</section>
resources/js/ui/music-player-spotify.js JS
/**
 * Spotify-style card behavior (UI demo, no real <audio>, no network).
 *
 * Holds a single demo track (title/artist/duration + a token-based album-art
 * gradient class) plus play/pause and elapsed-time state. A 1s ticker advances
 * `currentTime` while playing and stops at the end of the track. The seek bar is
 * driven by `seek(fraction)` / `nudge()`, which the Blade wires to pointer and
 * keyboard events. The vinyl disc's slide-out + spin are pure CSS toggled off
 * `playing` in the markup.
 *
 * Self-registers on `alpine:init` so import order does not matter. The ticker is
 * cleared in `destroy()` to avoid leaking a timer when the card unmounts.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSpotifyCard', (config = {}) => ({
        track: {
            title: config.title ?? 'Neon Skyline',
            artist: config.artist ?? 'The Aurora Set',
            art: config.art ?? 'from-primary to-accent',
            durationSeconds: config.durationSeconds ?? 214,
        },
        playing: false,
        currentTime: 0,
        timer: null,

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

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

        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;
        },

        // Demo transport: prev restarts the track; next rewinds and pauses at the end.
        prev() {
            this.currentTime = 0;
        },

        next() {
            this.currentTime = this.duration;
            this.playing = false;
        },

        // 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));
        },

        // 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.currentTime = this.duration;
                this.playing = false; // reached the end
                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-spotify

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/music-player-spotify 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.3
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/music-player-spotify.blade.php
js resources/js/ui/music-player-spotify.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-spotify.md
# Brok UI block: Spotify Card (`music-player-spotify`)

A horizontal Spotify-style music card with a blurred album-art backdrop, album cover, track metadata, a seekable progress bar, transport controls, and a vinyl disc that slides out and spins while playing.

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-spotify
```

## Render it

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

## Props

- `heading` (mixed|null, default `null`) — Optional label shown above the card; when set, labels the card via aria-labelledby.
- `title` (string, default `Neon Skyline`) — Track title.
- `artist` (string, default `The Aurora Set`) — Track artist.
- `art` (string, default `from-primary to-accent`) — Token gradient class pair, e.g. 'from-primary to-accent', used for the backdrop, cover, and disc label.
- `duration-seconds` (int, default `214`) — Track duration in seconds, used to compute the seek bar's total time.
- `size` (string, default `md`) — Card size: md, lg, or xl; scales the cover, disc, and title.

## Use when

- Presenting a horizontal Spotify-style music card with a blurred album-art backdrop and a vinyl disc that slides out and spins while playing.
- Choosing a compact size, sm through xl, for a single demo track with seek and transport controls.

## Avoid when

- Needing a volume slider, a built-in multi-track playlist, or a vertical layout; use music-player-card for volume and playlist, or music-player-spotify-vertical for a vertical card.

## Rules

- Render the installed block with `<x-blocks.music-player-spotify />` 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-spotify
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/music-player-spotify.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 horizontal Spotify-style music card with a blurred album-art backdrop and a vinyl disc that slides out and spins while playing.
  • Choosing a compact size, sm through xl, for a single demo track with seek and transport controls.

Avoid when

  • Needing a volume slider, a built-in multi-track playlist, or a vertical layout; use music-player-card for volume and playlist, or music-player-spotify-vertical for a vertical card.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
blurred backdrop album cover with sliding vinyl disc track title and artist seek bar with elapsed/total time previous/play-pause/next transport

Usage

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

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

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-spotify.blade.php Blade
@props([
    'heading' => null,
    'title' => 'Neon Skyline',
    'artist' => 'The Aurora Set',
    'art' => 'from-primary to-accent',
    'durationSeconds' => 214,
    'size' => 'md',
])

@php
    // Whole, JIT-visible class strings keyed by size — never splice the size var into a class.
    $sizes = [
        'md' => [
            'card' => 'max-w-md p-4',
            'cover' => 'size-24',
            'note' => 'size-9',
            'disc' => 'size-24',
            'title' => 'text-base',
            'shift' => 'data-[playing=true]:translate-x-12 rtl:data-[playing=true]:-translate-x-12',
        ],
        'lg' => [
            'card' => 'max-w-lg p-6',
            'cover' => 'size-28',
            'note' => 'size-11',
            'disc' => 'size-28',
            'title' => 'text-lg',
            'shift' => 'data-[playing=true]:translate-x-14 rtl:data-[playing=true]:-translate-x-14',
        ],
        'xl' => [
            'card' => 'max-w-xl p-6',
            'cover' => 'size-36',
            'note' => 'size-14',
            'disc' => 'size-36',
            'title' => 'text-xl',
            'shift' => 'data-[playing=true]:translate-x-16 rtl:data-[playing=true]:-translate-x-16',
        ],
    ];
    $s = $sizes[$size] ?? $sizes['md'];
@endphp

<section
    x-data="uiSpotifyCard({ title: @js($title), artist: @js($artist), art: @js($art), durationSeconds: {{ (int) $durationSeconds }} })"
    data-slot="spotify-card"
    @if ($heading) aria-labelledby="spotify-card-heading" @else aria-label="{{ __('Music player') }}" @endif
    {{ $attributes->merge(['class' => 'relative mx-auto w-full overflow-hidden rounded-lg border border-border bg-card text-card-foreground shadow-sm ' . $s['card']]) }}
>
    {{-- (a) Full-bleed blurred album-art backdrop + readability overlay --}}
    <div class="pointer-events-none absolute inset-0 -z-10" aria-hidden="true">
        <div class="absolute inset-0 scale-110 bg-gradient-to-br blur-2xl" :class="track.art"></div>
        <div class="absolute inset-0 bg-gradient-to-r from-background/90 to-background/40"></div>
    </div>

    @if ($heading)
        <h2 id="spotify-card-heading" class="mb-4 text-xs font-medium uppercase tracking-wide text-muted-foreground">
            {{ $heading }}
        </h2>
    @endif

    {{-- (b) Foreground row --}}
    <div class="relative flex items-center gap-4">
        {{-- Cover + vinyl cluster --}}
        <div class="relative shrink-0 {{ $s['cover'] }}">
            {{-- (c) Vinyl disc behind the cover: slides out to the end side and spins while playing --}}
            <div
                class="absolute top-0 start-0 flex {{ $s['disc'] }} items-center justify-center rounded-full bg-gradient-to-br from-foreground/80 to-foreground/40 shadow-md transition-transform duration-500 ease-out motion-reduce:transition-none {{ $s['shift'] }}"
                :data-playing="playing"
                :class="playing ? 'motion-safe:animate-spin' : ''"
                style="animation-duration: 4s"
                aria-hidden="true"
            >
                {{-- concentric grooves --}}
                <div class="absolute inset-2 rounded-full border border-background/20"></div>
                <div class="absolute inset-5 rounded-full border border-background/20"></div>
                {{-- center label + hole --}}
                <div class="flex size-1/3 items-center justify-center rounded-full bg-gradient-to-br" :class="track.art">
                    <div class="size-1/4 rounded-full bg-card"></div>
                </div>
            </div>

            {{-- Album cover (token gradient + music note) --}}
            <div
                class="relative flex {{ $s['cover'] }} items-center justify-center overflow-hidden rounded-lg bg-gradient-to-br shadow-sm ring-1 ring-border"
                :class="track.art"
                aria-hidden="true"
            >
                <svg class="{{ $s['note'] }} 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>
            </div>
        </div>

        {{-- Metadata + seek + controls --}}
        <div class="min-w-0 flex-1">
            <p class="truncate font-semibold text-foreground {{ $s['title'] }}" x-text="track.title">{{ $title }}</p>
            <p class="truncate text-sm text-muted-foreground" x-text="track.artist">{{ $artist }}</p>

            {{-- 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-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}%; transform: translate(-50%, -50%)`"
                    ></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-2 flex items-center justify-start 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" class="rounded-full" aria-label="{{ __('Play') }}" x-bind:aria-label="playing ? '{{ __('Pause') }}' : '{{ __('Play') }}'" x-on:click="toggle()">
                    <svg class="size-5" 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-5" 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>
        </div>
    </div>
</section>
resources/js/ui/music-player-spotify.js JS
/**
 * Spotify-style card behavior (UI demo, no real <audio>, no network).
 *
 * Holds a single demo track (title/artist/duration + a token-based album-art
 * gradient class) plus play/pause and elapsed-time state. A 1s ticker advances
 * `currentTime` while playing and stops at the end of the track. The seek bar is
 * driven by `seek(fraction)` / `nudge()`, which the Blade wires to pointer and
 * keyboard events. The vinyl disc's slide-out + spin are pure CSS toggled off
 * `playing` in the markup.
 *
 * Self-registers on `alpine:init` so import order does not matter. The ticker is
 * cleared in `destroy()` to avoid leaking a timer when the card unmounts.
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiSpotifyCard', (config = {}) => ({
        track: {
            title: config.title ?? 'Neon Skyline',
            artist: config.artist ?? 'The Aurora Set',
            art: config.art ?? 'from-primary to-accent',
            durationSeconds: config.durationSeconds ?? 214,
        },
        playing: false,
        currentTime: 0,
        timer: null,

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

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

        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;
        },

        // Demo transport: prev restarts the track; next rewinds and pauses at the end.
        prev() {
            this.currentTime = 0;
        },

        next() {
            this.currentTime = this.duration;
            this.playing = false;
        },

        // 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));
        },

        // 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.currentTime = this.duration;
                this.playing = false; // reached the end
                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.3
Status
Stable
License
open
Deprecation
Not deprecated
Contract
v1
Foundation
≥ 1.0.0