Skip to content
WolfWave
Developers

OBS Widget

How WolfWave's now-playing OBS overlay is built. A Tailwind + TypeScript workspace that compiles into a single self-contained widget.html with smooth play/stop transitions.

The OBS overlay is one self-contained HTML file at apps/native/WolfWave/Resources/widget.html. The native app bundles it, and WidgetHTTPService serves it to OBS Browser Source clients.

Don't edit that file directly. The real source is a Tailwind + TypeScript workspace at apps/widget/. The bundled HTML is a generated artifact, produced by the explicit Bun build or by CI. Xcode bundles the committed artifact; it does not rebuild it.

Architecture

flowchart TD
    A[design-system/tokens.json] -->|bun run tokens| B[widget-tokens.generated.js]
    C[apps/widget/src/widget.html] --> D[apps/widget/build.ts]
    E[apps/widget/src/widget.css] --> F[Tailwind CLI]
    G[apps/widget/src/widget.ts] --> H[Bun.build IIFE]
    F -->|minified css| D
    H -->|widget.js| D
    B --> D
    D -->|inline + write| I[apps/native/WolfWave/Resources/widget.html]
    I --> J[WidgetHTTPService → OBS]

Everything lands in one HTML file: <style>, the tokens <script>, and the runtime <script> all inlined. No <link>, no <script src>, no extra HTTP round-trips. The supported path is the local HTTP URL exposed by WolfWave, which also bootstraps the authenticated WebSocket connection.

Message contract

The browser widget consumes the server-to-client messages from the shared WebSocket service. The widget itself is receive-only. Schemas are frozen by the test suite. Adding fields server-side is safe; renaming fields requires a coordinated change.

TypePayloadCadence
welcome{}Once on connect
now_playing{ track, artist, album, duration, elapsed, isPlaying, artworkURL }Track change
progress{ elapsed, duration, isPlaying }~1 Hz
playback_state{ isPlaying, track?, artist?, album? }State change
overlay_visibility{ visible }Stream Deck visibility toggle
widget_config{ theme, layout, textColor, backgroundColor, fontFamily }Settings change
queue_upcoming{ items: [{ title, requesterUsername }] } (max 3)Queue change

The browser widget never sends back. The native app pushes, the browser renders.

Control API

The same listener also exposes a separate, loopback-only control role for Stream Deck. The widget's overlay credential is read-only and cannot send commands. The command protocol lives on the Stream Deck page.

Paused playback

When Music.app reports the loaded track as paused (kPSp), the widget stays on stream. The card is not hidden. Instead:

  • The widget root gains the .is-paused class
  • Album artwork drops to ~55% opacity with reduced saturation
  • A pause glyph overlays the artwork
  • The progress loop suspends, so the bar freezes the moment pause arrives

The card only fades out on a genuine "track cleared" event (Music.app quits, permission revoked, or tracking disabled). Hitting pause keeps the song context visible so chat knows the integration is still healthy.

Themes and layouts

Five themes, five layouts, ready to go. They live in design-system/tokens.json under widget.themes and widget.layouts:

The selectable themes are Default, Dark, Light, Glass, and Neon. The widget adds 16 px of transparent padding on every side, so size the OBS Browser Source 32 px larger than the generated card:

LayoutGenerated cardRecommended OBS canvas
Horizontal500 × 100532 × 132
Vertical220 × 280252 × 312
Compact350 × 56382 × 88
Vinyl260 × 300292 × 332
Classic440 × 112472 × 144

Vinyl is a spinning record with the album art as its label and a circular progress ring; Classic is an album tile beside a card with title, artist, and a progress bar.

tokens.json also defines a WolfWave theme that's hidden from the picker (WidgetTheme.order in the generated tokens excludes it), so the app exposes five selectable themes.

Swap themes without rebuilding. Themes aren't compiled into utility variants. They arrive at runtime over the WebSocket (widget_config messages), so changing the theme or layout in the app's Stream Widgets settings restyles the overlay live. No URL edit, no rebuild, no OBS refresh.

Preview before you commit. The Stream Widgets settings pane (Widget Appearance) shows a live preview right under the controls. Change a theme, layout, font, or color and the preview updates as you go, so you can dial in the look before copying the URL into OBS. Default and Glass expose the Text and Background color pickers; the other themes ship fixed palettes.

URL parameters:

ParameterWhat it does
?token=<hex>LAN bootstrap token; the widget forwards it when opening the WebSocket. Loopback pages receive the token in the served HTML instead.
?duration=8Auto-hide after N seconds (0 = never)
?hideAlbumArtRender without the artwork tile
?queueTicker=1Show the upcoming-queue ticker panel (off by default)

Theme and layout aren't URL parameters. Set them in Settings → Stream Widgets and the overlay follows along live.

Queue ticker

Off by default, opt-in per Browser Source. Add ?queueTicker=1 to your widget URL to show a small panel listing the next 3 song requests (title + requester). It's a separate fixed-position panel, not part of the now-playing card or any layout, so turning it on never changes an existing Browser Source's size or position. When the queue is empty it shows "Queue open, !sr to join" instead of disappearing, so the panel doesn't flicker in and out as the queue drains.

To run it alongside the now-playing card, add it to your existing widget URL: ?queueTicker=1 if that URL has no query parameters yet, or &queueTicker=1 if it already has one (a LAN URL carrying ?token=..., for instance). Both render in the same Browser Source, sized to fit both. Or add a second Browser Source pointed at the same widget URL with ?queueTicker=1 and position it independently in your scene, the same way chat boxes and alert widgets are typically composed in OBS.

Transitions

The container moves through a four-state machine. Class swaps are driven from src/widget.ts → TRANSITIONS:

TriggerClass pathTiming
song startswidget-hiddenwidget-enteringwidget-visible600 ms, bouncy cubic-bezier(0.34, 1.56, 0.64, 1)
song stopswidget-visiblewidget-exitingwidget-hidden500 ms, calm cubic-bezier(0.4, 0, 0.2, 1)
track skip while visibleinner .track-meta + .artwork crossfade280 ms total
song stops.progress-fill.draining width 0400 ms ease-out

The container animation does not re-trigger on track skip. That's deliberate. Otherwise rapid skips strobe the stream.

Pause does not trigger the exit animation. Per the native AppleMusicSource.extractPlayerState contract, only true stop (kPSS) or an empty current track maps to NOT_PLAYING.

File map

apps/widget/
├── src/
│   ├── widget.html       # HTML shell with %%TAILWIND_CSS%% / %%TOKENS_JS%% / %%WIDGET_JS%% placeholders
│   ├── widget.css        # @tailwind directives + custom state classes (transitions, progress, decorative layers)
│   └── widget.ts         # All runtime. State, transitions, WS, message dispatch, render
├── tailwind.config.ts    # Token-driven theme.extend; preflight + container disabled
├── postcss.config.js
├── build.ts              # Bundles JS, runs Tailwind, inlines into the template, writes the output file
├── package.json
└── README.md             # Mirrors this page (kept in sync intentionally)

The runtime source is heavily commented top-to-bottom, with banner sections (CONFIG, STATE, TRANSITIONS, RENDER, WEBSOCKET, MESSAGE HANDLERS, BOOT) and paragraph blocks on every non-trivial function. Read it linearly to understand the whole widget.

Dev loop

# Regenerate tokens when tokens.json or its generator inputs change
bun run tokens

# Rebuild the widget with the current generated token module
make widget

# Or build the ordered monorepo graph (tokens before widget)
bun run build

Output lands at apps/native/WolfWave/Resources/widget.html. To spot-check the supported path, run the native app and open http://localhost:<widgetHTTPPort>/.

When rebuilds happen

  • Widget-only work. Run make widget (or bun run --filter widget build). Run bun run tokens first when token definitions or generator inputs changed.
  • Root build. bun run build lets Turborepo order token generation before the widget build.
  • Xcode. Xcode does not invoke Bun. It bundles the committed widget.html, which keeps native-only builds independent of the JavaScript toolchain.
  • CI. test.yml and build_release.yml rebuild the artifact before xcodebuild; test CI also checks for drift.

Security

The widget handles only the read-only overlay token; the token and WebSocket role handshake are documented in the security model. Widget-specific behavior:

  • Loopback pages get the overlay token injected into the served HTML. WidgetHTTPService returns those responses with Cache-Control: no-store, so the token never sits in a browser cache.
  • LAN pages read ?token=<hex> from the URL and forward it as an overlay subprotocol. Every widget document sets no-referrer, so cross-origin artwork requests cannot leak that query token.

See also

On this page