diff --git a/app.js b/app.js
index 3a8af83..2041b67 100644
--- a/app.js
+++ b/app.js
@@ -1,1145 +1,478 @@
-// Import the audio manager
+// app.js - Main Application Orchestrator
+import * as config from './config.js';
+import * as state from './state.js';
+import * as ui from './ui.js';
+import * as timer from './timer.js';
+import camera from './camera.js'; // Default export
import audioManager from './audio.js';
import deepLinkManager from './deeplinks.js';
+import * as pushFlic from './pushFlicIntegration.js';
-// Initialize variables
-let players = [];
-let currentPlayerIndex = 0;
-let gameState = 'setup'; // setup, running, paused, over
-let carouselPosition = 0;
-let startX = 0;
-let currentX = 0;
-let pushSubscription = null;
-const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
-const BACKEND_URL = 'https://webpush.virtonline.eu';
-const BUTTON_ID = 'game-button';
-const SINGLE_CLICK = 'SingleClick';
+// --- Core Game Actions ---
-// DOM Elements
-const carousel = document.getElementById('carousel');
-const gameButton = document.getElementById('gameButton');
-const setupButton = document.getElementById('setupButton');
-const addPlayerButton = document.getElementById('addPlayerButton');
-const resetButton = document.getElementById('resetButton');
-const playerModal = document.getElementById('playerModal');
-const resetModal = document.getElementById('resetModal');
-const playerForm = document.getElementById('playerForm');
-const cancelButton = document.getElementById('cancelButton');
-const deletePlayerButton = document.getElementById('deletePlayerButton');
-const resetCancelButton = document.getElementById('resetCancelButton');
-const resetConfirmButton = document.getElementById('resetConfirmButton');
-const playerImage = document.getElementById('playerImage');
-const imagePreview = document.getElementById('imagePreview');
-const playerTimeContainer = document.getElementById('playerTimeContainer');
-const remainingTimeContainer = document.getElementById('remainingTimeContainer');
-const playerRemainingTime = document.getElementById('playerRemainingTime');
-const cameraButton = document.getElementById('cameraButton');
-const cameraContainer = document.getElementById('cameraContainer');
-const cameraView = document.getElementById('cameraView');
-const cameraCanvas = document.getElementById('cameraCanvas');
-const cameraCaptureButton = document.getElementById('cameraCaptureButton');
-const cameraCancelButton = document.getElementById('cameraCancelButton');
-
-let stream = null;
-
-// Get stored basic auth credentials or prompt user for them
-function getBasicAuthCredentials() {
- // Try to get stored credentials from localStorage
- const storedAuth = localStorage.getItem('basicAuthCredentials');
- if (storedAuth) {
- try {
- return JSON.parse(storedAuth);
- } catch (error) {
- console.error('Failed to parse stored credentials:', error);
- // Fall through to prompt
- }
- }
-
- // If no stored credentials, prompt the user
- const username = prompt('Please enter your username for authentication:');
- if (!username) return null;
-
- const password = prompt('Please enter your password:');
- if (!password) return null;
-
- // Store the credentials for future use
- const credentials = { username, password };
- localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
-
- return credentials;
-}
-
-// Create Basic Auth header
-function createBasicAuthHeader(credentials) {
- if (!credentials || !credentials.username || !credentials.password) {
- return null;
- }
-
- return 'Basic ' + btoa(`${credentials.username}:${credentials.password}`);
-}
-
-async function subscribeToPushNotifications() {
- let buttonId = BUTTON_ID;
- // 1. Validate input buttonId
- if (!buttonId || typeof buttonId !== 'string' || buttonId.trim() === '') {
- console.error('Button ID is required to subscribe.');
- alert('Please provide a valid Flic Button ID/Serial.');
+function startGame() {
+ if (state.getPlayers().length < 2) {
+ alert('You need at least 2 players to start.');
return;
}
-
- // 2. Check for browser support
- if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
- console.error('Push Messaging is not supported');
- alert('Sorry, Push Notifications are not supported by your browser.');
- return;
- }
-
- try {
- // 3. Request notification permission (requires user interaction)
- const permission = await Notification.requestPermission();
- if (permission !== 'granted') {
- console.error('Notification permission not granted.');
- alert('You denied notification permission. Please enable it in browser settings if you want to link the button.');
- return;
- }
- console.log('Notification permission granted.');
-
- // 4. Get Service Worker registration
- const registration = await navigator.serviceWorker.ready;
- console.log('Service Worker is ready.');
-
- // 5. Get existing subscription
- let existingSubscription = await registration.pushManager.getSubscription();
- let needsResubscribe = false;
-
- if (existingSubscription) {
- console.log('Existing subscription found.');
-
- // 6. Compare applicationServerKeys
- const existingKeyArrayBuffer = existingSubscription.options.applicationServerKey;
- if (!existingKeyArrayBuffer) {
- console.warn("Existing subscription doesn't have an applicationServerKey.");
- needsResubscribe = true; // Treat as needing resubscription
- } else {
- const existingKeyBase64 = arrayBufferToBase64(existingKeyArrayBuffer);
- console.log('Existing VAPID Key (Base64):', existingKeyBase64);
- console.log('Current VAPID Key (Base64): ', PUBLIC_VAPID_KEY);
-
- if (existingKeyBase64 !== PUBLIC_VAPID_KEY) {
- console.log('VAPID keys DO NOT match. Unsubscribing the old one.');
- await existingSubscription.unsubscribe();
- console.log('Successfully unsubscribed old subscription.');
- existingSubscription = null; // Clear it so we subscribe anew below
- needsResubscribe = true; // Explicitly flag for clarity
- } else {
- console.log('VAPID keys match. No need to resubscribe.');
- needsResubscribe = false;
- }
- }
- } else {
- console.log('No existing subscription found.');
- needsResubscribe = true; // No subscription exists, so we need one
- }
-
- // 7. Subscribe if needed (no existing sub or keys mismatched)
- let finalSubscription = existingSubscription; // Use existing if keys matched
- if (needsResubscribe || !finalSubscription) {
- console.log('Attempting to subscribe with current VAPID key...');
- const applicationServerKey = urlBase64ToUint8Array(PUBLIC_VAPID_KEY);
- finalSubscription = await registration.pushManager.subscribe({
- userVisibleOnly: true,
- applicationServerKey: applicationServerKey
- });
- console.log('New push subscription obtained:', finalSubscription);
- }
-
- // 8. Send the final subscription (new or validated existing) to your server
- if (!finalSubscription) {
- console.error("Failed to obtain a final subscription object.");
- alert("Could not get subscription details. Please try again.");
- return;
- }
-
- console.log(`Sending subscription for button "${buttonId}" to backend...`);
-
- // Get basic auth credentials
- const credentials = getBasicAuthCredentials();
- if (!credentials) {
- console.error('Authentication credentials are required.');
- alert('Authentication failed. Please try again with valid credentials.');
- return;
- }
-
- // Create headers with auth
- const headers = {
- 'Content-Type': 'application/json'
- };
-
- // Add Authorization header with Basic Auth if credentials are available
- const authHeader = createBasicAuthHeader(credentials);
- if (authHeader) {
- headers['Authorization'] = authHeader;
- }
-
- const response = await fetch(`${BACKEND_URL}/subscribe`, {
- method: 'POST',
- body: JSON.stringify({
- button_id: buttonId,
- subscription: finalSubscription
- }),
- headers: headers
- });
-
- // 9. Handle the server response
- if (response.ok) {
- const result = await response.json();
- console.log('Push subscription successfully sent to server:', result.message);
- } else {
- let errorMessage = `Server error: ${response.status}`;
-
- // If it's an auth error, clear stored credentials and try again
- if (response.status === 401 || response.status === 403) {
- localStorage.removeItem('basicAuthCredentials');
- errorMessage = 'Authentication failed. Please try again with valid credentials.';
- }
-
- try {
- const errorResult = await response.json();
- errorMessage = errorResult.message || errorMessage;
- } catch (e) {
- errorMessage = response.statusText || errorMessage;
- }
- console.error('Failed to send push subscription to server:', errorMessage);
- alert(`Failed to save notification settings on server: ${errorMessage}`);
- }
-
- } catch (error) {
- console.error('Error during push subscription process:', error);
- // Handle potential errors from permission request, SW registration, get/unsubscribe/subscribe, or fetch network issues
- if (error.name === 'InvalidStateError') {
- alert(`Subscription failed: ${error.message}. Please try again or ensure no conflicting subscriptions exist.`);
- } else {
- alert(`An error occurred: ${error.message}`);
- }
+ if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) {
+ state.setGameState(config.GAME_STATES.RUNNING);
+ audioManager.play('gameStart');
+ timer.startTimer();
+ ui.updateGameButton();
+ ui.renderPlayers(); // Ensure active timer styling is applied
}
}
-// Helper to convert ArrayBuffer to Base64 string (URL safe)
-function arrayBufferToBase64(buffer) {
- let binary = '';
- const bytes = new Uint8Array(buffer);
- const len = bytes.byteLength;
- for (let i = 0; i < len; i++) {
- binary += String.fromCharCode(bytes[i]);
+function pauseGame() {
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ state.setGameState(config.GAME_STATES.PAUSED);
+ audioManager.play('gamePause');
+ timer.stopTimer();
+ ui.updateGameButton();
+ ui.renderPlayers(); // Ensure active timer styling is removed
}
- return window.btoa(binary)
- .replace(/\+/g, '-')
- .replace(/\//g, '_')
- .replace(/=+$/, ''); // Remove padding
-}
-
-function urlBase64ToUint8Array(base64String) {
- const padding = '='.repeat((4 - base64String.length % 4) % 4);
- const base64 = (base64String + padding)
- .replace(/\-/g, '+')
- .replace(/_/g, '/');
-
- const rawData = window.atob(base64);
- const outputArray = new Uint8Array(rawData.length);
-
- for (let i = 0; i < rawData.length; ++i) {
- outputArray[i] = rawData.charCodeAt(i);
- }
- return outputArray;
}
-// Add sound toggle button
-const createSoundToggleButton = () => {
- const headerButtons = document.querySelector('.header-buttons');
- const soundButton = document.createElement('button');
- soundButton.id = 'soundToggleButton';
- soundButton.className = 'header-button';
- soundButton.title = 'Toggle Sound';
- soundButton.innerHTML = '';
-
- soundButton.addEventListener('click', () => {
- const isMuted = audioManager.toggleMute();
- soundButton.innerHTML = isMuted ?
- '' :
- '';
-
- // Play feedback sound if unmuting
- if (!isMuted) {
- audioManager.play('buttonClick');
- }
- });
-
- headerButtons.prepend(soundButton);
-
- // Set initial icon state based on mute setting
- if (audioManager.muted) {
- soundButton.innerHTML = '';
+function resumeGame() {
+ if (state.getGameState() === config.GAME_STATES.PAUSED) {
+ // Check if there's actually a player with time left
+ if (state.findNextPlayerWithTime() === -1) {
+ console.log("Cannot resume, no players have time left.");
+ // Optionally set state to OVER here
+ handleGameOver();
+ return;
+ }
+ state.setGameState(config.GAME_STATES.RUNNING);
+ audioManager.play('gameResume');
+ timer.startTimer();
+ ui.updateGameButton();
+ ui.renderPlayers(); // Ensure active timer styling is applied
}
-};
+}
-// Create the sound toggle button when page loads
-createSoundToggleButton();
+function togglePauseResume() {
+ const currentGameState = state.getGameState();
+ if (currentGameState === config.GAME_STATES.RUNNING) {
+ pauseGame();
+ } else if (currentGameState === config.GAME_STATES.PAUSED) {
+ resumeGame();
+ } else if (currentGameState === config.GAME_STATES.SETUP) {
+ startGame();
+ } else if (currentGameState === config.GAME_STATES.OVER) {
+ resetGame(); // Or just go back to setup? Let's reset.
+ startGame();
+ }
+}
-// Load data from localStorage or use defaults
-function loadData() {
- const savedData = localStorage.getItem('gameTimerData');
- if (savedData) {
- const parsedData = JSON.parse(savedData);
- players = parsedData.players;
- gameState = parsedData.gameState || 'setup';
- currentPlayerIndex = parsedData.currentPlayerIndex || 0;
+function switchToPlayer(index) {
+ if (index >= 0 && index < state.getPlayers().length) {
+ const previousIndex = state.getCurrentPlayerIndex();
+ if(index !== previousIndex) {
+ state.setCurrentPlayerIndex(index);
+ audioManager.play('playerSwitch');
+ ui.renderPlayers(); // Update UI immediately
+
+ // If the game is running, restart the timer for the new player
+ // The timer interval callback will handle the decrementing
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ timer.startTimer(); // This clears the old interval and starts anew
+ }
+ }
+ }
+}
+
+function nextPlayer() {
+ const currentGameState = state.getGameState();
+ let newIndex = -1;
+
+ if (currentGameState === config.GAME_STATES.RUNNING) {
+ newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time
} else {
- // Default players if no saved data
- players = [
- { id: 1, name: 'Player 1', timeInSeconds: 300, remainingTime: 300, image: null },
- { id: 2, name: 'Player 2', timeInSeconds: 300, remainingTime: 300, image: null }
- ];
- saveData();
+ // Allow cycling through all players if not running
+ const playerCount = state.getPlayers().length;
+ if(playerCount > 0) {
+ newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount;
+ }
}
- renderPlayers();
- updateGameButton();
-}
-// Save data to localStorage
-function saveData() {
- const dataToSave = {
- players,
- gameState,
- currentPlayerIndex
- };
- localStorage.setItem('gameTimerData', JSON.stringify(dataToSave));
-}
-
-// Render players to carousel
-function renderPlayers() {
- carousel.innerHTML = '';
-
- players.forEach((player, index) => {
- const card = document.createElement('div');
- card.className = `player-card ${index === currentPlayerIndex ? 'active-player' : 'inactive-player'}`;
-
- const minutes = Math.floor(player.remainingTime / 60);
- const seconds = player.remainingTime % 60;
- const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
-
- // Create timer element with appropriate classes
- const timerClasses = [];
-
- // Add timer-active class if this is current player and game is running
- if (index === currentPlayerIndex && gameState === 'running') {
- timerClasses.push('timer-active');
- }
-
- // Add timer-finished class if player has no time left
- if (player.remainingTime <= 0) {
- timerClasses.push('timer-finished');
- }
-
- card.innerHTML = `
-
- ${player.image ? `

` : '
'}
-
- ${player.name}
- ${timeString}
- `;
-
- carousel.appendChild(card);
- });
-
- // Update carousel position
- carousel.style.transform = `translateX(${-100 * currentPlayerIndex}%)`;
-}
-
-// Update game button text based on game state
-function updateGameButton() {
- switch (gameState) {
- case 'setup':
- gameButton.textContent = 'Start Game';
- break;
- case 'running':
- gameButton.textContent = 'Pause Game';
- break;
- case 'paused':
- gameButton.textContent = 'Resume Game';
- break;
- case 'over':
- gameButton.textContent = 'Game Over';
- break;
+ if (newIndex !== -1) {
+ switchToPlayer(newIndex);
+ } else if (currentGameState === config.GAME_STATES.RUNNING) {
+ console.log("NextPlayer: No other player has time remaining.");
+ // Optionally handle game over immediately? Timer logic should catch this too.
}
}
-// Handle game button click
-gameButton.addEventListener('click', () => {
- // Play button click sound
- audioManager.play('buttonClick');
-
- if (players.length < 2) {
- alert('You need at least 2 players to start a game.');
- return;
- }
-
- switch (gameState) {
- case 'setup':
- gameState = 'running';
- audioManager.play('gameStart');
- startTimer();
- break;
- case 'running':
- gameState = 'paused';
- audioManager.play('gamePause');
- stopTimer();
- break;
- case 'paused':
- gameState = 'running';
- audioManager.play('gameResume');
- startTimer();
- break;
- case 'over':
- // Reset timers and start new game
- players.forEach(player => {
- player.remainingTime = player.timeInSeconds;
- });
- gameState = 'setup';
- // No specific sound for this state change
- break;
- }
-
- updateGameButton();
- renderPlayers(); // Make sure to re-render after state change
- saveData();
-});
+function previousPlayer() {
+ const currentGameState = state.getGameState();
+ let newIndex = -1;
-// Timer variables
-let timerInterval = null;
-
-// Check if all timers have reached zero
-function areAllTimersFinished() {
- return players.every(player => player.remainingTime <= 0);
-}
-
-// Find a player who still has time left
-function findNextPlayerWithTime() {
- const startIndex = (currentPlayerIndex + 1) % players.length;
- let index = startIndex;
-
- do {
- if (players[index].remainingTime > 0) {
- return index;
- }
- index = (index + 1) % players.length;
- } while (index !== startIndex);
-
- return -1; // No player has time left
-}
-
-// Start the timer for current player
-function startTimer() {
- if (timerInterval) clearInterval(timerInterval);
-
- // Stop any ongoing sounds when starting timer
- audioManager.stopAllSounds();
-
- // Immediately render to show the active timer effect
- renderPlayers();
-
- timerInterval = setInterval(() => {
- const currentPlayer = players[currentPlayerIndex];
-
- // Only decrease time if the current player has time left
- if (currentPlayer.remainingTime > 0) {
- currentPlayer.remainingTime--;
-
- // Play appropriate timer sounds based on remaining time
- audioManager.playTimerSound(currentPlayer.remainingTime);
- }
-
- // Check if current player's time is up
- if (currentPlayer.remainingTime <= 0) {
- currentPlayer.remainingTime = 0;
-
- // Play time expired sound
- audioManager.playTimerExpired();
-
- // Check if all timers are at zero
- if (areAllTimersFinished()) {
- gameState = 'over';
- audioManager.play('gameOver');
- updateGameButton();
- stopTimer();
- } else {
- // Find the next player who still has time
- const nextPlayerIndex = findNextPlayerWithTime();
- if (nextPlayerIndex !== -1) {
- currentPlayerIndex = nextPlayerIndex;
- // Play switch player sound
- audioManager.play('playerSwitch');
- }
- }
- }
-
- renderPlayers();
- saveData();
- }, 1000);
-}
-
-// Stop the timer
-function stopTimer() {
- clearInterval(timerInterval);
- timerInterval = null;
- renderPlayers(); // Make sure to re-render after stopping timer
-}
-
-// Carousel touch events
-let isDragging = false;
-
-carousel.addEventListener('touchstart', (e) => {
- startX = e.touches[0].clientX;
- currentX = startX;
- isDragging = true;
-});
-
-carousel.addEventListener('touchmove', (e) => {
- if (!isDragging) return;
-
- currentX = e.touches[0].clientX;
- const diff = currentX - startX;
- const currentTranslate = -100 * currentPlayerIndex + (diff / carousel.offsetWidth * 100);
-
- carousel.style.transform = `translateX(${currentTranslate}%)`;
-});
-
-carousel.addEventListener('touchend', (e) => {
- if (!isDragging) return;
-
- isDragging = false;
- const diff = currentX - startX;
-
- // If dragged more than 10% of width, change player
- if (Math.abs(diff) > carousel.offsetWidth * 0.1) {
- moveCarousel(diff);
- }
-
- // Reset carousel to proper position
- carousel.style.transform = `translateX(${-100 * currentPlayerIndex}%)`;
- renderPlayers();
- saveData();
-});
-
-function moveCarousel(diff) {
- const previousIndex = currentPlayerIndex;
-
- // Only change players that have remaining time during a running game
- if (gameState === 'running') {
- let newIndex;
- if (diff < 0) {
- // Try to go to next player with time
- newIndex = findNextPlayerWithTimeCircular(currentPlayerIndex, 1);
- } else {
- // Try to go to previous player with time
- newIndex = findNextPlayerWithTimeCircular(currentPlayerIndex, -1);
- }
-
- if (newIndex !== -1) {
- currentPlayerIndex = newIndex;
- }
+ if (currentGameState === config.GAME_STATES.RUNNING) {
+ newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time
} else {
- // Normal navigation when game not running
- if (diff < 0) {
- currentPlayerIndex = (currentPlayerIndex + 1) % players.length;
- } else if (diff > 0) {
- currentPlayerIndex = (currentPlayerIndex - 1 + players.length) % players.length;
- }
+ // Allow cycling through all players if not running
+ const playerCount = state.getPlayers().length;
+ if (playerCount > 0) {
+ newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount;
+ }
}
- // Play player switch sound if player actually changed
- if (previousIndex !== currentPlayerIndex) {
- audioManager.play('playerSwitch');
- }
+ if (newIndex !== -1) {
+ switchToPlayer(newIndex);
+ } else if (currentGameState === config.GAME_STATES.RUNNING) {
+ console.log("PreviousPlayer: No other player has time remaining.");
+ }
}
-// Find next player with time in specified direction
-function findNextPlayerWithTimeCircular(startIndex, direction) {
- let index = startIndex;
-
- for (let i = 0; i < players.length; i++) {
- index = (index + direction + players.length) % players.length;
- if (players[index].remainingTime > 0) {
- return index;
- }
- }
-
- return -1; // No player has time left
-}
-function handleFlicAction(action, buttonId, timestamp) {
- console.log(`[App] Received Flic Action: ${action} from Button: ${buttonId} at Timestamp: ${timestamp}`);
-
- // --- Trigger your PWA action based on the 'action' string ---
- switch (action) {
- case 'SingleClick':
- console.log('[App] Remotely triggered single click action...');
- handleSingleClickLogic(buttonId);
- break;
-
- case 'DoubleClick':
- console.log('[App] Remotely triggered double click action...');
- handleDoubleClickLogic();
- break;
-
- case 'Hold':
- console.log('[App] Remotely triggered hold action...');
- handleHoldLogic();
- break;
-
- default:
- console.warn(`[App] Unknown Flic action received: ${action}`);
- }
-}
-
-// --- Listener for messages from the Service Worker ---
-if ('serviceWorker' in navigator) {
- navigator.serviceWorker.addEventListener('message', event => {
- console.log('[App] Message received from Service Worker:', event.data);
-
- // Check if the message is the one we expect
- if (event.data && event.data.type === 'flic-action') {
- const { action, button, timestamp } = event.data;
- handleFlicAction(action, button, timestamp);
- }
- // Add else if blocks here for other message types if needed
- });
-
-// Optional: Send a message TO the service worker if needed
-// navigator.serviceWorker.ready.then(registration => {
-// registration.active.postMessage({ type: 'client-ready' });
-// });
-}
-
-function handleSingleClickLogic(buttonId) {
- console.log(`Single Click Logic Executed from Button: ${buttonId}`);
-
- if (buttonId === BUTTON_ID) {
- moveCarousel(-1); // Move to next player
- }
- // Reset carousel to proper position
- carousel.style.transform = `translateX(${-100 * currentPlayerIndex}%)`;
- renderPlayers();
- saveData();
-}
-
-function handleDoubleClickLogic() {
- console.log("Double Click Logic Executed!");
-
- if (buttonId === BUTTON_ID) {
- moveCarousel(1); // Move to previous player
- }
- // Reset carousel to proper position
- carousel.style.transform = `translateX(${-100 * currentPlayerIndex}%)`;
- renderPlayers();
- saveData();
-}
-
-function handleHoldLogic() {
- console.log("Hold Logic Executed!");
- // Implement game pause/resume toggle
- if (players.length < 2) {
- console.log('Need at least 2 players to toggle game state.');
- return;
- }
-
- // Toggle between running and paused states
- switch (gameState) {
- case 'setup':
- // Start the game if in setup
- gameState = 'running';
- audioManager.play('gameStart');
- startTimer();
- break;
- case 'running':
- // Pause the game if running
- gameState = 'paused';
- audioManager.play('gamePause');
- stopTimer();
- break;
- case 'paused':
- // Resume the game if paused
- gameState = 'running';
- audioManager.play('gameResume');
- startTimer();
- break;
- case 'over':
- // Reset timers and start new game if game is over
- players.forEach(player => {
- player.remainingTime = player.timeInSeconds;
- });
- gameState = 'setup';
- break;
- }
-
- updateGameButton();
- renderPlayers(); // Make sure to re-render after state change
- saveData();
-}
-
-// Setup button
-setupButton.addEventListener('click', () => {
- audioManager.play('buttonClick');
-
- if (gameState === 'running') {
- alert('Please pause the game before editing players.');
- return;
- }
-
- const currentPlayer = players[currentPlayerIndex];
- document.getElementById('modalTitle').textContent = 'Edit Player';
- document.getElementById('playerName').value = currentPlayer.name;
- document.getElementById('playerTime').value = currentPlayer.timeInSeconds / 60;
-
- // Show or hide remaining time edit field based on game state
- if (gameState === 'paused' || gameState === 'over') {
- remainingTimeContainer.style.display = 'block';
- const minutes = Math.floor(currentPlayer.remainingTime / 60);
- const seconds = currentPlayer.remainingTime % 60;
- playerRemainingTime.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
- } else {
- remainingTimeContainer.style.display = 'none';
- }
-
- if (currentPlayer.image) {
- imagePreview.innerHTML = `
`;
- } else {
- imagePreview.innerHTML = '';
- }
-
- playerModal.classList.add('active');
- deletePlayerButton.style.display = 'block';
- cleanupCameraData();
-
- // Play modal open sound
- audioManager.play('modalOpen');
-});
-
-// Add player button
-addPlayerButton.addEventListener('click', () => {
- audioManager.play('buttonClick');
-
- if (gameState === 'running') {
- alert('Please pause the game before adding players.');
- return;
- }
-
- document.getElementById('modalTitle').textContent = 'Add New Player';
- document.getElementById('playerName').value = `Player ${players.length + 1}`;
- document.getElementById('playerTime').value = 5;
- remainingTimeContainer.style.display = 'none';
- imagePreview.innerHTML = '';
-
- playerModal.classList.add('active');
- deletePlayerButton.style.display = 'none';
- cleanupCameraData();
-
- // Play modal open sound
- audioManager.play('modalOpen');
-});
-
-// Reset button
-resetButton.addEventListener('click', () => {
- audioManager.play('buttonClick');
-
- if (gameState === 'running') {
- alert('Please pause the game before resetting.');
- return;
- }
-
- resetModal.classList.add('active');
- audioManager.play('modalOpen');
-});
-
-// Cancel reset
-resetCancelButton.addEventListener('click', () => {
- audioManager.play('buttonClick');
- resetModal.classList.remove('active');
- audioManager.play('modalClose');
-});
-
-// Confirm reset
-resetConfirmButton.addEventListener('click', () => {
- audioManager.play('buttonClick');
-
- players = [
- { id: 1, name: 'Player 1', timeInSeconds: 300, remainingTime: 300, image: null },
- { id: 2, name: 'Player 2', timeInSeconds: 300, remainingTime: 300, image: null }
- ];
- gameState = 'setup';
- currentPlayerIndex = 0;
-
- renderPlayers();
- updateGameButton();
- saveData();
- resetModal.classList.remove('active');
-
- // Play reset sound (use game over as it's a complete reset)
+function handleGameOver() {
+ state.setGameState(config.GAME_STATES.OVER);
audioManager.play('gameOver');
-});
-
-// Player image upload preview
-playerImage.addEventListener('change', (e) => {
- const file = e.target.files[0];
- if (file) {
- const reader = new FileReader();
- reader.onload = (event) => {
- imagePreview.innerHTML = `
`;
- };
- reader.readAsDataURL(file);
- }
-});
-
-// Parse time string (MM:SS) to seconds
-function parseTimeString(timeString) {
- const [minutes, seconds] = timeString.split(':').map(part => parseInt(part, 10));
- return (minutes * 60) + seconds;
+ timer.stopTimer(); // Ensure timer is stopped
+ ui.updateGameButton();
+ ui.renderPlayers(); // Update to show final state
}
-// Camera button click handler
-cameraButton.addEventListener('click', async (e) => {
- e.preventDefault();
-
- // Check if the browser supports getUserMedia
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- alert('Your browser does not support camera access or it is not available on this device.');
+function resetGame() {
+ timer.stopTimer(); // Stop timer if running/paused
+ state.resetPlayersTime();
+ state.setGameState(config.GAME_STATES.SETUP);
+ state.setCurrentPlayerIndex(0); // Go back to first player
+ audioManager.play('buttonClick'); // Or a specific reset sound?
+ ui.updateGameButton();
+ ui.renderPlayers();
+}
+
+function fullResetApp() {
+ timer.stopTimer();
+ state.resetToDefaults();
+ audioManager.play('gameOver'); // Use game over sound for full reset
+ ui.hideResetModal();
+ ui.updateGameButton();
+ ui.renderPlayers();
+}
+
+// --- Event Handlers ---
+
+function handleGameButtonClick() {
+ audioManager.play('buttonClick');
+ togglePauseResume();
+}
+
+function handleSetupButtonClick() {
+ audioManager.play('buttonClick');
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ alert('Please pause the game before editing players.');
+ return;
+ }
+ const currentPlayer = state.getCurrentPlayer();
+ if (!currentPlayer) {
+ console.warn("Edit clicked but no current player?");
+ return; // Or show Add Player modal?
+ }
+ camera.stopStream(); // Ensure camera is off before opening modal
+ ui.showPlayerModal(false, currentPlayer);
+}
+
+function handleAddPlayerButtonClick() {
+ audioManager.play('buttonClick');
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ alert('Please pause the game before adding players.');
+ return;
+ }
+ camera.stopStream(); // Ensure camera is off before opening modal
+ ui.showPlayerModal(true);
+}
+
+function handleResetButtonClick() {
+ audioManager.play('buttonClick');
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ alert('Please pause the game before resetting.');
+ return;
+ }
+ ui.showResetModal();
+}
+
+function handlePlayerFormSubmit(event) {
+ event.preventDefault();
+ audioManager.play('buttonClick');
+
+ const name = ui.elements.playerNameInput.value.trim();
+ const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10);
+ let remainingTimeSeconds = 0; // Default
+ const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player';
+ const currentGameState = state.getGameState();
+
+ if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) {
+ alert('Please enter a valid name and positive time.');
return;
}
-
- try {
- // Get permission and access to the camera
- stream = await navigator.mediaDevices.getUserMedia({
- video: {
- facingMode: 'user', // Default to front camera
- width: { ideal: 1280 },
- height: { ideal: 720 }
- }
- });
-
- // Show the camera UI
- cameraContainer.classList.add('active');
-
- // Attach the stream to the video element
- cameraView.srcObject = stream;
- } catch (error) {
- console.error('Error accessing camera:', error);
- alert('Could not access the camera: ' + error.message);
- }
-});
-// Camera capture button click handler
-cameraCaptureButton.addEventListener('click', () => {
- // Set canvas dimensions to match video
- cameraCanvas.width = cameraView.videoWidth;
- cameraCanvas.height = cameraView.videoHeight;
-
- // Draw the current video frame to the canvas
- const context = cameraCanvas.getContext('2d');
- context.drawImage(cameraView, 0, 0, cameraCanvas.width, cameraCanvas.height);
-
- // Convert canvas to data URL
- const imageDataUrl = cameraCanvas.toDataURL('image/jpeg');
-
- // Update the image preview with the captured photo
- imagePreview.innerHTML = `
`;
-
- // Stop the camera stream and close the camera UI
- stopCameraStream();
- cameraContainer.classList.remove('active');
-
- // Since we're capturing directly, we don't need to use the file input
- // Instead, store the data URL to use when saving the player
- playerImage.dataset.capturedImage = imageDataUrl;
- playerImage.value = ''; // Clear the file input
-});
-
-// Camera cancel button handler
-cameraCancelButton.addEventListener('click', () => {
- stopCameraStream();
- cameraContainer.classList.remove('active');
-});
-
-// Function to stop the camera stream
-function stopCameraStream() {
- if (stream) {
- stream.getTracks().forEach(track => track.stop());
- stream = null;
- }
-}
-
-// Player form submit
-playerForm.addEventListener('submit', (e) => {
- e.preventDefault();
-
- const name = document.getElementById('playerName').value;
- const timeInMinutes = parseInt(document.getElementById('playerTime').value);
- const timeInSeconds = timeInMinutes * 60;
-
- // Get remaining time if it's visible
- let remainingTimeValue = timeInSeconds;
- if (remainingTimeContainer.style.display === 'block') {
- const remainingTimeString = playerRemainingTime.value;
- // Validate the time format
- if (!/^\d{2}:\d{2}$/.test(remainingTimeString)) {
- alert('Please enter time in MM:SS format (e.g., 05:30)');
+ // Get remaining time ONLY if editing and game is paused/over
+ if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) {
+ const remainingTimeString = ui.elements.playerRemainingTimeInput.value;
+ const parsedSeconds = ui.parseTimeString(remainingTimeString);
+ if (parsedSeconds === null) { // Check if parsing failed
+ alert('Please enter remaining time in MM:SS format (e.g., 05:30).');
return;
}
- remainingTimeValue = parseTimeString(remainingTimeString);
+ remainingTimeSeconds = parsedSeconds;
+ // Validate remaining time against total time? Optional.
+ if (remainingTimeSeconds > timeInMinutes * 60) {
+ alert('Remaining time cannot be greater than the total time.');
+ return;
+ }
+ } else {
+ // For new players or when editing in setup, remaining time matches total time
+ remainingTimeSeconds = timeInMinutes * 60;
}
-
- // Check for captured image from camera
- let playerImageData = null;
- const capturedImage = playerImage.dataset.capturedImage;
-
- // Define the savePlayer function
- const savePlayer = () => {
- const isNewPlayer = document.getElementById('modalTitle').textContent === 'Add New Player';
-
+
+ let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null;
+ const imageFile = ui.elements.playerImageInput.files[0];
+
+ const saveAction = (finalImageData) => {
if (isNewPlayer) {
- // Add new player
- const newId = Date.now();
- players.push({
- id: newId,
- name: name,
- timeInSeconds: timeInSeconds,
- remainingTime: timeInSeconds,
- image: playerImageData
- });
- currentPlayerIndex = players.length - 1;
-
- // Play player added sound
+ state.addPlayer(name, timeInMinutes, finalImageData);
audioManager.play('playerAdded');
} else {
- // Update existing player
- const player = players[currentPlayerIndex];
- player.name = name;
- player.timeInSeconds = timeInSeconds;
-
- // Update remaining time based on game state and form input
- if (gameState === 'setup') {
- player.remainingTime = timeInSeconds;
- } else if (gameState === 'paused' || gameState === 'over') {
- player.remainingTime = remainingTimeValue;
- }
-
- if (playerImageData !== null) {
- player.image = playerImageData;
- }
-
- // Play player edited sound
+ const playerIndex = state.getCurrentPlayerIndex();
+ // Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one
+ const imageArg = finalImageData !== null ? finalImageData : (imageFile || ui.elements.playerImageInput.dataset.capturedImage ? finalImageData : undefined);
+ state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg);
audioManager.play('playerEdited');
}
-
- renderPlayers();
- saveData();
- playerModal.classList.remove('active');
- document.getElementById('playerImage').value = '';
- playerImage.dataset.capturedImage = ''; // Clear captured image data
-
- // Also play modal close sound
- audioManager.play('modalClose');
+ ui.hidePlayerModal();
+ ui.renderPlayers();
+ ui.updateGameButton(); // Update in case player count changed for setup state
+ camera.stopStream(); // Ensure camera is stopped
};
-
- // Process image: either from captured image or uploaded file
- if (capturedImage) {
- playerImageData = capturedImage;
- savePlayer();
+
+ if (!imageDataUrl && imageFile) {
+ // Handle file upload: Read file as Data URL
+ const reader = new FileReader();
+ reader.onload = (e) => saveAction(e.target.result);
+ reader.onerror = (e) => {
+ console.error("Error reading image file:", e);
+ alert("Error processing image file.");
+ };
+ reader.readAsDataURL(imageFile);
} else {
- // Check for uploaded file
- const imageFile = document.getElementById('playerImage').files[0];
- if (imageFile) {
- const reader = new FileReader();
- reader.onload = (event) => {
- playerImageData = event.target.result;
- savePlayer();
- };
- reader.readAsDataURL(imageFile);
- } else {
- // Current player's existing image or null
- if (document.getElementById('modalTitle').textContent !== 'Add New Player') {
- playerImageData = players[currentPlayerIndex].image;
- }
- savePlayer();
- }
+ // Handle captured image or no image change
+ const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image;
+ // If imageDataUrl has content (from camera), use it.
+ // If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later).
+ // If it's a new player and no image, pass null.
+ saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage));
}
-});
+}
-// Cancel button
-cancelButton.addEventListener('click', () => {
+function handlePlayerModalCancel() {
audioManager.play('buttonClick');
- playerModal.classList.remove('active');
- document.getElementById('playerImage').value = '';
- cleanupCameraData();
- audioManager.play('modalClose');
-});
+ ui.hidePlayerModal();
+ camera.stopStream(); // Make sure camera turns off
+}
-// Delete player button
-deletePlayerButton.addEventListener('click', () => {
+function handleDeletePlayer() {
audioManager.play('buttonClick');
-
- if (players.length <= 2) {
- alert('You need at least 2 players. Add another player before deleting this one.');
- return;
+ const success = state.deletePlayer(state.getCurrentPlayerIndex());
+ if (success) {
+ audioManager.play('playerDeleted');
+ ui.hidePlayerModal();
+ ui.renderPlayers();
+ ui.updateGameButton(); // Update in case player count dropped below 2
+ } else {
+ alert('Cannot delete player. Minimum of 2 players required.');
}
-
- players.splice(currentPlayerIndex, 1);
-
- if (currentPlayerIndex >= players.length) {
- currentPlayerIndex = players.length - 1;
- }
-
- renderPlayers();
- saveData();
- playerModal.classList.remove('active');
- cleanupCameraData();
-
- // Play player deleted sound
- audioManager.play('playerDeleted');
- // Also play modal close sound
- audioManager.play('modalClose');
-});
-
-// Function to create deep links
-function createDeepLink(action) {
- return deepLinkManager.generateDeepLink(action);
+ camera.stopStream();
}
-// Function to setup deep links
-function setupDeepLinks() {
- // Register handlers for each action
- deepLinkManager.registerHandler('start', () => {
- if (gameState === 'setup' || gameState === 'paused') {
- if (players.length < 2) {
- console.log('Cannot start: Need at least 2 players');
- return;
- }
- gameState = 'running';
- audioManager.play('gameStart');
- startTimer();
- updateGameButton();
- renderPlayers();
- saveData();
- }
- });
-
- deepLinkManager.registerHandler('pause', () => {
- if (gameState === 'running') {
- gameState = 'paused';
- audioManager.play('gamePause');
- stopTimer();
- updateGameButton();
- renderPlayers();
- saveData();
- }
- });
-
- deepLinkManager.registerHandler('toggle', () => {
- // Simply trigger the game button click
- gameButton.click();
- });
-
- deepLinkManager.registerHandler('nextplayer', () => {
- if (gameState === 'running') {
- const nextIndex = findNextPlayerWithTimeCircular(currentPlayerIndex, 1);
- if (nextIndex !== -1 && nextIndex !== currentPlayerIndex) {
- currentPlayerIndex = nextIndex;
- audioManager.play('playerSwitch');
- renderPlayers();
- saveData();
- }
- }
- });
-
- deepLinkManager.registerHandler('reset', () => {
- // Show the reset confirmation dialog
- resetButton.click();
- });
-
- // Process deep links on page load
- deepLinkManager.processDeepLink();
+function handleResetConfirm() {
+ audioManager.play('buttonClick');
+ fullResetApp();
}
-// Clean up when the modal is closed
-function cleanupCameraData() {
- // Clear any captured image data
- if (playerImage) {
- playerImage.dataset.capturedImage = '';
- }
-
- // Make sure camera is stopped
- stopCameraStream();
-
- // Hide camera UI if visible
- cameraContainer.classList.remove('active');
+function handleResetCancel() {
+ audioManager.play('buttonClick');
+ ui.hideResetModal();
}
-// Make sure to handle rotation by adding window event listener for orientation changes
-window.addEventListener('orientationchange', () => {
- // If camera is active, adjust video dimensions
- if (cameraContainer.classList.contains('active') && stream) {
- // Give a moment for the orientation to complete
- setTimeout(() => {
- // This may cause the video to briefly reset but will ensure proper dimensions
- cameraView.srcObject = null;
- cameraView.srcObject = stream;
- }, 300);
- }
-});
+function handleCameraButtonClick(event) {
+ event.preventDefault(); // Prevent form submission if inside form
+ audioManager.play('buttonClick');
+ camera.open(); // Open the camera interface
+}
-// Listen for service worker messages (for receiving actions while app is running)
-navigator.serviceWorker.addEventListener('message', (event) => {
- if (event.data && event.data.type === 'ACTION') {
- console.log('Received action from service worker:', event.data.action);
- deepLinkManager.handleAction(event.data.action);
- }
-});
+// --- Timer Callbacks ---
-// Service Worker Registration
-if ('serviceWorker' in navigator) {
- window.addEventListener('load', () => {
- navigator.serviceWorker.register('/sw.js')
- .then(registration => {
- console.log('ServiceWorker registered');
-
- // Request notification permission and subscribe
- Notification.requestPermission().then(permission => {
- if (permission === 'granted') {
- subscribeToPushNotifications();
- }
- });
-
- setupDeepLinks();
- })
- .catch(error => {
- console.log('ServiceWorker registration failed:', error);
- setupDeepLinks();
+function handleTimerTick() {
+ // Timer module already updated the state, just need to redraw UI
+ ui.renderPlayers();
+}
+
+function handlePlayerSwitchOnTimer(newPlayerIndex) {
+ // Timer detected current player ran out, found next player
+ console.log(`Timer switching to player index: ${newPlayerIndex}`);
+ switchToPlayer(newPlayerIndex);
+ // Sound is handled in switchToPlayer
+}
+
+// --- Camera Callback ---
+function handleCameraCapture(imageDataUrl) {
+ console.log("Image captured");
+ ui.updateImagePreviewFromDataUrl(imageDataUrl);
+ // Camera module already closed the camera UI
+}
+
+// --- Service Worker and PWA Setup ---
+
+function setupServiceWorker() {
+ if ('serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js')
+ .then(registration => {
+ console.log('ServiceWorker registered successfully.');
+
+ // Listen for messages FROM the Service Worker (e.g., Flic actions)
+ navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
+
+ // Initialize Flic integration (which will try to subscribe)
+ initFlic();
+
+ // Setup deep links *after* SW might be ready
+ setupDeepLinks();
+ })
+ .catch(error => {
+ console.error('ServiceWorker registration failed:', error);
+ // Still setup deep links even if SW fails
+ setupDeepLinks();
+ // Maybe inform user push notifications won't work
+ });
});
- });
-} else {
- // If service workers aren't supported, still handle deep links
- window.addEventListener('load', setupDeepLinks);
+ // Listen for SW controller changes
+ navigator.serviceWorker.addEventListener('controllerchange', () => {
+ console.log('Service Worker controller changed, potentially updated.');
+ // window.location.reload(); // Consider prompting user to reload
+ });
+
+ } else {
+ console.warn('ServiceWorker not supported.');
+ // Setup deep links anyway
+ window.addEventListener('load', setupDeepLinks);
+ }
}
-// Also check for hash changes (needed for handling link activation when app is already open)
-window.addEventListener('hashchange', () => {
- console.log('Hash changed, checking for actions');
- deepLinkManager.processDeepLink();
-});
+function handleServiceWorkerMessage(event) {
+ console.log('[App] Message received from Service Worker:', event.data);
+ if (event.data?.type === 'flic-action') {
+ const { action, button, timestamp } = event.data;
+ pushFlic.handleFlicAction(action, button, timestamp);
+ }
+ else if (event.data?.type === 'ACTION') { // Handle deep link actions sent from SW
+ console.log('Received action from service worker via postMessage:', event.data.action);
+ deepLinkManager.handleAction(event.data.action);
+ }
+ // Add other message type handlers if needed
+}
-// Also check for navigation events that might include search parameters
-window.addEventListener('popstate', () => {
- console.log('Navigation occurred, checking for actions');
- deepLinkManager.processDeepLink();
-});
+// --- Deep Link Setup ---
+function setupDeepLinks() {
+ deepLinkManager.registerHandler('start', startGame);
+ deepLinkManager.registerHandler('pause', pauseGame);
+ deepLinkManager.registerHandler('toggle', togglePauseResume);
+ deepLinkManager.registerHandler('nextplayer', nextPlayer);
+ deepLinkManager.registerHandler('prevplayer', previousPlayer); // Add previous player handler
+ deepLinkManager.registerHandler('reset', handleResetButtonClick); // Show confirmation
-// Initialize the app
-loadData();
+ // Process initial deep link on load
+ deepLinkManager.processDeepLink();
+
+ // Listen for subsequent deep links
+ window.addEventListener('hashchange', deepLinkManager.processDeepLink);
+ // Also listen for popstate if using history API or query params
+ window.addEventListener('popstate', deepLinkManager.processDeepLink);
+}
+
+// --- Flic Integration Setup ---
+function initFlic() {
+ // Define what happens for each Flic button action
+ const flicActionHandlers = {
+ [config.FLIC_ACTIONS.SINGLE_CLICK]: nextPlayer,
+ [config.FLIC_ACTIONS.DOUBLE_CLICK]: previousPlayer, // Map double-click to previous player
+ [config.FLIC_ACTIONS.HOLD]: togglePauseResume,
+ };
+ pushFlic.initPushFlic(flicActionHandlers);
+}
+
+
+// --- Initialization ---
+
+function initialize() {
+ console.log("Initializing Game Timer App...");
+
+ // 1. Load saved state or defaults
+ state.loadData();
+
+ // 2. Initialize UI (pass carousel swipe handler)
+ ui.initUI({
+ onCarouselSwipe: (direction) => {
+ if (direction > 0) nextPlayer(); else previousPlayer();
+ }
+ });
+
+ // 3. Initialize Timer (pass callbacks for UI updates/state changes)
+ timer.initTimer({
+ onTimerTick: handleTimerTick,
+ onPlayerSwitch: handlePlayerSwitchOnTimer,
+ onGameOver: handleGameOver
+ });
+
+ // 4. Initialize Camera (pass elements and capture callback)
+ camera.init(
+ { // Pass relevant DOM elements
+ cameraContainer: ui.elements.cameraContainer,
+ cameraView: ui.elements.cameraView,
+ cameraCanvas: ui.elements.cameraCanvas,
+ cameraCaptureButton: ui.elements.cameraCaptureButton,
+ cameraCancelButton: ui.elements.cameraCancelButton
+ },
+ { // Pass options/callbacks
+ onCapture: handleCameraCapture
+ }
+ );
+
+ // 5. Set up UI Event Listeners that trigger actions
+ ui.elements.gameButton.addEventListener('click', handleGameButtonClick);
+ ui.elements.setupButton.addEventListener('click', handleSetupButtonClick);
+ ui.elements.addPlayerButton.addEventListener('click', handleAddPlayerButtonClick);
+ ui.elements.resetButton.addEventListener('click', handleResetButtonClick);
+ ui.elements.playerForm.addEventListener('submit', handlePlayerFormSubmit);
+ ui.elements.cancelButton.addEventListener('click', handlePlayerModalCancel);
+ ui.elements.deletePlayerButton.addEventListener('click', handleDeletePlayer);
+ ui.elements.resetConfirmButton.addEventListener('click', handleResetConfirm);
+ ui.elements.resetCancelButton.addEventListener('click', handleResetCancel);
+ ui.elements.cameraButton.addEventListener('click', handleCameraButtonClick);
+
+ // 6. Setup Service Worker (which also initializes Flic and Deep Links)
+ setupServiceWorker();
+
+ // 7. Initial UI Update based on loaded state
+ ui.renderPlayers();
+ ui.updateGameButton();
+
+ // 8. Resume timer if state loaded as 'running' (e.g., after refresh)
+ // Note: This might be undesirable. Usually, a refresh should pause.
+ // Consider forcing state to 'paused' or 'setup' on load?
+ // For now, let's reset running state to paused on load.
+ if (state.getGameState() === config.GAME_STATES.RUNNING) {
+ console.log("Game was running on load, setting to paused.");
+ state.setGameState(config.GAME_STATES.PAUSED);
+ ui.updateGameButton();
+ ui.renderPlayers();
+ }
+
+ console.log("App Initialized.");
+}
+
+// --- Start the application ---
+initialize();
\ No newline at end of file
diff --git a/camera.js b/camera.js
new file mode 100644
index 0000000..1d39d8c
--- /dev/null
+++ b/camera.js
@@ -0,0 +1,116 @@
+// camera.js
+import { CSS_CLASSES } from './config.js';
+
+let stream = null;
+let elements = {}; // To store references to DOM elements passed during init
+let onCaptureCallback = null; // Callback when image is captured
+
+export function initCamera(cameraElements, options) {
+ elements = cameraElements; // Store refs like { cameraContainer, cameraView, etc. }
+ onCaptureCallback = options.onCapture;
+
+ // Add internal listeners for capture/cancel buttons
+ elements.cameraCaptureButton?.addEventListener('click', handleCapture);
+ elements.cameraCancelButton?.addEventListener('click', closeCamera);
+
+ // Handle orientation change to potentially reset stream dimensions
+ window.addEventListener('orientationchange', handleOrientationChange);
+}
+
+async function openCamera() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ alert('Camera access not supported or available on this device.');
+ return false; // Indicate failure
+ }
+
+ try {
+ stream = await navigator.mediaDevices.getUserMedia({
+ video: {
+ facingMode: 'user', // Prefer front camera
+ width: { ideal: 1280 },
+ height: { ideal: 720 }
+ }
+ });
+
+ elements.cameraContainer?.classList.add(CSS_CLASSES.CAMERA_ACTIVE);
+ if (elements.cameraView) {
+ elements.cameraView.srcObject = stream;
+ // Wait for video metadata to load to get correct dimensions
+ elements.cameraView.onloadedmetadata = () => {
+ elements.cameraView.play(); // Start playing the video stream
+ };
+ }
+ return true; // Indicate success
+ } catch (error) {
+ console.error('Error accessing camera:', error);
+ alert('Could not access camera: ' + error.message);
+ closeCamera(); // Ensure cleanup if opening failed
+ return false; // Indicate failure
+ }
+}
+
+function handleCapture() {
+ if (!elements.cameraView || !elements.cameraCanvas || !stream) return;
+
+ // Set canvas dimensions to match video's actual dimensions
+ elements.cameraCanvas.width = elements.cameraView.videoWidth;
+ elements.cameraCanvas.height = elements.cameraView.videoHeight;
+
+ // Draw the current video frame to the canvas
+ const context = elements.cameraCanvas.getContext('2d');
+ // Flip horizontally for front camera to make it mirror-like
+ if (stream.getVideoTracks()[0]?.getSettings()?.facingMode === 'user') {
+ context.translate(elements.cameraCanvas.width, 0);
+ context.scale(-1, 1);
+ }
+ context.drawImage(elements.cameraView, 0, 0, elements.cameraCanvas.width, elements.cameraCanvas.height);
+
+ // Convert canvas to data URL (JPEG format)
+ const imageDataUrl = elements.cameraCanvas.toDataURL('image/jpeg', 0.9); // Quality 0.9
+
+ // Call the callback provided during init with the image data
+ if (onCaptureCallback) {
+ onCaptureCallback(imageDataUrl);
+ }
+
+ // Stop stream and hide UI after capture
+ closeCamera();
+}
+
+function stopCameraStream() {
+ if (stream) {
+ stream.getTracks().forEach(track => track.stop());
+ stream = null;
+ }
+ // Also clear the srcObject
+ if (elements.cameraView) {
+ elements.cameraView.srcObject = null;
+ }
+}
+
+function closeCamera() {
+ stopCameraStream();
+ elements.cameraContainer?.classList.remove(CSS_CLASSES.CAMERA_ACTIVE);
+}
+
+function handleOrientationChange() {
+ // If camera is active, restart stream to potentially adjust aspect ratio/resolution
+ if (elements.cameraContainer?.classList.contains(CSS_CLASSES.CAMERA_ACTIVE) && stream) {
+ console.log("Orientation changed, re-evaluating camera stream...");
+ // Short delay to allow layout to settle
+ setTimeout(async () => {
+ // Stop existing stream before requesting new one
+ // This might cause a flicker but ensures constraints are re-evaluated
+ stopCameraStream();
+ await openCamera(); // Attempt to reopen with potentially new constraints
+ }, 300);
+ }
+}
+
+// Public API for camera module
+export default {
+ init: initCamera,
+ open: openCamera,
+ close: closeCamera,
+ stopStream: stopCameraStream // Expose if needed externally, e.g., when modal closes
+};
\ No newline at end of file
diff --git a/config.js b/config.js
new file mode 100644
index 0000000..20ecb62
--- /dev/null
+++ b/config.js
@@ -0,0 +1,37 @@
+// config.js
+export const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
+export const BACKEND_URL = 'https://webpush.virtonline.eu';
+export const FLIC_BUTTON_ID = 'game-button'; // Example ID, might need configuration
+export const LOCAL_STORAGE_KEY = 'gameTimerData';
+
+// Default player settings
+export const DEFAULT_PLAYER_TIME_SECONDS = 300; // 5 minutes
+export const DEFAULT_PLAYERS = [
+ { id: 1, name: 'Player 1', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null },
+ { id: 2, name: 'Player 2', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null }
+];
+
+// CSS Classes (optional, but can help consistency)
+export const CSS_CLASSES = {
+ ACTIVE_PLAYER: 'active-player',
+ INACTIVE_PLAYER: 'inactive-player',
+ TIMER_ACTIVE: 'timer-active',
+ TIMER_FINISHED: 'timer-finished',
+ MODAL_ACTIVE: 'active',
+ CAMERA_ACTIVE: 'active'
+};
+
+// Game States
+export const GAME_STATES = {
+ SETUP: 'setup',
+ RUNNING: 'running',
+ PAUSED: 'paused',
+ OVER: 'over'
+};
+
+// Flic Actions
+export const FLIC_ACTIONS = {
+ SINGLE_CLICK: 'SingleClick',
+ DOUBLE_CLICK: 'DoubleClick',
+ HOLD: 'Hold'
+};
\ No newline at end of file
diff --git a/pushFlicIntegration.js b/pushFlicIntegration.js
new file mode 100644
index 0000000..19cca3a
--- /dev/null
+++ b/pushFlicIntegration.js
@@ -0,0 +1,197 @@
+// pushFlicIntegration.js
+import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS } from './config.js';
+
+let pushSubscription = null; // Keep track locally if needed
+let actionHandlers = {}; // Store handlers for different Flic actions
+
+// --- Helper Functions ---
+
+// Get stored basic auth credentials or prompt user for them
+function getBasicAuthCredentials() {
+ const storedAuth = localStorage.getItem('basicAuthCredentials');
+ if (storedAuth) {
+ try { return JSON.parse(storedAuth); } catch (error) { console.error('Failed to parse stored credentials:', error); }
+ }
+ const username = prompt('Please enter your username for backend authentication:');
+ if (!username) return null;
+ const password = prompt('Please enter your password:');
+ if (!password) return null;
+ const credentials = { username, password };
+ localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
+ return credentials;
+}
+
+// Create Basic Auth header string
+function createBasicAuthHeader(credentials) {
+ if (!credentials?.username || !credentials.password) return null;
+ return 'Basic ' + btoa(`${credentials.username}:${credentials.password}`);
+}
+
+// Convert URL-safe base64 string to Uint8Array
+function urlBase64ToUint8Array(base64String) {
+ const padding = '='.repeat((4 - base64String.length % 4) % 4);
+ const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
+ const rawData = window.atob(base64);
+ const outputArray = new Uint8Array(rawData.length);
+ for (let i = 0; i < rawData.length; ++i) {
+ outputArray[i] = rawData.charCodeAt(i);
+ }
+ return outputArray;
+}
+
+// Convert ArrayBuffer to URL-safe Base64 string
+function arrayBufferToBase64(buffer) {
+ let binary = '';
+ const bytes = new Uint8Array(buffer);
+ for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); }
+ return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
+}
+
+// --- Push Subscription Logic ---
+
+async function subscribeToPush() {
+ const buttonId = FLIC_BUTTON_ID; // Use configured button ID
+
+ if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
+ console.error('Push Messaging is not supported.');
+ alert('Push Notifications are not supported by your browser.');
+ return;
+ }
+
+ try {
+ const permission = await Notification.requestPermission();
+ if (permission !== 'granted') {
+ console.warn('Notification permission denied.');
+ alert('Please enable notifications to link the Flic button.');
+ return;
+ }
+
+ const registration = await navigator.serviceWorker.ready;
+ let existingSubscription = await registration.pushManager.getSubscription();
+ let needsResubscribe = !existingSubscription;
+
+ if (existingSubscription) {
+ const existingKey = existingSubscription.options?.applicationServerKey;
+ if (!existingKey || arrayBufferToBase64(existingKey) !== PUBLIC_VAPID_KEY) {
+ console.log('VAPID key mismatch or missing. Unsubscribing old subscription.');
+ await existingSubscription.unsubscribe();
+ existingSubscription = null;
+ needsResubscribe = true;
+ } else {
+ console.log('Existing valid subscription found.');
+ pushSubscription = existingSubscription; // Store it
+ }
+ }
+
+ let finalSubscription = existingSubscription;
+ if (needsResubscribe) {
+ console.log('Subscribing for push notifications...');
+ const applicationServerKey = urlBase64ToUint8Array(PUBLIC_VAPID_KEY);
+ finalSubscription = await registration.pushManager.subscribe({
+ userVisibleOnly: true,
+ applicationServerKey: applicationServerKey
+ });
+ console.log('New push subscription obtained:', finalSubscription);
+ pushSubscription = finalSubscription; // Store it
+ }
+
+ if (!finalSubscription) {
+ console.error("Failed to obtain a subscription object.");
+ alert("Could not get subscription details.");
+ return;
+ }
+
+ // Send to backend
+ await sendSubscriptionToServer(finalSubscription, buttonId);
+
+ } catch (error) {
+ console.error('Error during push subscription:', error);
+ alert(`Subscription failed: ${error.message}`);
+ }
+}
+
+async function sendSubscriptionToServer(subscription, buttonId) {
+ console.log(`Sending subscription for button "${buttonId}" to backend...`);
+ const credentials = getBasicAuthCredentials();
+ if (!credentials) {
+ alert('Authentication required to save button link.');
+ return;
+ }
+
+ const headers = { 'Content-Type': 'application/json' };
+ const authHeader = createBasicAuthHeader(credentials);
+ if (authHeader) headers['Authorization'] = authHeader;
+
+ try {
+ const response = await fetch(`${BACKEND_URL}/subscribe`, {
+ method: 'POST',
+ body: JSON.stringify({ button_id: buttonId, subscription: subscription }),
+ headers: headers
+ });
+
+ if (response.ok) {
+ const result = await response.json();
+ console.log('Subscription sent successfully:', result.message);
+ // Maybe show a success message to the user
+ } else {
+ let errorMsg = `Server error: ${response.status}`;
+ if (response.status === 401 || response.status === 403) {
+ localStorage.removeItem('basicAuthCredentials'); // Clear bad creds
+ errorMsg = 'Authentication failed. Please try again.';
+ } else {
+ try { errorMsg = (await response.json()).message || errorMsg; } catch (e) { /* use default */ }
+ }
+ console.error('Failed to send subscription:', errorMsg);
+ alert(`Failed to save link: ${errorMsg}`);
+ }
+ } catch (error) {
+ console.error('Network error sending subscription:', error);
+ alert(`Network error: ${error.message}`);
+ }
+}
+
+// --- Flic Action Handling ---
+
+// Called by app.js when a message is received from the service worker
+export function handleFlicAction(action, buttonId, timestamp) {
+ console.log(`[PushFlic] Received Action: ${action} from Button: ${buttonId}`);
+
+ // Ignore actions from buttons other than the configured one
+ if (buttonId !== FLIC_BUTTON_ID) {
+ console.warn(`[PushFlic] Ignoring action from unknown button: ${buttonId}`);
+ return;
+ }
+
+ // Find the registered handler for this action
+ const handler = actionHandlers[action];
+ if (handler && typeof handler === 'function') {
+ console.log(`[PushFlic] Executing handler for ${action}`);
+ handler(); // Execute the handler registered in app.js
+ } else {
+ console.warn(`[PushFlic] No handler registered for action: ${action}`);
+ }
+}
+
+// --- Initialization ---
+
+export function initPushFlic(handlers) {
+ actionHandlers = handlers; // Store the handlers passed from app.js
+ // Example: handlers = { SingleClick: handleNextPlayer, Hold: handleTogglePause }
+
+ // Attempt to subscribe immediately if permission might already be granted
+ // Or trigger subscription on a user action (e.g., a "Link Flic Button" button)
+ // For simplicity, let's try subscribing if SW is ready and permission allows
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.ready.then(registration => {
+ Notification.requestPermission().then(permission => {
+ if (permission === 'granted') {
+ console.log('[PushFlic] Permission granted, attempting subscription.');
+ subscribeToPush();
+ } else {
+ console.log('[PushFlic] Notification permission not granted.');
+ // Optionally provide a button for the user to trigger subscription later
+ }
+ });
+ });
+ }
+}
\ No newline at end of file
diff --git a/state.js b/state.js
new file mode 100644
index 0000000..6705897
--- /dev/null
+++ b/state.js
@@ -0,0 +1,230 @@
+// state.js
+import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from './config.js';
+
+let players = [];
+let currentPlayerIndex = 0;
+let gameState = GAME_STATES.SETUP;
+
+// --- State Accessors ---
+
+export function getPlayers() {
+ return [...players]; // Return a copy to prevent direct mutation
+}
+
+export function getCurrentPlayer() {
+ if (players.length === 0) return null;
+ return players[currentPlayerIndex];
+}
+
+export function getPlayerById(id) {
+ return players.find(p => p.id === id);
+}
+
+export function getCurrentPlayerIndex() {
+ return currentPlayerIndex;
+}
+
+export function getGameState() {
+ return gameState;
+}
+
+// --- State Mutators ---
+
+export function setPlayers(newPlayers) {
+ players = newPlayers;
+ saveData();
+}
+
+export function setCurrentPlayerIndex(index) {
+ if (index >= 0 && index < players.length) {
+ currentPlayerIndex = index;
+ saveData();
+ } else {
+ console.error(`Invalid player index: ${index}`);
+ }
+}
+
+export function setGameState(newState) {
+ if (Object.values(GAME_STATES).includes(newState)) {
+ gameState = newState;
+ saveData();
+ } else {
+ console.error(`Invalid game state: ${newState}`);
+ }
+}
+
+export function updatePlayerTime(index, remainingTime) {
+ if (index >= 0 && index < players.length) {
+ players[index].remainingTime = Math.max(0, remainingTime); // Ensure time doesn't go below 0
+ saveData(); // Save data whenever time updates
+ }
+}
+
+export function addPlayer(name, timeInMinutes, image = null) {
+ const timeInSeconds = timeInMinutes * 60;
+ const newId = Date.now();
+ players.push({
+ id: newId,
+ name: name,
+ timeInSeconds: timeInSeconds,
+ remainingTime: timeInSeconds,
+ image: image
+ });
+ currentPlayerIndex = players.length - 1; // Focus new player
+ saveData();
+ return players[players.length - 1]; // Return the newly added player
+}
+
+export function updatePlayer(index, name, timeInMinutes, remainingTimeSeconds, image) {
+ if (index >= 0 && index < players.length) {
+ const player = players[index];
+ const timeInSeconds = timeInMinutes * 60;
+
+ player.name = name;
+ player.timeInSeconds = timeInSeconds;
+
+ // Update remaining time carefully based on game state
+ if (gameState === GAME_STATES.SETUP) {
+ player.remainingTime = timeInSeconds;
+ } else if (gameState === GAME_STATES.PAUSED || gameState === GAME_STATES.OVER) {
+ // Allow direct setting of remaining time only when paused or over
+ player.remainingTime = remainingTimeSeconds;
+ }
+ // If running, remaining time is managed by the timer, don't override here unless intended
+
+ if (image !== undefined) { // Allow updating image (null means remove image)
+ player.image = image;
+ }
+ saveData();
+ return player;
+ }
+ return null;
+}
+
+export function deletePlayer(index) {
+ if (players.length <= 2) {
+ console.warn('Cannot delete player, minimum 2 players required.');
+ return false; // Indicate deletion failed
+ }
+ if (index >= 0 && index < players.length) {
+ players.splice(index, 1);
+ if (currentPlayerIndex >= players.length) {
+ currentPlayerIndex = players.length - 1;
+ } else if (currentPlayerIndex > index) {
+ // Adjust index if deleting someone before the current player
+ // No adjustment needed if deleting current or after current
+ }
+ saveData();
+ return true; // Indicate success
+ }
+ return false; // Indicate deletion failed
+}
+
+export function resetPlayersTime() {
+ players.forEach(player => {
+ player.remainingTime = player.timeInSeconds;
+ });
+ saveData();
+}
+
+export function resetToDefaults() {
+ // Deep copy default players to avoid modifying the constant
+ players = JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
+ gameState = GAME_STATES.SETUP;
+ currentPlayerIndex = 0;
+ saveData();
+}
+
+export function areAllTimersFinished() {
+ return players.every(player => player.remainingTime <= 0);
+}
+
+// Returns the index of the next player with time > 0, or -1 if none
+export function findNextPlayerWithTime() {
+ if (players.length === 0) return -1;
+ const startIndex = (currentPlayerIndex + 1) % players.length;
+ let index = startIndex;
+
+ do {
+ if (players[index].remainingTime > 0) {
+ return index;
+ }
+ index = (index + 1) % players.length;
+ } while (index !== startIndex);
+
+ // Check current player last if no others found
+ if(players[currentPlayerIndex].remainingTime > 0) {
+ return currentPlayerIndex;
+ }
+
+ return -1; // No player has time left
+}
+
+// Find next player with time in specified direction (1 for next, -1 for prev)
+export function findNextPlayerWithTimeCircular(direction) {
+ if (players.length === 0) return -1;
+ let index = currentPlayerIndex;
+
+ for (let i = 0; i < players.length; i++) {
+ index = (index + direction + players.length) % players.length;
+ if (players[index]?.remainingTime > 0) { // Check if player exists and has time
+ return index;
+ }
+ }
+
+ // If no other player found, check if current player has time (only relevant if direction search fails)
+ if (players[currentPlayerIndex]?.remainingTime > 0) {
+ return currentPlayerIndex;
+ }
+
+ return -1; // No player has time left
+}
+
+
+// --- Persistence ---
+
+export function saveData() {
+ const dataToSave = {
+ players,
+ gameState,
+ currentPlayerIndex
+ };
+ try {
+ localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dataToSave));
+ } catch (error) {
+ console.error("Error saving data to localStorage:", error);
+ // Maybe notify the user that settings won't be saved
+ }
+}
+
+export function loadData() {
+ const savedData = localStorage.getItem(LOCAL_STORAGE_KEY);
+ if (savedData) {
+ try {
+ const parsedData = JSON.parse(savedData);
+ players = parsedData.players || JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
+ gameState = parsedData.gameState || GAME_STATES.SETUP;
+ currentPlayerIndex = parsedData.currentPlayerIndex || 0;
+
+ // Basic validation/migration if needed
+ if (currentPlayerIndex >= players.length) {
+ currentPlayerIndex = 0;
+ }
+ // Ensure all players have necessary properties
+ players = players.map(p => ({
+ id: p.id || Date.now() + Math.random(), // Ensure ID exists
+ name: p.name || 'Player',
+ timeInSeconds: p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS,
+ remainingTime: p.remainingTime !== undefined ? p.remainingTime : (p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS),
+ image: p.image || null
+ }));
+
+ } catch (error) {
+ console.error("Error parsing data from localStorage:", error);
+ resetToDefaults(); // Reset to defaults if stored data is corrupt
+ }
+ } else {
+ resetToDefaults(); // Use defaults if no saved data
+ }
+ // No saveData() here, loadData just loads the state
+}
\ No newline at end of file
diff --git a/timer.js b/timer.js
new file mode 100644
index 0000000..2007f89
--- /dev/null
+++ b/timer.js
@@ -0,0 +1,99 @@
+// timer.js
+import * as state from './state.js';
+import { GAME_STATES } from './config.js';
+import audioManager from './audio.js';
+
+let timerInterval = null;
+let onTimerTickCallback = null; // Callback for UI updates
+let onPlayerSwitchCallback = null; // Callback for when player switches due to time running out
+let onGameOverCallback = null; // Callback for when all players run out of time
+
+export function initTimer(options) {
+ onTimerTickCallback = options.onTimerTick;
+ onPlayerSwitchCallback = options.onPlayerSwitch;
+ onGameOverCallback = options.onGameOver;
+}
+
+export function startTimer() {
+ if (timerInterval) clearInterval(timerInterval); // Clear existing interval if any
+
+ // Stop any previous sounds (like low time warning) before starting fresh
+ audioManager.stopAllSounds(); // Consider if this is too aggressive
+
+ timerInterval = setInterval(() => {
+ const currentPlayerIndex = state.getCurrentPlayerIndex();
+ const currentPlayer = state.getCurrentPlayer(); // Get player data after index
+
+ if (!currentPlayer) {
+ console.warn("Timer running but no current player found.");
+ stopTimer();
+ return;
+ }
+
+ // Only decrease time if the current player has time left
+ if (currentPlayer.remainingTime > 0) {
+ const newTime = currentPlayer.remainingTime - 1;
+ state.updatePlayerTime(currentPlayerIndex, newTime); // Update state
+
+ // Play timer sounds
+ audioManager.playTimerSound(newTime);
+
+ // Notify UI to update
+ if (onTimerTickCallback) onTimerTickCallback();
+
+ } else { // Current player's time just hit 0 or was already 0
+ // Ensure time is exactly 0 if it somehow went negative (unlikely with check above)
+ if(currentPlayer.remainingTime < 0) {
+ state.updatePlayerTime(currentPlayerIndex, 0);
+ }
+
+ // Stop this player's timer tick sound if it was playing
+ // audioManager.stop('timerTick'); // Or specific low time sound
+
+ // Play time expired sound (only once)
+ // Check if we just hit zero to avoid playing repeatedly
+ // This logic might be complex, audioManager could handle idempotency
+ if (currentPlayer.remainingTime === 0 && !currentPlayer.timeExpiredSoundPlayed) {
+ audioManager.playTimerExpired();
+ // We need a way to mark that the sound played for this player this turn.
+ // This might require adding a temporary flag to the player state,
+ // or handling it within the audioManager. Let's assume audioManager handles it for now.
+ }
+
+
+ // Check if the game should end or switch player
+ if (state.areAllTimersFinished()) {
+ stopTimer();
+ if (onGameOverCallback) onGameOverCallback();
+ } else {
+ // Find the *next* player who still has time
+ const nextPlayerIndex = state.findNextPlayerWithTime(); // This finds ANY player with time
+ if (nextPlayerIndex !== -1 && nextPlayerIndex !== currentPlayerIndex) {
+ // Switch player
+ if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
+ // Play switch sound (might be handled in app.js based on state change)
+ // audioManager.play('playerSwitch'); // Or let app.js handle sounds based on actions
+ // Immediately update UI after switch
+ if (onTimerTickCallback) onTimerTickCallback();
+ } else if (nextPlayerIndex === -1) {
+ // This case shouldn't be reached if areAllTimersFinished is checked first, but as a safeguard:
+ console.warn("Timer tick: Current player out of time, but no next player found, yet not all timers finished?");
+ stopTimer(); // Stop timer if state is inconsistent
+ if (onGameOverCallback) onGameOverCallback(); // Treat as game over
+ }
+ // If nextPlayerIndex is the same as currentPlayerIndex, means others are out of time, let this timer continue (or rather, stop ticking down as remainingTime is 0)
+ }
+ }
+ }, 1000);
+}
+
+export function stopTimer() {
+ clearInterval(timerInterval);
+ timerInterval = null;
+ // Optionally stop timer sounds here if needed
+ // audioManager.stop('timerTick');
+}
+
+export function isTimerRunning() {
+ return timerInterval !== null;
+}
\ No newline at end of file
diff --git a/ui.js b/ui.js
new file mode 100644
index 0000000..bb61e9c
--- /dev/null
+++ b/ui.js
@@ -0,0 +1,286 @@
+// ui.js
+import * as state from './state.js';
+import { GAME_STATES, CSS_CLASSES } from './config.js';
+import audioManager from './audio.js';
+
+// --- DOM Elements ---
+export const elements = {
+ carousel: document.getElementById('carousel'),
+ gameButton: document.getElementById('gameButton'),
+ setupButton: document.getElementById('setupButton'),
+ addPlayerButton: document.getElementById('addPlayerButton'),
+ resetButton: document.getElementById('resetButton'),
+ playerModal: document.getElementById('playerModal'),
+ resetModal: document.getElementById('resetModal'),
+ playerForm: document.getElementById('playerForm'),
+ modalTitle: document.getElementById('modalTitle'),
+ playerNameInput: document.getElementById('playerName'),
+ playerTimeInput: document.getElementById('playerTime'),
+ playerImageInput: document.getElementById('playerImage'),
+ imagePreview: document.getElementById('imagePreview'),
+ playerTimeContainer: document.getElementById('playerTimeContainer'), // Parent of playerTimeInput
+ remainingTimeContainer: document.getElementById('remainingTimeContainer'),
+ playerRemainingTimeInput: document.getElementById('playerRemainingTime'),
+ deletePlayerButton: document.getElementById('deletePlayerButton'),
+ cancelButton: document.getElementById('cancelButton'), // Modal cancel
+ resetCancelButton: document.getElementById('resetCancelButton'),
+ resetConfirmButton: document.getElementById('resetConfirmButton'),
+ cameraButton: document.getElementById('cameraButton'),
+ // Camera elements needed by camera.js, but listed here for central management if desired
+ cameraContainer: document.getElementById('cameraContainer'),
+ cameraView: document.getElementById('cameraView'),
+ cameraCanvas: document.getElementById('cameraCanvas'),
+ cameraCaptureButton: document.getElementById('cameraCaptureButton'),
+ cameraCancelButton: document.getElementById('cameraCancelButton'),
+ // Header buttons container for sound toggle
+ headerButtons: document.querySelector('.header-buttons')
+};
+
+let isDragging = false;
+let startX = 0;
+let currentX = 0;
+let carouselSwipeHandler = null; // To store the bound function for removal
+
+// --- Rendering Functions ---
+
+export function renderPlayers() {
+ const players = state.getPlayers();
+ const currentIndex = state.getCurrentPlayerIndex();
+ const currentGameState = state.getGameState();
+ elements.carousel.innerHTML = '';
+
+ if (players.length === 0) {
+ // Optionally display a message if there are no players
+ elements.carousel.innerHTML = 'Add players to start
';
+ return;
+ }
+
+ players.forEach((player, index) => {
+ const card = document.createElement('div');
+ const isActive = index === currentIndex;
+ card.className = `player-card ${isActive ? CSS_CLASSES.ACTIVE_PLAYER : CSS_CLASSES.INACTIVE_PLAYER}`;
+
+ const minutes = Math.floor(player.remainingTime / 60);
+ const seconds = player.remainingTime % 60;
+ const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+
+ const timerClasses = [];
+ if (isActive && currentGameState === GAME_STATES.RUNNING) {
+ timerClasses.push(CSS_CLASSES.TIMER_ACTIVE);
+ }
+ if (player.remainingTime <= 0) {
+ timerClasses.push(CSS_CLASSES.TIMER_FINISHED);
+ }
+
+ card.innerHTML = `
+
+ ${player.image ? `

` : '
'}
+
+ ${player.name}
+ ${timeString}
+ `;
+ elements.carousel.appendChild(card);
+ });
+
+ updateCarouselPosition();
+}
+
+export function updateCarouselPosition() {
+ const currentIndex = state.getCurrentPlayerIndex();
+ elements.carousel.style.transform = `translateX(${-100 * currentIndex}%)`;
+}
+
+export function updateGameButton() {
+ const currentGameState = state.getGameState();
+ switch (currentGameState) {
+ case GAME_STATES.SETUP:
+ elements.gameButton.textContent = 'Start Game';
+ break;
+ case GAME_STATES.RUNNING:
+ elements.gameButton.textContent = 'Pause Game';
+ break;
+ case GAME_STATES.PAUSED:
+ elements.gameButton.textContent = 'Resume Game';
+ break;
+ case GAME_STATES.OVER:
+ elements.gameButton.textContent = 'Game Over';
+ break;
+ }
+ // Disable button if less than 2 players in setup
+ elements.gameButton.disabled = currentGameState === GAME_STATES.SETUP && state.getPlayers().length < 2;
+}
+
+
+// --- Modal Functions ---
+
+export function showPlayerModal(isNewPlayer, player = null) {
+ const currentGameState = state.getGameState();
+ if (isNewPlayer) {
+ elements.modalTitle.textContent = 'Add New Player';
+ elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
+ elements.playerTimeInput.value = state.DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time
+ elements.remainingTimeContainer.style.display = 'none';
+ elements.imagePreview.innerHTML = '';
+ elements.deletePlayerButton.style.display = 'none';
+ } else if (player) {
+ elements.modalTitle.textContent = 'Edit Player';
+ elements.playerNameInput.value = player.name;
+ elements.playerTimeInput.value = player.timeInSeconds / 60;
+
+ if (currentGameState === GAME_STATES.PAUSED || currentGameState === GAME_STATES.OVER) {
+ elements.remainingTimeContainer.style.display = 'block';
+ const minutes = Math.floor(player.remainingTime / 60);
+ const seconds = player.remainingTime % 60;
+ elements.playerRemainingTimeInput.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
+ } else {
+ elements.remainingTimeContainer.style.display = 'none';
+ }
+
+ elements.imagePreview.innerHTML = player.image ? `
` : '';
+ elements.deletePlayerButton.style.display = 'block';
+ }
+
+ // Reset file input and captured image data
+ elements.playerImageInput.value = '';
+ elements.playerImageInput.dataset.capturedImage = '';
+
+ elements.playerModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
+ audioManager.play('modalOpen');
+}
+
+export function hidePlayerModal() {
+ elements.playerModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
+ audioManager.play('modalClose');
+ // Potentially call camera cleanup here if it wasn't done elsewhere
+}
+
+export function showResetModal() {
+ elements.resetModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
+ audioManager.play('modalOpen');
+}
+
+export function hideResetModal() {
+ elements.resetModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
+ audioManager.play('modalClose');
+}
+
+export function updateImagePreviewFromFile(file) {
+ if (file) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ elements.imagePreview.innerHTML = `
`;
+ // Clear any previously captured image data if a file is selected
+ elements.playerImageInput.dataset.capturedImage = '';
+ };
+ reader.readAsDataURL(file);
+ }
+}
+
+export function updateImagePreviewFromDataUrl(dataUrl) {
+ elements.imagePreview.innerHTML = `
`;
+ // Store data URL and clear file input
+ elements.playerImageInput.dataset.capturedImage = dataUrl;
+ elements.playerImageInput.value = '';
+}
+
+
+// --- Carousel Touch Handling ---
+
+function handleTouchStart(e) {
+ startX = e.touches[0].clientX;
+ currentX = startX;
+ isDragging = true;
+ // Optional: Add a class to the carousel for visual feedback during drag
+ elements.carousel.style.transition = 'none'; // Disable transition during drag
+}
+
+function handleTouchMove(e) {
+ if (!isDragging) return;
+
+ currentX = e.touches[0].clientX;
+ const diff = currentX - startX;
+ const currentIndex = state.getCurrentPlayerIndex();
+ const currentTranslate = -100 * currentIndex + (diff / elements.carousel.offsetWidth * 100);
+
+ elements.carousel.style.transform = `translateX(${currentTranslate}%)`;
+}
+
+function handleTouchEnd(e) {
+ if (!isDragging) return;
+
+ isDragging = false;
+ elements.carousel.style.transition = ''; // Re-enable transition
+
+ const diff = currentX - startX;
+ const threshold = elements.carousel.offsetWidth * 0.1; // 10% swipe threshold
+
+ if (Math.abs(diff) > threshold) {
+ // Call the handler passed during initialization
+ if (carouselSwipeHandler) {
+ carouselSwipeHandler(diff < 0 ? 1 : -1); // Pass direction: 1 for next, -1 for prev
+ }
+ } else {
+ // Snap back if swipe wasn't enough
+ updateCarouselPosition();
+ }
+}
+
+// --- UI Initialization ---
+
+// Add sound toggle button
+function createSoundToggleButton() {
+ const soundButton = document.createElement('button');
+ soundButton.id = 'soundToggleButton';
+ soundButton.className = 'header-button';
+ soundButton.title = 'Toggle Sound';
+ soundButton.innerHTML = audioManager.muted ? '' : '';
+
+ soundButton.addEventListener('click', () => {
+ const isMuted = audioManager.toggleMute();
+ soundButton.innerHTML = isMuted ? '' : '';
+ if (!isMuted) audioManager.play('buttonClick'); // Feedback only when unmuting
+ });
+
+ elements.headerButtons.prepend(soundButton); // Add to the beginning
+}
+
+// Parse time string (MM:SS) to seconds - Helper needed for form processing
+export function parseTimeString(timeString) {
+ if (!/^\d{1,2}:\d{2}$/.test(timeString)) {
+ console.error('Invalid time format:', timeString);
+ return null; // Indicate error
+ }
+ const parts = timeString.split(':');
+ const minutes = parseInt(parts[0], 10);
+ const seconds = parseInt(parts[1], 10);
+ if (isNaN(minutes) || isNaN(seconds) || seconds > 59) {
+ console.error('Invalid time value:', timeString);
+ return null;
+ }
+ return (minutes * 60) + seconds;
+}
+
+// Sets up basic UI elements and listeners that primarily affect the UI itself
+export function initUI(options) {
+ // Store the swipe handler provided by app.js
+ carouselSwipeHandler = options.onCarouselSwipe;
+
+ createSoundToggleButton();
+
+ // Carousel touch events
+ elements.carousel.addEventListener('touchstart', handleTouchStart, { passive: true });
+ elements.carousel.addEventListener('touchmove', handleTouchMove, { passive: true });
+ elements.carousel.addEventListener('touchend', handleTouchEnd);
+
+ // Image file input preview
+ elements.playerImageInput.addEventListener('change', (e) => {
+ const file = e.target.files[0];
+ if (file) {
+ updateImagePreviewFromFile(file);
+ }
+ });
+
+ // Initial render
+ renderPlayers();
+ updateGameButton();
+}
\ No newline at end of file