diff --git a/src/api/broadcasts.ts b/src/api/broadcasts.ts new file mode 100644 index 00000000..7526d1e9 --- /dev/null +++ b/src/api/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/index.ts b/src/api/index.ts index 875497a5..28374a70 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -8,3 +8,4 @@ export * from './openings' export * from './lichess' export * from './home' export * from './utils' +export * from './broadcasts' 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 new file mode 100644 index 00000000..7822b8b3 --- /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 + .getLastMainlineNode() + .addChild( + newFen, + moveString, + san, + false, + 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 00000000..5881c1ee --- /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 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/components/Board/BoardController.tsx b/src/components/Board/BoardController.tsx index 5ce85639..5f2fdbdf 100644 --- a/src/components/Board/BoardController.tsx +++ b/src/components/Board/BoardController.tsx @@ -67,7 +67,7 @@ export const BoardController: React.FC = ({ const getLast = useCallback(() => { if (!currentNode) return - + let lastNode = currentNode while (lastNode?.mainChild) { lastNode = lastNode.mainChild diff --git a/src/components/Common/PlayerInfo.tsx b/src/components/Common/PlayerInfo.tsx index 9be59962..1384c99a 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 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..ebc076fc --- /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 { fetchLichessTVGame, streamLichessGameMoves } from 'src/api' +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 fetchLichessTVGame() + + // 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, + }) + + streamLichessGameMoves( + 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..a99d0c09 --- /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/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/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 ( + +
+
+
+ + ) + } + + 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..77861ee0 --- /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 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 = () => { /> +
diff --git a/src/pages/turing.tsx b/src/pages/turing.tsx index ab491a53..474c4974 100644 --- a/src/pages/turing.tsx +++ b/src/pages/turing.tsx @@ -46,13 +46,17 @@ const TuringPage: NextPage = () => { } }, [controller.game, controller.stats?.rating]) + if (controller.loading || !controller.game) { + return ( + +
+
+ ) + } + return ( - - {controller.game && ( - - )} - + ) } @@ -66,7 +70,6 @@ const Turing: React.FC = (props: Props) => { const { game, stats } = props const { isMobile } = useContext(WindowSizeContext) - const controller = useContext(TuringControllerContext) const containerVariants = { @@ -189,9 +192,9 @@ const Turing: React.FC = (props: Props) => { id="turing-page" className="relative flex aspect-square w-full max-w-[75vh] flex-shrink-0" > - @@ -248,9 +251,9 @@ const Turing: React.FC = (props: Props) => { id="turing-page" className="relative flex aspect-square h-[100vw] w-screen" > -
diff --git a/src/types/broadcast/index.ts b/src/types/broadcast/index.ts new file mode 100644 index 00000000..008bc2d6 --- /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 560fd377..3ccda4a2 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,5 +11,6 @@ export * from './modal' export * from './blog' export * from './leaderboard' export * from './stream' +export * from './broadcast' export * from './puzzle' export * from './engine'