From 456dcc56140637d9bdb5dc0a090b71ccac529786 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 22:38:54 -0400 Subject: [PATCH 01/17] feat: add initial implementation of broadcasts --- src/api/lichess/broadcasts.ts | 295 +++++++++++ src/api/lichess/index.ts | 1 + src/components/Analysis/BroadcastAnalysis.tsx | 469 ++++++++++++++++++ src/components/Analysis/BroadcastGameList.tsx | 219 ++++++++ src/components/Analysis/index.ts | 2 + src/hooks/index.ts | 1 + src/hooks/useBroadcastController.ts | 411 +++++++++++++++ .../broadcast/[broadcastId]/[roundId].tsx | 271 ++++++++++ src/pages/broadcast/index.tsx | 290 +++++++++++ src/types/broadcast/index.ts | 92 ++++ src/types/index.ts | 1 + 11 files changed, 2052 insertions(+) create mode 100644 src/api/lichess/broadcasts.ts create mode 100644 src/components/Analysis/BroadcastAnalysis.tsx create mode 100644 src/components/Analysis/BroadcastGameList.tsx create mode 100644 src/hooks/useBroadcastController.ts create mode 100644 src/pages/broadcast/[broadcastId]/[roundId].tsx create mode 100644 src/pages/broadcast/index.tsx create mode 100644 src/types/broadcast/index.ts diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts new file mode 100644 index 00000000..daba0f46 --- /dev/null +++ b/src/api/lichess/broadcasts.ts @@ -0,0 +1,295 @@ +import { Broadcast, BroadcastGame, PGNParseResult } from 'src/types' + +const readStream = (processLine: (data: any) => void) => (response: any) => { + const stream = response.body.getReader() + const matcher = /\r?\n/ + const decoder = new TextDecoder() + let buf = '' + + const loop = () => + stream.read().then(({ done, value }: { done: boolean; value: any }) => { + if (done) { + if (buf.length > 0) processLine(JSON.parse(buf)) + } else { + const chunk = decoder.decode(value, { + stream: true, + }) + buf += chunk + + const parts = (buf || '').split(matcher) + buf = parts.pop() as string + for (const i of parts.filter((p) => p)) processLine(JSON.parse(i)) + + return loop() + } + }) + + return loop() +} + +export const getLichessBroadcasts = async (): Promise => { + const response = await fetch('https://lichess.org/api/broadcast', { + headers: { + Accept: 'application/x-ndjson', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + if (!response.body) { + throw new Error('No response body') + } + + const broadcasts: Broadcast[] = [] + + return new Promise((resolve, reject) => { + const onMessage = (message: any) => { + try { + broadcasts.push(message as Broadcast) + } catch (error) { + console.error('Error parsing broadcast message:', error) + } + } + + const onComplete = () => { + resolve(broadcasts) + } + + readStream(onMessage)(response).then(onComplete).catch(reject) + }) +} + +export const streamBroadcastRound = async ( + roundId: string, + onPGNUpdate: (pgn: string) => void, + onComplete: () => void, + abortSignal?: AbortSignal, +) => { + const stream = fetch( + `https://lichess.org/api/stream/broadcast/round/${roundId}.pgn`, + { + signal: abortSignal, + headers: { + Accept: 'application/x-chess-pgn', + }, + }, + ) + + const onMessage = (data: string) => { + if (data.trim()) { + onPGNUpdate(data) + } + } + + try { + const response = await stream + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + if (!response.body) { + throw new Error('No response body') + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + while (true) { + const { done, value } = await reader.read() + + if (done) { + if (buffer.trim()) { + onMessage(buffer) + } + break + } + + const chunk = decoder.decode(value, { stream: true }) + buffer += chunk + + // Split on double newlines to separate PGN games + const parts = buffer.split('\n\n\n') + buffer = parts.pop() || '' + + for (const part of parts) { + if (part.trim()) { + onMessage(part) + } + } + } + + onComplete() + } catch (error) { + if (abortSignal?.aborted) { + console.log('Broadcast stream aborted') + } else { + console.error('Broadcast stream error:', error) + throw error + } + } +} + +export const parsePGNData = (pgnData: string): PGNParseResult => { + const games: BroadcastGame[] = [] + const errors: string[] = [] + + try { + // Split the PGN data into individual games + const gameStrings = pgnData + .split(/\n\n\[Event/) + .filter((game) => game.trim()) + + for (let i = 0; i < gameStrings.length; i++) { + let gameString = gameStrings[i] + + // Add back the [Event header if it was removed by split + if (i > 0 && !gameString.startsWith('[Event')) { + gameString = '[Event' + gameString + } + + try { + const game = parseSinglePGN(gameString) + if (game) { + games.push(game) + } + } catch (error) { + errors.push(`Error parsing game ${i + 1}: ${error}`) + } + } + } catch (error) { + errors.push(`Error splitting PGN data: ${error}`) + } + + return { games, errors } +} + +const parseSinglePGN = (pgnString: string): BroadcastGame | null => { + const lines = pgnString.trim().split('\n') + const headers: Record = {} + let movesSection = '' + let inMoves = false + + // Parse headers and moves + for (const line of lines) { + const trimmedLine = line.trim() + + if (trimmedLine.startsWith('[') && trimmedLine.endsWith(']')) { + // Parse header + const match = trimmedLine.match(/^\[(\w+)\s+"([^"]*)"\]$/) + if (match) { + headers[match[1]] = match[2] + } + } else if (trimmedLine && !inMoves) { + inMoves = true + movesSection = trimmedLine + } else if (inMoves && trimmedLine) { + movesSection += ' ' + trimmedLine + } + } + + // Extract essential data + const white = headers.White || 'Unknown' + const black = headers.Black || 'Unknown' + const result = headers.Result || '*' + const event = headers.Event || '' + const site = headers.Site || '' + const date = headers.Date || headers.UTCDate || '' + const round = headers.Round || '' + + // Parse moves from moves section + const moves = parseMovesFromPGN(movesSection) + const fen = extractFENFromMoves(moves) + + const game: BroadcastGame = { + id: generateGameId(white, black, event, site), + white, + black, + result, + moves, + pgn: pgnString, + fen: fen || 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', + event, + site, + date, + round, + eco: headers.ECO, + opening: headers.Opening, + whiteElo: headers.WhiteElo ? parseInt(headers.WhiteElo) : undefined, + blackElo: headers.BlackElo ? parseInt(headers.BlackElo) : undefined, + timeControl: headers.TimeControl, + termination: headers.Termination, + annotator: headers.Annotator, + studyName: headers.StudyName, + chapterName: headers.ChapterName, + utcDate: headers.UTCDate, + utcTime: headers.UTCTime, + } + + // Extract last move if available + if (moves.length > 0) { + const lastMove = moves[moves.length - 1] + // This would need proper move parsing to convert SAN to UCI + // For now, we'll leave it undefined and handle in the controller + } + + return game +} + +const parseMovesFromPGN = (movesSection: string): string[] => { + const moves: string[] = [] + + // Remove comments, variations, and result + const cleanMoves = movesSection + .replace(/\{[^}]*\}/g, '') // Remove comments + .replace(/\([^)]*\)/g, '') // Remove variations + .replace(/\s*(1-0|0-1|1\/2-1\/2|\*)\s*$/, '') // Remove result + .trim() + + // Split by move numbers and extract moves + const tokens = cleanMoves.split(/\s+/) + + for (const token of tokens) { + // Skip move numbers (e.g., "1.", "2.", etc.) + if (/^\d+\.+$/.test(token)) { + continue + } + + // Skip empty tokens + if (!token.trim()) { + continue + } + + // Add valid moves + if ( + /^[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8](\=[NBRQ])?[\+\#]?$/.test(token) || + token === 'O-O' || + token === 'O-O-O' + ) { + moves.push(token) + } + } + + return moves +} + +const extractFENFromMoves = (moves: string[]): string | null => { + // This would require a full chess engine to calculate the FEN from moves + // For now, return null and handle in the controller with chess.js + return null +} + +const generateGameId = ( + white: string, + black: string, + event: string, + site: string, +): string => { + const baseString = `${white}-${black}-${event}-${site}` + return btoa(baseString) + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 12) +} diff --git a/src/api/lichess/index.ts b/src/api/lichess/index.ts index 7f1dfdc3..024fed9a 100644 --- a/src/api/lichess/index.ts +++ b/src/api/lichess/index.ts @@ -1 +1,2 @@ export * from './streaming' +export * from './broadcasts' diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx new file mode 100644 index 00000000..208fac4e --- /dev/null +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -0,0 +1,469 @@ +import React, { + useMemo, + useState, + useEffect, + useCallback, + useContext, +} from 'react' +import { motion } from 'framer-motion' +import type { Key } from 'chessground/types' +import { Chess, PieceSymbol } from 'chess.ts' +import type { DrawShape } from 'chessground/draw' + +import { WindowSizeContext } from 'src/contexts' +import { MAIA_MODELS } from 'src/constants/common' +import { GameInfo } from 'src/components/Common/GameInfo' +import { GameBoard } from 'src/components/Board/GameBoard' +import { PlayerInfo } from 'src/components/Common/PlayerInfo' +import { MovesContainer } from 'src/components/Board/MovesContainer' +import { LiveGame, GameNode, BroadcastStreamController } from 'src/types' +import { BoardController } from 'src/components/Board/BoardController' +import { PromotionOverlay } from 'src/components/Board/PromotionOverlay' +import { AnalysisSidebar } from 'src/components/Analysis' +import { ConfigurableScreens } from 'src/components/Analysis/ConfigurableScreens' +import { BroadcastGameList } from 'src/components/Analysis/BroadcastGameList' +import { useAnalysisController } from 'src/hooks/useAnalysisController' + +interface Props { + game: LiveGame + broadcastController: BroadcastStreamController & { + currentLiveGame: LiveGame | null + } + analysisController: ReturnType +} + +export const BroadcastAnalysis: React.FC = ({ + game, + broadcastController, + analysisController, +}) => { + const { width } = useContext(WindowSizeContext) + const isMobile = useMemo(() => width > 0 && width <= 670, [width]) + + const [hoverArrow, setHoverArrow] = useState(null) + const [currentSquare, setCurrentSquare] = useState(null) + const [promotionFromTo, setPromotionFromTo] = useState< + [string, string] | null + >(null) + + useEffect(() => { + setHoverArrow(null) + }, [analysisController.currentNode]) + + const hover = (move?: string) => { + if (move) { + setHoverArrow({ + orig: move.slice(0, 2) as Key, + dest: move.slice(2, 4) as Key, + brush: 'green', + modifiers: { + lineWidth: 10, + }, + }) + } else { + setHoverArrow(null) + } + } + + const makeMove = (move: string) => { + if (!analysisController.currentNode || !game.tree) return + + const chess = new Chess(analysisController.currentNode.fen) + const moveAttempt = chess.move({ + from: move.slice(0, 2), + to: move.slice(2, 4), + promotion: move[4] ? (move[4] as PieceSymbol) : undefined, + }) + + if (moveAttempt) { + const newFen = chess.fen() + const moveString = + moveAttempt.from + + moveAttempt.to + + (moveAttempt.promotion ? moveAttempt.promotion : '') + const san = moveAttempt.san + + if (analysisController.currentNode.mainChild?.move === moveString) { + analysisController.goToNode(analysisController.currentNode.mainChild) + } else { + const newVariation = game.tree.addVariation( + analysisController.currentNode, + newFen, + moveString, + san, + analysisController.currentMaiaModel, + ) + analysisController.goToNode(newVariation) + } + } + } + + const onPlayerMakeMove = useCallback( + (playedMove: [string, string] | null) => { + if (!playedMove) return + + const availableMoves: { from: string; to: string }[] = [] + for (const [from, tos] of analysisController.availableMoves.entries()) { + for (const to of tos as string[]) { + availableMoves.push({ from, to }) + } + } + + const matching = availableMoves.filter((m) => { + return m.from === playedMove[0] && m.to === playedMove[1] + }) + + if (matching.length > 1) { + setPromotionFromTo(playedMove) + return + } + + const moveUci = playedMove[0] + playedMove[1] + makeMove(moveUci) + }, + [analysisController.availableMoves], + ) + + const onPlayerSelectPromotion = useCallback( + (piece: string) => { + if (!promotionFromTo) { + return + } + setPromotionFromTo(null) + const moveUci = promotionFromTo[0] + promotionFromTo[1] + piece + makeMove(moveUci) + }, + [promotionFromTo, setPromotionFromTo], + ) + + const launchContinue = useCallback(() => { + const fen = analysisController.currentNode?.fen as string + const url = '/play' + '?fen=' + encodeURIComponent(fen) + window.open(url) + }, [analysisController.currentNode]) + + const currentPlayer = useMemo(() => { + if (!analysisController.currentNode) return 'white' + const chess = new Chess(analysisController.currentNode.fen) + return chess.turn() === 'w' ? 'white' : 'black' + }, [analysisController.currentNode]) + + const NestedGameInfo = () => ( +
+
+ {[game.whitePlayer, game.blackPlayer].map((player, index) => ( +
+
+
+

{player.name}

+ + {player.rating ? <>({player.rating}) : null} + +
+ {game.termination?.winner === (index == 0 ? 'white' : 'black') ? ( +

1

+ ) : game.termination?.winner !== 'none' ? ( +

0

+ ) : game.termination === undefined ? ( + <> + ) : ( +

½

+ )} +
+ ))} +
+ + {broadcastController.currentBroadcast?.tour.name} + {broadcastController.currentRound && ( + <> • {broadcastController.currentRound.name} + )} + +
+
+
+
+
+ {game.whitePlayer.name} + {game.whitePlayer.rating && ( + ({game.whitePlayer.rating}) + )} +
+
+ {broadcastController.broadcastState.isLive && !game.termination ? ( + LIVE + ) : game.termination?.winner === 'none' ? ( + ½-½ + ) : ( + + + {game.termination?.winner === 'white' ? '1' : '0'} + + - + + {game.termination?.winner === 'black' ? '1' : '0'} + + + )} +
+
+
+ {game.blackPlayer.name} + {game.blackPlayer.rating && ( + ({game.blackPlayer.rating}) + )} +
+
+
+ ) + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + duration: 0.2, + staggerChildren: 0.05, + }, + }, + } + + const itemVariants = { + hidden: { + opacity: 0, + y: 4, + }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.25, + ease: [0.25, 0.46, 0.45, 0.94], + type: 'tween', + }, + }, + exit: { + opacity: 0, + y: -4, + transition: { + duration: 0.2, + ease: [0.25, 0.46, 0.45, 0.94], + type: 'tween', + }, + }, + } + + const desktopLayout = ( + +
+ + + + +
+
+ +
+
+
+ +
+ +
+ { + const baseShapes = [...analysisController.arrows] + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + return baseShapes + })()} + currentNode={analysisController.currentNode as GameNode} + orientation={analysisController.orientation} + onPlayerMakeMove={onPlayerMakeMove} + goToNode={analysisController.goToNode} + gameTree={game.tree} + /> + {promotionFromTo ? ( + + ) : null} +
+ +
+ +
+ { + // Analysis toggle not needed for broadcast - always enabled + }} + itemVariants={itemVariants} + /> +
+
+ ) + + const mobileLayout = ( + +
+ + + + +
+ { + const baseShapes = [...analysisController.arrows] + if (hoverArrow) { + baseShapes.push(hoverArrow) + } + return baseShapes + })()} + currentNode={analysisController.currentNode as GameNode} + orientation={analysisController.orientation} + onPlayerMakeMove={onPlayerMakeMove} + goToNode={analysisController.goToNode} + gameTree={game.tree} + /> + {promotionFromTo ? ( + + ) : null} +
+
+
+ +
+
+ +
+
+ +
+
+
+ ) + + return
{isMobile ? mobileLayout : desktopLayout}
+} diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx new file mode 100644 index 00000000..8101de4d --- /dev/null +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -0,0 +1,219 @@ +import React, { useState, useMemo } from 'react' +import { motion } from 'framer-motion' +import { BroadcastStreamController, BroadcastGame } from 'src/types' + +interface BroadcastGameListProps { + broadcastController: BroadcastStreamController + onGameSelected?: () => void +} + +export const BroadcastGameList: React.FC = ({ + broadcastController, + onGameSelected, +}) => { + const [selectedRoundId, setSelectedRoundId] = useState( + broadcastController.currentRound?.id || '', + ) + + const handleRoundChange = (roundId: string) => { + setSelectedRoundId(roundId) + broadcastController.selectRound(roundId) + } + + const handleGameSelect = (game: BroadcastGame) => { + broadcastController.selectGame(game.id) + onGameSelected?.() + } + + const currentGames = useMemo(() => { + if (!broadcastController.roundData?.games) return [] + return Array.from(broadcastController.roundData.games.values()) + }, [broadcastController.roundData?.games]) + + const getGameStatus = (game: BroadcastGame) => { + if (game.result === '*') { + return { status: 'Live', color: 'text-red-400' } + } else if (game.result === '1-0') { + return { status: '1-0', color: 'text-primary' } + } else if (game.result === '0-1') { + return { status: '0-1', color: 'text-primary' } + } else if (game.result === '1/2-1/2') { + return { status: '½-½', color: 'text-primary' } + } + return { status: game.result, color: 'text-secondary' } + } + + const formatPlayerName = (name: string, elo?: number) => { + const displayName = name.length > 12 ? name.substring(0, 12) + '...' : name + return elo ? `${displayName} (${elo})` : displayName + } + + return ( +
+
+
+

+ {broadcastController.currentBroadcast?.tour.name || + 'Live Broadcast'} +

+ {broadcastController.broadcastState.isLive && ( +
+
+ LIVE +
+ )} +
+ + {/* Round Selector */} + {broadcastController.currentBroadcast && ( +
+ + +
+ )} + + {/* Connection Status */} + {broadcastController.broadcastState.error && ( +
+
+ Connection Error + +
+
+ )} + + {broadcastController.broadcastState.isConnecting && ( +
+
+
+ Connecting... +
+
+ )} +
+ +
+ {currentGames.length === 0 ? ( +
+
+ + live_tv + +

+ {broadcastController.broadcastState.isConnecting + ? 'Loading games...' + : 'No games available'} +

+
+
+ ) : ( + <> + {currentGames.map((game, index) => { + const isSelected = broadcastController.currentGame?.id === game.id + const gameStatus = getGameStatus(game) + + return ( +
+
+

{index + 1}

+
+ +
+ ) + })} + + )} +
+ + {/* Footer with broadcast info */} +
+
+

+ Watch on{' '} + + Lichess + +

+
+
+
+ ) +} diff --git a/src/components/Analysis/index.ts b/src/components/Analysis/index.ts index fc650a15..6a548c15 100644 --- a/src/components/Analysis/index.ts +++ b/src/components/Analysis/index.ts @@ -13,3 +13,5 @@ export * from './AnalysisOverlay' export * from './InteractiveDescription' export * from './AnalysisSidebar' export * from './LearnFromMistakes' +export * from './BroadcastGameList' +export * from './BroadcastAnalysis' diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 11ac3dbb..ed96c7cb 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,5 +1,6 @@ export * from './useAnalysisController' export * from './useBaseTreeController' +export * from './useBroadcastController' export * from './useChessSound' export * from './useLocalStorage' export * from './useOpeningDrillController' diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts new file mode 100644 index 00000000..d580fb99 --- /dev/null +++ b/src/hooks/useBroadcastController.ts @@ -0,0 +1,411 @@ +import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { Chess } from 'chess.ts' +import { GameTree } from 'src/types/base/tree' +import { AvailableMoves } from 'src/types/training' +import { + Broadcast, + BroadcastRound, + BroadcastGame, + BroadcastRoundData, + BroadcastState, + BroadcastStreamController, + LiveGame, +} from 'src/types' +import { + getLichessBroadcasts, + streamBroadcastRound, + parsePGNData, +} from 'src/api/lichess/broadcasts' + +export const useBroadcastController = (): BroadcastStreamController => { + const [broadcasts, setBroadcasts] = useState([]) + const [currentBroadcast, setCurrentBroadcast] = useState( + null, + ) + const [currentRound, setCurrentRound] = useState(null) + const [currentGame, setCurrentGame] = useState(null) + const [roundData, setRoundData] = useState(null) + const [broadcastState, setBroadcastState] = useState({ + isConnected: false, + isConnecting: false, + isLive: false, + error: null, + roundStarted: false, + roundEnded: false, + gameEnded: false, + }) + + const abortController = useRef(null) + const currentRoundId = useRef(null) + const gameStates = useRef>(new Map()) + const lastPGNData = useRef('') + + const loadBroadcasts = useCallback(async () => { + try { + setBroadcastState((prev) => ({ + ...prev, + isConnecting: true, + error: null, + })) + const broadcastList = await getLichessBroadcasts() + setBroadcasts(broadcastList) + setBroadcastState((prev) => ({ ...prev, isConnecting: false })) + } catch (error) { + console.error('Error loading broadcasts:', error) + setBroadcastState((prev) => ({ + ...prev, + isConnecting: false, + error: + error instanceof Error ? error.message : 'Failed to load broadcasts', + })) + } + }, []) + + const selectBroadcast = useCallback( + (broadcastId: string) => { + const broadcast = broadcasts.find((b) => b.tour.id === broadcastId) + if (broadcast) { + setCurrentBroadcast(broadcast) + // Auto-select default round if available + const defaultRound = + broadcast.rounds.find((r) => r.id === broadcast.defaultRoundId) || + broadcast.rounds.find((r) => r.ongoing) || + broadcast.rounds[0] + if (defaultRound) { + setCurrentRound(defaultRound) + } + } + }, + [broadcasts], + ) + + const selectRound = useCallback( + (roundId: string) => { + if (currentBroadcast) { + const round = currentBroadcast.rounds.find((r) => r.id === roundId) + if (round) { + setCurrentRound(round) + // Stop current stream if different round + if (currentRoundId.current !== roundId) { + stopRoundStream() + } + } + } + }, + [currentBroadcast], + ) + + const selectGame = useCallback( + (gameId: string) => { + if (roundData) { + const game = roundData.games.get(gameId) + if (game) { + setCurrentGame(game) + } + } + }, + [roundData], + ) + + const stopRoundStream = useCallback(() => { + if (abortController.current) { + abortController.current.abort() + abortController.current = null + } + + setBroadcastState({ + isConnected: false, + isConnecting: false, + isLive: false, + error: null, + roundStarted: false, + roundEnded: false, + gameEnded: false, + }) + + currentRoundId.current = null + gameStates.current.clear() + lastPGNData.current = '' + }, []) + + const createLiveGameFromBroadcastGame = useCallback( + (broadcastGame: BroadcastGame): LiveGame => { + const startingFen = + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' + + // Build game tree from moves + const tree = new GameTree(startingFen) + const chess = new Chess(startingFen) + let currentNode = tree.getRoot() + + const gameStates = [ + { + board: startingFen, + lastMove: undefined as [string, string] | undefined, + san: undefined as string | undefined, + check: false, + maia_values: {}, + }, + ] + + // Process each move + for (const moveStr of broadcastGame.moves) { + try { + const move = chess.move(moveStr) + if (move) { + const newFen = chess.fen() + const uci = + move.from + move.to + (move.promotion ? move.promotion : '') + + gameStates.push({ + board: newFen, + lastMove: [move.from, move.to], + san: move.san, + check: chess.inCheck(), + maia_values: {}, + }) + + currentNode = tree.addMainMove(currentNode, newFen, uci, move.san) + } + } catch (error) { + console.warn(`Error processing move ${moveStr}:`, error) + break + } + } + + return { + id: broadcastGame.id, + blackPlayer: { + name: broadcastGame.black, + rating: broadcastGame.blackElo, + }, + whitePlayer: { + name: broadcastGame.white, + rating: broadcastGame.whiteElo, + }, + gameType: 'broadcast', + type: 'stream' as const, + moves: gameStates, + availableMoves: new Array(gameStates.length).fill( + {}, + ) as AvailableMoves[], + termination: + broadcastGame.result === '*' + ? undefined + : { + result: broadcastGame.result, + winner: + broadcastGame.result === '1-0' + ? 'white' + : broadcastGame.result === '0-1' + ? 'black' + : 'none', + }, + maiaEvaluations: [], + stockfishEvaluations: [], + loadedFen: broadcastGame.fen, + loaded: true, + tree, + } as LiveGame + }, + [], + ) + + const handlePGNUpdate = useCallback( + (pgnData: string) => { + // Skip if it's the same data we already processed + if (pgnData === lastPGNData.current) { + return + } + + lastPGNData.current = pgnData + + const parseResult = parsePGNData(pgnData) + + if (parseResult.errors.length > 0) { + console.warn('PGN parsing errors:', parseResult.errors) + } + + if (parseResult.games.length === 0) { + return + } + + // Update round data + const newGames = new Map() + const updatedGameStates = new Map() + let hasNewMoves = false + + for (const game of parseResult.games) { + newGames.set(game.id, game) + + // Check if this game has new moves compared to our stored state + const existingGameState = gameStates.current.get(game.id) + const newLiveGame = createLiveGameFromBroadcastGame(game) + + if ( + !existingGameState || + existingGameState.moves.length !== newLiveGame.moves.length + ) { + hasNewMoves = true + + // Play sound for new moves if game was already loaded + if ( + existingGameState && + newLiveGame.moves.length > existingGameState.moves.length + ) { + try { + const audio = new Audio('/assets/sound/move.mp3') + audio + .play() + .catch((e) => console.log('Could not play move sound:', e)) + } catch (e) {} + } + } + + updatedGameStates.set(game.id, newLiveGame) + } + + gameStates.current = updatedGameStates + + setRoundData((prev) => { + const newRoundData: BroadcastRoundData = { + roundId: currentRoundId.current || '', + broadcastId: currentBroadcast?.tour.id || '', + games: newGames, + lastUpdate: Date.now(), + } + return newRoundData + }) + + // Update current game if it exists in the new data + if (currentGame) { + const updatedCurrentGame = newGames.get(currentGame.id) + if (updatedCurrentGame) { + setCurrentGame(updatedCurrentGame) + } + } else if (parseResult.games.length > 0) { + // Auto-select first game if none selected + setCurrentGame(parseResult.games[0]) + } + + // Update broadcast state + setBroadcastState((prev) => ({ + ...prev, + isConnected: true, + isConnecting: false, + isLive: true, + roundStarted: true, + error: null, + })) + }, + [currentBroadcast, currentGame, createLiveGameFromBroadcastGame], + ) + + const handleStreamComplete = useCallback(() => { + setBroadcastState((prev) => ({ + ...prev, + isConnected: false, + isLive: false, + roundEnded: true, + gameEnded: true, + })) + }, []) + + const startRoundStream = useCallback( + async (roundId: string) => { + if (abortController.current) { + abortController.current.abort() + } + + abortController.current = new AbortController() + currentRoundId.current = roundId + + setBroadcastState((prev) => ({ + ...prev, + isConnecting: true, + error: null, + })) + + try { + await streamBroadcastRound( + roundId, + handlePGNUpdate, + handleStreamComplete, + abortController.current.signal, + ) + } catch (error) { + console.error('Round stream error:', error) + + const errorMessage = + error instanceof Error ? error.message : 'Unknown streaming error' + + setBroadcastState({ + isConnected: false, + isConnecting: false, + isLive: false, + error: errorMessage, + roundStarted: false, + roundEnded: false, + gameEnded: false, + }) + + abortController.current = null + } + }, + [handlePGNUpdate, handleStreamComplete], + ) + + const reconnect = useCallback(() => { + if (currentRoundId.current) { + startRoundStream(currentRoundId.current) + } + }, [startRoundStream]) + + // Auto-start stream when round is selected and ongoing + useEffect(() => { + if ( + currentRound?.ongoing && + !broadcastState.isConnecting && + !broadcastState.isConnected + ) { + startRoundStream(currentRound.id) + } + }, [ + currentRound, + broadcastState.isConnecting, + broadcastState.isConnected, + startRoundStream, + ]) + + // Cleanup on unmount + useEffect(() => { + return () => { + stopRoundStream() + } + }, [stopRoundStream]) + + // Get current live game state for the selected game + const currentLiveGame = useMemo(() => { + if (currentGame && gameStates.current.has(currentGame.id)) { + return gameStates.current.get(currentGame.id) || null + } + return null + }, [currentGame, roundData?.lastUpdate]) + + return { + broadcasts, + currentBroadcast, + currentRound, + currentGame, + roundData, + broadcastState, + loadBroadcasts, + selectBroadcast, + selectRound, + selectGame, + startRoundStream, + stopRoundStream, + reconnect, + currentLiveGame, + } as BroadcastStreamController & { currentLiveGame: LiveGame | null } +} diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx new file mode 100644 index 00000000..ac99cc4d --- /dev/null +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -0,0 +1,271 @@ +import React, { useEffect, useState, useMemo, useRef } from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import Head from 'next/head' +import { AnimatePresence } from 'framer-motion' + +import { DelayedLoading } from 'src/components' +import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' +import { DownloadModelModal } from 'src/components/Common/DownloadModelModal' +import { useBroadcastController } from 'src/hooks/useBroadcastController' +import { useAnalysisController } from 'src/hooks' +import { TreeControllerContext } from 'src/contexts' +import { BroadcastAnalysis } from 'src/components/Analysis/BroadcastAnalysis' +import { AnalyzedGame, BroadcastStreamController, LiveGame } from 'src/types' +import { GameTree } from 'src/types/base/tree' + +const BroadcastAnalysisPage: NextPage = () => { + const router = useRouter() + const { broadcastId, roundId } = router.query as { + broadcastId: string + roundId: string + } + + const broadcastController = useBroadcastController() + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + const initializeBroadcast = async () => { + if (!broadcastId || !roundId) return + + try { + setLoading(true) + setError(null) + + // Load broadcasts if not already loaded + if (broadcastController.broadcasts.length === 0) { + await broadcastController.loadBroadcasts() + } + + // Find and select the broadcast + const broadcast = broadcastController.broadcasts.find( + (b) => b.tour.id === broadcastId, + ) + + if (!broadcast) { + throw new Error('Broadcast not found') + } + + // Find the round + const round = broadcast.rounds.find((r) => r.id === roundId) + if (!round) { + throw new Error('Round not found') + } + + // Select broadcast and round + broadcastController.selectBroadcast(broadcastId) + broadcastController.selectRound(roundId) + + setLoading(false) + } catch (err) { + console.error('Error initializing broadcast:', err) + setError( + err instanceof Error ? err.message : 'Failed to load broadcast', + ) + setLoading(false) + } + } + + initializeBroadcast() + }, [broadcastId, roundId, broadcastController.broadcasts.length]) + + // Create a dummy game for analysis controller when no game is selected + const dummyGame: AnalyzedGame = useMemo(() => { + const startingFen = + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' + const dummyTree = new GameTree(startingFen) + + return { + id: '', + blackPlayer: { name: 'Black' }, + whitePlayer: { name: 'White' }, + moves: [ + { + board: startingFen, + lastMove: undefined, + san: undefined, + check: false, + maia_values: {}, + }, + ], + availableMoves: [{}], + gameType: 'broadcast', + termination: { result: '*', winner: undefined }, + maiaEvaluations: [{}], + stockfishEvaluations: [undefined], + tree: dummyTree, + type: 'stream' as const, + } + }, []) + + const currentGame = (broadcastController as any).currentLiveGame || dummyGame + const analysisController = useAnalysisController( + currentGame, + undefined, + false, + ) + + // Auto-follow live moves for the selected game + const lastGameMoveCount = useRef(0) + + useEffect(() => { + const currentLiveGame = (broadcastController as any).currentLiveGame + if (currentLiveGame?.tree && analysisController) { + try { + const mainLine = currentLiveGame.tree.getMainLine() + const currentMoveCount = mainLine.length + + // If new moves have been added to the game + if (currentMoveCount > lastGameMoveCount.current) { + lastGameMoveCount.current = currentMoveCount + + // Find the last node in the main line + let lastNode = currentLiveGame.tree.getRoot() + while (lastNode.mainChild) { + lastNode = lastNode.mainChild + } + + // Only auto-follow if user is currently at the previous last node + if ( + analysisController.currentNode && + lastNode.parent === analysisController.currentNode + ) { + analysisController.setCurrentNode(lastNode) + } + } + } catch (error) { + console.error('Error setting current node:', error) + } + } + }, [(broadcastController as any).currentLiveGame, analysisController]) + + // When we select a new game, set the current node to the last move + useEffect(() => { + const currentLiveGame = (broadcastController as any).currentLiveGame + if (currentLiveGame?.loaded) { + const mainLine = currentLiveGame.tree.getMainLine() + if (mainLine.length > 0) { + analysisController.setCurrentNode(mainLine[mainLine.length - 1]) + } + } + }, [broadcastController.currentGame?.id]) + + const pageTitle = useMemo(() => { + if ( + broadcastController.currentBroadcast && + broadcastController.currentRound + ) { + return `${broadcastController.currentBroadcast.tour.name} • ${broadcastController.currentRound.name} – Maia Chess` + } + return 'Live Broadcast – Maia Chess' + }, [broadcastController.currentBroadcast, broadcastController.currentRound]) + + const pageDescription = useMemo(() => { + if (broadcastController.currentBroadcast) { + return `Watch ${broadcastController.currentBroadcast.tour.name} live with real-time Maia AI analysis.` + } + return 'Watch live chess broadcasts with real-time Maia AI analysis.' + }, [broadcastController.currentBroadcast]) + + if (loading) { + return ( + <> + + Loading Broadcast – Maia Chess + + +
+
+

Loading Broadcast

+

Connecting to live tournament...

+
+
+
+ + ) + } + + if (error) { + return ( + <> + + Broadcast Error – Maia Chess + +
+
+

+ Error Loading Broadcast +

+

{error}

+
+ + +
+
+
+ + ) + } + + return ( + <> + + {pageTitle} + + + + + {analysisController && + (analysisController.maia.status === 'no-cache' || + analysisController.maia.status === 'downloading') ? ( + + ) : null} + + + + {!(broadcastController as any).currentLiveGame?.loaded && + broadcastController.currentGame && + !broadcastController.broadcastState.roundEnded ? ( +
+ +

Loading game...

+
+
+ ) : null} + {analysisController && ( + + )} +
+ + ) +} + +export default function AuthenticatedBroadcastAnalysisPage() { + return ( + + + + ) +} diff --git a/src/pages/broadcast/index.tsx b/src/pages/broadcast/index.tsx new file mode 100644 index 00000000..67a02538 --- /dev/null +++ b/src/pages/broadcast/index.tsx @@ -0,0 +1,290 @@ +import React, { useEffect, useState } from 'react' +import { NextPage } from 'next' +import { useRouter } from 'next/router' +import Head from 'next/head' +import { motion } from 'framer-motion' + +import { DelayedLoading } from 'src/components' +import { AuthenticatedWrapper } from 'src/components/Common/AuthenticatedWrapper' +import { useBroadcastController } from 'src/hooks/useBroadcastController' +import { Broadcast } from 'src/types' + +const BroadcastsPage: NextPage = () => { + const router = useRouter() + const broadcastController = useBroadcastController() + const [loading, setLoading] = useState(true) + + useEffect(() => { + const loadData = async () => { + try { + await broadcastController.loadBroadcasts() + } catch (error) { + console.error('Error loading broadcasts:', error) + } finally { + setLoading(false) + } + } + + loadData() + }, []) + + const handleSelectBroadcast = (broadcast: Broadcast) => { + const defaultRound = + broadcast.rounds.find((r) => r.id === broadcast.defaultRoundId) || + broadcast.rounds.find((r) => r.ongoing) || + broadcast.rounds[0] + + if (defaultRound) { + router.push(`/broadcast/${broadcast.tour.id}/${defaultRound.id}`) + } + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + } + + const containerVariants = { + hidden: { opacity: 0 }, + visible: { + opacity: 1, + transition: { + duration: 0.3, + staggerChildren: 0.1, + }, + }, + } + + const itemVariants = { + hidden: { + opacity: 0, + y: 20, + }, + visible: { + opacity: 1, + y: 0, + transition: { + duration: 0.4, + ease: [0.25, 0.46, 0.45, 0.94], + }, + }, + } + + if (loading) { + return ( + <> + + Live Broadcasts – Maia Chess + + + +
+
+

+ Loading Live Broadcasts +

+

Fetching ongoing tournaments...

+
+
+
+ + ) + } + + if (broadcastController.broadcastState.error) { + return ( + <> + + Live Broadcasts – Maia Chess + +
+
+

+ Error Loading Broadcasts +

+

+ {broadcastController.broadcastState.error} +

+ +
+
+ + ) + } + + return ( + <> + + Live Broadcasts – Maia Chess + + + +
+ + +

+ Live Broadcasts +

+

+ Watch ongoing chess tournaments with real-time Maia AI analysis +

+
+ + {broadcastController.broadcasts.length === 0 ? ( + + + live_tv + +

+ No Live Broadcasts +

+

+ There are currently no ongoing tournaments available. +

+ +
+ ) : ( +
+ {broadcastController.broadcasts.map((broadcast, index) => { + const ongoingRounds = broadcast.rounds.filter((r) => r.ongoing) + const hasOngoingRounds = ongoingRounds.length > 0 + + return ( + +
+
+
+

+ {broadcast.tour.name} +

+ {hasOngoingRounds && ( +
+
+ + LIVE + +
+ )} +
+
+
+ Tier {broadcast.tour.tier} +
+ {broadcast.tour.dates.length > 0 && ( +
+ {formatDate(broadcast.tour.dates[0])} +
+ )} +
+
+ +
+
+ Rounds ({broadcast.rounds.length}) +
+
+ {broadcast.rounds.slice(0, 3).map((round) => ( +
+ + {round.name} + + + {round.ongoing ? 'Live' : 'Finished'} + +
+ ))} + {broadcast.rounds.length > 3 && ( +
+ +{broadcast.rounds.length - 3} more rounds +
+ )} +
+
+ + +
+
+ ) + })} +
+ )} + + +

+ Broadcasts powered by{' '} + + Lichess + +

+
+
+
+ + ) +} + +export default function AuthenticatedBroadcastsPage() { + return ( + + + + ) +} diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts new file mode 100644 index 00000000..77f1e916 --- /dev/null +++ b/src/types/broadcast/index.ts @@ -0,0 +1,92 @@ +export interface BroadcastTour { + id: string + name: string + slug: string + info: Record + createdAt: number + url: string + tier: number + dates: number[] +} + +export interface BroadcastRound { + id: string + name: string + slug: string + createdAt: number + ongoing: boolean + startsAt: number + rated: boolean + url: string +} + +export interface Broadcast { + tour: BroadcastTour + rounds: BroadcastRound[] + defaultRoundId: string +} + +export interface BroadcastGame { + id: string + white: string + black: string + result: string + moves: string[] + pgn: string + fen: string + lastMove?: [string, string] + event: string + site: string + date: string + round: string + eco?: string + opening?: string + whiteElo?: number + blackElo?: number + timeControl?: string + termination?: string + annotator?: string + studyName?: string + chapterName?: string + utcDate?: string + utcTime?: string +} + +export interface BroadcastRoundData { + roundId: string + broadcastId: string + games: Map + lastUpdate: number +} + +export interface BroadcastState { + isConnected: boolean + isConnecting: boolean + isLive: boolean + error: string | null + roundStarted: boolean + roundEnded: boolean + gameEnded: boolean +} + +export interface BroadcastStreamController { + broadcasts: Broadcast[] + currentBroadcast: Broadcast | null + currentRound: BroadcastRound | null + currentGame: BroadcastGame | null + currentLiveGame: any | null + roundData: BroadcastRoundData | null + broadcastState: BroadcastState + loadBroadcasts: () => Promise + selectBroadcast: (broadcastId: string) => void + selectRound: (roundId: string) => void + selectGame: (gameId: string) => void + startRoundStream: (roundId: string) => void + stopRoundStream: () => void + reconnect: () => void +} + +export interface PGNParseResult { + games: BroadcastGame[] + errors: string[] +} diff --git a/src/types/index.ts b/src/types/index.ts index 525df138..f4ce8634 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,3 +9,4 @@ export * from './modal' export * from './blog' export * from './leaderboard' export * from './stream' +export * from './broadcast' From 8724cff099c32a23c51e011910f95adf5bed5e00 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 22:39:00 -0400 Subject: [PATCH 02/17] feat: include unofficial broadcasts --- src/api/lichess/broadcasts.ts | 56 +++-- src/hooks/useBroadcastController.ts | 104 +++++++++- src/pages/_app.tsx | 1 + .../broadcast/[broadcastId]/[roundId].tsx | 25 ++- src/pages/broadcast/index.tsx | 193 +++++++++++------- src/types/broadcast/index.ts | 32 ++- 6 files changed, 307 insertions(+), 104 deletions(-) diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts index daba0f46..ee5c9483 100644 --- a/src/api/lichess/broadcasts.ts +++ b/src/api/lichess/broadcasts.ts @@ -1,4 +1,10 @@ -import { Broadcast, BroadcastGame, PGNParseResult } from 'src/types' +import { + Broadcast, + BroadcastGame, + PGNParseResult, + TopBroadcastsResponse, + TopBroadcastItem, +} from 'src/types' const readStream = (processLine: (data: any) => void) => (response: any) => { const stream = response.body.getReader() @@ -61,6 +67,31 @@ export const getLichessBroadcasts = async (): Promise => { }) } +export const getLichessTopBroadcasts = + async (): Promise => { + const response = await fetch('https://lichess.org/api/broadcast/top', { + headers: { + Accept: 'application/json', + }, + }) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return response.json() + } + +export const convertTopBroadcastToBroadcast = ( + item: TopBroadcastItem, +): Broadcast => { + return { + tour: item.tour, + rounds: [item.round], + defaultRoundId: item.round.id, + } +} + export const streamBroadcastRound = async ( roundId: string, onPGNUpdate: (pgn: string) => void, @@ -202,7 +233,7 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => { // Parse moves from moves section const moves = parseMovesFromPGN(movesSection) - const fen = extractFENFromMoves(moves) + const fen = extractFENFromMoves() const game: BroadcastGame = { id: generateGameId(white, black, event, site), @@ -229,12 +260,8 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => { utcTime: headers.UTCTime, } - // Extract last move if available - if (moves.length > 0) { - const lastMove = moves[moves.length - 1] - // This would need proper move parsing to convert SAN to UCI - // For now, we'll leave it undefined and handle in the controller - } + // Note: Last move extraction would need proper move parsing to convert SAN to UCI + // For now, we'll leave it undefined and handle in the controller return game } @@ -276,7 +303,7 @@ const parseMovesFromPGN = (movesSection: string): string[] => { return moves } -const extractFENFromMoves = (moves: string[]): string | null => { +const extractFENFromMoves = (): string | null => { // This would require a full chess engine to calculate the FEN from moves // For now, return null and handle in the controller with chess.js return null @@ -289,7 +316,12 @@ const generateGameId = ( site: string, ): string => { const baseString = `${white}-${black}-${event}-${site}` - return btoa(baseString) - .replace(/[^a-zA-Z0-9]/g, '') - .substring(0, 12) + // Use a simple hash instead of deprecated btoa for better compatibility + let hash = 0 + for (let i = 0; i < baseString.length; i++) { + const char = baseString.charCodeAt(i) + hash = (hash << 5) - hash + char + hash = hash & hash // Convert to 32bit integer + } + return Math.abs(hash).toString(36).substring(0, 12) } diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index d580fb99..38651835 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -9,16 +9,21 @@ import { BroadcastRoundData, BroadcastState, BroadcastStreamController, + BroadcastSection, LiveGame, } from 'src/types' import { getLichessBroadcasts, + getLichessTopBroadcasts, + convertTopBroadcastToBroadcast, streamBroadcastRound, parsePGNData, } from 'src/api/lichess/broadcasts' export const useBroadcastController = (): BroadcastStreamController => { - const [broadcasts, setBroadcasts] = useState([]) + const [broadcastSections, setBroadcastSections] = useState< + BroadcastSection[] + >([]) const [currentBroadcast, setCurrentBroadcast] = useState( null, ) @@ -47,8 +52,90 @@ export const useBroadcastController = (): BroadcastStreamController => { isConnecting: true, error: null, })) - const broadcastList = await getLichessBroadcasts() - setBroadcasts(broadcastList) + + // Load both official and top broadcasts concurrently + const [officialBroadcasts, topBroadcasts] = await Promise.all([ + getLichessBroadcasts(), + getLichessTopBroadcasts(), + ]) + + // Organize broadcasts into sections + const sections: BroadcastSection[] = [] + + // Official active broadcasts + const officialActive = officialBroadcasts.filter((b) => + b.rounds.some((r) => r.ongoing), + ) + if (officialActive.length > 0) { + sections.push({ + title: 'Official Live Tournaments', + broadcasts: officialActive, + type: 'official-active', + }) + } + + // Top active broadcasts (unofficial) + const unofficialActive = topBroadcasts.active + .map(convertTopBroadcastToBroadcast) + .filter( + (b) => + !officialActive.some((official) => official.tour.id === b.tour.id), + ) + if (unofficialActive.length > 0) { + sections.push({ + title: 'Community Live Broadcasts', + broadcasts: unofficialActive, + type: 'unofficial-active', + }) + } + + // Official upcoming broadcasts + const officialUpcoming = officialBroadcasts.filter( + (b) => + b.rounds.every((r) => !r.ongoing) && + b.rounds.some((r) => r.startsAt > Date.now()), + ) + if (officialUpcoming.length > 0) { + sections.push({ + title: 'Upcoming Official Tournaments', + broadcasts: officialUpcoming, + type: 'official-upcoming', + }) + } + + // Top upcoming broadcasts (unofficial) + const unofficialUpcoming = topBroadcasts.upcoming.map( + convertTopBroadcastToBroadcast, + ) + if (unofficialUpcoming.length > 0) { + sections.push({ + title: 'Upcoming Community Broadcasts', + broadcasts: unofficialUpcoming, + type: 'unofficial-upcoming', + }) + } + + // Past broadcasts (mix of official and top) + const officialPast = officialBroadcasts.filter( + (b) => + b.rounds.every((r) => !r.ongoing) && + b.rounds.every((r) => r.startsAt <= Date.now()), + ) + const pastBroadcasts = [ + ...officialPast, + ...topBroadcasts.past.currentPageResults.map( + convertTopBroadcastToBroadcast, + ), + ] + if (pastBroadcasts.length > 0) { + sections.push({ + title: 'Recent Tournaments', + broadcasts: pastBroadcasts, + type: 'past', + }) + } + + setBroadcastSections(sections) setBroadcastState((prev) => ({ ...prev, isConnecting: false })) } catch (error) { console.error('Error loading broadcasts:', error) @@ -63,7 +150,12 @@ export const useBroadcastController = (): BroadcastStreamController => { const selectBroadcast = useCallback( (broadcastId: string) => { - const broadcast = broadcasts.find((b) => b.tour.id === broadcastId) + // Find broadcast across all sections + let broadcast: Broadcast | undefined + for (const section of broadcastSections) { + broadcast = section.broadcasts.find((b) => b.tour.id === broadcastId) + if (broadcast) break + } if (broadcast) { setCurrentBroadcast(broadcast) // Auto-select default round if available @@ -76,7 +168,7 @@ export const useBroadcastController = (): BroadcastStreamController => { } } }, - [broadcasts], + [broadcastSections], ) const selectRound = useCallback( @@ -393,7 +485,7 @@ export const useBroadcastController = (): BroadcastStreamController => { }, [currentGame, roundData?.lastUpdate]) return { - broadcasts, + broadcastSections, currentBroadcast, currentRound, currentGame, diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 80eeee3a..746b09aa 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -44,6 +44,7 @@ function MaiaPlatform({ Component, pageProps }: AppProps) { '/openings', '/puzzles', '/settings', + '/broadcast', ].some((path) => router.pathname.includes(path)) useEffect(() => { diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx index ac99cc4d..ad04b1f5 100644 --- a/src/pages/broadcast/[broadcastId]/[roundId].tsx +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -11,7 +11,12 @@ import { useBroadcastController } from 'src/hooks/useBroadcastController' import { useAnalysisController } from 'src/hooks' import { TreeControllerContext } from 'src/contexts' import { BroadcastAnalysis } from 'src/components/Analysis/BroadcastAnalysis' -import { AnalyzedGame, BroadcastStreamController, LiveGame } from 'src/types' +import { + AnalyzedGame, + Broadcast, + BroadcastStreamController, + LiveGame, +} from 'src/types' import { GameTree } from 'src/types/base/tree' const BroadcastAnalysisPage: NextPage = () => { @@ -34,21 +39,25 @@ const BroadcastAnalysisPage: NextPage = () => { setError(null) // Load broadcasts if not already loaded - if (broadcastController.broadcasts.length === 0) { + if (broadcastController.broadcastSections.length === 0) { await broadcastController.loadBroadcasts() } - // Find and select the broadcast - const broadcast = broadcastController.broadcasts.find( - (b) => b.tour.id === broadcastId, - ) + // Find and select the broadcast across all sections + let broadcast: Broadcast | undefined + for (const section of broadcastController.broadcastSections) { + broadcast = section.broadcasts.find((b) => b.tour.id === broadcastId) + if (broadcast) break + } if (!broadcast) { throw new Error('Broadcast not found') } // Find the round - const round = broadcast.rounds.find((r) => r.id === roundId) + const round = broadcast.rounds.find( + (r: { id: string }) => r.id === roundId, + ) if (!round) { throw new Error('Round not found') } @@ -68,7 +77,7 @@ const BroadcastAnalysisPage: NextPage = () => { } initializeBroadcast() - }, [broadcastId, roundId, broadcastController.broadcasts.length]) + }, [broadcastId, roundId, broadcastController.broadcastSections.length]) // Create a dummy game for analysis controller when no game is selected const dummyGame: AnalyzedGame = useMemo(() => { diff --git a/src/pages/broadcast/index.tsx b/src/pages/broadcast/index.tsx index 67a02538..ff15a3c7 100644 --- a/src/pages/broadcast/index.tsx +++ b/src/pages/broadcast/index.tsx @@ -149,7 +149,7 @@ const BroadcastsPage: NextPage = () => {

- {broadcastController.broadcasts.length === 0 ? ( + {broadcastController.broadcastSections.length === 0 ? ( { ) : ( -
- {broadcastController.broadcasts.map((broadcast, index) => { - const ongoingRounds = broadcast.rounds.filter((r) => r.ongoing) - const hasOngoingRounds = ongoingRounds.length > 0 - - return ( +
+ {broadcastController.broadcastSections.map( + (section, sectionIndex) => ( -
-
-
-

- {broadcast.tour.name} -

- {hasOngoingRounds && ( -
-
- - LIVE - -
- )} -
-
-
- Tier {broadcast.tour.tier} -
- {broadcast.tour.dates.length > 0 && ( -
- {formatDate(broadcast.tour.dates[0])} -
- )} +

+ {section.title} + {(section.type === 'official-active' || + section.type === 'unofficial-active') && ( +
+
+ + LIVE +
-

+ )} + -
-
- Rounds ({broadcast.rounds.length}) -
-
- {broadcast.rounds.slice(0, 3).map((round) => ( -
- - {round.name} - - + {section.broadcasts.map((broadcast, index) => { + const ongoingRounds = broadcast.rounds.filter( + (r) => r.ongoing, + ) + const hasOngoingRounds = ongoingRounds.length > 0 + const isActive = section.type.includes('active') + const isPast = section.type === 'past' + + return ( + +
+
+
+

+ {broadcast.tour.name} +

+ {hasOngoingRounds && isActive && ( +
+
+ + LIVE + +
+ )} +
+
+
+ Tier {broadcast.tour.tier} +
+ {broadcast.tour.dates.length > 0 && ( +
+ {formatDate(broadcast.tour.dates[0])} +
+ )} +
+
+ +
+
+ Rounds ({broadcast.rounds.length}) +
+
+ {broadcast.rounds.slice(0, 3).map((round) => ( +
+ + {round.name} + + + {round.ongoing + ? 'Live' + : isPast + ? 'Finished' + : 'Upcoming'} + +
+ ))} + {broadcast.rounds.length > 3 && ( +
+ +{broadcast.rounds.length - 3} more rounds +
+ )} +
+
+ +
- ))} - {broadcast.rounds.length > 3 && ( -
- +{broadcast.rounds.length - 3} more rounds + {hasOngoingRounds + ? 'Watch Live' + : isPast + ? 'View Tournament' + : 'Coming Soon'} +
- )} -
-
- - + + ) + })}
- ) - })} + ), + )}
)} diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts index 77f1e916..77dee34e 100644 --- a/src/types/broadcast/index.ts +++ b/src/types/broadcast/index.ts @@ -69,12 +69,40 @@ export interface BroadcastState { gameEnded: boolean } -export interface BroadcastStreamController { +export interface TopBroadcastItem { + tour: BroadcastTour + round: BroadcastRound +} + +export interface TopBroadcastsResponse { + active: TopBroadcastItem[] + upcoming: TopBroadcastItem[] + past: { + currentPage: number + maxPerPage: number + currentPageResults: TopBroadcastItem[] + previousPage: number | null + nextPage: number | null + } +} + +export interface BroadcastSection { + title: string broadcasts: Broadcast[] + type: + | 'official-active' + | 'unofficial-active' + | 'official-upcoming' + | 'unofficial-upcoming' + | 'past' +} + +export interface BroadcastStreamController { + broadcastSections: BroadcastSection[] currentBroadcast: Broadcast | null currentRound: BroadcastRound | null currentGame: BroadcastGame | null - currentLiveGame: any | null + currentLiveGame: unknown | null roundData: BroadcastRoundData | null broadcastState: BroadcastState loadBroadcasts: () => Promise From 60246292e7ec64ea898073574e8dd452a60a508c Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 23:05:58 -0400 Subject: [PATCH 03/17] feat: support multiple games in rounds --- src/api/lichess/broadcasts.ts | 20 +++++++ src/components/Analysis/BroadcastAnalysis.tsx | 16 +++++- src/components/Analysis/BroadcastGameList.tsx | 26 ++++++++- src/hooks/useBroadcastController.ts | 55 +++++++++++-------- .../broadcast/[broadcastId]/[roundId].tsx | 3 +- 5 files changed, 91 insertions(+), 29 deletions(-) diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts index ee5c9483..39e25e61 100644 --- a/src/api/lichess/broadcasts.ts +++ b/src/api/lichess/broadcasts.ts @@ -92,6 +92,25 @@ export const convertTopBroadcastToBroadcast = ( } } +export const getBroadcastRoundPGN = async ( + roundId: string, +): Promise => { + const response = await fetch( + `https://lichess.org/api/broadcast/round/${roundId}.pgn`, + { + headers: { + Accept: 'application/x-chess-pgn', + }, + }, + ) + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + + return await response.text() +} + export const streamBroadcastRound = async ( roundId: string, onPGNUpdate: (pgn: string) => void, @@ -110,6 +129,7 @@ export const streamBroadcastRound = async ( const onMessage = (data: string) => { if (data.trim()) { + console.log('Received PGN data length:', data.length) onPGNUpdate(data) } } diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx index 208fac4e..10b494c6 100644 --- a/src/components/Analysis/BroadcastAnalysis.tsx +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -279,8 +279,20 @@ export const BroadcastAnalysis: React.FC = ({
-
- +
+
+ +
+
+ +
diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx index 8101de4d..0a7120e4 100644 --- a/src/components/Analysis/BroadcastGameList.tsx +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react' +import React, { useState, useMemo, useEffect } from 'react' import { motion } from 'framer-motion' import { BroadcastStreamController, BroadcastGame } from 'src/types' @@ -15,6 +15,16 @@ export const BroadcastGameList: React.FC = ({ broadcastController.currentRound?.id || '', ) + // Sync selectedRoundId when currentRound changes + useEffect(() => { + if ( + broadcastController.currentRound?.id && + broadcastController.currentRound.id !== selectedRoundId + ) { + setSelectedRoundId(broadcastController.currentRound.id) + } + }, [broadcastController.currentRound?.id, selectedRoundId]) + const handleRoundChange = (roundId: string) => { setSelectedRoundId(roundId) broadcastController.selectRound(roundId) @@ -26,8 +36,18 @@ export const BroadcastGameList: React.FC = ({ } const currentGames = useMemo(() => { - if (!broadcastController.roundData?.games) return [] - return Array.from(broadcastController.roundData.games.values()) + if (!broadcastController.roundData?.games) { + console.log('No round data games available') + return [] + } + const games = Array.from(broadcastController.roundData.games.values()) + console.log( + 'BroadcastGameList displaying', + games.length, + 'games:', + games.map((g) => `${g.white} vs ${g.black}`), + ) + return games }, [broadcastController.roundData?.games]) const getGameStatus = (game: BroadcastGame) => { diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index 38651835..cae1ced7 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -16,6 +16,7 @@ import { getLichessBroadcasts, getLichessTopBroadcasts, convertTopBroadcastToBroadcast, + getBroadcastRoundPGN, streamBroadcastRound, parsePGNData, } from 'src/api/lichess/broadcasts' @@ -311,34 +312,41 @@ export const useBroadcastController = (): BroadcastStreamController => { } lastPGNData.current = pgnData + console.log('Processing PGN update with length:', pgnData.length) const parseResult = parsePGNData(pgnData) + console.log('Parsed games count:', parseResult.games.length) + console.log( + 'Game IDs:', + parseResult.games.map((g) => `${g.white} vs ${g.black}`), + ) if (parseResult.errors.length > 0) { console.warn('PGN parsing errors:', parseResult.errors) } if (parseResult.games.length === 0) { + console.warn('No games found in PGN data') return } - // Update round data - const newGames = new Map() - const updatedGameStates = new Map() - let hasNewMoves = false + // Determine if this is initial load (multiple games) or update (single game) + const isInitialLoad = parseResult.games.length > 1 + console.log('Is initial load:', isInitialLoad) - for (const game of parseResult.games) { - newGames.set(game.id, game) + setRoundData((prevRoundData) => { + // Start with existing games + const existingGames = + prevRoundData?.games || new Map() + const updatedGames = new Map(existingGames) - // Check if this game has new moves compared to our stored state - const existingGameState = gameStates.current.get(game.id) - const newLiveGame = createLiveGameFromBroadcastGame(game) + // Process new/updated games + for (const game of parseResult.games) { + updatedGames.set(game.id, game) - if ( - !existingGameState || - existingGameState.moves.length !== newLiveGame.moves.length - ) { - hasNewMoves = true + // Update game states + const existingGameState = gameStates.current.get(game.id) + const newLiveGame = createLiveGameFromBroadcastGame(game) // Play sound for new moves if game was already loaded if ( @@ -352,18 +360,16 @@ export const useBroadcastController = (): BroadcastStreamController => { .catch((e) => console.log('Could not play move sound:', e)) } catch (e) {} } - } - updatedGameStates.set(game.id, newLiveGame) - } + gameStates.current.set(game.id, newLiveGame) + } - gameStates.current = updatedGameStates + console.log('Updated games map, now has', updatedGames.size, 'games') - setRoundData((prev) => { const newRoundData: BroadcastRoundData = { roundId: currentRoundId.current || '', broadcastId: currentBroadcast?.tour.id || '', - games: newGames, + games: updatedGames, lastUpdate: Date.now(), } return newRoundData @@ -371,9 +377,11 @@ export const useBroadcastController = (): BroadcastStreamController => { // Update current game if it exists in the new data if (currentGame) { - const updatedCurrentGame = newGames.get(currentGame.id) - if (updatedCurrentGame) { - setCurrentGame(updatedCurrentGame) + const updatedGame = parseResult.games.find( + (g) => g.id === currentGame.id, + ) + if (updatedGame) { + setCurrentGame(updatedGame) } } else if (parseResult.games.length > 0) { // Auto-select first game if none selected @@ -419,6 +427,7 @@ export const useBroadcastController = (): BroadcastStreamController => { })) try { + // Start streaming - this will send all games initially, then updates await streamBroadcastRound( roundId, handlePGNUpdate, diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx index ad04b1f5..ec59c3b9 100644 --- a/src/pages/broadcast/[broadcastId]/[roundId].tsx +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -51,7 +51,8 @@ const BroadcastAnalysisPage: NextPage = () => { } if (!broadcast) { - throw new Error('Broadcast not found') + // throw new Error('Broadcast not found') + return } // Find the round From af3ab8d2237e139354ecc693f642e4b9c2f56ad9 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 23:17:53 -0400 Subject: [PATCH 04/17] style: clean up UI --- src/components/Analysis/BroadcastAnalysis.tsx | 6 ++- src/components/Analysis/BroadcastGameList.tsx | 53 ++++++------------- 2 files changed, 20 insertions(+), 39 deletions(-) diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx index 10b494c6..55f9ee4a 100644 --- a/src/components/Analysis/BroadcastAnalysis.tsx +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -157,7 +157,9 @@ export const BroadcastAnalysis: React.FC = ({
-

{player.name}

+

+ {player.name} +

{player.rating ? <>({player.rating}) : null} @@ -174,7 +176,7 @@ export const BroadcastAnalysis: React.FC = ({
))}
- + {broadcastController.currentBroadcast?.tour.name} {broadcastController.currentRound && ( <> • {broadcastController.currentRound.name} diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx index 0a7120e4..aa926e1e 100644 --- a/src/components/Analysis/BroadcastGameList.tsx +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -70,42 +70,28 @@ export const BroadcastGameList: React.FC = ({ return (
-
-
-

+
+
+

{broadcastController.currentBroadcast?.tour.name || 'Live Broadcast'}

- {broadcastController.broadcastState.isLive && ( -
-
- LIVE -
- )}
{/* Round Selector */} {broadcastController.currentBroadcast && ( -
- - -
+ )} {/* Connection Status */} @@ -133,7 +119,7 @@ export const BroadcastGameList: React.FC = ({ )}
-
+
{currentGames.length === 0 ? (
@@ -203,13 +189,6 @@ export const BroadcastGameList: React.FC = ({ )}
- {(game.opening || game.eco) && ( -
- - {game.eco} {game.opening} - -
- )}
) From d66f29e3529a020238d9516deef0a13399e46c3f Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 23:39:09 -0400 Subject: [PATCH 05/17] feat: add clocks to game board --- src/api/lichess/broadcasts.ts | 139 ++++++++++++++---- src/components/Analysis/BroadcastAnalysis.tsx | 22 +++ src/components/Analysis/BroadcastGameList.tsx | 10 +- src/hooks/useBroadcastController.ts | 13 -- src/types/broadcast/index.ts | 10 ++ 5 files changed, 144 insertions(+), 50 deletions(-) diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts index 39e25e61..0482b737 100644 --- a/src/api/lichess/broadcasts.ts +++ b/src/api/lichess/broadcasts.ts @@ -1,3 +1,4 @@ +import { Chess } from 'chess.ts' import { Broadcast, BroadcastGame, @@ -129,7 +130,6 @@ export const streamBroadcastRound = async ( const onMessage = (data: string) => { if (data.trim()) { - console.log('Received PGN data length:', data.length) onPGNUpdate(data) } } @@ -251,10 +251,22 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => { const date = headers.Date || headers.UTCDate || '' const round = headers.Round || '' - // Parse moves from moves section - const moves = parseMovesFromPGN(movesSection) + // Parse moves and clock information from full PGN + console.log(`Parsing PGN for ${white} vs ${black}`) + const parseResult = parseMovesAndClocksFromPGN(pgnString) + const moves = parseResult.moves + const { whiteClock, blackClock } = parseResult const fen = extractFENFromMoves() + // Debug clock parsing + if (whiteClock || blackClock) { + console.log(`Clock data for ${white} vs ${black}:`, { + whiteClock, + blackClock, + movesSection: movesSection.substring(0, 200) + '...', + }) + } + const game: BroadcastGame = { id: generateGameId(white, black, event, site), white, @@ -278,6 +290,8 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => { chapterName: headers.ChapterName, utcDate: headers.UTCDate, utcTime: headers.UTCTime, + whiteClock, + blackClock, } // Note: Last move extraction would need proper move parsing to convert SAN to UCI @@ -286,41 +300,110 @@ const parseSinglePGN = (pgnString: string): BroadcastGame | null => { return game } -const parseMovesFromPGN = (movesSection: string): string[] => { +const parseMovesAndClocksFromPGN = ( + pgnString: string, +): { + moves: string[] + whiteClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } + blackClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } +} => { const moves: string[] = [] + let whiteClock: + | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number } + | undefined + let blackClock: + | { timeInSeconds: number; isActive: boolean; lastUpdateTime: number } + | undefined - // Remove comments, variations, and result - const cleanMoves = movesSection - .replace(/\{[^}]*\}/g, '') // Remove comments - .replace(/\([^)]*\)/g, '') // Remove variations - .replace(/\s*(1-0|0-1|1\/2-1\/2|\*)\s*$/, '') // Remove result - .trim() - - // Split by move numbers and extract moves - const tokens = cleanMoves.split(/\s+/) + try { + // Use chess.js to parse the full PGN + const chess = new Chess() + const success = chess.loadPgn(pgnString) + + if (!success) { + console.warn( + 'Failed to parse PGN with chess.js, falling back to manual parsing', + ) + return { moves } + } - for (const token of tokens) { - // Skip move numbers (e.g., "1.", "2.", etc.) - if (/^\d+\.+$/.test(token)) { - continue + // Get all moves from the game history + const history = chess.history({ verbose: true }) + for (const move of history) { + moves.push(move.san) } - // Skip empty tokens - if (!token.trim()) { - continue + // Get comments which contain clock information + const comments = chess.getComments() + let lastWhiteClock: any = null + let lastBlackClock: any = null + + for (const commentData of comments) { + const comment = commentData.comment + + // Extract clock from comment using regex + const clockMatch = comment.match(/\[%clk\s+(\d+):(\d+)(?::(\d+))?\]/) + if (clockMatch) { + const hours = clockMatch[3] ? parseInt(clockMatch[1]) : 0 + const minutes = clockMatch[3] + ? parseInt(clockMatch[2]) + : parseInt(clockMatch[1]) + const seconds = clockMatch[3] + ? parseInt(clockMatch[3]) + : parseInt(clockMatch[2]) + + const timeInSeconds = hours * 3600 + minutes * 60 + seconds + const clockData = { + timeInSeconds, + isActive: false, + lastUpdateTime: Date.now(), + } + + // Determine if this is white or black's move based on the FEN + const chess_temp = new Chess(commentData.fen) + const isWhiteToMove = chess_temp.turn() === 'b' // After white's move, it's black's turn + + if (isWhiteToMove) { + lastWhiteClock = clockData + } else { + lastBlackClock = clockData + } + + console.log( + `Found clock for ${isWhiteToMove ? 'white' : 'black'}: ${hours}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')} = ${timeInSeconds}s`, + ) + } } - // Add valid moves - if ( - /^[NBRQK]?[a-h]?[1-8]?x?[a-h][1-8](\=[NBRQ])?[\+\#]?$/.test(token) || - token === 'O-O' || - token === 'O-O-O' - ) { - moves.push(token) + whiteClock = lastWhiteClock + blackClock = lastBlackClock + + // Determine which clock is active based on current turn + if (moves.length > 0) { + const finalPosition = new Chess() + finalPosition.loadPgn(pgnString) + const isCurrentlyWhiteTurn = finalPosition.turn() === 'w' + + if (whiteClock) { + whiteClock.isActive = isCurrentlyWhiteTurn + } + if (blackClock) { + blackClock.isActive = !isCurrentlyWhiteTurn + } } + } catch (error) { + console.warn('Error parsing PGN with chess.js:', error) } - return moves + return { moves, whiteClock, blackClock } } const extractFENFromMoves = (): string | null => { diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx index 55f9ee4a..a99aea8f 100644 --- a/src/components/Analysis/BroadcastAnalysis.tsx +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -319,6 +319,23 @@ export const BroadcastAnalysis: React.FC = ({ analysisController.orientation === 'white' ? 'black' : 'white' } termination={game.termination?.winner} + clock={(() => { + const clock = + analysisController.orientation === 'white' + ? broadcastController.currentGame?.blackClock + : broadcastController.currentGame?.whiteClock + console.log('Top PlayerInfo clock data:', { + orientation: analysisController.orientation, + currentGame: + broadcastController.currentGame?.white + + ' vs ' + + broadcastController.currentGame?.black, + whiteClock: broadcastController.currentGame?.whiteClock, + blackClock: broadcastController.currentGame?.blackClock, + selectedClock: clock, + }) + return clock + })()} />
= ({ } termination={game.termination?.winner} showArrowLegend={true} + clock={ + analysisController.orientation === 'white' + ? broadcastController.currentGame?.whiteClock + : broadcastController.currentGame?.blackClock + } />
= ({ const currentGames = useMemo(() => { if (!broadcastController.roundData?.games) { - console.log('No round data games available') return [] } - const games = Array.from(broadcastController.roundData.games.values()) - console.log( - 'BroadcastGameList displaying', - games.length, - 'games:', - games.map((g) => `${g.white} vs ${g.black}`), - ) - return games + return Array.from(broadcastController.roundData.games.values()) }, [broadcastController.roundData?.games]) const getGameStatus = (game: BroadcastGame) => { diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index cae1ced7..f94bcdc0 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -312,28 +312,17 @@ export const useBroadcastController = (): BroadcastStreamController => { } lastPGNData.current = pgnData - console.log('Processing PGN update with length:', pgnData.length) const parseResult = parsePGNData(pgnData) - console.log('Parsed games count:', parseResult.games.length) - console.log( - 'Game IDs:', - parseResult.games.map((g) => `${g.white} vs ${g.black}`), - ) if (parseResult.errors.length > 0) { console.warn('PGN parsing errors:', parseResult.errors) } if (parseResult.games.length === 0) { - console.warn('No games found in PGN data') return } - // Determine if this is initial load (multiple games) or update (single game) - const isInitialLoad = parseResult.games.length > 1 - console.log('Is initial load:', isInitialLoad) - setRoundData((prevRoundData) => { // Start with existing games const existingGames = @@ -364,8 +353,6 @@ export const useBroadcastController = (): BroadcastStreamController => { gameStates.current.set(game.id, newLiveGame) } - console.log('Updated games map, now has', updatedGames.size, 'games') - const newRoundData: BroadcastRoundData = { roundId: currentRoundId.current || '', broadcastId: currentBroadcast?.tour.id || '', diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts index 77dee34e..9fcdf3bd 100644 --- a/src/types/broadcast/index.ts +++ b/src/types/broadcast/index.ts @@ -50,6 +50,16 @@ export interface BroadcastGame { chapterName?: string utcDate?: string utcTime?: string + whiteClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } + blackClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } } export interface BroadcastRoundData { From 706714fb791ca8ddc0ad2e121e451ae761a4a7bc Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 30 Jul 2025 23:48:57 -0400 Subject: [PATCH 06/17] fix: don't change games when new moves come in --- src/hooks/useBroadcastController.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index f94bcdc0..70f56f75 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -337,8 +337,10 @@ export const useBroadcastController = (): BroadcastStreamController => { const existingGameState = gameStates.current.get(game.id) const newLiveGame = createLiveGameFromBroadcastGame(game) - // Play sound for new moves if game was already loaded + // Play sound for new moves only if this is the currently selected game if ( + currentGame && + game.id === currentGame.id && existingGameState && newLiveGame.moves.length > existingGameState.moves.length ) { @@ -362,16 +364,17 @@ export const useBroadcastController = (): BroadcastStreamController => { return newRoundData }) - // Update current game if it exists in the new data + // Update current game data if it's in the update, but don't switch to a different game if (currentGame) { - const updatedGame = parseResult.games.find( + const updatedCurrentGame = parseResult.games.find( (g) => g.id === currentGame.id, ) - if (updatedGame) { - setCurrentGame(updatedGame) + if (updatedCurrentGame) { + // Update the currently selected game with new data (including clocks) + setCurrentGame(updatedCurrentGame) } } else if (parseResult.games.length > 0) { - // Auto-select first game if none selected + // Auto-select first game only if no game is currently selected setCurrentGame(parseResult.games[0]) } From b8f5f4d50399756ed41a806d1fb1fb5ab2f1393b Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 31 Jul 2025 00:12:26 -0400 Subject: [PATCH 07/17] fix: prevent rebuilding of game node/tree on new move --- src/hooks/useBroadcastController.ts | 152 +++++++++++++----- .../broadcast/[broadcastId]/[roundId].tsx | 43 +++-- 2 files changed, 142 insertions(+), 53 deletions(-) diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index 70f56f75..aa42ca7e 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -193,6 +193,10 @@ export const useBroadcastController = (): BroadcastStreamController => { if (roundData) { const game = roundData.games.get(gameId) if (game) { + console.log( + 'Manual game selection:', + game.white + ' vs ' + game.black, + ) setCurrentGame(game) } } @@ -226,46 +230,99 @@ export const useBroadcastController = (): BroadcastStreamController => { const startingFen = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1' - // Build game tree from moves - const tree = new GameTree(startingFen) - const chess = new Chess(startingFen) - let currentNode = tree.getRoot() - - const gameStates = [ - { - board: startingFen, - lastMove: undefined as [string, string] | undefined, - san: undefined as string | undefined, - check: false, - maia_values: {}, - }, - ] + // Check if we have an existing game state to build upon + const existingLiveGame = gameStates.current.get(broadcastGame.id) + + let tree: GameTree + let movesList: any[] + let existingMoveCount = 0 + + if (existingLiveGame && existingLiveGame.tree) { + // Reuse existing tree and states - preserve all analysis and variations + tree = existingLiveGame.tree + movesList = [...existingLiveGame.moves] + existingMoveCount = movesList.length - 1 // Subtract 1 for initial position + console.log( + `Reusing existing tree with ${existingMoveCount} moves, adding ${broadcastGame.moves.length - existingMoveCount} new moves`, + ) + } else { + // Create new tree only for new games + tree = new GameTree(startingFen) + movesList = [ + { + board: startingFen, + lastMove: undefined as [string, string] | undefined, + san: undefined as string | undefined, + check: false, + maia_values: {}, + }, + ] + console.log( + `Creating new tree for ${broadcastGame.white} vs ${broadcastGame.black}`, + ) + } - // Process each move - for (const moveStr of broadcastGame.moves) { - try { - const move = chess.move(moveStr) - if (move) { - const newFen = chess.fen() - const uci = - move.from + move.to + (move.promotion ? move.promotion : '') - - gameStates.push({ - board: newFen, - lastMove: [move.from, move.to], - san: move.san, - check: chess.inCheck(), - maia_values: {}, - }) - - currentNode = tree.addMainMove(currentNode, newFen, uci, move.san) + // Only process new moves that we don't already have + if (broadcastGame.moves.length > existingMoveCount) { + const chess = new Chess(startingFen) + let currentNode = tree.getRoot() + + // Replay existing moves to get to the current position + for (let i = 0; i < existingMoveCount; i++) { + try { + const move = chess.move(broadcastGame.moves[i]) + if (move && currentNode.mainChild) { + currentNode = currentNode.mainChild + } + } catch (error) { + console.warn( + `Error replaying existing move ${broadcastGame.moves[i]}:`, + error, + ) + break + } + } + + // Add only the new moves + for (let i = existingMoveCount; i < broadcastGame.moves.length; i++) { + try { + const moveStr = broadcastGame.moves[i] + const move = chess.move(moveStr) + if (move) { + const newFen = chess.fen() + const uci = + move.from + move.to + (move.promotion ? move.promotion : '') + + movesList.push({ + board: newFen, + lastMove: [move.from, move.to], + san: move.san, + check: chess.inCheck(), + maia_values: {}, + }) + + currentNode = tree.addMainMove(currentNode, newFen, uci, move.san) + console.log(`Added new move: ${move.san}`) + } + } catch (error) { + console.warn( + `Error processing new move ${broadcastGame.moves[i]}:`, + error, + ) + break } - } catch (error) { - console.warn(`Error processing move ${moveStr}:`, error) - break } } + // Preserve existing availableMoves array (legacy) and extend if needed + const availableMoves = + existingLiveGame?.availableMoves || new Array(movesList.length).fill({}) + + // Extend availableMoves array if we have new moves + while (availableMoves.length < movesList.length) { + availableMoves.push({}) + } + return { id: broadcastGame.id, blackPlayer: { @@ -278,10 +335,8 @@ export const useBroadcastController = (): BroadcastStreamController => { }, gameType: 'broadcast', type: 'stream' as const, - moves: gameStates, - availableMoves: new Array(gameStates.length).fill( - {}, - ) as AvailableMoves[], + moves: movesList, + availableMoves: availableMoves as AvailableMoves[], termination: broadcastGame.result === '*' ? undefined @@ -294,8 +349,8 @@ export const useBroadcastController = (): BroadcastStreamController => { ? 'black' : 'none', }, - maiaEvaluations: [], - stockfishEvaluations: [], + maiaEvaluations: existingLiveGame?.maiaEvaluations || [], + stockfishEvaluations: existingLiveGame?.stockfishEvaluations || [], loadedFen: broadcastGame.fen, loaded: true, tree, @@ -366,15 +421,30 @@ export const useBroadcastController = (): BroadcastStreamController => { // Update current game data if it's in the update, but don't switch to a different game if (currentGame) { + console.log( + 'Current game selected:', + currentGame.white + ' vs ' + currentGame.black, + ) const updatedCurrentGame = parseResult.games.find( (g) => g.id === currentGame.id, ) if (updatedCurrentGame) { + console.log('Updating current game with new data') // Update the currently selected game with new data (including clocks) setCurrentGame(updatedCurrentGame) + } else { + console.log( + 'Current game not in update - keeping selection unchanged', + ) } + // Important: Do NOT change game selection if current game is not in the update } else if (parseResult.games.length > 0) { // Auto-select first game only if no game is currently selected + console.log('No game selected - auto-selecting first game') + console.log( + 'Auto-selecting:', + parseResult.games[0].white + ' vs ' + parseResult.games[0].black, + ) setCurrentGame(parseResult.games[0]) } diff --git a/src/pages/broadcast/[broadcastId]/[roundId].tsx b/src/pages/broadcast/[broadcastId]/[roundId].tsx index ec59c3b9..31914285 100644 --- a/src/pages/broadcast/[broadcastId]/[roundId].tsx +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -121,31 +121,45 @@ const BroadcastAnalysisPage: NextPage = () => { useEffect(() => { const currentLiveGame = (broadcastController as any).currentLiveGame - if (currentLiveGame?.tree && analysisController) { + if ( + currentLiveGame?.tree && + analysisController && + analysisController.currentNode + ) { try { const mainLine = currentLiveGame.tree.getMainLine() const currentMoveCount = mainLine.length // If new moves have been added to the game if (currentMoveCount > lastGameMoveCount.current) { - lastGameMoveCount.current = currentMoveCount + console.log( + `New move detected: ${lastGameMoveCount.current} -> ${currentMoveCount}`, + ) // Find the last node in the main line - let lastNode = currentLiveGame.tree.getRoot() - while (lastNode.mainChild) { - lastNode = lastNode.mainChild - } + const lastNode = mainLine[mainLine.length - 1] - // Only auto-follow if user is currently at the previous last node - if ( - analysisController.currentNode && - lastNode.parent === analysisController.currentNode - ) { + // Only auto-follow if user is currently at the previous last node (or close to it) + const isAtLatestPosition = + lastNode.parent === analysisController.currentNode || + lastNode === analysisController.currentNode + + console.log('Auto-follow check:', { + isAtLatestPosition, + currentNodeId: analysisController.currentNode.id, + lastNodeParentId: lastNode.parent?.id, + lastNodeId: lastNode.id, + }) + + if (isAtLatestPosition) { + console.log('Auto-following to new move') analysisController.setCurrentNode(lastNode) } + + lastGameMoveCount.current = currentMoveCount } } catch (error) { - console.error('Error setting current node:', error) + console.error('Error in auto-follow logic:', error) } } }, [(broadcastController as any).currentLiveGame, analysisController]) @@ -157,6 +171,11 @@ const BroadcastAnalysisPage: NextPage = () => { const mainLine = currentLiveGame.tree.getMainLine() if (mainLine.length > 0) { analysisController.setCurrentNode(mainLine[mainLine.length - 1]) + // Update the move count tracker for the new game + lastGameMoveCount.current = mainLine.length + } else { + // Reset move count for games with no moves + lastGameMoveCount.current = 0 } } }, [broadcastController.currentGame?.id]) From bfd4dc48c9a7407cf66ba13b6e8b72162beec6ee Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 31 Jul 2025 00:17:54 -0400 Subject: [PATCH 08/17] fix: don't change games when new moves come in --- src/hooks/useBroadcastController.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index aa42ca7e..f16bfbc9 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -378,6 +378,8 @@ export const useBroadcastController = (): BroadcastStreamController => { return } + let allGamesAfterUpdate: BroadcastGame[] = [] + setRoundData((prevRoundData) => { // Start with existing games const existingGames = @@ -410,6 +412,9 @@ export const useBroadcastController = (): BroadcastStreamController => { gameStates.current.set(game.id, newLiveGame) } + // Store all games for auto-selection logic + allGamesAfterUpdate = Array.from(updatedGames.values()) + const newRoundData: BroadcastRoundData = { roundId: currentRoundId.current || '', broadcastId: currentBroadcast?.tour.id || '', @@ -438,14 +443,15 @@ export const useBroadcastController = (): BroadcastStreamController => { ) } // Important: Do NOT change game selection if current game is not in the update - } else if (parseResult.games.length > 0) { + } else if (allGamesAfterUpdate.length > 0) { // Auto-select first game only if no game is currently selected + // Use the first game from the complete games list, not just the updated games console.log('No game selected - auto-selecting first game') console.log( 'Auto-selecting:', - parseResult.games[0].white + ' vs ' + parseResult.games[0].black, + allGamesAfterUpdate[0].white + ' vs ' + allGamesAfterUpdate[0].black, ) - setCurrentGame(parseResult.games[0]) + setCurrentGame(allGamesAfterUpdate[0]) } // Update broadcast state From bfeca233a3fcda353398a1d5380e663c11ad9116 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Thu, 31 Jul 2025 00:33:19 -0400 Subject: [PATCH 09/17] style: improve broadcast list page --- src/hooks/useBroadcastController.ts | 52 +++++++++++++++-------------- src/pages/broadcast/index.tsx | 46 +++++++++++++------------ 2 files changed, 51 insertions(+), 47 deletions(-) diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index f16bfbc9..f00f8a07 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -63,7 +63,7 @@ export const useBroadcastController = (): BroadcastStreamController => { // Organize broadcasts into sections const sections: BroadcastSection[] = [] - // Official active broadcasts + // 1. Official active broadcasts (Lichess official live tournaments) const officialActive = officialBroadcasts.filter((b) => b.rounds.some((r) => r.ongoing), ) @@ -75,7 +75,23 @@ export const useBroadcastController = (): BroadcastStreamController => { }) } - // Top active broadcasts (unofficial) + // 2. Official upcoming broadcasts (Lichess official upcoming tournaments) - max 4 + const officialUpcoming = officialBroadcasts + .filter( + (b) => + b.rounds.every((r) => !r.ongoing) && + b.rounds.some((r) => r.startsAt > Date.now()), + ) + .slice(0, 4) // Limit to 4 + if (officialUpcoming.length > 0) { + sections.push({ + title: 'Upcoming Official Tournaments', + broadcasts: officialUpcoming, + type: 'official-upcoming', + }) + } + + // 3. Community live broadcasts (all live community broadcasts) const unofficialActive = topBroadcasts.active .map(convertTopBroadcastToBroadcast) .filter( @@ -86,37 +102,23 @@ export const useBroadcastController = (): BroadcastStreamController => { sections.push({ title: 'Community Live Broadcasts', broadcasts: unofficialActive, - type: 'unofficial-active', - }) - } - - // Official upcoming broadcasts - const officialUpcoming = officialBroadcasts.filter( - (b) => - b.rounds.every((r) => !r.ongoing) && - b.rounds.some((r) => r.startsAt > Date.now()), - ) - if (officialUpcoming.length > 0) { - sections.push({ - title: 'Upcoming Official Tournaments', - broadcasts: officialUpcoming, - type: 'official-upcoming', + type: 'community-active', }) } - // Top upcoming broadcasts (unofficial) - const unofficialUpcoming = topBroadcasts.upcoming.map( - convertTopBroadcastToBroadcast, - ) + // 4. Community upcoming broadcasts - max 5 + const unofficialUpcoming = topBroadcasts.upcoming + .map(convertTopBroadcastToBroadcast) + .slice(0, 5) // Limit to 5 if (unofficialUpcoming.length > 0) { sections.push({ title: 'Upcoming Community Broadcasts', broadcasts: unofficialUpcoming, - type: 'unofficial-upcoming', + type: 'community-upcoming', }) } - // Past broadcasts (mix of official and top) + // 5. Past tournaments (separate section) - max 8 const officialPast = officialBroadcasts.filter( (b) => b.rounds.every((r) => !r.ongoing) && @@ -127,10 +129,10 @@ export const useBroadcastController = (): BroadcastStreamController => { ...topBroadcasts.past.currentPageResults.map( convertTopBroadcastToBroadcast, ), - ] + ].slice(0, 8) // Limit to 8 if (pastBroadcasts.length > 0) { sections.push({ - title: 'Recent Tournaments', + title: 'Past Tournaments', broadcasts: pastBroadcasts, type: 'past', }) diff --git a/src/pages/broadcast/index.tsx b/src/pages/broadcast/index.tsx index ff15a3c7..709af530 100644 --- a/src/pages/broadcast/index.tsx +++ b/src/pages/broadcast/index.tsx @@ -103,8 +103,8 @@ const BroadcastsPage: NextPage = () => { Live Broadcasts – Maia Chess -
-
+
+

Error Loading Broadcasts

@@ -113,7 +113,7 @@ const BroadcastsPage: NextPage = () => {

@@ -133,9 +133,9 @@ const BroadcastsPage: NextPage = () => { /> -
+
{

) : ( -
+
{broadcastController.broadcastSections.map( (section, sectionIndex) => (

{section.title} {(section.type === 'official-active' || - section.type === 'unofficial-active') && ( + section.type === 'community-active') && (
@@ -192,25 +192,27 @@ const BroadcastsPage: NextPage = () => { )}

-
+
{section.broadcasts.map((broadcast, index) => { const ongoingRounds = broadcast.rounds.filter( (r) => r.ongoing, ) const hasOngoingRounds = ongoingRounds.length > 0 - const isActive = section.type.includes('active') + const isActive = + section.type.includes('active') || + section.type.includes('community') const isPast = section.type === 'past' return ( -
-
+
+
-

+

{broadcast.tour.name}

{hasOngoingRounds && isActive && ( @@ -234,8 +236,8 @@ const BroadcastsPage: NextPage = () => {
-
-
+
+
Rounds ({broadcast.rounds.length})
@@ -252,7 +254,7 @@ const BroadcastsPage: NextPage = () => { round.ongoing ? 'bg-red-500/20 text-red-400' : isPast - ? 'bg-background-3 text-secondary' + ? 'bg-background-2 text-secondary' : 'bg-blue-500/20 text-blue-400' }`} > @@ -275,12 +277,12 @@ const BroadcastsPage: NextPage = () => {
From 399c3af76db1cc7774e9797d41dd8b758b3dcb00 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Wed, 6 Aug 2025 00:53:44 -0400 Subject: [PATCH 15/17] fix: handle future rounds of broadcasts --- src/components/Analysis/BroadcastGameList.tsx | 4 +- src/hooks/useBroadcastController.ts | 40 +++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/src/components/Analysis/BroadcastGameList.tsx b/src/components/Analysis/BroadcastGameList.tsx index 2716769a..5881c1ee 100644 --- a/src/components/Analysis/BroadcastGameList.tsx +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -121,7 +121,9 @@ export const BroadcastGameList: React.FC = ({

{broadcastController.broadcastState.isConnecting ? 'Loading games...' - : 'No games available'} + : broadcastController.currentRound?.ongoing + ? 'No games available' + : 'Round not started yet'}

diff --git a/src/hooks/useBroadcastController.ts b/src/hooks/useBroadcastController.ts index df93cdf7..043ec565 100644 --- a/src/hooks/useBroadcastController.ts +++ b/src/hooks/useBroadcastController.ts @@ -587,15 +587,49 @@ export const useBroadcastController = (): BroadcastStreamController => { error: null, })) + // Set up a timeout to handle rounds with no data (future rounds) + const timeoutId = setTimeout(() => { + console.log( + 'Stream timeout - no data received, likely future/empty round', + ) + if (abortController.current && currentRoundId.current === roundId) { + // Set empty round data instead of staying in connecting state + setRoundData({ + roundId: roundId, + broadcastId: currentBroadcast?.tour.id || '', + games: new Map(), + lastUpdate: Date.now(), + }) + + setBroadcastState({ + isConnected: true, + isConnecting: false, + isLive: false, + error: null, + roundStarted: true, + roundEnded: false, + gameEnded: false, + }) + } + }, 5000) // 5 second timeout + try { // Start streaming - this will send all games initially, then updates await streamBroadcastRound( roundId, - handlePGNUpdate, - handleStreamComplete, + (pgnData) => { + // Clear timeout if we receive data + clearTimeout(timeoutId) + handlePGNUpdate(pgnData) + }, + () => { + clearTimeout(timeoutId) + handleStreamComplete() + }, abortController.current.signal, ) } catch (error) { + clearTimeout(timeoutId) console.error('Round stream error:', error) const errorMessage = @@ -614,7 +648,7 @@ export const useBroadcastController = (): BroadcastStreamController => { abortController.current = null } }, - [handlePGNUpdate, handleStreamComplete], + [handlePGNUpdate, handleStreamComplete, currentBroadcast], ) const reconnect = useCallback(() => { From 570f73ad2065a99b74a43028134c95838fe39764 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 9 Aug 2025 01:06:06 -0400 Subject: [PATCH 16/17] feat: add broadcasts to home page --- src/components/Home/HomeHero.tsx | 2 - .../Home/LiveChessBoardShowcase.tsx | 205 ++++++++++++++++ src/components/Home/LiveChessShowcase.tsx | 232 ++++++++++++++++++ src/pages/index.tsx | 2 + 4 files changed, 439 insertions(+), 2 deletions(-) create mode 100644 src/components/Home/LiveChessBoardShowcase.tsx create mode 100644 src/components/Home/LiveChessShowcase.tsx diff --git a/src/components/Home/HomeHero.tsx b/src/components/Home/HomeHero.tsx index 9e2ebb2e..6f471208 100644 --- a/src/components/Home/HomeHero.tsx +++ b/src/components/Home/HomeHero.tsx @@ -18,7 +18,6 @@ import { PlayType } from 'src/types' import { getGlobalStats, getActiveUserCount } from 'src/api' import { AuthContext, ModalContext } from 'src/contexts' import { AnimatedNumber } from 'src/components/Common/AnimatedNumber' -import { LiveChessBoard } from 'src/components/Home/LiveChessBoard' interface Props { scrollHandler: () => void @@ -292,7 +291,6 @@ export const HomeHero: React.FC = ({ scrollHandler }: Props) => { <> )} -
) diff --git a/src/components/Home/LiveChessBoardShowcase.tsx b/src/components/Home/LiveChessBoardShowcase.tsx new file mode 100644 index 00000000..cc98bc6d --- /dev/null +++ b/src/components/Home/LiveChessBoardShowcase.tsx @@ -0,0 +1,205 @@ +import React, { useState, useEffect, useCallback, useRef } from 'react' +import { useRouter } from 'next/router' +import { motion } from 'framer-motion' +import { Chess } from 'chess.ts' +import Chessground from '@react-chess/chessground' +import { getLichessTVGame, streamLichessGame } from 'src/api/lichess/streaming' +import { StreamedGame, StreamedMove } from 'src/types/stream' + +interface LiveGameData { + gameId: string + white?: { + user: { + id: string + name: string + } + rating?: number + } + black?: { + user: { + id: string + name: string + } + rating?: number + } + currentFen?: string + isLive?: boolean +} + +export const LiveChessBoardShowcase: React.FC = () => { + const router = useRouter() + const [liveGame, setLiveGame] = useState(null) + const [currentFen, setCurrentFen] = useState( + 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1', + ) + const [error, setError] = useState(null) + const abortController = useRef(null) + + const handleGameStart = useCallback((gameData: StreamedGame) => { + if (gameData.fen) { + setCurrentFen(gameData.fen) + } + setLiveGame({ + gameId: gameData.id, + white: gameData.players?.white, + black: gameData.players?.black, + currentFen: gameData.fen, + isLive: true, + }) + }, []) + + const handleMove = useCallback((moveData: StreamedMove) => { + if (moveData.fen) { + setCurrentFen(moveData.fen) + } + }, []) + + const handleStreamComplete = useCallback(() => { + console.log('Live board showcase - Stream completed') + fetchNewGame() + }, []) + + const fetchNewGame = useCallback(async () => { + try { + setError(null) + const tvGame = await getLichessTVGame() + + // Stop current stream if any + if (abortController.current) { + abortController.current.abort() + } + + // Start new stream + abortController.current = new AbortController() + + setLiveGame({ + gameId: tvGame.gameId, + white: tvGame.white, + black: tvGame.black, + isLive: true, + }) + + streamLichessGame( + tvGame.gameId, + handleGameStart, + handleMove, + handleStreamComplete, + abortController.current.signal, + ).catch((err) => { + if (err.name !== 'AbortError') { + console.error('Live board streaming error:', err) + setError('Connection lost') + } + }) + } catch (err) { + console.error('Error fetching new live game:', err) + setError('Failed to load live game') + } + }, [handleGameStart, handleMove, handleStreamComplete]) + + useEffect(() => { + // Initial fetch + fetchNewGame() + + // Cleanup on unmount + return () => { + if (abortController.current) { + abortController.current.abort() + } + } + }, []) // Remove fetchNewGame dependency to prevent re-renders + + const handleClick = () => { + if (liveGame?.gameId) { + router.push(`/analysis/stream/${liveGame.gameId}`) + } + } + + // Keep FEN only; Chessground renders from FEN directly + + return ( +
+ + {/* Live indicator */} + {liveGame?.isLive && ( +
+
+ LIVE +
+ )} + + {/* Chess board */} + + + + {/* Player names below the board */} + {liveGame && ( +
+
+
+
+ + {liveGame.white?.user?.name || 'White'} + + {liveGame.white?.rating && ( + + ({liveGame.white.rating}) + + )} +
+ vs +
+
+ + {liveGame.black?.user?.name || 'Black'} + + {liveGame.black?.rating && ( + + ({liveGame.black.rating}) + + )} +
+
+
+ )} + + {error && ( +
+

{error}

+ +
+ )} +
+ ) +} diff --git a/src/components/Home/LiveChessShowcase.tsx b/src/components/Home/LiveChessShowcase.tsx new file mode 100644 index 00000000..42fed4f3 --- /dev/null +++ b/src/components/Home/LiveChessShowcase.tsx @@ -0,0 +1,232 @@ +import React, { useState, useEffect, useCallback } from 'react' +import Link from 'next/link' +import { motion } from 'framer-motion' +import { LiveChessBoardShowcase } from './LiveChessBoardShowcase' +import { + getLichessBroadcasts, + getLichessTopBroadcasts, + convertTopBroadcastToBroadcast, +} from 'src/api/lichess/broadcasts' +import { Broadcast } from 'src/types' + +interface BroadcastWidgetProps { + broadcast: Broadcast +} + +const BroadcastWidget: React.FC = ({ broadcast }) => { + // Get the first ongoing round, or the first round if none are ongoing + const activeRound = + broadcast.rounds.find((r) => r.ongoing) || broadcast.rounds[0] + + return ( + + {/* Tournament card */} + +
+ {/* Header */} +
+

+ {broadcast.tour.name} +

+ {activeRound?.ongoing && ( +
+ + + LIVE + +
+ )} +
+ + {/* Body */} +
+
+

+ {activeRound?.name} +

+ {/* Placeholder meta; could be expanded when we parse PGN/game counts */} +

Ongoing round

+
+ +
+ View + + chevron_right + +
+
+
+ + + {/* Spacing under card */} +
+ + ) +} + +export const LiveChessShowcase: React.FC = () => { + const [topBroadcasts, setTopBroadcasts] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const fetchBroadcasts = useCallback(async () => { + try { + setError(null) + setIsLoading(true) + + // Load both official and top broadcasts + const [officialBroadcasts, topBroadcastsData] = await Promise.all([ + getLichessBroadcasts(), + getLichessTopBroadcasts(), + ]) + + // Get top ongoing broadcasts with live rounds (official first, then unofficial) + const officialActive = officialBroadcasts + .filter((b) => b.rounds.some((r) => r.ongoing)) + .slice(0, 1) // Take top 1 official + + const unofficialActive = topBroadcastsData.active + .map(convertTopBroadcastToBroadcast) + .filter( + (b) => + // Must have ongoing rounds and not be in official list + b.rounds.some((r) => r.ongoing) && + !officialActive.some((official) => official.tour.id === b.tour.id), + ) + .slice(0, 1) // Take top 1 unofficial + + const broadcasts = [...officialActive, ...unofficialActive].slice(0, 2) + setTopBroadcasts(broadcasts) + } catch (err) { + console.error('Error fetching broadcasts:', err) + setError('Failed to load broadcasts') + } finally { + setIsLoading(false) + } + }, []) + + useEffect(() => { + fetchBroadcasts() + // Refresh every 10 minutes + const interval = setInterval(fetchBroadcasts, 600000) + return () => clearInterval(interval) + }, [fetchBroadcasts]) + + return ( +
+
+
+ {/* Header on the left */} +
+

Live Chess

+

+ Watch live games and tournaments with real-time Maia AI analysis +

+
+ + {/* Live content on the right */} +
+ {/* Live Lichess TV Game */} +
+

+ Maia TV +

+ +
+ + {/* Top Live Broadcasts */} + {isLoading ? ( +
+

+ Live Tournament +

+ +
+ + stadia_controller + +

+ Loading tournaments... +

+
+
+
+ ) : error ? ( +
+

+ Live Tournament +

+ +
+ + error + +

{error}

+ +
+
+
+ ) : topBroadcasts.length > 0 ? ( +
+

+ Live Tournament +

+ +
+ ) : ( +
+

+ Live Tournament +

+ +
+ + stadia_controller + +

+ No live tournaments +

+ + View all + +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e39303f2..26346762 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -12,6 +12,7 @@ import { AdditionalFeaturesSection, PageNavigation, } from 'src/components' +import { LiveChessShowcase } from 'src/components/Home/LiveChessShowcase' const Home: NextPage = () => { const { setPlaySetupModalProps } = useContext(ModalContext) @@ -40,6 +41,7 @@ const Home: NextPage = () => { /> +
From 1601e17ac4a139b63bd6c4a8a7c4fdeb04d31398 Mon Sep 17 00:00:00 2001 From: Kevin Thomas Date: Sat, 23 Aug 2025 22:53:29 -0700 Subject: [PATCH 17/17] fix: build errors --- src/components/Analysis/AnalysisSidebar.tsx | 4 +- src/components/Analysis/BroadcastAnalysis.tsx | 18 +++---- src/components/Board/BoardController.tsx | 2 +- src/components/Turing/TuringGames.tsx | 8 ++-- src/components/Turing/TuringLog.tsx | 8 ++-- src/contexts/TuringControllerContext.ts | 12 ++--- .../useAnalysisController.ts | 1 - .../useTreeController/useTreeController.ts | 9 ++-- .../useTuringController.ts | 48 ++++++++++--------- src/pages/turing.tsx | 27 ++++++----- 10 files changed, 70 insertions(+), 67 deletions(-) diff --git a/src/components/Analysis/AnalysisSidebar.tsx b/src/components/Analysis/AnalysisSidebar.tsx index e84a54a3..b3eacca7 100644 --- a/src/components/Analysis/AnalysisSidebar.tsx +++ b/src/components/Analysis/AnalysisSidebar.tsx @@ -144,7 +144,7 @@ export const AnalysisSidebar: React.FC = ({ ], } } - currentNode={controller.currentNode} + currentNode={controller.currentNode ?? undefined} />
@@ -254,7 +254,7 @@ export const AnalysisSidebar: React.FC = ({ ], } } - currentNode={controller.currentNode} + currentNode={controller.currentNode ?? undefined} />
diff --git a/src/components/Analysis/BroadcastAnalysis.tsx b/src/components/Analysis/BroadcastAnalysis.tsx index 63a7b804..7822b8b3 100644 --- a/src/components/Analysis/BroadcastAnalysis.tsx +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -86,13 +86,15 @@ export const BroadcastAnalysis: React.FC = ({ if (analysisController.currentNode.mainChild?.move === moveString) { analysisController.goToNode(analysisController.currentNode.mainChild) } else { - const newVariation = game.tree.addVariation( - analysisController.currentNode, - newFen, - moveString, - san, - analysisController.currentMaiaModel, - ) + const newVariation = game.tree + .getLastMainlineNode() + .addChild( + newFen, + moveString, + san, + false, + analysisController.currentMaiaModel, + ) analysisController.goToNode(newVariation) } } @@ -289,7 +291,6 @@ export const BroadcastAnalysis: React.FC = ({ = ({ = ({ const getLast = useCallback(() => { if (!currentNode) return - + let lastNode = currentNode while (lastNode?.mainChild) { lastNode = lastNode.mainChild diff --git a/src/components/Turing/TuringGames.tsx b/src/components/Turing/TuringGames.tsx index fd23cdcd..f6f6a74a 100644 --- a/src/components/Turing/TuringGames.tsx +++ b/src/components/Turing/TuringGames.tsx @@ -3,17 +3,17 @@ import { useContext } from 'react' import { TuringControllerContext } from 'src/contexts' export const TuringGames: React.FC = () => { - const { gameIds, setCurrentGameId, games } = useContext( + const { gameIds, setCurrentIndex, games } = useContext( TuringControllerContext, ) return (
- {gameIds.map((id) => { - const game = games[id] + {gameIds.map((id, index) => { + const game = games[index] return (
) : ( gameIds.map((gameId, index) => { - const game = games[gameId] - const isCurrentGame = gameId === currentGameId + const game = games[index] + const isCurrentGame = index === currentIndex const getStatusInfo = () => { if (game.result?.correct === true) { return { @@ -48,7 +48,7 @@ export const TuringLog: React.FC = () => { return (