Shared listening rooms backed by your own Navidrome library. Open a room, share the URL, queue tracks together, chat, send hearts. Synced playback across every device.
- Synced playback across every connected client (server-driven, sub-second drift, auto-corrects)
- Lossless audio — Navidrome streams the original file with
format=raw; no transcoding - Shared queue — anyone in the room can add tracks or queue whole public Navidrome playlists
- Chat with live presence (see who's listening)
- Mobile-ready — Media Session integration for lock-screen and Bluetooth controls
- Heart reactions — tap the heart, pink hearts flood every connected screen
- Optional admin — one named user can be exempt from rate limits and delete rooms (off by default)
- A running Navidrome instance reachable from wherever you'll host this
- One Navidrome account that has access to the music you want to share
- Either Docker (easiest) or Node 24+
git clone https://github.com/YOUR_USERNAME/navidisco.git
cd navidisco
cp .env.example .env.local
# Edit .env.local — fill in NAVIDROME_URL / NAVIDROME_USER / NAVIDROME_PASSWORD
docker compose up --buildOpen http://localhost:6969.
The compose stack uses a named volume (navidisco-data) for the SQLite database, so room data survives docker compose down.
git clone https://github.com/YOUR_USERNAME/navidisco.git
cd navidisco
npm install
cp .env.example .env.local
# Edit .env.local
npx prisma migrate deploy
npm run build
npm startFor development with auto-reload:
npm run devEverything is environment variables. .env.local is gitignored and is the right place to put them locally; in Docker, the compose file reads .env.local automatically.
| Variable | Required | Default | Description |
|---|---|---|---|
NAVIDROME_URL |
yes | — | URL to your Navidrome instance, e.g. http://192.168.1.10:4533 |
NAVIDROME_USER |
yes | — | A Navidrome account with access to the music you want to share |
NAVIDROME_PASSWORD |
yes | — | Password for that account |
NAVIDISCO_ADMIN_NAME |
no | — | Display name granted admin privileges. Case-insensitive. |
NAVIDISCO_ADMIN_PASSWORD |
no | — | Required when claiming the admin name |
DATABASE_URL |
no | file:./dev.db |
SQLite connection string |
PORT |
no | 6969 |
Port to listen on |
If both NAVIDISCO_ADMIN_NAME and NAVIDISCO_ADMIN_PASSWORD are set:
- That display name is reserved. Anyone trying to use it has to enter the password.
- The authenticated admin can delete rooms and is exempt from queue rate limits.
If either is unset, the admin role is disabled. Every user is equal — anyone can pause, skip, queue tracks, but no one can delete rooms.
Non-admin users are limited to 5 tracks added per 5 minutes and 15 per hour (in-memory; resets on server restart). Tweak the constants in src/lib/rateLimit.ts for different numbers.
The server is the source of truth. It holds canonical playback state — { trackId, startedAt: wall-clock ms, paused } — and broadcasts updates over Socket.IO. Each client streams the audio independently from a server-side proxy that keeps Navidrome credentials off the wire, computes its target position from Date.now() - startedAt, and re-seeks if its <audio> element drifts more than 600ms. Periodic 3-second drift correction handles tab backgrounding and buffer stalls.
Auto-advance is a server-side setTimeout keyed on track duration. It re-arms on server boot if you restart mid-track. Track changes (auto-advance and lock-screen skip) trigger an immediate optimistic source swap inside the original event tick, which keeps iOS audio focus alive across changes.
- Next.js 16 (App Router) with a custom server for Socket.IO
- Prisma 7 + SQLite (via
@prisma/adapter-better-sqlite3) - Subsonic API for Navidrome
- Tailwind v4 for styling
- No real authentication. Display name is just localStorage. Anyone with a room URL gets in. Made for friend-sized groups, not the open web.
- No transcoding fallback. If a file's codec isn't natively decodable in the browser, it won't play. FLAC, MP3, AAC, OGG, ALAC all work in modern browsers.
- One process, one database. Not built to scale horizontally.
- Rate limits are in-memory; restart resets them.
If your use case outgrows any of these, you'll need to fork and extend.