diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4c689d5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,61 @@ +# 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.* +# Don't ignore config.env.js +!config.env.js + +# Project specific files +dev-start.sh +generate-config.sh +labels.example +virt-game-timer.service +package.json +package-lock.json \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f5ea3f5 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..291e807 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# 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 +config.env.js + +# Editor directories and files +.idea/ +.vscode/ +*.swp +*.swo + +# OS specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Docker +.docker/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..98996ed --- /dev/null +++ b/Dockerfile @@ -0,0 +1,35 @@ +# Use a lightweight server +FROM nginx:alpine + +# Install bash for the script execution +RUN apk add --no-cache bash + +# Set working directory +WORKDIR /usr/share/nginx/html + +# Copy all the application files +COPY . . + +# Create a simple script to generate config.env.js +RUN echo '#!/bin/sh' > /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "// config.env.js - Generated from .env" > config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "// This file contains environment variables for the PWA" >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "// Generated on $(date)" >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "" >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "window.ENV_CONFIG = {" >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'grep -v "^#" .env | grep "=" | while read line; do' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo ' key=$(echo $line | cut -d= -f1)' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo ' value=$(echo $line | cut -d= -f2-)' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo ' echo " $key: \"$value\"," >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'done' >> /usr/share/nginx/html/docker-generate-config.sh && \ + echo 'echo "};" >> config.env.js' >> /usr/share/nginx/html/docker-generate-config.sh && \ + chmod +x /usr/share/nginx/html/docker-generate-config.sh + +# Generate config.env.js from .env +RUN /usr/share/nginx/html/docker-generate-config.sh + +# Remove the .env file and the generation script for security +RUN rm .env docker-generate-config.sh + +# Expose port 80 +EXPOSE 80 diff --git a/README.md b/README.md index af43194..0d6fa21 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,163 @@ -# game-timer +# Game Timer -Multi-player chess timer with carousel navigation \ No newline at end of file +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 and converted to a `config.env.js` file that is served by the web server. + +### 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. Generate the `config.env.js` file using the provided script: + ```bash + ./generate-config.sh + ``` + +4. 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 + +This document provides step-by-step instructions to pull the source code and deploy the Progressive Web App (PWA) using Docker on a production server. + +## Prerequisites + +- **Git:** Installed on your production server. +- **Docker:** Installed and running on your production server. +- **Basic Knowledge:** Familiarity with the command line. + +## Steps + +### 1. Clone the Repository + +Log in to your production server and navigate to the directory where you want to store the project. Then run: + +```bash +git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git +cd game-timer +``` + +### 2. Build the Docker image + +From the repository root, run the following command to build your Docker image: + +```bash +docker build -t 'game-timer:latest' . +``` + +or use the npm script: + +```bash +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 + +Check if it's running: + +```bash +docker ps +``` + +View logs (if needed): + +```bash +docker logs game-timer +``` + +After running the container, open your web browser and navigate to: + +``` +http://localhost +``` + +### 5. Terminate + +To stop your running game-timer container, use: + +```bash +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. \ No newline at end of file diff --git a/css/styles.css b/css/styles.css new file mode 100644 index 0000000..d0772d7 --- /dev/null +++ b/css/styles.css @@ -0,0 +1,492 @@ +* { + margin: 0; + padding: 0; + box-sizing: border-box; + font-family: Arial, sans-serif; +} + +body { + background-color: #f5f5f5; + color: #333; + overflow-x: hidden; +} + +.app-container { + max-width: 100%; + margin: 0 auto; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.header { + background-color: #2c3e50; + color: white; + padding: 1rem; + display: flex; + justify-content: space-between; + align-items: center; + position: fixed; + top: 0; + width: 100%; + z-index: 10; +} + +.game-controls { + text-align: center; + flex: 1; +} + +.game-button { + background-color: #3498db; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + font-size: 1rem; + cursor: pointer; +} + +.header-buttons { + display: flex; + gap: 0.5rem; +} + +.header-button { + background-color: transparent; + color: white; + border: none; + font-size: 1.2rem; + cursor: pointer; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; +} + +.header-button:hover { + background-color: rgba(255, 255, 255, 0.1); +} + +.carousel-container { + margin-top: 70px; + margin-bottom: 60px; /* Add some space for the footer */ + width: 100%; + overflow: hidden; + flex: 1; + touch-action: pan-x; +} + +.carousel { + display: flex; + transition: transform 0.3s ease; + height: calc(100vh - 70px); +} + +/* Adjust the preview image in the modal to maintain consistency */ +#imagePreview.player-image { + width: 180px; /* Slightly smaller than the main display but still larger than original 120px */ + height: 180px; + margin: 0.5rem auto; +} + +.player-card { + min-width: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; /* Increased from 1rem for more spacing */ + transition: all 0.3s ease; +} + +.active-player { + opacity: 1; +} + +.inactive-player { + opacity: 0.6; +} + +/* New styles for timer active state */ +.player-timer { + font-size: 4rem; + font-weight: bold; + margin: 1rem 0; + padding: 0.5rem 1.5rem; + border-radius: 12px; + position: relative; +} + +/* Timer background effect when game is running */ +.timer-active { + background-color: #ffecee; /* Light red base color */ + box-shadow: 0 0 15px rgba(231, 76, 60, 0.5); + animation: pulsate 1.5s ease-out infinite; +} + +/* Timer of a player that has run out of time */ +.timer-finished { + color: #e74c3c; + text-decoration: line-through; + opacity: 0.7; +} + +/* Pulsating animation */ +@keyframes pulsate { + 0% { + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); + background-color: #ffecee; + } + 50% { + box-shadow: 0 0 20px 0 rgba(231, 76, 60, 0.5); + background-color: #ffe0e0; + } + 100% { + box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7); + background-color: #ffecee; + } +} + +.player-image { + width: 240px; /* Doubled from 120px */ + height: 240px; /* Doubled from 120px */ + border-radius: 50%; + background-color: #ddd; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 2rem; /* Increased from 1rem */ + overflow: hidden; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* Added shadow for better visual presence */ +} + +.player-image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.player-image i { + font-size: 6rem; /* Doubled from 3rem */ + color: #888; +} + +.player-name { + font-size: 3rem; /* Doubled from 1.5rem */ + margin-bottom: 1rem; /* Increased from 0.5rem */ + font-weight: bold; + text-align: center; +} + +.modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 20; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; +} + +.modal.active { + opacity: 1; + pointer-events: auto; +} + +.modal-content { + background-color: white; + padding: 2rem; + border-radius: 8px; + width: 90%; + max-width: 500px; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-group label { + display: block; + margin-bottom: 0.5rem; + font-weight: bold; +} + +.form-group input { + width: 100%; + padding: 0.5rem; + border: 1px solid #ddd; + border-radius: 4px; +} + +.form-buttons { + display: flex; + justify-content: space-between; + margin-top: 1rem; +} + +.form-buttons button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + flex: 1; + margin: 0 0.5rem; +} + +.form-buttons button:first-child { + margin-left: 0; +} + +.form-buttons button:last-child { + margin-right: 0; +} + +.delete-button-container { + margin-top: 1rem; +} + +.save-button { + background-color: #27ae60; + color: white; +} + +.cancel-button { + background-color: #e74c3c; + color: white; +} + +.delete-button { + background-color: #e74c3c; + color: white; + width: 100%; +} + +/* Add these styles to your styles.css file */ + +.image-input-container { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +.camera-button { + background-color: #3498db; + color: white; + border: none; + padding: 0.5rem; + border-radius: 4px; + cursor: pointer; + display: flex; + align-items: center; + gap: 5px; +} + +.camera-button:hover { + background-color: #2980b9; +} + +/* Optional: Hide the default file input appearance and use a custom button */ +input[type="file"] { + max-width: 120px; +} + +.camera-container { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: #000; + z-index: 30; + display: none; + flex-direction: column; +} + +.camera-container.active { + display: flex; +} + +.camera-view { + flex: 1; + position: relative; + overflow: hidden; +} + +.camera-view video { + width: 100%; + height: 100%; + object-fit: cover; +} + +.camera-controls { + display: flex; + justify-content: space-around; + padding: 1rem; + background-color: #222; +} + +.camera-button-large { + width: 60px; + height: 60px; + border-radius: 50%; + background-color: #fff; + border: 3px solid #3498db; + cursor: pointer; +} + +.camera-button-cancel { + background-color: #e74c3c; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; +} + +.app-footer { + background-color: #2c3e50; + color: white; + padding: 1rem; + text-align: center; + font-size: 0.9rem; + margin-top: auto; /* This pushes the footer to the bottom when possible */ +} + +.author-info { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.3rem; +} + +/* Push notification controls */ +.push-notification-controls { + margin-right: 10px; +} + +.notification-status-container { + margin: 1rem 0; + padding: 1rem; + background-color: #f8f9fa; + border-radius: 4px; +} + +.notification-status p { + margin-bottom: 0.5rem; +} + +.advanced-options { + margin-top: 1rem; + display: flex; + justify-content: space-between; + border-top: 1px solid #eee; + padding-top: 1rem; +} + +.advanced-options button { + padding: 0.5rem 1rem; + border: none; + border-radius: 4px; + cursor: pointer; + flex: 1; + margin: 0 0.5rem; +} + +.advanced-options button:first-child { + margin-left: 0; +} + +.advanced-options button:last-child { + margin-right: 0; +} + +/* Status indicators */ +.status-granted { + color: #28a745; + font-weight: bold; +} + +.status-denied { + color: #dc3545; + font-weight: bold; +} + +.status-default { + color: #ffc107; + font-weight: bold; +} + +.status-active { + color: #28a745; + font-weight: bold; +} + +.status-inactive { + color: #6c757d; + font-weight: bold; +} +/* Service Worker Message Monitor Styles */ +.message-monitor-section { + margin-top: 1.5rem; + padding-top: 1rem; + border-top: 1px solid #eee; +} + +.message-monitor-section h3 { + margin-bottom: 0.5rem; +} + +.monitor-controls { + display: flex; + justify-content: space-between; + margin: 0.5rem 0; +} + +.action-button { + background-color: #3498db; + color: white; + border: none; + padding: 0.5rem 1rem; + border-radius: 4px; + cursor: pointer; + width: 100%; +} + +.action-button:hover { + background-color: #2980b9; +} + +button:disabled { + background-color: #cccccc !important; /* Gray background */ + color: #888888 !important; /* Darker gray text */ + cursor: not-allowed !important; /* Change cursor */ + opacity: 0.7 !important; /* Reduce opacity */ +} + +.cancel-button:disabled { + background-color: #e0e0e0 !important; /* Light gray */ + color: #999999 !important; + border: 1px solid #cccccc !important; +} + +.message-output { + background-color: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 0.75rem; + margin-top: 0.5rem; + height: 150px; + overflow-y: auto; + font-size: 0.85rem; + white-space: pre-wrap; +} diff --git a/dev-start.sh b/dev-start.sh new file mode 100755 index 0000000..24e4446 --- /dev/null +++ b/dev-start.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# Script to start a local development server with environment variables + +# Check if .env file exists +if [ ! -f .env ]; then + echo "Error: .env file not found!" + echo "Please create a .env file based on .env.example" + exit 1 +fi + +# Generate config.env.js from .env +echo "Generating config.env.js from .env..." +./generate-config.sh + +# Start a local development server +echo "Starting development server..." +if command -v python3 &> /dev/null; then + echo "Using Python3 HTTP server on port 8000..." + python3 -m http.server 8000 +elif command -v python &> /dev/null; then + echo "Using Python HTTP server on port 8000..." + python -m SimpleHTTPServer 8000 +elif command -v npx &> /dev/null; then + echo "Using npx serve on port 8000..." + npx serve -l 8000 +else + echo "Error: Could not find a suitable static file server." + echo "Please install Python or Node.js, or manually start a server." + exit 1 +fi diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000..d8045d9 Binary files /dev/null and b/favicon.ico differ diff --git a/generate-config.sh b/generate-config.sh new file mode 100755 index 0000000..0ce6951 --- /dev/null +++ b/generate-config.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# Script to generate config.env.js from .env file +# Usage: ./generate-config.sh + +# Check if .env file exists +if [ ! -f .env ]; then + echo "Error: .env file not found!" + echo "Please create a .env file based on .env.example" + exit 1 +fi + +echo "Generating config.env.js from .env..." + +# Create config.env.js file +echo "// config.env.js - Generated from .env" > config.env.js +echo "// This file contains environment variables for the PWA" >> config.env.js +echo "// Generated on $(date)" >> config.env.js +echo "" >> config.env.js +echo "window.ENV_CONFIG = {" >> config.env.js + +# Read .env file line by line +while IFS="=" read -r key value || [ -n "$key" ]; do + # Skip comments and empty lines + [[ $key =~ ^#.*$ ]] && continue + [[ -z $key ]] && continue + + # Remove quotes if present + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + + # Add the key-value pair to config.env.js + echo " $key: \"$value\"," >> config.env.js +done < .env + +echo "};" >> config.env.js + +echo "config.env.js generated successfully!" diff --git a/icons/android-chrome-192x192.png b/icons/android-chrome-192x192.png new file mode 100644 index 0000000..7fabb38 Binary files /dev/null and b/icons/android-chrome-192x192.png differ diff --git a/icons/android-chrome-512x512.png b/icons/android-chrome-512x512.png new file mode 100644 index 0000000..6c47bef Binary files /dev/null and b/icons/android-chrome-512x512.png differ diff --git a/icons/apple-touch-icon.png b/icons/apple-touch-icon.png new file mode 100644 index 0000000..6534042 Binary files /dev/null and b/icons/apple-touch-icon.png differ diff --git a/icons/favicon-16x16.png b/icons/favicon-16x16.png new file mode 100644 index 0000000..8d52034 Binary files /dev/null and b/icons/favicon-16x16.png differ diff --git a/icons/favicon-32x32.png b/icons/favicon-32x32.png new file mode 100644 index 0000000..ec20525 Binary files /dev/null and b/icons/favicon-32x32.png differ diff --git a/images/screenshot1.png b/images/screenshot1.png new file mode 100644 index 0000000..b1c6477 Binary files /dev/null and b/images/screenshot1.png differ diff --git a/images/screenshot2.png b/images/screenshot2.png new file mode 100644 index 0000000..3347df3 Binary files /dev/null and b/images/screenshot2.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..d4e1ee5 --- /dev/null +++ b/index.html @@ -0,0 +1,286 @@ + + + + + + + Game Timer + + + + + + + + + + + + + + + + + +
+
+
+ +
+
+ +
+
+ + + +
+
+ + +
+ + + + + + + + +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + \ No newline at end of file diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..14dd361 --- /dev/null +++ b/js/app.js @@ -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); + } +})(); \ No newline at end of file diff --git a/js/config.js b/js/config.js new file mode 100644 index 0000000..b534198 --- /dev/null +++ b/js/config.js @@ -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' +}; \ No newline at end of file diff --git a/js/core/eventHandlers.js b/js/core/eventHandlers.js new file mode 100644 index 0000000..1328610 --- /dev/null +++ b/js/core/eventHandlers.js @@ -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 +} \ No newline at end of file diff --git a/js/core/gameActions.js b/js/core/gameActions.js new file mode 100644 index 0000000..6bc3276 --- /dev/null +++ b/js/core/gameActions.js @@ -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(); +} \ No newline at end of file diff --git a/js/core/playerManager.js b/js/core/playerManager.js new file mode 100644 index 0000000..3338918 --- /dev/null +++ b/js/core/playerManager.js @@ -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(); +} \ No newline at end of file diff --git a/js/core/state.js b/js/core/state.js new file mode 100644 index 0000000..2b807fe --- /dev/null +++ b/js/core/state.js @@ -0,0 +1,230 @@ +// state.js +import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js'; + +let players = []; +let currentPlayerIndex = 0; +let gameState = GAME_STATES.SETUP; + +// --- State Accessors --- + +export function getPlayers() { + return [...players]; // Return a copy to prevent direct mutation +} + +export function getCurrentPlayer() { + if (players.length === 0) return null; + return players[currentPlayerIndex]; +} + +export function getPlayerById(id) { + return players.find(p => p.id === id); +} + +export function getCurrentPlayerIndex() { + return currentPlayerIndex; +} + +export function getGameState() { + return gameState; +} + +// --- State Mutators --- + +export function setPlayers(newPlayers) { + players = newPlayers; + saveData(); +} + +export function setCurrentPlayerIndex(index) { + if (index >= 0 && index < players.length) { + currentPlayerIndex = index; + saveData(); + } else { + console.error(`Invalid player index: ${index}`); + } +} + +export function setGameState(newState) { + if (Object.values(GAME_STATES).includes(newState)) { + gameState = newState; + saveData(); + } else { + console.error(`Invalid game state: ${newState}`); + } +} + +export function updatePlayerTime(index, remainingTime) { + if (index >= 0 && index < players.length) { + players[index].remainingTime = Math.max(0, remainingTime); // Ensure time doesn't go below 0 + saveData(); // Save data whenever time updates + } +} + +export function addPlayer(name, timeInMinutes, image = null) { + const timeInSeconds = timeInMinutes * 60; + const newId = Date.now(); + players.push({ + id: newId, + name: name, + timeInSeconds: timeInSeconds, + remainingTime: timeInSeconds, + image: image + }); + currentPlayerIndex = players.length - 1; // Focus new player + saveData(); + return players[players.length - 1]; // Return the newly added player +} + +export function updatePlayer(index, name, timeInMinutes, remainingTimeSeconds, image) { + if (index >= 0 && index < players.length) { + const player = players[index]; + const timeInSeconds = timeInMinutes * 60; + + player.name = name; + player.timeInSeconds = timeInSeconds; + + // Update remaining time carefully based on game state + if (gameState === GAME_STATES.SETUP) { + player.remainingTime = timeInSeconds; + } else if (gameState === GAME_STATES.PAUSED || gameState === GAME_STATES.OVER) { + // Allow direct setting of remaining time only when paused or over + player.remainingTime = remainingTimeSeconds; + } + // If running, remaining time is managed by the timer, don't override here unless intended + + if (image !== undefined) { // Allow updating image (null means remove image) + player.image = image; + } + saveData(); + return player; + } + return null; +} + +export function deletePlayer(index) { + if (players.length <= 2) { + console.warn('Cannot delete player, minimum 2 players required.'); + return false; // Indicate deletion failed + } + if (index >= 0 && index < players.length) { + players.splice(index, 1); + if (currentPlayerIndex >= players.length) { + currentPlayerIndex = players.length - 1; + } else if (currentPlayerIndex > index) { + // Adjust index if deleting someone before the current player + // No adjustment needed if deleting current or after current + } + saveData(); + return true; // Indicate success + } + return false; // Indicate deletion failed +} + +export function resetPlayersTime() { + players.forEach(player => { + player.remainingTime = player.timeInSeconds; + }); + saveData(); +} + +export function resetToDefaults() { + // Deep copy default players to avoid modifying the constant + players = JSON.parse(JSON.stringify(DEFAULT_PLAYERS)); + gameState = GAME_STATES.SETUP; + currentPlayerIndex = 0; + saveData(); +} + +export function areAllTimersFinished() { + return players.every(player => player.remainingTime <= 0); +} + +// Returns the index of the next player with time > 0, or -1 if none +export function findNextPlayerWithTime() { + if (players.length === 0) return -1; + const startIndex = (currentPlayerIndex + 1) % players.length; + let index = startIndex; + + do { + if (players[index].remainingTime > 0) { + return index; + } + index = (index + 1) % players.length; + } while (index !== startIndex); + + // Check current player last if no others found + if(players[currentPlayerIndex].remainingTime > 0) { + return currentPlayerIndex; + } + + return -1; // No player has time left +} + +// Find next player with time in specified direction (1 for next, -1 for prev) +export function findNextPlayerWithTimeCircular(direction) { + if (players.length === 0) return -1; + let index = currentPlayerIndex; + + for (let i = 0; i < players.length; i++) { + index = (index + direction + players.length) % players.length; + if (players[index]?.remainingTime > 0) { // Check if player exists and has time + return index; + } + } + + // If no other player found, check if current player has time (only relevant if direction search fails) + if (players[currentPlayerIndex]?.remainingTime > 0) { + return currentPlayerIndex; + } + + return -1; // No player has time left +} + + +// --- Persistence --- + +export function saveData() { + const dataToSave = { + players, + gameState, + currentPlayerIndex + }; + try { + localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dataToSave)); + } catch (error) { + console.error("Error saving data to localStorage:", error); + // Maybe notify the user that settings won't be saved + } +} + +export function loadData() { + const savedData = localStorage.getItem(LOCAL_STORAGE_KEY); + if (savedData) { + try { + const parsedData = JSON.parse(savedData); + players = parsedData.players || JSON.parse(JSON.stringify(DEFAULT_PLAYERS)); + gameState = parsedData.gameState || GAME_STATES.SETUP; + currentPlayerIndex = parsedData.currentPlayerIndex || 0; + + // Basic validation/migration if needed + if (currentPlayerIndex >= players.length) { + currentPlayerIndex = 0; + } + // Ensure all players have necessary properties + players = players.map(p => ({ + id: p.id || Date.now() + Math.random(), // Ensure ID exists + name: p.name || 'Player', + timeInSeconds: p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS, + remainingTime: p.remainingTime !== undefined ? p.remainingTime : (p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS), + image: p.image || null + })); + + } catch (error) { + console.error("Error parsing data from localStorage:", error); + resetToDefaults(); // Reset to defaults if stored data is corrupt + } + } else { + resetToDefaults(); // Use defaults if no saved data + } + // No saveData() here, loadData just loads the state +} \ No newline at end of file diff --git a/js/core/timer.js b/js/core/timer.js new file mode 100644 index 0000000..955a83f --- /dev/null +++ b/js/core/timer.js @@ -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(); +} \ No newline at end of file diff --git a/js/env-loader.js b/js/env-loader.js new file mode 100644 index 0000000..97f76cb --- /dev/null +++ b/js/env-loader.js @@ -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 +}; \ No newline at end of file diff --git a/js/services/pushFlicIntegration.js b/js/services/pushFlicIntegration.js new file mode 100644 index 0000000..8398288 --- /dev/null +++ b/js/services/pushFlicIntegration.js @@ -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(); +} \ No newline at end of file diff --git a/js/services/screenLockManager.js b/js/services/screenLockManager.js new file mode 100644 index 0000000..c96ae4b --- /dev/null +++ b/js/services/screenLockManager.js @@ -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} - 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} - 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} - 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; +} diff --git a/js/services/serviceWorkerManager.js b/js/services/serviceWorkerManager.js new file mode 100644 index 0000000..81cbab9 --- /dev/null +++ b/js/services/serviceWorkerManager.js @@ -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.'); + } +} \ No newline at end of file diff --git a/js/ui/audio.js b/js/ui/audio.js new file mode 100644 index 0000000..2e907e2 --- /dev/null +++ b/js/ui/audio.js @@ -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; \ No newline at end of file diff --git a/js/ui/camera.js b/js/ui/camera.js new file mode 100644 index 0000000..725409d --- /dev/null +++ b/js/ui/camera.js @@ -0,0 +1,116 @@ +// camera.js +import { CSS_CLASSES } from '../config.js'; + +let stream = null; +let elements = {}; // To store references to DOM elements passed during init +let onCaptureCallback = null; // Callback when image is captured + +export function initCamera(cameraElements, options) { + elements = cameraElements; // Store refs like { cameraContainer, cameraView, etc. } + onCaptureCallback = options.onCapture; + + // Add internal listeners for capture/cancel buttons + elements.cameraCaptureButton?.addEventListener('click', handleCapture); + elements.cameraCancelButton?.addEventListener('click', closeCamera); + + // Handle orientation change to potentially reset stream dimensions + window.addEventListener('orientationchange', handleOrientationChange); +} + +async function openCamera() { + if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) { + alert('Camera access not supported or available on this device.'); + return false; // Indicate failure + } + + try { + stream = await navigator.mediaDevices.getUserMedia({ + video: { + facingMode: 'user', // Prefer front camera + width: { ideal: 1280 }, + height: { ideal: 720 } + } + }); + + elements.cameraContainer?.classList.add(CSS_CLASSES.CAMERA_ACTIVE); + if (elements.cameraView) { + elements.cameraView.srcObject = stream; + // Wait for video metadata to load to get correct dimensions + elements.cameraView.onloadedmetadata = () => { + elements.cameraView.play(); // Start playing the video stream + }; + } + return true; // Indicate success + } catch (error) { + console.error('Error accessing camera:', error); + alert('Could not access camera: ' + error.message); + closeCamera(); // Ensure cleanup if opening failed + return false; // Indicate failure + } +} + +function handleCapture() { + if (!elements.cameraView || !elements.cameraCanvas || !stream) return; + + // Set canvas dimensions to match video's actual dimensions + elements.cameraCanvas.width = elements.cameraView.videoWidth; + elements.cameraCanvas.height = elements.cameraView.videoHeight; + + // Draw the current video frame to the canvas + const context = elements.cameraCanvas.getContext('2d'); + // Flip horizontally for front camera to make it mirror-like + if (stream.getVideoTracks()[0]?.getSettings()?.facingMode === 'user') { + context.translate(elements.cameraCanvas.width, 0); + context.scale(-1, 1); + } + context.drawImage(elements.cameraView, 0, 0, elements.cameraCanvas.width, elements.cameraCanvas.height); + + // Convert canvas to data URL (JPEG format) + const imageDataUrl = elements.cameraCanvas.toDataURL('image/jpeg', 0.9); // Quality 0.9 + + // Call the callback provided during init with the image data + if (onCaptureCallback) { + onCaptureCallback(imageDataUrl); + } + + // Stop stream and hide UI after capture + closeCamera(); +} + +function stopCameraStream() { + if (stream) { + stream.getTracks().forEach(track => track.stop()); + stream = null; + } + // Also clear the srcObject + if (elements.cameraView) { + elements.cameraView.srcObject = null; + } +} + +function closeCamera() { + stopCameraStream(); + elements.cameraContainer?.classList.remove(CSS_CLASSES.CAMERA_ACTIVE); +} + +function handleOrientationChange() { + // If camera is active, restart stream to potentially adjust aspect ratio/resolution + if (elements.cameraContainer?.classList.contains(CSS_CLASSES.CAMERA_ACTIVE) && stream) { + console.log("Orientation changed, re-evaluating camera stream..."); + // Short delay to allow layout to settle + setTimeout(async () => { + // Stop existing stream before requesting new one + // This might cause a flicker but ensures constraints are re-evaluated + stopCameraStream(); + await openCamera(); // Attempt to reopen with potentially new constraints + }, 300); + } +} + +// Public API for camera module +export default { + init: initCamera, + open: openCamera, + close: closeCamera, + stopStream: stopCameraStream // Expose if needed externally, e.g., when modal closes +}; \ No newline at end of file diff --git a/js/ui/pushSettingsUI.js b/js/ui/pushSettingsUI.js new file mode 100644 index 0000000..2000c0f --- /dev/null +++ b/js/ui/pushSettingsUI.js @@ -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}`); + } +} diff --git a/js/ui/ui.js b/js/ui/ui.js new file mode 100644 index 0000000..b399f51 --- /dev/null +++ b/js/ui/ui.js @@ -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 = '

Add players to start

'; + return; + } + + players.forEach((player, index) => { + const card = document.createElement('div'); + const isActive = index === currentIndex; + card.className = `player-card ${isActive ? CSS_CLASSES.ACTIVE_PLAYER : CSS_CLASSES.INACTIVE_PLAYER}`; + + const minutes = Math.floor(player.remainingTime / 60); + const seconds = player.remainingTime % 60; + const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; + + const timerClasses = []; + if (isActive && currentGameState === GAME_STATES.RUNNING) { + timerClasses.push(CSS_CLASSES.TIMER_ACTIVE); + } + if (player.remainingTime <= 0) { + timerClasses.push(CSS_CLASSES.TIMER_FINISHED); + } + + card.innerHTML = ` +
+ ${player.image ? `${player.name}` : ''} +
+
${player.name}
+
${timeString}
+ `; + elements.carousel.appendChild(card); + }); + + updateCarouselPosition(); +} + +export function updateCarouselPosition() { + const currentIndex = state.getCurrentPlayerIndex(); + elements.carousel.style.transform = `translateX(${-100 * currentIndex}%)`; +} + +export function updateGameButton() { + const currentGameState = state.getGameState(); + switch (currentGameState) { + case GAME_STATES.SETUP: + elements.gameButton.textContent = 'Start Game'; + break; + case GAME_STATES.RUNNING: + elements.gameButton.textContent = 'Pause Game'; + break; + case GAME_STATES.PAUSED: + elements.gameButton.textContent = 'Resume Game'; + break; + case GAME_STATES.OVER: + elements.gameButton.textContent = 'Game Over'; + break; + } + // Disable button if less than 2 players in setup + elements.gameButton.disabled = currentGameState === GAME_STATES.SETUP && state.getPlayers().length < 2; +} + + +// --- Modal Functions --- + +export function showPlayerModal(isNewPlayer, player = null) { + const currentGameState = state.getGameState(); + if (isNewPlayer) { + elements.modalTitle.textContent = 'Add New Player'; + elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`; + elements.playerTimeInput.value = 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 = ''; + 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 ? `${player.name}` : ''; + elements.deletePlayerButton.style.display = 'block'; + } + + // Reset file input and captured image data + elements.playerImageInput.value = ''; + elements.playerImageInput.dataset.capturedImage = ''; + + elements.playerModal.classList.add(CSS_CLASSES.MODAL_ACTIVE); + audioManager.play('modalOpen'); +} + +export function hidePlayerModal() { + elements.playerModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE); + audioManager.play('modalClose'); + // Potentially call camera cleanup here if it wasn't done elsewhere +} + +export function showResetModal() { + elements.resetModal.classList.add(CSS_CLASSES.MODAL_ACTIVE); + audioManager.play('modalOpen'); +} + +export function hideResetModal() { + elements.resetModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE); + audioManager.play('modalClose'); +} + +export function updateImagePreviewFromFile(file) { + if (file) { + const reader = new FileReader(); + reader.onload = (event) => { + elements.imagePreview.innerHTML = `Player Preview`; + // Clear any previously captured image data if a file is selected + elements.playerImageInput.dataset.capturedImage = ''; + }; + reader.readAsDataURL(file); + } +} + +export function updateImagePreviewFromDataUrl(dataUrl) { + elements.imagePreview.innerHTML = `Player Preview`; + // Store data URL and clear file input + elements.playerImageInput.dataset.capturedImage = dataUrl; + elements.playerImageInput.value = ''; +} + + +// --- Carousel Touch Handling --- + +function handleTouchStart(e) { + startX = e.touches[0].clientX; + currentX = startX; + isDragging = true; + // Optional: Add a class to the carousel for visual feedback during drag + elements.carousel.style.transition = 'none'; // Disable transition during drag +} + +function handleTouchMove(e) { + if (!isDragging) return; + + currentX = e.touches[0].clientX; + const diff = currentX - startX; + const currentIndex = state.getCurrentPlayerIndex(); + const currentTranslate = -100 * currentIndex + (diff / elements.carousel.offsetWidth * 100); + + elements.carousel.style.transform = `translateX(${currentTranslate}%)`; +} + +function handleTouchEnd(e) { + if (!isDragging) return; + + isDragging = false; + elements.carousel.style.transition = ''; // Re-enable transition + + const diff = currentX - startX; + const threshold = elements.carousel.offsetWidth * 0.1; // 10% swipe threshold + + if (Math.abs(diff) > threshold) { + // Call the handler passed during initialization + if (carouselSwipeHandler) { + carouselSwipeHandler(diff < 0 ? 1 : -1); // Pass direction: 1 for next, -1 for prev + } + } else { + // Snap back if swipe wasn't enough + updateCarouselPosition(); + } +} + +// --- UI Initialization --- + +// Add sound toggle button +function createSoundToggleButton() { + const soundButton = document.createElement('button'); + soundButton.id = 'soundToggleButton'; + soundButton.className = 'header-button'; + soundButton.title = 'Toggle Sound'; + soundButton.innerHTML = audioManager.muted ? '' : ''; + + soundButton.addEventListener('click', () => { + const isMuted = audioManager.toggleMute(); + soundButton.innerHTML = isMuted ? '' : ''; + if (!isMuted) audioManager.play('buttonClick'); // Feedback only when unmuting + }); + + elements.headerButtons.prepend(soundButton); // Add to the beginning +} + +// Parse time string (MM:SS) to seconds - Helper needed for form processing +export function parseTimeString(timeString) { + if (!/^\d{1,2}:\d{2}$/.test(timeString)) { + console.error('Invalid time format:', timeString); + return null; // Indicate error + } + const parts = timeString.split(':'); + const minutes = parseInt(parts[0], 10); + const seconds = parseInt(parts[1], 10); + if (isNaN(minutes) || isNaN(seconds) || seconds > 59) { + console.error('Invalid time value:', timeString); + return null; + } + return (minutes * 60) + seconds; +} + +// Sets up basic UI elements and listeners that primarily affect the UI itself +export function initUI(options) { + // Store the swipe handler provided by app.js + carouselSwipeHandler = options.onCarouselSwipe; + + createSoundToggleButton(); + + // Carousel touch events + elements.carousel.addEventListener('touchstart', handleTouchStart, { passive: true }); + elements.carousel.addEventListener('touchmove', handleTouchMove, { passive: true }); + elements.carousel.addEventListener('touchend', handleTouchEnd); + + // Image file input preview + elements.playerImageInput.addEventListener('change', (e) => { + const file = e.target.files[0]; + if (file) { + updateImagePreviewFromFile(file); + } + }); + + // Initial render + renderPlayers(); + updateGameButton(); +} \ No newline at end of file diff --git a/labels.example b/labels.example new file mode 100644 index 0000000..22af919 --- /dev/null +++ b/labels.example @@ -0,0 +1,31 @@ +# Enable Traefik for this container +traefik.enable=true + +# Docker Network +traefik.docker.network=traefik + +# Route requests based on Host +traefik.http.routers.game-timer.rule=Host(`game-timer.virtonline.eu`) +# Specify the entrypoint ('websecure' for HTTPS) +traefik.http.routers.game-timer.entrypoints=web-secure +traefik.http.routers.game-timer.tls=true +traefik.http.routers.game-timer.tls.certResolver=default +# Link the router to the service defined below +traefik.http.routers.game-timer.service=game-timer + +# Point the service to the container's port +traefik.http.services.game-timer.loadbalancer.server.port=80 + +# Declaring the user list +# +# Note: when used in docker-compose.yml all dollar signs in the hash need to be doubled for escaping. +# To create a user:password pair, the following command can be used: +# echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g +# +# Also note that dollar signs should NOT be doubled when they are not evaluated (e.g. Ansible docker_container module). +# for docker lables use +# `htpasswd -nb user password` +traefik.http.middlewares.game-timer-auth.basicauth.users=user:$apr1$rFge2lVe$DpoqxMsxSVJubFLXu4OMr1 + +# Apply the middleware to the router +traefik.http.routers.game-timer.middlewares=game-timer-auth diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..685bf49 --- /dev/null +++ b/manifest.json @@ -0,0 +1,51 @@ +{ + "name": "Game Timer PWA", + "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": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/icons/apple-touch-icon.png", + "sizes": "180x180", + "type": "image/png" + }, + { + "src": "/icons/favicon-32x32.png", + "sizes": "32x32", + "type": "image/png" + }, + { + "src": "/icons/favicon-16x16.png", + "sizes": "16x16", + "type": "image/png" + } + ], + "screenshots": [ + { + "src": "/images/screenshot1.png", + "sizes": "2560x1860", + "type": "image/png", + "form_factor": "wide" + }, + { + "src": "/images/screenshot2.png", + "sizes": "750x1594", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..d51e586 --- /dev/null +++ b/package.json @@ -0,0 +1,26 @@ +{ + "name": "game-timer", + "version": "1.0.0", + "description": "Multi-player chess timer with carousel navigation", + "main": "src/js/app.js", + "scripts": { + "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", + "generate-config": "./generate-config.sh", + "dev": "./dev-start.sh" + }, + "keywords": [ + "timer", + "game", + "chess", + "pwa" + ], + "author": "", + "license": "SEE LICENSE IN LICENSE", + "repository": { + "type": "git", + "url": "https://gitea.virtonline.eu/2HoursProject/game-timer.git" + } +} \ No newline at end of file diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..6ca8ad8 --- /dev/null +++ b/sw.js @@ -0,0 +1,210 @@ +// Service Worker version +const CACHE_VERSION = 'v1.0.2'; +const CACHE_NAME = `game-timer-${CACHE_VERSION}`; + +// Files to cache +const CACHE_FILES = [ + '/', + '/index.html', + '/manifest.json', + '/sw.js', + '/favicon.ico', + '/config.env.js', + '/css/styles.css', + '/icons/android-chrome-192x192.png', + '/icons/android-chrome-512x512.png', + '/icons/apple-touch-icon.png', + '/icons/favicon-32x32.png', + '/icons/favicon-16x16.png', + '/images/screenshot1.png', + '/images/screenshot2.png', + '/js/app.js', + '/js/config.js', + '/js/env-loader.js', + '/js/core/eventHandlers.js', + '/js/core/gameActions.js', + '/js/core/playerManager.js', + '/js/core/state.js', + '/js/core/timer.js', + '/js/services/pushFlicIntegration.js', + '/js/services/screenLockManager.js', + '/js/services/serviceWorkerManager.js', + '/js/ui/audio.js', + '/js/ui/camera.js', + '/js/ui/pushSettingsUI.js', + '/js/ui/ui.js' +]; + +// Install event - Cache files +self.addEventListener('install', event => { + console.log('[ServiceWorker] Install'); + event.waitUntil( + caches.open(CACHE_NAME) + .then(cache => { + console.log('[ServiceWorker] Caching app shell'); + return cache.addAll(CACHE_FILES); + }) + .then(() => { + console.log('[ServiceWorker] Skip waiting on install'); + return self.skipWaiting(); + }) + ); +}); + +// Activate event - Clean old caches +self.addEventListener('activate', event => { + console.log('[ServiceWorker] Activate'); + event.waitUntil( + caches.keys().then(keyList => { + return Promise.all(keyList.map(key => { + if (key !== CACHE_NAME) { + console.log('[ServiceWorker] Removing old cache', key); + return caches.delete(key); + } + })); + }) + .then(() => { + console.log('[ServiceWorker] Claiming clients'); + return self.clients.claim(); + }) + ); +}); + +// Helper function to determine if a response should be cached +function shouldCacheResponse(request, response) { + // Only cache GET requests + if (request.method !== 'GET') return false; + + // Don't cache errors + if (!response || response.status !== 200) return false; + + // Check if URL should be cached + const url = new URL(request.url); + + // Don't cache query parameters (except common ones for content) + if (url.search && !url.search.match(/\?(v|version|cache)=/)) return false; + + return true; +} + +self.addEventListener('push', event => { + console.log('[ServiceWorker] Push received'); + + let pushData = { + title: 'Flic Action', + body: 'Button pressed!', + data: { + action: 'Unknown', + button: 'Unknown', + batteryLevel: undefined, + timestamp: new Date().toISOString() + } + }; + + // --- Attempt to parse data payload --- + if (event.data) { + try { + const parsedData = event.data.json(); + console.log('[ServiceWorker] Push data:', parsedData); + + // Use parsed data for notification and message + pushData = { + title: parsedData.title || pushData.title, + body: parsedData.body || pushData.body, + data: parsedData.data || pushData.data + }; + + // Ensure all required fields are present in data + pushData.data = pushData.data || {}; + if (!pushData.data.timestamp) { + pushData.data.timestamp = new Date().toISOString(); + } + + } catch (e) { + console.error('[ServiceWorker] Error parsing push data:', e); + // Use default notification if parsing fails + pushData.body = event.data.text() || pushData.body; // Fallback to text + } + } else { + console.log('[ServiceWorker] Push event but no data'); + } + + // --- Send message to client(s) --- + const messagePayload = { + type: 'flic-action', // Custom message type + action: pushData.data.action || 'Unknown', // e.g., 'SingleClick', 'DoubleClick', 'Hold' + button: pushData.data.button || 'Unknown', // e.g., the button name + timestamp: pushData.data.timestamp || new Date().toISOString(), // e.g., the timestamp of the action + batteryLevel: pushData.data.batteryLevel // e.g., the battery level percentage + }; + + console.log('[ServiceWorker] Preparing message payload:', messagePayload); + + event.waitUntil( + self.clients.matchAll({ + type: 'window', + includeUncontrolled: true + }).then(clientList => { + if (!clientList || clientList.length === 0) { + // No clients available, just resolve + return Promise.resolve(); + } + + // Post message to each client with improved reliability + let messageSent = false; + const sendPromises = clientList.map(client => { + console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload); + try { + // Try to send the message and mark it as sent + client.postMessage(messagePayload); + messageSent = true; + + // Just return true to indicate message was sent + return Promise.resolve(true); + } catch (error) { + console.error('[ServiceWorker] Error posting message to client:', error); + return Promise.resolve(false); + } + }); + + return Promise.all(sendPromises).then(() => { + // No notifications will be shown for any action, including low battery + return Promise.resolve(); + }); + }) + ); +}); + +// Listen for messages from client +self.addEventListener('message', event => { + const message = event.data; + + if (!message || typeof message !== 'object') { + return; + } + + // No battery-related handling +}); + +// This helps with navigation after app is installed +self.addEventListener('notificationclick', event => { + console.log('[ServiceWorker] Notification click received'); + + event.notification.close(); + + // Handle the notification click + event.waitUntil( + self.clients.matchAll({ type: 'window' }) + .then(clientList => { + for (const client of clientList) { + if (client.url.startsWith(self.location.origin) && 'focus' in client) { + return client.focus(); + } + } + + if (self.clients.openWindow) { + return self.clients.openWindow('/'); + } + }) + ); +}); \ No newline at end of file diff --git a/virt-game-timer.service b/virt-game-timer.service new file mode 100644 index 0000000..0016371 --- /dev/null +++ b/virt-game-timer.service @@ -0,0 +1,30 @@ +[Unit] +Description=virt-game-timer (virt-game-timer) +Requires=docker.service +After=docker.service +DefaultDependencies=no + +[Service] +Type=simple +Environment="HOME=/root" +ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-game-timer 2>/dev/null || true' +ExecStartPre=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-game-timer 2>/dev/null || true' + +ExecStart=/usr/bin/env docker run \ + --rm \ + --name=virt-game-timer \ + --log-driver=none \ + --network=traefik \ + --label-file=/virt/game-timer/labels \ + --mount type=bind,src=/etc/localtime,dst=/etc/localtime,ro \ + game-timer:latest + +ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker kill virt-game-timer 2>/dev/null || true' +ExecStop=-/usr/bin/env sh -c '/usr/bin/env docker rm virt-game-timer 2>/dev/null || true' + +Restart=always +RestartSec=30 +SyslogIdentifier=virt-game-timer + +[Install] +WantedBy=multi-user.target \ No newline at end of file