Skip to content
Brok UI

Loading…

No results

Media Widget

Open source

Player widgets — a circular radial control pad and a now-playing card with a scrollable playlist.

Preview

resources/views/blocks/media-widget-radial.blade.php Blade
@props([
    // Centre button starts paused; the inline Alpine toggle swaps the icon.
    'playing' => false,
])

{{--
    Each wedge is a real, keyboard-operable control: a <x-ui.button> laid over
    the wedge via <foreignObject>. Clicking/activating one sets `active` to its
    index, which toggles `aria-pressed` on that button and tints its decorative
    wedge — the widget has no backing audio element in this block, so "active"
    tracks the last control the visitor engaged rather than a live volume/seek
    state.
--}}

@php
    // Donut control pad geometry. viewBox 0 0 192 192, centre (96,96),
    // outer radius 88, inner radius 40. Four 90deg arc segments with a small
    // angular gap so the wedges read as discrete controls.
    $cx = 96;
    $cy = 96;
    $outer = 88;
    $inner = 40;
    $gap = 6; // degrees of padding between segments

    // Each segment: [start angle, end angle] in degrees, plus its label + icon key.
    // 0deg points right; angles increase clockwise in SVG screen space (y-down).
    $segments = [
        ['from' => 225, 'to' => 315, 'label' => __('Volume up'), 'icon' => 'volume-up', 'tx' => 96, 'ty' => 26],
        ['from' => 45, 'to' => 135, 'label' => __('Volume down'), 'icon' => 'volume-down', 'tx' => 96, 'ty' => 166],
        ['from' => 135, 'to' => 225, 'label' => __('Skip back'), 'icon' => 'skip-back', 'tx' => 26, 'ty' => 96],
        ['from' => 315, 'to' => 405, 'label' => __('Skip forward'), 'icon' => 'skip-forward', 'tx' => 166, 'ty' => 96],
    ];

    // Build a donut-wedge path between two radii across an angle range.
    $point = function ($r, $deg) use ($cx, $cy) {
        $rad = deg2rad($deg);
        return [
            round($cx + $r * cos($rad), 3),
            round($cy + $r * sin($rad), 3),
        ];
    };

    $wedge = function ($from, $to) use ($point, $outer, $inner, $gap) {
        $a0 = $from + $gap / 2;
        $a1 = $to - $gap / 2;
        $large = ($a1 - $a0) > 180 ? 1 : 0;

        [$ox0, $oy0] = $point($outer, $a0);
        [$ox1, $oy1] = $point($outer, $a1);
        [$ix1, $iy1] = $point($inner, $a1);
        [$ix0, $iy0] = $point($inner, $a0);

        return "M {$ox0} {$oy0} "
            ."A {$outer} {$outer} 0 {$large} 1 {$ox1} {$oy1} "
            ."L {$ix1} {$iy1} "
            ."A {$inner} {$inner} 0 {$large} 0 {$ix0} {$iy0} "
            ."Z";
    };

    $iconPaths = [
        'volume-up' => '<path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16 8a5 5 0 0 1 0 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
        'volume-down' => '<path d="M3 9v6h4l5 5V4L7 9H3z"/>',
        'skip-back' => '<path d="M18 6 9 12l9 6V6zM6 6h2v12H6z"/>',
        'skip-forward' => '<path d="M6 6l9 6-9 6V6zM16 6h2v12h-2z"/>',
    ];
@endphp

<x-ui.card
    x-data="{ playing: {{ Js::from((bool) $playing) }}, active: null }"
    {{ $attributes->merge(['class' => 'w-full max-w-[15rem] aspect-square flex items-center justify-center p-4']) }}
>
    <div data-slot="media-widget-radial" class="relative w-full h-full">
        <svg
            viewBox="0 0 192 192"
            class="w-full h-full"
            role="group"
            aria-label="{{ __('Playback controls') }}"
        >
            @foreach ($segments as $i => $seg)
                <g class="group/seg">
                    <path
                        d="{{ $wedge($seg['from'], $seg['to']) }}"
                        class="fill-secondary transition-colors group-hover/seg:fill-muted"
                        :class="{ 'fill-muted': active === {{ $i }} }"
                    />
                    {{-- The real control: a button positioned over the wedge via foreignObject. --}}
                    <foreignObject x="{{ $seg['tx'] - 22 }}" y="{{ $seg['ty'] - 22 }}" width="44" height="44">
                        <x-ui.button
                            type="button"
                            variant="ghost"
                            shape="pill"
                            :icon-label="$seg['label']"
                            x-on:click="active = {{ $i }}"
                            x-bind:aria-pressed="active === {{ $i }} ? 'true' : 'false'"
                            class="!size-full text-muted-foreground"
                        >
                            <svg
                                viewBox="0 0 24 24"
                                fill="currentColor"
                                aria-hidden="true"
                                class="size-5 {{ in_array($seg['icon'], ['skip-back', 'skip-forward'], true) ? 'rtl:-scale-x-100' : '' }}"
                            >
                                {!! $iconPaths[$seg['icon']] !!}
                            </svg>
                        </x-ui.button>
                    </foreignObject>
                </g>
            @endforeach
        </svg>

        {{-- Centre play / pause toggle. --}}
        <button
            type="button"
            x-on:click="playing = !playing"
            x-bind:aria-label="playing ? @js(__('Pause')) : @js(__('Play'))"
            aria-label="{{ __('Play') }}"
            title="{{ __('Play or pause') }}"
            class="absolute inset-0 m-auto inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            style="width: 41.6%; height: 41.6%"
        >
            <span class="sr-only">{{ __('Play or pause') }}</span>
            <svg x-show="!playing" class="size-2/5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M8 5v14l11-7z" />
            </svg>
            <svg x-show="playing" x-cloak class="size-2/5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M6 5h4v14H6zM14 5h4v14h-4z" />
            </svg>
        </button>
    </div>
</x-ui.card>

Installation

terminal
php artisan ui:add blocks/media-widget-radial

Installs these dependencies automatically:

Registry contract

Install confidence

php artisan ui:add blocks/media-widget-radial 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.5
License
open
Stability
stable
Contract
v1
Foundation
≥ 1.0.0
Type Generated target
blade resources/views/blocks/media-widget-radial.blade.php

Registry dependencies

card 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.

media-widget-radial.md
# Brok UI block: Media Widget Radial (`media-widget-radial`)

A circular donut control pad with four arc segments (volume up/down, skip back/forward) around a centre play/pause toggle.

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/media-widget-radial
```

## Render it

```blade
<x-blocks.media-widget-radial />
```

## Props

- `playing` (bool, default `false`) — Initial play/pause state; the centre button toggles it.

## Use when

- Presenting playback controls as a circular donut pad with four wedge segments, volume up/down and skip back/forward, around a centre play/pause button.
- Wanting a compact, decorative radial control layout rather than a linear transport bar.

## Avoid when

- Needing a working player with real seek/volume state or a track list; use music-player-card or media-widget-playlist.

## Rules

- Render the installed block with `<x-blocks.media-widget-radial />` 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/media-widget-radial
- Registry JSON (files, props, contract): https://brokui.dev/r/open/blocks/media-widget-radial.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 playback controls as a circular donut pad with four wedge segments, volume up/down and skip back/forward, around a centre play/pause button.
  • Wanting a compact, decorative radial control layout rather than a linear transport bar.

Avoid when

  • Needing a working player with real seek/volume state or a track list; use music-player-card or media-widget-playlist.

Anti-patterns

  • Do not replace semantic props with conflicting utility classes.
  • Do not remove labels, focus styles, or state attributes.
Anatomy
donut wedge segments for volume and skip centre play/pause toggle button

Usage

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

resources/views/welcome.blade.php Blade
<x-blocks.media-widget-radial />

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 file ui:add writes into your app. The preview above renders this same source — there are no preview-only blocks.

resources/views/blocks/media-widget-radial.blade.php Blade
@props([
    // Centre button starts paused; the inline Alpine toggle swaps the icon.
    'playing' => false,
])

{{--
    Each wedge is a real, keyboard-operable control: a <x-ui.button> laid over
    the wedge via <foreignObject>. Clicking/activating one sets `active` to its
    index, which toggles `aria-pressed` on that button and tints its decorative
    wedge — the widget has no backing audio element in this block, so "active"
    tracks the last control the visitor engaged rather than a live volume/seek
    state.
--}}

@php
    // Donut control pad geometry. viewBox 0 0 192 192, centre (96,96),
    // outer radius 88, inner radius 40. Four 90deg arc segments with a small
    // angular gap so the wedges read as discrete controls.
    $cx = 96;
    $cy = 96;
    $outer = 88;
    $inner = 40;
    $gap = 6; // degrees of padding between segments

    // Each segment: [start angle, end angle] in degrees, plus its label + icon key.
    // 0deg points right; angles increase clockwise in SVG screen space (y-down).
    $segments = [
        ['from' => 225, 'to' => 315, 'label' => __('Volume up'), 'icon' => 'volume-up', 'tx' => 96, 'ty' => 26],
        ['from' => 45, 'to' => 135, 'label' => __('Volume down'), 'icon' => 'volume-down', 'tx' => 96, 'ty' => 166],
        ['from' => 135, 'to' => 225, 'label' => __('Skip back'), 'icon' => 'skip-back', 'tx' => 26, 'ty' => 96],
        ['from' => 315, 'to' => 405, 'label' => __('Skip forward'), 'icon' => 'skip-forward', 'tx' => 166, 'ty' => 96],
    ];

    // Build a donut-wedge path between two radii across an angle range.
    $point = function ($r, $deg) use ($cx, $cy) {
        $rad = deg2rad($deg);
        return [
            round($cx + $r * cos($rad), 3),
            round($cy + $r * sin($rad), 3),
        ];
    };

    $wedge = function ($from, $to) use ($point, $outer, $inner, $gap) {
        $a0 = $from + $gap / 2;
        $a1 = $to - $gap / 2;
        $large = ($a1 - $a0) > 180 ? 1 : 0;

        [$ox0, $oy0] = $point($outer, $a0);
        [$ox1, $oy1] = $point($outer, $a1);
        [$ix1, $iy1] = $point($inner, $a1);
        [$ix0, $iy0] = $point($inner, $a0);

        return "M {$ox0} {$oy0} "
            ."A {$outer} {$outer} 0 {$large} 1 {$ox1} {$oy1} "
            ."L {$ix1} {$iy1} "
            ."A {$inner} {$inner} 0 {$large} 0 {$ix0} {$iy0} "
            ."Z";
    };

    $iconPaths = [
        'volume-up' => '<path d="M3 9v6h4l5 5V4L7 9H3z"/><path d="M16 8a5 5 0 0 1 0 8" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
        'volume-down' => '<path d="M3 9v6h4l5 5V4L7 9H3z"/>',
        'skip-back' => '<path d="M18 6 9 12l9 6V6zM6 6h2v12H6z"/>',
        'skip-forward' => '<path d="M6 6l9 6-9 6V6zM16 6h2v12h-2z"/>',
    ];
@endphp

<x-ui.card
    x-data="{ playing: {{ Js::from((bool) $playing) }}, active: null }"
    {{ $attributes->merge(['class' => 'w-full max-w-[15rem] aspect-square flex items-center justify-center p-4']) }}
>
    <div data-slot="media-widget-radial" class="relative w-full h-full">
        <svg
            viewBox="0 0 192 192"
            class="w-full h-full"
            role="group"
            aria-label="{{ __('Playback controls') }}"
        >
            @foreach ($segments as $i => $seg)
                <g class="group/seg">
                    <path
                        d="{{ $wedge($seg['from'], $seg['to']) }}"
                        class="fill-secondary transition-colors group-hover/seg:fill-muted"
                        :class="{ 'fill-muted': active === {{ $i }} }"
                    />
                    {{-- The real control: a button positioned over the wedge via foreignObject. --}}
                    <foreignObject x="{{ $seg['tx'] - 22 }}" y="{{ $seg['ty'] - 22 }}" width="44" height="44">
                        <x-ui.button
                            type="button"
                            variant="ghost"
                            shape="pill"
                            :icon-label="$seg['label']"
                            x-on:click="active = {{ $i }}"
                            x-bind:aria-pressed="active === {{ $i }} ? 'true' : 'false'"
                            class="!size-full text-muted-foreground"
                        >
                            <svg
                                viewBox="0 0 24 24"
                                fill="currentColor"
                                aria-hidden="true"
                                class="size-5 {{ in_array($seg['icon'], ['skip-back', 'skip-forward'], true) ? 'rtl:-scale-x-100' : '' }}"
                            >
                                {!! $iconPaths[$seg['icon']] !!}
                            </svg>
                        </x-ui.button>
                    </foreignObject>
                </g>
            @endforeach
        </svg>

        {{-- Centre play / pause toggle. --}}
        <button
            type="button"
            x-on:click="playing = !playing"
            x-bind:aria-label="playing ? @js(__('Pause')) : @js(__('Play'))"
            aria-label="{{ __('Play') }}"
            title="{{ __('Play or pause') }}"
            class="absolute inset-0 m-auto inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground shadow-sm transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
            style="width: 41.6%; height: 41.6%"
        >
            <span class="sr-only">{{ __('Play or pause') }}</span>
            <svg x-show="!playing" class="size-2/5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M8 5v14l11-7z" />
            </svg>
            <svg x-show="playing" x-cloak class="size-2/5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
                <path d="M6 5h4v14H6zM14 5h4v14h-4z" />
            </svg>
        </button>
    </div>
</x-ui.card>

Ownership & lifecycle

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