One Pinia store owns the entire audio session: a singleton <audio>, the Web Audio analyser, and the reactive state. The two visual components are pure projections — mount and unmount them freely, nothing ever stops playback.
flowchart LR
User(["👤 User"]):::user
subgraph host[" Your Vue 3 app "]
direction TB
Inline["MusicPlayer"]:::comp
FAB["MiniPlayer"]:::comp
end
subgraph core[" pulse-player core "]
direction TB
Store(["useAudioStore<br/><sub>Pinia singleton</sub>"]):::store
Audio["<audio>"]:::audio
FFT["AnalyserNode<br/><sub>4-band FFT</sub>"]:::audio
end
User -- "click · drag · hover" --> Inline
User -- "tap · long-press" --> FAB
Inline & FAB <-- "actions ↔ reactive state" --> Store
Store --> Audio --> FFT --> Store
classDef user fill:#3DBDA7,stroke:#3DBDA7,color:#06060a,font-weight:bold
classDef comp fill:#1a1a26,stroke:#3DBDA7,color:#fff,font-weight:600
classDef store fill:#14141a,stroke:#3DBDA7,color:#fff,font-weight:600
classDef audio fill:#1a1a26,stroke:#444,color:#ccc
style host fill:#0a0a14,stroke:#3DBDA7,stroke-width:1px,color:#fff
style core fill:#0a0a14,stroke:#3DBDA7,stroke-width:1px,color:#fff
| Layer | Owns | Type |
|---|---|---|
useAudioStore |
the <audio> element, the AudioContext + AnalyserNode, reactive state, the ambientEq global flag, all actions, the typed event bus (play, pause, trackchange, error), per-session counters, dispose() tear-down |
Pinia store (singleton) |
MusicPlayer.vue |
the inline card layout, the four responsive states (narrow/compact/FAB), the drag-resize handle, all CSS variables tied to --pulse-scale |
Vue 3 SFC |
MiniPlayer.vue |
the floating FAB, drag/swipe gestures, radial menu, progress ring, optional pulso heartbeat | Vue 3 SFC (Teleport to body) |
shared/useProgressRing |
the SVG circular-progress geometry (radius, circumference, offset) used by both the inline FAB chrome and the floating FAB |
composable |
shared/types |
the canonical PulseVariant union (single source of truth for the 10 themes) + ALL_VARIANTS readonly array |
module |
useDemoTour |
the guided product tour — scripted timeline, two-tier abort, pause/resume, step jump, scroll/tween helpers, prefers-reduced-motion gate. Demo-only — not exported from src/lib/index.ts |
composable |
setAudioTracks(tracks) |
replace the playlist before mount | function |
- Persistent session. Playback state lives outside the Vue component tree, so it survives every route change.
- Always in sync. Mount three
<MusicPlayer />and one<MiniPlayer />on the same page — they all read the same store, no glue required. - One source of truth. Components don't own audio state, they project it. Mount / unmount never breaks playback.
HTMLAudioElement
│
▼ audioContext.createMediaElementSource()
MediaElementAudioSourceNode
│
▼ sourceNode.connect(analyser)
AnalyserNode (FFT, fftSize=256, smoothing=0.5)
│
▼ analyser.connect(audioCtx.destination)
Audio output
│
│ (also)
▼ analyser.getByteFrequencyData() — every frame
eqBars (4 bars) → ShallowRef + triggerRef → NOW PLAYING + FAB chrome
eqAmbientBars (64 bars) → ShallowRef + triggerRef → ambient EQ visualiser
The rAF loop is cancellable and tied to the play state — it starts on toggle() → play, stops on pause/close, so idle CPU is genuinely zero. Arrays are mutated in place and signalled via triggerRef() → zero allocations on the hot path. AudioContext falls back to webkitAudioContext for Safari < 14.1.
The analyser is wrapped in a try / catch — if the browser refuses the connection (cross-origin without CORS, missing API, …) the bars stay flat but playback still works.
| API | Notes |
|---|---|
HTMLAudioElement |
universal |
Web Audio (AudioContext, AnalyserNode, MediaElementAudioSourceNode) |
needed only for the EQ bars; degrades silently |
ResizeObserver |
drives the auto-scale (Safari 13.1+, all evergreen browsers) |
Vue 3 <Teleport> |
drives the FAB mount-to-body |