All notable changes to this project will be documented in this file.
-
Artwork placement. New
artworkPositionoption ('info'|'button'), also settable withdata-artwork-position, chooses where theartworkimage renders.'info'(the default) is the existing behaviour — the cover sits in the info row beside the title.'button'fills the play/pause button with it instead, which still works when the info row is hidden. Only one placement ever renders, so the same cover can't appear twice.'button'needs no other options. The cover is the control, so the stylesheet drops the button's ring and uses cover-sized defaults (64px, 8px rounding) instead of the transport button's 36px circle — those defaults are wrong for artwork in both directions, since 36px smudges a cover and 50% rounding crops square album art into a circle.buttonSize/buttonRadiusstill override it for a different tile.Button artwork is decorative: the image is hidden from assistive tech and the button keeps its own
aria-label. A broken cover falls back to the same placeholder tile as the info-row image. A scrim between the cover and the glyph keeps the glyph above the WCAG 3:1 non-text contrast minimum over any image — re-theme it with--wfp-btn-artwork-scrim/--wfp-btn-artwork-color, but re-theme both together or the contrast guarantee goes with it. -
Button corner radius. New
buttonRadiusoption, also settable withdata-button-radius, sets the play/pause button's corner radius —0for a square button, or any CSS length (8,'0.5rem'). Defaults tonull, which leaves the stylesheet's circular50%in place. Shapes thecirclebutton style; the bareminimalglyph has no box to round.
-
buttonSizeis no longer ignored on narrow screens. Themax-width: 480pxrule hardcodedwidth: 32px, which tied on specificity with the base.waveform-btnrule and won on source order — so abuttonSize: 64button silently collapsed to 32px on a phone. The rule now reads--wfp-btn-sizewith32pxas its fallback, so an unset button still gets the narrow-screen default while an explicit size is honoured. -
Author-supplied values are now escaped when the player first renders.
artist,artwork, andbuttonSizewere interpolated into the initial markup unescaped, so a quote in any of them — including viadata-artist,data-artwork, ordata-button-size— could close its attribute and inject markup. All values_build()interpolates now go throughescapeHtml. This aligns the first render with every other path in the player, which has always written this metadata viatextContent.
loadTrack()now reconciles artist & artwork metadata in place. PreviouslyloadTrack()could only update the artist text or artwork image when the corresponding element already existed, so switching to a track that added or removed artist/artwork meant destroying and recreating the whole player. It now adds, updates, or removes the artist<span>and artwork<img>directly (keepingartworkAltin sync), so consumers can swap tracks whose metadata presence differs without tearing down the instance or causing layout churn. Pass an empty artist ('') orartwork: nullto remove them, a value to add or update, or omit them to leave the existing DOM untouched. The artwork error-fallback listener is now bound through the player's abort signal, sodestroy()tears it down. Thanks @jeryj.
- Localizable seek value text (
seekValueText). New option templating the seek slider's spokenaria-valuetext;%1$sis the current time and%2$sthe total duration (both formattedM:SS), with sequential%sand reordered positional args supported for translation. Defaults to'%1$s of %2$s', so existing output is unchanged. Also settable viadata-seek-value-text. Lets consumers localize the connective text without reformatting the times. - Localizable UI strings. The remaining hardcoded English strings are now
options (each also settable via
data-*), so non-English UIs can translate every screen-reader / lock-screen string:playPauseLabel(play buttonaria-label, default'Play/Pause'),speedLabel(speed button + menuaria-label, default'Playback speed'),artworkAlt(artwork<img>alt text, default'Album artwork'), andunknownTrackText(Media Session title fallback when notitleis set, default'Unknown Track'). Defaults reproduce the previous output, so existing behavior is unchanged.
- Duplicate scrub time (readout + tooltip). During a seek-drag both the current-time display and the hover tooltip showed the same target time. The current-time readout now owns the live scrub time and the duplicate tooltip is suppressed while dragging (it still shows when there is no current-time readout to fall back on).
- Accessible playback-speed menu (#11). The speed selector is now a proper
role="menu"ofmenuitemradiooptions (aria-checkedreflects the active rate) with full arrow-key / Home / End navigation and focus management — keyboard- and screen-reader-complete, while keeping the custom styling (no native<select>).
- Multi-player Media Session hijack.
navigator.mediaSessionis a single global, but every player registered its action handlers (and metadata) at load, so on a page with several players the last one to load captured the lock-screen controls — play/pause and the scrubber drove the wrong track. A player now (re)claims the handlers and metadata when it starts playing, so the lock screen always controls the track you're actually listening to.
- Media Session track navigation. New
onNextTrack/onPreviousTrackoptions register lock-screen / Now-Playing skip-track buttons (called with the player). Wired automatically bywaveform-bar(queue) andwaveform-playlist(track nav).
- Space starts playback while the waveform itself is focused (#10). The seek slider swallowed Space, so play/pause only worked when the player root was focused. Reported by @jeryj.
- iOS lock-screen metadata. Media Session metadata was set once at load
(iOS ignores that) and
playbackStatewas never set — a blank Now-Playing card. It's now re-asserted on play, withplaybackState+setPositionState. - Drag-seek double-fire. The synthetic click after a pointer drag re-seeked from a stale coordinate, which could snap the playhead (e.g. to the start) near the beginning of a track.
- Gradient direction.
waveformColor/progressColorstop arrays now render along a configurable axis —waveformGradient: 'vertical'(default, top→bottom),'horizontal'(a hue sweep across the whole waveform) or'diagonal'(alsodata-waveform-gradient).
- BREAKING — renamed the
subtitleoption toartist(data-subtitle->data-artist, the.waveform-subtitleclass ->.waveform-artist, and theloadTrack(url, title, artist, ...)parameter). Aligns with the bar/playlist and the standard "title + artist" convention. No back-compat alias. - BREAKING — DOM chrome colours moved to CSS variables. Removed the
buttonColor,buttonHoverColor,textColor,textSecondaryColor,backgroundColorandborderColoroptions (and theirdata-*attributes; the last three were already dead). Theme the button/title/meta text via--wfp-button-color/--wfp-text-color/--wfp-text-secondary-colorin CSS instead — the player adds a.waveform-theme-lightclass when it detects a light page. Canvas colours (waveformColor/progressColor) stay options. - Live time while scrubbing. Dragging to seek now updates the time readout
and a tooltip live at the drag position (Spotify-style) instead of only on
release. The tooltip appears on any waveform style during a drag (the seekbar
handle stays seekbar-only);
showHoverTimestill governs the hover tooltip.
- Seekbar drag-to-scrub. Press and drag on the waveform/seekbar to scrub; the seek commits on release, so audio keeps playing during the drag instead of re-seeking on every move.
- Opt-in circular seek handle on the
seekbarstyle —seekHandle(defaultfalse, alsodata-seek-handle). A draggable handle that expands on hover; the bar turns it on.
- Hardened peak extraction. Replaced
Math.max(...peaks)with a loop-based max (noRangeErroron very large arrays) and centralised the default sample count in a sharedDEFAULT_SAMPLES(1800) constant so live extraction andDEFAULT_OPTIONScan't drift. - Playback-speed menu accessibility. The speed toggle now sets
aria-haspopup/aria-expanded(so the open/closed state is announced), closes onEscape, and returns focus to the trigger after a rate is chosen. Kept as a lightweight disclosure — the options remain plain buttons in the tab order, no ARIA-menu machinery.
- Activating a control no longer steals keyboard focus onto the player wrapper. Clicking/activating the play button — or any interactive control (slider, link, input) — now keeps focus on that control; the container only takes focus when the click lands on a non-interactive area. (Reported in #10.)
- Active-marker label now flashes then fades instead of staying visible the
whole time the playhead is past a marker. When the playhead reaches a marker
its label appears for ~2.5s and then fades out, while the highlight stays.
Driven by a
.show-labelclass the player adds/removes, so it's CSS-overridable: keep it on with.waveform-marker.active .waveform-marker-tooltip { opacity: 1 }or hide it with{ opacity: 0 !important }.
- Hover-time tooltip. With
showHoverTime: true(data-show-hover-time), a tooltip follows the pointer over the waveform showing the time at that position. The option was previously parsed but did nothing — it's now wired up (works in both self and external modes). - Active markers. As playback passes a marker, the player highlights it and
reveals its label automatically — it drives the existing
setActiveMarker()from the progress loop (self and external modes), so chaptered tracks and DJ cues light up as you reach them. - Artwork fallback. When the
artworkimage fails to load (404 / broken URL), the player now shows a muted music-note placeholder tile instead of the browser's broken-image icon.
index.d.ts@defaultJSDoc synced with the runtime. The hand-written type file still annotatedheightas@default 60andbarRadiusas@default 0;DEFAULT_OPTIONSis64and1. Corrected so IDE tooltips match actual behaviour. Documentation-only — no type or runtime change.
- Live-decoded waveforms are now accurate.
extractPeaksinspected only ~1 in 10 frames per window (a real-time speed shortcut), which missed transients and made the waveform shape shift noticeably when the sample count changed. It now scans every frame — matching WaveformGen's offline output exactly (same shape, not just same amplitude), so a live decode and a pre-generated.jsonrender identically.decodeAudioDatadominates the cost, so the full scan is effectively free.
samplesdefault raised 256 → 1800 (the SoundCloud / WaveformGen figure). It is the source peak resolution for live decode only (ignored whenwaveformpeaks are supplied), resampled down to the visible bar count. At 256, any waveform wider than ~256 bars — common on wide or high-DPI displays — was upsampled and looked blurry; 1800 keeps it crisp. Paired with the every-frame scan it costs no extra extraction time. Override withsamples/data-samples.
buttonSizeoption (+data-button-size) to size the play/pause button. A number is treated as px; a string (e.g.'4rem') is used verbatim. It sets a new--wfp-btn-sizeCSS variable that scales both button styles —circleandminimal, box and glyph — proportionally from a single value, so there are no per-style magic numbers left to drift. The default reproduces the prior 36px sizing exactly. Forwarded by the Astro/React wrappers.
- Minimal play button glyph size now actually applies.
.waveform-btn svg(16px) had equal specificity but later source order than the minimal-button rule, so it silently won — the glyph was pinned at 16px no matter what the minimal rule said (1.13.0/1.13.1 changes had no visible effect). The rule is now.waveform-btn.waveform-btn-minimal svg(higher specificity), and both button styles derive their size from--wfp-btn-size.
- Minimal play button (
buttonStyle: 'minimal') now renders the bare play/pause glyph at the same visual size as the default circle button — the glyph was noticeably smaller than the ring it replaces. Larger fixed box (2.5rem) + 36px glyph; still a fixed width so the waveform never shifts on play/pause.
- Live theme switching. Auto-themed players (no explicit
colorPresetor hand-set colours) now re-detect the page theme and redraw on a runtime light/dark switch — a class/attribute flip on<html>/<body>(Tailwinddark,data-theme,data-color-scheme) or an OSprefers-color-schemechange. Previously the palette was fixed at load, so a toggle left the player stuck in the old theme (invisible waveform/buttons). Event-driven (one sharedMutationObserver+matchMedia, no polling), with a publicrefreshTheme()to trigger it manually. Explicit presets/colours are never overridden.
- Larger
minimalbutton glyph. The bare play/pause glyph read too small; bumped the fixed button box to 2.25rem and the icon to 28px (still a fixed box, so it doesn't shift the waveform on play/pause).
bpmoption (data-bpm). Show a known BPM in the badge (withshowBPM) without decoding the audio — ideal when peaks are pre-generated but the tempo is known (e.g. sample-pack previews). A supplied value wins over detection.
- Minimal button no longer shifts the waveform on play/pause. The
buttonStyle: 'minimal'button was auto-width, so swapping the play glyph (optically nudged 1px) for the pause glyph changed its width — moving the adjacent waveform and re-sampling its bars on every toggle. It's now a fixed box, so the canvas stays put.
buttonStyleoption ('circle' | 'minimal', plusdata-button-style).'minimal'renders a bare play/pause glyph with no circle — the look sample-pack and beat stores use in their preview grids. Composes with thepreviewlayout.
- Default look refresh. The
mirrorstyle now usesbarSpacing: 2(distinct thin bars rather than a solid fill); the defaultbarRadiusis1(soft caps), defaultsamplesis256(more source fidelity — resampled to fit the bar pitch), and defaultheightis64. Players that set these explicitly are unaffected.
previewlayout (layout: 'preview'/data-layout="preview") — centers the title under the waveform and trims the meta row, for sample-pack previews and dense grids.
- Monochrome by default (shadcn). The keyboard-focus ring and speed-menu
active state are now neutral, driven by a new
--wfp-accentCSS variable (default zinc). Set--wfp-accentto a brand hue to re-tint.
WaveformPlayer.utils.parseDataAttributesexposed on the utils bridge, so wrappers (waveform-bar, waveform-playlist) can read the player's fulldata-*option surface off a host element without re-implementing — and drifting from — the contract.
-
TypeScript definitions — the package now ships a hand-authored
index.d.ts(wired through atypesexport condition), so bundler/IDE users get full IntelliSense and type-checking with zero runtime bytes. It is the single source of truth for the option surface, theWaveformPlayerclass API, and a typed custom-event map (addEventListener('waveformplayer:timeupdate', …)now typese.detail). The React/Astro wrappers can re-export from it instead of re-declaring the option list. -
Dual ESM + CommonJS build — added a
dist/waveform-player.cjsbundle and arequireexport condition, sorequire('@arraypress/waveform-player')works under Node CJS (previously ESM-only). External sourcemaps now ship for the ESM, CJS, and minified IIFE bundles. New./styles.cssexport alias. -
Accessibility polish (near-zero footprint): a
@media (prefers-reduced-motion: reduce)guard that neutralizes transitions/animations,role="alert"on the error node, andaria-busytoggled on the seek slider while loading. -
CSS custom properties for spacing —
--waveform-line-height(1.4),--waveform-body-gap(8px), and--waveform-track-gap(12px). Lets embedders (e.g.@arraypress/waveform-bar) retune layout without!importantoverrides of internal classes. Defaults are unchanged. -
Expressive bar styling (bundle-neutral):
barRadiusfor rounded bar caps (bars/mirror; falls back to square whereroundRectis unavailable), and gradient fills —waveformColor/progressColornow also accept an array of CSS colour stops (e.g.['#fafafa', '#71717a']) rendered as a vertical canvas gradient. Both work via constructor options,data-*attributes (gradients as a JSON array), and the React/Astro wrappers. -
Lifecycle + event-contract completeness (so controllers/analytics stop reaching into internals):
waveformplayer:destroyevent — the symmetric counterpart to:ready; lets listeners release references on teardown.loadTrack(url, title, artist, { autoplay: false })— load / restore / enqueue without forcing playback.waveformplayer:endednow carries{ currentTime, duration }, and is synthesized inexternalmode when progress reaches the end (fires once).request-play/request-pause/request-seekdetail now includesmarkersandwaveform, so controllers don't re-fetch them.parseDataAttributesnow readsdata-audio-mode,data-show-markers,data-accessible-seek,data-seek-label,data-play-icon,data-pause-icon(previously silently inert on every auto-init path).- Shorthand aliases —
style/data-styleforwaveformStyle, andsrc/data-srcforurl(e.g.<div data-waveform-player data-src="t.mp3" data-style="bars">). The canonical names still work and win if both are set.
-
Controller-support helpers (so
@arraypress/waveform-barand other external controllers stop reaching into internals / shipping divergent copies):player.setActiveMarker(index | null)— highlight the current marker via a core-owned.waveform-marker.activeclass instead of poking the marker DOM.WaveformPlayer.utils— a static bridge exposingformatTime,extractTitleFromUrl,escapeHtml, andisSafeHref(allow-listshttp/https/relative URLs; rejectsjavascript:/data:script schemes).setVolume()now coerces + guards non-finite input (no moreNaNreachingaudio.volume).- The
request-*event detail now always includes anartistfield, so the published contract is self-consistent.
-
errorTextoption (default'Unable to load audio') — customize/localize the message shown when audio fails to load. Escaped before render. Also settable viadata-error-text.
- Type hygiene in
audio.js: cast thewebkitAudioContextfallback (silences ts2568) and dropped a no-opawaiton the synchronousdetectBPM().
- AudioContext leak on failed decode —
generateWaveform()now closes the context in afinallyblock. Browsers hard-cap live AudioContexts (~6 in Chrome), so leaking one per failed load could break every later player on a catalogue page. destroy()listener leak — all document/container/seek listeners are now registered against anAbortControllerand torn down ondestroy(). The old teardown left the outside-click and container listeners attached.- Constructor crash in external mode —
audioMode: 'external'combined withshowPlaybackSpeed: truethrew on init (dereferencing the null<audio>);updateSpeedUI()now no-ops without audio. onTimeUpdateargument order — external mode previously fired(player, currentTime, duration); it now matches self mode's documented(currentTime, duration, player)so one handler works in both modes.⚠️ Behavior change for external-mode consumers relying on the old order.- Markers in external mode —
renderMarkers()now uses a mode-agnostic duration, so chapter markers render when audio is delegated. - Accessible seek in external mode — the duration is now published
unconditionally, so keyboard seeking / the ARIA slider work even when
showTimeis off. generateId()— hashes the full URL (+ a counter) instead of a 10-charbtoa()prefix: no more collisions for same-host tracks and no throw on non-Latin1 / Unicode URLs.formatTime()— addsH:MM:SSrollover past one hour and clamps negatives (also fixesaria-valuetext).- Autoplay — the autoplay
play()no longer emits an unhandled promise rejection when the browser blocks it.
-
Accessible seek slider — the waveform surface (
.waveform-container) is now exposed as a keyboard-operable ARIA slider:role="slider", focusable in the tab order, witharia-valuemin/aria-valuemax/aria-valuenowand a readablearia-valuetext(e.g."0:30 of 2:00") kept in sync on metadata load,timeupdate, and externalsetProgress(). When focused it handles the standard slider keys — ←/↓ and →/↑ (±5s), Page Up/Page Down (±10s), Home/End — callingpreventDefault()(no page scroll). Works in bothself(callsseekTo()) andexternalmode (dispatcheswaveformplayer:request-seek, exactly like click-to-seek). Addresses WCAG 2.1.1 (Keyboard) and 4.1.2 (Name, Role, Value). Resolves #8; upstreamed from WordPress/Gutenberg's external accessibility layer (#9).Two new options:
accessibleSeek(boolean, defaulttrue— setfalseto opt out and keep the prior markup) andseekLabel(string, defaultnull— accessible name for the slider, falls back to the track title, then'Seek').The existing container-level keyboard shortcuts (number-key seek, space, volume on ↑/↓, mute) are untouched; they live on the outer container and only run when it is the active element, so they don't collide with the slider on the inner element.
-
package.jsonexports field — added"type": "module", pointedmainatdist/waveform-player.esm.js(was IIFE bundle with no exports), and added a properexportsmap so SSR / Node consumers canimport { WaveformPlayer } from '@arraypress/waveform-player'without "does not provide an export named 'WaveformPlayer'" errors.Browser direct-script usage via the
unpkgfield ordist/waveform-player.js?urlimports is unaffected — the IIFE bundle stays on disk for<script>tag and Vite asset-URL loading.
-
WaveformPlayer.getPeaksUrl(audioUrl)— static helper that derives a peaks-JSON URL from an audio URL by swapping the extension (.mp3 / .wav / .ogg / .flac / .m4a / .aac→.json). Pair with@arraypress/waveform-gento pre-generate peaks at build time and skip the Web Audio decode pass at runtime — big perf win on catalogues with many tracks.Preserves query strings and URL fragments. Returns
undefinedfor empty input or unrecognised extensions so callers can pass through unconditionally:new WaveformPlayer('#el', { url: track.audioUrl, waveform: WaveformPlayer.getPeaksUrl(track.audioUrl), });
-
External audio mode (
audioMode: 'external') — turns a WaveformPlayer instance into a visualization-only surface that delegates playback to an external controller (e.g.@arraypress/waveform-bar). The player renders the canvas + scrubber + play button as usual, but skips creating its own<audio>element. Play / pause / seek interactions dispatch cancelablewaveformplayer:request-play,waveformplayer:request-pause, andwaveformplayer:request-seekevents on the container; the controller listens and routes the action to its own audio source.Pair with WaveformBar 1.3+: any
[data-waveform-player][data-audio-mode="external"]element with a matching URL becomes a synced visual mirror of bar playback (canvas scrubs in real time, play/pause icon reflects bar state).<div data-waveform-player data-audio-mode="external" data-url="song.mp3" data-waveform-style="bars"></div>
new WaveformPlayer(el, { audioMode: 'external' });
player.setPlayingState(playing)— external-state pump for the play/pause visual state. Toggles the play/pause icon, starts/stops the smooth-update RAF, dispatcheswaveformplayer:play/:pauseso existing listeners still fire.player.setProgress(currentTime, duration)— external-state pump for the scrubber + canvas + time displays. Drives the visualization from an external clock without touching audio.
Fully additive. audioMode defaults to 'self' — every existing instance behaves exactly as before. The new event dispatchers and setPlayingState / setProgress methods only fire in external mode, so they can't disturb self-mode callers.
- JSON waveform files can now include
markers— automatically loaded if no markers are set via data attributes
- Drawing style aliases:
bar,block,dotnow accepted alongsidebars,blocks,dotsforwaveformStyle
- JSON Waveform Loading —
data-waveformnow accepts a URL to a JSON file ending in.json- Fetches the file and reads peaks from the response (supports
[...]array or{ peaks: [...] }object) - Falls back gracefully if fetch fails (player generates waveform from audio as before)
- No changes to constructor,
load(), orloadTrack()— fully backwards compatible
- Fetches the file and reads peaks from the response (supports
<!-- JSON file instead of inline peaks -->
<div data-waveform-player data-url="song.mp3" data-waveform="waveforms/song.json"></div>
<!-- WaveformBar — same attribute -->
<div data-wb-play data-url="song.mp3" data-wb-waveform="waveforms/song.json"></div>
<!-- Inline peaks still work exactly as before -->
<div data-waveform-player data-url="song.mp3" data-waveform="[0.2, 0.37, 0.41, ...]"></div>- Reverted JSON config file feature (
data-config) introduced in 1.4.0 due to breaking changes with WaveformBar integration. The feature caused layout and sizing issues when the player was used inside WaveformBar. Will be revisited in a future release with proper integration testing.
- JSON Config Files — Load track configuration from external JSON files via
data-configattribute- Single attribute setup:
<div data-waveform-player data-config="waveforms/track.json"></div> - JSON supports
url,title,artist,artwork,album,samples,peaks,markers, andmeta - Priority order: JSON config (base) → data attributes (override) → JS options (override)
- Config files are cached in memory — subsequent loads of the same file are instant
- Works with
loadTrack()viaoptions.configfor dynamic track loading metaobject passes through for use by extensions (e.g. WaveformBar readsmeta.bpm,meta.key)
- Single attribute setup:
{
"url": "audio/track.mp3",
"title": "Track Title",
"artist": "Artist Name",
"artwork": "covers/artwork.webp",
"samples": 200,
"peaks": [
0.2,
0.37,
0.41,
...
],
"markers": [],
"meta": {
"bpm": "128",
"key": "Am"
}
}Generate config files with @arraypress/waveform-gen:
npx @arraypress/waveform-gen ./audio/*.mp3 --output ./waveforms/- Fixed marker elements from previous track persisting when loading a new track without markers
- Fixed markers from previous track persisting when loading a new track without markers via
loadTrack()
- Removed inline
style.heighton canvas element that prevented it from filling available width in flex containers. Container div still controls height.
- Fixed waveform canvas not filling available width when embedded in flex containers (e.g. persistent bottom bars).
resizeCanvas()now reads width from the container div instead of the canvas element.
- Fixed uncaught
NotAllowedErrorwhenloadTrack()triggers autoplay before user interaction (e.g. on session restore). Theplay()promise returned since v1.2.2 was not being caught internally.
showControlsoption - Hide the play/pause button for custom UI implementations (data-show-controls="false")showInfooption - Hide the title, artist, time, and metadata bar (data-show-info="false")- Both options work via HTML data attributes or JavaScript API
- Waveform automatically fills the full width when controls are hidden
<!-- Waveform only, no button or info -->
<div data-waveform-player
data-url="song.mp3"
data-show-controls="false"
data-show-info="false">
</div>play()now returns the Promise fromHTMLMediaElement.play(), allowing callers to handle errors likeAbortError
Thanks to @scruffian for the contribution.
- Fixed null reference error when
destroy()is called during resize events - Cleaned up window resize listener on destroy to prevent memory leaks
- Added destruction guards to all event handlers to prevent race conditions
- Added
bubbles: trueto all custom events for better framework integration
Thanks to @scruffian for contributing these fixes.
- Automatic Theme Detection - Player now automatically adapts to your website's color scheme
- Detects light/dark themes automatically
- Checks background brightness, theme classes, and system preferences
- Works seamlessly on WordPress, Shopify, and all platforms
- Override with explicit
data-color-preset="light"or"dark"if needed
- 6 visual styles: bars, mirror, line, blocks, dots, seekbar
- BPM detection
- Waveform caching with pre-generated data
- Keyboard controls
- Media Session API integration
- Speed control
- Chapter markers
- Dynamic track loading
- Initial patch release
- Zero-config audio player with waveform visualization
- HTML data attribute API
- JavaScript API
- ~8KB gzipped, zero dependencies
- Framework agnostic (React, Vue, Angular, vanilla JS)