Navidisco: shared listening rooms for your self-hosted music

Navidisco: shared listening rooms for your self-hosted music
Photo by C D-X / Unsplash
navidisco
Shared listening sessions backed by Navidrome

A small project I built this weekend. I have a bunch of FLACs sitting in a Navidrome instance on the home server. Spotify Group Sessions exists, Apple Music has its own version, but nothing lets a few friends gather around a subsonic instance. 😄

Navidisco is a tiny web app: you open a room, paste the link in a group chat, and everyone who joins listens to the same thing at the same time. Add tracks to a shared queue, talk in chat, queue up entire Navidrome playlists. That's the whole thing.

The interesting parts

Audio sync. Every client streams independently. The server holds the canonical state — a track ID, a wall-clock timestamp marking when it started, and a paused flag. On every state change, it broadcasts to the room over Socket.IO. Clients compute their target playback position as (now - startedAt) / 1000 and re-seek their <audio> element if it's drifted more than 0.6 seconds. A periodic 3-second drift check keeps things honest after long pauses or backgrounded tabs.

It's the same approach as Jellyfin SyncPlay. Sample-accurate sync would need a server-side mixer; this gets you sub-100ms drift on a LAN and is good enough for "we're listening to this together" rather than "we're DJing a wedding."

Lossless audio. The Subsonic stream endpoint takes a format parameter. Pass format=raw and Navidrome serves the original file unconverted. Upload FLAC, get FLAC. Modern browsers play FLAC natively in <audio>. No transcoding, no quality loss, no decisions to make.

Server-side proxy. Navidrome credentials never reach the browser. The custom Next.js server intercepts /api/stream/<id> and /api/cover/<id> and pipes them through with full Range support — so the browser can seek, buffer ahead, do everything it would do talking directly to Navidrome, but with a salt+token URL it never sees.

Resilient timers. Auto-advance is a server-side setTimeout based on track duration. If the dev server restarts mid-track (or the host reboots), the in-memory timer is gone but the database still says "track X is playing." On boot, navidisco scans for rooms with a track in flight, computes remaining time, and re-arms the advance. The client also fires a hint when its <audio> element ends, which the server only acts on if the trackId still matches the current one — so multiple listeners' "ended" events don't cause skipping.

The stack

  • Next.js 16 (App Router, custom server) — for the UI and route handlers
  • Socket.IO â€” realtime room state, mounted on the same HTTP server Next is serving from. One process, one port
  • Prisma 7 + SQLite â€” rooms, queues, chat history. Prisma 7's driver-adapter pattern is new but @prisma/adapter-better-sqlite3 works without ceremony
  • Subsonic API for Navidrome — search3getPlaylistsgetPlayliststreamgetCoverArt
  • No auth â€” display name in localStorage. Bearer tokens, OAuth, all that comes later or never

The whole thing is around a thousand lines of TypeScript. Most of the complexity is in the sync state machine; the UI is a single React client component with a few sub-components and Tailwind classes.

What's missing

It's a v1. There's no authentication, so anyone with the room URL gets in. There's no transcoding fallback, so if you upload some weird AIFF the browser can't decode, it just won't play. The "DJ" model is democratic: anyone in the room can pause, skip, or remove queue items. None of that is wrong for friends. All of it would be wrong for strangers.

What's next on my list: a Media Session API "now playing" hook so the lock screen and Bluetooth controls do the right thing.

Why bother

There's a particular pleasure in enjoying music with a friend from across the country, or planet.

It does one thing... and it does it well.