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}` : ''} -
-
${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 = `${currentPlayer.name}`; - } 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 = `Player`; - }; - 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 = `Player`; - - // 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}` : ''} +
+
${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 ? `${player.name}` : ''; + 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 = `Player Preview`; + // 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 = `Player Preview`; + // 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