Add connection status component and improve socket reconnection handling
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m20s

This commit is contained in:
Mathias Wagner 2025-05-14 20:10:46 +02:00
parent f2712bdcec
commit 301e08b6e6
9 changed files with 651 additions and 106 deletions

View File

@ -7,6 +7,7 @@ import HomeScreen from "./components/HomeScreen";
import SongSubmissionScreen from "./components/SongSubmissionScreen"; import SongSubmissionScreen from "./components/SongSubmissionScreen";
import VotingScreen from "./components/VotingScreen"; import VotingScreen from "./components/VotingScreen";
import ResultsScreen from "./components/ResultsScreen"; import ResultsScreen from "./components/ResultsScreen";
import ConnectionStatus from "./components/ConnectionStatus";
const App = () => { const App = () => {
const [cursorPos, setCursorPos] = useState({x: 0, y: 0}); const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
@ -84,12 +85,6 @@ const App = () => {
</div> </div>
<div className="content-container"> <div className="content-container">
{!isConnected && (
<div className="connection-status">
<p>Connecting to server...</p>
</div>
)}
{error && ( {error && (
<div className="error-message"> <div className="error-message">
<p>{error}</p> <p>{error}</p>
@ -97,6 +92,9 @@ const App = () => {
)} )}
{renderGameScreen()} {renderGameScreen()}
{/* Show connection status component */}
<ConnectionStatus />
</div> </div>
</> </>
) )

View File

@ -0,0 +1,55 @@
// connection-status.sass - Component for displaying network connection status
.connection-status
position: fixed
bottom: 0
left: 0
right: 0
padding: 0.5rem 1rem
display: flex
align-items: center
justify-content: center
gap: 0.5rem
z-index: 1000
transition: all 0.3s ease
animation: slideUp 0.3s forwards
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
&.connected
background-color: rgba($success, 0.9)
color: #fff
animation: slideUp 0.3s forwards, fadeOut 0.5s 2.5s forwards
&.disconnected
background-color: rgba(#f44336, 0.9)
color: #fff
&.reconnecting
background-color: rgba(#ff9800, 0.9)
color: #fff
&.offline
background-color: rgba(#f44336, 0.9)
color: #fff
.connection-icon
display: flex
align-items: center
justify-content: center
.connection-message
text-align: center
@keyframes slideUp
from
transform: translateY(100%)
to
transform: translateY(0)
@keyframes fadeOut
from
opacity: 1
to
opacity: 0
transform: translateY(100%)

View File

@ -253,11 +253,46 @@
.voting-actions .voting-actions
display: flex display: flex
justify-content: center justify-content: center
flex-direction: column
align-items: center
margin: 2rem 0 margin: 2rem 0
.btn .btn
min-width: 180px min-width: 180px
font-size: 1rem font-size: 1rem
&.offline
background-color: $secondary
position: relative
.offline-notice
margin-top: 1rem
padding: 0.5rem
background-color: rgba($secondary, 0.2)
border: 2px solid $secondary
max-width: 400px
text-align: center
font-size: 0.7rem
svg
margin-right: 0.5rem
color: $secondary
.offline-vote-status
margin-top: 1rem
padding: 0.5rem
background-color: rgba($success, 0.2)
border: 2px solid $success
max-width: 400px
text-align: center
font-size: 0.7rem
color: $success
animation: pulse-opacity 2s infinite
&.error
background-color: rgba(#f44336, 0.2)
border-color: #f44336
color: #f44336
// Voting status and information // Voting status and information
.voting-status .voting-status
@ -274,6 +309,17 @@
font-size: 0.9rem font-size: 0.9rem
color: $text color: $text
.reconnecting-notice
margin: 0.5rem auto 1rem auto
padding: 0.5rem
background-color: rgba($secondary, 0.2)
border: 2px solid $secondary
max-width: 400px
text-align: center
font-size: 0.7rem
color: $secondary
animation: pulse-opacity 2s infinite
.auto-advance-notice .auto-advance-notice
margin: 1rem auto margin: 1rem auto
max-width: 400px max-width: 400px
@ -297,6 +343,11 @@
color: $primary color: $primary
font-weight: bold font-weight: bold
.offline-badge
color: $secondary
margin-left: 0.5rem
font-size: 0.7rem
// Player votes list styling // Player votes list styling
.player-votes .player-votes
background-color: rgba(0, 0, 0, 0.2) background-color: rgba(0, 0, 0, 0.2)
@ -368,6 +419,21 @@
background-color: rgba(255, 255, 255, 0.05) background-color: rgba(255, 255, 255, 0.05)
border-color: rgba(255, 255, 255, 0.1) border-color: rgba(255, 255, 255, 0.1)
opacity: 0.7 opacity: 0.7
&.offline-voted
background-color: rgba($secondary, 0.1)
border-color: rgba($secondary, 0.5)
.offline-icon
color: $secondary
margin-left: 0.5rem
animation: pixel-pulse 1.5s infinite
@keyframes pulse-opacity
0%, 100%
opacity: 1
50%
opacity: 0.5
@keyframes pixel-blink @keyframes pixel-blink
0%, 100% 0%, 100%

View File

@ -12,6 +12,7 @@
@import './components/youtube-embed' @import './components/youtube-embed'
@import './components/youtube-search' @import './components/youtube-search'
@import './components/song-form-overlay' @import './components/song-form-overlay'
@import './components/connection-status'
// Global styles // Global styles
html, body html, body

View File

@ -0,0 +1,70 @@
// ConnectionStatus.jsx
import { useState, useEffect } from 'react';
import { useSocket } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWifi, faExclamationTriangle, faSpinner } from '@fortawesome/free-solid-svg-icons';
function ConnectionStatus() {
const { socket, isConnected, isReconnecting, reconnectionAttempts } = useSocket();
const [showStatus, setShowStatus] = useState(false);
const [offline, setOffline] = useState(!navigator.onLine);
// Monitor browser's online/offline status
useEffect(() => {
const handleOnline = () => setOffline(false);
const handleOffline = () => setOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Always show status when disconnected, hide after successful connection
useEffect(() => {
if (!isConnected || isReconnecting || offline) {
setShowStatus(true);
} else {
// Hide the status indicator after a delay
const timer = setTimeout(() => {
setShowStatus(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [isConnected, isReconnecting, offline]);
if (!showStatus) return null;
let statusClass = 'connection-status';
let icon, message;
if (offline) {
statusClass += ' offline';
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.';
} else if (!isConnected) {
statusClass += ' disconnected';
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...';
} else if (isReconnecting) {
statusClass += ' reconnecting';
icon = <FontAwesomeIcon icon={faSpinner} spin />;
message = `Verbindungsversuch ${reconnectionAttempts}...`;
} else {
statusClass += ' connected';
icon = <FontAwesomeIcon icon={faWifi} />;
message = 'Verbunden mit dem Server';
}
return (
<div className={statusClass}>
<div className="connection-icon">{icon}</div>
<div className="connection-message">{message}</div>
</div>
);
}
export default ConnectionStatus;

View File

@ -2,15 +2,16 @@
import { useState, useEffect, useMemo } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { useGame } from '../context/GameContext'; import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown } from '@fortawesome/free-solid-svg-icons'; import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import YouTubeEmbed from './YouTubeEmbed'; import YouTubeEmbed from './YouTubeEmbed';
function VotingScreen() { function VotingScreen() {
const { lobby, currentPlayer, submitVote, isHost } = useGame(); const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
const [hasVoted, setHasVoted] = useState(false); const [hasVoted, setHasVoted] = useState(false);
const [selectedSong, setSelectedSong] = useState(null); const [selectedSong, setSelectedSong] = useState(null);
const [countdown, setCountdown] = useState(null); const [countdown, setCountdown] = useState(null);
const [processingByeAdvance, setProcessingByeAdvance] = useState(false); const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
// Hole aktuellen Kampf // Hole aktuellen Kampf
const battle = lobby?.currentBattle || null; const battle = lobby?.currentBattle || null;
@ -39,10 +40,10 @@ function VotingScreen() {
useEffect(() => { useEffect(() => {
if (battle && battle.votes && currentPlayer) { if (battle && battle.votes && currentPlayer) {
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert // Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
// Da Stimmen als Objekt, nicht als Map gesendet werden setHasVoted(
const votesObj = battle.votes || {}; Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
setHasVoted(Object.prototype.hasOwnProperty.call(votesObj, currentPlayer.id) || battle.votes[currentPlayer.id] !== undefined
Object.keys(votesObj).includes(currentPlayer.id)); );
} else { } else {
setHasVoted(false); setHasVoted(false);
} }
@ -59,8 +60,41 @@ function VotingScreen() {
const handleSubmitVote = async () => { const handleSubmitVote = async () => {
if (!selectedSong || hasVoted) return; if (!selectedSong || hasVoted) return;
await submitVote(selectedSong); try {
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft // If offline, show status but still try to submit (it will be queued)
if (isOffline) {
setOfflineVoteStatus('pending');
}
await submitVote(selectedSong);
if (isOffline) {
// In offline mode, optimistically update UI
setHasVoted(true);
setOfflineVoteStatus('queued');
// Store vote locally for later sync
try {
const savedVotes = JSON.parse(localStorage.getItem('pendingVotes') || '{}');
if (battle) {
savedVotes[`battle_${battle.round}_${battle.song1.id}`] = {
songId: selectedSong,
battleRound: battle.round,
timestamp: Date.now()
};
localStorage.setItem('pendingVotes', JSON.stringify(savedVotes));
}
} catch (e) {
console.error("Fehler beim Speichern der Offline-Stimme:", e);
}
}
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
} catch (error) {
console.error("Fehler bei der Stimmabgabe:", error);
setOfflineVoteStatus('error');
}
}; };
// Handle bye round advancement - für automatisches Weiterkommen // Handle bye round advancement - für automatisches Weiterkommen
@ -69,12 +103,18 @@ function VotingScreen() {
setProcessingByeAdvance(true); setProcessingByeAdvance(true);
try { try {
// Nur der Host kann im Bye-Modus weiterschaltenNNULL // If offline, show notification that the action will be queued
if (isOffline) {
setOfflineVoteStatus('byeQueued');
}
// Nur der Host kann im Bye-Modus weiterschalten
if (battle && battle.song1 && !battle.song2 && battle.song1.id) { if (battle && battle.song1 && !battle.song2 && battle.song1.id) {
await submitVote(battle.song1.id); await submitVote(battle.song1.id);
} }
} catch (error) { } catch (error) {
console.error("Fehler beim Fortfahren:", error); console.error("Fehler beim Fortfahren:", error);
setOfflineVoteStatus('error');
} finally { } finally {
// Verzögerung, um mehrere Klicks zu verhindern // Verzögerung, um mehrere Klicks zu verhindern
setTimeout(() => setProcessingByeAdvance(false), 1000); setTimeout(() => setProcessingByeAdvance(false), 1000);
@ -258,19 +298,46 @@ function VotingScreen() {
{!hasVoted && ( {!hasVoted && (
<div className="voting-actions"> <div className="voting-actions">
<button <button
className="btn primary" className={`btn primary ${isOffline ? 'offline' : ''}`}
onClick={handleSubmitVote} onClick={handleSubmitVote}
disabled={!selectedSong} disabled={!selectedSong}
> >
Abstimmen {isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
</button> </button>
{isOffline && (
<div className="offline-notice">
<FontAwesomeIcon icon={faExclamationTriangle} />
<span>Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist.</span>
</div>
)}
{offlineVoteStatus === 'queued' && (
<div className="offline-vote-status">
<span>Stimme gespeichert! Wird gesendet, wenn online.</span>
</div>
)}
{offlineVoteStatus === 'error' && (
<div className="offline-vote-status error">
<span>Fehler beim Speichern der Stimme.</span>
</div>
)}
</div> </div>
)} )}
<div className="voting-status"> <div className="voting-status">
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p> <p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
{isReconnecting && (
<div className="reconnecting-notice">
<span>Versuche die Verbindung wiederherzustellen...</span>
</div>
)}
<div className="votes-count"> <div className="votes-count">
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen <span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
{isOffline && <span className="offline-badge"> (Offline-Modus)</span>}
</div> </div>
{/* Liste der Spielerstimmen */} {/* Liste der Spielerstimmen */}
@ -282,12 +349,20 @@ function VotingScreen() {
const hasPlayerVoted = battle.votes && const hasPlayerVoted = battle.votes &&
Object.keys(battle.votes).includes(player.id); Object.keys(battle.votes).includes(player.id);
// Highlight current player to show they've voted offline
const isCurrentPlayerOfflineVoted = player.id === currentPlayer.id &&
isOffline &&
hasVoted;
return ( return (
<li key={player.id} className={hasPlayerVoted ? 'voted' : 'not-voted'}> <li key={player.id} className={`${hasPlayerVoted ? 'voted' : 'not-voted'} ${isCurrentPlayerOfflineVoted ? 'offline-voted' : ''}`}>
{player.name} {player.id === currentPlayer.id && '(Du)'} {player.name} {player.id === currentPlayer.id && '(Du)'}
{hasPlayerVoted && {hasPlayerVoted &&
<FontAwesomeIcon icon={faCheck} className="vote-icon" /> <FontAwesomeIcon icon={faCheck} className="vote-icon" />
} }
{isCurrentPlayerOfflineVoted &&
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
}
</li> </li>
); );
})} })}

View File

@ -11,35 +11,61 @@ const GameContext = createContext(null);
export function SocketProvider({ children }) { export function SocketProvider({ children }) {
const [socket, setSocket] = useState(null); const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [reconnectionAttempts, setReconnectionAttempts] = useState(0);
const [offlineActions, setOfflineActions] = useState([]);
const [lastActivityTime, setLastActivityTime] = useState(Date.now());
useEffect(() => { useEffect(() => {
// Create socket connection on component mount // Create socket connection on component mount with improved reconnection settings
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, { const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
autoConnect: true, autoConnect: true,
reconnection: true, reconnection: true,
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 5000, reconnectionDelayMax: 10000,
reconnectionAttempts: 5 reconnectionAttempts: Infinity, // Keep trying to reconnect indefinitely
timeout: 20000, // Longer timeout for initial connection
randomizationFactor: 0.5 // Add some randomization to reconnection attempts to prevent thundering herd
}); });
// Socket event listeners // Socket event listeners
socketInstance.on('connect', () => { socketInstance.on('connect', () => {
setIsConnected(true); setIsConnected(true);
setIsReconnecting(false);
setReconnectionAttempts(0);
console.log('Connected to server'); console.log('Connected to server');
// Process any actions that were queued while offline
if (offlineActions.length > 0) {
console.log(`Processing ${offlineActions.length} queued actions from offline mode`);
// Wait a moment to ensure connection is stable
setTimeout(() => {
processOfflineActions(socketInstance);
}, 1000);
}
// Try to reconnect to game if we have saved data // Try to reconnect to game if we have saved data
const savedGameData = localStorage.getItem('songBattleGame'); const savedGameData = localStorage.getItem('songBattleGame');
if (savedGameData) { if (savedGameData) {
try { try {
const { lobbyId, playerName } = JSON.parse(savedGameData); const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
if (lobbyId && playerName) { if (lobbyId && playerName) {
console.log(`Attempting to reconnect to lobby: ${lobbyId}`); console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
socketInstance.emit('reconnect_to_lobby', { lobbyId, playerName }, (response) => { socketInstance.emit('reconnect_to_lobby', {
lobbyId,
playerName,
lastKnownState // Send last known game state for server reconciliation
}, (response) => {
if (response.error) { if (response.error) {
console.error('Reconnection failed:', response.error); console.error('Reconnection failed:', response.error);
localStorage.removeItem('songBattleGame'); // Don't remove data immediately, we might try again
if (response.error === 'Lobby not found') {
localStorage.removeItem('songBattleGame');
}
} else { } else {
console.log('Successfully reconnected to lobby'); console.log('Successfully reconnected to lobby');
// Update the saved game data with latest state
updateSavedGameData(response.lobby);
} }
}); });
} }
@ -50,25 +76,200 @@ export function SocketProvider({ children }) {
} }
}); });
socketInstance.on('disconnect', () => { socketInstance.on('disconnect', (reason) => {
setIsConnected(false); setIsConnected(false);
console.log('Disconnected from server'); console.log(`Disconnected from server: ${reason}`);
// If the disconnection is expected, don't try to reconnect
if (reason === 'io client disconnect') {
console.log('Disconnection was initiated by the client');
} else {
// Start reconnection process for unexpected disconnections
setIsReconnecting(true);
}
}); });
socketInstance.on('connect_error', (error) => { socketInstance.on('connect_error', (error) => {
console.error('Connection error:', error); console.error('Connection error:', error);
setIsReconnecting(true);
}); });
socketInstance.on('reconnect_attempt', (attemptNumber) => {
console.log(`Reconnection attempt #${attemptNumber}`);
setReconnectionAttempts(attemptNumber);
});
socketInstance.on('reconnect', () => {
console.log('Reconnected to server after interruption');
setIsReconnecting(false);
setReconnectionAttempts(0);
});
socketInstance.on('reconnect_error', (error) => {
console.error('Reconnection error:', error);
});
socketInstance.on('reconnect_failed', () => {
console.error('Failed to reconnect to server after maximum attempts');
setIsReconnecting(false);
});
// Setup a heartbeat to detect silent disconnections
const heartbeatInterval = setInterval(() => {
if (socketInstance && isConnected) {
// Check if we've seen activity recently (within 15 seconds)
const timeSinceLastActivity = Date.now() - lastActivityTime;
if (timeSinceLastActivity > 15000) {
console.log('No recent activity, sending ping to verify connection');
socketInstance.emit('ping', () => {
// Update activity time when we get a response
setLastActivityTime(Date.now());
});
// Set a timeout to check if the ping was successful
setTimeout(() => {
const newTimeSinceLastActivity = Date.now() - lastActivityTime;
if (newTimeSinceLastActivity > 15000) {
console.warn('No response to ping, connection may be dead');
// Force a reconnection attempt
socketInstance.disconnect().connect();
}
}, 5000);
}
}
}, 30000); // Check every 30 seconds
setSocket(socketInstance); setSocket(socketInstance);
// Clean up socket connection on unmount // Function to process queued offline actions
const processOfflineActions = (socket) => {
if (!socket || !offlineActions.length) return;
// Process each action in sequence
const processAction = (index) => {
if (index >= offlineActions.length) {
// All actions processed, clear the queue
setOfflineActions([]);
return;
}
const { action, payload, callback } = offlineActions[index];
console.log(`Processing offline action: ${action}`, payload);
// Emit the action to the server
socket.emit(action, payload, (response) => {
if (response.error) {
console.error(`Error processing offline action ${action}:`, response.error);
} else {
console.log(`Successfully processed offline action: ${action}`);
if (callback) callback(response);
}
// Process the next action
processAction(index + 1);
});
};
// Start processing from the first action
processAction(0);
};
// Update activity timestamp on any received message
const originalOnEvent = socketInstance.onEvent;
socketInstance.onEvent = (packet) => {
setLastActivityTime(Date.now());
originalOnEvent.call(socketInstance, packet);
};
// Function to update saved game data with latest state
const updateSavedGameData = (lobby) => {
if (!lobby) return;
const currentData = localStorage.getItem('songBattleGame');
if (currentData) {
try {
const data = JSON.parse(currentData);
data.lastKnownState = {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
};
localStorage.setItem('songBattleGame', JSON.stringify(data));
} catch (e) {
console.error('Error updating saved game data:', e);
}
}
};
// Clean up on unmount
return () => { return () => {
clearInterval(heartbeatInterval);
socketInstance.disconnect(); socketInstance.disconnect();
}; };
}, []); }, []);
// Offline mode detection
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Monitor browser's online/offline status
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Add an action to the offline queue
const queueOfflineAction = (action, payload, callback) => {
console.log(`Queuing offline action: ${action}`, payload);
setOfflineActions(prev => [...prev, { action, payload, callback }]);
// Store in localStorage as fallback
try {
const offlineQueue = JSON.parse(localStorage.getItem('offlineActionQueue') || '[]');
offlineQueue.push({ action, payload, timestamp: Date.now() });
localStorage.setItem('offlineActionQueue', JSON.stringify(offlineQueue));
} catch (e) {
console.error('Error storing offline action in localStorage:', e);
}
};
// Emit an event, queue it if offline
const safeEmit = (eventName, data, callback) => {
// Update activity timestamp
setLastActivityTime(Date.now());
if (socket && isConnected && !isOffline) {
// Online - send immediately
socket.emit(eventName, data, callback);
} else {
// Offline or disconnected - queue for later
queueOfflineAction(eventName, data, callback);
// For specific actions, provide optimistic UI updates
if (eventName === 'submit_vote' && data.songId) {
// Optimistically show the vote locally
// Implementation depends on your UI update mechanism
}
}
};
return ( return (
<SocketContext.Provider value={{ socket, isConnected }}> <SocketContext.Provider value={{
socket,
isConnected,
isReconnecting,
reconnectionAttempts,
isOffline,
safeEmit,
queueOfflineAction
}}>
{children} {children}
</SocketContext.Provider> </SocketContext.Provider>
); );
@ -78,7 +279,7 @@ export function SocketProvider({ children }) {
* Game state provider - manages game state and provides methods for game actions * Game state provider - manages game state and provides methods for game actions
*/ */
export function GameProvider({ children }) { export function GameProvider({ children }) {
const { socket, isConnected } = useContext(SocketContext); const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
const [lobby, setLobby] = useState(null); const [lobby, setLobby] = useState(null);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [currentPlayer, setCurrentPlayer] = useState(null); const [currentPlayer, setCurrentPlayer] = useState(null);
@ -86,12 +287,38 @@ export function GameProvider({ children }) {
// Save game info when lobby is joined // Save game info when lobby is joined
useEffect(() => { useEffect(() => {
if (lobby && currentPlayer) { if (lobby && currentPlayer) {
sessionStorage.setItem('songBattleGame', JSON.stringify({ const savedData = {
lobbyId: lobby.id, lobbyId: lobby.id,
playerName: currentPlayer.name playerName: currentPlayer.name,
})); lastKnownState: {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
} }
}, [lobby, currentPlayer]); }, [lobby, currentPlayer]);
// Helper function to update saved game data
const updateSavedGameData = (updatedLobby) => {
if (!updatedLobby || !currentPlayer) return;
const savedData = {
lobbyId: updatedLobby.id,
playerName: currentPlayer.name,
lastKnownState: {
gameState: updatedLobby.state,
currentBattle: updatedLobby.currentBattle,
playerState: {
isReady: updatedLobby.players?.find(p => p.id === currentPlayer.id)?.isReady,
hasVoted: updatedLobby.currentBattle?.votes?.[currentPlayer.id] !== undefined
},
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
};
// Clear error after 5 seconds // Clear error after 5 seconds
useEffect(() => { useEffect(() => {
@ -263,7 +490,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('create_lobby', { playerName }, (response) => { safeEmit('create_lobby', { playerName }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} else { } else {
@ -284,7 +511,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('join_lobby', { lobbyId, playerName }, (response) => { safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} else { } else {
@ -305,7 +532,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('update_settings', { settings }, (response) => { safeEmit('update_settings', { settings }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} }
@ -319,7 +546,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('start_game', {}, (response) => { safeEmit('start_game', {}, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} }
@ -338,7 +565,7 @@ export function GameProvider({ children }) {
console.log('Current lobby state before adding song:', lobby); console.log('Current lobby state before adding song:', lobby);
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
socket.emit('add_song', { song }, (response) => { safeEmit('add_song', { song }, (response) => {
console.log('Song addition response:', response); console.log('Song addition response:', response);
if (response.error) { if (response.error) {
console.error('Error adding song:', response.error); console.error('Error adding song:', response.error);
@ -368,6 +595,9 @@ export function GameProvider({ children }) {
console.log('Updated lobby that was set:', updatedLobby); console.log('Updated lobby that was set:', updatedLobby);
}, 0); }, 0);
// Store the latest state for offline reconciliation
updateSavedGameData(updatedLobby);
// Resolve with the updated lobby // Resolve with the updated lobby
resolve(updatedLobby); resolve(updatedLobby);
} else { } else {
@ -378,8 +608,7 @@ export function GameProvider({ children }) {
}); });
}); });
}; };
// Search for songs on YouTube
// Search for songs on YouTube
const searchYouTube = (query) => { const searchYouTube = (query) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (!socket || !isConnected) { if (!socket || !isConnected) {
@ -388,7 +617,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('search_youtube', { query }, (response) => { safeEmit('search_youtube', { query }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
reject(response.error); reject(response.error);
@ -398,7 +627,7 @@ export function GameProvider({ children }) {
}); });
}); });
}; };
// Get metadata for a YouTube video by ID // Get metadata for a YouTube video by ID
const getYouTubeMetadata = (videoId) => { const getYouTubeMetadata = (videoId) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -408,7 +637,7 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('get_video_metadata', { videoId }, (response) => { safeEmit('get_video_metadata', { videoId }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
reject(response.error); reject(response.error);
@ -426,9 +655,12 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('remove_song', { songId }, (response) => { safeEmit('remove_song', { songId }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
} }
}); });
}; };
@ -440,9 +672,12 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('player_ready', {}, (response) => { safeEmit('player_ready', {}, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
} }
}); });
}; };
@ -454,9 +689,12 @@ export function GameProvider({ children }) {
return; return;
} }
socket.emit('submit_vote', { songId }, (response) => { safeEmit('submit_vote', { songId }, (response) => {
if (response.error) { if (response.error) {
setError(response.error); setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
} }
}); });
}; };
@ -474,6 +712,8 @@ export function GameProvider({ children }) {
error, error,
currentPlayer, currentPlayer,
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId, isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
isOffline: isOffline,
isReconnecting,
createLobby, createLobby,
joinLobby, joinLobby,
updateSettings, updateSettings,

View File

@ -112,9 +112,10 @@ class GameManager {
* @param {string} playerId - New ID of the reconnecting player * @param {string} playerId - New ID of the reconnecting player
* @param {string} lobbyId - ID of the lobby * @param {string} lobbyId - ID of the lobby
* @param {string} playerName - Name of the player * @param {string} playerName - Name of the player
* @param {Object} lastKnownState - Last known game state from client
* @returns {Object} Lobby data or error * @returns {Object} Lobby data or error
*/ */
handleReconnect(playerId, lobbyId, playerName) { handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) {
// Check if lobby exists // Check if lobby exists
if (!this.lobbies.has(lobbyId)) { if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' }; return { error: 'Lobby not found' };
@ -135,6 +136,65 @@ class GameManager {
// Update map // Update map
this.playerToLobby.set(playerId, lobbyId); this.playerToLobby.set(playerId, lobbyId);
// Handle state reconciliation if lastKnownState is provided
if (lastKnownState && lastKnownState.gameState && lastKnownState.timestamp) {
console.log(`Reconciling player state based on last known state: ${lastKnownState.gameState} from ${new Date(lastKnownState.timestamp).toISOString()}`);
// If the client's last known state was VOTING and the current battle has votes
if (lastKnownState.gameState === 'VOTING' &&
lastKnownState.currentBattle &&
lastKnownState.playerState &&
lastKnownState.playerState.hasVoted) {
// The player already voted in this battle according to their local state
// If we still have the same battle, ensure their vote is counted
if (lobby.currentBattle &&
lastKnownState.currentBattle.round === lobby.currentBattle.round) {
console.log(`Player ${playerName} had voted in battle round ${lastKnownState.currentBattle.round}, checking if vote needs restoration`);
// Check if their vote is missing
const hasVote = lobby.currentBattle.votes &&
(lobby.currentBattle.votes.has(playerId) ||
Object.keys(lobby.currentBattle.votes).includes(playerId));
// If their vote is missing, we need to determine which song they voted for
if (!hasVote && lastKnownState.currentBattle.votes) {
// Find which song they voted for
const votedSongId = lastKnownState.currentBattle.votes[lobby.players[playerIndex].id];
if (votedSongId) {
console.log(`Restoring player ${playerName}'s vote for song ${votedSongId}`);
// Recreate their vote
this.submitVote(playerId, votedSongId, true);
}
}
}
}
// If the client's last known state was SONG_SUBMISSION and they were ready
if (lastKnownState.gameState === 'SONG_SUBMISSION' &&
lastKnownState.playerState &&
lastKnownState.playerState.isReady &&
lobby.state === 'SONG_SUBMISSION' &&
!lobby.players[playerIndex].isReady) {
console.log(`Restoring ready status for player ${playerName}`);
lobby.players[playerIndex].isReady = true;
// Check if all players are now ready
const allReady = lobby.players.every(p => p.isReady || !p.isConnected);
if (allReady) {
console.log('All players ready after reconnection, starting tournament...');
this._startTournament(lobby);
return {
lobby,
lobbyId,
tournamentStarted: true
};
}
}
}
return { lobby, lobbyId }; return { lobby, lobbyId };
} }
@ -220,7 +280,6 @@ class GameManager {
// Check if player is in a lobby // Check if player is in a lobby
let lobbyId = this.playerToLobby.get(playerId); let lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) { if (!lobbyId) {
// If no mapping exists, try to find the player in any lobby
console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`); console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`);
// Search all lobbies for this player // Search all lobbies for this player
@ -266,31 +325,6 @@ class GameManager {
const playerIndex = lobby.players.findIndex(p => p.id === playerId); const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) { if (playerIndex === -1) {
console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`); console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`);
// First try: Find the host if this is a host request
if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) {
console.log('[DEBUG] This appears to be the host. Looking for host player...');
const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId);
if (hostIndex !== -1) {
console.log(`[DEBUG] Found host at index ${hostIndex}`);
return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId);
}
}
// Second try: Match any connected player as fallback
console.log('[DEBUG] Trying to find any connected player...');
const connectedIndex = lobby.players.findIndex(p => p.isConnected);
if (connectedIndex !== -1) {
console.log(`[DEBUG] Found connected player at index ${connectedIndex}`);
return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId);
}
// If we still can't find a match, use the first player as last resort
if (lobby.players.length > 0) {
console.log('[DEBUG] Using first player as last resort');
return this._addSongToPlayer(0, lobby, lobbyId, song, playerId);
}
return { error: 'Player not found in lobby' }; return { error: 'Player not found in lobby' };
} }
@ -519,9 +553,10 @@ class GameManager {
* Submit a vote for a song in a battle * Submit a vote for a song in a battle
* @param {string} playerId - ID of the voting player * @param {string} playerId - ID of the voting player
* @param {string} songId - ID of the voted song * @param {string} songId - ID of the voted song
* @param {boolean} isRestoredVote - Whether this is a restored vote during reconnection
* @returns {Object} Updated lobby data or error * @returns {Object} Updated lobby data or error
*/ */
submitVote(playerId, songId) { submitVote(playerId, songId, isRestoredVote = false) {
const lobbyId = this.playerToLobby.get(playerId); const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) { if (!lobbyId) {
return { error: 'Player not in a lobby' }; return { error: 'Player not in a lobby' };
@ -545,7 +580,7 @@ class GameManager {
} }
// Check if player has already voted in this battle // Check if player has already voted in this battle
if (lobby.currentBattle.votes && lobby.currentBattle.votes.has(playerId)) { if (lobby.currentBattle.votes && Object.prototype.hasOwnProperty.call(lobby.currentBattle.votes, playerId) && !isRestoredVote) {
return { error: 'Already voted in this battle' }; return { error: 'Already voted in this battle' };
} }
@ -559,16 +594,16 @@ class GameManager {
const player = lobby.players.find(p => p.id === playerId); const player = lobby.players.find(p => p.id === playerId);
const playerName = player ? player.name : 'Unknown Player'; const playerName = player ? player.name : 'Unknown Player';
// Initialize votes Map if it doesn't exist // Initialize votes Object if it doesn't exist
if (!lobby.currentBattle.votes) { if (!lobby.currentBattle.votes) {
lobby.currentBattle.votes = new Map(); lobby.currentBattle.votes = {};
} }
// Record the vote with player name for UI display // Record the vote with player name for UI display
lobby.currentBattle.votes.set(playerId, { lobby.currentBattle.votes[playerId] = {
songId, songId,
playerName playerName
}); };
// Update vote counts // Update vote counts
if (songId === lobby.currentBattle.song1.id) { if (songId === lobby.currentBattle.song1.id) {
@ -578,7 +613,7 @@ class GameManager {
} }
// Add a voteCount attribute for easier UI rendering // Add a voteCount attribute for easier UI rendering
lobby.currentBattle.voteCount = lobby.currentBattle.votes.size; lobby.currentBattle.voteCount = Object.keys(lobby.currentBattle.votes).length;
// For bye battles, the host's vote is all that's needed // For bye battles, the host's vote is all that's needed
if (lobby.currentBattle.bye === true && playerId === lobby.hostId) { if (lobby.currentBattle.bye === true && playerId === lobby.hostId) {
@ -605,7 +640,7 @@ class GameManager {
// For regular battles, check if all connected players have voted // For regular battles, check if all connected players have voted
const connectedPlayers = lobby.players.filter(p => p.isConnected).length; const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
const voteCount = lobby.currentBattle.votes.size; const voteCount = Object.keys(lobby.currentBattle.votes).length;
if (voteCount >= connectedPlayers) { if (voteCount >= connectedPlayers) {
// Determine winner // Determine winner
@ -680,7 +715,7 @@ class GameManager {
// For voting state, check if we need to progress // For voting state, check if we need to progress
if (lobby.state === 'VOTING' && lobby.currentBattle) { if (lobby.state === 'VOTING' && lobby.currentBattle) {
// Add null check for votes property // Add null check for votes property
const totalVotes = lobby.currentBattle.votes?.size || 0; const totalVotes = lobby.currentBattle.votes ? Object.keys(lobby.currentBattle.votes).length : 0;
const remainingPlayers = lobby.players.filter(p => p.isConnected).length; const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
if (totalVotes >= remainingPlayers && totalVotes > 0) { if (totalVotes >= remainingPlayers && totalVotes > 0) {
@ -765,7 +800,7 @@ class GameManager {
winner: null, // Set to null until host advances it winner: null, // Set to null until host advances it
song1Votes: 0, song1Votes: 0,
song2Votes: 0, song2Votes: 0,
votes: new Map() // Initialize votes as a Map votes: {} // Initialize votes as an empty object
}); });
} }
@ -779,7 +814,7 @@ class GameManager {
song1Votes: 0, song1Votes: 0,
song2Votes: 0, song2Votes: 0,
winner: null, winner: null,
votes: new Map() votes: {}
}); });
} }
} }
@ -795,8 +830,8 @@ class GameManager {
// Ensure we create a proper battle object with all required fields // Ensure we create a proper battle object with all required fields
lobby.currentBattle = { lobby.currentBattle = {
...brackets[0], ...brackets[0],
// Make sure votes is a Map (it might be serialized incorrectly) // Make sure votes is an object (not a Map)
votes: brackets[0].votes || new Map(), votes: brackets[0].votes || {},
voteCount: 0 voteCount: 0
}; };
@ -863,24 +898,22 @@ class GameManager {
winner: null, // Set to null until host advances it winner: null, // Set to null until host advances it
song1Votes: 0, song1Votes: 0,
song2Votes: 0, song2Votes: 0,
votes: new Map() // Initialize votes as a Map votes: {} // Initialize votes as an object
}); });
} } // Create pairs for next round
for (let i = 0; i < nextRoundSongs.length; i += 2) {
// Create pairs for next round if (i + 1 < nextRoundSongs.length) {
for (let i = 0; i < nextRoundSongs.length; i += 2) { nextRound.push({
if (i + 1 < nextRoundSongs.length) { round,
nextRound.push({ song1: nextRoundSongs[i],
round, song2: nextRoundSongs[i + 1],
song1: nextRoundSongs[i], song1Votes: 0,
song2: nextRoundSongs[i + 1], song2Votes: 0,
song1Votes: 0, winner: null,
song2Votes: 0, votes: {}
winner: null, });
votes: new Map() }
});
} }
}
// Update brackets and reset index // Update brackets and reset index
lobby.brackets = nextRound; lobby.brackets = nextRound;
@ -890,9 +923,9 @@ class GameManager {
if (nextRound.length > 0) { if (nextRound.length > 0) {
lobby.currentBattle = nextRound[0]; lobby.currentBattle = nextRound[0];
// Ensure votes is initialized as a Map // Ensure votes is initialized as an object
if (!lobby.currentBattle.votes) { if (!lobby.currentBattle.votes) {
lobby.currentBattle.votes = new Map(); lobby.currentBattle.votes = {};
} }
// Initialize vote count for UI // Initialize vote count for UI

View File

@ -73,9 +73,9 @@ io.on('connection', (socket) => {
}); });
// Attempt to reconnect to a lobby // Attempt to reconnect to a lobby
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => { socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => {
try { try {
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName); const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState);
if (result.error) { if (result.error) {
if (callback) callback(result); if (callback) callback(result);
@ -337,7 +337,7 @@ io.on('connection', (socket) => {
} }
}); });
// Handle disconnection // Handle player disconnection
socket.on('disconnect', () => { socket.on('disconnect', () => {
try { try {
const result = gameManager.handleDisconnect(socket.id); const result = gameManager.handleDisconnect(socket.id);
@ -355,6 +355,13 @@ io.on('connection', (socket) => {
console.error('Error handling disconnect:', error); console.error('Error handling disconnect:', error);
} }
}); });
// Simple ping handler to help with connection testing
socket.on('ping', (callback) => {
if (callback && typeof callback === 'function') {
callback({ success: true, timestamp: Date.now() });
}
});
}); });
server.on('error', (error) => { server.on('error', (error) => {