diff --git a/src/lib/engine/shared.ts b/src/lib/engine/shared.ts index 328366a6..8b2ddc31 100644 --- a/src/lib/engine/shared.ts +++ b/src/lib/engine/shared.ts @@ -18,10 +18,14 @@ export const isMultiThreadSupported = () => { } }; -export const isIosDevice = () => /iPhone|iPad|iPod/i.test(navigator.userAgent); +export const isIosDevice = () => + typeof navigator !== "undefined" && + /iPhone|iPad|iPod/i.test(navigator.userAgent); export const isMobileDevice = () => - isIosDevice() || /Android|Opera Mini/i.test(navigator.userAgent); + isIosDevice() || + (typeof navigator !== "undefined" && + /Android|Opera Mini/i.test(navigator.userAgent)); export const isEngineSupported = (name: EngineName): boolean => { switch (name) { diff --git a/src/lib/engine/worker.ts b/src/lib/engine/worker.ts index 224e0d9c..4b98c61a 100644 --- a/src/lib/engine/worker.ts +++ b/src/lib/engine/worker.ts @@ -45,6 +45,11 @@ export const sendCommandsToWorker = ( }; export const getRecommendedWorkersNb = (): number => { + // Return default value during SSR + if (typeof navigator === "undefined") { + return 4; + } + const maxWorkersNbFromThreads = Math.max( 1, Math.round(navigator.hardwareConcurrency - 4), diff --git a/src/sections/analysis/panelToolbar/index.tsx b/src/sections/analysis/panelToolbar/index.tsx index 827d64ce..69156ac7 100644 --- a/src/sections/analysis/panelToolbar/index.tsx +++ b/src/sections/analysis/panelToolbar/index.tsx @@ -5,6 +5,7 @@ import { boardAtom, gameAtom } from "../states"; import { useChessActions } from "@/hooks/useChessActions"; import FlipBoardButton from "./flipBoardButton"; import NextMoveButton from "./nextMoveButton"; +import PlayButton from "./playButton"; import GoToLastPositionButton from "./goToLastPositionButton"; import SaveButton from "./saveButton"; import { useEffect } from "react"; @@ -19,11 +20,14 @@ export default function PanelToolBar() { useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { - if (boardHistory.length === 0) return; - if (e.key === "ArrowLeft") { + if (e.key === "ArrowLeft" && boardHistory.length > 0) { undoBoardMove(); - } else if (e.key === "ArrowDown") { + } else if (e.key === "ArrowDown" && boardHistory.length > 0) { resetBoard(); + } else if (e.key === " " || e.key === "Spacebar") { + // Space bar will be handled by PlayButton component + // We prevent default here to avoid page scrolling + e.preventDefault(); } }; @@ -62,6 +66,8 @@ export default function PanelToolBar() { + + diff --git a/src/sections/analysis/panelToolbar/playButton.tsx b/src/sections/analysis/panelToolbar/playButton.tsx new file mode 100644 index 00000000..5f20c4af --- /dev/null +++ b/src/sections/analysis/panelToolbar/playButton.tsx @@ -0,0 +1,119 @@ +import { Icon } from "@iconify/react"; +import { Grid2 as Grid, IconButton, Tooltip } from "@mui/material"; +import { useAtomValue } from "jotai"; +import { boardAtom, gameAtom } from "../states"; +import { useChessActions } from "@/hooks/useChessActions"; +import { useCallback, useEffect, useRef, useState } from "react"; + +const PLAY_SPEED = 1000; // 1 second between moves + +export default function PlayButton() { + const { playMove: playBoardMove } = useChessActions(boardAtom); + const game = useAtomValue(gameAtom); + const board = useAtomValue(boardAtom); + + const [isPlaying, setIsPlaying] = useState(false); + const intervalRef = useRef(null); + + const gameHistory = game.history(); + const boardHistory = board.history(); + + const isButtonEnabled = + boardHistory.length < gameHistory.length && + gameHistory.slice(0, boardHistory.length).join() === boardHistory.join(); + + const playNextMove = useCallback(() => { + if (!isButtonEnabled) { + setIsPlaying(false); + return; + } + + const nextMoveIndex = boardHistory.length; + const nextMove = game.history({ verbose: true })[nextMoveIndex]; + + if (nextMove) { + const comment = game + .getComments() + .find((c) => c.fen === nextMove.after)?.comment; + + playBoardMove({ + from: nextMove.from, + to: nextMove.to, + promotion: nextMove.promotion, + comment, + }); + } else { + setIsPlaying(false); + } + }, [isButtonEnabled, boardHistory.length, gameHistory, game, playBoardMove]); + + const togglePlay = useCallback(() => { + if (isPlaying) { + setIsPlaying(false); + } else { + setIsPlaying(true); + } + }, [isPlaying]); + + // Handle interval management + useEffect(() => { + if (isPlaying) { + intervalRef.current = setInterval(playNextMove, PLAY_SPEED); + } else { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + } + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [isPlaying, playNextMove]); + + // Stop playing when no more moves available + useEffect(() => { + if (isPlaying && !isButtonEnabled) { + setIsPlaying(false); + } + }, [isPlaying, isButtonEnabled]); + + // Spacebar shortcut + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === " " || e.key === "Spacebar") { + e.preventDefault(); + if (isButtonEnabled || isPlaying) { + togglePlay(); + } + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [togglePlay, isButtonEnabled, isPlaying]); + + return ( + + + + + + + + ); +}