A Roon extension that lets house guests view the live play queue and add music to it from their phones — a collaborative party DJ. The host configures everything from Roon's own Extensions settings; guests just open a URL on the Wi-Fi.
- Now Playing card + live queue that updates on every phone in real time, with a toggle between Up Next and Recently Played
- Search your full Roon library (local + streaming) — tracks, albums, and artists (tap an artist to browse their music), with type/artist filters and result counts
- Add tracks or albums to the queue; live "Added by …" attribution
- Host-controlled zone, access mode, what can be added, where adds land, and limits
- Mobile-first, accessible UI (WCAG AA, keyboard-operable, screen-reader friendly)
- One-tap QR join — show
/joinon any screen and guests scan to open the app
| Now Playing + queue | Search (artist filter) | Artist drill-in |
|---|---|---|
![]() |
![]() |
![]() |
One Node.js + TypeScript process is the Roon extension, a Fastify + Socket.IO server,
and the host of the React web app. All Roon access is behind a RoonGateway interface,
so a FakeRoonGateway drives the unit tests and full Playwright e2e with no hardware.
shared/ TypeScript types shared by server + web
server/ Roon extension + Fastify API + Socket.IO + composition root
web/ React + Vite guest app
e2e/ Playwright specs (run the server against the fake gateway)
See docs/superpowers/specs/ and docs/superpowers/plans/ for the design and plan.
- Node.js 22+ (an
.nvmrcpins the version; runnvm use) - A Roon Core on the same LAN (for real use)
npm installpulls thenode-roon-api*dependencies straight from RoonLabs' public GitHub repositories (the official open-source SDK), so install needs network access to GitHub. They are commit-pinned for reproducible installs.
npm install
npm run build # builds shared, server, and webnpm run build
npm start # starts the server on PORT (default 8080)For unattended/party use, run npm start under a process supervisor that restarts on
exit (a systemd unit with Restart=always, or pm2) so a crash brings the server back
automatically.
On start it prints the LAN URLs to share with guests. Then, in Roon:
- Open Roon → Settings → Extensions.
- Enable "Roon DJ".
- Click its Settings and choose the party zone plus any options (access mode, what guests can add, where adds land, per-guest limit, etc.).
- Share the URL with guests — easiest is to open the built-in QR page at
http://<host>:8080/joinon any screen (TV, tablet, your phone) and have guests scan it. The startup log prints both the app URL and the/joinURL.
Settings are live — changing them in Roon updates guest behavior immediately.
Pairing with a Core writes
config.json, which holds live Roon pairing secrets (tokens). Never commit or share it — it is gitignored. Seeconfig.example.jsonfor the shape.
| Setting | Options | Default |
|---|---|---|
| Party zone | your Roon zones | first zone |
| Guest access | Open · Passcode · Name required | Open |
| Party passcode | text | empty |
| What guests can add | Tracks · Tracks+Albums · Tracks+Albums+Playlists | Tracks+Albums |
| Where adds land | End of queue · Play next | End of queue |
| Max adds per guest | 0 = unlimited | 0 |
| Show Now Playing to guests | Yes/No | Yes |
Two terminals:
# terminal 1 — API + Roon extension (real Core), watch mode
npm run dev -w @roon-extensions/dj-server
# terminal 2 — Vite dev server with HMR (proxies /api and /socket.io to :8080)
npm run dev -w @roon-extensions/dj-web # http://localhost:5173To develop the UI without a Roon Core, run the fake-backed full stack:
npm run build -w @roon-extensions/dj-shared && npm run build -w @roon-extensions/dj-web
ROONDJ_FAKE=1 PORT=8080 node --import tsx/esm server/src/fakeServer.ts
# open http://localhost:8080npm test # unit tests — server + web (Vitest)
npm run e2e # Playwright e2e + axe accessibility (Desktop Chrome, Pixel 5, iOS Safari)The e2e suite boots the stack against FakeRoonGateway, so it needs no Roon hardware
and is CI-friendly. The CI badge at the top of this README reflects live status; see
.github/workflows/ for the exact jobs.
- ✅ A unit-test suite (Vitest) — server-side coverage of access modes, add rules, caps, art cache, queue relay, routes, realtime, search/browse, and text cleanup (with enforced coverage thresholds), plus web-side tests (jsdom + Testing Library) for the time formatting and search-row behavior.
- ✅ Playwright e2e across Desktop Chrome, Mobile (Pixel 5), and iOS Safari (WebKit): queue render, search + add, browse drill-in + focus management, live cross-client updates, passcode/name gates, per-guest cap, reconnect + no-zone states, and zero axe accessibility violations with keyboard navigation. CI runs the full unit + e2e suites on every push — check the badge above for current pass/fail.
- ✅ Validated end-to-end against a live Roon Core in both Chromium and WebKit,
including over plain-HTTP LAN access (a non-secure context — note the app uses
crypto.getRandomValues, not the secure-context-onlycrypto.randomUUID). - ℹ️ The real
RoonGateway(inserver/src/roon/) talks to a live Roon Core. On first run, smoke-test: enable in Roon → pick a zone → open/joinon a phone → search/add a track and an artist → confirm it lands and shows on a second device. A build id is shown in the app header to confirm clients are on the latest build.
Guest display names are optional — they only appear in name-required mode, or if a guest chooses to type one. When present, a name lives only in bounded in-memory caches: it is shown to other guests as "added by <name>" attribution and in the in-memory history, and it is gone when the server restarts. Guest data is never written to disk, sent off-box, or tracked. There are no analytics, no telemetry, no cookies, and no third-party network calls — all data is in-process and LAN-local, and the server does not log guest IPs or queries.
A guest's own browser keeps their chosen name and a random session id in localStorage
(and the party passcode in sessionStorage) on their device, so they don't have to
re-enter them. The only thing the host writes to disk is Roon's pairing state in
config.json (the Core connection tokens — see below); no guest data is persisted there.
After rebuilding the web app, restart the server. The static file server indexes the built asset set at startup, so a rebuild while the server is running keeps serving the stale bundle until you restart. (This only affects deploy actions — it never happens during a live party.)
MIT — see LICENSE. RoonDJ is original work; the Roon integration is isolated
to the server/src/roon/ directory, which uses the official open-source Roon SDK
(node-roon-api*, Apache-2.0). No proprietary Roon Labs code is included. See THIRD-PARTY-NOTICES.md. "Roon" is a
trademark of Roon Labs LLC; this is an independent extension, not affiliated with Roon Labs.
- Host approval/moderation flow (a live
/hostview for pending adds) - Vote-to-skip and richer guest powers
- Full reorder/remove (constrained by Roon's public API)
- Verify/expand playlist add-as-target and per-artist top-tracks
The HostSettings model already carries fields for moderation and guest powers; they're
not yet surfaced in the Roon settings UI to avoid exposing inert controls.


