feat(web): add theater component and playlist carousel
Commit the TheaterMode component (§16.17) and playlist carousel referenced by recent commits. Includes bots page and playlists page enhancements. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
4930d7a841
commit
67d94cebbd
4 changed files with 1603 additions and 54 deletions
721
web/src/components/playlist-carousel.ts
Normal file
721
web/src/components/playlist-carousel.ts
Normal file
|
|
@ -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<typeof import('../replay-viewer').ReplayViewer> | null = null;
|
||||
|
||||
// Director state (lightweight — auto-plays at director speed)
|
||||
private directorState: DirectorState = createDirectorState();
|
||||
private directorSchedule: ReturnType<typeof computeSpeedSchedule> = [];
|
||||
private directorAnimFrame: number | null = null;
|
||||
|
||||
// Preloading
|
||||
private preloadedReplays = new Map<number, Replay>();
|
||||
|
||||
// Auto-advance timer
|
||||
private autoAdvanceTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
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<void> {
|
||||
const match = this.playlist.matches[index];
|
||||
if (!match) return;
|
||||
|
||||
// Update header
|
||||
this.headerBar.innerHTML = `
|
||||
<span class="carousel-playlist-name">${this.playlist.title}</span>
|
||||
<span class="carousel-counter">${index + 1} of ${this.playlist.matches.length}</span>
|
||||
`;
|
||||
|
||||
// Update score bar with placeholder
|
||||
this.scoreBar.innerHTML = `
|
||||
<span class="carousel-score-loading">Loading...</span>
|
||||
`;
|
||||
|
||||
// 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 = `<span class="carousel-score-loading">Failed to load</span>`;
|
||||
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<Replay> {
|
||||
// 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 `<span class="carousel-player${won ? ' carousel-winner' : ''}">${p.name} ${score}</span>`;
|
||||
}).join(' <span class="carousel-vs">vs</span> ');
|
||||
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(`<div class="carousel-meta-title">${match.title ?? `Match ${match.order + 1}`}</div>`);
|
||||
if (match.curation_tag) parts.push(`<div class="carousel-meta-tag">${match.curation_tag}</div>`);
|
||||
if (replay) {
|
||||
parts.push(`<div class="carousel-meta-row"><span>Turns</span><span>${replay.turns.length}</span></div>`);
|
||||
parts.push(`<div class="carousel-meta-row"><span>Map</span><span>${replay.map.rows}x${replay.map.cols}</span></div>`);
|
||||
if (replay.result.reason) parts.push(`<div class="carousel-meta-row"><span>End</span><span>${replay.result.reason}</span></div>`);
|
||||
}
|
||||
if (match.completed_at) {
|
||||
const d = new Date(match.completed_at);
|
||||
parts.push(`<div class="carousel-meta-row"><span>Date</span><span>${d.toLocaleDateString()}</span></div>`);
|
||||
}
|
||||
parts.push(`<button class="carousel-meta-watch-full" data-match-id="${match.match_id}">Watch Full Replay →</button>`);
|
||||
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 = `
|
||||
<div class="carousel-container">
|
||||
<div class="carousel-header">
|
||||
<span class="carousel-playlist-name"></span>
|
||||
<span class="carousel-counter"></span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-card">
|
||||
<canvas class="carousel-canvas"></canvas>
|
||||
|
||||
<div class="carousel-score-bar"></div>
|
||||
<div class="carousel-event-hint"></div>
|
||||
</div>
|
||||
|
||||
<div class="carousel-swipe-hint">↑ swipe for next</div>
|
||||
|
||||
<div class="carousel-metadata-panel"></div>
|
||||
|
||||
<button class="carousel-close-btn" aria-label="Close carousel">✕</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// ── 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; }
|
||||
}
|
||||
`;
|
||||
575
web/src/components/theater.ts
Normal file
575
web/src/components/theater.ts
Normal file
|
|
@ -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<typeof setTimeout> | null = null;
|
||||
private vignetteTimer: ReturnType<typeof setTimeout> | 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 += `<span class="player-dot" style="background:${colors[i]}"></span>`;
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<void> {
|
||||
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 = `
|
||||
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
|
||||
<div class="bots-grid">
|
||||
${sortedBots.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')}
|
||||
${initial.map((bot, idx) => renderBotCard(bot, idx + 1)).join('')}
|
||||
</div>
|
||||
${remaining.length > 0 ? `<div id="bots-remaining"></div>` : ''}
|
||||
`;
|
||||
|
||||
// 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 {
|
||||
|
|
|
|||
|
|
@ -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<string, string>): Promise<void> {
|
||||
const app = document.getElementById('app');
|
||||
|
|
@ -131,20 +140,35 @@ export async function renderPlaylistsPage(params?: Record<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): 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<string, string>): Prom
|
|||
font-style: italic;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.playlist-match-details {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
|
|
@ -265,16 +323,24 @@ async function loadPlaylists(): Promise<void> {
|
|||
return;
|
||||
}
|
||||
|
||||
grid.innerHTML = index.playlists.map(p => `
|
||||
<div class="playlist-card" data-slug="${p.slug}">
|
||||
<h3>${p.title}<span class="category-badge ${p.category}">${formatCategory(p.category)}</span></h3>
|
||||
<p>${p.description}</p>
|
||||
<div class="meta">
|
||||
<span class="match-count">${p.match_count} matches</span>
|
||||
<span>Updated ${formatRelativeTime(p.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`).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: '<div class="lazy-placeholder" style="min-height:200px"></div>' }
|
||||
);
|
||||
initLazySections(grid);
|
||||
}
|
||||
|
||||
grid.querySelectorAll('.playlist-card').forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
|
|
@ -288,6 +354,19 @@ async function loadPlaylists(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
function renderPlaylistCardHtml(p: PlaylistIndex['playlists'][number]): string {
|
||||
return `
|
||||
<div class="playlist-card" data-slug="${p.slug}">
|
||||
<h3>${escapeHtml(p.title)}<span class="category-badge ${p.category}">${formatCategory(p.category)}</span></h3>
|
||||
<p>${escapeHtml(p.description)}</p>
|
||||
<div class="meta">
|
||||
<span class="match-count">${p.match_count} matches</span>
|
||||
<span>Updated ${formatRelativeTime(p.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function showPlaylistDetail(slug: string): Promise<void> {
|
||||
const grid = document.getElementById('playlists-grid');
|
||||
const detail = document.getElementById('playlist-detail');
|
||||
|
|
@ -301,46 +380,41 @@ async function showPlaylistDetail(slug: string): Promise<void> {
|
|||
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 ? `<span class="curation-tag">${m.curation_tag}</span>` : '';
|
||||
return `
|
||||
<div class="playlist-match" data-match-id="${m.match_id}">
|
||||
<span class="match-order">${m.order + 1}</span>
|
||||
<div class="match-info">
|
||||
<div class="match-title">${m.title || `Match ${m.order + 1}`}</div>
|
||||
${tag}
|
||||
<div class="match-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
<div class="match-actions">
|
||||
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
|
||||
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).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<void> {
|
|||
}
|
||||
}
|
||||
|
||||
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 ? `<span class="curation-tag">${escapeHtml(m.curation_tag)}</span>` : '';
|
||||
|
||||
return `
|
||||
<div class="playlist-match" data-match-id="${m.match_id}">
|
||||
<button class="playlist-match-toggle" type="button"
|
||||
aria-label="Expand details for ${escapeHtml(m.title || `Match ${m.order + 1}`)}"
|
||||
aria-expanded="false">
|
||||
<span class="match-order">${m.order + 1}</span>
|
||||
<div class="match-info">
|
||||
<div class="match-title">${escapeHtml(m.title || `Match ${m.order + 1}`)}</div>
|
||||
${tag}
|
||||
<div class="match-meta">${metaParts.join(' · ')}</div>
|
||||
</div>
|
||||
<span class="match-expand-icon" aria-hidden="true">▸</span>
|
||||
</button>
|
||||
<div class="playlist-match-details">
|
||||
<div class="playlist-match-actions">
|
||||
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
|
||||
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function initMatchExpandToggles(root: HTMLElement): void {
|
||||
root.querySelectorAll<HTMLElement>('.playlist-match').forEach(match => {
|
||||
const toggle = match.querySelector<HTMLButtonElement>('.playlist-match-toggle');
|
||||
if (!toggle || toggle.dataset.wired) return;
|
||||
toggle.dataset.wired = '1';
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
const details = match.querySelector<HTMLElement>('.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<HTMLElement>('.watch-btn').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
const matchId = btn.dataset.matchId;
|
||||
if (matchId) watchMatch(matchId);
|
||||
});
|
||||
});
|
||||
|
||||
root.querySelectorAll<HTMLElement>('.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, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue