Initial commit
This commit is contained in:
123
js/app.js
Normal file
123
js/app.js
Normal file
@@ -0,0 +1,123 @@
|
||||
// app.js - Main Application Orchestrator
|
||||
import * as config from './config.js';
|
||||
import * as state from './core/state.js';
|
||||
import * as ui from './ui/ui.js';
|
||||
import * as timer from './core/timer.js';
|
||||
import camera from './ui/camera.js'; // Default export
|
||||
import * as pushSettingsUI from './ui/pushSettingsUI.js'; // Import the new push settings UI module
|
||||
|
||||
// Import externalized modules
|
||||
import * as gameActions from './core/gameActions.js';
|
||||
import * as playerManager from './core/playerManager.js';
|
||||
import * as eventHandlers from './core/eventHandlers.js';
|
||||
import * as serviceWorkerManager from './services/serviceWorkerManager.js';
|
||||
import * as screenLockManager from './services/screenLockManager.js'; // Import the screen lock manager
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
async function initialize() {
|
||||
console.log("Initializing Game Timer App...");
|
||||
|
||||
// 0. Wait for environment variables to load
|
||||
try {
|
||||
// Use the ensureEnvLoaded function from config.js to make sure environment variables are loaded
|
||||
await config.ensureEnvLoaded();
|
||||
console.log("Environment variables loaded and verified");
|
||||
} catch (error) {
|
||||
console.warn("Failed to load environment variables, using defaults:", error);
|
||||
}
|
||||
|
||||
// 1. Load saved state or defaults
|
||||
state.loadData();
|
||||
|
||||
// Setup Flic action handlers early in the initialization process
|
||||
// to ensure they're available when the service worker initializes
|
||||
const flicActionHandlers = {
|
||||
[config.FLIC_ACTIONS.SINGLE_CLICK]: playerManager.nextPlayer,
|
||||
[config.FLIC_ACTIONS.DOUBLE_CLICK]: playerManager.previousPlayer,
|
||||
[config.FLIC_ACTIONS.HOLD]: gameActions.togglePauseResume,
|
||||
};
|
||||
|
||||
// Log the registered handlers for debugging
|
||||
console.log("Registering Flic action handlers:", {
|
||||
"SingleClick": config.FLIC_ACTIONS.SINGLE_CLICK,
|
||||
"DoubleClick": config.FLIC_ACTIONS.DOUBLE_CLICK,
|
||||
"Hold": config.FLIC_ACTIONS.HOLD
|
||||
});
|
||||
|
||||
serviceWorkerManager.setFlicActionHandlers(flicActionHandlers);
|
||||
|
||||
// 2. Initialize UI (pass carousel swipe handler)
|
||||
ui.initUI({
|
||||
onCarouselSwipe: (direction) => {
|
||||
if (direction > 0) playerManager.nextPlayer(); else playerManager.previousPlayer();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
|
||||
timer.initTimer({
|
||||
onTimerTick: eventHandlers.handleTimerTick,
|
||||
onPlayerSwitch: eventHandlers.handlePlayerSwitchOnTimer,
|
||||
onGameOver: gameActions.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: eventHandlers.handleCameraCapture
|
||||
}
|
||||
);
|
||||
|
||||
// 5. Set up UI Event Listeners that trigger actions
|
||||
ui.elements.gameButton.addEventListener('click', eventHandlers.handleGameButtonClick);
|
||||
ui.elements.setupButton.addEventListener('click', eventHandlers.handleSetupButtonClick);
|
||||
ui.elements.addPlayerButton.addEventListener('click', eventHandlers.handleAddPlayerButtonClick);
|
||||
ui.elements.resetButton.addEventListener('click', eventHandlers.handleResetButtonClick);
|
||||
ui.elements.playerForm.addEventListener('submit', playerManager.handlePlayerFormSubmit);
|
||||
ui.elements.cancelButton.addEventListener('click', eventHandlers.handlePlayerModalCancel);
|
||||
ui.elements.deletePlayerButton.addEventListener('click', playerManager.handleDeletePlayer);
|
||||
ui.elements.resetConfirmButton.addEventListener('click', eventHandlers.handleResetConfirm);
|
||||
ui.elements.resetCancelButton.addEventListener('click', eventHandlers.handleResetCancel);
|
||||
ui.elements.cameraButton.addEventListener('click', eventHandlers.handleCameraButtonClick);
|
||||
|
||||
// 6. Initialize Push Notification Settings UI
|
||||
pushSettingsUI.initPushSettingsUI();
|
||||
|
||||
// 7. Setup Service Worker (which also initializes Flic)
|
||||
serviceWorkerManager.setupServiceWorker(serviceWorkerManager.flicMessageHandler);
|
||||
|
||||
// 8. Initialize Screen Lock Manager (automatically acquires wake lock)
|
||||
const screenLockSupported = await screenLockManager.initScreenLockManager();
|
||||
console.log(`Screen Wake Lock API ${screenLockSupported ? 'is' : 'is not'} supported`);
|
||||
|
||||
// 9. Initial UI Update based on loaded state
|
||||
ui.renderPlayers();
|
||||
ui.updateGameButton();
|
||||
|
||||
// 10. 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 ---
|
||||
// We need to use an async IIFE to await the async initialize function
|
||||
(async () => {
|
||||
try {
|
||||
await initialize();
|
||||
} catch (error) {
|
||||
console.error("Error initializing application:", error);
|
||||
}
|
||||
})();
|
||||
67
js/config.js
Normal file
67
js/config.js
Normal file
@@ -0,0 +1,67 @@
|
||||
// config.js
|
||||
import { getEnv, waitForEnv } from './env-loader.js';
|
||||
|
||||
// Initialize environment variables
|
||||
let envInitialized = false;
|
||||
let initPromise = null;
|
||||
|
||||
// Function to ensure environment variables are loaded
|
||||
export async function ensureEnvLoaded() {
|
||||
if (envInitialized) return;
|
||||
|
||||
if (!initPromise) {
|
||||
initPromise = waitForEnv().then(() => {
|
||||
envInitialized = true;
|
||||
console.log('Environment variables loaded in config.js');
|
||||
});
|
||||
}
|
||||
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
// Initialize immediately
|
||||
ensureEnvLoaded();
|
||||
|
||||
// Direct access to environment variables (synchronous, may return default values if called too early)
|
||||
export function getPublicVapidKey() {
|
||||
return getEnv('PUBLIC_VAPID_KEY');
|
||||
}
|
||||
|
||||
export function getBackendUrl() {
|
||||
return getEnv('BACKEND_URL');
|
||||
}
|
||||
|
||||
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'
|
||||
};
|
||||
92
js/core/eventHandlers.js
Normal file
92
js/core/eventHandlers.js
Normal file
@@ -0,0 +1,92 @@
|
||||
// eventHandlers.js - UI event handlers
|
||||
import * as config from '../config.js';
|
||||
import * as state from './state.js';
|
||||
import * as ui from '../ui/ui.js';
|
||||
import audioManager from '../ui/audio.js';
|
||||
import camera from '../ui/camera.js';
|
||||
import { togglePauseResume, fullResetApp } from './gameActions.js';
|
||||
import { handlePlayerFormSubmit, handleDeletePlayer } from './playerManager.js';
|
||||
|
||||
export function handleGameButtonClick() {
|
||||
audioManager.play('buttonClick');
|
||||
togglePauseResume();
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export 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);
|
||||
}
|
||||
|
||||
export function handleResetButtonClick() {
|
||||
audioManager.play('buttonClick');
|
||||
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||
alert('Please pause the game before resetting.');
|
||||
return;
|
||||
}
|
||||
ui.showResetModal();
|
||||
}
|
||||
|
||||
export function handlePlayerModalCancel() {
|
||||
audioManager.play('buttonClick');
|
||||
ui.hidePlayerModal();
|
||||
camera.stopStream(); // Make sure camera turns off
|
||||
}
|
||||
|
||||
export function handleResetConfirm() {
|
||||
audioManager.play('buttonClick');
|
||||
fullResetApp();
|
||||
}
|
||||
|
||||
export function handleResetCancel() {
|
||||
audioManager.play('buttonClick');
|
||||
ui.hideResetModal();
|
||||
}
|
||||
|
||||
export function handleCameraButtonClick(event) {
|
||||
event.preventDefault(); // Prevent form submission if inside form
|
||||
audioManager.play('buttonClick');
|
||||
camera.open(); // Open the camera interface
|
||||
}
|
||||
|
||||
// --- Timer Callbacks ---
|
||||
export function handleTimerTick() {
|
||||
// Timer module already updated the state, just need to redraw UI
|
||||
ui.renderPlayers();
|
||||
}
|
||||
|
||||
export function handlePlayerSwitchOnTimer(newPlayerIndex) {
|
||||
// Timer detected current player ran out, found next player
|
||||
console.log(`Timer switching to player index: ${newPlayerIndex}`);
|
||||
// Import switchToPlayer dynamically to avoid circular dependency
|
||||
import('./playerManager.js').then(module => {
|
||||
module.switchToPlayer(newPlayerIndex);
|
||||
});
|
||||
// Sound is handled in switchToPlayer
|
||||
}
|
||||
|
||||
// --- Camera Callback ---
|
||||
export function handleCameraCapture(imageDataUrl) {
|
||||
console.log("Image captured");
|
||||
ui.updateImagePreviewFromDataUrl(imageDataUrl);
|
||||
// Camera module already closed the camera UI
|
||||
}
|
||||
134
js/core/gameActions.js
Normal file
134
js/core/gameActions.js
Normal file
@@ -0,0 +1,134 @@
|
||||
// gameActions.js - Core game action functions
|
||||
import * as config from '../config.js';
|
||||
import * as state from './state.js';
|
||||
import * as ui from '../ui/ui.js';
|
||||
import * as timer from './timer.js';
|
||||
import audioManager from '../ui/audio.js';
|
||||
import * as screenLockManager from '../services/screenLockManager.js'; // Import screen lock manager
|
||||
|
||||
// --- Core Game Actions ---
|
||||
|
||||
// Declare handleGameOver at the top level to avoid referencing before definition
|
||||
export function handleGameOver() {
|
||||
state.setGameState(config.GAME_STATES.OVER);
|
||||
audioManager.play('gameOver');
|
||||
timer.stopTimer(); // Ensure timer is stopped
|
||||
|
||||
// Release screen wake lock when game is over
|
||||
screenLockManager.releaseWakeLock().then(success => {
|
||||
if (success) {
|
||||
console.log('Screen wake lock released on game over');
|
||||
}
|
||||
});
|
||||
|
||||
ui.updateGameButton();
|
||||
ui.renderPlayers(); // Update to show final state
|
||||
}
|
||||
|
||||
export 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();
|
||||
|
||||
// Acquire screen wake lock when game starts
|
||||
screenLockManager.acquireWakeLock().then(success => {
|
||||
if (success) {
|
||||
console.log('Screen wake lock acquired for game');
|
||||
} else {
|
||||
console.warn('Failed to acquire screen wake lock');
|
||||
}
|
||||
});
|
||||
|
||||
ui.updateGameButton();
|
||||
ui.renderPlayers(); // Ensure active timer styling is applied
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseGame() {
|
||||
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||
state.setGameState(config.GAME_STATES.PAUSED);
|
||||
audioManager.play('gamePause');
|
||||
timer.stopTimer();
|
||||
|
||||
// Release screen wake lock when game is paused
|
||||
screenLockManager.releaseWakeLock().then(success => {
|
||||
if (success) {
|
||||
console.log('Screen wake lock released on pause');
|
||||
}
|
||||
});
|
||||
|
||||
ui.updateGameButton();
|
||||
ui.renderPlayers(); // Ensure active timer styling is removed
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
|
||||
export 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();
|
||||
}
|
||||
}
|
||||
|
||||
export 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
|
||||
|
||||
// Release screen wake lock when game is reset
|
||||
screenLockManager.releaseWakeLock().then(success => {
|
||||
if (success) {
|
||||
console.log('Screen wake lock released on reset');
|
||||
}
|
||||
});
|
||||
|
||||
audioManager.play('buttonClick'); // Or a specific reset sound?
|
||||
ui.updateGameButton();
|
||||
ui.renderPlayers();
|
||||
}
|
||||
|
||||
export function fullResetApp() {
|
||||
timer.stopTimer();
|
||||
state.resetToDefaults();
|
||||
|
||||
// Release screen wake lock on full reset
|
||||
screenLockManager.releaseWakeLock().then(success => {
|
||||
if (success) {
|
||||
console.log('Screen wake lock released on full reset');
|
||||
}
|
||||
});
|
||||
|
||||
audioManager.play('gameOver'); // Use game over sound for full reset
|
||||
ui.hideResetModal();
|
||||
ui.updateGameButton();
|
||||
ui.renderPlayers();
|
||||
}
|
||||
154
js/core/playerManager.js
Normal file
154
js/core/playerManager.js
Normal file
@@ -0,0 +1,154 @@
|
||||
// playerManager.js - Player-related operations
|
||||
import * as config from '../config.js';
|
||||
import * as state from './state.js';
|
||||
import * as ui from '../ui/ui.js';
|
||||
import * as timer from './timer.js';
|
||||
import audioManager from '../ui/audio.js';
|
||||
import camera from '../ui/camera.js';
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export 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.
|
||||
}
|
||||
}
|
||||
|
||||
export 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.");
|
||||
}
|
||||
}
|
||||
|
||||
export 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 : (isNewPlayer ? null : 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));
|
||||
}
|
||||
}
|
||||
|
||||
export 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();
|
||||
}
|
||||
230
js/core/state.js
Normal file
230
js/core/state.js
Normal file
@@ -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
|
||||
}
|
||||
104
js/core/timer.js
Normal file
104
js/core/timer.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// timer.js
|
||||
import * as state from './state.js';
|
||||
import { GAME_STATES } from '../config.js';
|
||||
import audioManager from '../ui/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
|
||||
let timeExpiredFlagsById = new Map(); // Track which players have had their timeout sound played
|
||||
|
||||
export function initTimer(options) {
|
||||
onTimerTickCallback = options.onTimerTick;
|
||||
onPlayerSwitchCallback = options.onPlayerSwitch;
|
||||
onGameOverCallback = options.onGameOver;
|
||||
timeExpiredFlagsById.clear(); // Reset flags on init
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
// Reset the expired sound flags when starting a new timer
|
||||
timeExpiredFlagsById.clear();
|
||||
|
||||
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 - ensure we're not leaking audio resources
|
||||
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
|
||||
if(currentPlayer.remainingTime < 0) {
|
||||
state.updatePlayerTime(currentPlayerIndex, 0);
|
||||
}
|
||||
|
||||
// Play time expired sound (only once per player per game)
|
||||
if (!timeExpiredFlagsById.has(currentPlayer.id)) {
|
||||
audioManager.playTimerExpired();
|
||||
timeExpiredFlagsById.set(currentPlayer.id, true);
|
||||
}
|
||||
|
||||
// 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 and ensure we stop any sounds from current player
|
||||
audioManager.stopTimerSounds(); // Stop specific timer sounds before switching
|
||||
|
||||
if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
export function stopTimer() {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
// Stop all timer-related sounds to prevent them from continuing to play
|
||||
audioManager.stopTimerSounds();
|
||||
}
|
||||
|
||||
export function isTimerRunning() {
|
||||
return timerInterval !== null;
|
||||
}
|
||||
|
||||
// Clean up resources when the application is closing or component unmounts
|
||||
export function cleanup() {
|
||||
stopTimer();
|
||||
timeExpiredFlagsById.clear();
|
||||
audioManager.stopAllSounds();
|
||||
}
|
||||
87
js/env-loader.js
Normal file
87
js/env-loader.js
Normal file
@@ -0,0 +1,87 @@
|
||||
// env-loader.js
|
||||
// This module is responsible for loading environment variables for the PWA
|
||||
|
||||
// Store environment variables in a global object
|
||||
window.ENV_CONFIG = {
|
||||
// Default values that will be overridden when config.env.js loads
|
||||
PUBLIC_VAPID_KEY: 'your_public_vapid_key_here',
|
||||
BACKEND_URL: 'https://your-push-server.example.com'
|
||||
};
|
||||
|
||||
// Function to load environment variables from config.env.js
|
||||
async function loadEnvVariables() {
|
||||
try {
|
||||
// Try to fetch the config.env.js file which will be generated at build time or deployment
|
||||
const response = await fetch('/config.env.js');
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Could not load config.env.js file. Using default values.');
|
||||
return;
|
||||
}
|
||||
|
||||
const configText = await response.text();
|
||||
|
||||
// Extract the configuration object from the JavaScript file
|
||||
// The file should be in format: window.ENV_CONFIG = { key: "value" };
|
||||
try {
|
||||
// Create a safe way to evaluate the config file
|
||||
const configScript = document.createElement('script');
|
||||
configScript.textContent = configText;
|
||||
document.head.appendChild(configScript);
|
||||
document.head.removeChild(configScript);
|
||||
|
||||
console.log('Environment variables loaded successfully from config.env.js');
|
||||
|
||||
// Dispatch an event to notify that environment variables have been loaded
|
||||
window.dispatchEvent(new CustomEvent('env-config-loaded', {
|
||||
detail: { config: window.ENV_CONFIG }
|
||||
}));
|
||||
} catch (parseError) {
|
||||
console.error('Error parsing config.env.js:', parseError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading environment variables:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Export function to initialize environment variables
|
||||
export async function initEnv() {
|
||||
await loadEnvVariables();
|
||||
return window.ENV_CONFIG;
|
||||
}
|
||||
|
||||
// Start loading environment variables immediately
|
||||
initEnv();
|
||||
|
||||
// Export access functions for environment variables
|
||||
export function getEnv(key, defaultValue = '') {
|
||||
return window.ENV_CONFIG[key] || defaultValue;
|
||||
}
|
||||
|
||||
// Export a function to wait for environment variables to be loaded
|
||||
export function waitForEnv() {
|
||||
return new Promise((resolve) => {
|
||||
// If we already have non-default values, resolve immediately
|
||||
if (window.ENV_CONFIG.BACKEND_URL !== 'https://your-push-server.example.com') {
|
||||
resolve(window.ENV_CONFIG);
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise, wait for the env-config-loaded event
|
||||
window.addEventListener('env-config-loaded', (event) => {
|
||||
resolve(event.detail.config);
|
||||
}, { once: true });
|
||||
|
||||
// Set a timeout to resolve with current values if loading takes too long
|
||||
setTimeout(() => {
|
||||
console.warn('Environment loading timed out, using current values');
|
||||
resolve(window.ENV_CONFIG);
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
initEnv,
|
||||
getEnv,
|
||||
waitForEnv
|
||||
};
|
||||
251
js/services/pushFlicIntegration.js
Normal file
251
js/services/pushFlicIntegration.js
Normal file
@@ -0,0 +1,251 @@
|
||||
// pushFlicIntegration.js
|
||||
import { getPublicVapidKey, getBackendUrl, FLIC_BUTTON_ID} 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 {
|
||||
const credentials = JSON.parse(storedAuth);
|
||||
// Check if the credentials are valid
|
||||
if (credentials.username && credentials.password) {
|
||||
console.log('Using stored basic auth credentials.');
|
||||
return credentials;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to parse stored credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// No valid stored credentials found
|
||||
// The function will return null and the caller should handle prompting if needed
|
||||
console.log('No valid stored credentials found.');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// First request notification permission
|
||||
console.log('Requesting notification permission...');
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
console.warn('Notification permission denied.');
|
||||
alert('Please enable notifications to link the Flic button.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Notification permission granted.');
|
||||
|
||||
// Get stored credentials but don't prompt
|
||||
let credentials = getBasicAuthCredentials();
|
||||
const hasExistingCreds = !!credentials;
|
||||
console.log('Has existing credentials:', hasExistingCreds);
|
||||
|
||||
// No prompting for credentials - user must enter them manually in the UI
|
||||
if (!credentials) {
|
||||
console.log('No credentials found. User needs to enter them manually.');
|
||||
// Just return if no credentials are available
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
let existingSubscription = await registration.pushManager.getSubscription();
|
||||
let needsResubscribe = !existingSubscription;
|
||||
|
||||
console.log('Existing subscription found:', !!existingSubscription);
|
||||
|
||||
if (existingSubscription) {
|
||||
const existingKey = existingSubscription.options?.applicationServerKey;
|
||||
if (!existingKey || arrayBufferToBase64(existingKey) !== getPublicVapidKey()) {
|
||||
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(getPublicVapidKey());
|
||||
try {
|
||||
finalSubscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: applicationServerKey
|
||||
});
|
||||
console.log('New push subscription obtained:', finalSubscription);
|
||||
pushSubscription = finalSubscription; // Store it
|
||||
} catch (subscribeError) {
|
||||
console.error('Error subscribing to push:', subscribeError);
|
||||
alert(`Failed to subscribe: ${subscribeError.message}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalSubscription) {
|
||||
console.error("Failed to obtain a subscription object.");
|
||||
alert("Could not get subscription details.");
|
||||
return;
|
||||
}
|
||||
|
||||
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) {
|
||||
console.log('No credentials found. User needs to enter them manually.');
|
||||
return;
|
||||
}
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const authHeader = createBasicAuthHeader(credentials);
|
||||
if (authHeader) headers['Authorization'] = authHeader;
|
||||
|
||||
try {
|
||||
// Add support for handling CORS preflight with credentials
|
||||
const response = await fetch(`${getBackendUrl()}/subscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ button_id: buttonId, subscription: subscription }),
|
||||
headers: headers,
|
||||
credentials: 'include' // This ensures credentials are sent with OPTIONS requests too
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
console.log('Subscription sent successfully:', result.message);
|
||||
|
||||
// Update the UI to show subscription status as active
|
||||
const subscriptionStatusElement = document.getElementById('subscriptionStatus');
|
||||
if (subscriptionStatusElement) {
|
||||
subscriptionStatusElement.textContent = 'active';
|
||||
subscriptionStatusElement.className = 'status-active';
|
||||
|
||||
// Enable unsubscribe button when subscription is active
|
||||
const unsubscribeButton = document.getElementById('pushUnsubscribeButton');
|
||||
if (unsubscribeButton) unsubscribeButton.disabled = false;
|
||||
|
||||
// Change subscribe button text to "Re-subscribe"
|
||||
const resubscribeButton = document.getElementById('pushResubscribeButton');
|
||||
if (resubscribeButton) resubscribeButton.textContent = 'Re-subscribe';
|
||||
|
||||
// Enable simulate button when subscription is active
|
||||
const simulateButton = document.getElementById('simulateClickButton');
|
||||
if (simulateButton) simulateButton.disabled = false;
|
||||
}
|
||||
|
||||
// Success alert removed as requested
|
||||
} 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, batteryLevel) {
|
||||
console.log(`[PushFlic] Received Action: ${action} from Button: ${buttonId} battery: ${batteryLevel}% at ${timestamp}`);
|
||||
|
||||
// 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}`);
|
||||
try {
|
||||
// Execute the handler registered in app.js
|
||||
handler();
|
||||
// Log success
|
||||
console.log(`[PushFlic] Successfully executed handler for ${action}`);
|
||||
} catch (error) {
|
||||
console.error(`[PushFlic] Error executing handler for ${action}:`, error);
|
||||
}
|
||||
} else {
|
||||
console.warn(`[PushFlic] No handler registered for action: ${action}. Available handlers:`, Object.keys(actionHandlers));
|
||||
}
|
||||
}
|
||||
|
||||
// --- Initialization ---
|
||||
|
||||
// Initialize PushFlic with action handlers
|
||||
export function initPushFlic(handlers) {
|
||||
if (handlers && Object.keys(handlers).length > 0) {
|
||||
actionHandlers = handlers;
|
||||
console.log('[PushFlic] Stored action handlers:', Object.keys(actionHandlers));
|
||||
} else {
|
||||
console.warn('[PushFlic] No action handlers provided to initPushFlic!');
|
||||
}
|
||||
}
|
||||
|
||||
// New function to manually trigger the subscription process
|
||||
export function setupPushNotifications() {
|
||||
console.log('[PushFlic] Manually triggering push notification setup');
|
||||
subscribeToPush();
|
||||
}
|
||||
128
js/services/screenLockManager.js
Normal file
128
js/services/screenLockManager.js
Normal file
@@ -0,0 +1,128 @@
|
||||
// screenLockManager.js - Manages screen wake lock to prevent screen from turning off
|
||||
// Uses the Screen Wake Lock API: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
|
||||
|
||||
let wakeLock = null;
|
||||
let isLockEnabled = false;
|
||||
|
||||
/**
|
||||
* Requests a screen wake lock to prevent the screen from turning off
|
||||
* @returns {Promise<boolean>} - True if wake lock was acquired successfully
|
||||
*/
|
||||
export async function acquireWakeLock() {
|
||||
if (!isScreenWakeLockSupported()) {
|
||||
console.warn('[ScreenLockManager] Screen Wake Lock API not supported in this browser');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Release any existing wake lock first
|
||||
await releaseWakeLock();
|
||||
|
||||
// Request a new wake lock
|
||||
wakeLock = await navigator.wakeLock.request('screen');
|
||||
isLockEnabled = true;
|
||||
|
||||
console.log('[ScreenLockManager] Screen Wake Lock acquired');
|
||||
|
||||
// Add event listeners to reacquire the lock when needed
|
||||
setupWakeLockListeners();
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ScreenLockManager] Error acquiring wake lock:', error);
|
||||
isLockEnabled = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases the screen wake lock if one is active
|
||||
* @returns {Promise<boolean>} - True if wake lock was released successfully
|
||||
*/
|
||||
export async function releaseWakeLock() {
|
||||
if (!wakeLock) {
|
||||
return true; // No wake lock to release
|
||||
}
|
||||
|
||||
try {
|
||||
await wakeLock.release();
|
||||
wakeLock = null;
|
||||
isLockEnabled = false;
|
||||
console.log('[ScreenLockManager] Screen Wake Lock released');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[ScreenLockManager] Error releasing wake lock:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Screen Wake Lock API is supported in this browser
|
||||
* @returns {boolean} - True if supported
|
||||
*/
|
||||
export function isScreenWakeLockSupported() {
|
||||
return 'wakeLock' in navigator && 'request' in navigator.wakeLock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current status of the wake lock
|
||||
* @returns {boolean} - True if wake lock is currently active
|
||||
*/
|
||||
export function isWakeLockActive() {
|
||||
return isLockEnabled && wakeLock !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up event listeners to reacquire the wake lock when needed
|
||||
* (e.g., when the page becomes visible again after being hidden)
|
||||
*/
|
||||
function setupWakeLockListeners() {
|
||||
// When the page becomes visible again, reacquire the wake lock
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
// When the screen orientation changes, reacquire the wake lock
|
||||
if ('screen' in window && 'orientation' in window.screen) {
|
||||
window.screen.orientation.addEventListener('change', handleOrientationChange);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles visibility change events to reacquire wake lock when page becomes visible
|
||||
*/
|
||||
async function handleVisibilityChange() {
|
||||
if (isLockEnabled && document.visibilityState === 'visible') {
|
||||
// Only try to reacquire if we previously had a lock
|
||||
await acquireWakeLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles orientation change events to reacquire wake lock
|
||||
*/
|
||||
async function handleOrientationChange() {
|
||||
if (isLockEnabled) {
|
||||
// Some devices may release the wake lock on orientation change
|
||||
await acquireWakeLock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the screen lock manager
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {boolean} options.autoAcquire - Whether to automatically acquire wake lock on init
|
||||
* @returns {Promise<boolean>} - True if initialization was successful
|
||||
*/
|
||||
export async function initScreenLockManager(options = {}) {
|
||||
const { autoAcquire = true } = options; // Default to true - automatically acquire on init
|
||||
|
||||
// Check for support
|
||||
const isSupported = isScreenWakeLockSupported();
|
||||
console.log(`[ScreenLockManager] Screen Wake Lock API ${isSupported ? 'is' : 'is not'} supported`);
|
||||
|
||||
// Automatically acquire wake lock if supported (now default behavior)
|
||||
if (autoAcquire && isSupported) {
|
||||
return await acquireWakeLock();
|
||||
}
|
||||
|
||||
return isSupported;
|
||||
}
|
||||
116
js/services/serviceWorkerManager.js
Normal file
116
js/services/serviceWorkerManager.js
Normal file
@@ -0,0 +1,116 @@
|
||||
// serviceWorkerManager.js - Service worker registration and Flic integration
|
||||
import * as pushFlic from './pushFlicIntegration.js';
|
||||
|
||||
// Store the action handlers passed from app.js
|
||||
let flicActionHandlers = {};
|
||||
|
||||
export function setFlicActionHandlers(handlers) {
|
||||
if (handlers && Object.keys(handlers).length > 0) {
|
||||
flicActionHandlers = handlers;
|
||||
console.log('[ServiceWorkerManager] Stored action handlers:', Object.keys(flicActionHandlers));
|
||||
|
||||
// Always pass handlers to pushFlic, regardless of service worker state
|
||||
pushFlic.initPushFlic(flicActionHandlers);
|
||||
} else {
|
||||
console.warn('[ServiceWorkerManager] No action handlers provided to setFlicActionHandlers!');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flic Integration Setup ---
|
||||
export function initFlic() {
|
||||
// Make sure we have handlers before initializing
|
||||
if (Object.keys(flicActionHandlers).length === 0) {
|
||||
console.warn('[ServiceWorkerManager] No Flic handlers registered before initFlic! Actions may not work.');
|
||||
}
|
||||
|
||||
// This function is used by setupServiceWorker and relies on
|
||||
// flicActionHandlers being set before this is called
|
||||
console.log('[ServiceWorkerManager] Initializing PushFlic with handlers:', Object.keys(flicActionHandlers));
|
||||
pushFlic.initPushFlic(flicActionHandlers);
|
||||
}
|
||||
|
||||
// Export functions for manually triggering push notifications setup
|
||||
export function setupPushNotifications() {
|
||||
pushFlic.setupPushNotifications();
|
||||
}
|
||||
|
||||
// --- Handle Messages from Service Worker ---
|
||||
|
||||
export function flicMessageHandler(event) {
|
||||
// This function is passed to setupServiceWorker and called when a message arrives from the service worker
|
||||
console.log('[App] Message received from Service Worker:', event.data);
|
||||
|
||||
// Check if this is a Flic action message
|
||||
if (event.data && event.data.type === 'flic-action') {
|
||||
const { action, button, timestamp, batteryLevel } = event.data;
|
||||
|
||||
try {
|
||||
// Pass to push-flic service to handle
|
||||
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
|
||||
} catch (error) {
|
||||
console.error('[App] Error handling flic action:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global message handler function to ensure we catch all service worker messages
|
||||
function handleServiceWorkerMessage(event) {
|
||||
// Check if the message might be from our service worker
|
||||
if (event.data && typeof event.data === 'object') {
|
||||
console.log('[App] Potential window message received:', event.data);
|
||||
|
||||
// If it looks like a flic action message, handle it
|
||||
if (event.data.type === 'flic-action') {
|
||||
try {
|
||||
// Process the message with our flicMessageHandler
|
||||
flicMessageHandler(event);
|
||||
} catch (error) {
|
||||
console.error('[App] Error handling window message:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Service Worker and PWA Setup ---
|
||||
export function setupServiceWorker(messageHandler) {
|
||||
if ('serviceWorker' in navigator) {
|
||||
console.log('[ServiceWorkerManager] Setting up service worker...');
|
||||
|
||||
// Set up global message event listener on window object
|
||||
window.addEventListener('message', handleServiceWorkerMessage);
|
||||
|
||||
// Listen for messages FROM the Service Worker
|
||||
// This is the main way messages from the service worker are received
|
||||
navigator.serviceWorker.addEventListener('message', event => {
|
||||
console.log('[ServiceWorkerManager] Service worker message received:', event.data);
|
||||
messageHandler(event);
|
||||
});
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
console.log('[ServiceWorkerManager] Window loaded, registering service worker...');
|
||||
navigator.serviceWorker.register('/sw.js')
|
||||
.then(registration => {
|
||||
console.log('[ServiceWorkerManager] ServiceWorker registered successfully.');
|
||||
|
||||
// Add an event listener that will work with service worker controlled clients
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[ServiceWorkerManager] Service worker already controlling the page.');
|
||||
}
|
||||
|
||||
// Initialize Flic integration
|
||||
initFlic();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('[ServiceWorkerManager] ServiceWorker registration failed:', error);
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for SW controller changes
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[ServiceWorkerManager] Service Worker controller changed, potentially updated.');
|
||||
});
|
||||
|
||||
} else {
|
||||
console.warn('[ServiceWorkerManager] ServiceWorker not supported.');
|
||||
}
|
||||
}
|
||||
323
js/ui/audio.js
Normal file
323
js/ui/audio.js
Normal file
@@ -0,0 +1,323 @@
|
||||
// Audio Manager using Web Audio API
|
||||
const audioManager = {
|
||||
audioContext: null,
|
||||
muted: false,
|
||||
sounds: {},
|
||||
lowTimeThreshold: 10, // Seconds threshold for low time warning
|
||||
lastTickTime: 0, // Track when we started continuous ticking
|
||||
tickFadeoutTime: 3, // Seconds after which tick sound fades out
|
||||
|
||||
// Initialize the audio context
|
||||
init() {
|
||||
try {
|
||||
// Create AudioContext (with fallback for older browsers)
|
||||
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||
|
||||
// Check for saved mute preference
|
||||
const savedMute = localStorage.getItem('gameTimerMuted');
|
||||
this.muted = savedMute === 'true';
|
||||
|
||||
// Create all the sounds
|
||||
this.createSounds();
|
||||
|
||||
console.log('Web Audio API initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Web Audio API initialization failed:', error);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
// Create all the sound generators
|
||||
createSounds() {
|
||||
// Game sounds
|
||||
this.sounds.tick = this.createTickSound();
|
||||
this.sounds.lowTime = this.createLowTimeSound();
|
||||
this.sounds.timeUp = this.createTimeUpSound();
|
||||
this.sounds.gameStart = this.createGameStartSound();
|
||||
this.sounds.gamePause = this.createGamePauseSound();
|
||||
this.sounds.gameResume = this.createGameResumeSound();
|
||||
this.sounds.gameOver = this.createGameOverSound();
|
||||
this.sounds.playerSwitch = this.createPlayerSwitchSound();
|
||||
|
||||
// UI sounds
|
||||
this.sounds.buttonClick = this.createButtonClickSound();
|
||||
this.sounds.modalOpen = this.createModalOpenSound();
|
||||
this.sounds.modalClose = this.createModalCloseSound();
|
||||
this.sounds.playerAdded = this.createPlayerAddedSound();
|
||||
this.sounds.playerEdited = this.createPlayerEditedSound();
|
||||
this.sounds.playerDeleted = this.createPlayerDeletedSound();
|
||||
},
|
||||
|
||||
// Helper function to create an oscillator
|
||||
createOscillator(type, frequency, startTime, duration, gain = 1.0, ramp = false) {
|
||||
if (this.audioContext === null) this.init();
|
||||
|
||||
const oscillator = this.audioContext.createOscillator();
|
||||
const gainNode = this.audioContext.createGain();
|
||||
|
||||
oscillator.type = type;
|
||||
oscillator.frequency.value = frequency;
|
||||
gainNode.gain.value = gain;
|
||||
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(this.audioContext.destination);
|
||||
|
||||
oscillator.start(startTime);
|
||||
|
||||
if (ramp) {
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||
}
|
||||
|
||||
oscillator.stop(startTime + duration);
|
||||
|
||||
return { oscillator, gainNode };
|
||||
},
|
||||
|
||||
// Sound creators
|
||||
createTickSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
const currentTime = Date.now() / 1000;
|
||||
|
||||
// Initialize lastTickTime if it's not set
|
||||
if (this.lastTickTime === 0) {
|
||||
this.lastTickTime = currentTime;
|
||||
}
|
||||
|
||||
// Calculate how long we've been ticking continuously
|
||||
const tickDuration = currentTime - this.lastTickTime;
|
||||
|
||||
// Determine volume based on duration
|
||||
let volume = 0.1; // Default/initial volume
|
||||
|
||||
if (tickDuration <= this.tickFadeoutTime) {
|
||||
// Linear fade from 0.1 to 0 over tickFadeoutTime seconds
|
||||
volume = 0.1 * (1 - (tickDuration / this.tickFadeoutTime));
|
||||
} else {
|
||||
// After tickFadeoutTime, don't play any sound
|
||||
return; // Exit without playing sound
|
||||
}
|
||||
|
||||
// Only play if volume is significant
|
||||
if (volume > 0.001) {
|
||||
this.createOscillator('sine', 800, now, 0.03, volume);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
createLowTimeSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
// Low time warning is always audible
|
||||
this.createOscillator('triangle', 660, now, 0.1, 0.2);
|
||||
// Reset tick fade timer on low time warning
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createTimeUpSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// First note
|
||||
this.createOscillator('sawtooth', 440, now, 0.2, 0.3);
|
||||
|
||||
// Second note (lower)
|
||||
this.createOscillator('sawtooth', 220, now + 0.25, 0.3, 0.4);
|
||||
|
||||
// Reset tick fade timer
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createGameStartSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Rising sequence
|
||||
this.createOscillator('sine', 440, now, 0.1, 0.3);
|
||||
this.createOscillator('sine', 554, now + 0.1, 0.1, 0.3);
|
||||
this.createOscillator('sine', 659, now + 0.2, 0.3, 0.3, true);
|
||||
|
||||
// Reset tick fade timer
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createGamePauseSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Two notes pause sound
|
||||
this.createOscillator('sine', 659, now, 0.1, 0.3);
|
||||
this.createOscillator('sine', 523, now + 0.15, 0.2, 0.3, true);
|
||||
|
||||
// Reset tick fade timer
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createGameResumeSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Rising sequence (opposite of pause)
|
||||
this.createOscillator('sine', 523, now, 0.1, 0.3);
|
||||
this.createOscillator('sine', 659, now + 0.15, 0.2, 0.3, true);
|
||||
|
||||
// Reset tick fade timer
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createGameOverSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Fanfare
|
||||
this.createOscillator('square', 440, now, 0.1, 0.3);
|
||||
this.createOscillator('square', 554, now + 0.1, 0.1, 0.3);
|
||||
this.createOscillator('square', 659, now + 0.2, 0.1, 0.3);
|
||||
this.createOscillator('square', 880, now + 0.3, 0.4, 0.3, true);
|
||||
|
||||
// Reset tick fade timer
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createPlayerSwitchSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
this.createOscillator('sine', 1200, now, 0.05, 0.2);
|
||||
|
||||
// Reset tick fade timer on player switch
|
||||
this.lastTickTime = 0;
|
||||
};
|
||||
},
|
||||
|
||||
createButtonClickSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
this.createOscillator('sine', 700, now, 0.04, 0.1);
|
||||
};
|
||||
},
|
||||
|
||||
createModalOpenSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Ascending sound
|
||||
this.createOscillator('sine', 400, now, 0.1, 0.2);
|
||||
this.createOscillator('sine', 600, now + 0.1, 0.1, 0.2);
|
||||
};
|
||||
},
|
||||
|
||||
createModalCloseSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Descending sound
|
||||
this.createOscillator('sine', 600, now, 0.1, 0.2);
|
||||
this.createOscillator('sine', 400, now + 0.1, 0.1, 0.2);
|
||||
};
|
||||
},
|
||||
|
||||
createPlayerAddedSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Positive ascending notes
|
||||
this.createOscillator('sine', 440, now, 0.1, 0.2);
|
||||
this.createOscillator('sine', 523, now + 0.1, 0.1, 0.2);
|
||||
this.createOscillator('sine', 659, now + 0.2, 0.2, 0.2, true);
|
||||
};
|
||||
},
|
||||
|
||||
createPlayerEditedSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Two note confirmation
|
||||
this.createOscillator('sine', 440, now, 0.1, 0.2);
|
||||
this.createOscillator('sine', 523, now + 0.15, 0.15, 0.2);
|
||||
};
|
||||
},
|
||||
|
||||
createPlayerDeletedSound() {
|
||||
return () => {
|
||||
const now = this.audioContext.currentTime;
|
||||
|
||||
// Descending notes
|
||||
this.createOscillator('sine', 659, now, 0.1, 0.2);
|
||||
this.createOscillator('sine', 523, now + 0.1, 0.1, 0.2);
|
||||
this.createOscillator('sine', 392, now + 0.2, 0.2, 0.2, true);
|
||||
};
|
||||
},
|
||||
|
||||
// Play a sound if not muted
|
||||
play(soundName) {
|
||||
if (this.muted || !this.sounds[soundName]) return;
|
||||
|
||||
// Resume audio context if it's suspended (needed for newer browsers)
|
||||
if (this.audioContext.state === 'suspended') {
|
||||
this.audioContext.resume();
|
||||
}
|
||||
|
||||
this.sounds[soundName]();
|
||||
},
|
||||
|
||||
// Toggle mute state
|
||||
toggleMute() {
|
||||
this.muted = !this.muted;
|
||||
localStorage.setItem('gameTimerMuted', this.muted);
|
||||
return this.muted;
|
||||
},
|
||||
|
||||
// Play timer sounds based on remaining time
|
||||
playTimerSound(remainingSeconds) {
|
||||
if (remainingSeconds <= 0) {
|
||||
// Reset tick fade timer when timer stops
|
||||
this.lastTickTime = 0;
|
||||
return; // Don't play sounds for zero time
|
||||
}
|
||||
|
||||
if (remainingSeconds <= this.lowTimeThreshold) {
|
||||
// Play low time warning sound (this resets the tick fade timer)
|
||||
this.play('lowTime');
|
||||
} else if (remainingSeconds % 1 === 0) {
|
||||
// Normal tick sound on every second
|
||||
this.play('tick');
|
||||
}
|
||||
},
|
||||
|
||||
// Play timer expired sound
|
||||
playTimerExpired() {
|
||||
this.play('timeUp');
|
||||
},
|
||||
|
||||
// Stop all sounds and reset the tick fading
|
||||
stopAllSounds() {
|
||||
// Reset tick fade timer when stopping sounds
|
||||
this.lastTickTime = 0;
|
||||
},
|
||||
|
||||
// Stop timer-specific sounds (tick, low time warning)
|
||||
stopTimerSounds() {
|
||||
// Reset tick fade timer when stopping timer sounds
|
||||
this.lastTickTime = 0;
|
||||
// In this implementation, sounds are so short-lived that
|
||||
// they don't need to be explicitly stopped, just fade prevention is enough
|
||||
},
|
||||
|
||||
// Reset the tick fading (call this when timer is paused or player changes)
|
||||
resetTickFade() {
|
||||
this.lastTickTime = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// Initialize audio on module load
|
||||
audioManager.init();
|
||||
|
||||
// Export the audio manager
|
||||
export default audioManager;
|
||||
116
js/ui/camera.js
Normal file
116
js/ui/camera.js
Normal file
@@ -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
|
||||
};
|
||||
435
js/ui/pushSettingsUI.js
Normal file
435
js/ui/pushSettingsUI.js
Normal file
@@ -0,0 +1,435 @@
|
||||
// pushSettingsUI.js - UI handling for push notification settings
|
||||
import { setupPushNotifications } from '../services/serviceWorkerManager.js';
|
||||
import { FLIC_BUTTON_ID, getBackendUrl} from '../config.js';
|
||||
|
||||
// --- DOM Elements ---
|
||||
const elements = {
|
||||
pushSettingsButton: null,
|
||||
pushSettingsModal: null,
|
||||
notificationPermissionStatus: null,
|
||||
subscriptionStatus: null,
|
||||
pushUsername: null,
|
||||
pushPassword: null,
|
||||
pushSaveButton: null,
|
||||
pushCancelButton: null,
|
||||
pushUnsubscribeButton: null,
|
||||
pushResubscribeButton: null,
|
||||
// Message monitor elements
|
||||
swMessagesOutput: null,
|
||||
simulateClickButton: null
|
||||
};
|
||||
|
||||
// --- State ---
|
||||
let currentSubscription = null;
|
||||
let messageListener = null;
|
||||
let isMonitoring = false;
|
||||
|
||||
// --- Initialization ---
|
||||
export function initPushSettingsUI() {
|
||||
// Cache DOM elements
|
||||
elements.pushSettingsButton = document.getElementById('pushSettingsButton');
|
||||
elements.pushSettingsModal = document.getElementById('pushSettingsModal');
|
||||
elements.notificationPermissionStatus = document.getElementById('notificationPermissionStatus');
|
||||
elements.subscriptionStatus = document.getElementById('subscriptionStatus');
|
||||
elements.pushUsername = document.getElementById('pushUsername');
|
||||
elements.pushPassword = document.getElementById('pushPassword');
|
||||
elements.pushSaveButton = document.getElementById('pushSaveButton');
|
||||
elements.pushCancelButton = document.getElementById('pushCancelButton');
|
||||
elements.pushUnsubscribeButton = document.getElementById('pushUnsubscribeButton');
|
||||
elements.pushResubscribeButton = document.getElementById('pushResubscribeButton');
|
||||
|
||||
// Message Monitor elements
|
||||
elements.swMessagesOutput = document.getElementById('swMessagesOutput');
|
||||
elements.simulateClickButton = document.getElementById('simulateClickButton');
|
||||
|
||||
// Set up event listeners
|
||||
elements.pushSettingsButton.addEventListener('click', openPushSettingsModal);
|
||||
elements.pushCancelButton.addEventListener('click', closePushSettingsModal);
|
||||
elements.pushSaveButton.addEventListener('click', saveCredentialsAndSubscribe);
|
||||
elements.pushUnsubscribeButton.addEventListener('click', unsubscribeFromPush);
|
||||
elements.pushResubscribeButton.addEventListener('click', resubscribeToPush);
|
||||
|
||||
// Initial status check
|
||||
updateNotificationStatus();
|
||||
}
|
||||
|
||||
// --- UI Functions ---
|
||||
|
||||
// Open the push settings modal and update statuses
|
||||
function openPushSettingsModal() {
|
||||
// Update status displays
|
||||
updateNotificationStatus();
|
||||
updateSubscriptionStatus();
|
||||
|
||||
// Load saved credentials if available
|
||||
loadSavedCredentials();
|
||||
|
||||
// Start monitoring automatically when modal opens
|
||||
startMessageMonitoring();
|
||||
|
||||
// Show the modal
|
||||
elements.pushSettingsModal.classList.add('active');
|
||||
}
|
||||
|
||||
// Close the push settings modal
|
||||
function closePushSettingsModal() {
|
||||
// Stop monitoring when the modal is closed to avoid unnecessary processing
|
||||
stopMessageMonitoring();
|
||||
elements.pushSettingsModal.classList.remove('active');
|
||||
}
|
||||
|
||||
// --- Message Monitor Functions ---
|
||||
|
||||
// Start monitoring service worker messages
|
||||
function startMessageMonitoring() {
|
||||
// If already monitoring, don't set up a new listener
|
||||
if (isMonitoring) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
elements.swMessagesOutput.textContent = 'Service Worker not supported in this browser.';
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset the output area
|
||||
elements.swMessagesOutput.textContent = 'Monitoring for service worker messages...';
|
||||
|
||||
// Create and register the message listener
|
||||
messageListener = function(event) {
|
||||
const now = new Date().toISOString();
|
||||
const formattedMessage = `[${now}] Message received: \n${JSON.stringify(event.data, null, 2)}\n\n`;
|
||||
elements.swMessagesOutput.textContent += formattedMessage;
|
||||
|
||||
// Auto-scroll to the bottom
|
||||
elements.swMessagesOutput.scrollTop = elements.swMessagesOutput.scrollHeight;
|
||||
};
|
||||
|
||||
// Add the listener
|
||||
navigator.serviceWorker.addEventListener('message', messageListener);
|
||||
isMonitoring = true;
|
||||
}
|
||||
|
||||
// Stop monitoring service worker messages
|
||||
function stopMessageMonitoring() {
|
||||
if (messageListener) {
|
||||
navigator.serviceWorker.removeEventListener('message', messageListener);
|
||||
messageListener = null;
|
||||
isMonitoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Update the notification permission status display
|
||||
function updateNotificationStatus() {
|
||||
if (!('Notification' in window)) {
|
||||
elements.notificationPermissionStatus.textContent = 'Not Supported';
|
||||
elements.notificationPermissionStatus.className = 'status-denied';
|
||||
// Disable subscribe button when notifications are not supported
|
||||
elements.pushResubscribeButton.disabled = true;
|
||||
elements.pushResubscribeButton.classList.add('disabled');
|
||||
return;
|
||||
}
|
||||
|
||||
const permission = Notification.permission;
|
||||
elements.notificationPermissionStatus.textContent = permission;
|
||||
|
||||
switch (permission) {
|
||||
case 'granted':
|
||||
elements.notificationPermissionStatus.className = 'status-granted';
|
||||
// Enable subscribe button when permission is granted
|
||||
elements.pushResubscribeButton.disabled = false;
|
||||
elements.pushResubscribeButton.classList.remove('disabled');
|
||||
break;
|
||||
case 'denied':
|
||||
elements.notificationPermissionStatus.className = 'status-denied';
|
||||
// Disable subscribe button when permission is denied
|
||||
elements.pushResubscribeButton.disabled = true;
|
||||
elements.pushResubscribeButton.classList.add('disabled');
|
||||
break;
|
||||
default:
|
||||
elements.notificationPermissionStatus.className = 'status-default';
|
||||
// Enable subscribe button for default state (prompt)
|
||||
elements.pushResubscribeButton.disabled = false;
|
||||
elements.pushResubscribeButton.classList.remove('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
// Update the subscription status display
|
||||
async function updateSubscriptionStatus() {
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
elements.subscriptionStatus.textContent = 'Not Supported';
|
||||
elements.subscriptionStatus.className = 'status-denied';
|
||||
// Disable unsubscribe button when not supported
|
||||
elements.pushUnsubscribeButton.disabled = true;
|
||||
// Set subscribe button text
|
||||
elements.pushResubscribeButton.textContent = 'Subscribe';
|
||||
// Disable simulate button when not supported
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
currentSubscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (currentSubscription) {
|
||||
elements.subscriptionStatus.textContent = 'active';
|
||||
elements.subscriptionStatus.className = 'status-active';
|
||||
// Enable unsubscribe button when subscription is active
|
||||
elements.pushUnsubscribeButton.disabled = false;
|
||||
// Change subscribe button text to "Re-subscribe"
|
||||
elements.pushResubscribeButton.textContent = 'Re-subscribe';
|
||||
// Enable simulate button when subscription is active
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = false;
|
||||
}
|
||||
} else {
|
||||
elements.subscriptionStatus.textContent = 'Not Subscribed';
|
||||
elements.subscriptionStatus.className = 'status-inactive';
|
||||
// Disable unsubscribe button when not subscribed
|
||||
elements.pushUnsubscribeButton.disabled = true;
|
||||
// Set subscribe button text
|
||||
elements.pushResubscribeButton.textContent = 'Subscribe';
|
||||
// Disable simulate button when not subscribed
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking subscription status:', error);
|
||||
elements.subscriptionStatus.textContent = 'Error';
|
||||
elements.subscriptionStatus.className = 'status-denied';
|
||||
// Disable unsubscribe button on error
|
||||
elements.pushUnsubscribeButton.disabled = true;
|
||||
// Set subscribe button text
|
||||
elements.pushResubscribeButton.textContent = 'Subscribe';
|
||||
// Disable simulate button on error
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load saved credentials from localStorage
|
||||
function loadSavedCredentials() {
|
||||
try {
|
||||
const storedAuth = localStorage.getItem('basicAuthCredentials');
|
||||
if (storedAuth) {
|
||||
const credentials = JSON.parse(storedAuth);
|
||||
if (credentials.username && credentials.password) {
|
||||
elements.pushUsername.value = credentials.username;
|
||||
elements.pushPassword.value = credentials.password;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading saved credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Action Functions ---
|
||||
|
||||
// Save credentials and close the modal
|
||||
async function saveCredentialsAndSubscribe() {
|
||||
const username = elements.pushUsername.value.trim();
|
||||
const password = elements.pushPassword.value.trim();
|
||||
|
||||
if (!username || !password) {
|
||||
alert('Please enter both username and password');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save credentials to localStorage
|
||||
const credentials = { username, password };
|
||||
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
|
||||
|
||||
// Close the modal
|
||||
closePushSettingsModal();
|
||||
}
|
||||
|
||||
// Unsubscribe from push notifications
|
||||
async function unsubscribeFromPush() {
|
||||
if (!currentSubscription) {
|
||||
alert('No active subscription to unsubscribe from');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await currentSubscription.unsubscribe();
|
||||
await updateSubscriptionStatus();
|
||||
// No success alert
|
||||
} catch (error) {
|
||||
console.error('Error unsubscribing:', error);
|
||||
alert(`Error unsubscribing: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to push notifications
|
||||
async function resubscribeToPush() {
|
||||
try {
|
||||
let username = elements.pushUsername.value.trim();
|
||||
let password = elements.pushPassword.value.trim();
|
||||
|
||||
// If fields are empty, try to use stored credentials
|
||||
if (!username || !password) {
|
||||
try {
|
||||
const storedAuth = localStorage.getItem('basicAuthCredentials');
|
||||
if (storedAuth) {
|
||||
const credentials = JSON.parse(storedAuth);
|
||||
if (credentials.username && credentials.password) {
|
||||
username = credentials.username;
|
||||
password = credentials.password;
|
||||
|
||||
// Update the form fields with stored values
|
||||
elements.pushUsername.value = username;
|
||||
elements.pushPassword.value = password;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading stored credentials:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we have credentials, show alert if missing
|
||||
if (!username || !password) {
|
||||
console.log('No credentials available. Showing alert.');
|
||||
alert('Please enter your username and password to subscribe.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Save the credentials to localStorage
|
||||
const credentials = { username, password };
|
||||
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
|
||||
console.log('Saved credentials to localStorage');
|
||||
|
||||
// Use the credentials to subscribe
|
||||
await setupPushNotifications();
|
||||
|
||||
// Wait a moment for the subscription to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// Get the updated subscription
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
currentSubscription = await registration.pushManager.getSubscription();
|
||||
|
||||
// Update the UI directly
|
||||
if (currentSubscription && elements.subscriptionStatus) {
|
||||
elements.subscriptionStatus.textContent = 'active';
|
||||
elements.subscriptionStatus.className = 'status-active';
|
||||
// Enable unsubscribe button when subscription is active
|
||||
elements.pushUnsubscribeButton.disabled = false;
|
||||
// Change subscribe button text to "Re-subscribe"
|
||||
elements.pushResubscribeButton.textContent = 'Re-subscribe';
|
||||
// Enable simulate button when subscription is active
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = false;
|
||||
}
|
||||
} else {
|
||||
// Disable unsubscribe button when not subscribed
|
||||
elements.pushUnsubscribeButton.disabled = true;
|
||||
// Set subscribe button text
|
||||
elements.pushResubscribeButton.textContent = 'Subscribe';
|
||||
// Disable simulate button when not subscribed
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = true;
|
||||
}
|
||||
// Fall back to the standard update function
|
||||
await updateSubscriptionStatus();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error subscribing:', error);
|
||||
alert(`Error subscribing: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger sendSubscriptionToServer with the current subscription
|
||||
export async function sendSubscriptionToServer() {
|
||||
if (!currentSubscription) {
|
||||
await updateSubscriptionStatus();
|
||||
if (!currentSubscription) {
|
||||
// No alert, just return silently
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Get stored credentials
|
||||
let credentials;
|
||||
try {
|
||||
const storedAuth = localStorage.getItem('basicAuthCredentials');
|
||||
if (storedAuth) {
|
||||
credentials = JSON.parse(storedAuth);
|
||||
if (!credentials.username || !credentials.password) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
} else {
|
||||
throw new Error('No stored credentials');
|
||||
}
|
||||
} catch (error) {
|
||||
// No alert, just open the modal to let the user set credentials
|
||||
openPushSettingsModal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create Basic Auth header
|
||||
const createBasicAuthHeader = (creds) => {
|
||||
return 'Basic ' + btoa(`${creds.username}:${creds.password}`);
|
||||
};
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': createBasicAuthHeader(credentials)
|
||||
};
|
||||
|
||||
try {
|
||||
// Make the request to the server
|
||||
const response = await fetch(`${getBackendUrl()}/subscribe`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
button_id: FLIC_BUTTON_ID,
|
||||
subscription: currentSubscription
|
||||
}),
|
||||
headers: headers,
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
// No success alert
|
||||
|
||||
// Update the currentSubscription variable
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
currentSubscription = await registration.pushManager.getSubscription();
|
||||
|
||||
// Directly update the subscription status element in the DOM
|
||||
if (currentSubscription && elements.subscriptionStatus) {
|
||||
elements.subscriptionStatus.textContent = 'active';
|
||||
elements.subscriptionStatus.className = 'status-active';
|
||||
// Enable unsubscribe button when subscription is active
|
||||
elements.pushUnsubscribeButton.disabled = false;
|
||||
// Change subscribe button text to "Re-subscribe"
|
||||
elements.pushResubscribeButton.textContent = 'Re-subscribe';
|
||||
// Enable simulate button when subscription is active
|
||||
if (elements.simulateClickButton) {
|
||||
elements.simulateClickButton.disabled = false;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let errorMsg = `Server error: ${response.status}`;
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
localStorage.removeItem('basicAuthCredentials');
|
||||
errorMsg = 'Authentication failed. Credentials cleared.';
|
||||
} else {
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMsg = errorData.message || errorMsg;
|
||||
} catch (e) { /* use default */ }
|
||||
}
|
||||
// No error alert, just log the error
|
||||
console.error(`Failed to send subscription: ${errorMsg}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// No error alert, just log the error
|
||||
console.error(`Network error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
289
js/ui/ui.js
Normal file
289
js/ui/ui.js
Normal file
@@ -0,0 +1,289 @@
|
||||
// ui.js
|
||||
import * as state from '../core/state.js';
|
||||
import { GAME_STATES, CSS_CLASSES, DEFAULT_PLAYER_TIME_SECONDS } 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 = '<p style="text-align: center; width: 100%;">Add players to start</p>';
|
||||
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 = `
|
||||
<div class="player-image">
|
||||
${player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>'}
|
||||
</div>
|
||||
<div class="player-name">${player.name}</div>
|
||||
<div class="player-timer ${timerClasses.join(' ')}">${timeString}</div>
|
||||
`;
|
||||
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 = DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time from config
|
||||
elements.remainingTimeContainer.style.display = 'none';
|
||||
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible for new players
|
||||
elements.imagePreview.innerHTML = '<i class="fas fa-user"></i>';
|
||||
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';
|
||||
elements.playerTimeContainer.style.display = 'none'; // Hide Time field when Remaining Time is shown
|
||||
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.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible otherwise
|
||||
}
|
||||
|
||||
elements.imagePreview.innerHTML = player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>';
|
||||
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 = `<img src="${event.target.result}" alt="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 = `<img src="${dataUrl}" alt="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 ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
|
||||
|
||||
soundButton.addEventListener('click', () => {
|
||||
const isMuted = audioManager.toggleMute();
|
||||
soundButton.innerHTML = isMuted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user