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
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m20s
This commit is contained in:
@ -7,6 +7,7 @@ import HomeScreen from "./components/HomeScreen";
|
||||
import SongSubmissionScreen from "./components/SongSubmissionScreen";
|
||||
import VotingScreen from "./components/VotingScreen";
|
||||
import ResultsScreen from "./components/ResultsScreen";
|
||||
import ConnectionStatus from "./components/ConnectionStatus";
|
||||
|
||||
const App = () => {
|
||||
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
|
||||
@ -84,12 +85,6 @@ const App = () => {
|
||||
</div>
|
||||
|
||||
<div className="content-container">
|
||||
{!isConnected && (
|
||||
<div className="connection-status">
|
||||
<p>Connecting to server...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="error-message">
|
||||
<p>{error}</p>
|
||||
@ -97,6 +92,9 @@ const App = () => {
|
||||
)}
|
||||
|
||||
{renderGameScreen()}
|
||||
|
||||
{/* Show connection status component */}
|
||||
<ConnectionStatus />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
55
client/src/common/styles/components/connection-status.sass
Normal file
55
client/src/common/styles/components/connection-status.sass
Normal 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%)
|
@ -253,11 +253,46 @@
|
||||
.voting-actions
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
margin: 2rem 0
|
||||
|
||||
.btn
|
||||
min-width: 180px
|
||||
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
|
||||
@ -274,6 +309,17 @@
|
||||
font-size: 0.9rem
|
||||
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
|
||||
margin: 1rem auto
|
||||
max-width: 400px
|
||||
@ -297,6 +343,11 @@
|
||||
color: $primary
|
||||
font-weight: bold
|
||||
|
||||
.offline-badge
|
||||
color: $secondary
|
||||
margin-left: 0.5rem
|
||||
font-size: 0.7rem
|
||||
|
||||
// Player votes list styling
|
||||
.player-votes
|
||||
background-color: rgba(0, 0, 0, 0.2)
|
||||
@ -368,6 +419,21 @@
|
||||
background-color: rgba(255, 255, 255, 0.05)
|
||||
border-color: rgba(255, 255, 255, 0.1)
|
||||
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
|
||||
0%, 100%
|
||||
|
@ -12,6 +12,7 @@
|
||||
@import './components/youtube-embed'
|
||||
@import './components/youtube-search'
|
||||
@import './components/song-form-overlay'
|
||||
@import './components/connection-status'
|
||||
|
||||
// Global styles
|
||||
html, body
|
||||
|
70
client/src/components/ConnectionStatus.jsx
Normal file
70
client/src/components/ConnectionStatus.jsx
Normal 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;
|
@ -2,15 +2,16 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { useGame } from '../context/GameContext';
|
||||
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';
|
||||
|
||||
function VotingScreen() {
|
||||
const { lobby, currentPlayer, submitVote, isHost } = useGame();
|
||||
const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
|
||||
const [hasVoted, setHasVoted] = useState(false);
|
||||
const [selectedSong, setSelectedSong] = useState(null);
|
||||
const [countdown, setCountdown] = useState(null);
|
||||
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
|
||||
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
|
||||
|
||||
// Hole aktuellen Kampf
|
||||
const battle = lobby?.currentBattle || null;
|
||||
@ -39,10 +40,10 @@ function VotingScreen() {
|
||||
useEffect(() => {
|
||||
if (battle && battle.votes && currentPlayer) {
|
||||
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
|
||||
// Da Stimmen als Objekt, nicht als Map gesendet werden
|
||||
const votesObj = battle.votes || {};
|
||||
setHasVoted(Object.prototype.hasOwnProperty.call(votesObj, currentPlayer.id) ||
|
||||
Object.keys(votesObj).includes(currentPlayer.id));
|
||||
setHasVoted(
|
||||
Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
|
||||
battle.votes[currentPlayer.id] !== undefined
|
||||
);
|
||||
} else {
|
||||
setHasVoted(false);
|
||||
}
|
||||
@ -59,8 +60,41 @@ function VotingScreen() {
|
||||
const handleSubmitVote = async () => {
|
||||
if (!selectedSong || hasVoted) return;
|
||||
|
||||
await submitVote(selectedSong);
|
||||
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
|
||||
try {
|
||||
// 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
|
||||
@ -69,12 +103,18 @@ function VotingScreen() {
|
||||
|
||||
setProcessingByeAdvance(true);
|
||||
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) {
|
||||
await submitVote(battle.song1.id);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Fortfahren:", error);
|
||||
setOfflineVoteStatus('error');
|
||||
} finally {
|
||||
// Verzögerung, um mehrere Klicks zu verhindern
|
||||
setTimeout(() => setProcessingByeAdvance(false), 1000);
|
||||
@ -258,19 +298,46 @@ function VotingScreen() {
|
||||
{!hasVoted && (
|
||||
<div className="voting-actions">
|
||||
<button
|
||||
className="btn primary"
|
||||
className={`btn primary ${isOffline ? 'offline' : ''}`}
|
||||
onClick={handleSubmitVote}
|
||||
disabled={!selectedSong}
|
||||
>
|
||||
Abstimmen
|
||||
{isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
|
||||
</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 className="voting-status">
|
||||
<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">
|
||||
<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>
|
||||
|
||||
{/* Liste der Spielerstimmen */}
|
||||
@ -282,12 +349,20 @@ function VotingScreen() {
|
||||
const hasPlayerVoted = battle.votes &&
|
||||
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 (
|
||||
<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)'}
|
||||
{hasPlayerVoted &&
|
||||
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
|
||||
}
|
||||
{isCurrentPlayerOfflineVoted &&
|
||||
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
|
||||
}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
|
@ -11,35 +11,61 @@ const GameContext = createContext(null);
|
||||
export function SocketProvider({ children }) {
|
||||
const [socket, setSocket] = useState(null);
|
||||
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(() => {
|
||||
// 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, {
|
||||
autoConnect: true,
|
||||
reconnection: true,
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
reconnectionAttempts: 5
|
||||
reconnectionDelayMax: 10000,
|
||||
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
|
||||
socketInstance.on('connect', () => {
|
||||
setIsConnected(true);
|
||||
setIsReconnecting(false);
|
||||
setReconnectionAttempts(0);
|
||||
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
|
||||
const savedGameData = localStorage.getItem('songBattleGame');
|
||||
if (savedGameData) {
|
||||
try {
|
||||
const { lobbyId, playerName } = JSON.parse(savedGameData);
|
||||
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
|
||||
if (lobbyId && playerName) {
|
||||
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) {
|
||||
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 {
|
||||
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);
|
||||
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) => {
|
||||
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);
|
||||
|
||||
// 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 () => {
|
||||
clearInterval(heartbeatInterval);
|
||||
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 (
|
||||
<SocketContext.Provider value={{ socket, isConnected }}>
|
||||
<SocketContext.Provider value={{
|
||||
socket,
|
||||
isConnected,
|
||||
isReconnecting,
|
||||
reconnectionAttempts,
|
||||
isOffline,
|
||||
safeEmit,
|
||||
queueOfflineAction
|
||||
}}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
@ -78,7 +279,7 @@ export function SocketProvider({ children }) {
|
||||
* Game state provider - manages game state and provides methods for game actions
|
||||
*/
|
||||
export function GameProvider({ children }) {
|
||||
const { socket, isConnected } = useContext(SocketContext);
|
||||
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
|
||||
const [lobby, setLobby] = useState(null);
|
||||
const [error, setError] = useState(null);
|
||||
const [currentPlayer, setCurrentPlayer] = useState(null);
|
||||
@ -86,12 +287,38 @@ export function GameProvider({ children }) {
|
||||
// Save game info when lobby is joined
|
||||
useEffect(() => {
|
||||
if (lobby && currentPlayer) {
|
||||
sessionStorage.setItem('songBattleGame', JSON.stringify({
|
||||
const savedData = {
|
||||
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]);
|
||||
|
||||
// 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
|
||||
useEffect(() => {
|
||||
@ -263,7 +490,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('create_lobby', { playerName }, (response) => {
|
||||
safeEmit('create_lobby', { playerName }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else {
|
||||
@ -284,7 +511,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else {
|
||||
@ -305,7 +532,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('update_settings', { settings }, (response) => {
|
||||
safeEmit('update_settings', { settings }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
@ -319,7 +546,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('start_game', {}, (response) => {
|
||||
safeEmit('start_game', {}, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
}
|
||||
@ -338,7 +565,7 @@ export function GameProvider({ children }) {
|
||||
console.log('Current lobby state before adding song:', lobby);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
socket.emit('add_song', { song }, (response) => {
|
||||
safeEmit('add_song', { song }, (response) => {
|
||||
console.log('Song addition response:', response);
|
||||
if (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);
|
||||
}, 0);
|
||||
|
||||
// Store the latest state for offline reconciliation
|
||||
updateSavedGameData(updatedLobby);
|
||||
|
||||
// Resolve with the updated lobby
|
||||
resolve(updatedLobby);
|
||||
} else {
|
||||
@ -378,8 +608,7 @@ export function GameProvider({ children }) {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// Search for songs on YouTube
|
||||
// Search for songs on YouTube
|
||||
const searchYouTube = (query) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!socket || !isConnected) {
|
||||
@ -388,7 +617,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('search_youtube', { query }, (response) => {
|
||||
safeEmit('search_youtube', { query }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
reject(response.error);
|
||||
@ -398,7 +627,7 @@ export function GameProvider({ children }) {
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
// Get metadata for a YouTube video by ID
|
||||
const getYouTubeMetadata = (videoId) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -408,7 +637,7 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('get_video_metadata', { videoId }, (response) => {
|
||||
safeEmit('get_video_metadata', { videoId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
reject(response.error);
|
||||
@ -426,9 +655,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('remove_song', { songId }, (response) => {
|
||||
safeEmit('remove_song', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -440,9 +672,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('player_ready', {}, (response) => {
|
||||
safeEmit('player_ready', {}, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -454,9 +689,12 @@ export function GameProvider({ children }) {
|
||||
return;
|
||||
}
|
||||
|
||||
socket.emit('submit_vote', { songId }, (response) => {
|
||||
safeEmit('submit_vote', { songId }, (response) => {
|
||||
if (response.error) {
|
||||
setError(response.error);
|
||||
} else if (response.lobby) {
|
||||
setLobby(response.lobby);
|
||||
updateSavedGameData(response.lobby);
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -474,6 +712,8 @@ export function GameProvider({ children }) {
|
||||
error,
|
||||
currentPlayer,
|
||||
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
|
||||
isOffline: isOffline,
|
||||
isReconnecting,
|
||||
createLobby,
|
||||
joinLobby,
|
||||
updateSettings,
|
||||
|
Reference in New Issue
Block a user