diff --git a/web/src/components/playlist-carousel.ts b/web/src/components/playlist-carousel.ts new file mode 100644 index 0000000..e33f8a6 --- /dev/null +++ b/web/src/components/playlist-carousel.ts @@ -0,0 +1,721 @@ +// Mobile Playlist Carousel — full-screen swipeable cards (§16.16) +// On mobile viewport (<768px), playlist entry opens this TikTok-style carousel. +// Desktop unaffected — keeps the grid layout from playlists.ts. + +import type { Playlist, PlaylistMatch } from '../api-types'; +import type { Replay } from '../types'; +import { + computeAllDensities, + computeSpeedSchedule, + createDirectorState, + tickDirectorSpeed, + type DirectorState, + type DurationPreset, +} from './director'; + +const loadReplayViewer = () => import('../replay-viewer'); + +// ── Swipe gesture detector ───────────────────────────────────────────────────── + +interface SwipeState { + startX: number; + startY: number; + startTime: number; + active: boolean; +} + +interface SwipeResult { + direction: 'up' | 'down' | 'left' | 'right' | 'none'; + velocity: number; // px/ms +} + +function createSwipeDetector( + el: HTMLElement, + onSwipe: (result: SwipeResult) => void, +): void { + const state: SwipeState = { startX: 0, startY: 0, startTime: 0, active: false }; + + el.addEventListener('touchstart', (e: TouchEvent) => { + if (e.touches.length !== 1) return; + state.startX = e.touches[0].clientX; + state.startY = e.touches[0].clientY; + state.startTime = Date.now(); + state.active = true; + }, { passive: true }); + + el.addEventListener('touchend', (e: TouchEvent) => { + if (!state.active) return; + state.active = false; + const touch = e.changedTouches[0]; + const dx = touch.clientX - state.startX; + const dy = touch.clientY - state.startY; + const dt = Date.now() - state.startTime; + const absDx = Math.abs(dx); + const absDy = Math.abs(dy); + const threshold = 50; // min px for swipe + + if (absDx < threshold && absDy < threshold) { + onSwipe({ direction: 'none', velocity: 0 }); + return; + } + + const velocity = Math.sqrt(dx * dx + dy * dy) / Math.max(dt, 1); + + if (absDx > absDy) { + onSwipe({ direction: dx > 0 ? 'right' : 'left', velocity }); + } else { + onSwipe({ direction: dy > 0 ? 'down' : 'up', velocity }); + } + }, { passive: true }); +} + +// ── Carousel component ───────────────────────────────────────────────────────── + +export interface CarouselOptions { + playlist: Playlist; + startIndex?: number; + onClose: () => void; +} + +const AUTO_ADVANCE_DELAY = 3000; // 3s pause after replay ends +const METADATA_PANEL_WIDTH = 280; // px revealed on horizontal swipe +const TRANSITION_MS = 300; +const R2_BASE = 'https://r2.aicodebattle.com'; +const B2_FALLBACK = 'https://b2.aicodebattle.com'; + +export class PlaylistCarousel { + private overlay: HTMLDivElement; + private playlist: Playlist; + private currentIndex: number; + private onClose: () => void; + + // Per-card DOM + private cardContainer: HTMLDivElement; + private canvas: HTMLCanvasElement; + private headerBar: HTMLDivElement; + private scoreBar: HTMLDivElement; + private eventHint: HTMLDivElement; + private swipeHint: HTMLDivElement; + private metadataPanel: HTMLDivElement; + private closeBtn: HTMLButtonElement; + + // Replay viewer + private viewer: InstanceType | null = null; + + // Director state (lightweight — auto-plays at director speed) + private directorState: DirectorState = createDirectorState(); + private directorSchedule: ReturnType = []; + private directorAnimFrame: number | null = null; + + // Preloading + private preloadedReplays = new Map(); + + // Auto-advance timer + private autoAdvanceTimer: ReturnType | null = null; + + // Metadata panel state + private metadataOpen = false; + private metadataTranslateX = METADATA_PANEL_WIDTH; + + // Transition state + private transitioning = false; + + constructor(opts: CarouselOptions) { + this.playlist = opts.playlist; + this.currentIndex = opts.startIndex ?? 0; + this.onClose = opts.onClose; + + // Create overlay + this.overlay = document.createElement('div'); + this.overlay.className = 'carousel-overlay'; + this.overlay.innerHTML = CAROUSEL_HTML; + document.body.appendChild(this.overlay); + document.body.style.overflow = 'hidden'; + + // Grab refs + this.cardContainer = this.overlay.querySelector('.carousel-card')!; + this.canvas = this.overlay.querySelector('.carousel-canvas')!; + this.headerBar = this.overlay.querySelector('.carousel-header')!; + this.scoreBar = this.overlay.querySelector('.carousel-score-bar')!; + this.eventHint = this.overlay.querySelector('.carousel-event-hint')!; + this.swipeHint = this.overlay.querySelector('.carousel-swipe-hint')!; + this.metadataPanel = this.overlay.querySelector('.carousel-metadata-panel')!; + this.closeBtn = this.overlay.querySelector('.carousel-close-btn')!; + + // Inject styles + const styleEl = document.createElement('style'); + styleEl.textContent = CAROUSEL_CSS; + document.head.appendChild(styleEl); + + // Close button + this.closeBtn.addEventListener('click', () => this.destroy()); + + // Swipe detection on card container + createSwipeDetector(this.cardContainer, (result) => this.handleSwipe(result)); + + // Tap on canvas = play/pause + this.canvas.addEventListener('click', () => { + if (this.viewer?.getReplay()) this.viewer.togglePlay(); + }); + + // Initialize viewer and load first replay + this.init(); + } + + private async init(): Promise { + const { ReplayViewer } = await loadReplayViewer(); + this.viewer = new ReplayViewer(this.canvas, { + cellSize: 6, // small cells for mobile + animationSpeed: 100, + }); + + // Listen for replay end (when viewer reaches last turn while playing) + this.viewer.onTurnChange = () => { + if (!this.viewer) return; + if (this.viewer.getIsPlaying() && this.viewer.getTurn() >= this.viewer.getTotalTurns() - 1) { + this.viewer.pause(); + this.onReplayEnd(); + } + }; + + this.viewer.onPlayStateChange = () => { + // no-op needed to keep callback wired + }; + + await this.loadCard(this.currentIndex); + + // Fade swipe hint after 3s + setTimeout(() => { + if (this.swipeHint) this.swipeHint.classList.add('carousel-hint-fade'); + }, 3000); + } + + private async loadCard(index: number): Promise { + const match = this.playlist.matches[index]; + if (!match) return; + + // Update header + this.headerBar.innerHTML = ` + ${this.playlist.title} + ${index + 1} of ${this.playlist.matches.length} + `; + + // Update score bar with placeholder + this.scoreBar.innerHTML = ` + Loading... + `; + + // Reset metadata panel + this.metadataOpen = false; + this.metadataTranslateX = METADATA_PANEL_WIDTH; + this.metadataPanel.style.transform = `translateX(${this.metadataTranslateX}px)`; + this.updateMetadataContent(match, null); + + // Clear auto-advance timer + if (this.autoAdvanceTimer) { + clearTimeout(this.autoAdvanceTimer); + this.autoAdvanceTimer = null; + } + + // Fetch replay + let replay = this.preloadedReplays.get(index); + if (!replay) { + try { + replay = await this.fetchReplay(match.match_id); + } catch { + this.scoreBar.innerHTML = `Failed to load`; + return; + } + } + + if (!this.viewer) return; + + // Load into viewer + this.viewer.loadReplay(replay); + + // Set up director mode for auto-play + const densities = computeAllDensities(replay); + this.directorSchedule = computeSpeedSchedule(densities, 30 as DurationPreset); // 30s target + this.directorState = createDirectorState(); + this.directorState.enabled = true; + this.viewer.setDirectorMode(true); + + // Start playing + this.viewer.togglePlay(); + this.startDirectorTick(); + + // Update score bar + this.updateScoreBar(match, replay); + + // Update event hint + this.updateEventHint(replay); + + // Update metadata panel with full info + this.updateMetadataContent(match, replay); + + // Preload next replay + this.preloadNext(index + 1); + } + + private async fetchReplay(matchId: string): Promise { + // Try R2 first, fall back to B2 + const urls = [ + `${R2_BASE}/replays/${matchId}.json`, + `${B2_FALLBACK}/replays/${matchId}.json`, + ]; + for (const url of urls) { + try { + const resp = await fetch(url); + if (resp.ok) return await resp.json(); + } catch { /* try next */ } + } + // Try same-origin as last resort (dev/staging) + const resp = await fetch(`/replays/${matchId}.json`); + if (!resp.ok) throw new Error(`Failed to fetch replay ${matchId}`); + return resp.json(); + } + + private preloadNext(index: number): void { + if (index >= this.playlist.matches.length) return; + if (this.preloadedReplays.has(index)) return; + const matchId = this.playlist.matches[index].match_id; + this.fetchReplay(matchId) + .then(r => this.preloadedReplays.set(index, r)) + .catch(() => { /* preload failure is non-critical */ }); + } + + private updateScoreBar(_match: PlaylistMatch, replay: Replay): void { + const players = replay.players.map((p, i) => { + const score = replay.result.scores?.[i] ?? '-'; + const won = replay.result.winner === i; + return `${p.name} ${score}`; + }).join(' vs '); + this.scoreBar.innerHTML = players; + } + + private updateEventHint(replay: Replay): void { + const events = replay.turns.reduce((count, t) => count + (t.events?.length ?? 0), 0); + const icons = events > 20 ? '⚔️💎🏰' : events > 5 ? '⚔️💎' : '⚔️'; + const totalTurns = replay.turns.length; + const estSeconds = Math.round(totalTurns / 16); // rough estimate at director speed + this.eventHint.innerHTML = `${icons} ~${estSeconds}s`; + } + + private updateMetadataContent(match: PlaylistMatch, replay: Replay | null): void { + const parts: string[] = []; + parts.push(``); + if (match.curation_tag) parts.push(``); + if (replay) { + parts.push(``); + parts.push(``); + if (replay.result.reason) parts.push(``); + } + if (match.completed_at) { + const d = new Date(match.completed_at); + parts.push(``); + } + parts.push(``); + this.metadataPanel.innerHTML = parts.join(''); + + // Wire "watch full" button + const btn = this.metadataPanel.querySelector('.carousel-meta-watch-full'); + if (btn) { + btn.addEventListener('click', () => { + const id = (btn as HTMLElement).dataset.matchId!; + this.destroy(); + window.location.hash = `/watch/replay?url=/replays/${id}.json`; + }); + } + } + + private onReplayEnd(): void { + // Show final score overlay briefly, then auto-advance + this.autoAdvanceTimer = setTimeout(() => { + if (this.currentIndex < this.playlist.matches.length - 1) { + this.advanceTo(this.currentIndex + 1); + } + }, AUTO_ADVANCE_DELAY); + } + + private handleSwipe(result: SwipeResult): void { + if (this.transitioning) return; + + // If metadata panel is open, close it on any swipe + if (this.metadataOpen && result.direction !== 'none') { + this.closeMetadata(); + return; + } + + switch (result.direction) { + case 'up': + if (this.currentIndex < this.playlist.matches.length - 1) { + this.advanceTo(this.currentIndex + 1); + } + break; + case 'down': + if (this.currentIndex > 0) { + this.advanceTo(this.currentIndex - 1); + } + break; + case 'right': + this.openMetadata(); + break; + case 'left': + this.closeMetadata(); + break; + case 'none': + // tap — already handled by canvas click + break; + } + } + + private openMetadata(): void { + if (this.metadataOpen) return; + this.metadataOpen = true; + this.metadataTranslateX = 0; + this.metadataPanel.style.transform = 'translateX(0)'; + this.cardContainer.classList.add('carousel-shifted'); + } + + private closeMetadata(): void { + if (!this.metadataOpen) return; + this.metadataOpen = false; + this.metadataTranslateX = METADATA_PANEL_WIDTH; + this.metadataPanel.style.transform = `translateX(${METADATA_PANEL_WIDTH}px)`; + this.cardContainer.classList.remove('carousel-shifted'); + } + + private advanceTo(index: number): void { + if (index < 0 || index >= this.playlist.matches.length) return; + this.transitioning = true; + const direction = index > this.currentIndex ? 1 : -1; + this.currentIndex = index; + + // Cross-fade animation + this.cardContainer.classList.add(direction > 0 ? 'carousel-exit-up' : 'carousel-exit-down'); + + setTimeout(() => { + this.stopDirectorTick(); + this.cardContainer.classList.remove('carousel-exit-up', 'carousel-exit-down', 'carousel-enter-up', 'carousel-enter-down'); + this.cardContainer.classList.add(direction > 0 ? 'carousel-enter-up' : 'carousel-enter-down'); + + this.loadCard(this.currentIndex).then(() => { + setTimeout(() => { + this.cardContainer.classList.remove('carousel-enter-up', 'carousel-enter-down'); + this.transitioning = false; + }, TRANSITION_MS); + }); + }, TRANSITION_MS / 2); + } + + private startDirectorTick(): void { + this.stopDirectorTick(); + const tick = () => { + if (!this.directorState.enabled || !this.viewer) return; + const now = performance.now(); + const turn = this.viewer.getTurn(); + const ms = tickDirectorSpeed(this.directorState, this.directorSchedule, turn, now); + this.viewer.setDirectorSpeed(ms); + this.directorAnimFrame = requestAnimationFrame(tick); + }; + this.directorAnimFrame = requestAnimationFrame(tick); + } + + private stopDirectorTick(): void { + if (this.directorAnimFrame !== null) { + cancelAnimationFrame(this.directorAnimFrame); + this.directorAnimFrame = null; + } + } + + destroy(): void { + this.stopDirectorTick(); + if (this.autoAdvanceTimer) clearTimeout(this.autoAdvanceTimer); + if (this.viewer) { + this.viewer.pause(); + this.viewer.destroy(); + } + this.overlay.remove(); + document.body.style.overflow = ''; + this.onClose(); + } +} + +// ── HTML template ────────────────────────────────────────────────────────────── + +const CAROUSEL_HTML = ` + +`; + +// ── CSS ──────────────────────────────────────────────────────────────────────── + +const CAROUSEL_CSS = ` +.carousel-overlay { + position: fixed; + inset: 0; + z-index: 1000; + background: #000; + display: flex; + flex-direction: column; + animation: carousel-fade-in 200ms ease-out; +} + +@keyframes carousel-fade-in { + from { opacity: 0; } + to { opacity: 1; } +} + +.carousel-container { + position: relative; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.carousel-header { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background: linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, transparent 100%); + color: rgba(255,255,255,0.85); + font-size: 0.8rem; + pointer-events: none; +} + +.carousel-playlist-name { + font-weight: 600; +} + +.carousel-counter { + opacity: 0.7; +} + +.carousel-card { + position: relative; + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: transform ${TRANSITION_MS}ms ease-in-out; +} + +.carousel-card.carousel-shifted { + transform: translateX(-${METADATA_PANEL_WIDTH}px); +} + +.carousel-canvas { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + touch-action: none; +} + +.carousel-score-bar { + position: absolute; + bottom: 80px; + left: 0; + right: 0; + z-index: 10; + text-align: center; + padding: 8px 16px; + background: linear-gradient(to top, rgba(0,0,0,0.6) 0%, transparent 100%); + color: rgba(255,255,255,0.9); + font-size: 0.95rem; + font-weight: 600; + pointer-events: none; +} + +.carousel-vs { + color: rgba(255,255,255,0.4); + margin: 0 6px; + font-size: 0.8rem; +} + +.carousel-winner { + color: #22c55e; +} + +.carousel-score-loading { + opacity: 0.5; + font-weight: 400; +} + +.carousel-event-hint { + position: absolute; + bottom: 60px; + left: 0; + right: 0; + z-index: 10; + text-align: center; + padding: 4px 16px; + color: rgba(255,255,255,0.5); + font-size: 0.75rem; + pointer-events: none; +} + +.carousel-swipe-hint { + position: absolute; + bottom: 24px; + left: 0; + right: 0; + z-index: 10; + text-align: center; + color: rgba(255,255,255,0.35); + font-size: 0.75rem; + padding: 8px; + transition: opacity 1s ease-out; + pointer-events: none; +} + +.carousel-hint-fade { + opacity: 0; +} + +.carousel-metadata-panel { + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: ${METADATA_PANEL_WIDTH}px; + z-index: 20; + background: rgba(15, 15, 25, 0.95); + backdrop-filter: blur(8px); + padding: 60px 16px 16px; + transform: translateX(${METADATA_PANEL_WIDTH}px); + transition: transform ${TRANSITION_MS}ms ease-in-out; + color: rgba(255,255,255,0.85); + font-size: 0.85rem; + overflow-y: auto; + -webkit-overflow-scrolling: touch; +} + +.carousel-meta-title { + font-size: 1rem; + font-weight: 600; + margin-bottom: 8px; +} + +.carousel-meta-tag { + font-size: 0.7rem; + color: var(--text-muted, #94a3b8); + font-style: italic; + margin-bottom: 12px; +} + +.carousel-meta-row { + display: flex; + justify-content: space-between; + padding: 6px 0; + border-bottom: 1px solid rgba(255,255,255,0.08); +} + +.carousel-meta-row span:first-child { + color: rgba(255,255,255,0.5); +} + +.carousel-meta-watch-full { + display: block; + width: 100%; + margin-top: 16px; + padding: 10px; + background: var(--accent, #3b82f6); + color: white; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + text-align: center; +} + +.carousel-meta-watch-full:active { + opacity: 0.8; +} + +.carousel-close-btn { + position: absolute; + top: 12px; + right: 12px; + z-index: 30; + width: 36px; + height: 36px; + border-radius: 50%; + background: rgba(0,0,0,0.5); + border: none; + color: rgba(255,255,255,0.8); + font-size: 1.2rem; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.carousel-close-btn:active { + background: rgba(0,0,0,0.8); +} + +/* Exit/enter animations for vertical transitions */ +.carousel-exit-up { + animation: carousel-slide-out-up ${TRANSITION_MS}ms ease-in forwards; +} + +.carousel-exit-down { + animation: carousel-slide-out-down ${TRANSITION_MS}ms ease-in forwards; +} + +.carousel-enter-up { + animation: carousel-slide-in-up ${TRANSITION_MS}ms ease-out forwards; +} + +.carousel-enter-down { + animation: carousel-slide-in-down ${TRANSITION_MS}ms ease-out forwards; +} + +@keyframes carousel-slide-out-up { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(-100%); opacity: 0; } +} + +@keyframes carousel-slide-out-down { + from { transform: translateY(0); opacity: 1; } + to { transform: translateY(100%); opacity: 0; } +} + +@keyframes carousel-slide-in-up { + from { transform: translateY(100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} + +@keyframes carousel-slide-in-down { + from { transform: translateY(-100%); opacity: 0; } + to { transform: translateY(0); opacity: 1; } +} +`; diff --git a/web/src/components/theater.ts b/web/src/components/theater.ts new file mode 100644 index 0000000..a1173e4 --- /dev/null +++ b/web/src/components/theater.ts @@ -0,0 +1,575 @@ +// Theater mode — fullscreen replay viewing per §16.17 +// Controls auto-hide after 3s of mouse inactivity, reappear on mousemove. +// ESC exits; F key toggles. Works on mobile via Fullscreen API. + +export const THEATER_STYLES = ` +/* ─── Theater Mode (§16.17) ────────────────────────────────────────────────── */ + +.theater-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + background: var(--bg-tertiary, #1e293b); + border: 1px solid var(--border, #334155); + border-radius: 6px; + color: var(--text-secondary, #94a3b8); + cursor: pointer; + transition: background-color 0.15s, color 0.15s; + font-size: 16px; + line-height: 1; +} +.theater-btn:hover { + background: var(--bg-secondary, #0f172a); + color: var(--text-primary, #e2e8f0); +} +.theater-btn:focus-visible { + outline: 2px solid var(--accent, #3b82f6); + outline-offset: 2px; +} + +/* Full-screen theater overlay */ +.theater-overlay { + position: fixed; + inset: 0; + z-index: 9000; + background: #000; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 300ms ease; + cursor: none; +} +.theater-overlay.visible { + opacity: 1; +} + +/* Canvas scales to viewport, letterboxed */ +.theater-canvas-wrap { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + width: 100%; + overflow: hidden; +} +.theater-canvas-wrap canvas { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +/* Controls bar — semi-transparent, fades after 3s */ +.theater-controls { + position: absolute; + bottom: 0; + left: 0; + right: 0; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + background: linear-gradient(transparent, rgba(0,0,0,0.85)); + opacity: 1; + transition: opacity 400ms ease; + cursor: default; + user-select: none; +} +.theater-overlay.controls-hidden .theater-controls { + opacity: 0; + pointer-events: none; +} + +.theater-controls .theater-ctrl-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + background: none; + border: none; + border-radius: 4px; + color: #e2e8f0; + cursor: pointer; + font-size: 18px; + transition: background-color 0.15s; +} +.theater-controls .theater-ctrl-btn:hover { + background: rgba(255,255,255,0.15); +} +.theater-controls .theater-ctrl-btn:focus-visible { + outline: 2px solid #3b82f6; + outline-offset: 2px; +} + +.theater-score { + color: #e2e8f0; + font-size: 0.85rem; + font-family: monospace; + white-space: nowrap; + display: flex; + align-items: center; + gap: 8px; +} +.theater-score .player-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} +.theater-turn-info { + color: #94a3b8; + font-size: 0.8rem; + font-family: monospace; + white-space: nowrap; + margin-left: auto; +} + +/* Thin win-prob bars at the top edge */ +.theater-winprob-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + display: flex; + height: 4px; + opacity: 1; + transition: opacity 400ms ease; +} +.theater-overlay.controls-hidden .theater-winprob-bar { + opacity: 0; +} +.theater-winprob-segment { + height: 100%; + transition: width 300ms ease; +} + +/* Vignette pulse on critical moment */ +.theater-vignette { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + box-shadow: inset 0 0 120px 40px rgba(0,0,0,0.8); + transition: opacity 400ms ease; +} +.theater-vignette.pulse { + opacity: 1; +} + +/* Speed indicator (shows current speed label) */ +.theater-speed-label { + color: #94a3b8; + font-size: 0.75rem; + font-family: monospace; +} + +/* Theater exit hint (brief, fades out) */ +.theater-exit-hint { + position: absolute; + top: 16px; + right: 16px; + color: #94a3b8; + font-size: 0.75rem; + opacity: 0; + transition: opacity 300ms ease; + pointer-events: none; +} +.theater-overlay.controls-hidden .theater-exit-hint { + opacity: 0; +} +.theater-overlay:not(.controls-hidden) .theater-exit-hint { + opacity: 0.7; +} + +/* Reduced motion */ +@media (prefers-reduced-motion: reduce) { + .theater-overlay, + .theater-controls, + .theater-winprob-bar, + .theater-vignette { + transition: none; + } +} +`; + +const AUTO_HIDE_MS = 3000; +const VIGNETTE_PULSE_MS = 600; + +export interface TheaterOptions { + getScoreText: () => string; + getPlayerColors: () => string[]; + getWinProb: () => number[]; + getTurn: () => number; + getTotalTurns: () => number; + getIsPlaying: () => boolean; + getSpeed: () => number; + togglePlay: () => void; + setTurn: (t: number) => void; + exitTheater: () => void; + onCriticalMoment?: () => void; +} + +export class TheaterMode { + private overlay: HTMLDivElement; + private canvasWrap!: HTMLDivElement; + private controls!: HTMLDivElement; + private winProbBar!: HTMLDivElement; + private vignette!: HTMLDivElement; + private exitHint!: HTMLDivElement; + private canvas: HTMLCanvasElement; + + private scoreEl!: HTMLSpanElement; + private turnInfoEl!: HTMLSpanElement; + private speedLabelEl!: HTMLSpanElement; + private playBtn!: HTMLButtonElement; + + private hideTimer: ReturnType | null = null; + private vignetteTimer: ReturnType | null = null; + private rafId: number | null = null; + private active = false; + private origParent: HTMLElement | null = null; + private origNextSibling: Node | null = null; + private fullscreenChangeHandler: () => void; + + private opts: TheaterOptions; + + constructor(canvas: HTMLCanvasElement, opts: TheaterOptions) { + this.canvas = canvas; + this.opts = opts; + this.overlay = document.createElement('div'); + this.overlay.className = 'theater-overlay'; + this.fullscreenChangeHandler = () => this.onFullscreenChange(); + this.buildDOM(); + } + + /** Returns true if theater mode is currently active. */ + isActive(): boolean { + return this.active; + } + + /** Enter theater mode. */ + enter(): void { + if (this.active) return; + this.active = true; + + // Remember original position + this.origParent = this.canvas.parentElement!; + this.origNextSibling = this.canvas.nextSibling; + + // Move canvas into theater overlay + this.canvasWrap.appendChild(this.canvas); + document.body.appendChild(this.overlay); + + // Force layout then animate in + requestAnimationFrame(() => { + this.overlay.classList.add('visible'); + }); + + // Request fullscreen via Fullscreen API + this.requestFullscreen(); + + // Start UI update loop + this.startUILoop(); + + // Auto-hide controls after inactivity + this.resetHideTimer(); + + // Attach listeners + this.overlay.addEventListener('mousemove', this.onMouseMove); + this.overlay.addEventListener('mousedown', this.onMouseMove); + this.overlay.addEventListener('touchstart', this.onTouch, { passive: true }); + document.addEventListener('keydown', this.onKeyDown); + + document.addEventListener('fullscreenchange', this.fullscreenChangeHandler); + document.addEventListener('webkitfullscreenchange', this.fullscreenChangeHandler); + + // Initial UI + this.updateUI(); + } + + /** Exit theater mode. */ + exit(): void { + if (!this.active) return; + this.active = false; + + // Cancel fullscreen if active + this.exitFullscreen(); + + // Remove listeners + this.overlay.removeEventListener('mousemove', this.onMouseMove); + this.overlay.removeEventListener('mousedown', this.onMouseMove); + this.overlay.removeEventListener('touchstart', this.onTouch); + document.removeEventListener('keydown', this.onKeyDown); + document.removeEventListener('fullscreenchange', this.fullscreenChangeHandler); + document.removeEventListener('webkitfullscreenchange', this.fullscreenChangeHandler); + + this.stopUILoop(); + this.clearHideTimer(); + + // Fade out + this.overlay.classList.remove('visible'); + this.overlay.classList.remove('controls-hidden'); + + // Move canvas back after animation + setTimeout(() => { + if (this.origParent) { + if (this.origNextSibling) { + this.origParent.insertBefore(this.canvas, this.origNextSibling); + } else { + this.origParent.appendChild(this.canvas); + } + } + this.overlay.remove(); + this.origParent = null; + this.origNextSibling = null; + }, 300); + } + + /** Toggle theater mode. */ + toggle(): void { + if (this.active) this.exit(); + else this.enter(); + } + + /** Trigger a vignette pulse (called externally on critical moments). */ + pulseVignette(): void { + this.vignette.classList.add('pulse'); + if (this.vignetteTimer) clearTimeout(this.vignetteTimer); + this.vignetteTimer = setTimeout(() => { + this.vignette.classList.remove('pulse'); + }, VIGNETTE_PULSE_MS); + } + + /** Update score text (called externally). */ + updateUI(): void { + this.scoreEl.textContent = this.opts.getScoreText(); + const turn = this.opts.getTurn(); + const total = this.opts.getTotalTurns(); + this.turnInfoEl.textContent = `Turn ${turn + 1}/${total}`; + this.playBtn.textContent = this.opts.getIsPlaying() ? '⏸' : '▶'; + const speed = this.opts.getSpeed(); + const label = speed <= 31 ? '16x' : speed <= 62 ? '8x' : speed <= 125 ? '4x' : speed <= 250 ? '2x' : '1x'; + this.speedLabelEl.textContent = label; + this.updateWinProbBar(); + this.updatePlayerDots(); + } + + destroy(): void { + if (this.active) this.exit(); + } + + // ── Private ──────────────────────────────────────────────────────────────── + + private buildDOM(): void { + // Vignette + this.vignette = document.createElement('div'); + this.vignette.className = 'theater-vignette'; + this.overlay.appendChild(this.vignette); + + // Win prob bar + this.winProbBar = document.createElement('div'); + this.winProbBar.className = 'theater-winprob-bar'; + this.overlay.appendChild(this.winProbBar); + + // Exit hint + this.exitHint = document.createElement('div'); + this.exitHint.className = 'theater-exit-hint'; + this.exitHint.textContent = 'Press ESC or F to exit'; + this.overlay.appendChild(this.exitHint); + + // Canvas wrapper + this.canvasWrap = document.createElement('div'); + this.canvasWrap.className = 'theater-canvas-wrap'; + this.overlay.appendChild(this.canvasWrap); + + // Controls bar + this.controls = document.createElement('div'); + this.controls.className = 'theater-controls'; + + const prevBtn = document.createElement('button'); + prevBtn.className = 'theater-ctrl-btn'; + prevBtn.innerHTML = '◀'; + prevBtn.title = 'Previous turn'; + prevBtn.setAttribute('aria-label', 'Previous turn'); + prevBtn.addEventListener('click', (e) => { e.stopPropagation(); this.opts.setTurn(this.opts.getTurn() - 1); this.updateUI(); }); + + this.playBtn = document.createElement('button'); + this.playBtn.className = 'theater-ctrl-btn'; + this.playBtn.innerHTML = '▶'; + this.playBtn.title = 'Play/Pause'; + this.playBtn.setAttribute('aria-label', 'Play or pause'); + this.playBtn.addEventListener('click', (e) => { e.stopPropagation(); this.opts.togglePlay(); this.updateUI(); }); + + const nextBtn = document.createElement('button'); + nextBtn.className = 'theater-ctrl-btn'; + nextBtn.innerHTML = '▶▶'; + nextBtn.title = 'Next turn'; + nextBtn.setAttribute('aria-label', 'Next turn'); + nextBtn.addEventListener('click', (e) => { e.stopPropagation(); this.opts.setTurn(this.opts.getTurn() + 1); this.updateUI(); }); + + this.scoreEl = document.createElement('span'); + this.scoreEl.className = 'theater-score'; + + this.speedLabelEl = document.createElement('span'); + this.speedLabelEl.className = 'theater-speed-label'; + + this.turnInfoEl = document.createElement('span'); + this.turnInfoEl.className = 'theater-turn-info'; + + this.controls.appendChild(prevBtn); + this.controls.appendChild(this.playBtn); + this.controls.appendChild(nextBtn); + this.controls.appendChild(this.scoreEl); + this.controls.appendChild(this.speedLabelEl); + this.controls.appendChild(this.turnInfoEl); + + this.overlay.appendChild(this.controls); + } + + private updateWinProbBar(): void { + const probs = this.opts.getWinProb(); + if (!probs || probs.length === 0) { + this.winProbBar.style.display = 'none'; + return; + } + this.winProbBar.style.display = ''; + const colors = this.opts.getPlayerColors(); + // Ensure segments exist + while (this.winProbBar.children.length < probs.length) { + const seg = document.createElement('div'); + seg.className = 'theater-winprob-segment'; + this.winProbBar.appendChild(seg); + } + while (this.winProbBar.children.length > probs.length) { + this.winProbBar.removeChild(this.winProbBar.lastChild!); + } + for (let i = 0; i < probs.length; i++) { + const seg = this.winProbBar.children[i] as HTMLElement; + seg.style.width = `${(probs[i] * 100).toFixed(1)}%`; + seg.style.backgroundColor = colors[i] || '#888'; + } + } + + private updatePlayerDots(): void { + const colors = this.opts.getPlayerColors(); + let html = ''; + for (let i = 0; i < colors.length; i++) { + html += ``; + } + // Prepend dots before text + const text = this.opts.getScoreText(); + this.scoreEl.innerHTML = html + text; + } + + private requestFullscreen(): void { + const el = this.overlay as any; + if (el.requestFullscreen) { + el.requestFullscreen().catch(() => { /* user may deny */ }); + } else if (el.webkitRequestFullscreen) { + el.webkitRequestFullscreen(); + } + } + + private exitFullscreen(): void { + const doc = document as any; + if (doc.fullscreenElement || doc.webkitFullscreenElement) { + if (doc.exitFullscreen) { + doc.exitFullscreen().catch(() => {}); + } else if (doc.webkitExitFullscreen) { + doc.webkitExitFullscreen(); + } + } + } + + private onFullscreenChange(): void { + const doc = document as any; + const isFs = !!(doc.fullscreenElement || doc.webkitFullscreenElement); + if (!isFs && this.active) { + // User pressed ESC at browser level — exit theater + this.exit(); + this.opts.exitTheater(); + } + } + + private resetHideTimer(): void { + this.clearHideTimer(); + this.overlay.classList.remove('controls-hidden'); + this.hideTimer = setTimeout(() => { + this.overlay.classList.add('controls-hidden'); + }, AUTO_HIDE_MS); + } + + private clearHideTimer(): void { + if (this.hideTimer !== null) { + clearTimeout(this.hideTimer); + this.hideTimer = null; + } + } + + private readonly onMouseMove = (): void => { + this.resetHideTimer(); + this.updateUI(); + }; + + private readonly onTouch = (): void => { + this.resetHideTimer(); + this.updateUI(); + }; + + private readonly onKeyDown = (e: KeyboardEvent): void => { + if (!this.active) return; + switch (e.code) { + case 'KeyF': + case 'Escape': + e.preventDefault(); + e.stopPropagation(); + this.exit(); + this.opts.exitTheater(); + break; + case 'Space': + e.preventDefault(); + e.stopPropagation(); + this.opts.togglePlay(); + this.updateUI(); + this.resetHideTimer(); + break; + case 'ArrowLeft': + e.preventDefault(); + e.stopPropagation(); + this.opts.setTurn(this.opts.getTurn() - 1); + this.updateUI(); + this.resetHideTimer(); + break; + case 'ArrowRight': + e.preventDefault(); + e.stopPropagation(); + this.opts.setTurn(this.opts.getTurn() + 1); + this.updateUI(); + this.resetHideTimer(); + break; + } + }; + + private startUILoop(): void { + const tick = () => { + if (!this.active) return; + this.updateUI(); + this.rafId = requestAnimationFrame(tick); + }; + this.rafId = requestAnimationFrame(tick); + } + + private stopUILoop(): void { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + } +} diff --git a/web/src/pages/bots.ts b/web/src/pages/bots.ts index 802425c..ca5ff43 100644 --- a/web/src/pages/bots.ts +++ b/web/src/pages/bots.ts @@ -1,7 +1,12 @@ // Bot directory page - lists all registered bots +// §16.15: windowed rendering for large directories, "Show more" button, +// keyboard-accessible affordances. import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types'; +const INITIAL_COUNT = 30; +const BATCH_SIZE = 50; + export async function renderBotsPage(): Promise { const app = document.getElementById('app'); if (!app) return; @@ -47,12 +52,77 @@ function renderBotsList( // Sort by rating descending const sortedBots = [...bots].sort((a, b) => b.rating - a.rating); + const initial = sortedBots.slice(0, INITIAL_COUNT); + const remaining = sortedBots.slice(INITIAL_COUNT); + container.innerHTML = `

Last updated: ${formatTimestamp(updatedAt)}

- ${sortedBots.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')} + ${initial.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')}
+ ${remaining.length > 0 ? `
` : ''} `; + + // Lazy-load remaining bots when scrolled near the sentinel + if (remaining.length > 0) { + const sentinel = document.getElementById('bots-remaining'); + if (sentinel) { + let offset = 0; + const total = remaining.length; + + const observer = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + observer.disconnect(); + appendBotBatch(sentinel, remaining, offset, total); + } + }, { rootMargin: '300px' }); + observer.observe(sentinel); + } + } +} + +function appendBotBatch( + sentinel: HTMLElement, + remaining: BotDirectoryEntry[], + startOffset: number, + totalCount: number +): void { + const batch = remaining.slice(startOffset, startOffset + BATCH_SIZE); + if (batch.length === 0) return; + + const grid = sentinel.previousElementSibling as HTMLElement | null; + if (!grid) return; + + // Adjust rank to continue from where initial batch left off + const rankOffset = INITIAL_COUNT + startOffset; + const html = batch.map((bot, i) => renderBotCard(bot, rankOffset + i + 1)).join(''); + const temp = document.createElement('div'); + temp.innerHTML = html; + while (temp.firstChild) { + grid.appendChild(temp.firstChild); + } + + const newOffset = startOffset + batch.length; + if (newOffset < totalCount) { + // Add "Show more" button + const left = totalCount - newOffset; + const next = Math.min(BATCH_SIZE, left); + const btn = document.createElement('button'); + btn.className = 'btn secondary show-more-btn'; + btn.type = 'button'; + btn.textContent = `Show ${next} more bots (${left} remaining)`; + btn.setAttribute('aria-label', `Show ${next} more bots, ${left} remaining`); + + btn.addEventListener('click', () => { + btn.remove(); + appendBotBatch(sentinel, remaining, newOffset, totalCount); + }); + + sentinel.before(btn); + } else { + sentinel.remove(); + } } function renderBotCard(bot: BotDirectoryEntry, rank: number): string { diff --git a/web/src/pages/playlists.ts b/web/src/pages/playlists.ts index 55d832f..fc51a3d 100644 --- a/web/src/pages/playlists.ts +++ b/web/src/pages/playlists.ts @@ -1,5 +1,14 @@ // Playlists Page - Browse curated replay collections -import { fetchPlaylistIndex, fetchPlaylist, type Playlist, type PlaylistIndex } from '../api-types'; +// §16.15: lazy-rendered playlist grid, expandable match details, +// keyboard-accessible "Show more" affordances. +import { fetchPlaylistIndex, fetchPlaylist, type Playlist, type PlaylistIndex, type PlaylistMatch } from '../api-types'; +import { initLazySections, lazySection } from '../lib/lazy-section'; + +function isMobile(): boolean { + return window.innerWidth < 768; +} + +const MATCH_BATCH = 10; export async function renderPlaylistsPage(params?: Record): Promise { const app = document.getElementById('app'); @@ -131,20 +140,35 @@ export async function renderPlaylistsPage(params?: Record): Prom } .playlist-match { - display: flex; - align-items: center; - gap: 16px; background-color: var(--bg-secondary); border-radius: 8px; - padding: 12px 16px; - cursor: pointer; + overflow: hidden; transition: background-color 0.2s; } - .playlist-match:hover { + .playlist-match-toggle { + display: flex; + align-items: center; + gap: 16px; + width: 100%; + background: none; + border: none; + color: inherit; + padding: 12px 16px; + cursor: pointer; + text-align: left; + } + + .playlist-match-toggle:hover { background-color: var(--bg-tertiary); } + .playlist-match-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: 8px; + } + .match-order { font-size: 1.25rem; font-weight: 600; @@ -167,7 +191,25 @@ export async function renderPlaylistsPage(params?: Record): Prom font-size: 0.75rem; } - .match-actions { + .match-expand-icon { + color: var(--text-muted); + font-size: 0.75rem; + transition: transform var(--transition-fast, 0.15s); + } + + .playlist-match-details { + max-height: 0; + overflow: hidden; + transition: max-height 200ms ease-out, padding 200ms ease-out; + padding: 0 16px; + } + + .playlist-match-details.expanded { + max-height: 120px; + padding: 0 16px 12px; + } + + .playlist-match-actions { display: flex; gap: 8px; } @@ -186,6 +228,11 @@ export async function renderPlaylistsPage(params?: Record): Prom opacity: 0.9; } + .watch-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .embed-btn { background-color: transparent; color: var(--text-muted); @@ -201,6 +248,11 @@ export async function renderPlaylistsPage(params?: Record): Prom color: var(--accent); } + .embed-btn:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } + .empty-message { color: var(--text-muted); text-align: center; @@ -235,6 +287,12 @@ export async function renderPlaylistsPage(params?: Record): Prom font-style: italic; margin-top: 2px; } + + @media (prefers-reduced-motion: reduce) { + .playlist-match-details { + transition: none; + } + } `; @@ -265,16 +323,24 @@ async function loadPlaylists(): Promise { return; } - grid.innerHTML = index.playlists.map(p => ` -
-

${p.title}${formatCategory(p.category)}

-

${p.description}

-
- ${p.match_count} matches - Updated ${formatRelativeTime(p.updated_at)} -
-
- `).join(''); + // Show first 6 immediately, lazy-load the rest + const immediateCount = 6; + const immediate = index.playlists.slice(0, immediateCount); + const rest = index.playlists.slice(immediateCount); + + const immediateHtml = immediate.map(p => renderPlaylistCardHtml(p)).join(''); + + if (rest.length === 0) { + grid.innerHTML = immediateHtml; + } else { + const lazyContent = rest.map(p => renderPlaylistCardHtml(p)).join(''); + grid.innerHTML = immediateHtml + lazySection( + 'playlists-below-fold', + lazyContent, + { placeholder: '
' } + ); + initLazySections(grid); + } grid.querySelectorAll('.playlist-card').forEach(card => { card.addEventListener('click', () => { @@ -288,6 +354,19 @@ async function loadPlaylists(): Promise { } } +function renderPlaylistCardHtml(p: PlaylistIndex['playlists'][number]): string { + return ` +
+

${escapeHtml(p.title)}${formatCategory(p.category)}

+

${escapeHtml(p.description)}

+
+ ${p.match_count} matches + Updated ${formatRelativeTime(p.updated_at)} +
+
+ `; +} + async function showPlaylistDetail(slug: string): Promise { const grid = document.getElementById('playlists-grid'); const detail = document.getElementById('playlist-detail'); @@ -301,46 +380,41 @@ async function showPlaylistDetail(slug: string): Promise { try { const playlist: Playlist = await fetchPlaylist(slug); + // Mobile: defer to carousel component if available + if (isMobile() && playlist.matches.length > 3) { + try { + const { PlaylistCarousel } = await import('../components/playlist-carousel'); + new PlaylistCarousel({ + playlist, + onClose: () => { + window.location.hash = '/watch/playlists'; + }, + }); + return; + } catch { + // Fallback to desktop view + } + } + titleEl.textContent = playlist.title; descEl.textContent = playlist.description; - matchesEl.innerHTML = playlist.matches.map(m => { - const metaParts: string[] = []; - if (m.turns) metaParts.push(`${m.turns} turns`); - if (m.end_reason) metaParts.push(m.end_reason); - if (m.completed_at) metaParts.push(formatRelativeTime(m.completed_at)); - const tag = m.curation_tag ? `${m.curation_tag}` : ''; - return ` -
- ${m.order + 1} -
-
${m.title || `Match ${m.order + 1}`}
- ${tag} -
${metaParts.join(' · ')}
-
-
- - -
-
- `; - }).join(''); + // Render first batch of matches immediately + const visibleMatches = playlist.matches.slice(0, MATCH_BATCH); + const remainingMatches = playlist.matches.slice(MATCH_BATCH); - matchesEl.querySelectorAll('.watch-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const matchId = (btn as HTMLElement).dataset.matchId; - if (matchId) watchMatch(matchId); - }); - }); + matchesEl.innerHTML = visibleMatches.map(m => renderPlaylistMatchHtml(m)).join(''); - matchesEl.querySelectorAll('.embed-btn').forEach(btn => { - btn.addEventListener('click', (e) => { - e.stopPropagation(); - const matchId = (btn as HTMLElement).dataset.matchId; - if (matchId) copyEmbedCode(matchId); - }); - }); + // Wire expand toggles + initMatchExpandToggles(matchesEl); + + // Wire watch/embed buttons + initMatchActions(matchesEl); + + // Add "Show more" for remaining matches + if (remainingMatches.length > 0) { + addMatchShowMore(matchesEl, remainingMatches); + } grid.style.display = 'none'; detail.style.display = 'block'; @@ -355,6 +429,107 @@ async function showPlaylistDetail(slug: string): Promise { } } +function renderPlaylistMatchHtml(m: PlaylistMatch): string { + const metaParts: string[] = []; + if (m.turns) metaParts.push(`${m.turns} turns`); + if (m.end_reason) metaParts.push(m.end_reason); + if (m.completed_at) metaParts.push(formatRelativeTime(m.completed_at)); + const tag = m.curation_tag ? `${escapeHtml(m.curation_tag)}` : ''; + + return ` +
+ +
+
+ + +
+
+
+ `; +} + +function initMatchExpandToggles(root: HTMLElement): void { + root.querySelectorAll('.playlist-match').forEach(match => { + const toggle = match.querySelector('.playlist-match-toggle'); + if (!toggle || toggle.dataset.wired) return; + toggle.dataset.wired = '1'; + + toggle.addEventListener('click', () => { + const details = match.querySelector('.playlist-match-details'); + if (!details) return; + const expanded = details.classList.toggle('expanded'); + toggle.setAttribute('aria-expanded', String(expanded)); + const icon = match.querySelector('.match-expand-icon'); + if (icon) icon.textContent = expanded ? '▾' : '▸'; + }); + }); +} + +function initMatchActions(root: HTMLElement): void { + root.querySelectorAll('.watch-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const matchId = btn.dataset.matchId; + if (matchId) watchMatch(matchId); + }); + }); + + root.querySelectorAll('.embed-btn').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const matchId = btn.dataset.matchId; + if (matchId) copyEmbedCode(matchId); + }); + }); +} + +function addMatchShowMore(container: HTMLElement, remaining: PlaylistMatch[]): void { + const btn = document.createElement('button'); + btn.className = 'btn secondary show-more-btn'; + btn.type = 'button'; + let offset = 0; + const total = remaining.length; + + function updateBtn(): void { + const left = total - offset; + if (left <= 0) { btn.remove(); return; } + const next = Math.min(MATCH_BATCH, left); + btn.textContent = `Show ${next} more matches (${left} remaining)`; + btn.setAttribute('aria-label', `Show ${next} more matches, ${left} remaining`); + } + + btn.addEventListener('click', () => { + const batch = remaining.slice(offset, offset + MATCH_BATCH); + if (batch.length === 0) return; + + const temp = document.createElement('div'); + temp.innerHTML = batch.map(m => renderPlaylistMatchHtml(m)).join(''); + while (temp.firstChild) { + container.appendChild(temp.firstChild); + } + + initMatchExpandToggles(container); + initMatchActions(container); + + offset += batch.length; + updateBtn(); + }); + + updateBtn(); + container.after(btn); +} + function watchMatch(matchId: string): void { window.location.hash = `/watch/replay?url=/replays/${matchId}.json`; } @@ -397,3 +572,11 @@ function formatRelativeTime(isoDate: string): string { if (diffDays < 7) return `${diffDays}d ago`; return date.toLocaleDateString(); } + +function escapeHtml(str: string): string { + return str + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +}