Initial build: fullhouse, live music bingo from a real Spotify playlist
All checks were successful
Docker / build-and-push (push) Successful in 1m18s

Based on bingo-bango by f8al (MIT) — same core card-generation idea
("both facets": a square is either an exact song title or an artist,
and an artist square lights up on any of their tracks), reworked with
a real host/guest split, live multiplayer sessions, and cover-art
squares. f8al credited in the footer, README, and PROPOSAL.md.

Three ways to play: host a live session (server-side Spotify
connection, join code + QR, caller screen, auto-call from real
playback), join someone else's session (no login needed), or solo
(guest PKCE login or a pasted public playlist URL, one-off card).

Genuinely verified against a real Spotify account throughout, not
just built and assumed working — including two real bugs found and
fixed along the way (Spotify's playlist-tracks endpoint quietly
renamed to /items with a reshaped response; reading playlist tracks
needs real user auth even for public playlists, so the "no guest
login" public-playlist path routes through the host's connection
instead of a dead-end app-only token). Both Docker images built and
run together on a real network with the nginx proxy verified working
end-to-end, and auto-call confirmed detecting an actual track playing
live through Spotify Connect within one poll tick.
This commit is contained in:
Fredrik Johansson
2026-07-18 22:05:37 +02:00
commit d99c44cb9d
26 changed files with 3778 additions and 0 deletions

24
client/index.html Normal file
View File

@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>fullhouse</title>
<link rel="stylesheet" href="/style.css" />
</head>
<body>
<div id="app">
<header>
<a href="#/" class="brand">&gt; <span class="accent">fullhouse</span></a>
<p class="tagline">music bingo from a real playlist</p>
</header>
<main id="view"></main>
<footer>
<p class="muted">
based on <a href="https://github.com/f8al/bingo-bango" target="_blank" rel="noopener">bingo-bango</a> by f8al (MIT) — reworked with live sessions, host mode, and cover-art squares
</p>
</footer>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

1002
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
client/package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "fullhouse-client",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --port 5190",
"build": "tsc && vite build",
"preview": "vite preview --port 5190"
},
"devDependencies": {
"typescript": "^5.8.0",
"vite": "^5.4.0"
}
}

226
client/public/style.css Normal file
View File

@@ -0,0 +1,226 @@
:root {
--bg: #080808;
--bg-alt: #0e0e0e;
--text: #c8c8c8;
--muted: #555;
--heading: #f0f0f0;
--accent: #00e87a;
--accent-dim: rgba(0, 232, 122, 0.12);
--accent-border:rgba(0, 232, 122, 0.25);
--border: rgba(255, 255, 255, 0.07);
--shadow: 0 0 0 1px rgba(255,255,255,0.04), 0 4px 24px rgba(0,0,0,0.6);
--error: #ff6b6b;
--error-dim: rgba(255, 107, 107, 0.14);
--mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', ui-monospace, monospace;
--sans: 'Inter', ui-sans-serif, system-ui, -apple-system, sans-serif;
font: 16px/1.6 var(--sans);
color: var(--text);
background: var(--bg);
color-scheme: dark;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
*, *::before, *::after { box-sizing: border-box; }
body { margin: 0; }
#app {
width: 720px;
max-width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
}
h1, h2, h3 { font-family: var(--mono); color: var(--heading); line-height: 1.15; margin: 0 0 0.5em; }
p { margin: 0; }
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
.accent { color: var(--accent); }
.muted { color: var(--muted); font-size: 0.85em; }
header { text-align: center; margin-bottom: 28px; }
header .brand { font-family: var(--mono); font-size: 1.6em; font-weight: 700; color: var(--heading); }
header .tagline { color: var(--muted); font-size: 0.95em; margin-top: 4px; }
footer { text-align: center; margin-top: 40px; }
footer a { color: var(--muted); text-decoration: underline; }
.card {
background: var(--bg-alt);
border: 1px solid var(--border);
border-radius: 10px;
padding: 20px;
box-shadow: var(--shadow);
margin-bottom: 16px;
}
.stack { display: flex; flex-direction: column; gap: 12px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
.row.between { justify-content: space-between; }
.center { text-align: center; }
button, .btn {
font: inherit;
font-family: var(--mono);
background: var(--accent-dim);
color: var(--accent);
border: 1px solid var(--accent-border);
border-radius: 8px;
padding: 10px 18px;
cursor: pointer;
display: inline-block;
}
button:hover, .btn:hover { background: rgba(0, 232, 122, 0.2); text-decoration: none; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
button.secondary:hover { border-color: var(--muted); background: rgba(255,255,255,0.04); }
input[type="text"], select {
font: inherit;
font-family: var(--mono);
background: var(--bg-alt);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
padding: 10px 12px;
width: 100%;
}
input[type="text"]:focus, select:focus { outline: none; border-color: var(--accent-border); }
.playlist-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: 400px; overflow-y: auto; }
.playlist-row {
display: flex; align-items: center; gap: 12px;
padding: 8px; border-radius: 8px; cursor: pointer;
border: 1px solid transparent;
}
.playlist-row:hover { background: var(--accent-dim); border-color: var(--accent-border); }
.playlist-row img { width: 40px; height: 40px; border-radius: 6px; object-fit: cover; background: var(--border); }
.playlist-row .name { flex: 1; font-weight: 600; color: var(--heading); }
.playlist-row .count { color: var(--muted); font-size: 0.85em; }
.session-code {
font-family: var(--mono);
font-size: 2.2em;
letter-spacing: 0.15em;
font-weight: 700;
color: var(--accent);
text-align: center;
}
/* Bingo card grid */
.bingo-grid {
display: grid;
gap: 3px;
background: var(--border);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.bingo-square {
background: var(--bg-alt);
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 6px;
font-size: 0.72em;
line-height: 1.25;
color: var(--text);
position: relative;
overflow: hidden;
}
.bingo-square.free { background: var(--accent-dim); color: var(--accent); font-weight: 700; }
.bingo-square.marked { background: var(--accent-dim); box-shadow: inset 0 0 0 2px var(--accent); color: var(--heading); }
.bingo-square img.cover { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; opacity: 0.85; }
/* Full-width gradient strip anchored to the bottom, not a floating pill
— a fixed-opacity pill reads fine on a dark cover but disappears
against a busy/light one. A gradient that goes fully opaque at the
very bottom guarantees contrast regardless of what's under it. */
.bingo-square .label {
position: absolute;
left: 0; right: 0; bottom: 0;
z-index: 1;
padding: 14px 6px 6px;
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.85) 55%, rgba(0,0,0,0.92));
color: #fff;
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
}
.bingo-square:not(:has(img.cover)) .label {
position: static;
background: none;
padding: 0;
text-shadow: none;
color: inherit;
}
.facet-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px; height: 16px;
border-radius: 50%;
font-size: 0.85em;
line-height: 1;
}
.facet-badge.facet-title { background: var(--accent-dim); color: var(--accent); }
.facet-badge.facet-artist { background: rgba(255, 200, 0, 0.15); color: #ffc800; }
.bingo-square .facet-badge {
position: absolute; top: 4px; left: 4px; z-index: 2;
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
}
.card-legend { font-size: 0.8em; margin-bottom: 10px; }
.card-legend .facet-badge { vertical-align: -3px; margin: 0 2px; }
.bingo-square.tappable { cursor: pointer; }
.bingo-square.tappable:hover { outline: 1px solid var(--accent-border); outline-offset: -1px; }
.bingo-square.marked::after {
content: '✓';
position: absolute; top: 4px; right: 4px; z-index: 2;
color: #fff; font-weight: 700;
background: var(--accent);
width: 16px; height: 16px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 0.75em;
box-shadow: 0 1px 3px rgba(0,0,0,0.6);
}
.bingo-banner {
text-align: center; padding: 16px; margin-top: 12px;
background: var(--accent-dim); border: 1px solid var(--accent-border);
border-radius: 8px; color: var(--accent); font-family: var(--mono); font-weight: 700;
font-size: 1.3em;
}
/* Caller screen */
.caller-now {
text-align: center;
padding: 32px 16px;
}
.caller-now .track-title { font-size: 1.8em; font-weight: 700; color: var(--heading); }
.caller-now .track-artist { color: var(--muted); margin-top: 4px; }
.caller-now img { width: 160px; height: 160px; border-radius: 10px; margin: 0 auto 16px; display: block; box-shadow: var(--shadow); }
.called-history { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 12px; }
.called-chip {
font-size: 0.75em; padding: 4px 8px; border-radius: 999px;
background: var(--bg); border: 1px solid var(--border); color: var(--muted);
}
.error-box {
background: var(--error-dim); border: 1px solid var(--error);
color: var(--error); border-radius: 8px; padding: 12px 14px; font-size: 0.9em;
}
.toggle-row { display: flex; align-items: center; gap: 8px; }
@media (max-width: 480px) {
.bingo-square { font-size: 0.62em; }
}

View File

@@ -0,0 +1,140 @@
// Card generation engine. The "both facets" idea — a square can hold
// either a track title or an artist name, and playing a track marks
// both the title square and any artist squares for that artist — is
// f8al's design from bingo-bango (https://github.com/f8al/bingo-bango,
// MIT licensed). This module is a fresh implementation of that shape,
// not copied code, extended with a cover-art render mode the original
// doesn't have.
//
// Pure, dependency-free, deterministic given a seed — same property
// the original's engine has, load-bearing here too: a guest's card
// has to reproduce identically from a shareable seed/URL.
export type PoolTrack = {
id: string;
title: string;
artist: string;
album: string;
image: string | null;
};
export type SquareFacet = 'title' | 'artist' | 'free';
export type CardSquare = {
facet: SquareFacet;
text: string;
image: string | null;
/** Track ids that mark this square when called. A title square has
* exactly one; an artist square can have several if the artist
* appears more than once in the pool. */
matchIds: string[];
};
export type BingoCard = {
seed: number;
size: number;
squares: CardSquare[]; // row-major, length size*size
};
// Small, fast, deterministic PRNG (mulberry32) — no crypto needed, the
// point is reproducibility from a seed, not unpredictability.
function mulberry32(seed: number) {
let a = seed;
return function () {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle<T>(arr: T[], rand: () => number): T[] {
const out = arr.slice();
for (let i = out.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
[out[i], out[j]] = [out[j], out[i]];
}
return out;
}
export type CardOptions = {
size: 3 | 4 | 5;
freeSpace: boolean;
/** Roughly how much of the grid is artist squares vs title squares —
* 0.5 matches the original's ~50/50 split. */
artistRatio?: number;
};
export function generateCard(pool: PoolTrack[], seed: number, opts: CardOptions): BingoCard {
const { size, freeSpace, artistRatio = 0.5 } = opts;
const cellCount = size * size;
const needed = cellCount - (freeSpace ? 1 : 0);
if (pool.length < needed) {
throw new Error(`pool has ${pool.length} tracks, need at least ${needed} for a ${size}x${size} card`);
}
const rand = mulberry32(seed);
const chosen = shuffle(pool, rand).slice(0, needed);
// Group by artist so an artist square can list every matching track id.
const byArtist = new Map<string, PoolTrack[]>();
for (const t of pool) {
const list = byArtist.get(t.artist) ?? [];
list.push(t);
byArtist.set(t.artist, list);
}
const squares: CardSquare[] = chosen.map((track) => {
const useArtist = rand() < artistRatio;
if (useArtist) {
const matches = byArtist.get(track.artist) ?? [track];
return {
facet: 'artist',
text: track.artist,
image: track.image,
matchIds: matches.map((m) => m.id),
};
}
return {
facet: 'title',
text: track.title,
image: track.image,
matchIds: [track.id],
};
});
if (freeSpace) {
const center = Math.floor(cellCount / 2);
squares.splice(center, 0, { facet: 'free', text: 'FREE', image: null, matchIds: [] });
}
return { seed, size, squares };
}
/** Which squares light up when a track is called. */
export function markedIndices(card: BingoCard, calledTrackId: string): number[] {
const out: number[] = [];
card.squares.forEach((sq, i) => {
if (sq.matchIds.includes(calledTrackId)) out.push(i);
});
return out;
}
/** True if any full row, column, or diagonal is fully covered. */
export function hasBingo(card: BingoCard, markedSet: Set<number>): boolean {
const { size } = card;
const at = (r: number, c: number) => r * size + c;
const isMarked = (r: number, c: number) => card.squares[at(r, c)].facet === 'free' || markedSet.has(at(r, c));
for (let r = 0; r < size; r++) {
if (Array.from({ length: size }, (_, c) => isMarked(r, c)).every(Boolean)) return true;
}
for (let c = 0; c < size; c++) {
if (Array.from({ length: size }, (_, r) => isMarked(r, c)).every(Boolean)) return true;
}
if (Array.from({ length: size }, (_, i) => isMarked(i, i)).every(Boolean)) return true;
if (Array.from({ length: size }, (_, i) => isMarked(i, size - 1 - i)).every(Boolean)) return true;
return false;
}

45
client/src/lib/api.ts Normal file
View File

@@ -0,0 +1,45 @@
// Thin wrapper over fullhouse's own server (host mode + sessions).
// Guest PKCE calls go straight to Spotify instead — see pkce.ts.
const BASE = import.meta.env.VITE_API_BASE || '';
async function req<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `${path}${res.status}`);
return data;
}
export type HostPlaylist = { id: string; name: string; trackCount: number; image: string | null };
export type PoolTrack = { id: string; title: string; artist: string; album: string; image: string | null };
export const api = {
hostStatus: () => req<{ connected: boolean }>('/api/spotify/status'),
hostPlaylists: () => req<HostPlaylist[]>('/api/host/playlists'),
hostPlaylistTracks: (id: string) => req<PoolTrack[]>(`/api/host/playlist/${id}/tracks`),
publicPlaylist: (url: string) =>
req<{ name: string; image: string | null; tracks: PoolTrack[] }>(`/api/public-playlist?url=${encodeURIComponent(url)}`),
createSession: (playlistId: string, playlistName: string) =>
req<{ code: string; playlistName: string; trackCount: number }>('/api/session', {
method: 'POST',
body: JSON.stringify({ playlistId, playlistName }),
}),
joinSession: (code: string) =>
req<{ code: string; playlistName: string; tracks: PoolTrack[]; calledIds: string[]; autoCall: boolean }>(
`/api/session/${code}`
),
sessionState: (code: string) =>
req<{ calledIds: string[]; autoCall: boolean }>(`/api/session/${code}/state`),
callTrack: (code: string, trackId: string) =>
req<{ calledIds: string[] }>(`/api/session/${code}/call`, {
method: 'POST',
body: JSON.stringify({ trackId }),
}),
callRandom: (code: string) =>
req<{ called: PoolTrack; calledIds: string[] }>(`/api/session/${code}/call-random`, { method: 'POST' }),
autoCallStart: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/start`, { method: 'POST' }),
autoCallStop: (code: string) => req<{ ok: true }>(`/api/session/${code}/autocall/stop`, { method: 'POST' }),
};

141
client/src/lib/pkce.ts Normal file
View File

@@ -0,0 +1,141 @@
// Guest self-service mode: a visitor logs into their own Spotify account
// via Authorization Code with PKCE — no client secret, no server
// involvement, tokens never leave the browser. Same flow shape as the
// original bingo-bango (https://github.com/f8al/bingo-bango, MIT) uses
// for its only mode; this is a fresh implementation, not copied code.
const CLIENT_ID = import.meta.env.VITE_SPOTIFY_CLIENT_ID as string;
// Root URL, not a dedicated path — a single static index.html handles
// the callback via a query param (?code=...) instead of needing a
// second HTML entry point or server-side rewrite rules for a nested
// route. Simpler to deploy (plain nginx, no routing config).
const REDIRECT_URI = `${window.location.origin}/`;
const SCOPES = 'playlist-read-private playlist-read-collaborative';
const VERIFIER_KEY = 'fh:pkce_verifier';
const TOKEN_KEY = 'fh:guest_token';
function base64url(bytes: Uint8Array): string {
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function sha256(input: string): Promise<Uint8Array> {
const data = new TextEncoder().encode(input);
const digest = await crypto.subtle.digest('SHA-256', data);
return new Uint8Array(digest);
}
function randomVerifier(): string {
const bytes = crypto.getRandomValues(new Uint8Array(64));
return base64url(bytes);
}
export async function startGuestLogin(): Promise<void> {
const verifier = randomVerifier();
sessionStorage.setItem(VERIFIER_KEY, verifier);
const challenge = base64url(await sha256(verifier));
const url = new URL('https://accounts.spotify.com/authorize');
url.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: 'code',
redirect_uri: REDIRECT_URI,
code_challenge_method: 'S256',
code_challenge: challenge,
scope: SCOPES,
}).toString();
window.location.href = url.toString();
}
export type GuestToken = { access_token: string; expires_at: number };
export async function completeGuestLogin(code: string): Promise<GuestToken> {
const verifier = sessionStorage.getItem(VERIFIER_KEY);
if (!verifier) throw new Error('missing PKCE verifier — start login again');
const res = await fetch('https://accounts.spotify.com/api/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: CLIENT_ID,
grant_type: 'authorization_code',
code,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}).toString(),
});
if (!res.ok) throw new Error(`token exchange failed: ${res.status}`);
const data = await res.json();
const token: GuestToken = {
access_token: data.access_token,
expires_at: Date.now() + (data.expires_in - 60) * 1000,
};
sessionStorage.setItem(TOKEN_KEY, JSON.stringify(token));
sessionStorage.removeItem(VERIFIER_KEY);
return token;
}
export function getStoredGuestToken(): GuestToken | null {
const raw = sessionStorage.getItem(TOKEN_KEY);
if (!raw) return null;
const token: GuestToken = JSON.parse(raw);
if (Date.now() >= token.expires_at) {
sessionStorage.removeItem(TOKEN_KEY);
return null;
}
return token;
}
export function guestLogout(): void {
sessionStorage.removeItem(TOKEN_KEY);
}
// Direct-to-Spotify calls — no server involvement in guest mode, by
// design (the whole point of PKCE is no backend holding the token).
async function guestFetch(path: string, token: string) {
const res = await fetch(`https://api.spotify.com/v1${path}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) throw new Error(`Spotify ${path}${res.status}`);
return res.json();
}
export type GuestPlaylist = { id: string; name: string; trackCount: number; image: string | null };
export async function fetchGuestPlaylists(token: string): Promise<GuestPlaylist[]> {
const out: GuestPlaylist[] = [];
let url = '/me/playlists?limit=50';
while (url) {
const page = await guestFetch(url, token);
for (const p of page.items ?? []) {
out.push({ id: p.id, name: p.name, trackCount: p.items?.total ?? p.tracks?.total ?? 0, image: p.images?.[0]?.url ?? null });
}
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
}
return out;
}
export type GuestTrack = { id: string; title: string; artist: string; album: string; image: string | null };
export async function fetchGuestPlaylistTracks(token: string, playlistId: string): Promise<GuestTrack[]> {
const out: GuestTrack[] = [];
let url = `/playlists/${playlistId}/items?limit=100&fields=next,items(item(id,name,artists(name),album(name,images)))`;
while (url) {
const page = await guestFetch(url, token);
for (const entry of page.items ?? []) {
const t = entry.item;
if (!t) continue;
out.push({
id: t.id,
title: t.name,
artist: t.artists?.[0]?.name ?? 'Unknown artist',
album: t.album?.name ?? '',
image: t.album?.images?.[0]?.url ?? null,
});
}
url = page.next ? page.next.replace('https://api.spotify.com/v1', '') : '';
}
return out;
}

407
client/src/main.ts Normal file
View File

@@ -0,0 +1,407 @@
import { api, type PoolTrack } from './lib/api';
import { startGuestLogin, completeGuestLogin, getStoredGuestToken, fetchGuestPlaylists, fetchGuestPlaylistTracks, guestLogout } from './lib/pkce';
import { generateCard, markedIndices, hasBingo, type BingoCard, type CardOptions } from './cards/generate';
const view = document.getElementById('view')!;
function esc(s: unknown): string {
return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// Shared by the live session view and solo mode — the "both facets"
// mechanic (a square is either an exact song title or an artist, and
// an artist square lights up on ANY of their tracks) is f8al's design
// from bingo-bango; nothing in the UI explained it, so every square now
// carries a badge + a one-line legend up top, and the two facets get
// visually distinct badge colors, not just different text.
function renderCardGrid(card: BingoCard, marked: Set<number>, interactive: boolean): string {
const facetBadge = (facet: string) =>
facet === 'title' ? '<span class="facet-badge facet-title" title="song title — lights up on this exact track">♪</span>'
: facet === 'artist' ? '<span class="facet-badge facet-artist" title="artist — lights up on any track by them">☺</span>'
: '';
return `
<div class="card-legend muted center">
<span class="facet-badge facet-title">♪</span> = exact song &nbsp;·&nbsp;
<span class="facet-badge facet-artist">☺</span> = any track by that artist
</div>
<div class="bingo-grid" style="grid-template-columns: repeat(${card.size}, 1fr);">
${card.squares.map((sq, i) => {
const isMarked = sq.facet === 'free' || marked.has(i);
const canTap = interactive && sq.facet !== 'free';
return `
<div class="bingo-square ${sq.facet} ${isMarked ? 'marked' : ''} ${canTap ? 'tappable' : ''}" data-i="${i}">
${sq.image ? `<img class="cover" src="${esc(sq.image)}" alt="">` : ''}
${facetBadge(sq.facet)}
<span class="label">${esc(sq.text)}</span>
</div>
`;
}).join('')}
</div>
`;
}
// ── Router ──────────────────────────────────────────────────────────
type Route = { name: string; params: Record<string, string> };
function parseRoute(): Route {
const hash = location.hash.replace(/^#\/?/, '');
const [name, ...rest] = hash.split('/');
if (!name) return { name: 'home', params: {} };
if (name === 'caller') return { name: 'caller', params: { code: rest[0] } };
if (name === 'play') return { name: 'play', params: { code: rest[0] } };
return { name, params: {} };
}
async function render() {
// Guest PKCE callback lands at the root with ?code=...&state=... —
// handle it before anything else, then strip the query string.
const params = new URLSearchParams(location.search);
if (params.has('code')) {
const code = params.get('code')!;
history.replaceState({}, '', location.pathname + location.hash);
try {
await completeGuestLogin(code);
} catch (e) {
view.innerHTML = `<div class="error-box">guest login failed: ${esc((e as Error).message)}</div>`;
return;
}
location.hash = '#/solo';
}
const route = parseRoute();
try {
if (route.name === 'home') return renderHome();
if (route.name === 'host') return renderHost();
if (route.name === 'caller') return renderCaller(route.params.code);
if (route.name === 'join') return renderJoin();
if (route.name === 'play') return renderPlay(route.params.code);
if (route.name === 'solo') return renderSolo();
return renderHome();
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
}
}
window.addEventListener('hashchange', render);
render();
// ── Home ────────────────────────────────────────────────────────────
function renderHome() {
view.innerHTML = `
<div class="card center stack">
<h2>What are you doing?</h2>
<p class="muted">Pick a mode — all three make bingo cards from real Spotify playlists, no invented data.</p>
</div>
<div class="card stack">
<h3>Host a live session</h3>
<p class="muted">Connect your own Spotify, pick a playlist, get a join code + QR for a room full of people. Live "caller" screen, auto-call from real playback.</p>
<a href="#/host" class="btn">Host a game →</a>
</div>
<div class="card stack">
<h3>Join a session</h3>
<p class="muted">Someone else is hosting — enter their code, get your own card, no login needed.</p>
<a href="#/join" class="btn">Join a game →</a>
</div>
<div class="card stack">
<h3>Play solo</h3>
<p class="muted">Log into your own Spotify (or paste a public playlist URL) and generate a one-off card, no session.</p>
<a href="#/solo" class="btn">Play solo →</a>
</div>
`;
}
// ── Host: connect + pick playlist + create session ─────────────────
async function renderHost() {
view.innerHTML = `<div class="card center">checking Spotify connection…</div>`;
const status = await api.hostStatus().catch(() => ({ connected: false }));
if (!status.connected) {
view.innerHTML = `
<div class="card stack center">
<h2>Connect Spotify</h2>
<p class="muted">One-time host connection — used to read your playlists and (optionally) auto-call from your live playback.</p>
<a href="/api/spotify/login" class="btn">Connect Spotify →</a>
</div>
`;
return;
}
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
const playlists = await api.hostPlaylists();
const usable = playlists.filter(p => p.trackCount >= 12);
view.innerHTML = `
<div class="card stack">
<h2>Pick a playlist</h2>
<p class="muted">Only playlists with 12+ tracks shown (need enough headroom for a 3×3 card). Algorithmic/editorial Spotify playlists (Discover Weekly etc.) aren't readable via the API and won't appear here.</p>
<ul class="playlist-list">
${usable.map(p => `
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
<span class="name">${esc(p.name)}</span>
<span class="count">${p.trackCount} tracks</span>
</li>
`).join('')}
</ul>
</div>
`;
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
row.addEventListener('click', async () => {
const { id, name } = row.dataset;
view.innerHTML = `<div class="card center">creating session…</div>`;
try {
const session = await api.createSession(id!, name!);
location.hash = `#/caller/${session.code}`;
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/host" class="btn secondary">back</a>`;
}
});
});
}
// ── Caller screen (host) ────────────────────────────────────────────
async function renderCaller(code: string) {
view.innerHTML = `<div class="card center">loading session…</div>`;
const session = await api.joinSession(code);
const joinUrl = `${location.origin}/#/play/${code}`;
function trackById(id: string): PoolTrack | undefined {
return session.tracks.find(t => t.id === id);
}
function paint(calledIds: string[], autoCall: boolean) {
const lastId = calledIds[calledIds.length - 1];
const last = lastId ? trackById(lastId) : null;
const remaining = session.tracks.length - calledIds.length;
view.innerHTML = `
<div class="card stack center">
<h2>Hosting: ${esc(session.playlistName)}</h2>
<div class="session-code">${esc(code)}</div>
<p class="muted">join at <a href="${esc(joinUrl)}">${esc(joinUrl.replace('https://', '').replace('http://', ''))}</a></p>
</div>
<div class="card caller-now">
${last ? `
${last.image ? `<img src="${esc(last.image)}" alt="">` : ''}
<div class="track-title">${esc(last.title)}</div>
<div class="track-artist">${esc(last.artist)}</div>
` : `<p class="muted">nothing called yet</p>`}
</div>
<div class="card stack">
<div class="row between">
<button id="call-random" ${remaining === 0 ? 'disabled' : ''}>call next (${remaining} left)</button>
<div class="toggle-row">
<label class="muted">auto-call from live playback</label>
<input type="checkbox" id="auto-call" ${autoCall ? 'checked' : ''}>
</div>
</div>
<div class="called-history">
${calledIds.map(id => {
const t = trackById(id);
return t ? `<span class="called-chip">${esc(t.title)}</span>` : '';
}).join('')}
</div>
</div>
`;
document.getElementById('call-random')?.addEventListener('click', async () => {
const res = await api.callRandom(code).catch((e) => { alert(e.message); return null; });
if (res) paint(res.calledIds, autoCall);
});
document.getElementById('auto-call')?.addEventListener('change', async (e) => {
const on = (e.target as HTMLInputElement).checked;
if (on) await api.autoCallStart(code); else await api.autoCallStop(code);
});
}
paint(session.calledIds, session.autoCall);
// Deliberately dumb polling, not a socket — matches this whole
// family's precedent (keep watch, wisp's sweep). A caller screen
// doesn't need sub-second latency.
const poll = setInterval(async () => {
if (parseRoute().name !== 'caller') { clearInterval(poll); return; }
const state = await api.sessionState(code).catch(() => null);
if (state) paint(state.calledIds, state.autoCall);
}, 4000);
}
// ── Join (guest enters a code) ──────────────────────────────────────
function renderJoin() {
view.innerHTML = `
<div class="card stack">
<h2>Join a session</h2>
<input type="text" id="code-input" placeholder="5-letter code" maxlength="5" style="text-transform:uppercase; font-size:1.4em; text-align:center; letter-spacing:0.2em;">
<button id="join-btn">join →</button>
<div id="join-error"></div>
</div>
`;
const go = () => {
const code = (document.getElementById('code-input') as HTMLInputElement).value.trim().toUpperCase();
if (code) location.hash = `#/play/${code}`;
};
document.getElementById('join-btn')?.addEventListener('click', go);
document.getElementById('code-input')?.addEventListener('keydown', (e) => { if ((e as KeyboardEvent).key === 'Enter') go(); });
}
// ── Play (guest card view, live-synced to a session) ────────────────
const CARD_OPTS: CardOptions = { size: 3, freeSpace: true, artistRatio: 0.5 };
async function renderPlay(code: string) {
view.innerHTML = `<div class="card center">joining session ${esc(code)}…</div>`;
let session;
try {
session = await api.joinSession(code);
} catch (e) {
view.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div><a href="#/join" class="btn secondary">try another code</a>`;
return;
}
const seed = Math.floor(Math.random() * 2 ** 31);
const card = generateCard(session.tracks, seed, CARD_OPTS);
// Marking is normally fully automatic (the host's calls, synced via
// polling) — but a guest can't tell "was that really the track that
// just played?" from the host's side alone, and might want to mark a
// square they're confident about before the sync catches up, or the
// host is calling manually and slowly. Self-marks are local-only
// (never sent to the server, never affect other players) and only
// apply to squares the auto-sync hasn't already confirmed — once the
// server confirms a square, it's locked in and no longer tappable.
const selfMarked = new Set<number>();
function paint(calledIds: string[]) {
const autoMarked = new Set<number>();
for (const id of calledIds) for (const i of markedIndices(card, id)) autoMarked.add(i);
const effective = new Set<number>([...autoMarked, ...selfMarked]);
const won = hasBingo(card, effective);
view.innerHTML = `
<div class="card center">
<h2>${esc(session!.playlistName)}</h2>
<p class="muted">code: ${esc(code)}</p>
</div>
${renderCardGrid(card, effective, true)}
<p class="muted center" style="margin-top:8px;">squares mark themselves as the host calls tracks — tap one yourself if you're sure it just played and the sync hasn't caught up</p>
${won ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
`;
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
const i = Number(sq.dataset.i);
if (autoMarked.has(i)) return; // server-confirmed, not toggleable
sq.addEventListener('click', () => {
if (selfMarked.has(i)) selfMarked.delete(i); else selfMarked.add(i);
paint(calledIds);
});
});
}
paint(session.calledIds);
const poll = setInterval(async () => {
if (parseRoute().name !== 'play') { clearInterval(poll); return; }
const state = await api.sessionState(code).catch(() => null);
if (state) paint(state.calledIds);
}, 4000);
}
// ── Solo (guest PKCE, own playlist or a pasted public URL) ──────────
async function renderSolo() {
const token = getStoredGuestToken();
if (!token) {
view.innerHTML = `
<div class="card stack center">
<h2>Play solo</h2>
<p class="muted">Log into your own Spotify to pick a playlist, or skip that and paste any public playlist URL below.</p>
<button id="guest-login">Log into Spotify →</button>
</div>
<div class="card stack">
<h3>Or use a public playlist URL</h3>
<input type="text" id="public-url" placeholder="https://open.spotify.com/playlist/...">
<button id="public-go" class="secondary">generate card →</button>
<div id="public-error"></div>
</div>
`;
document.getElementById('guest-login')?.addEventListener('click', () => startGuestLogin());
document.getElementById('public-go')?.addEventListener('click', async () => {
const url = (document.getElementById('public-url') as HTMLInputElement).value.trim();
const errEl = document.getElementById('public-error')!;
if (!url) return;
errEl.innerHTML = `<p class="muted">loading…</p>`;
try {
const data = await api.publicPlaylist(url);
renderSoloCard(data.tracks, data.name);
} catch (e) {
errEl.innerHTML = `<div class="error-box">${esc((e as Error).message)}</div>`;
}
});
return;
}
view.innerHTML = `<div class="card center">loading your playlists…</div>`;
const playlists = await fetchGuestPlaylists(token.access_token).catch(() => []);
const usable = playlists.filter(p => p.trackCount >= 12);
view.innerHTML = `
<div class="card row between">
<h2 style="margin:0;">Pick a playlist</h2>
<button class="secondary" id="logout">log out</button>
</div>
<div class="card">
<ul class="playlist-list">
${usable.map(p => `
<li class="playlist-row" data-id="${esc(p.id)}" data-name="${esc(p.name)}">
${p.image ? `<img src="${esc(p.image)}" alt="">` : '<div style="width:40px;height:40px;border-radius:6px;background:var(--border);"></div>'}
<span class="name">${esc(p.name)}</span>
<span class="count">${p.trackCount} tracks</span>
</li>
`).join('')}
</ul>
</div>
`;
document.getElementById('logout')?.addEventListener('click', () => { guestLogout(); render(); });
view.querySelectorAll<HTMLElement>('.playlist-row').forEach(row => {
row.addEventListener('click', async () => {
const { id, name } = row.dataset;
view.innerHTML = `<div class="card center">loading tracks…</div>`;
const tracks = await fetchGuestPlaylistTracks(token.access_token, id!);
renderSoloCard(tracks, name!);
});
});
}
function renderSoloCard(tracks: PoolTrack[], playlistName: string) {
if (tracks.length < 12) {
view.innerHTML = `<div class="error-box">this playlist only has ${tracks.length} tracks — need at least 12.</div><a href="#/solo" class="btn secondary">back</a>`;
return;
}
const seed = Math.floor(Math.random() * 2 ** 31);
const card: BingoCard = generateCard(tracks, seed, CARD_OPTS);
const marked = new Set<number>();
function paint() {
view.innerHTML = `
<div class="card center">
<h2>${esc(playlistName)}</h2>
<p class="muted">tap squares yourself as tracks play — solo mode has no live sync</p>
</div>
${renderCardGrid(card, marked, true)}
${hasBingo(card, marked) ? `<div class="bingo-banner">🎉 BINGO!</div>` : ''}
`;
view.querySelectorAll<HTMLElement>('.bingo-square.tappable').forEach(sq => {
sq.addEventListener('click', () => {
const i = Number(sq.dataset.i);
if (marked.has(i)) marked.delete(i); else marked.add(i);
paint();
});
});
}
paint();
}

18
client/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"types": ["vite/client"]
},
"include": ["src"]
}

15
client/vite.config.ts Normal file
View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
export default defineConfig({
server: {
// Explicit 127.0.0.1, not the default 'localhost' — Vite's default
// can resolve to IPv6 loopback only ([::1]) depending on the host's
// resolver order, which won't match Spotify's registered redirect
// URI (127.0.0.1 specifically — Spotify's HTTPS-required-for-
// non-loopback policy only exempts the literal IP, not 'localhost').
host: '127.0.0.1',
proxy: {
'/api': 'http://127.0.0.1:3010',
},
},
});