Compare commits

..

2 Commits

Author SHA1 Message Date
cpu
832f19235f refactor 2025-03-29 03:45:26 +01:00
cpu
5763407edc clean up 2025-03-28 22:36:08 +01:00
31 changed files with 957 additions and 550 deletions

58
.dockerignore Normal file
View File

@@ -0,0 +1,58 @@
# Version control
.git/
.gitignore
# Node.js
node_modules/
npm-debug.log
yarn-debug.log
yarn-error.log
Dockerfile
# Development files
.dockerignore
.editorconfig
.eslintrc
.stylelintrc
.prettierrc
.vscode/
.idea/
*.swp
*.swo
# docs/
README.md
LICENSE
CHANGELOG.md
*.md
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Test files
__tests__/
test/
tests/
coverage/
# Build artifacts
dist/
build/
# Environment files
# We need .env for our application
#.env
.env.*
# Project specific files
labels.example
virt-game-timer.service
package.json
package-lock.json

12
.env.example Normal file
View File

@@ -0,0 +1,12 @@
# Environment Variables Example for Game Timer Application
# Copy this file to .env and fill in your own values
# Public VAPID key for push notifications
# Generate your own VAPID keys for production:
# https://github.com/web-push-libs/web-push#generatevapidkeys
PUBLIC_VAPID_KEY=your_public_vapid_key_here
# Backend URL for your push notification server
BACKEND_URL=https://your-push-server.example.com
# Other environment variables can be added here

39
.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Dependency directories
node_modules/
jspm_packages/
# Build outputs
dist/
build/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# Editor directories and files
.idea/
.vscode/
*.swp
*.swo
# OS specific files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Docker
.docker/

View File

@@ -1,11 +1,14 @@
# Use the official Nginx image as the base image
# Use a lightweight server
FROM nginx:alpine
# Remove the default Nginx static files
RUN rm -rf /usr/share/nginx/html/*
# Set working directory
WORKDIR /usr/share/nginx/html
# Copy all your app's files into the Nginx directory
COPY . /usr/share/nginx/html
# Copy all the application files
COPY . .
# Copy the .env file
COPY .env .
# Expose port 80
EXPOSE 80

110
README.md
View File

@@ -1,6 +1,61 @@
# Game Timer
Multi-player chess timer with carousel navigation
Multi-player game-timer timer with carousel navigation
## Project Structure
```
game-timer/
├── css/ # CSS stylesheets
├── icons/ # App icons
├── images/ # Image assets
├── js/ # Symbolic link to src/js for compatibility
├── index.html # Main HTML entry point
├── manifest.json # PWA manifest
├── sw.js # Service Worker
├── src/ # Source code
│ └── js/ # JavaScript files
│ ├── core/ # Core application logic
│ ├── ui/ # UI-related code
│ └── services/ # External services integration
├── Dockerfile # Docker container definition (nginx)
├── .dockerignore # Files to exclude from Docker build
├── .env # Environment variables for production
├── .env.example # Example environment variables template
└── package.json # Project metadata and deployment scripts
```
## Environment Variables
The application uses environment variables for configuration. These are loaded from a `.env` file at runtime.
### Setting Up Environment Variables
1. Copy `.env.example` to `.env`:
```bash
cp .env.example .env
```
2. Edit the `.env` file with your own values:
```
# Public VAPID key for push notifications
PUBLIC_VAPID_KEY=your_public_vapid_key_here
# Backend URL for push notifications
BACKEND_URL=https://your-push-server.example.com
```
3. For security, never commit your `.env` file to version control. It's already included in `.gitignore`.
### Generating VAPID Keys
For push notifications, you need to generate your own VAPID keys:
```bash
npx web-push generate-vapid-keys
```
Use the public key in your `.env` file and keep the private key secure for your backend server.
# PWA Containerized Deployment
@@ -23,7 +78,7 @@ git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git
cd game-timer
```
### 2. Build the docker image
### 2. Build the Docker image
From the repository root, run the following command to build your Docker image:
@@ -31,12 +86,24 @@ From the repository root, run the following command to build your Docker image:
docker build -t 'game-timer:latest' .
```
### 3. Run the Docker Container
Once the image is built, run the container on port 8080 with:
or use the npm script:
```bash
docker run -d -p 8080:80 --name game-timer-container game-timer:latest
npm run docker:build
```
### 3. Run the Docker Container
Once the image is built, run the container on port 80 with:
```bash
docker run -d -p 80:80 --name game-timer game-timer:latest
```
or use the npm script:
```bash
npm run start
```
### 4. Verify the Deployment
@@ -50,19 +117,42 @@ docker ps
View logs (if needed):
```bash
docker logs game-timer-container
docker logs game-timer
```
After running the container, open your web browser and navigate to:
```bash
```
http://localhost
```
### 5. Terminate
To stop your running game-timer-container, use:
To stop your running game-timer container, use:
```bash
docker stop game-timer-container
docker stop game-timer
docker rm game-timer
```
or use the npm script:
```bash
npm run stop
```
## Development
For local development without Docker, you can use any static file server such as:
```bash
python -m http.server
```
or
```bash
npx serve
```
This will start a local development server and you can access the application in your browser.

446
app.js
View File

@@ -1,446 +0,0 @@
// app.js - Main Application Orchestrator
import * as config from './config.js';
import * as state from './state.js';
import * as ui from './ui.js';
import * as timer from './timer.js';
import camera from './camera.js'; // Default export
import audioManager from './audio.js';
import * as pushFlic from './pushFlicIntegration.js';
// --- Core Game Actions ---
function startGame() {
if (state.getPlayers().length < 2) {
alert('You need at least 2 players to start.');
return;
}
if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) {
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameStart');
timer.startTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
function pauseGame() {
if (state.getGameState() === config.GAME_STATES.RUNNING) {
state.setGameState(config.GAME_STATES.PAUSED);
audioManager.play('gamePause');
timer.stopTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is removed
}
}
function resumeGame() {
if (state.getGameState() === config.GAME_STATES.PAUSED) {
// Check if there's actually a player with time left
if (state.findNextPlayerWithTime() === -1) {
console.log("Cannot resume, no players have time left.");
// Optionally set state to OVER here
handleGameOver();
return;
}
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameResume');
timer.startTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
function togglePauseResume() {
const currentGameState = state.getGameState();
if (currentGameState === config.GAME_STATES.RUNNING) {
pauseGame();
} else if (currentGameState === config.GAME_STATES.PAUSED) {
resumeGame();
} else if (currentGameState === config.GAME_STATES.SETUP) {
startGame();
} else if (currentGameState === config.GAME_STATES.OVER) {
resetGame(); // Or just go back to setup? Let's reset.
startGame();
}
}
function switchToPlayer(index) {
if (index >= 0 && index < state.getPlayers().length) {
const previousIndex = state.getCurrentPlayerIndex();
if(index !== previousIndex) {
state.setCurrentPlayerIndex(index);
audioManager.play('playerSwitch');
ui.renderPlayers(); // Update UI immediately
// If the game is running, restart the timer for the new player
// The timer interval callback will handle the decrementing
if (state.getGameState() === config.GAME_STATES.RUNNING) {
timer.startTimer(); // This clears the old interval and starts anew
}
}
}
}
function nextPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if(playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("NextPlayer: No other player has time remaining.");
// Optionally handle game over immediately? Timer logic should catch this too.
}
}
function previousPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if (playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("PreviousPlayer: No other player has time remaining.");
}
}
function handleGameOver() {
state.setGameState(config.GAME_STATES.OVER);
audioManager.play('gameOver');
timer.stopTimer(); // Ensure timer is stopped
ui.updateGameButton();
ui.renderPlayers(); // Update to show final state
}
function resetGame() {
timer.stopTimer(); // Stop timer if running/paused
state.resetPlayersTime();
state.setGameState(config.GAME_STATES.SETUP);
state.setCurrentPlayerIndex(0); // Go back to first player
audioManager.play('buttonClick'); // Or a specific reset sound?
ui.updateGameButton();
ui.renderPlayers();
}
function fullResetApp() {
timer.stopTimer();
state.resetToDefaults();
audioManager.play('gameOver'); // Use game over sound for full reset
ui.hideResetModal();
ui.updateGameButton();
ui.renderPlayers();
}
// --- Event Handlers ---
function handleGameButtonClick() {
audioManager.play('buttonClick');
togglePauseResume();
}
function handleSetupButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before editing players.');
return;
}
const currentPlayer = state.getCurrentPlayer();
if (!currentPlayer) {
console.warn("Edit clicked but no current player?");
return; // Or show Add Player modal?
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(false, currentPlayer);
}
function handleAddPlayerButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before adding players.');
return;
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(true);
}
function handleResetButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before resetting.');
return;
}
ui.showResetModal();
}
function handlePlayerFormSubmit(event) {
event.preventDefault();
audioManager.play('buttonClick');
const name = ui.elements.playerNameInput.value.trim();
const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10);
let remainingTimeSeconds = 0; // Default
const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player';
const currentGameState = state.getGameState();
if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) {
alert('Please enter a valid name and positive time.');
return;
}
// Get remaining time ONLY if editing and game is paused/over
if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) {
const remainingTimeString = ui.elements.playerRemainingTimeInput.value;
const parsedSeconds = ui.parseTimeString(remainingTimeString);
if (parsedSeconds === null) { // Check if parsing failed
alert('Please enter remaining time in MM:SS format (e.g., 05:30).');
return;
}
remainingTimeSeconds = parsedSeconds;
// Validate remaining time against total time? Optional.
if (remainingTimeSeconds > timeInMinutes * 60) {
alert('Remaining time cannot be greater than the total time.');
return;
}
} else {
// For new players or when editing in setup, remaining time matches total time
remainingTimeSeconds = timeInMinutes * 60;
}
let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null;
const imageFile = ui.elements.playerImageInput.files[0];
const saveAction = (finalImageData) => {
if (isNewPlayer) {
state.addPlayer(name, timeInMinutes, finalImageData);
audioManager.play('playerAdded');
} else {
const playerIndex = state.getCurrentPlayerIndex();
// Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one
const imageArg = finalImageData !== null ? finalImageData : (imageFile || ui.elements.playerImageInput.dataset.capturedImage ? finalImageData : undefined);
state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg);
audioManager.play('playerEdited');
}
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count changed for setup state
camera.stopStream(); // Ensure camera is stopped
};
if (!imageDataUrl && imageFile) {
// Handle file upload: Read file as Data URL
const reader = new FileReader();
reader.onload = (e) => saveAction(e.target.result);
reader.onerror = (e) => {
console.error("Error reading image file:", e);
alert("Error processing image file.");
};
reader.readAsDataURL(imageFile);
} else {
// Handle captured image or no image change
const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image;
// If imageDataUrl has content (from camera), use it.
// If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later).
// If it's a new player and no image, pass null.
saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage));
}
}
function handlePlayerModalCancel() {
audioManager.play('buttonClick');
ui.hidePlayerModal();
camera.stopStream(); // Make sure camera turns off
}
function handleDeletePlayer() {
audioManager.play('buttonClick');
const success = state.deletePlayer(state.getCurrentPlayerIndex());
if (success) {
audioManager.play('playerDeleted');
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count dropped below 2
} else {
alert('Cannot delete player. Minimum of 2 players required.');
}
camera.stopStream();
}
function handleResetConfirm() {
audioManager.play('buttonClick');
fullResetApp();
}
function handleResetCancel() {
audioManager.play('buttonClick');
ui.hideResetModal();
}
function handleCameraButtonClick(event) {
event.preventDefault(); // Prevent form submission if inside form
audioManager.play('buttonClick');
camera.open(); // Open the camera interface
}
// --- Timer Callbacks ---
function handleTimerTick() {
// Timer module already updated the state, just need to redraw UI
ui.renderPlayers();
}
function handlePlayerSwitchOnTimer(newPlayerIndex) {
// Timer detected current player ran out, found next player
console.log(`Timer switching to player index: ${newPlayerIndex}`);
switchToPlayer(newPlayerIndex);
// Sound is handled in switchToPlayer
}
// --- Camera Callback ---
function handleCameraCapture(imageDataUrl) {
console.log("Image captured");
ui.updateImagePreviewFromDataUrl(imageDataUrl);
// Camera module already closed the camera UI
}
// --- Service Worker and PWA Setup ---
function setupServiceWorker() {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registered successfully.');
// Listen for messages FROM the Service Worker (e.g., Flic actions)
navigator.serviceWorker.addEventListener('message', handleServiceWorkerMessage);
// Initialize Flic integration (which will try to subscribe)
initFlic();
})
.catch(error => {
console.error('ServiceWorker registration failed:', error);
});
});
// Listen for SW controller changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker controller changed, potentially updated.');
// window.location.reload(); // Consider prompting user to reload
});
} else {
console.warn('ServiceWorker not supported.');
}
}
function handleServiceWorkerMessage(event) {
console.log('[App] Message received from Service Worker:', event.data);
if (event.data?.type === 'flic-action') {
const { action, button, timestamp, batteryLevel } = event.data;
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
}
}
// --- Flic Integration Setup ---
function initFlic() {
// Define what happens for each Flic button action
const flicActionHandlers = {
[config.FLIC_ACTIONS.SINGLE_CLICK]: nextPlayer,
[config.FLIC_ACTIONS.DOUBLE_CLICK]: previousPlayer, // Map double-click to previous player
[config.FLIC_ACTIONS.HOLD]: togglePauseResume,
};
pushFlic.initPushFlic(flicActionHandlers);
}
// --- Initialization ---
function initialize() {
console.log("Initializing Game Timer App...");
// 1. Load saved state or defaults
state.loadData();
// 2. Initialize UI (pass carousel swipe handler)
ui.initUI({
onCarouselSwipe: (direction) => {
if (direction > 0) nextPlayer(); else previousPlayer();
}
});
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
timer.initTimer({
onTimerTick: handleTimerTick,
onPlayerSwitch: handlePlayerSwitchOnTimer,
onGameOver: handleGameOver
});
// 4. Initialize Camera (pass elements and capture callback)
camera.init(
{ // Pass relevant DOM elements
cameraContainer: ui.elements.cameraContainer,
cameraView: ui.elements.cameraView,
cameraCanvas: ui.elements.cameraCanvas,
cameraCaptureButton: ui.elements.cameraCaptureButton,
cameraCancelButton: ui.elements.cameraCancelButton
},
{ // Pass options/callbacks
onCapture: handleCameraCapture
}
);
// 5. Set up UI Event Listeners that trigger actions
ui.elements.gameButton.addEventListener('click', handleGameButtonClick);
ui.elements.setupButton.addEventListener('click', handleSetupButtonClick);
ui.elements.addPlayerButton.addEventListener('click', handleAddPlayerButtonClick);
ui.elements.resetButton.addEventListener('click', handleResetButtonClick);
ui.elements.playerForm.addEventListener('submit', handlePlayerFormSubmit);
ui.elements.cancelButton.addEventListener('click', handlePlayerModalCancel);
ui.elements.deletePlayerButton.addEventListener('click', handleDeletePlayer);
ui.elements.resetConfirmButton.addEventListener('click', handleResetConfirm);
ui.elements.resetCancelButton.addEventListener('click', handleResetCancel);
ui.elements.cameraButton.addEventListener('click', handleCameraButtonClick);
// 6. Setup Service Worker (which also initializes Flic)
setupServiceWorker();
// 7. Initial UI Update based on loaded state
ui.renderPlayers();
ui.updateGameButton();
// 8. Resume timer if state loaded as 'running' (e.g., after refresh)
// Note: This might be undesirable. Usually, a refresh should pause.
// Consider forcing state to 'paused' or 'setup' on load?
// For now, let's reset running state to paused on load.
if (state.getGameState() === config.GAME_STATES.RUNNING) {
console.log("Game was running on load, setting to paused.");
state.setGameState(config.GAME_STATES.PAUSED);
ui.updateGameButton();
ui.renderPlayers();
}
console.log("App Initialized.");
}
// --- Start the application ---
initialize();

BIN
images/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

BIN
images/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

View File

@@ -5,9 +5,20 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#2c3e50">
<title>Game Timer</title>
<link rel="manifest" href="manifest.json">
<!-- Favicon links -->
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<!-- Web app manifest -->
<link rel="manifest" href="/manifest.json">
<!-- App icons for various platforms -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- CSS stylesheets -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="styles.css">
<link rel="stylesheet" href="/css/styles.css">
</head>
<body>
@@ -102,7 +113,7 @@
</div>
<!-- Main application script -->
<script type="module" src="app.js"></script>
<script type="module" src="/js/app.js"></script>
<script>
if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js")

1
js Symbolic link
View File

@@ -0,0 +1 @@
src/js

View File

@@ -3,7 +3,9 @@
"short_name": "Game Timer",
"description": "Multi-player chess-like timer with carousel navigation",
"start_url": "/index.html",
"id": "/index.html",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"background_color": "#f5f5f5",
"theme_color": "#2c3e50",
"icons": [
@@ -35,21 +37,15 @@
],
"screenshots": [
{
"src": "/screenshots/screenshot1.png",
"sizes": "2604x2269",
"src": "/images/screenshot1.png",
"sizes": "2560x1860",
"type": "image/png",
"form_factor": "wide"
},
{
"src": "/screenshots/screenshot2.png",
"sizes": "1082x2402",
"src": "/images/screenshot2.png",
"sizes": "750x1594",
"type": "image/png"
}
],
"url_handlers": [
{
"origin": "https://game-timer.virtonline.eu"
}
],
"gcm_sender_id": "103953800507"
]
}

View File

@@ -1,17 +1,13 @@
{
"name": "countdown",
"name": "game-timer",
"version": "1.0.0",
"description": "Multi-player chess timer with carousel navigation",
"main": "app.js",
"main": "src/js/app.js",
"scripts": {
"start": "docker run -d -p 8080:80 --name game-timer-container game-timer:latest",
"stop": "docker stop game-timer-container && docker rm game-timer-container",
"build": "docker build -t 'game-timer:latest' .",
"rebuild": "npm run stop || true && npm run build && npm run start",
"logs": "docker logs game-timer-container",
"status": "docker ps | grep game-timer-container",
"dev": "cd /usr/share/nginx/html && python -m http.server 8000",
"clean": "docker system prune -f"
"docker:build": "docker build -t 'game-timer:latest' .",
"start": "docker run -d -p 80:80 --name game-timer game-timer:latest",
"stop": "docker stop game-timer && docker rm game-timer",
"rebuild": "npm run stop || true && npm run docker:build && npm run start"
},
"keywords": [
"timer",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

View File

@@ -1,19 +0,0 @@
{
"name": "Game Timer",
"short_name": "Game Timer",
"icons": [
{
"src": "/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"theme_color": "#ffffff",
"background_color": "#ffffff",
"display": "standalone"
}

107
src/js/app.js Normal file
View File

@@ -0,0 +1,107 @@
// 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 audioManager from './ui/audio.js';
import * as pushFlic from './services/pushFlicIntegration.js';
import { initEnv } from './env-loader.js';
// 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';
// --- Initialization ---
async function initialize() {
console.log("Initializing Game Timer App...");
// 0. Wait for environment variables to load
try {
await initEnv();
console.log("Environment variables loaded");
} catch (error) {
console.warn("Failed to load environment variables, using defaults:", error);
}
// 1. Load saved state or defaults
state.loadData();
// 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. Setup Flic action handlers
const flicActionHandlers = {
[config.FLIC_ACTIONS.SINGLE_CLICK]: playerManager.nextPlayer,
[config.FLIC_ACTIONS.DOUBLE_CLICK]: playerManager.previousPlayer,
[config.FLIC_ACTIONS.HOLD]: gameActions.togglePauseResume,
};
serviceWorkerManager.setFlicActionHandlers(flicActionHandlers);
// 7. Setup Service Worker (which also initializes Flic)
serviceWorkerManager.setupServiceWorker(serviceWorkerManager.handleServiceWorkerMessage);
// 8. Initial UI Update based on loaded state
ui.renderPlayers();
ui.updateGameButton();
// 9. 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);
}
})();

View File

@@ -1,6 +1,17 @@
// config.js
export const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
export const BACKEND_URL = 'https://webpush.virtonline.eu';
import { getEnv } from './env-loader.js';
export function getPublicVapidKey() {
// Get the VAPID key from environment variables through the env-loader
return getEnv('PUBLIC_VAPID_KEY', 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E');
}
// The VAPID key should not be exposed directly in the source code
// Use the getter function instead: getPublicVapidKey()
// export const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
// Get backend URL from environment variables
export const BACKEND_URL = getEnv('BACKEND_URL', 'https://webpush.virtonline.eu');
export const FLIC_BUTTON_ID = 'game-button'; // Example ID, might need configuration
export const LOCAL_STORAGE_KEY = 'gameTimerData';
export const FLIC_BATTERY_THRESHOLD = 50; // Battery percentage threshold for low battery warning

View 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
}

View File

@@ -0,0 +1,91 @@
// 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';
// --- 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
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();
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();
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
audioManager.play('buttonClick'); // Or a specific reset sound?
ui.updateGameButton();
ui.renderPlayers();
}
export function fullResetApp() {
timer.stopTimer();
state.resetToDefaults();
audioManager.play('gameOver'); // Use game over sound for full reset
ui.hideResetModal();
ui.updateGameButton();
ui.renderPlayers();
}

View 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();
}

View File

@@ -1,5 +1,5 @@
// state.js
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from './config.js';
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
let players = [];
let currentPlayerIndex = 0;

View File

@@ -1,24 +1,29 @@
// timer.js
import * as state from './state.js';
import { GAME_STATES } from './config.js';
import audioManager from './audio.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(); // Consider if this is too aggressive
audioManager.stopAllSounds();
// Reset the expired sound flags when starting a new timer
timeExpiredFlagsById.clear();
timerInterval = setInterval(() => {
const currentPlayerIndex = state.getCurrentPlayerIndex();
@@ -35,32 +40,24 @@ export function startTimer() {
const newTime = currentPlayer.remainingTime - 1;
state.updatePlayerTime(currentPlayerIndex, newTime); // Update state
// Play timer sounds
// 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 (unlikely with check above)
// Ensure time is exactly 0 if it somehow went negative
if(currentPlayer.remainingTime < 0) {
state.updatePlayerTime(currentPlayerIndex, 0);
}
// Stop this player's timer tick sound if it was playing
// audioManager.stop('timerTick'); // Or specific low time sound
// Play time expired sound (only once)
// Check if we just hit zero to avoid playing repeatedly
// This logic might be complex, audioManager could handle idempotency
if (currentPlayer.remainingTime === 0 && !currentPlayer.timeExpiredSoundPlayed) {
// Play time expired sound (only once per player per game)
if (!timeExpiredFlagsById.has(currentPlayer.id)) {
audioManager.playTimerExpired();
// We need a way to mark that the sound played for this player this turn.
// This might require adding a temporary flag to the player state,
// or handling it within the audioManager. Let's assume audioManager handles it for now.
timeExpiredFlagsById.set(currentPlayer.id, true);
}
// Check if the game should end or switch player
if (state.areAllTimersFinished()) {
stopTimer();
@@ -69,10 +66,11 @@ export function startTimer() {
// 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
// Switch player and ensure we stop any sounds from current player
audioManager.stopTimerSounds(); // Stop specific timer sounds before switching
if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
// Play switch sound (might be handled in app.js based on state change)
// audioManager.play('playerSwitch'); // Or let app.js handle sounds based on actions
// Immediately update UI after switch
if (onTimerTickCallback) onTimerTickCallback();
} else if (nextPlayerIndex === -1) {
@@ -81,7 +79,7 @@ export function startTimer() {
stopTimer(); // Stop timer if state is inconsistent
if (onGameOverCallback) onGameOverCallback(); // Treat as game over
}
// If nextPlayerIndex is the same as currentPlayerIndex, means others are out of time, let this timer continue (or rather, stop ticking down as remainingTime is 0)
// If nextPlayerIndex is the same as currentPlayerIndex, means others are out of time, let this timer continue
}
}
}, 1000);
@@ -90,10 +88,17 @@ export function startTimer() {
export function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
// Optionally stop timer sounds here if needed
// audioManager.stop('timerTick');
// 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();
}

88
src/js/env-loader.js Normal file
View File

@@ -0,0 +1,88 @@
// env-loader.js
// This module is responsible for loading environment variables from .env file
// Store environment variables in a global object
window.ENV_CONFIG = {};
// Function to load environment variables from .env file
async function loadEnvVariables() {
try {
// Fetch the .env file as text
const response = await fetch('/.env');
if (!response.ok) {
console.warn('Could not load .env file. Using default values.');
setDefaultEnvValues();
return;
}
const envText = await response.text();
// Parse the .env file content
const envVars = parseEnvFile(envText);
// Store in the global ENV_CONFIG object
window.ENV_CONFIG = { ...window.ENV_CONFIG, ...envVars };
console.log('Environment variables loaded successfully');
} catch (error) {
console.error('Error loading environment variables:', error);
setDefaultEnvValues();
}
}
// Parse .env file content into key-value pairs
function parseEnvFile(envText) {
const envVars = {};
// Split by lines and process each line
envText.split('\n').forEach(line => {
// Skip empty lines and comments
if (!line || line.trim().startsWith('#')) return;
// Extract key-value pairs
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
const key = match[1];
let value = match[2] || '';
// Remove quotes if present
if (value.startsWith('"') && value.endsWith('"')) {
value = value.slice(1, -1);
}
envVars[key] = value;
}
});
return envVars;
}
// Set default values for required environment variables
function setDefaultEnvValues() {
window.ENV_CONFIG = {
...window.ENV_CONFIG,
PUBLIC_VAPID_KEY: 'your_public_vapid_key_here',
BACKEND_URL: 'https://your-push-server.example.com'
};
console.log('Using default environment values');
}
// Export function to initialize environment variables
export async function initEnv() {
await loadEnvVariables();
return window.ENV_CONFIG;
}
// Auto-initialize when imported
initEnv();
// Export access functions for environment variables
export function getEnv(key, defaultValue = '') {
return window.ENV_CONFIG[key] || defaultValue;
}
export default {
initEnv,
getEnv
};

View File

@@ -1,5 +1,5 @@
// pushFlicIntegration.js
import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from './config.js';
import { getPublicVapidKey, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from '../config.js';
let pushSubscription = null; // Keep track locally if needed
let actionHandlers = {}; // Store handlers for different Flic actions
@@ -88,7 +88,7 @@ function showBatteryWarning(batteryLevel) {
if (Notification.permission === 'granted') {
new Notification('Flic Button Low Battery', {
body: message,
icon: '/favicon.ico'
icon: '/public/favicon.ico'
});
}
}
@@ -139,7 +139,7 @@ async function subscribeToPush() {
if (existingSubscription) {
const existingKey = existingSubscription.options?.applicationServerKey;
if (!existingKey || arrayBufferToBase64(existingKey) !== PUBLIC_VAPID_KEY) {
if (!existingKey || arrayBufferToBase64(existingKey) !== getPublicVapidKey()) {
console.log('VAPID key mismatch or missing. Unsubscribing old subscription.');
await existingSubscription.unsubscribe();
existingSubscription = null;
@@ -153,7 +153,7 @@ async function subscribeToPush() {
let finalSubscription = existingSubscription;
if (needsResubscribe) {
console.log('Subscribing for push notifications...');
const applicationServerKey = urlBase64ToUint8Array(PUBLIC_VAPID_KEY);
const applicationServerKey = urlBase64ToUint8Array(getPublicVapidKey());
finalSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey

View File

@@ -0,0 +1,58 @@
// serviceWorkerManager.js - Service worker registration and Flic integration
import * as config from '../config.js';
import * as pushFlic from './pushFlicIntegration.js';
// Store the action handlers passed from app.js
let flicActionHandlers = {};
export function setFlicActionHandlers(handlers) {
flicActionHandlers = handlers;
}
// --- Flic Integration Setup ---
export function initFlic() {
// This function is used by setupServiceWorker and relies on
// flicActionHandlers being set before this is called
pushFlic.initPushFlic(flicActionHandlers);
}
export function handleServiceWorkerMessage(event) {
console.log('[App] Message received from Service Worker:', event.data);
if (event.data?.type === 'flic-action') {
const { action, button, timestamp, batteryLevel } = event.data;
if (flicActionHandlers[action]) {
flicActionHandlers[action]();
} else {
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
}
}
}
// --- Service Worker and PWA Setup ---
export function setupServiceWorker(messageHandler) {
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('ServiceWorker registered successfully.');
// Listen for messages FROM the Service Worker (e.g., Flic actions)
navigator.serviceWorker.addEventListener('message', messageHandler);
// Initialize Flic integration (which will try to subscribe)
initFlic();
})
.catch(error => {
console.error('ServiceWorker registration failed:', error);
});
});
// Listen for SW controller changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('Service Worker controller changed, potentially updated.');
// window.location.reload(); // Consider prompting user to reload
});
} else {
console.warn('ServiceWorker not supported.');
}
}

View File

@@ -302,6 +302,14 @@ const audioManager = {
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;

View File

@@ -1,5 +1,5 @@
// camera.js
import { CSS_CLASSES } from './config.js';
import { CSS_CLASSES } from '../config.js';
let stream = null;
let elements = {}; // To store references to DOM elements passed during init

View File

@@ -1,6 +1,6 @@
// ui.js
import * as state from './state.js';
import { GAME_STATES, CSS_CLASSES } from './config.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 ---
@@ -118,8 +118,9 @@ export function showPlayerModal(isNewPlayer, player = null) {
if (isNewPlayer) {
elements.modalTitle.textContent = 'Add New Player';
elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
elements.playerTimeInput.value = state.DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time
elements.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) {
@@ -129,11 +130,13 @@ export function showPlayerModal(isNewPlayer, player = null) {
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>';

29
sw.js
View File

@@ -1,20 +1,33 @@
// Service Worker version
const CACHE_VERSION = 'v1.0.0';
const CACHE_VERSION = 'v1.0.2';
const CACHE_NAME = `game-timer-${CACHE_VERSION}`;
// Files to cache
const CACHE_FILES = [
'/',
'/sw.js',
'/index.html',
'/app.js',
'/audio.js',
'/styles.css',
'/manifest.json',
'/css/styles.css',
'/favicon.ico',
'/icons/android-chrome-192x192.png',
'/icons/android-chrome-512x512.png',
'/icons/apple-touch-icon.png',
'/icons/favicon-32x32.png',
'/icons/favicon-16x16.png'
'/icons/favicon-16x16.png',
'/js/app.js',
'/js/config.js',
'/js/env-loader.js',
'/js/ui/audio.js',
'/js/ui/camera.js',
'/js/ui/ui.js',
'/js/core/state.js',
'/js/core/timer.js',
'/js/core/gameActions.js',
'/js/core/playerManager.js',
'/js/core/eventHandlers.js',
'/js/services/pushFlicIntegration.js',
'/js/services/serviceWorkerManager.js'
];
// Install event - Cache files
@@ -124,7 +137,7 @@ self.addEventListener('push', event => {
// If no window is open, we MUST show a notification
return self.registration.showNotification(pushData.title, {
body: pushData.body,
icon: '/icons/icon-192x192.png', // Optional: path to an icon
icon: '/icons/android-chrome-192x192.png', // Updated path
data: pushData.data // Pass data if needed when notification is clicked
});
}
@@ -144,7 +157,7 @@ self.addEventListener('push', event => {
if (!messageSent) { // Only show notification if no message was sent? Or always show?
return self.registration.showNotification(pushData.title, {
body: pushData.body,
icon: '/icons/icon-192x192.png',
icon: '/icons/android-chrome-192x192.png',
data: pushData.data
});
}
@@ -159,7 +172,7 @@ self.addEventListener('push', event => {
/*
const notificationOptions = {
body: pushData.body,
icon: '/icons/icon-192x192.png', // Optional: path to an icon
icon: './icons/android-chrome-192x192.png', // Optional: path to an icon
data: pushData.data // Attach data if needed when notification is clicked
};
event.waitUntil(

36
test.html Normal file
View File

@@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Service Worker Test</title>
</head>
<body>
<h1>Service Worker Test</h1>
<p>This page tests if the service worker is registered correctly.</p>
<div id="status">Checking service worker registration...</div>
<script>
const statusDiv = document.getElementById('status');
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js')
.then(registration => {
statusDiv.innerHTML = 'Service worker registered successfully!<br>' +
'Scope: ' + registration.scope;
console.log('ServiceWorker registration successful with scope: ', registration.scope);
})
.catch(error => {
statusDiv.innerHTML = 'Service worker registration failed: ' + error;
console.error('ServiceWorker registration failed: ', error);
});
navigator.serviceWorker.ready.then(registration => {
statusDiv.innerHTML += '<br><br>Service worker is ready!';
});
} else {
statusDiv.innerHTML = 'Service workers are not supported in this browser.';
}
</script>
</body>
</html>