# Persist Player (Moonarc)

An audio player that keeps playing while you navigate: the same <audio> element is carried into the next page by Astro's router, so position, volume and playback never restart. Native controls, zero JavaScript of its own. Put it in the layout; every page gets the one that is already playing.

- Import: `import PersistPlayer from '@moonarc/core/PersistPlayer'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/persist-player.json`
- Tier A · category transition · trigger click
- Readout: `<PersistPlayer src="/audio/loop.mp3">`
- Browser support: widely (every browser); elsewhere: without the router the player is a normal audio element that restarts with the page
- Measured cost: 0 B JS (CSS 6.0 kB raw)
- Page: https://moonarc.dev/components/persist-player/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `src` | `string` | none | Audio file. MP3 plays everywhere; keep it small (this site's 20-second loop is a 98 kB MP3). |
| `title` | `string` | none | Track title. |
| `artist` | `string` | none | Artist or source line. |
| `loop` | `boolean` | `false` | Loop the track. |
| `name` | `string` | `'ma-player'` | Persist key. The same key on two pages pairs the players; a page without it drops the player and stops the audio. |

## Usage

```astro
---
// in the layout, so every page has it
---
<PersistPlayer src="/audio/loop.mp3" title="Ambient loop" artist="synthesised, CC0" loop />
```

## Reduced motion

The bars beside the title stay still; the player is unaffected.

## With ClientRouter

PersistPlayer is built on the router: transition:persist moves the existing element into the new document during the swap, so the media element, its buffer and its play state are the same object before and after.

## Craft

- The element persists, not the state: no script serialises the position and none restores it. The browser keeps decoding because nothing told it to stop.
- Native controls. Play, seek, volume, the media session and the OS media keys are the browser's; a custom UI would need script for every one of them.
- The equalizer bars run only where :playing exists (Safari 15.4, Firefox 150; Chrome has none yet) and stay still elsewhere. :where() forgives the selector, so the rule is harmless in engines without it. Nothing is faked.
- The persist key is a prop, so two players on one site can coexist and a page that should stop the music simply leaves the player out.

## Replaces

- SPA global players (React context + Howler)
- iframes kept alive for audio

## Source

```astro
---
/**
 * PersistPlayer — an audio player that keeps playing across page
 * navigations. The native <audio controls> sits in a wrapper marked
 * transition:persist, so Astro's router moves the very same element into
 * the next page instead of replacing it: playback, position and volume
 * continue. Zero JS of its own; the controls are the browser's. Where
 * :playing exists (Safari 15.4, Firefox 150; not Chrome) the bars move
 * while the track plays.
 */
import type { HTMLAttributes } from 'astro/types';

interface Props extends HTMLAttributes<'div'> {
  /** Audio file. */
  src: string;
  /** Track title. */
  title?: string;
  /** Artist or source line. */
  artist?: string;
  /** Loop the track. */
  loop?: boolean;
  /** Persist key; the same key on two pages pairs the players. */
  name?: string;
}

const { src, title, artist, loop = false, name = 'ma-player', class: className, ...rest } = Astro.props;
---

<div class:list={['ma-player', className]} transition:persist={name} {...rest}>
  <span class="ma-player__bars" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
  {(title || artist) && (
    <span class="ma-player__meta">
      {title && <b class="ma-player__title">{title}</b>}
      {artist && <span class="ma-player__artist">{artist}</span>}
    </span>
  )}
  <audio class="ma-player__audio" controls preload="metadata" src={src} loop={loop}></audio>
</div>

<style is:global>
  @layer components {
    :where(.ma-player) {
      display: grid;
      grid-template-columns: auto 1fr;
      align-items: center;
      gap: 0.6em 0.9em;
      padding: 0.85em 1em;
      border: 1px solid var(--ma-edge);
      border-radius: 0.75em;
      background: var(--ma-panel);
      color: var(--ma-ink);
    }
    :where(.ma-player__bars) {
      display: flex;
      align-items: flex-end;
      gap: 2px;
      width: 1.1em;
      height: 1.1em;
    }
    :where(.ma-player__bars > i) {
      flex: 1;
      height: 100%;
      border-radius: 1px;
      background: currentColor;
      transform-origin: 50% 100%;
      scale: 1 0.35;
      /* the bar's index, named for the player and set on every bar (0 here): a page's own --ma-i must not delay the first */
      --ma-player-i: 0;
      animation: ma-player-bar var(--ma-dur-ambient) var(--ma-ease-in-out) calc(var(--ma-player-i) * var(--ma-stagger-relaxed)) infinite alternate;
      animation-play-state: paused;
    }
    :where(.ma-player__bars > i:nth-child(2)) {
      --ma-player-i: 1;
      scale: 1 0.7;
    }
    :where(.ma-player__bars > i:nth-child(3)) {
      --ma-player-i: 2;
      scale: 1 0.5;
    }
    :where(.ma-player__bars > i:nth-child(4)) {
      --ma-player-i: 3;
      scale: 1 0.85;
    }
    /* :playing exists in WebKit and Firefox (150), not in Chrome; :where() forgives the selector there and the bars stay still */
    :where(.ma-player:has(.ma-player__audio:playing) .ma-player__bars > i) {
      animation-play-state: running;
    }
    :where(.ma-player__meta) {
      display: grid;
      min-width: 0;
      line-height: 1.3;
    }
    :where(.ma-player__title) {
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    :where(.ma-player__artist) {
      font-size: 0.8125em;
      opacity: var(--ma-dim, 0.7);
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
    }
    :where(.ma-player__audio) {
      grid-column: 1 / -1;
      width: 100%;
      min-width: 0;
      height: 2.25em;
    }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-player__bars > i) {
        animation: none;
      }
    }
  }

  @keyframes ma-player-bar {
    from {
      scale: 1 0.25;
    }
    to {
      scale: 1 1;
    }
  }
</style>

```
