@vinylproject/washtub-player
v0.3.0
Published
Embeddable audio player Web Component for Washtub. Plays single songs or full albums via embed tokens. Works in any framework (React, Vue, Svelte, Angular) or plain HTML.
Readme
@vinylproject/washtub-player
Embeddable audio player Web Component for Washtub. Plays single songs or full albums via embed tokens. Works in any framework (React, Vue, Svelte, Angular) or plain HTML.
Installation
npm install @vinylproject/washtub-playerOr load via CDN / script tag (UMD build):
<script src="https://your-cdn.com/washtub-embed.umd.cjs"></script>Quick start
<washtub-player
token="your-embed-token"
api-base="https://your-washtub-instance.com"
></washtub-player>import '@vinylproject/washtub-player'Importing the package registers the <washtub-player> custom element automatically.
Attributes
| Attribute | Type | Default | Description |
|------------|--------|--------------------------|-------------------------------------|
| token | string | "" | Embed token from the Washtub API |
| api-base | string | window.location.origin | Base URL for the /api/embed/ endpoint |
Styling
The player supports three tiers of customization, from simple theming to full structural overrides.
Tier 1: CSS custom properties (theming)
Set these on the <washtub-player> element or any ancestor to change colors, fonts, and dimensions:
washtub-player {
--wt-accent: #1db954; /* Accent color (borders, buttons, active items) */
--wt-bg: transparent; /* Player background */
--wt-text: inherit; /* Primary text color */
--wt-text-muted: #a3a3a3; /* Secondary text (artist, meta, timestamps) */
--wt-border: #333; /* Border color */
--wt-border-light: rgba(255, 255, 255, 0.06); /* Track separator color */
--wt-hover: rgba(99, 102, 241, 0.06); /* Track hover background */
--wt-radius: 2px; /* Border radius */
--wt-font: inherit; /* Font family */
--wt-cover-size: 56px; /* Cover art size (song) */
--wt-cover-size-album: 64px; /* Cover art size (album) */
--wt-error: #ef4444; /* Error text color */
--wt-seek-height: 4px; /* Seek bar height */
--wt-tracklist-max-height: 320px; /* Max tracklist height before scrolling */
--wt-badge-opacity: 0.7; /* "Powered by Washtub" badge opacity */
}Tier 2: CSS ::part() selectors (structural overrides)
Every visual element in the shadow DOM exposes a part attribute, allowing full CSS control via ::part():
/* Circular cover art */
washtub-player::part(cover) {
border-radius: 50%;
}
/* Custom play button */
washtub-player::part(play-button) {
background: #1db954;
color: white;
border-radius: 50%;
width: 36px;
height: 36px;
}
/* Thicker seek bar */
washtub-player::part(seek-bar) {
height: 8px;
border-radius: 4px;
}
/* Hide the badge */
washtub-player::part(badge) {
display: none;
}Available parts
| Part | Element | Description |
|------------------|----------------|------------------------------------------|
| player | Container | Outermost player wrapper |
| loading | <div> | Loading state text |
| error | <div> | Error state text |
| header | <div> | Cover art + details row |
| cover | <img> | Cover art image |
| details | <div> | Title, artist, and meta wrapper |
| title | <div> | Song or album title |
| artist | <div> | Artist name |
| meta | <div> | Metadata row (genre, year, duration, etc.) |
| quality | <div> | Quality info line (format, bitrate, sample rate) |
| controls | <div> | Play button + seek bar + time wrapper |
| play-button | <button> | Play/pause button |
| seek-bar | <div> | Seek bar track (background) |
| seek-fill | <div> | Seek bar fill (progress indicator) |
| time | <span> | Current time / duration display |
| tracklist | <div> | Album tracklist container |
| track | <button> | Individual track row |
| track-number | <span> | Track number |
| track-title | <span> | Track title text |
| track-artist | <span> | Track artist (when different from album) |
| track-duration | <span> | Track duration |
| badge | <div> | "Powered by Washtub" footer |
Tier 3: Slots (content replacement)
Named slots let you replace specific parts of the UI with your own markup:
<!-- Replace the play/pause icon with a custom SVG -->
<washtub-player token="abc123">
<svg slot="play-icon" viewBox="0 0 24 24" width="20" height="20">
<path d="M8 5v14l11-7z" fill="currentColor"/>
</svg>
</washtub-player>
<!-- Replace the badge with custom branding -->
<washtub-player token="abc123">
<span slot="badge">Shared via MyApp</span>
</washtub-player>Available slots
| Slot | Default content | Description |
|-------------|---------------------------|--------------------------------|
| play-icon | SVG play/pause icon | Play button icon |
| badge | "Powered by Washtub" | Footer badge |
Events
The player emits custom events that bubble and cross shadow DOM boundaries (composed: true). Listen on the element or any ancestor:
const player = document.querySelector('washtub-player')
player.addEventListener('wt-ready', (e) => {
console.log('Metadata loaded:', e.detail.meta)
})
player.addEventListener('wt-play', (e) => {
console.log('Playing at', e.detail.currentTime)
})
player.addEventListener('wt-pause', (e) => {
console.log('Paused at', e.detail.currentTime)
})
player.addEventListener('wt-time-update', (e) => {
console.log(`${e.detail.currentTime} / ${e.detail.duration}`)
})
player.addEventListener('wt-track-change', (e) => {
console.log('Now playing track', e.detail.index, e.detail.track.title)
})
player.addEventListener('wt-toggle', () => {
console.log('Player requests play/pause toggle (external control mode)')
})
player.addEventListener('wt-seek', (e) => {
console.log('Player requests seek to', e.detail.time, 'seconds')
})
player.addEventListener('wt-disconnected', () => {
console.log('Player removed from DOM')
})
player.addEventListener('wt-error', (e) => {
console.error('Player error:', e.detail.message)
})| Event | detail payload | Fires when |
|--------------------|----------------------------------------------------|----------------------------------------------------|
| wt-ready | { meta: SongMeta \| AlbumMeta } | Embed token resolved successfully |
| wt-error | { message: string } | Token resolution or playback fails |
| wt-play | { currentTime: number, duration: number } | Playback starts |
| wt-pause | { currentTime: number, duration: number } | Playback pauses |
| wt-time-update | { currentTime: number, duration: number } | Playback position changes |
| wt-track-change | { index: number, track: EmbedTrack } | Active track changes (albums) |
| wt-toggle | {} | Play/pause requested while externally controlled |
| wt-seek | { time: number } | Seek requested while externally controlled |
| wt-disconnected | {} | Player element removed from DOM |
External control (bridge integration)
For host pages that manage their own audio element (e.g., a persistent audio player that survives page navigation), the player supports an external control mode. When enabled, the player's internal audio is silenced while its UI stays visually in sync with the host's audio state.
How it works
- Listen for
wt-play— the user clicked play on the washtub-player - Set
player.externalControl = trueand callplayer.pause()to silence internal audio - Play the stream URL through your own audio element
- Sync state back: write to
player.playing,player.currentTime,player.duration - While externally controlled, user interactions emit
wt-toggleandwt-seekinstead of controlling internal audio
Properties (read/write when externally controlled)
| Property | Type | Description |
|----------------|-----------|--------------------------------------------------|
| meta | EmbedMeta \| null | Resolved embed metadata (read-only in practice) |
| playing | boolean | Whether the player shows as playing |
| currentTime | number | Current playback position in seconds |
| duration | number | Total duration in seconds |
| externalControl | boolean | When true, suppresses internal state updates |
Public methods
| Method | Description |
|----------------|--------------------------------------------|
| pause() | Pause internal audio |
| resume() | Resume internal audio (if a source is loaded) |
| seek(time) | Seek internal audio to time seconds |
Example bridge
const player = document.querySelector('washtub-player')
const myAudio = new Audio()
player.addEventListener('wt-play', (e) => {
player.externalControl = true
player.pause()
player.playing = true
myAudio.src = player.meta.stream_url
myAudio.play()
})
player.addEventListener('wt-toggle', () => {
if (myAudio.paused) { myAudio.play() } else { myAudio.pause() }
})
player.addEventListener('wt-seek', (e) => {
myAudio.currentTime = e.detail.time
})
myAudio.addEventListener('timeupdate', () => {
player.currentTime = myAudio.currentTime
player.duration = myAudio.duration
player.playing = !myAudio.paused
})Headless mode (bring your own UI)
For consumers who need 100% custom UI, the package exports the data-fetching and audio logic as standalone modules:
import { resolveToken, AudioController, formatTime, formatDuration, formatBitrate, formatSampleRate } from '@vinylproject/washtub-player'
import type { EmbedMeta, SongMeta, AlbumMeta, EmbedTrack } from '@vinylproject/washtub-player'resolveToken(apiBase, token): Promise<EmbedMeta>
Fetches embed metadata from the API. Returns a SongMeta or AlbumMeta object.
const meta = await resolveToken('https://your-instance.com', 'embed-token')
if (meta.type === 'song') {
console.log(meta.title, meta.artist, meta.stream_url)
} else {
console.log(meta.title, meta.tracks.length, 'tracks')
}AudioController
A thin wrapper around HTMLAudioElement that manages playback state:
const audio = new AudioController()
audio.onStateChange = () => {
console.log(audio.playing, audio.currentTime, audio.duration)
}
audio.onTrackEnded = () => {
console.log('Track finished')
}
// Play a stream URL
audio.play('https://example.com/stream.mp3')
// Control playback
audio.pause()
audio.toggle()
audio.seek(30) // seek to 30 seconds
// Read state
audio.playing // boolean
audio.currentTime // number (seconds)
audio.duration // number (seconds)
audio.currentSrc // string
// Clean up
audio.dispose()Utility functions
formatTime(seconds: number): string // 90 → "1:30"
formatDuration(ms: number): string // 90000 → "1:30"
formatBitrate(bps: number): string // 320000 → "320 kbps"
formatSampleRate(hz: number): string // 44100 → "44.1 kHz"Example: React custom player
import { useEffect, useRef, useState } from 'react'
import { resolveToken, AudioController, formatTime } from '@vinylproject/washtub-player'
import type { EmbedMeta } from '@vinylproject/washtub-player'
function CustomPlayer({ token, apiBase }: { token: string; apiBase: string }) {
const [meta, setMeta] = useState<EmbedMeta | null>(null)
const [playing, setPlaying] = useState(false)
const [time, setTime] = useState(0)
const audio = useRef(new AudioController())
useEffect(() => {
resolveToken(apiBase, token).then(setMeta)
const ctrl = audio.current
ctrl.onStateChange = () => {
setPlaying(ctrl.playing)
setTime(ctrl.currentTime)
}
return () => ctrl.dispose()
}, [token, apiBase])
if (!meta || meta.type !== 'song') return null
return (
<div>
<h3>{meta.title}</h3>
<p>{meta.artist}</p>
<button onClick={() => audio.current.toggle()}>
{playing ? 'Pause' : 'Play'}
</button>
<span>{formatTime(time)}</span>
</div>
)
}Example: Vue 3 custom player
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { resolveToken, AudioController, formatTime } from '@vinylproject/washtub-player'
import type { EmbedMeta } from '@vinylproject/washtub-player'
const props = defineProps<{ token: string; apiBase: string }>()
const meta = ref<EmbedMeta | null>(null)
const playing = ref(false)
const time = ref(0)
const audio = new AudioController()
audio.onStateChange = () => {
playing.value = audio.playing
time.value = audio.currentTime
}
onMounted(async () => {
meta.value = await resolveToken(props.apiBase, props.token)
})
onUnmounted(() => audio.dispose())
</script>
<template>
<div v-if="meta?.type === 'song'">
<h3>{{ meta.title }}</h3>
<p>{{ meta.artist }}</p>
<button @click="audio.toggle()">{{ playing ? 'Pause' : 'Play' }}</button>
<span>{{ formatTime(time) }}</span>
</div>
</template>Type reference
interface SongMeta {
type: 'song'
title: string
artist: string | null
album: string | null
year: number | null
genre: string
format: string
bit_rate: number
sample_rate: number
duration_ms: number
cover_url: string | null
allow_download: boolean
stream_url: string
}
interface AlbumMeta {
type: 'album'
title: string
artist: string | null
year: number | null
cover_url: string | null
allow_download: boolean
tracks: EmbedTrack[]
}
interface EmbedTrack {
id: number
title: string
artist: string
track_number: number
disc_number: number
duration_ms: number
format: string
bit_rate: number
genre: string
stream_url: string
}
type EmbedMeta = SongMeta | AlbumMetaDevelopment
# Install dependencies
npm install
# Build (typecheck + vite build)
npm run build
# Watch mode (rebuild on changes)
npm run dev
# Typecheck only
npm run typecheckBuild output goes to dist/ with three files:
washtub-embed.js— ES modulewashtub-embed.umd.cjs— UMD bundle (for script tags)index.d.ts— TypeScript declarations
Architecture
src/
├── index.ts Main exports (component + headless API)
├── washtub-player.ts Lit Web Component (<washtub-player> custom element)
├── api.ts Token resolution + TypeScript interfaces
├── audio.ts AudioController (HTMLAudioElement wrapper)
├── styles.ts Shadow DOM styles (CSS custom properties + parts)
└── utils.ts Formatting helpers (time, duration, bitrate)The component uses Lit for reactive rendering inside a Shadow DOM. Lit is bundled into the output — consumers don't need to install it separately.
