Watch and listen together — sync YouTube playback in real time, build a collaborative queue, vote to skip, and chat with friends. No account required.
Live: together.chtnnhfoundation.org · Source: github.com/chtnnh/together
Most watch-party apps assume desktop, always-on video, and one look for everyone. Together is built for how people actually listen:
| Mobile-first | Four-tab room nav (Queue default), full-height chat, bottom add URL bar, and a unified header. Install as a PWA for a full-screen session on your phone. |
| Audio-only mode | Hide the player and keep listening — perfect for music sessions, background listening, or saving bandwidth. Toggle per browser; doesn’t affect anyone else. |
| Personal themes | Pick your own accent theme (midnight, ocean, sunset, forest, lavender). Saved locally — your room, your colors. |
- Synchronized YouTube playback with drift correction
- Now-playing bar — pinned strip with seek, play/pause, skip, volume, reactions, and skip votes (stacked on mobile, inline on desktop)
- Keyboard shortcuts — Space, arrows, M, N,
/,?(desktop) - Seek bar — scrub to any point when you have playback control
- Play any track from the queue on demand (not just “next”)
- Late-join sync (“tap to sync” when autoplay is blocked)
- Header Sync playback button and explicit connection status
- Host/co-host play, pause, and skip
- Open controls mode — unlock playback so every member can control play/skip/seek and add directly to the queue
- Loop modes: off, repeat current track, repeat queue — queue loop toggle on the Queue tab (mobile and desktop)
- Playback stability when the queue ends or the browser tab loses focus
- Crossfade between tracks; volume normalization on track change
- Background playback on mobile (tab hidden without pausing the room)
- Unavailable or deleted YouTube videos blocked at import with inline error banner
- Two-lane queue: member requests → host DJ queue
- Drag-and-drop queue reordering (host/co-host), including touch drag on mobile
- Mobile room layout — Requests, Queue, History, and Chat tabs; add URL/search pinned to the bottom on Queue and Requests
- Democratic promote votes on requests (optional)
- Clear all requests or queue in one click
- Queue history with one-click re-add
- YouTube URL paste and search
- Smart track resolution (ISRC-first, fuzzy title/artist matching, alternate picker)
- Recent rooms on home and live public room directory
- Spotify & SoundCloud import — server routes in place; UI deferred (TODO v0.3)
- Public, unlisted, or password-protected rooms with signed invite links
- Custom room names; room settings persist across host refresh
- Kick, ban, promote and demote co-hosts
- Ownership transfer to signed-in participants
- Vote-to-skip with configurable threshold (votes reset when the track changes)
- Democratic request promotion (optional)
- Text chat with emoji picker and @mentions (autocomplete + highlight)
- Slow mode and profanity filter (host settings)
- Six theme presets including High contrast — personal accent colors
- Audio-only mode — hide video on all screen sizes, keep the music going
- Stream quality preference (auto / 720p / 480p / 144p)
- Reduced motion toggle; respects
prefers-reduced-motion - Activity toasts (join, leave, kick, ban, promote, skip)
- Share sheet, Open Graph link previews, Copy Discord status
- Installable app with Create room and Join room manifest shortcuts
- Service worker caches the app shell; offline fallback page
- Playwright E2E and visual regression tests (Linux Docker baselines in CI)
- Sign in to save room settings, save/load playlists, and sync preferences across devices
┌──────────────────────────────┐ WebSocket ┌────────────────────────────────────┐
│ Next.js (Vercel) │ ◄────────────────► │ Cloudflare Worker │
│ together.chtnnhfoundation… │ │ realtime.together.chtnnhfound… │
└──────────────┬───────────────┘ │ + Durable Objects (rooms) │
│ └────────────────────────────────────┘
│ SQL
▼
┌──────────────────────────────┐
│ Supabase Postgres │
│ (rooms, settings, playlists)│
└──────────────────────────────┘
| Package / app | Role |
|---|---|
apps/web |
Next.js 15 UI, REST API routes, room pages |
services/realtime |
Cloudflare Worker + Durable Object for WebSocket rooms |
packages/shared |
Event protocol, Zod schemas, playback math |
packages/db |
Drizzle ORM schema + migrations |
packages/ui |
Shared React components |
packages/track-resolver |
YouTube / ISRC / fuzzy matching |
- Node.js ≥ 22 (required by Wrangler for the realtime dev server)
- pnpm 9 (
corepack enable && corepack prepare pnpm@9.15.9 --activate) - PostgreSQL (local or Supabase)
- YouTube Data API key (required for search/import)
- Cloudflare account (optional — only if you deploy the realtime worker yourself)
- Optional: Spotify, Apple Music, Supabase auth keys
git clone https://github.com/chtnnh/together.git
cd together
pnpm installCopy the example env and fill in values at the repo root (monorepo-wide):
cp .env.example .envMinimum for local dev:
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/together
YOUTUBE_API_KEY=your_youtube_api_key
NEXT_PUBLIC_REALTIME_URL=ws://127.0.0.1:8787
NEXT_PUBLIC_APP_URL=http://localhost:3000
ROOM_TOKEN_SECRET=dev-secret-change-meEnv is loaded from the repo root by
apps/web/next.config.tsandpackages/db/drizzle.config.ts. You do not need to duplicate keys intoapps/web/.env.localunless you want overrides.
pnpm db:migrateThis applies all SQL in packages/db/drizzle/ via Drizzle Kit.
Production: point at .env.prod without overwriting local .env:
ENV_FILE=.env.prod pnpm db:migrateSupabase: use the Session pooler URI (port 5432) for migrations if the direct host fails — db.*.supabase.co is IPv6-only and many networks cannot reach it.
Supabase → Settings → Database → Connection string → Session pooler (not Transaction / 6543).
DATABASE_URL=postgresql://postgres.[project-ref]:[password]@aws-0-[region].pooler.supabase.com:5432/postgresFor Vercel runtime, use the Transaction pooler (port 6543, ?pgbouncer=true) instead.
Run migrations once from your machine (or CI), not from Vercel serverless.
Terminal 1 — realtime worker:
pnpm --filter @together/realtime dev
# listens on http://127.0.0.1:8787Terminal 2 — web app:
pnpm --filter @together/web dev
# http://localhost:3000- Open http://localhost:3000 and create a room
- Realtime health: http://127.0.0.1:8787/health
- DB health: http://localhost:3000/api/health/db
| Command | Description |
|---|---|
pnpm dev |
Start all apps via Turborepo |
pnpm --filter @together/web dev |
Next.js dev server |
pnpm --filter @together/realtime dev |
Cloudflare Worker (Wrangler dev) |
pnpm db:migrate |
Apply Drizzle migrations |
pnpm db:generate |
Generate migration from schema changes |
pnpm --filter @together/web build |
Production build |
pnpm --filter @together/web test |
Playwright E2E tests |
pnpm typecheck |
Typecheck all packages |
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | Postgres connection (Supabase in prod) |
YOUTUBE_API_KEY |
Yes | YouTube Data API v3 |
NEXT_PUBLIC_REALTIME_URL |
Yes | WebSocket base — prod: wss://realtime.together.chtnnhfoundation.org |
NEXT_PUBLIC_APP_URL |
Yes | Public site URL |
ROOM_TOKEN_SECRET |
Yes | JWT secret for room tokens (long random string) |
NEXT_PUBLIC_SUPABASE_URL |
Optional | Supabase project URL |
NEXT_PUBLIC_SUPABASE_ANON_KEY |
Optional | Supabase anon key |
SUPABASE_SERVICE_ROLE_KEY |
Optional | Server-side Supabase |
SPOTIFY_CLIENT_ID / SECRET |
Optional | Spotify playlist import |
NEXT_PUBLIC_SPOTIFY_CLIENT_ID |
Optional | Spotify OAuth redirect (client) |
APPLE_MUSIC_* |
Optional | Apple MusicKit import |
See .env.example for the full list.
together/
├── apps/web/ # Next.js app
├── services/realtime/ # Cloudflare Worker + Durable Object
├── packages/
│ ├── shared/ # Protocol & schemas
│ ├── db/ # Drizzle schema + migrations
│ ├── ui/ # Component library
│ └── track-resolver/ # Track matching
├── .env.example
├── turbo.json
└── pnpm-workspace.yaml
pnpm --filter @together/web test:install # first time / CI: install Playwright browsers
pnpm --filter @together/web test # starts web + realtime via Playwright webServerPlaywright specs live in apps/web/e2e/. Requires Node.js 22+ (Wrangler dev server).
| Symptom | Likely cause |
|---|---|
| “Connecting…” forever | Worker unreachable — check realtime health and NEXT_PUBLIC_REALTIME_URL |
| YouTube search empty | Missing/invalid YOUTUBE_API_KEY, or empty override in apps/web/.env.local |
| Room create fails | DATABASE_URL wrong or migrations not applied |
EHOSTUNREACH on migrate |
Direct db.*.supabase.co is IPv6-only — use Session pooler URI (port 5432) in .env |
| WebSocket reconnect loop | Two tabs open for same room; close duplicate tabs |
| “Offline — start realtime server” | Worker unreachable from browser |
- Mobile room layout: unified header, four-tab nav (Queue default), full-height chat, bottom add URL bar
- Skip vote UI beside reactions on mobile; full copy and tight layout on desktop
- Queue loop toggle (go through queue → loop queue → loop video) on the Queue tab
- Playback fixes: no replay stutter when queue ends; tab blur no longer stops sync
- Visual regression CI runs in Linux Docker with a single canonical snapshot set
- Unified playlist import via the room add bar (YouTube, Spotify, SoundCloud, Apple Music URLs)
- Google OAuth sign-in (Supabase)
- Live participant counts, ephemeral chat, DO lifecycle + room snapshots
- In-room account/playlists modals, 1080p/Max quality, superadmin console
- Auth, playlists, and room claim for signed-in users
- Playback sync fixes (server clock offset, drift correction)
- Open Graph preview images and favicon
- Cloudflare Workers Git integration build config
Licensed under the Apache License 2.0.
Built by chtnnh. v0.3.1 — together.chtnnhfoundation.org · Source on GitHub