diff --git a/src/api/lichess/broadcasts.ts b/src/api/lichess/broadcasts.ts new file mode 100644 index 0000000..7526d1e --- /dev/null +++ b/src/api/lichess/broadcasts.ts @@ -0,0 +1,469 @@ +import { Chess } from 'chess.ts' +import { + Broadcast, + BroadcastGame, + PGNParseResult, + TopBroadcastsResponse, + TopBroadcastItem, +} 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 getLichessBroadcastById = async ( + broadcastId: string, +): Promise => { + try { + console.log('Fetching broadcast by ID:', broadcastId) + const response = await fetch( + `https://lichess.org/api/broadcast/${broadcastId}`, + { + headers: { + Accept: 'application/json', + }, + }, + ) + + if (!response.ok) { + console.error(`Failed to fetch broadcast: ${response.status}`) + return null + } + + const data = await response.json() + console.log('Broadcast data received:', { + name: data.tour?.name, + rounds: data.rounds?.length, + roundNames: data.rounds?.map((r: any) => r.name), + }) + + // Validate that this looks like broadcast data + if (data.tour && data.rounds) { + return data as Broadcast + } + + console.error('Invalid broadcast data structure') + return null + } catch (error) { + console.error('Error fetching broadcast by ID:', error) + return null + } +} + +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 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, + 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 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, + 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, + whiteClock, + blackClock, + } + + // 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 +} + +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 + + 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 } + } + + // Get all moves from the game history + const history = chess.history({ verbose: true }) + for (const move of history) { + moves.push(move.san) + } + + // 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`, + ) + } + } + + 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, whiteClock, blackClock } +} + +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 +} + +const generateGameId = ( + white: string, + black: string, + event: string, + site: string, +): string => { + const baseString = `${white}-${black}-${event}-${site}` + // 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/api/lichess/index.ts b/src/api/lichess/index.ts index 7f1dfdc..024fed9 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 0000000..63a7b80 --- /dev/null +++ b/src/components/Analysis/BroadcastAnalysis.tsx @@ -0,0 +1,518 @@ +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 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 + })()} + /> +
+ { + 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 0000000..5881c1e --- /dev/null +++ b/src/components/Analysis/BroadcastGameList.tsx @@ -0,0 +1,212 @@ +import React, { useState, useMemo, useEffect } 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 || '', + ) + + // 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) + } + + 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'} +

+
+ + {/* 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...' + : broadcastController.currentRound?.ongoing + ? 'No games available' + : 'Round not started yet'} +

+
+
+ ) : ( + <> + {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 fc650a1..6a548c1 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/components/Common/PlayerInfo.tsx b/src/components/Common/PlayerInfo.tsx index 9be5996..1384c99 100644 --- a/src/components/Common/PlayerInfo.tsx +++ b/src/components/Common/PlayerInfo.tsx @@ -84,11 +84,15 @@ export const PlayerInfo: React.FC = ({
)} {clock && ( -
+
{formatTime(currentTime)} diff --git a/src/components/Home/HomeHero.tsx b/src/components/Home/HomeHero.tsx index 9e2ebb2..6f47120 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 0000000..cc98bc6 --- /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 0000000..42fed4f --- /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/hooks/index.ts b/src/hooks/index.ts index 11ac3db..ed96c7c 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 0000000..043ec56 --- /dev/null +++ b/src/hooks/useBroadcastController.ts @@ -0,0 +1,712 @@ +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, + BroadcastSection, + LiveGame, +} from 'src/types' +import { + getLichessBroadcasts, + getLichessBroadcastById, + getLichessTopBroadcasts, + convertTopBroadcastToBroadcast, + getBroadcastRoundPGN, + streamBroadcastRound, + parsePGNData, +} from 'src/api/lichess/broadcasts' + +export const useBroadcastController = (): BroadcastStreamController => { + const [broadcastSections, setBroadcastSections] = useState< + BroadcastSection[] + >([]) + 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 currentGameRef = useRef(null) + + // Keep ref in sync with state + useEffect(() => { + currentGameRef.current = currentGame + }, [currentGame]) + + const loadBroadcasts = useCallback(async () => { + try { + setBroadcastState((prev) => ({ + ...prev, + isConnecting: true, + error: null, + })) + + // Load both official and top broadcasts concurrently + const [officialBroadcasts, topBroadcasts] = await Promise.all([ + getLichessBroadcasts(), + getLichessTopBroadcasts(), + ]) + + // Organize broadcasts into sections + const sections: BroadcastSection[] = [] + + // 1. Official active broadcasts (Lichess official live tournaments) + 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', + }) + } + + // 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( + (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', + }) + } + + // 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', + }) + } + + // 5. Past tournaments (separate section) - max 8 + 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, + ), + ].slice(0, 8) // Limit to 8 + if (pastBroadcasts.length > 0) { + sections.push({ + title: 'Past Tournaments', + broadcasts: pastBroadcasts, + type: 'past', + }) + } + + setBroadcastSections(sections) + console.log( + 'Loaded broadcasts:', + sections.map((s) => ({ + title: s.title, + broadcasts: s.broadcasts.map((b) => ({ + name: b.tour.name, + rounds: b.rounds.length, + roundNames: b.rounds.map((r) => r.name), + })), + })), + ) + 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( + async (broadcastId: string) => { + console.log('Selecting broadcast:', broadcastId) + + // Always fetch complete broadcast data directly from API to ensure we get ALL rounds + // The /api/broadcast endpoint often only returns currently ongoing rounds + let broadcast: Broadcast | undefined + try { + console.log('Fetching complete broadcast data from API...') + const fetchedBroadcast = await getLichessBroadcastById(broadcastId) + if (fetchedBroadcast) { + broadcast = fetchedBroadcast + console.log('✓ Got complete broadcast data with all rounds') + } + } catch (error) { + console.error('Failed to get broadcast by ID:', error) + } + + // Fallback: use data from sections if API call failed + if (!broadcast) { + console.log('API call failed, falling back to section data...') + for (const section of broadcastSections) { + broadcast = section.broadcasts.find((b) => b.tour.id === broadcastId) + if (broadcast) { + console.log('⚠ Found in sections but may have incomplete rounds') + break + } + } + } + + if (broadcast) { + console.log( + 'Selected broadcast:', + broadcast.tour.name, + 'with', + broadcast.rounds.length, + 'rounds:', + broadcast.rounds.map((r) => r.name), + ) + 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) + } + } else { + console.error('Broadcast not found:', broadcastId) + } + }, + [broadcastSections], + ) + + const selectRound = useCallback( + (roundId: string) => { + if (currentBroadcast) { + const round = currentBroadcast.rounds.find((r) => r.id === roundId) + if (round) { + console.log('Selecting round:', round.name, `(${roundId})`) + + // Stop current stream if different round + if (currentRoundId.current !== roundId) { + console.log('Switching to different round, stopping current stream') + // Stop the current stream + if (abortController.current) { + abortController.current.abort() + abortController.current = null + } + setBroadcastState((prev) => ({ + ...prev, + isConnected: false, + isLive: false, + })) + currentRoundId.current = null + gameStates.current.clear() + lastPGNData.current = '' + + // Clear current game selection when switching rounds + setCurrentGame(null) + // Clear round data so games list shows loading state + setRoundData(null) + } + + setCurrentRound(round) + // The useEffect will automatically start streaming the new round + } + } + }, + [currentBroadcast], + ) + + const selectGame = useCallback( + (gameId: string) => { + if (roundData) { + const game = roundData.games.get(gameId) + if (game) { + console.log( + 'Manual game selection:', + game.white + ' vs ' + game.black, + ) + 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 + setCurrentGame(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' + + // 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}`, + ) + } + + // 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 + } + } + } + + // 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: { + name: broadcastGame.black, + rating: broadcastGame.blackElo, + }, + whitePlayer: { + name: broadcastGame.white, + rating: broadcastGame.whiteElo, + }, + gameType: 'broadcast', + type: 'stream' as const, + moves: movesList, + availableMoves: availableMoves as AvailableMoves[], + termination: + broadcastGame.result === '*' + ? undefined + : { + result: broadcastGame.result, + winner: + broadcastGame.result === '1-0' + ? 'white' + : broadcastGame.result === '0-1' + ? 'black' + : 'none', + }, + maiaEvaluations: existingLiveGame?.maiaEvaluations || [], + stockfishEvaluations: existingLiveGame?.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 + } + + // Store the current game ID to preserve selection + const currentGameId = currentGameRef.current?.id + console.log( + 'handlePGNUpdate - currentGameId:', + currentGameId, + 'currentGame:', + currentGameRef.current?.white + ' vs ' + currentGameRef.current?.black, + ) + + let allGamesAfterUpdate: BroadcastGame[] = [] + + setRoundData((prevRoundData) => { + // Start with existing games + const existingGames = + prevRoundData?.games || new Map() + const updatedGames = new Map(existingGames) + + // Process new/updated games + for (const game of parseResult.games) { + updatedGames.set(game.id, game) + + // Update game states + const existingGameState = gameStates.current.get(game.id) + const newLiveGame = createLiveGameFromBroadcastGame(game) + + // Play sound for new moves only if this is the currently selected game + if ( + currentGameId && + game.id === currentGameId && + 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) {} + } + + 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 || '', + games: updatedGames, + lastUpdate: Date.now(), + } + return newRoundData + }) + + // Preserve game selection - only update current game if it was already selected + if (currentGameId) { + console.log( + 'Current game selected:', + currentGameRef.current?.white + + ' vs ' + + currentGameRef.current?.black, + ) + const updatedCurrentGame = parseResult.games.find( + (g) => g.id === currentGameId, + ) + 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', + ) + // Keep the current game selection even if it's not in this update + // This prevents auto-switching to the first game + } + } else if (!currentGameRef.current && allGamesAfterUpdate.length > 0) { + // Auto-select first game only if no game is currently selected at all + console.log('No game selected - auto-selecting first game') + console.log( + 'Auto-selecting:', + allGamesAfterUpdate[0].white + ' vs ' + allGamesAfterUpdate[0].black, + ) + setCurrentGame(allGamesAfterUpdate[0]) + } else { + console.log( + 'No action taken - currentGameId:', + currentGameId, + 'currentGame exists:', + !!currentGameRef.current, + 'allGamesAfterUpdate.length:', + allGamesAfterUpdate.length, + ) + } + + // Update broadcast state + setBroadcastState((prev) => ({ + ...prev, + isConnected: true, + isConnecting: false, + isLive: true, + roundStarted: true, + error: null, + })) + }, + [currentBroadcast, 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, + })) + + // 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, + (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 = + 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, currentBroadcast], + ) + + const reconnect = useCallback(() => { + if (currentRoundId.current) { + startRoundStream(currentRoundId.current) + } + }, [startRoundStream]) + + // Auto-start stream when any round is selected + useEffect(() => { + if ( + currentRound && + !broadcastState.isConnecting && + !broadcastState.isConnected + ) { + console.log( + 'Starting stream for selected round:', + currentRound.name, + currentRound.ongoing ? '(Live)' : '(Past/Future)', + ) + 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 { + broadcastSections, + currentBroadcast, + currentRound, + currentGame, + roundData, + broadcastState, + loadBroadcasts, + selectBroadcast, + selectRound, + selectGame, + startRoundStream, + stopRoundStream, + reconnect, + currentLiveGame, + } as BroadcastStreamController & { currentLiveGame: LiveGame | null } +} diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 80eeee3..746b09a 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 new file mode 100644 index 0000000..811fa28 --- /dev/null +++ b/src/pages/broadcast/[broadcastId]/[roundId].tsx @@ -0,0 +1,293 @@ +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, + Broadcast, + 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.broadcastSections.length === 0) { + await broadcastController.loadBroadcasts() + } + + // 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') + return + } + + // Find the round + const round = broadcast.rounds.find( + (r: { id: string }) => r.id === roundId, + ) + if (!round) { + throw new Error('Round not found') + } + + // Select broadcast and round + await 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.broadcastSections.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 && + 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) { + console.log( + `New move detected: ${lastGameMoveCount.current} -> ${currentMoveCount}`, + ) + + // Find the last node in the main line + const lastNode = mainLine[mainLine.length - 1] + + // 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 + + if (isAtLatestPosition) { + console.log('Auto-following to new move') + analysisController.setCurrentNode(lastNode) + } + + lastGameMoveCount.current = currentMoveCount + } + } catch (error) { + console.error('Error in auto-follow logic:', 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]) + // 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]) + + 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 0000000..77861ee --- /dev/null +++ b/src/pages/broadcast/index.tsx @@ -0,0 +1,333 @@ +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.broadcastSections.length === 0 ? ( + + + live_tv + +

+ No Live Broadcasts +

+

+ There are currently no ongoing tournaments available. +

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

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

+ +
+ {section.broadcasts.map((broadcast, index) => { + const ongoingRounds = broadcast.rounds.filter( + (r) => r.ongoing, + ) + const hasOngoingRounds = ongoingRounds.length > 0 + const isActive = + section.type.includes('active') || + section.type.includes('community') + 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 +
+ )} +
+
+ + +
+
+ ) + })} +
+
+ ), + )} +
+ )} + + +

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

+
+
+
+ + ) +} + +export default function AuthenticatedBroadcastsPage() { + return ( + + + + ) +} diff --git a/src/pages/index.tsx b/src/pages/index.tsx index e39303f..2634676 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 = () => { /> +
diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts new file mode 100644 index 0000000..008bc2d --- /dev/null +++ b/src/types/broadcast/index.ts @@ -0,0 +1,130 @@ +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 + whiteClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } + blackClock?: { + timeInSeconds: number + isActive: boolean + lastUpdateTime: number + } +} + +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 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: unknown | null + roundData: BroadcastRoundData | null + broadcastState: BroadcastState + loadBroadcasts: () => Promise + selectBroadcast: (broadcastId: string) => Promise + 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 525df13..f4ce863 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'