Skip to content

Repository files navigation

Together

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


What makes Together different

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.

Features

Playback & sync

  • 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

Queue & discovery

  • 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)

Room & moderation

  • 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)

Chat & personalization

  • 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

PWA

  • Installable app with Create room and Join room manifest shortcuts
  • Service worker caches the app shell; offline fallback page

Quality

  • Biome lint/format, Vitest unit tests, Playwright E2E (desktop + mobile), and visual regression (Linux Docker baselines in CI)
  • Tiered git hooks: fast pre-commit, smoke+affected pre-push; full pnpm ci:local in CI
  • CI uploads Playwright HTML reports and failure screenshots — see CONTRIBUTING.md

Optional (requires Supabase auth)

  • Sign in to save room settings, save/load playlists, and sync preferences across devices

Architecture

┌──────────────────────────────┐     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

Prerequisites

  • 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

Local development

1. Clone and install

git clone https://github.com/chtnnh/together.git
cd together
pnpm install

2. Environment

Copy the example env and fill in values at the repo root (monorepo-wide):

cp .env.example .env

Minimum 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-me

Env is loaded from the repo root by apps/web/next.config.ts and packages/db/drizzle.config.ts. You do not need to duplicate keys into apps/web/.env.local unless you want overrides.

3. Database migrations

pnpm db:migrate

This 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:migrate

Supabase: 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/postgres

For Vercel runtime, use the Transaction pooler (port 6543, ?pgbouncer=true) instead.

Run migrations once from your machine (or CI), not from Vercel serverless.

4. Start services

Terminal 1 — realtime worker:

pnpm --filter @together/realtime dev
# listens on http://127.0.0.1:8787

Terminal 2 — web app:

pnpm --filter @together/web dev
# http://localhost:3000

5. Verify

6. Git hooks (after pnpm install)

Husky installs shared hooks automatically (prepare script).

Hook Command Typical time
pre-commit pnpm ci:pre-commit ~30–90s
pre-push pnpm ci:pre-push ~1-5 min (incremental) or ~10 min (large/infra diffs)

pre-commit: lint-staged (Biome), Biome on changed files, affected typecheck, Vitest --changed, DB guard if schema.ts changed.

pre-push: Biome (full repo), affected typecheck/unit/build, DB guard if schema changed. Diffs over 30 files vs main or CI infra changes run full quality + build + unit + db. E2E and visual run in CI only (pnpm ci:local). When the hook exits 0, git push continues automatically.

Optional know-code gate: bash scripts/enable-know-code-hooks.sh (machine-local). See CONTRIBUTING.md.

SKIP_HOOKS=1 git commit    # bypass hooks (you own CI)
git push --no-verify

Scripts

Day-to-day

Command Description
pnpm dev Start all apps via Turborepo
pnpm --filter @together/web dev Next.js dev server (:3000)
pnpm --filter @together/realtime dev Cloudflare Worker via Wrangler (:8787, Node 22+)
pnpm db:migrate Apply Drizzle migrations
pnpm db:generate Generate migration from schema changes
pnpm lint Biome check (format + lint, fails on warnings)
pnpm format Biome auto-fix
pnpm typecheck Typecheck all packages (Turborepo)
pnpm test:unit Vitest unit tests (all packages)

CI parity (ci:*)

Same scripts GitHub Actions runs — use pnpm ci:local for the full merge gate:

Command Description
pnpm ci:local Full pipeline: quality → build → unit → db → e2e → visual
pnpm ci:quality Biome + typecheck
pnpm ci:build Next.js + realtime worker build
pnpm ci:unit Vitest (all packages)
pnpm ci:db Drizzle journal / migration guard
pnpm ci:e2e Playwright E2E (mobile-chrome + chromium)
pnpm ci:visual Visual regression (Linux Docker baselines)
pnpm ci:pre-commit Hook: incremental (what pre-commit runs)
pnpm ci:pre-push Hook: quality + affected unit/build (what pre-push runs)

E2E & visual

Command Description
pnpm --filter @together/web test:install Install Playwright browsers (first time / CI)
pnpm --filter @together/web test Playwright E2E (mobile-chrome, then chromium)
pnpm --filter @together/web test:visual Visual regression (Docker / Linux)
pnpm --filter @together/web test:visual:update Regenerate visual baselines (Docker)
pnpm --filter @together/web build Production Next.js build

Environment variables

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.


Project structure

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

Testing

Together uses three layers: Vitest (unit), Playwright E2E (desktop + mobile), and visual regression (Linux Docker baselines in CI).

Unit (Vitest)

pnpm test:unit              # all packages (root vitest workspace)
pnpm ci:unit                # same, CI job

Vitest projects: packages/shared, packages/ui, packages/db, packages/track-resolver, services/realtime, apps/web.

E2E (Playwright)

pnpm --filter @together/web test:install   # first time / CI: browsers + deps
pnpm --filter @together/web test           # mobile-chrome, then chromium
pnpm ci:e2e                                # CI job (same as above)

Specs live in apps/web/e2e/. Playwright starts the web app and realtime worker via webServer config. Requires Node.js 22+ (Wrangler).

Run a subset:

pnpm --filter @together/web exec playwright test --project=chromium --grep @smoke
pnpm --filter @together/web exec playwright test apps/web/e2e/room.spec.ts

Visual regression

pnpm --filter @together/web test:visual          # compare against baselines
pnpm --filter @together/web test:visual:update   # regenerate (Docker)
pnpm ci:visual                                   # CI job

Baselines live under apps/web/e2e/visual-regression.spec.ts-snapshots/. CI runs in Linux Docker for deterministic pixels.

Full merge gate

pnpm ci:local    # runs ci:quality → build → unit → db → e2e → visual

See CONTRIBUTING.md for the GitHub Actions job mapping and hook behavior.


Troubleshooting

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

Releases

v0.3.1

  • 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

v0.3.0

  • 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

v0.2.3

  • 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

License

Licensed under the Apache License 2.0.


Acknowledgments

Built by chtnnh. v0.3.1 — together.chtnnhfoundation.org · Source on GitHub

About

stream audio and video, together

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Used by

Contributors

Languages