npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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-player

Or 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

  1. Listen for wt-play — the user clicked play on the washtub-player
  2. Set player.externalControl = true and call player.pause() to silence internal audio
  3. Play the stream URL through your own audio element
  4. Sync state back: write to player.playing, player.currentTime, player.duration
  5. While externally controlled, user interactions emit wt-toggle and wt-seek instead 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 | AlbumMeta

Development

# Install dependencies
npm install

# Build (typecheck + vite build)
npm run build

# Watch mode (rebuild on changes)
npm run dev

# Typecheck only
npm run typecheck

Build output goes to dist/ with three files:

  • washtub-embed.js — ES module
  • washtub-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.