// 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'; // --- Core Game Actions --- function startGame() { if (state.getPlayers().length < 2) { alert('You need at least 2 players to start.'); return; } 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 } } 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 } } 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 } } 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(); } } 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 { // Allow cycling through all players if not running const playerCount = state.getPlayers().length; if(playerCount > 0) { newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount; } } 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. } } function previousPlayer() { const currentGameState = state.getGameState(); let newIndex = -1; if (currentGameState === config.GAME_STATES.RUNNING) { newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time } else { // Allow cycling through all players if not running const playerCount = state.getPlayers().length; if (playerCount > 0) { newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount; } } if (newIndex !== -1) { switchToPlayer(newIndex); } else if (currentGameState === config.GAME_STATES.RUNNING) { console.log("PreviousPlayer: No other player has time remaining."); } } function handleGameOver() { state.setGameState(config.GAME_STATES.OVER); audioManager.play('gameOver'); timer.stopTimer(); // Ensure timer is stopped ui.updateGameButton(); ui.renderPlayers(); // Update to show final state } 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; } // 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; } 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; } let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null; const imageFile = ui.elements.playerImageInput.files[0]; const saveAction = (finalImageData) => { if (isNewPlayer) { state.addPlayer(name, timeInMinutes, finalImageData); audioManager.play('playerAdded'); } else { 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'); } ui.hidePlayerModal(); ui.renderPlayers(); ui.updateGameButton(); // Update in case player count changed for setup state camera.stopStream(); // Ensure camera is stopped }; 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 { // 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)); } } function handlePlayerModalCancel() { audioManager.play('buttonClick'); ui.hidePlayerModal(); camera.stopStream(); // Make sure camera turns off } function handleDeletePlayer() { audioManager.play('buttonClick'); 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.'); } camera.stopStream(); } function handleResetConfirm() { audioManager.play('buttonClick'); fullResetApp(); } function handleResetCancel() { audioManager.play('buttonClick'); ui.hideResetModal(); } function handleCameraButtonClick(event) { event.preventDefault(); // Prevent form submission if inside form audioManager.play('buttonClick'); camera.open(); // Open the camera interface } // --- Timer Callbacks --- 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 }); }); // 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); } } 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 } // --- 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 // 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();