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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Reset All Data
+
Are you sure you want to reset all players and timers? This action cannot be undone.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Push Notification Settings
+
+
+
Notification Permission: Unknown
+
Subscription Status: Unknown
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Monitoring for service worker messages...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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}
+ ${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 ? `
` : '';
+ 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 = `
`;
+ // 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 = `
`;
+ // 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