⚠️ HISTORICAL DOCUMENT: This spec was written for the original Mux-based architecture. The project has since migrated to AWS IVS + Supabase Storage. See CLAUDE.md for current architecture and TODO.md for current status.📚 Related Files: @CLAUDE.md (project context) | tech-stack.md (services & setup) | TODO.md (M1 tasks) | .claude/preferences.md (communication guide)
What This Document Is: This is the product specification — it defines what we're building, how it should work, and what it should look like.
What We're Building: A platform where artists/DJs run live listening parties with fan-submitted tracks. Fans upload audio files via a link, hosts manage a queue in real-time, and everyone watches a live stream together.
Why It Matters:
- For fans: Easy to submit tracks (just a link, no signup)
- For hosts: Real-time queue control (approve/skip/reorder instantly)
- For the business: Built-in revenue (tips + replay unlocks)
How to Use This Spec:
- GTM/Product: Read Vision, Roles, Core Flows, Milestones (skip technical details)
- Design: Focus on "Design System — Digital Workwear" section
- Engineering: Read entire spec, then check TODO.md for implementation tasks
Enable artists/hosts to run live listening events where fans submit any common audio file, hosts manage a queue in real-time, and sessions can be recorded and monetized later — built with an industrial, workwear-inspired design language. Visuals must not mimic Nero. Minimal hi‑vis accents; 90% neutral surfaces.
- Payouts/advanced billing (Stripe Connect later).
- Advanced moderation (simple takedown only).
- Native apps (responsive web only).
- Custom audio stitching (use Mux Live auto-recording).
- Fan: submits audio, views live page.
- Host: manages queue, plays tracks, ends session.
- Admin: basic takedown + event management.
- Public submission at
/submit/:token(metadata + file upload → queued). - Host dashboard at
/host/:eventId(approve/reorder/play/skip). - Public live page at
/live/:eventId(Now Playing + next up; embedded live player). - Logging for
submit,approve,play,skipevents.
flowchart LR
A[Client: Fan (Submit)] -->|Direct Upload| MUX[(Mux Direct Upload)]
A -->|POST| API[Next.js API Routes]
API --> DB[(Supabase Postgres)]
DB <-->|Realtime| HOST[Client: Host Dashboard]
DB <-->|Realtime| LIVE[Client: Public Live Page]
HOST -->|Controls: approve/play/skip| API
API --> DB
subgraph Streaming
OBS[Host OBS/Browser Audio] --> RTMP[RTMP ingest]
RTMP --> MuxLive[ Mux Live Stream ]
MuxLive --> Player[ HLS Player on /live ]
end
MUX --> API
API --> DB
- MVP (M1): Single shared Mux Live Stream; use
MUX_LIVE_PLAYBACK_ID_DEFAULTfor all events - Production (M2+): Each event gets unique
mux_live_playback_idvia Mux API on event creation - Rationale: Shared stream simplifies tonight's build; unique streams enable concurrent events later
sequenceDiagram
participant Fan
participant Web as App (Next.js)
participant SB as Supabase DB/Realtime
participant Mux as Mux Direct Upload
participant Host
Fan->>Web: Open /submit/:token
Web->>Web: Request Mux Direct Upload URL
Web->>Mux: Upload audio (tus)
Mux-->>Web: upload_id
Web->>SB: Insert submission {event_id, metadata, upload_id}
Host-->>SB: Subscribed to submissions channel
Host->>Web: Approve & Play submission
Web->>SB: Update status=playing; set now_playing
SB-->>Host: Realtime update (now playing)
SB-->>Fan: Realtime update (live page shows Now Playing)
profiles
iduuid PRIMARY KEY (references auth.users)display_nametextroletext CHECK (role IN ('fan', 'host', 'admin')) DEFAULT 'fan'created_attimestamptz DEFAULT now()
events
iduuid PRIMARY KEY DEFAULT gen_random_uuid()host_iduuid REFERENCES profiles(id) ON DELETE CASCADEnametext NOT NULLtokentext UNIQUE NOT NULLmux_live_playback_idtextis_liveboolean DEFAULT falsestarts_attimestamptzcreated_attimestamptz DEFAULT now()
submissions
iduuid PRIMARY KEY DEFAULT gen_random_uuid()event_iduuid REFERENCES events(id) ON DELETE CASCADEartist_nametexttrack_titletextupload_idtext (Mux Direct Upload ID)playback_idtext (set by webhook when asset ready)tip_centsint DEFAULT 0statustext CHECK (status IN ('pending', 'approved', 'playing', 'skipped', 'done')) DEFAULT 'pending'queue_positionintcreated_attimestamptz DEFAULT now()
now_playing
event_iduuid PRIMARY KEY REFERENCES events(id) ON DELETE CASCADEsubmission_iduuid REFERENCES submissions(id) ON DELETE SET NULLupdated_attimestamptz DEFAULT now()
event_logs
idbigserial PRIMARY KEYevent_iduuidprofile_iduuidactiontext (submit|approve|play|skip|upload_rejected)payloadjsonbcreated_attimestamptz DEFAULT now()
All tables have RLS enabled. Key policies:
Public Read (Anon + Authed):
-- events: only show live events publicly
CREATE POLICY "public_read_live_events" ON events
FOR SELECT TO anon, authenticated
USING (is_live = true);
-- submissions: only for live events
CREATE POLICY "public_read_submissions" ON submissions
FOR SELECT TO anon, authenticated
USING (EXISTS (SELECT 1 FROM events WHERE events.id = submissions.event_id AND events.is_live = true));
-- now_playing: public can see what's playing
CREATE POLICY "public_read_now_playing" ON now_playing
FOR SELECT TO anon, authenticated
USING (true);Public Write (Anon):
-- submissions: anyone can submit
CREATE POLICY "anon_insert_submissions" ON submissions
FOR INSERT TO anon, authenticated
WITH CHECK (true);
-- event_logs: anyone can log events
CREATE POLICY "anon_insert_logs" ON event_logs
FOR INSERT TO anon, authenticated
WITH CHECK (true);Host-Only Write (Authenticated):
-- submissions: only event host can update/delete
CREATE POLICY "host_manage_submissions" ON submissions
FOR UPDATE TO authenticated
USING (EXISTS (
SELECT 1 FROM events
WHERE events.id = submissions.event_id
AND events.host_id = auth.uid()
));
-- now_playing: only event host can control playback
CREATE POLICY "host_control_playback" ON now_playing
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM events
WHERE events.id = now_playing.event_id
AND events.host_id = auth.uid()
))
WITH CHECK (EXISTS (
SELECT 1 FROM events
WHERE events.id = now_playing.event_id
AND events.host_id = auth.uid()
));Admin-Only:
-- events: full CRUD for admins
CREATE POLICY "admin_manage_events" ON events
FOR ALL TO authenticated
USING (EXISTS (
SELECT 1 FROM profiles
WHERE profiles.id = auth.uid()
AND profiles.role = 'admin'
));Structured payloads by action type:
submit:{ submission_id, artist_name, track_title, file_size_mb }approve:{ submission_id, queue_position }play:{ submission_id, playback_id }skip:{ submission_id, reason: 'manual'|'error' }upload_rejected:{ upload_id, reason, file_size_mb }
Mux Integration:
POST /api/mux/create-upload→ returns Direct Upload URL + upload_idPOST /api/mux/webhook→ verify signature; onasset.ready, setplayback_id
Submissions:
POST /api/submissions→ create submission (uses upload_id)
Queue Management:
POST /api/queue/approve→ set status='approved', assign queue_positionPOST /api/queue/reorder→ acceptssubmissionIds[]array; updates queue_position to match orderPOST /api/queue/play→ set status='playing', update now_playingPOST /api/queue/skip→ set status='skipped', advance to next
Analytics:
POST /api/log→ append analytics event to event_logs
- On insert: New submissions start with status='pending', queue_position=null
- On approve: Set queue_position = (MAX(queue_position) + 1) for approved items
- On skip/done: Do NOT renumber; maintain original positions (preserves history)
- On reorder: Recalculate queue_position based on drag-drop array order (1-indexed)
- Client-side (Uppy): Restrict
allowedFileTypes[.wav, .mp3, .flac, .aiff, .m4a, .ogg] andmaxFileSize(500MB) for UX - Mux Direct Upload: Set
max_file_sizeparam when creating upload URL - Webhook: Verify
asset.duration < 3600s(reject files >1 hour) andasset.audio_only = true - On violation: Write
event_logsentry withaction: 'upload_rejected'for analytics
Rationale: Client checks prevent accidents; Mux enforces limits; webhook catches manipulation.
Subscribe to submissions (by event_id) and now_playing for instant UI updates.
- Queue updates:
channel('queue:${eventId}').on('postgres_changes', { table: 'submissions', filter: 'event_id=eq.${eventId}' }) - Now Playing:
channel('live:${eventId}').on('postgres_changes', { table: 'now_playing', filter: 'event_id=eq.${eventId}' }) - Cleanup: Call
supabase.removeChannel(channel)inuseEffectreturn to prevent memory leaks
Rationale: Event-scoped channels + filters ensure users only see updates for their event.
colors: {
dw: {
base: '#121212', // primary background (matte black)
surface: '#1E1C1A', // panels/cards/forms
text: '#E8E5D8', // headings/body default
textMuted: '#D0CDC2', // secondary text
muted: '#A8A595', // labels/tertiary (updated for WCAG AAA: 6.2:1)
accent: '#C8D400', // rare hi-vis (live/primary CTA)
alert: '#D86830', // warnings/recording
olive: '#5A6B56', // support/ok
navy: '#3A4350', // support
rust: '#7B5B3A', // support
success: '#4C7B47', // confirmations
}
}Contrast Validation:
dw-textondw-base: 11.2:1 (WCAG AAA ✅)dw-textMutedondw-surface: 8.9:1 (WCAG AAA ✅)dw-accentondw-base: 12.4:1 (WCAG AAA ✅)dw-mutedondw-base: 6.2:1 (WCAG AAA ✅)
Usage Rule: 90% neutral, 9% muted support, 1% hi‑vis (accent).
// Fonts: Satoshi (800), Inter (400, 600)
dw-h1: 'text-5xl font-[800] tracking-tight leading-none', // 48px
dw-h2: 'text-4xl font-[800] tracking-tight leading-tight', // 36px
dw-h3: 'text-2xl font-[700] tracking-normal', // 24px
dw-body: 'text-lg font-normal leading-relaxed', // 18px, 1.5 line-height
dw-label: 'text-sm font-medium uppercase tracking-wider', // 14px, +0.05em
dw-caption: 'text-xs font-normal tracking-normal', // 12pxUsage:
- Headings: Satoshi 800 (bold workwear feel)
- Body/UI: Inter 400-600 (legible, neutral)
- Labels: UPPERCASE + tracking for industrial look
- Duration: 150–300ms
- Easing:
ease-outwith weight (no bounces) - No parallax scrolling
- Drawer-like movement for panels
ProcessingBadge: { ready: boolean }
ready: false→text-dw-muted animate-pulse+ "⏳ Processing"ready: true→text-dw-success+ "✓ Ready"
HostQueue: { submissions[], onApprove(), onPlay(), onSkip(), onReorder() }
- Item states:
pending→bg-dw-surface border-dw-mutedapproved→bg-dw-surface border-dw-oliveplaying→bg-dw-accent/10 border-dw-accent(1% hi-vis rule)skipped→bg-dw-surface/50 border-dw-muted opacity-50
FileUploader (Uppy+Tus): { uploadUrl, onUploaded(uploadId) }
- Progress states:
- Default →
border-dw-muted - Active →
border-dw-accent(1% hi-vis) - Success →
border-dw-success - Error →
border-dw-alert
- Default →
LivePlayer (Mux): { playbackId }
- Embed Mux Player with
theme="minimal", accent colordw-accent
NowPlaying: { submission }
- Display:
dw-h2for track title,dw-bodyfor artist, processing badge
- Predictable, low-cognitive load. No destructive action without confirm.
- Disable Play until
playback_idexists; show Processing badge. - One hot color per view (accent or alert) — never both.
-
Empty Queue (Host): "No submissions yet. Share
/submit/${token}to collect tracks." →text-dw-mutedonbg-dw-surfacecard -
Upload Failed: "Upload failed. Check file size (<500MB) and type." →
text-dw-alertwith retry button inborder-dw-alert -
Not Authorized (Host Dashboard): "You don't own this event." → Redirect to
/with toast inbg-dw-rust text-dw-text -
Event Not Live (Live Page): "This event hasn't started yet." → Show countdown timer in
text-dw-textMuted
Goal: End-to-end submission → queue → live sync with industrial UI.
Deliverables:
- Next.js app with TypeScript + Tailwind (DW tokens configured)
- Pages:
/submit/[token],/host/[eventId],/live/[eventId] - Supabase schema + RLS policies deployed
- Mux Direct Upload API route + webhook handler (signature verified)
- Realtime hooks:
useRealtimeQueue,useRealtimeNowPlaying - Processing badges showing
playback_idstatus - Event logs for all actions (submit, approve, play, skip)
Success Criteria:
- Upload shows progress; appears in host queue < 1s
- Play/Skip updates
/livein < 1s - Webhook signature verified; RLS prevents non-host edits
- UI adheres to palette rules (≤1% hi-vis on screen)
- Zero critical bugs in E2E test (submit → approve → play → skip)
Time Budget: 6 hours (4 hours for senior dev)
Goal: Harden uploads/auth and refine industrial look & feel.
Deliverables:
- Uppy resume/retry on failed uploads (Tus chunk resumption)
- Supabase Auth gating on
/host/:eventId(magic link) - Empty states (no submissions, no events) with DW palette
- Error states (upload failed, not authorized, event not found)
- Accessibility pass: keyboard nav + WCAG AA contrast
- Admin takedown button (sets submission status to 'removed')
- Realtime subscription cleanup verified (no memory leaks)
Success Criteria:
- Upload success rate >95% (test with 500MB FLAC files)
- Mobile upload stable (iOS Safari, Android Chrome)
- Keyboard nav works for all primary actions
- Zero duplicate realtime subscriptions (test with React DevTools)
- WCAG AA contrast pass on all text/background pairs
Time Budget: 12-16 hours (spread across 5 days)
Goal: Add revenue with minimal UX disruption.
Deliverables:
- Stripe Checkout integration for submission tips/boosts
- Stripe Checkout for replay unlock ($5-10 price test)
- Mux Live auto-recording enabled (saves VOD when stream ends)
recordingstable: event_id, mux_asset_id, duration, created_atunlockstable: user_id, session_id, stripe_payment_id, unlocked_at- Replay page (
/replay/[eventId]) with paywall using support colors (not hi-vis) - Stripe webhook handler for
checkout.session.completed(creates unlock record)
Success Criteria:
- ≥10% submissions include tip/boost in tests (price elasticity validated)
- Replay unlock rate ≥15% (value proposition validated)
- First full event recorded and purchasable within 1h of stream end
- Stripe webhook signature verified (no spoofed payments)
Time Budget: 20-24 hours
Simplification: Use Mux Live auto-recording (no custom stitching); replay includes full stream (commentary + dead air = "authentic live archive")
- Supabase project created;
NEXT_PUBLIC_SUPABASE_URLcopied - Supabase RLS enabled on all tables (run migration.sql)
- Mux account created; API token generated (ID + Secret)
- Mux Live Stream created;
playback_idcopied - Mux webhook configured (use ngrok for local:
ngrok http 3000) - Test OBS → Mux RTMP connection (verify stream appears in Mux dashboard)
- Tailwind config extended with
dw.*color tokens - Fonts loaded: Satoshi (800 weight), Inter (400, 600 weights)
-
.env.localpopulated with all required secrets -
package.jsondependencies installed (npm install)
- Check Mux dashboard → Webhooks → Recent Deliveries
- Verify
MUX_WEBHOOK_SECRETmatches dashboard value - Test webhook locally:
ngrok http 3000→ update Mux webhook URL - Log raw webhook body + signature in
/api/mux/webhookfor debugging
- Check Supabase dashboard → Database → Replication →
submissionsenabled? - Console log:
supabase.channel('queue:xyz').subscribe((status) => console.log(status)) - Expected:
status === 'SUBSCRIBED' - Verify RLS policies allow SELECT for anon users on live events
- Check:
auth.uid()matchesevents.host_id? - Check: RLS policy
host_manage_submissionsexists? - Test:
SELECT * FROM submissions WHERE event_id = 'xxx'in Supabase SQL editor
- Check:
submissions.playback_id IS NOT NULL - If null, check Mux asset status:
curl https://api.mux.com/video/v1/assets/{asset_id} - Typical delay: 10-30s for small files, 2-5min for 500MB FLAC
- Ensure
ProcessingBadgecomponent blocks Play button whenready: false
- Verify
useEffectcleanup:return () => supabase.removeChannel(channel) - Test: Navigate between pages multiple times, check Chrome DevTools Memory profiler
- Expected: Subscriptions should drop to 0 when component unmounts
- Supabase Realtime: https://supabase.com/docs/guides/realtime
- Supabase RLS: https://supabase.com/docs/guides/auth/row-level-security
- Next.js App Router: https://nextjs.org/docs/app
- Mux Direct Upload: https://www.mux.com/docs/guides/upload-files-directly
- Mux API (Assets): https://www.mux.com/docs/api-reference/video/assets
- Mux Live Streaming: https://www.mux.com/docs/guides/start-live-streaming
- Mux Player: https://www.mux.com/docs/guides/play-your-videos
- Tus Protocol: https://tus.io/
- Stripe Checkout: https://docs.stripe.com/checkout/quickstart (M3 only)
- Stripe Webhooks: https://docs.stripe.com/webhooks (M3 only)
- Use Tailwind tokens above; enforce 1% hi-vis rule with lint comments if needed.
- Implement webhook signature verification + RLS policies from migration.sql.
- Block Play when
playback_idis null; show Processing badge. - Follow process: Spec → Todo → Code (read this spec, check TODO.md for tasks, implement)
- Test realtime cleanup: navigate between pages and verify subscriptions drop to 0.
- For M1, allow up to 5% hi-vis usage; tighten to 1% in M2 polish pass.
- Communication: Follow patterns in .claude/preferences.md (explain WHY before HOW)
- @CLAUDE.md → Project context, business rationale, architecture overview
- tech-stack.md → Service setup guides, costs, dependencies
- TODO.md → Detailed M1 task breakdown (50 tasks organized by phase)
- migration.sql → Database schema with RLS policies
- .env.example → Environment variables template
- .claude/preferences.md → How to communicate with Keegan
Last Updated: 2025-10-04