Compare commits

..

5 Commits

Author SHA1 Message Date
cpu
de2beab911 Initial commit 2025-04-06 10:43:29 +02:00
cpu
db06e71e69 Initial commit 2025-04-06 10:25:07 +02:00
cpu
a480ecaf65 Initial commit 2025-04-06 09:25:39 +02:00
cpu
2bebd8cc88 Initial commit 2025-03-31 23:44:36 +02:00
cpu
1cd819938f Initial commit 2025-03-31 23:33:18 +02:00
36 changed files with 3093 additions and 1579 deletions

61
.dockerignore Normal file
View File

@@ -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

12
.env.example Normal file
View File

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

40
.gitignore vendored Normal file
View File

@@ -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/

View File

@@ -1,11 +1,38 @@
# Use the official Nginx image as the base image # Use a lightweight server
FROM nginx:alpine FROM nginx:alpine
# Remove the default Nginx static files # Install bash for the script execution
RUN rm -rf /usr/share/nginx/html/* RUN apk add --no-cache bash
# Copy all your app's files into the Nginx directory # Set working directory
COPY . /usr/share/nginx/html 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
# Uninstall bash
RUN apk del bash
# Remove the .env file and the generation script for security
RUN rm .env docker-generate-config.sh
# Expose port 80 # Expose port 80
EXPOSE 80 EXPOSE 80

115
README.md
View File

@@ -1,6 +1,66 @@
# Game Timer # Game Timer
Multi-player chess timer with carousel navigation Multi-player game-timer timer with carousel navigation
## Project Structure
```
game-timer/
├── css/ # CSS stylesheets
├── icons/ # App icons
├── images/ # Image assets
├── js/ # Symbolic link to src/js for compatibility
├── index.html # Main HTML entry point
├── manifest.json # PWA manifest
├── sw.js # Service Worker
├── src/ # Source code
│ └── js/ # JavaScript files
│ ├── core/ # Core application logic
│ ├── ui/ # UI-related code
│ └── services/ # External services integration
├── Dockerfile # Docker container definition (nginx)
├── .dockerignore # Files to exclude from Docker build
├── .env # Environment variables for production
├── .env.example # Example environment variables template
└── package.json # Project metadata and deployment scripts
```
## Environment Variables
The application uses environment variables for configuration. These are loaded from a `.env` file 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 # PWA Containerized Deployment
@@ -23,7 +83,7 @@ git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git
cd game-timer cd game-timer
``` ```
### 2. Build the docker image ### 2. Build the Docker image
From the repository root, run the following command to build your Docker image: From the repository root, run the following command to build your Docker image:
@@ -31,12 +91,24 @@ From the repository root, run the following command to build your Docker image:
docker build -t 'game-timer:latest' . docker build -t 'game-timer:latest' .
``` ```
### 3. Run the Docker Container or use the npm script:
Once the image is built, run the container on port 8080 with:
```bash ```bash
docker run -d -p 8080:80 --name game-timer-container game-timer:latest npm run docker:build
```
### 3. Run the Docker Container
Once the image is built, run the container on port 80 with:
```bash
docker run -d -p 80:80 --name game-timer game-timer:latest
```
or use the npm script:
```bash
npm run start
``` ```
### 4. Verify the Deployment ### 4. Verify the Deployment
@@ -50,19 +122,42 @@ docker ps
View logs (if needed): View logs (if needed):
```bash ```bash
docker logs game-timer-container docker logs game-timer
``` ```
After running the container, open your web browser and navigate to: After running the container, open your web browser and navigate to:
```bash ```
http://localhost http://localhost
``` ```
### 5. Terminate ### 5. Terminate
To stop your running game-timer-container, use: To stop your running game-timer container, use:
```bash ```bash
docker stop game-timer-container docker stop game-timer
docker rm game-timer
``` ```
or use the npm script:
```bash
npm run stop
```
## Development
For local development without Docker, you can use any static file server such as:
```bash
python -m http.server
```
or
```bash
npx serve
```
This will start a local development server and you can access the application in your browser.

1145
app.js

File diff suppressed because it is too large Load Diff

View File

@@ -353,18 +353,124 @@ input[type="file"] {
cursor: pointer; cursor: pointer;
} }
.app-footer { /* Push notification controls */
background-color: #2c3e50; .push-notification-controls {
color: white; margin-right: 10px;
padding: 1rem;
text-align: center;
font-size: 0.9rem;
margin-top: auto; /* This pushes the footer to the bottom when possible */
} }
.author-info { .notification-status-container {
display: flex; margin: 1rem 0;
flex-direction: column; padding: 1rem;
align-items: center; background-color: #f8f9fa;
gap: 0.3rem; 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;
} }

View File

@@ -1,135 +0,0 @@
// deeplinks.js - Deep Link Manager for Game Timer
// Available actions
const VALID_ACTIONS = ['start', 'pause', 'toggle', 'nextplayer', 'reset'];
// Class to manage all deep link functionality
class DeepLinkManager {
constructor() {
this.actionHandlers = {};
// Initialize listeners
this.initServiceWorkerListener();
this.initHashChangeListener();
this.initPopStateListener();
}
// Register action handlers
registerHandler(action, handlerFn) {
if (VALID_ACTIONS.includes(action)) {
this.actionHandlers[action] = handlerFn;
console.log(`Registered handler for action: ${action}`);
} else {
console.warn(`Attempted to register handler for invalid action: ${action}`);
}
}
// Extract action from URL parameters (search or hash)
getActionFromUrl() {
// Check for action in both search params and hash
const searchParams = new URLSearchParams(window.location.search);
const hashParams = new URLSearchParams(window.location.hash.substring(1));
// First check search params (for direct curl or navigation)
const searchAction = searchParams.get('action');
if (searchAction && VALID_ACTIONS.includes(searchAction)) {
console.log('Found action in search params:', searchAction);
return searchAction;
}
// Then check hash params (existing deep link mechanism)
const hashAction = hashParams.get('action');
if (hashAction && VALID_ACTIONS.includes(hashAction)) {
console.log('Found action in hash params:', hashAction);
return hashAction;
}
return null;
}
// Process an action
handleAction(action) {
if (!action) return;
console.log('Processing action:', action);
if (this.actionHandlers[action]) {
this.actionHandlers[action]();
} else {
console.log('No handler registered for action:', action);
}
}
// Handle deep links from URL
processDeepLink() {
// Get action from URL parameters
const action = this.getActionFromUrl();
// Process the action if found
if (action) {
this.handleAction(action);
// Clear the parameters to prevent duplicate actions if page is refreshed
if (window.history && window.history.replaceState) {
// Create new URL without the action parameter
const newUrl = window.location.pathname;
window.history.replaceState({}, document.title, newUrl);
}
}
}
// Initialize service worker message listener
initServiceWorkerListener() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.addEventListener('message', (event) => {
if (event.data && event.data.type === 'ACTION') {
console.log('Received action from service worker:', event.data.action);
this.handleAction(event.data.action);
}
});
}
}
// Initialize hash change listener
initHashChangeListener() {
window.addEventListener('hashchange', () => {
console.log('Hash changed, checking for actions');
this.processDeepLink();
});
}
// Initialize popstate listener for navigation events
initPopStateListener() {
window.addEventListener('popstate', () => {
console.log('Navigation occurred, checking for actions');
this.processDeepLink();
});
}
// Send an action to the service worker
sendActionToServiceWorker(action) {
if ('serviceWorker' in navigator && navigator.serviceWorker.controller) {
navigator.serviceWorker.controller.postMessage({
type: 'PROCESS_ACTION',
action: action
});
}
}
// Generate a deep link URL for a specific action
generateDeepLink(action, useHash = false) {
if (!VALID_ACTIONS.includes(action)) {
console.warn(`Cannot generate deep link for invalid action: ${action}`);
return null;
}
const baseUrl = window.location.origin + window.location.pathname;
return useHash ?
`${baseUrl}#action=${action}` :
`${baseUrl}?action=${action}`;
}
}
// Export singleton instance
const deepLinkManager = new DeepLinkManager();
export default deepLinkManager;

30
dev-start.sh Executable file
View File

@@ -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

39
generate-config.sh Executable file
View File

@@ -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!"

BIN
images/screenshot1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

BIN
images/screenshot2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

View File

@@ -5,22 +5,30 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="theme-color" content="#2c3e50"> <meta name="theme-color" content="#2c3e50">
<title>Game Timer</title> <title>Game Timer</title>
<link rel="manifest" href="manifest.json">
<!-- Favicon links -->
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<link rel="shortcut icon" href="/favicon.ico" type="image/x-icon">
<!-- Web app manifest -->
<link rel="manifest" href="/manifest.json">
<!-- App icons for various platforms -->
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
<!-- CSS stylesheets -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="/css/styles.css">
<!-- Deep Linking - App Links for Android -->
<link rel="alternate" href="android-app://eu.virtonline.gametimer/https://game-timer.virtonline.eu" />
<!-- Deep Linking - Universal Links for iOS -->
<meta name="apple-itunes-app" content="app-id=yourAppID, app-argument=https://game-timer.virtonline.eu">
<!-- Deep Linking - Web App URL Handling -->
<link rel="alternate" href="web+gametimer://action" />
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
<header class="header"> <header class="header">
<div class="push-notification-controls">
<button id="pushSettingsButton" class="header-button" title="Push Notification Settings">
<i class="fas fa-bell"></i>
</button>
</div>
<div class="game-controls"> <div class="game-controls">
<button id="gameButton" class="game-button">Start Game</button> <button id="gameButton" class="game-button">Start Game</button>
</div> </div>
@@ -109,54 +117,48 @@
</div> </div>
</div> </div>
<!-- Script for handling URL scheme and deep links --> <!-- Push Notification Settings Modal -->
<script type="module"> <div id="pushSettingsModal" class="modal">
// Register the custom URL protocol handler (web+gametimer://) <div class="modal-content">
if ('registerProtocolHandler' in navigator) { <h2>Push Notification Settings</h2>
try { <div class="notification-status-container">
navigator.registerProtocolHandler( <div class="notification-status">
'web+gametimer', <p><strong>Notification Permission: </strong><span id="notificationPermissionStatus">Unknown</span></p>
'https://game-timer.virtonline.eu/?action=%s', <p><strong>Subscription Status: </strong><span id="subscriptionStatus">Unknown</span></p>
'Game Timer' </div>
); </div>
console.log('Protocol handler registered'); <div class="form-group">
} catch (e) { <label for="pushUsername">Username for Push Service</label>
console.error('Failed to register protocol handler:', e); <input type="text" id="pushUsername" placeholder="Enter username">
} </div>
} <div class="form-group">
<label for="pushPassword">Password</label>
<input type="password" id="pushPassword" placeholder="Enter password">
</div>
<div class="advanced-options">
<button type="button" id="pushUnsubscribeButton" class="cancel-button">Unsubscribe</button>
<button type="button" id="pushResubscribeButton" class="save-button">Subscribe</button>
</div>
<div class="form-buttons">
<button type="button" id="pushCancelButton" class="cancel-button">Cancel</button>
<button type="button" id="pushSaveButton" class="save-button">Save</button>
</div>
// Function to parse URL parameters <!-- Message Monitor Section - No visible title -->
function getUrlParams() { <div class="message-monitor-section">
const searchParams = new URLSearchParams(window.location.search); <div class="monitor-controls">
const hashParams = new URLSearchParams(window.location.hash.substring(1)); <button type="button" id="simulateClickButton" class="action-button" style="margin-right: 10px;">Simulate Remote Button Click</button>
</div>
<pre id="swMessagesOutput" class="message-output">Monitoring for service worker messages...</pre>
</div>
</div>
</div>
// Check search parameters first (for direct links) <!-- Load environment configuration first -->
const action = searchParams.get('action'); <script src="/config.env.js"></script>
if (action) {
// Clean the action parameter (remove 'web+gametimer://' if present)
return action.replace('web+gametimer://', '');
}
// Then check hash parameters (for deep links)
return hashParams.get('action');
}
// Initialize URL handling
function initUrlHandling() {
const action = getUrlParams();
if (action) {
console.log('URL action detected:', action);
// Set the action in the hash to be processed by the main app
window.location.hash = `action=${action}`;
}
}
// Run initialization when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initUrlHandling);
</script>
<!-- Main application script --> <!-- Main application script -->
<script type="module" src="app.js"></script> <script type="module" src="/js/app.js"></script>
<script> <script>
if ("serviceWorker" in navigator) { if ("serviceWorker" in navigator) {
navigator.serviceWorker.register("/sw.js") navigator.serviceWorker.register("/sw.js")
@@ -172,13 +174,107 @@
console.log('Notification permission:', permission); console.log('Notification permission:', permission);
}); });
} }
// Helper function to get stored credentials
async function getStoredCredentials() {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (!storedAuth) return null;
try {
const credentials = JSON.parse(storedAuth);
if (!credentials.username || !credentials.password) return null;
return credentials;
} catch (error) {
console.error('Failed to parse stored credentials:', error);
return null;
}
}
// Function to simulate a button click with default values
async function simulateButtonClick() {
const action = 'SingleClick'; // Default action
const buttonName = 'Game-button'; // Default button name
const batteryLevel = 100; // Default battery level
const output = document.getElementById('swMessagesOutput');
// Don't show the simulating text
try {
// Get credentials
const credentials = await getStoredCredentials();
if (!credentials) {
output.textContent = 'No credentials found. Please set up credentials first.\n';
return;
}
// Create basic auth header
const authHeader = 'Basic ' + btoa(`${credentials.username}:${credentials.password}`);
// Create timestamp (current time)
const timestamp = new Date().toISOString();
// Prepare request to backend webhook
let backendUrl;
try {
const configModule = await import('/js/config.js');
backendUrl = configModule.getBackendUrl();
} catch (error) {
output.textContent = 'Failed to load backend URL. Please check configuration.\n';
return;
}
const webhookUrl = `${backendUrl}/webhook/${action}`;
output.textContent = `Sending request to: ${webhookUrl}\n`;
output.textContent += `Authorization: Basic ****\n`;
output.textContent += `Button-Name: ${buttonName}\n`;
output.textContent += `Timestamp: ${timestamp}\n`;
output.textContent += `Button-Battery-Level: ${batteryLevel}\n\n`;
// Headers similar to the curl command
const headers = {
'Authorization': authHeader,
'Button-Name': buttonName,
'Timestamp': timestamp,
'Button-Battery-Level': batteryLevel.toString()
};
// Send GET request to webhook
const response = await fetch(webhookUrl, {
method: 'GET',
headers: headers,
credentials: 'include'
});
if (response.ok) {
let result;
try {
result = await response.json();
output.textContent += `Success! Response: ${JSON.stringify(result, null, 2)}\n`;
} catch (e) {
// Text response
result = await response.text();
output.textContent += `Success! Response: ${result}\n`;
}
} else {
let errorText;
try {
errorText = await response.text();
} catch (e) {
errorText = `Status ${response.status}`;
}
output.textContent += `Error: ${errorText}\n`;
}
} catch (error) {
output.textContent += `Error: ${error.message}\n`;
}
}
// Attach click event to the new button
const simulateClickButton = document.getElementById('simulateClickButton');
if (simulateClickButton) {
simulateClickButton.addEventListener('click', simulateButtonClick);
}
}); });
</script> </script>
<footer class="app-footer">
<div class="author-info">
<p>Vibe coded by Martin</p>
<p>Version 0.0.1</p>
</div>
</footer>
</body> </body>
</html> </html>

123
js/app.js Normal file
View File

@@ -0,0 +1,123 @@
// app.js - Main Application Orchestrator
import * as config from './config.js';
import * as state from './core/state.js';
import * as ui from './ui/ui.js';
import * as timer from './core/timer.js';
import camera from './ui/camera.js'; // Default export
import * as pushSettingsUI from './ui/pushSettingsUI.js'; // Import the new push settings UI module
// Import externalized modules
import * as gameActions from './core/gameActions.js';
import * as playerManager from './core/playerManager.js';
import * as eventHandlers from './core/eventHandlers.js';
import * as serviceWorkerManager from './services/serviceWorkerManager.js';
import * as screenLockManager from './services/screenLockManager.js'; // Import the screen lock manager
// --- Initialization ---
async function initialize() {
console.log("Initializing Game Timer App...");
// 0. Wait for environment variables to load
try {
// Use the ensureEnvLoaded function from config.js to make sure environment variables are loaded
await config.ensureEnvLoaded();
console.log("Environment variables loaded and verified");
} catch (error) {
console.warn("Failed to load environment variables, using defaults:", error);
}
// 1. Load saved state or defaults
state.loadData();
// Setup Flic action handlers early in the initialization process
// to ensure they're available when the service worker initializes
const flicActionHandlers = {
[config.FLIC_ACTIONS.SINGLE_CLICK]: playerManager.nextPlayer,
[config.FLIC_ACTIONS.DOUBLE_CLICK]: playerManager.previousPlayer,
[config.FLIC_ACTIONS.HOLD]: gameActions.togglePauseResume,
};
// Log the registered handlers for debugging
console.log("Registering Flic action handlers:", {
"SingleClick": config.FLIC_ACTIONS.SINGLE_CLICK,
"DoubleClick": config.FLIC_ACTIONS.DOUBLE_CLICK,
"Hold": config.FLIC_ACTIONS.HOLD
});
serviceWorkerManager.setFlicActionHandlers(flicActionHandlers);
// 2. Initialize UI (pass carousel swipe handler)
ui.initUI({
onCarouselSwipe: (direction) => {
if (direction > 0) playerManager.nextPlayer(); else playerManager.previousPlayer();
}
});
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
timer.initTimer({
onTimerTick: eventHandlers.handleTimerTick,
onPlayerSwitch: eventHandlers.handlePlayerSwitchOnTimer,
onGameOver: gameActions.handleGameOver
});
// 4. Initialize Camera (pass elements and capture callback)
camera.init(
{ // Pass relevant DOM elements
cameraContainer: ui.elements.cameraContainer,
cameraView: ui.elements.cameraView,
cameraCanvas: ui.elements.cameraCanvas,
cameraCaptureButton: ui.elements.cameraCaptureButton,
cameraCancelButton: ui.elements.cameraCancelButton
},
{ // Pass options/callbacks
onCapture: eventHandlers.handleCameraCapture
}
);
// 5. Set up UI Event Listeners that trigger actions
ui.elements.gameButton.addEventListener('click', eventHandlers.handleGameButtonClick);
ui.elements.setupButton.addEventListener('click', eventHandlers.handleSetupButtonClick);
ui.elements.addPlayerButton.addEventListener('click', eventHandlers.handleAddPlayerButtonClick);
ui.elements.resetButton.addEventListener('click', eventHandlers.handleResetButtonClick);
ui.elements.playerForm.addEventListener('submit', playerManager.handlePlayerFormSubmit);
ui.elements.cancelButton.addEventListener('click', eventHandlers.handlePlayerModalCancel);
ui.elements.deletePlayerButton.addEventListener('click', playerManager.handleDeletePlayer);
ui.elements.resetConfirmButton.addEventListener('click', eventHandlers.handleResetConfirm);
ui.elements.resetCancelButton.addEventListener('click', eventHandlers.handleResetCancel);
ui.elements.cameraButton.addEventListener('click', eventHandlers.handleCameraButtonClick);
// 6. Initialize Push Notification Settings UI
pushSettingsUI.initPushSettingsUI();
// 7. Setup Service Worker (which also initializes Flic)
serviceWorkerManager.setupServiceWorker(serviceWorkerManager.flicMessageHandler);
// 8. Initialize Screen Lock Manager (automatically acquires wake lock)
const screenLockSupported = await screenLockManager.initScreenLockManager();
console.log(`Screen Wake Lock API ${screenLockSupported ? 'is' : 'is not'} supported`);
// 9. Initial UI Update based on loaded state
ui.renderPlayers();
ui.updateGameButton();
// 10. Reset running state to paused on load
if (state.getGameState() === config.GAME_STATES.RUNNING) {
console.log("Game was running on load, setting to paused.");
state.setGameState(config.GAME_STATES.PAUSED);
ui.updateGameButton();
ui.renderPlayers();
}
console.log("App Initialized.");
}
// --- Start the application ---
// We need to use an async IIFE to await the async initialize function
(async () => {
try {
await initialize();
} catch (error) {
console.error("Error initializing application:", error);
}
})();

67
js/config.js Normal file
View File

@@ -0,0 +1,67 @@
// config.js
import { getEnv, waitForEnv } from './env-loader.js';
// Initialize environment variables
let envInitialized = false;
let initPromise = null;
// Function to ensure environment variables are loaded
export async function ensureEnvLoaded() {
if (envInitialized) return;
if (!initPromise) {
initPromise = waitForEnv().then(() => {
envInitialized = true;
console.log('Environment variables loaded in config.js');
});
}
return initPromise;
}
// Initialize immediately
ensureEnvLoaded();
// Direct access to environment variables (synchronous, may return default values if called too early)
export function getPublicVapidKey() {
return getEnv('PUBLIC_VAPID_KEY');
}
export function getBackendUrl() {
return getEnv('BACKEND_URL');
}
export const FLIC_BUTTON_ID = 'game-button'; // Example ID, might need configuration
export const LOCAL_STORAGE_KEY = 'gameTimerData';
// Default player settings
export const DEFAULT_PLAYER_TIME_SECONDS = 300; // 5 minutes
export const DEFAULT_PLAYERS = [
{ id: 1, name: 'Player 1', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null },
{ id: 2, name: 'Player 2', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null }
];
// CSS Classes (optional, but can help consistency)
export const CSS_CLASSES = {
ACTIVE_PLAYER: 'active-player',
INACTIVE_PLAYER: 'inactive-player',
TIMER_ACTIVE: 'timer-active',
TIMER_FINISHED: 'timer-finished',
MODAL_ACTIVE: 'active',
CAMERA_ACTIVE: 'active'
};
// Game States
export const GAME_STATES = {
SETUP: 'setup',
RUNNING: 'running',
PAUSED: 'paused',
OVER: 'over'
};
// Flic Actions
export const FLIC_ACTIONS = {
SINGLE_CLICK: 'SingleClick',
DOUBLE_CLICK: 'DoubleClick',
HOLD: 'Hold'
};

92
js/core/eventHandlers.js Normal file
View File

@@ -0,0 +1,92 @@
// eventHandlers.js - UI event handlers
import * as config from '../config.js';
import * as state from './state.js';
import * as ui from '../ui/ui.js';
import audioManager from '../ui/audio.js';
import camera from '../ui/camera.js';
import { togglePauseResume, fullResetApp } from './gameActions.js';
import { handlePlayerFormSubmit, handleDeletePlayer } from './playerManager.js';
export function handleGameButtonClick() {
audioManager.play('buttonClick');
togglePauseResume();
}
export function handleSetupButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before editing players.');
return;
}
const currentPlayer = state.getCurrentPlayer();
if (!currentPlayer) {
console.warn("Edit clicked but no current player?");
return; // Or show Add Player modal?
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(false, currentPlayer);
}
export function handleAddPlayerButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before adding players.');
return;
}
camera.stopStream(); // Ensure camera is off before opening modal
ui.showPlayerModal(true);
}
export function handleResetButtonClick() {
audioManager.play('buttonClick');
if (state.getGameState() === config.GAME_STATES.RUNNING) {
alert('Please pause the game before resetting.');
return;
}
ui.showResetModal();
}
export function handlePlayerModalCancel() {
audioManager.play('buttonClick');
ui.hidePlayerModal();
camera.stopStream(); // Make sure camera turns off
}
export function handleResetConfirm() {
audioManager.play('buttonClick');
fullResetApp();
}
export function handleResetCancel() {
audioManager.play('buttonClick');
ui.hideResetModal();
}
export function handleCameraButtonClick(event) {
event.preventDefault(); // Prevent form submission if inside form
audioManager.play('buttonClick');
camera.open(); // Open the camera interface
}
// --- Timer Callbacks ---
export function handleTimerTick() {
// Timer module already updated the state, just need to redraw UI
ui.renderPlayers();
}
export function handlePlayerSwitchOnTimer(newPlayerIndex) {
// Timer detected current player ran out, found next player
console.log(`Timer switching to player index: ${newPlayerIndex}`);
// Import switchToPlayer dynamically to avoid circular dependency
import('./playerManager.js').then(module => {
module.switchToPlayer(newPlayerIndex);
});
// Sound is handled in switchToPlayer
}
// --- Camera Callback ---
export function handleCameraCapture(imageDataUrl) {
console.log("Image captured");
ui.updateImagePreviewFromDataUrl(imageDataUrl);
// Camera module already closed the camera UI
}

134
js/core/gameActions.js Normal file
View File

@@ -0,0 +1,134 @@
// gameActions.js - Core game action functions
import * as config from '../config.js';
import * as state from './state.js';
import * as ui from '../ui/ui.js';
import * as timer from './timer.js';
import audioManager from '../ui/audio.js';
import * as screenLockManager from '../services/screenLockManager.js'; // Import screen lock manager
// --- Core Game Actions ---
// Declare handleGameOver at the top level to avoid referencing before definition
export function handleGameOver() {
state.setGameState(config.GAME_STATES.OVER);
audioManager.play('gameOver');
timer.stopTimer(); // Ensure timer is stopped
// Release screen wake lock when game is over
screenLockManager.releaseWakeLock().then(success => {
if (success) {
console.log('Screen wake lock released on game over');
}
});
ui.updateGameButton();
ui.renderPlayers(); // Update to show final state
}
export function startGame() {
if (state.getPlayers().length < 2) {
alert('You need at least 2 players to start.');
return;
}
if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) {
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameStart');
timer.startTimer();
// Acquire screen wake lock when game starts
screenLockManager.acquireWakeLock().then(success => {
if (success) {
console.log('Screen wake lock acquired for game');
} else {
console.warn('Failed to acquire screen wake lock');
}
});
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
export function pauseGame() {
if (state.getGameState() === config.GAME_STATES.RUNNING) {
state.setGameState(config.GAME_STATES.PAUSED);
audioManager.play('gamePause');
timer.stopTimer();
// Release screen wake lock when game is paused
screenLockManager.releaseWakeLock().then(success => {
if (success) {
console.log('Screen wake lock released on pause');
}
});
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is removed
}
}
export function resumeGame() {
if (state.getGameState() === config.GAME_STATES.PAUSED) {
// Check if there's actually a player with time left
if (state.findNextPlayerWithTime() === -1) {
console.log("Cannot resume, no players have time left.");
// Optionally set state to OVER here
handleGameOver();
return;
}
state.setGameState(config.GAME_STATES.RUNNING);
audioManager.play('gameResume');
timer.startTimer();
ui.updateGameButton();
ui.renderPlayers(); // Ensure active timer styling is applied
}
}
export function togglePauseResume() {
const currentGameState = state.getGameState();
if (currentGameState === config.GAME_STATES.RUNNING) {
pauseGame();
} else if (currentGameState === config.GAME_STATES.PAUSED) {
resumeGame();
} else if (currentGameState === config.GAME_STATES.SETUP) {
startGame();
} else if (currentGameState === config.GAME_STATES.OVER) {
resetGame(); // Or just go back to setup? Let's reset.
startGame();
}
}
export function resetGame() {
timer.stopTimer(); // Stop timer if running/paused
state.resetPlayersTime();
state.setGameState(config.GAME_STATES.SETUP);
state.setCurrentPlayerIndex(0); // Go back to first player
// Release screen wake lock when game is reset
screenLockManager.releaseWakeLock().then(success => {
if (success) {
console.log('Screen wake lock released on reset');
}
});
audioManager.play('buttonClick'); // Or a specific reset sound?
ui.updateGameButton();
ui.renderPlayers();
}
export function fullResetApp() {
timer.stopTimer();
state.resetToDefaults();
// Release screen wake lock on full reset
screenLockManager.releaseWakeLock().then(success => {
if (success) {
console.log('Screen wake lock released on full reset');
}
});
audioManager.play('gameOver'); // Use game over sound for full reset
ui.hideResetModal();
ui.updateGameButton();
ui.renderPlayers();
}

154
js/core/playerManager.js Normal file
View File

@@ -0,0 +1,154 @@
// playerManager.js - Player-related operations
import * as config from '../config.js';
import * as state from './state.js';
import * as ui from '../ui/ui.js';
import * as timer from './timer.js';
import audioManager from '../ui/audio.js';
import camera from '../ui/camera.js';
export function switchToPlayer(index) {
if (index >= 0 && index < state.getPlayers().length) {
const previousIndex = state.getCurrentPlayerIndex();
if(index !== previousIndex) {
state.setCurrentPlayerIndex(index);
audioManager.play('playerSwitch');
ui.renderPlayers(); // Update UI immediately
// If the game is running, restart the timer for the new player
// The timer interval callback will handle the decrementing
if (state.getGameState() === config.GAME_STATES.RUNNING) {
timer.startTimer(); // This clears the old interval and starts anew
}
}
}
}
export function nextPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if(playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("NextPlayer: No other player has time remaining.");
// Optionally handle game over immediately? Timer logic should catch this too.
}
}
export function previousPlayer() {
const currentGameState = state.getGameState();
let newIndex = -1;
if (currentGameState === config.GAME_STATES.RUNNING) {
newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time
} else {
// Allow cycling through all players if not running
const playerCount = state.getPlayers().length;
if (playerCount > 0) {
newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount;
}
}
if (newIndex !== -1) {
switchToPlayer(newIndex);
} else if (currentGameState === config.GAME_STATES.RUNNING) {
console.log("PreviousPlayer: No other player has time remaining.");
}
}
export function handlePlayerFormSubmit(event) {
event.preventDefault();
audioManager.play('buttonClick');
const name = ui.elements.playerNameInput.value.trim();
const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10);
let remainingTimeSeconds = 0; // Default
const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player';
const currentGameState = state.getGameState();
if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) {
alert('Please enter a valid name and positive time.');
return;
}
// Get remaining time ONLY if editing and game is paused/over
if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) {
const remainingTimeString = ui.elements.playerRemainingTimeInput.value;
const parsedSeconds = ui.parseTimeString(remainingTimeString);
if (parsedSeconds === null) { // Check if parsing failed
alert('Please enter remaining time in MM:SS format (e.g., 05:30).');
return;
}
remainingTimeSeconds = parsedSeconds;
// Validate remaining time against total time? Optional.
if (remainingTimeSeconds > timeInMinutes * 60) {
alert('Remaining time cannot be greater than the total time.');
return;
}
} else {
// For new players or when editing in setup, remaining time matches total time
remainingTimeSeconds = timeInMinutes * 60;
}
let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null;
const imageFile = ui.elements.playerImageInput.files[0];
const saveAction = (finalImageData) => {
if (isNewPlayer) {
state.addPlayer(name, timeInMinutes, finalImageData);
audioManager.play('playerAdded');
} else {
const playerIndex = state.getCurrentPlayerIndex();
// Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one
const imageArg = finalImageData !== null ? finalImageData : (isNewPlayer ? null : undefined);
state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg);
audioManager.play('playerEdited');
}
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count changed for setup state
camera.stopStream(); // Ensure camera is stopped
};
if (!imageDataUrl && imageFile) {
// Handle file upload: Read file as Data URL
const reader = new FileReader();
reader.onload = (e) => saveAction(e.target.result);
reader.onerror = (e) => {
console.error("Error reading image file:", e);
alert("Error processing image file.");
};
reader.readAsDataURL(imageFile);
} else {
// Handle captured image or no image change
const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image;
// If imageDataUrl has content (from camera), use it.
// If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later).
// If it's a new player and no image, pass null.
saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage));
}
}
export function handleDeletePlayer() {
audioManager.play('buttonClick');
const success = state.deletePlayer(state.getCurrentPlayerIndex());
if (success) {
audioManager.play('playerDeleted');
ui.hidePlayerModal();
ui.renderPlayers();
ui.updateGameButton(); // Update in case player count dropped below 2
} else {
alert('Cannot delete player. Minimum of 2 players required.');
}
camera.stopStream();
}

230
js/core/state.js Normal file
View File

@@ -0,0 +1,230 @@
// state.js
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
let players = [];
let currentPlayerIndex = 0;
let gameState = GAME_STATES.SETUP;
// --- State Accessors ---
export function getPlayers() {
return [...players]; // Return a copy to prevent direct mutation
}
export function getCurrentPlayer() {
if (players.length === 0) return null;
return players[currentPlayerIndex];
}
export function getPlayerById(id) {
return players.find(p => p.id === id);
}
export function getCurrentPlayerIndex() {
return currentPlayerIndex;
}
export function getGameState() {
return gameState;
}
// --- State Mutators ---
export function setPlayers(newPlayers) {
players = newPlayers;
saveData();
}
export function setCurrentPlayerIndex(index) {
if (index >= 0 && index < players.length) {
currentPlayerIndex = index;
saveData();
} else {
console.error(`Invalid player index: ${index}`);
}
}
export function setGameState(newState) {
if (Object.values(GAME_STATES).includes(newState)) {
gameState = newState;
saveData();
} else {
console.error(`Invalid game state: ${newState}`);
}
}
export function updatePlayerTime(index, remainingTime) {
if (index >= 0 && index < players.length) {
players[index].remainingTime = Math.max(0, remainingTime); // Ensure time doesn't go below 0
saveData(); // Save data whenever time updates
}
}
export function addPlayer(name, timeInMinutes, image = null) {
const timeInSeconds = timeInMinutes * 60;
const newId = Date.now();
players.push({
id: newId,
name: name,
timeInSeconds: timeInSeconds,
remainingTime: timeInSeconds,
image: image
});
currentPlayerIndex = players.length - 1; // Focus new player
saveData();
return players[players.length - 1]; // Return the newly added player
}
export function updatePlayer(index, name, timeInMinutes, remainingTimeSeconds, image) {
if (index >= 0 && index < players.length) {
const player = players[index];
const timeInSeconds = timeInMinutes * 60;
player.name = name;
player.timeInSeconds = timeInSeconds;
// Update remaining time carefully based on game state
if (gameState === GAME_STATES.SETUP) {
player.remainingTime = timeInSeconds;
} else if (gameState === GAME_STATES.PAUSED || gameState === GAME_STATES.OVER) {
// Allow direct setting of remaining time only when paused or over
player.remainingTime = remainingTimeSeconds;
}
// If running, remaining time is managed by the timer, don't override here unless intended
if (image !== undefined) { // Allow updating image (null means remove image)
player.image = image;
}
saveData();
return player;
}
return null;
}
export function deletePlayer(index) {
if (players.length <= 2) {
console.warn('Cannot delete player, minimum 2 players required.');
return false; // Indicate deletion failed
}
if (index >= 0 && index < players.length) {
players.splice(index, 1);
if (currentPlayerIndex >= players.length) {
currentPlayerIndex = players.length - 1;
} else if (currentPlayerIndex > index) {
// Adjust index if deleting someone before the current player
// No adjustment needed if deleting current or after current
}
saveData();
return true; // Indicate success
}
return false; // Indicate deletion failed
}
export function resetPlayersTime() {
players.forEach(player => {
player.remainingTime = player.timeInSeconds;
});
saveData();
}
export function resetToDefaults() {
// Deep copy default players to avoid modifying the constant
players = JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
gameState = GAME_STATES.SETUP;
currentPlayerIndex = 0;
saveData();
}
export function areAllTimersFinished() {
return players.every(player => player.remainingTime <= 0);
}
// Returns the index of the next player with time > 0, or -1 if none
export function findNextPlayerWithTime() {
if (players.length === 0) return -1;
const startIndex = (currentPlayerIndex + 1) % players.length;
let index = startIndex;
do {
if (players[index].remainingTime > 0) {
return index;
}
index = (index + 1) % players.length;
} while (index !== startIndex);
// Check current player last if no others found
if(players[currentPlayerIndex].remainingTime > 0) {
return currentPlayerIndex;
}
return -1; // No player has time left
}
// Find next player with time in specified direction (1 for next, -1 for prev)
export function findNextPlayerWithTimeCircular(direction) {
if (players.length === 0) return -1;
let index = currentPlayerIndex;
for (let i = 0; i < players.length; i++) {
index = (index + direction + players.length) % players.length;
if (players[index]?.remainingTime > 0) { // Check if player exists and has time
return index;
}
}
// If no other player found, check if current player has time (only relevant if direction search fails)
if (players[currentPlayerIndex]?.remainingTime > 0) {
return currentPlayerIndex;
}
return -1; // No player has time left
}
// --- Persistence ---
export function saveData() {
const dataToSave = {
players,
gameState,
currentPlayerIndex
};
try {
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dataToSave));
} catch (error) {
console.error("Error saving data to localStorage:", error);
// Maybe notify the user that settings won't be saved
}
}
export function loadData() {
const savedData = localStorage.getItem(LOCAL_STORAGE_KEY);
if (savedData) {
try {
const parsedData = JSON.parse(savedData);
players = parsedData.players || JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
gameState = parsedData.gameState || GAME_STATES.SETUP;
currentPlayerIndex = parsedData.currentPlayerIndex || 0;
// Basic validation/migration if needed
if (currentPlayerIndex >= players.length) {
currentPlayerIndex = 0;
}
// Ensure all players have necessary properties
players = players.map(p => ({
id: p.id || Date.now() + Math.random(), // Ensure ID exists
name: p.name || 'Player',
timeInSeconds: p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS,
remainingTime: p.remainingTime !== undefined ? p.remainingTime : (p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS),
image: p.image || null
}));
} catch (error) {
console.error("Error parsing data from localStorage:", error);
resetToDefaults(); // Reset to defaults if stored data is corrupt
}
} else {
resetToDefaults(); // Use defaults if no saved data
}
// No saveData() here, loadData just loads the state
}

104
js/core/timer.js Normal file
View File

@@ -0,0 +1,104 @@
// timer.js
import * as state from './state.js';
import { GAME_STATES } from '../config.js';
import audioManager from '../ui/audio.js';
let timerInterval = null;
let onTimerTickCallback = null; // Callback for UI updates
let onPlayerSwitchCallback = null; // Callback for when player switches due to time running out
let onGameOverCallback = null; // Callback for when all players run out of time
let timeExpiredFlagsById = new Map(); // Track which players have had their timeout sound played
export function initTimer(options) {
onTimerTickCallback = options.onTimerTick;
onPlayerSwitchCallback = options.onPlayerSwitch;
onGameOverCallback = options.onGameOver;
timeExpiredFlagsById.clear(); // Reset flags on init
}
export function startTimer() {
if (timerInterval) clearInterval(timerInterval); // Clear existing interval if any
// Stop any previous sounds (like low time warning) before starting fresh
audioManager.stopAllSounds();
// Reset the expired sound flags when starting a new timer
timeExpiredFlagsById.clear();
timerInterval = setInterval(() => {
const currentPlayerIndex = state.getCurrentPlayerIndex();
const currentPlayer = state.getCurrentPlayer(); // Get player data after index
if (!currentPlayer) {
console.warn("Timer running but no current player found.");
stopTimer();
return;
}
// Only decrease time if the current player has time left
if (currentPlayer.remainingTime > 0) {
const newTime = currentPlayer.remainingTime - 1;
state.updatePlayerTime(currentPlayerIndex, newTime); // Update state
// Play timer sounds - ensure we're not leaking audio resources
audioManager.playTimerSound(newTime);
// Notify UI to update
if (onTimerTickCallback) onTimerTickCallback();
} else { // Current player's time just hit 0 or was already 0
// Ensure time is exactly 0 if it somehow went negative
if(currentPlayer.remainingTime < 0) {
state.updatePlayerTime(currentPlayerIndex, 0);
}
// Play time expired sound (only once per player per game)
if (!timeExpiredFlagsById.has(currentPlayer.id)) {
audioManager.playTimerExpired();
timeExpiredFlagsById.set(currentPlayer.id, true);
}
// Check if the game should end or switch player
if (state.areAllTimersFinished()) {
stopTimer();
if (onGameOverCallback) onGameOverCallback();
} else {
// Find the *next* player who still has time
const nextPlayerIndex = state.findNextPlayerWithTime(); // This finds ANY player with time
if (nextPlayerIndex !== -1 && nextPlayerIndex !== currentPlayerIndex) {
// Switch player and ensure we stop any sounds from current player
audioManager.stopTimerSounds(); // Stop specific timer sounds before switching
if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
// Immediately update UI after switch
if (onTimerTickCallback) onTimerTickCallback();
} else if (nextPlayerIndex === -1) {
// This case shouldn't be reached if areAllTimersFinished is checked first, but as a safeguard:
console.warn("Timer tick: Current player out of time, but no next player found, yet not all timers finished?");
stopTimer(); // Stop timer if state is inconsistent
if (onGameOverCallback) onGameOverCallback(); // Treat as game over
}
// If nextPlayerIndex is the same as currentPlayerIndex, means others are out of time, let this timer continue
}
}
}, 1000);
}
export function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
// Stop all timer-related sounds to prevent them from continuing to play
audioManager.stopTimerSounds();
}
export function isTimerRunning() {
return timerInterval !== null;
}
// Clean up resources when the application is closing or component unmounts
export function cleanup() {
stopTimer();
timeExpiredFlagsById.clear();
audioManager.stopAllSounds();
}

87
js/env-loader.js Normal file
View File

@@ -0,0 +1,87 @@
// env-loader.js
// This module is responsible for loading environment variables for the PWA
// Store environment variables in a global object
window.ENV_CONFIG = {
// Default values that will be overridden when config.env.js loads
PUBLIC_VAPID_KEY: 'your_public_vapid_key_here',
BACKEND_URL: 'https://your-push-server.example.com'
};
// Function to load environment variables from config.env.js
async function loadEnvVariables() {
try {
// Try to fetch the config.env.js file which will be generated at build time or deployment
const response = await fetch('/config.env.js');
if (!response.ok) {
console.warn('Could not load config.env.js file. Using default values.');
return;
}
const configText = await response.text();
// Extract the configuration object from the JavaScript file
// The file should be in format: window.ENV_CONFIG = { key: "value" };
try {
// Create a safe way to evaluate the config file
const configScript = document.createElement('script');
configScript.textContent = configText;
document.head.appendChild(configScript);
document.head.removeChild(configScript);
console.log('Environment variables loaded successfully from config.env.js');
// Dispatch an event to notify that environment variables have been loaded
window.dispatchEvent(new CustomEvent('env-config-loaded', {
detail: { config: window.ENV_CONFIG }
}));
} catch (parseError) {
console.error('Error parsing config.env.js:', parseError);
}
} catch (error) {
console.error('Error loading environment variables:', error);
}
}
// Export function to initialize environment variables
export async function initEnv() {
await loadEnvVariables();
return window.ENV_CONFIG;
}
// Start loading environment variables immediately
initEnv();
// Export access functions for environment variables
export function getEnv(key, defaultValue = '') {
return window.ENV_CONFIG[key] || defaultValue;
}
// Export a function to wait for environment variables to be loaded
export function waitForEnv() {
return new Promise((resolve) => {
// If we already have non-default values, resolve immediately
if (window.ENV_CONFIG.BACKEND_URL !== 'https://your-push-server.example.com') {
resolve(window.ENV_CONFIG);
return;
}
// Otherwise, wait for the env-config-loaded event
window.addEventListener('env-config-loaded', (event) => {
resolve(event.detail.config);
}, { once: true });
// Set a timeout to resolve with current values if loading takes too long
setTimeout(() => {
console.warn('Environment loading timed out, using current values');
resolve(window.ENV_CONFIG);
}, 3000);
});
}
export default {
initEnv,
getEnv,
waitForEnv
};

View File

@@ -0,0 +1,251 @@
// pushFlicIntegration.js
import { getPublicVapidKey, getBackendUrl, FLIC_BUTTON_ID} from '../config.js';
let pushSubscription = null; // Keep track locally if needed
let actionHandlers = {}; // Store handlers for different Flic actions
// --- Helper Functions ---
// Get stored basic auth credentials or prompt user for them
function getBasicAuthCredentials() {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
try {
const credentials = JSON.parse(storedAuth);
// Check if the credentials are valid
if (credentials.username && credentials.password) {
console.log('Using stored basic auth credentials.');
return credentials;
}
} catch (error) {
console.error('Failed to parse stored credentials:', error);
}
}
// No valid stored credentials found
// The function will return null and the caller should handle prompting if needed
console.log('No valid stored credentials found.');
return null;
}
// Create Basic Auth header string
function createBasicAuthHeader(credentials) {
if (!credentials?.username || !credentials.password) return null;
return 'Basic ' + btoa(`${credentials.username}:${credentials.password}`);
}
// Convert URL-safe base64 string to Uint8Array
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/');
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
// Convert ArrayBuffer to URL-safe Base64 string
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); }
return window.btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// --- Push Subscription Logic ---
async function subscribeToPush() {
const buttonId = FLIC_BUTTON_ID; // Use configured button ID
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
console.error('Push Messaging is not supported.');
alert('Push Notifications are not supported by your browser.');
return;
}
try {
// First request notification permission
console.log('Requesting notification permission...');
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission denied.');
alert('Please enable notifications to link the Flic button.');
return;
}
console.log('Notification permission granted.');
// Get stored credentials but don't prompt
let credentials = getBasicAuthCredentials();
const hasExistingCreds = !!credentials;
console.log('Has existing credentials:', hasExistingCreds);
// No prompting for credentials - user must enter them manually in the UI
if (!credentials) {
console.log('No credentials found. User needs to enter them manually.');
// Just return if no credentials are available
return;
}
const registration = await navigator.serviceWorker.ready;
let existingSubscription = await registration.pushManager.getSubscription();
let needsResubscribe = !existingSubscription;
console.log('Existing subscription found:', !!existingSubscription);
if (existingSubscription) {
const existingKey = existingSubscription.options?.applicationServerKey;
if (!existingKey || arrayBufferToBase64(existingKey) !== getPublicVapidKey()) {
console.log('VAPID key mismatch or missing. Unsubscribing old subscription.');
await existingSubscription.unsubscribe();
existingSubscription = null;
needsResubscribe = true;
} else {
console.log('Existing valid subscription found.');
pushSubscription = existingSubscription; // Store it
}
}
let finalSubscription = existingSubscription;
if (needsResubscribe) {
console.log('Subscribing for push notifications...');
const applicationServerKey = urlBase64ToUint8Array(getPublicVapidKey());
try {
finalSubscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey
});
console.log('New push subscription obtained:', finalSubscription);
pushSubscription = finalSubscription; // Store it
} catch (subscribeError) {
console.error('Error subscribing to push:', subscribeError);
alert(`Failed to subscribe: ${subscribeError.message}`);
return;
}
}
if (!finalSubscription) {
console.error("Failed to obtain a subscription object.");
alert("Could not get subscription details.");
return;
}
await sendSubscriptionToServer(finalSubscription, buttonId);
} catch (error) {
console.error('Error during push subscription:', error);
alert(`Subscription failed: ${error.message}`);
}
}
async function sendSubscriptionToServer(subscription, buttonId) {
console.log(`Sending subscription for button "${buttonId}" to backend...`);
const credentials = getBasicAuthCredentials();
if (!credentials) {
console.log('No credentials found. User needs to enter them manually.');
return;
}
const headers = { 'Content-Type': 'application/json' };
const authHeader = createBasicAuthHeader(credentials);
if (authHeader) headers['Authorization'] = authHeader;
try {
// Add support for handling CORS preflight with credentials
const response = await fetch(`${getBackendUrl()}/subscribe`, {
method: 'POST',
body: JSON.stringify({ button_id: buttonId, subscription: subscription }),
headers: headers,
credentials: 'include' // This ensures credentials are sent with OPTIONS requests too
});
if (response.ok) {
const result = await response.json();
console.log('Subscription sent successfully:', result.message);
// Update the UI to show subscription status as active
const subscriptionStatusElement = document.getElementById('subscriptionStatus');
if (subscriptionStatusElement) {
subscriptionStatusElement.textContent = 'active';
subscriptionStatusElement.className = 'status-active';
// Enable unsubscribe button when subscription is active
const unsubscribeButton = document.getElementById('pushUnsubscribeButton');
if (unsubscribeButton) unsubscribeButton.disabled = false;
// Change subscribe button text to "Re-subscribe"
const resubscribeButton = document.getElementById('pushResubscribeButton');
if (resubscribeButton) resubscribeButton.textContent = 'Re-subscribe';
// Enable simulate button when subscription is active
const simulateButton = document.getElementById('simulateClickButton');
if (simulateButton) simulateButton.disabled = false;
}
// Success alert removed as requested
} else {
let errorMsg = `Server error: ${response.status}`;
if (response.status === 401 || response.status === 403) {
localStorage.removeItem('basicAuthCredentials'); // Clear bad creds
errorMsg = 'Authentication failed. Please try again.';
} else {
try { errorMsg = (await response.json()).message || errorMsg; } catch (e) { /* use default */ }
}
console.error('Failed to send subscription:', errorMsg);
alert(`Failed to save link: ${errorMsg}`);
}
} catch (error) {
console.error('Network error sending subscription:', error);
alert(`Network error: ${error.message}`);
}
}
// --- Flic Action Handling ---
// Called by app.js when a message is received from the service worker
export function handleFlicAction(action, buttonId, timestamp, batteryLevel) {
console.log(`[PushFlic] Received Action: ${action} from Button: ${buttonId} battery: ${batteryLevel}% at ${timestamp}`);
// Ignore actions from buttons other than the configured one
if (buttonId !== FLIC_BUTTON_ID) {
console.warn(`[PushFlic] Ignoring action from unknown button: ${buttonId}`);
return;
}
// Find the registered handler for this action
const handler = actionHandlers[action];
if (handler && typeof handler === 'function') {
console.log(`[PushFlic] Executing handler for ${action}`);
try {
// Execute the handler registered in app.js
handler();
// Log success
console.log(`[PushFlic] Successfully executed handler for ${action}`);
} catch (error) {
console.error(`[PushFlic] Error executing handler for ${action}:`, error);
}
} else {
console.warn(`[PushFlic] No handler registered for action: ${action}. Available handlers:`, Object.keys(actionHandlers));
}
}
// --- Initialization ---
// Initialize PushFlic with action handlers
export function initPushFlic(handlers) {
if (handlers && Object.keys(handlers).length > 0) {
actionHandlers = handlers;
console.log('[PushFlic] Stored action handlers:', Object.keys(actionHandlers));
} else {
console.warn('[PushFlic] No action handlers provided to initPushFlic!');
}
}
// New function to manually trigger the subscription process
export function setupPushNotifications() {
console.log('[PushFlic] Manually triggering push notification setup');
subscribeToPush();
}

View File

@@ -0,0 +1,128 @@
// screenLockManager.js - Manages screen wake lock to prevent screen from turning off
// Uses the Screen Wake Lock API: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Wake_Lock_API
let wakeLock = null;
let isLockEnabled = false;
/**
* Requests a screen wake lock to prevent the screen from turning off
* @returns {Promise<boolean>} - True if wake lock was acquired successfully
*/
export async function acquireWakeLock() {
if (!isScreenWakeLockSupported()) {
console.warn('[ScreenLockManager] Screen Wake Lock API not supported in this browser');
return false;
}
try {
// Release any existing wake lock first
await releaseWakeLock();
// Request a new wake lock
wakeLock = await navigator.wakeLock.request('screen');
isLockEnabled = true;
console.log('[ScreenLockManager] Screen Wake Lock acquired');
// Add event listeners to reacquire the lock when needed
setupWakeLockListeners();
return true;
} catch (error) {
console.error('[ScreenLockManager] Error acquiring wake lock:', error);
isLockEnabled = false;
return false;
}
}
/**
* Releases the screen wake lock if one is active
* @returns {Promise<boolean>} - True if wake lock was released successfully
*/
export async function releaseWakeLock() {
if (!wakeLock) {
return true; // No wake lock to release
}
try {
await wakeLock.release();
wakeLock = null;
isLockEnabled = false;
console.log('[ScreenLockManager] Screen Wake Lock released');
return true;
} catch (error) {
console.error('[ScreenLockManager] Error releasing wake lock:', error);
return false;
}
}
/**
* Checks if the Screen Wake Lock API is supported in this browser
* @returns {boolean} - True if supported
*/
export function isScreenWakeLockSupported() {
return 'wakeLock' in navigator && 'request' in navigator.wakeLock;
}
/**
* Returns the current status of the wake lock
* @returns {boolean} - True if wake lock is currently active
*/
export function isWakeLockActive() {
return isLockEnabled && wakeLock !== null;
}
/**
* Sets up event listeners to reacquire the wake lock when needed
* (e.g., when the page becomes visible again after being hidden)
*/
function setupWakeLockListeners() {
// When the page becomes visible again, reacquire the wake lock
document.addEventListener('visibilitychange', handleVisibilityChange);
// When the screen orientation changes, reacquire the wake lock
if ('screen' in window && 'orientation' in window.screen) {
window.screen.orientation.addEventListener('change', handleOrientationChange);
}
}
/**
* Handles visibility change events to reacquire wake lock when page becomes visible
*/
async function handleVisibilityChange() {
if (isLockEnabled && document.visibilityState === 'visible') {
// Only try to reacquire if we previously had a lock
await acquireWakeLock();
}
}
/**
* Handles orientation change events to reacquire wake lock
*/
async function handleOrientationChange() {
if (isLockEnabled) {
// Some devices may release the wake lock on orientation change
await acquireWakeLock();
}
}
/**
* Initializes the screen lock manager
* @param {Object} options - Configuration options
* @param {boolean} options.autoAcquire - Whether to automatically acquire wake lock on init
* @returns {Promise<boolean>} - True if initialization was successful
*/
export async function initScreenLockManager(options = {}) {
const { autoAcquire = true } = options; // Default to true - automatically acquire on init
// Check for support
const isSupported = isScreenWakeLockSupported();
console.log(`[ScreenLockManager] Screen Wake Lock API ${isSupported ? 'is' : 'is not'} supported`);
// Automatically acquire wake lock if supported (now default behavior)
if (autoAcquire && isSupported) {
return await acquireWakeLock();
}
return isSupported;
}

View File

@@ -0,0 +1,116 @@
// serviceWorkerManager.js - Service worker registration and Flic integration
import * as pushFlic from './pushFlicIntegration.js';
// Store the action handlers passed from app.js
let flicActionHandlers = {};
export function setFlicActionHandlers(handlers) {
if (handlers && Object.keys(handlers).length > 0) {
flicActionHandlers = handlers;
console.log('[ServiceWorkerManager] Stored action handlers:', Object.keys(flicActionHandlers));
// Always pass handlers to pushFlic, regardless of service worker state
pushFlic.initPushFlic(flicActionHandlers);
} else {
console.warn('[ServiceWorkerManager] No action handlers provided to setFlicActionHandlers!');
}
}
// --- Flic Integration Setup ---
export function initFlic() {
// Make sure we have handlers before initializing
if (Object.keys(flicActionHandlers).length === 0) {
console.warn('[ServiceWorkerManager] No Flic handlers registered before initFlic! Actions may not work.');
}
// This function is used by setupServiceWorker and relies on
// flicActionHandlers being set before this is called
console.log('[ServiceWorkerManager] Initializing PushFlic with handlers:', Object.keys(flicActionHandlers));
pushFlic.initPushFlic(flicActionHandlers);
}
// Export functions for manually triggering push notifications setup
export function setupPushNotifications() {
pushFlic.setupPushNotifications();
}
// --- Handle Messages from Service Worker ---
export function flicMessageHandler(event) {
// This function is passed to setupServiceWorker and called when a message arrives from the service worker
console.log('[App] Message received from Service Worker:', event.data);
// Check if this is a Flic action message
if (event.data && event.data.type === 'flic-action') {
const { action, button, timestamp, batteryLevel } = event.data;
try {
// Pass to push-flic service to handle
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
} catch (error) {
console.error('[App] Error handling flic action:', error);
}
}
}
// Global message handler function to ensure we catch all service worker messages
function handleServiceWorkerMessage(event) {
// Check if the message might be from our service worker
if (event.data && typeof event.data === 'object') {
console.log('[App] Potential window message received:', event.data);
// If it looks like a flic action message, handle it
if (event.data.type === 'flic-action') {
try {
// Process the message with our flicMessageHandler
flicMessageHandler(event);
} catch (error) {
console.error('[App] Error handling window message:', error);
}
}
}
}
// --- Service Worker and PWA Setup ---
export function setupServiceWorker(messageHandler) {
if ('serviceWorker' in navigator) {
console.log('[ServiceWorkerManager] Setting up service worker...');
// Set up global message event listener on window object
window.addEventListener('message', handleServiceWorkerMessage);
// Listen for messages FROM the Service Worker
// This is the main way messages from the service worker are received
navigator.serviceWorker.addEventListener('message', event => {
console.log('[ServiceWorkerManager] Service worker message received:', event.data);
messageHandler(event);
});
window.addEventListener('load', () => {
console.log('[ServiceWorkerManager] Window loaded, registering service worker...');
navigator.serviceWorker.register('/sw.js')
.then(registration => {
console.log('[ServiceWorkerManager] ServiceWorker registered successfully.');
// Add an event listener that will work with service worker controlled clients
if (navigator.serviceWorker.controller) {
console.log('[ServiceWorkerManager] Service worker already controlling the page.');
}
// Initialize Flic integration
initFlic();
})
.catch(error => {
console.error('[ServiceWorkerManager] ServiceWorker registration failed:', error);
});
});
// Listen for SW controller changes
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[ServiceWorkerManager] Service Worker controller changed, potentially updated.');
});
} else {
console.warn('[ServiceWorkerManager] ServiceWorker not supported.');
}
}

View File

@@ -302,6 +302,14 @@ const audioManager = {
this.lastTickTime = 0; 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) // Reset the tick fading (call this when timer is paused or player changes)
resetTickFade() { resetTickFade() {
this.lastTickTime = 0; this.lastTickTime = 0;

116
js/ui/camera.js Normal file
View File

@@ -0,0 +1,116 @@
// camera.js
import { CSS_CLASSES } from '../config.js';
let stream = null;
let elements = {}; // To store references to DOM elements passed during init
let onCaptureCallback = null; // Callback when image is captured
export function initCamera(cameraElements, options) {
elements = cameraElements; // Store refs like { cameraContainer, cameraView, etc. }
onCaptureCallback = options.onCapture;
// Add internal listeners for capture/cancel buttons
elements.cameraCaptureButton?.addEventListener('click', handleCapture);
elements.cameraCancelButton?.addEventListener('click', closeCamera);
// Handle orientation change to potentially reset stream dimensions
window.addEventListener('orientationchange', handleOrientationChange);
}
async function openCamera() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert('Camera access not supported or available on this device.');
return false; // Indicate failure
}
try {
stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'user', // Prefer front camera
width: { ideal: 1280 },
height: { ideal: 720 }
}
});
elements.cameraContainer?.classList.add(CSS_CLASSES.CAMERA_ACTIVE);
if (elements.cameraView) {
elements.cameraView.srcObject = stream;
// Wait for video metadata to load to get correct dimensions
elements.cameraView.onloadedmetadata = () => {
elements.cameraView.play(); // Start playing the video stream
};
}
return true; // Indicate success
} catch (error) {
console.error('Error accessing camera:', error);
alert('Could not access camera: ' + error.message);
closeCamera(); // Ensure cleanup if opening failed
return false; // Indicate failure
}
}
function handleCapture() {
if (!elements.cameraView || !elements.cameraCanvas || !stream) return;
// Set canvas dimensions to match video's actual dimensions
elements.cameraCanvas.width = elements.cameraView.videoWidth;
elements.cameraCanvas.height = elements.cameraView.videoHeight;
// Draw the current video frame to the canvas
const context = elements.cameraCanvas.getContext('2d');
// Flip horizontally for front camera to make it mirror-like
if (stream.getVideoTracks()[0]?.getSettings()?.facingMode === 'user') {
context.translate(elements.cameraCanvas.width, 0);
context.scale(-1, 1);
}
context.drawImage(elements.cameraView, 0, 0, elements.cameraCanvas.width, elements.cameraCanvas.height);
// Convert canvas to data URL (JPEG format)
const imageDataUrl = elements.cameraCanvas.toDataURL('image/jpeg', 0.9); // Quality 0.9
// Call the callback provided during init with the image data
if (onCaptureCallback) {
onCaptureCallback(imageDataUrl);
}
// Stop stream and hide UI after capture
closeCamera();
}
function stopCameraStream() {
if (stream) {
stream.getTracks().forEach(track => track.stop());
stream = null;
}
// Also clear the srcObject
if (elements.cameraView) {
elements.cameraView.srcObject = null;
}
}
function closeCamera() {
stopCameraStream();
elements.cameraContainer?.classList.remove(CSS_CLASSES.CAMERA_ACTIVE);
}
function handleOrientationChange() {
// If camera is active, restart stream to potentially adjust aspect ratio/resolution
if (elements.cameraContainer?.classList.contains(CSS_CLASSES.CAMERA_ACTIVE) && stream) {
console.log("Orientation changed, re-evaluating camera stream...");
// Short delay to allow layout to settle
setTimeout(async () => {
// Stop existing stream before requesting new one
// This might cause a flicker but ensures constraints are re-evaluated
stopCameraStream();
await openCamera(); // Attempt to reopen with potentially new constraints
}, 300);
}
}
// Public API for camera module
export default {
init: initCamera,
open: openCamera,
close: closeCamera,
stopStream: stopCameraStream // Expose if needed externally, e.g., when modal closes
};

435
js/ui/pushSettingsUI.js Normal file
View File

@@ -0,0 +1,435 @@
// pushSettingsUI.js - UI handling for push notification settings
import { setupPushNotifications } from '../services/serviceWorkerManager.js';
import { FLIC_BUTTON_ID, getBackendUrl} from '../config.js';
// --- DOM Elements ---
const elements = {
pushSettingsButton: null,
pushSettingsModal: null,
notificationPermissionStatus: null,
subscriptionStatus: null,
pushUsername: null,
pushPassword: null,
pushSaveButton: null,
pushCancelButton: null,
pushUnsubscribeButton: null,
pushResubscribeButton: null,
// Message monitor elements
swMessagesOutput: null,
simulateClickButton: null
};
// --- State ---
let currentSubscription = null;
let messageListener = null;
let isMonitoring = false;
// --- Initialization ---
export function initPushSettingsUI() {
// Cache DOM elements
elements.pushSettingsButton = document.getElementById('pushSettingsButton');
elements.pushSettingsModal = document.getElementById('pushSettingsModal');
elements.notificationPermissionStatus = document.getElementById('notificationPermissionStatus');
elements.subscriptionStatus = document.getElementById('subscriptionStatus');
elements.pushUsername = document.getElementById('pushUsername');
elements.pushPassword = document.getElementById('pushPassword');
elements.pushSaveButton = document.getElementById('pushSaveButton');
elements.pushCancelButton = document.getElementById('pushCancelButton');
elements.pushUnsubscribeButton = document.getElementById('pushUnsubscribeButton');
elements.pushResubscribeButton = document.getElementById('pushResubscribeButton');
// Message Monitor elements
elements.swMessagesOutput = document.getElementById('swMessagesOutput');
elements.simulateClickButton = document.getElementById('simulateClickButton');
// Set up event listeners
elements.pushSettingsButton.addEventListener('click', openPushSettingsModal);
elements.pushCancelButton.addEventListener('click', closePushSettingsModal);
elements.pushSaveButton.addEventListener('click', saveCredentialsAndSubscribe);
elements.pushUnsubscribeButton.addEventListener('click', unsubscribeFromPush);
elements.pushResubscribeButton.addEventListener('click', resubscribeToPush);
// Initial status check
updateNotificationStatus();
}
// --- UI Functions ---
// Open the push settings modal and update statuses
function openPushSettingsModal() {
// Update status displays
updateNotificationStatus();
updateSubscriptionStatus();
// Load saved credentials if available
loadSavedCredentials();
// Start monitoring automatically when modal opens
startMessageMonitoring();
// Show the modal
elements.pushSettingsModal.classList.add('active');
}
// Close the push settings modal
function closePushSettingsModal() {
// Stop monitoring when the modal is closed to avoid unnecessary processing
stopMessageMonitoring();
elements.pushSettingsModal.classList.remove('active');
}
// --- Message Monitor Functions ---
// Start monitoring service worker messages
function startMessageMonitoring() {
// If already monitoring, don't set up a new listener
if (isMonitoring) {
return;
}
if (!('serviceWorker' in navigator)) {
elements.swMessagesOutput.textContent = 'Service Worker not supported in this browser.';
return;
}
// Reset the output area
elements.swMessagesOutput.textContent = 'Monitoring for service worker messages...';
// Create and register the message listener
messageListener = function(event) {
const now = new Date().toISOString();
const formattedMessage = `[${now}] Message received: \n${JSON.stringify(event.data, null, 2)}\n\n`;
elements.swMessagesOutput.textContent += formattedMessage;
// Auto-scroll to the bottom
elements.swMessagesOutput.scrollTop = elements.swMessagesOutput.scrollHeight;
};
// Add the listener
navigator.serviceWorker.addEventListener('message', messageListener);
isMonitoring = true;
}
// Stop monitoring service worker messages
function stopMessageMonitoring() {
if (messageListener) {
navigator.serviceWorker.removeEventListener('message', messageListener);
messageListener = null;
isMonitoring = false;
}
}
// Update the notification permission status display
function updateNotificationStatus() {
if (!('Notification' in window)) {
elements.notificationPermissionStatus.textContent = 'Not Supported';
elements.notificationPermissionStatus.className = 'status-denied';
// Disable subscribe button when notifications are not supported
elements.pushResubscribeButton.disabled = true;
elements.pushResubscribeButton.classList.add('disabled');
return;
}
const permission = Notification.permission;
elements.notificationPermissionStatus.textContent = permission;
switch (permission) {
case 'granted':
elements.notificationPermissionStatus.className = 'status-granted';
// Enable subscribe button when permission is granted
elements.pushResubscribeButton.disabled = false;
elements.pushResubscribeButton.classList.remove('disabled');
break;
case 'denied':
elements.notificationPermissionStatus.className = 'status-denied';
// Disable subscribe button when permission is denied
elements.pushResubscribeButton.disabled = true;
elements.pushResubscribeButton.classList.add('disabled');
break;
default:
elements.notificationPermissionStatus.className = 'status-default';
// Enable subscribe button for default state (prompt)
elements.pushResubscribeButton.disabled = false;
elements.pushResubscribeButton.classList.remove('disabled');
}
}
// Update the subscription status display
async function updateSubscriptionStatus() {
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
elements.subscriptionStatus.textContent = 'Not Supported';
elements.subscriptionStatus.className = 'status-denied';
// Disable unsubscribe button when not supported
elements.pushUnsubscribeButton.disabled = true;
// Set subscribe button text
elements.pushResubscribeButton.textContent = 'Subscribe';
// Disable simulate button when not supported
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = true;
}
return;
}
try {
const registration = await navigator.serviceWorker.ready;
currentSubscription = await registration.pushManager.getSubscription();
if (currentSubscription) {
elements.subscriptionStatus.textContent = 'active';
elements.subscriptionStatus.className = 'status-active';
// Enable unsubscribe button when subscription is active
elements.pushUnsubscribeButton.disabled = false;
// Change subscribe button text to "Re-subscribe"
elements.pushResubscribeButton.textContent = 'Re-subscribe';
// Enable simulate button when subscription is active
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = false;
}
} else {
elements.subscriptionStatus.textContent = 'Not Subscribed';
elements.subscriptionStatus.className = 'status-inactive';
// Disable unsubscribe button when not subscribed
elements.pushUnsubscribeButton.disabled = true;
// Set subscribe button text
elements.pushResubscribeButton.textContent = 'Subscribe';
// Disable simulate button when not subscribed
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = true;
}
}
} catch (error) {
console.error('Error checking subscription status:', error);
elements.subscriptionStatus.textContent = 'Error';
elements.subscriptionStatus.className = 'status-denied';
// Disable unsubscribe button on error
elements.pushUnsubscribeButton.disabled = true;
// Set subscribe button text
elements.pushResubscribeButton.textContent = 'Subscribe';
// Disable simulate button on error
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = true;
}
}
}
// Load saved credentials from localStorage
function loadSavedCredentials() {
try {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
const credentials = JSON.parse(storedAuth);
if (credentials.username && credentials.password) {
elements.pushUsername.value = credentials.username;
elements.pushPassword.value = credentials.password;
}
}
} catch (error) {
console.error('Error loading saved credentials:', error);
}
}
// --- Action Functions ---
// Save credentials and close the modal
async function saveCredentialsAndSubscribe() {
const username = elements.pushUsername.value.trim();
const password = elements.pushPassword.value.trim();
if (!username || !password) {
alert('Please enter both username and password');
return;
}
// Save credentials to localStorage
const credentials = { username, password };
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
// Close the modal
closePushSettingsModal();
}
// Unsubscribe from push notifications
async function unsubscribeFromPush() {
if (!currentSubscription) {
alert('No active subscription to unsubscribe from');
return;
}
try {
await currentSubscription.unsubscribe();
await updateSubscriptionStatus();
// No success alert
} catch (error) {
console.error('Error unsubscribing:', error);
alert(`Error unsubscribing: ${error.message}`);
}
}
// Subscribe to push notifications
async function resubscribeToPush() {
try {
let username = elements.pushUsername.value.trim();
let password = elements.pushPassword.value.trim();
// If fields are empty, try to use stored credentials
if (!username || !password) {
try {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
const credentials = JSON.parse(storedAuth);
if (credentials.username && credentials.password) {
username = credentials.username;
password = credentials.password;
// Update the form fields with stored values
elements.pushUsername.value = username;
elements.pushPassword.value = password;
}
}
} catch (error) {
console.error('Error loading stored credentials:', error);
}
}
// Check if we have credentials, show alert if missing
if (!username || !password) {
console.log('No credentials available. Showing alert.');
alert('Please enter your username and password to subscribe.');
return;
}
// Save the credentials to localStorage
const credentials = { username, password };
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
console.log('Saved credentials to localStorage');
// Use the credentials to subscribe
await setupPushNotifications();
// Wait a moment for the subscription to complete
await new Promise(resolve => setTimeout(resolve, 500));
// Get the updated subscription
const registration = await navigator.serviceWorker.ready;
currentSubscription = await registration.pushManager.getSubscription();
// Update the UI directly
if (currentSubscription && elements.subscriptionStatus) {
elements.subscriptionStatus.textContent = 'active';
elements.subscriptionStatus.className = 'status-active';
// Enable unsubscribe button when subscription is active
elements.pushUnsubscribeButton.disabled = false;
// Change subscribe button text to "Re-subscribe"
elements.pushResubscribeButton.textContent = 'Re-subscribe';
// Enable simulate button when subscription is active
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = false;
}
} else {
// Disable unsubscribe button when not subscribed
elements.pushUnsubscribeButton.disabled = true;
// Set subscribe button text
elements.pushResubscribeButton.textContent = 'Subscribe';
// Disable simulate button when not subscribed
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = true;
}
// Fall back to the standard update function
await updateSubscriptionStatus();
}
} catch (error) {
console.error('Error subscribing:', error);
alert(`Error subscribing: ${error.message}`);
}
}
// Manually trigger sendSubscriptionToServer with the current subscription
export async function sendSubscriptionToServer() {
if (!currentSubscription) {
await updateSubscriptionStatus();
if (!currentSubscription) {
// No alert, just return silently
return;
}
}
// Get stored credentials
let credentials;
try {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
credentials = JSON.parse(storedAuth);
if (!credentials.username || !credentials.password) {
throw new Error('Invalid credentials');
}
} else {
throw new Error('No stored credentials');
}
} catch (error) {
// No alert, just open the modal to let the user set credentials
openPushSettingsModal();
return;
}
// Create Basic Auth header
const createBasicAuthHeader = (creds) => {
return 'Basic ' + btoa(`${creds.username}:${creds.password}`);
};
const headers = {
'Content-Type': 'application/json',
'Authorization': createBasicAuthHeader(credentials)
};
try {
// Make the request to the server
const response = await fetch(`${getBackendUrl()}/subscribe`, {
method: 'POST',
body: JSON.stringify({
button_id: FLIC_BUTTON_ID,
subscription: currentSubscription
}),
headers: headers,
credentials: 'include'
});
if (response.ok) {
const result = await response.json();
// No success alert
// Update the currentSubscription variable
const registration = await navigator.serviceWorker.ready;
currentSubscription = await registration.pushManager.getSubscription();
// Directly update the subscription status element in the DOM
if (currentSubscription && elements.subscriptionStatus) {
elements.subscriptionStatus.textContent = 'active';
elements.subscriptionStatus.className = 'status-active';
// Enable unsubscribe button when subscription is active
elements.pushUnsubscribeButton.disabled = false;
// Change subscribe button text to "Re-subscribe"
elements.pushResubscribeButton.textContent = 'Re-subscribe';
// Enable simulate button when subscription is active
if (elements.simulateClickButton) {
elements.simulateClickButton.disabled = false;
}
}
} else {
let errorMsg = `Server error: ${response.status}`;
if (response.status === 401 || response.status === 403) {
localStorage.removeItem('basicAuthCredentials');
errorMsg = 'Authentication failed. Credentials cleared.';
} else {
try {
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (e) { /* use default */ }
}
// No error alert, just log the error
console.error(`Failed to send subscription: ${errorMsg}`);
}
} catch (error) {
// No error alert, just log the error
console.error(`Network error: ${error.message}`);
}
}

289
js/ui/ui.js Normal file
View File

@@ -0,0 +1,289 @@
// ui.js
import * as state from '../core/state.js';
import { GAME_STATES, CSS_CLASSES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
import audioManager from './audio.js';
// --- DOM Elements ---
export const elements = {
carousel: document.getElementById('carousel'),
gameButton: document.getElementById('gameButton'),
setupButton: document.getElementById('setupButton'),
addPlayerButton: document.getElementById('addPlayerButton'),
resetButton: document.getElementById('resetButton'),
playerModal: document.getElementById('playerModal'),
resetModal: document.getElementById('resetModal'),
playerForm: document.getElementById('playerForm'),
modalTitle: document.getElementById('modalTitle'),
playerNameInput: document.getElementById('playerName'),
playerTimeInput: document.getElementById('playerTime'),
playerImageInput: document.getElementById('playerImage'),
imagePreview: document.getElementById('imagePreview'),
playerTimeContainer: document.getElementById('playerTimeContainer'), // Parent of playerTimeInput
remainingTimeContainer: document.getElementById('remainingTimeContainer'),
playerRemainingTimeInput: document.getElementById('playerRemainingTime'),
deletePlayerButton: document.getElementById('deletePlayerButton'),
cancelButton: document.getElementById('cancelButton'), // Modal cancel
resetCancelButton: document.getElementById('resetCancelButton'),
resetConfirmButton: document.getElementById('resetConfirmButton'),
cameraButton: document.getElementById('cameraButton'),
// Camera elements needed by camera.js, but listed here for central management if desired
cameraContainer: document.getElementById('cameraContainer'),
cameraView: document.getElementById('cameraView'),
cameraCanvas: document.getElementById('cameraCanvas'),
cameraCaptureButton: document.getElementById('cameraCaptureButton'),
cameraCancelButton: document.getElementById('cameraCancelButton'),
// Header buttons container for sound toggle
headerButtons: document.querySelector('.header-buttons')
};
let isDragging = false;
let startX = 0;
let currentX = 0;
let carouselSwipeHandler = null; // To store the bound function for removal
// --- Rendering Functions ---
export function renderPlayers() {
const players = state.getPlayers();
const currentIndex = state.getCurrentPlayerIndex();
const currentGameState = state.getGameState();
elements.carousel.innerHTML = '';
if (players.length === 0) {
// Optionally display a message if there are no players
elements.carousel.innerHTML = '<p style="text-align: center; width: 100%;">Add players to start</p>';
return;
}
players.forEach((player, index) => {
const card = document.createElement('div');
const isActive = index === currentIndex;
card.className = `player-card ${isActive ? CSS_CLASSES.ACTIVE_PLAYER : CSS_CLASSES.INACTIVE_PLAYER}`;
const minutes = Math.floor(player.remainingTime / 60);
const seconds = player.remainingTime % 60;
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
const timerClasses = [];
if (isActive && currentGameState === GAME_STATES.RUNNING) {
timerClasses.push(CSS_CLASSES.TIMER_ACTIVE);
}
if (player.remainingTime <= 0) {
timerClasses.push(CSS_CLASSES.TIMER_FINISHED);
}
card.innerHTML = `
<div class="player-image">
${player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>'}
</div>
<div class="player-name">${player.name}</div>
<div class="player-timer ${timerClasses.join(' ')}">${timeString}</div>
`;
elements.carousel.appendChild(card);
});
updateCarouselPosition();
}
export function updateCarouselPosition() {
const currentIndex = state.getCurrentPlayerIndex();
elements.carousel.style.transform = `translateX(${-100 * currentIndex}%)`;
}
export function updateGameButton() {
const currentGameState = state.getGameState();
switch (currentGameState) {
case GAME_STATES.SETUP:
elements.gameButton.textContent = 'Start Game';
break;
case GAME_STATES.RUNNING:
elements.gameButton.textContent = 'Pause Game';
break;
case GAME_STATES.PAUSED:
elements.gameButton.textContent = 'Resume Game';
break;
case GAME_STATES.OVER:
elements.gameButton.textContent = 'Game Over';
break;
}
// Disable button if less than 2 players in setup
elements.gameButton.disabled = currentGameState === GAME_STATES.SETUP && state.getPlayers().length < 2;
}
// --- Modal Functions ---
export function showPlayerModal(isNewPlayer, player = null) {
const currentGameState = state.getGameState();
if (isNewPlayer) {
elements.modalTitle.textContent = 'Add New Player';
elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
elements.playerTimeInput.value = DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time from config
elements.remainingTimeContainer.style.display = 'none';
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible for new players
elements.imagePreview.innerHTML = '<i class="fas fa-user"></i>';
elements.deletePlayerButton.style.display = 'none';
} else if (player) {
elements.modalTitle.textContent = 'Edit Player';
elements.playerNameInput.value = player.name;
elements.playerTimeInput.value = player.timeInSeconds / 60;
if (currentGameState === GAME_STATES.PAUSED || currentGameState === GAME_STATES.OVER) {
elements.remainingTimeContainer.style.display = 'block';
elements.playerTimeContainer.style.display = 'none'; // Hide Time field when Remaining Time is shown
const minutes = Math.floor(player.remainingTime / 60);
const seconds = player.remainingTime % 60;
elements.playerRemainingTimeInput.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
} else {
elements.remainingTimeContainer.style.display = 'none';
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible otherwise
}
elements.imagePreview.innerHTML = player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>';
elements.deletePlayerButton.style.display = 'block';
}
// Reset file input and captured image data
elements.playerImageInput.value = '';
elements.playerImageInput.dataset.capturedImage = '';
elements.playerModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
audioManager.play('modalOpen');
}
export function hidePlayerModal() {
elements.playerModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
audioManager.play('modalClose');
// Potentially call camera cleanup here if it wasn't done elsewhere
}
export function showResetModal() {
elements.resetModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
audioManager.play('modalOpen');
}
export function hideResetModal() {
elements.resetModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
audioManager.play('modalClose');
}
export function updateImagePreviewFromFile(file) {
if (file) {
const reader = new FileReader();
reader.onload = (event) => {
elements.imagePreview.innerHTML = `<img src="${event.target.result}" alt="Player Preview">`;
// Clear any previously captured image data if a file is selected
elements.playerImageInput.dataset.capturedImage = '';
};
reader.readAsDataURL(file);
}
}
export function updateImagePreviewFromDataUrl(dataUrl) {
elements.imagePreview.innerHTML = `<img src="${dataUrl}" alt="Player Preview">`;
// Store data URL and clear file input
elements.playerImageInput.dataset.capturedImage = dataUrl;
elements.playerImageInput.value = '';
}
// --- Carousel Touch Handling ---
function handleTouchStart(e) {
startX = e.touches[0].clientX;
currentX = startX;
isDragging = true;
// Optional: Add a class to the carousel for visual feedback during drag
elements.carousel.style.transition = 'none'; // Disable transition during drag
}
function handleTouchMove(e) {
if (!isDragging) return;
currentX = e.touches[0].clientX;
const diff = currentX - startX;
const currentIndex = state.getCurrentPlayerIndex();
const currentTranslate = -100 * currentIndex + (diff / elements.carousel.offsetWidth * 100);
elements.carousel.style.transform = `translateX(${currentTranslate}%)`;
}
function handleTouchEnd(e) {
if (!isDragging) return;
isDragging = false;
elements.carousel.style.transition = ''; // Re-enable transition
const diff = currentX - startX;
const threshold = elements.carousel.offsetWidth * 0.1; // 10% swipe threshold
if (Math.abs(diff) > threshold) {
// Call the handler passed during initialization
if (carouselSwipeHandler) {
carouselSwipeHandler(diff < 0 ? 1 : -1); // Pass direction: 1 for next, -1 for prev
}
} else {
// Snap back if swipe wasn't enough
updateCarouselPosition();
}
}
// --- UI Initialization ---
// Add sound toggle button
function createSoundToggleButton() {
const soundButton = document.createElement('button');
soundButton.id = 'soundToggleButton';
soundButton.className = 'header-button';
soundButton.title = 'Toggle Sound';
soundButton.innerHTML = audioManager.muted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
soundButton.addEventListener('click', () => {
const isMuted = audioManager.toggleMute();
soundButton.innerHTML = isMuted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
if (!isMuted) audioManager.play('buttonClick'); // Feedback only when unmuting
});
elements.headerButtons.prepend(soundButton); // Add to the beginning
}
// Parse time string (MM:SS) to seconds - Helper needed for form processing
export function parseTimeString(timeString) {
if (!/^\d{1,2}:\d{2}$/.test(timeString)) {
console.error('Invalid time format:', timeString);
return null; // Indicate error
}
const parts = timeString.split(':');
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
if (isNaN(minutes) || isNaN(seconds) || seconds > 59) {
console.error('Invalid time value:', timeString);
return null;
}
return (minutes * 60) + seconds;
}
// Sets up basic UI elements and listeners that primarily affect the UI itself
export function initUI(options) {
// Store the swipe handler provided by app.js
carouselSwipeHandler = options.onCarouselSwipe;
createSoundToggleButton();
// Carousel touch events
elements.carousel.addEventListener('touchstart', handleTouchStart, { passive: true });
elements.carousel.addEventListener('touchmove', handleTouchMove, { passive: true });
elements.carousel.addEventListener('touchend', handleTouchEnd);
// Image file input preview
elements.playerImageInput.addEventListener('change', (e) => {
const file = e.target.files[0];
if (file) {
updateImagePreviewFromFile(file);
}
});
// Initial render
renderPlayers();
updateGameButton();
}

View File

@@ -1,13 +1,21 @@
# Enable Traefik for this container
traefik.enable=true traefik.enable=true
# Docker Network
traefik.docker.network=traefik traefik.docker.network=traefik
# Route requests based on Host
traefik.http.routers.game-timer.rule=Host(`game-timer.virtonline.eu`) traefik.http.routers.game-timer.rule=Host(`game-timer.virtonline.eu`)
traefik.http.routers.game-timer.service=game-timer # 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=true
traefik.http.routers.game-timer.tls.certResolver=default traefik.http.routers.game-timer.tls.certResolver=default
traefik.http.routers.game-timer.entrypoints=web-secure # 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 traefik.http.services.game-timer.loadbalancer.server.port=80
traefik.http.routers.game-timer.middlewares=game-timer-auth
# Declaring the user list # Declaring the user list
# #
# Note: when used in docker-compose.yml all dollar signs in the hash need to be doubled for escaping. # Note: when used in docker-compose.yml all dollar signs in the hash need to be doubled for escaping.
@@ -18,3 +26,6 @@ traefik.http.routers.game-timer.middlewares=game-timer-auth
# for docker lables use # for docker lables use
# `htpasswd -nb user password` # `htpasswd -nb user password`
traefik.http.middlewares.game-timer-auth.basicauth.users=user:$apr1$rFge2lVe$DpoqxMsxSVJubFLXu4OMr1 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

View File

@@ -1,11 +1,16 @@
{ {
"name": "Game Timer PWA", "name": "Game Timer",
"short_name": "Game Timer", "short_name": "Timer",
"author": "Martin",
"version": "0.0.1",
"version_name": "0.0.1",
"description": "Multi-player chess-like timer with carousel navigation", "description": "Multi-player chess-like timer with carousel navigation",
"start_url": "/index.html", "start_url": "/",
"id": "/index.html",
"display": "standalone", "display": "standalone",
"background_color": "#f5f5f5", "display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"theme_color": "#2c3e50", "background_color": "#ffffff",
"theme_color": "#000000",
"icons": [ "icons": [
{ {
"src": "/icons/android-chrome-192x192.png", "src": "/icons/android-chrome-192x192.png",
@@ -35,66 +40,15 @@
], ],
"screenshots": [ "screenshots": [
{ {
"src": "/screenshots/screenshot1.png", "src": "/images/screenshot1.png",
"sizes": "2604x2269", "sizes": "2212×1614",
"type": "image/png", "type": "image/png",
"form_factor": "wide" "form_factor": "wide"
}, },
{ {
"src": "/screenshots/screenshot2.png", "src": "/images/screenshot2.png",
"sizes": "1082x2402", "sizes": "828×1912",
"type": "image/png" "type": "image/png"
} }
], ]
"url_handlers": [
{
"origin": "https://game-timer.virtonline.eu"
}
],
"handle_links": "preferred",
"file_handlers": [],
"protocol_handlers": [
{
"protocol": "web+gametimer",
"url": "/?action=%s"
}
],
"shortcuts": [
{
"name": "Start Game",
"short_name": "Start",
"description": "Start the game timer",
"url": "/?action=start",
"icons": [{ "src": "/icons/play.png", "sizes": "192x192" }]
},
{
"name": "Pause Game",
"short_name": "Pause",
"description": "Pause the game timer",
"url": "/?action=pause",
"icons": [{ "src": "/icons/pause.png", "sizes": "192x192" }]
},
{
"name": "Toggle Game",
"short_name": "Toggle",
"description": "Toggle game state (start/pause)",
"url": "/?action=toggle",
"icons": [{ "src": "/icons/toggle.png", "sizes": "192x192" }]
},
{
"name": "Next Player",
"short_name": "Next",
"description": "Go to next player",
"url": "/?action=nextplayer",
"icons": [{ "src": "/icons/next.png", "sizes": "192x192" }]
},
{
"name": "Reset Game",
"short_name": "Reset",
"description": "Reset the entire game",
"url": "/?action=reset",
"icons": [{ "src": "/icons/reset.png", "sizes": "192x192" }]
}
],
"gcm_sender_id": "103953800507"
} }

26
package.json Normal file
View File

@@ -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"
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 320 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 416 KiB

View File

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

198
sw.js
View File

@@ -1,26 +1,40 @@
// Service Worker version // Service Worker version
const CACHE_VERSION = 'v1.0.0'; const CACHE_VERSION = 'v1.0.2';
const CACHE_NAME = `game-timer-${CACHE_VERSION}`; const CACHE_NAME = `game-timer-${CACHE_VERSION}`;
// Files to cache // Files to cache
const CACHE_FILES = [ const CACHE_FILES = [
'/', '/',
'/index.html', '/index.html',
'/app.js',
'/audio.js',
'/deeplinks.js',
'/styles.css',
'/manifest.json', '/manifest.json',
'/sw.js',
'/favicon.ico',
'/config.env.js',
'/css/styles.css',
'/icons/android-chrome-192x192.png', '/icons/android-chrome-192x192.png',
'/icons/android-chrome-512x512.png', '/icons/android-chrome-512x512.png',
'/icons/apple-touch-icon.png', '/icons/apple-touch-icon.png',
'/icons/favicon-32x32.png', '/icons/favicon-32x32.png',
'/icons/favicon-16x16.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'
]; ];
// Valid deep link actions
const VALID_ACTIONS = ['start', 'pause', 'toggle', 'nextplayer', 'reset'];
// Install event - Cache files // Install event - Cache files
self.addEventListener('install', event => { self.addEventListener('install', event => {
console.log('[ServiceWorker] Install'); console.log('[ServiceWorker] Install');
@@ -56,53 +70,11 @@ self.addEventListener('activate', event => {
); );
}); });
// Fetch event - Serve from cache, fallback to network self.addEventListener('fetch', (event) => {
self.addEventListener('fetch', event => { console.log('[ServiceWorker] Fetch');
console.log('[ServiceWorker] Fetch', event.request.url);
// For navigation requests that include our deep link parameters,
// skip the cache and go straight to network
if (event.request.mode === 'navigate') {
const url = new URL(event.request.url);
// Check if request has action parameter or hash
if (url.searchParams.has('action') || url.hash.includes('action=')) {
console.log('[ServiceWorker] Processing deep link navigation');
// Verify the action is valid
const action = url.searchParams.get('action') ||
new URLSearchParams(url.hash.substring(1)).get('action');
if (action && VALID_ACTIONS.includes(action)) {
console.log('[ServiceWorker] Valid action found:', action);
// For navigation requests with valid actions, let the request go through
// so our app can handle the deep link
return;
}
}
}
event.respondWith( event.respondWith(
caches.match(event.request) caches.match(event.request).then((cachedResponse) => {
.then(response => { return cachedResponse || fetch(event.request);
return response || fetch(event.request)
.then(res => {
// Check if we should cache this response
if (shouldCacheResponse(event.request, res)) {
return caches.open(CACHE_NAME)
.then(cache => {
console.log('[ServiceWorker] Caching new resource:', event.request.url);
cache.put(event.request, res.clone());
return res;
});
}
return res;
});
})
.catch(error => {
console.log('[ServiceWorker] Fetch failed; returning offline page', error);
// You could return a custom offline page here
}) })
); );
}); });
@@ -124,37 +96,18 @@ function shouldCacheResponse(request, response) {
return true; return true;
} }
// Handle deep links from other apps (including Flic)
self.addEventListener('message', event => {
if (event.data && event.data.type === 'PROCESS_ACTION') {
const action = event.data.action;
console.log('[ServiceWorker] Received action message:', action);
// Validate the action
if (!VALID_ACTIONS.includes(action)) {
console.warn('[ServiceWorker] Invalid action received:', action);
return;
}
// Broadcast the action to all clients
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'ACTION',
action: action
});
});
});
}
});
self.addEventListener('push', event => { self.addEventListener('push', event => {
console.log('[ServiceWorker] Push received'); console.log('[ServiceWorker] Push received');
let pushData = { let pushData = {
title: 'Flic Action', title: 'Flic Action',
body: 'Button pressed!', body: 'Button pressed!',
data: { action: 'Unknown', button: 'Unknown' } // Default data data: {
action: 'Unknown',
button: 'Unknown',
batteryLevel: undefined,
timestamp: new Date().toISOString()
}
}; };
// --- Attempt to parse data payload --- // --- Attempt to parse data payload ---
@@ -167,10 +120,15 @@ self.addEventListener('push', event => {
pushData = { pushData = {
title: parsedData.title || pushData.title, title: parsedData.title || pushData.title,
body: parsedData.body || pushData.body, body: parsedData.body || pushData.body,
// IMPORTANT: Extract the action details sent from your backend data: parsedData.data || pushData.data
data: parsedData.data || pushData.data // Expecting { action: 'SingleClick', button: '...' }
}; };
// Ensure all required fields are present in data
pushData.data = pushData.data || {};
if (!pushData.data.timestamp) {
pushData.data.timestamp = new Date().toISOString();
}
} catch (e) { } catch (e) {
console.error('[ServiceWorker] Error parsing push data:', e); console.error('[ServiceWorker] Error parsing push data:', e);
// Use default notification if parsing fails // Use default notification if parsing fails
@@ -183,64 +141,58 @@ self.addEventListener('push', event => {
// --- Send message to client(s) --- // --- Send message to client(s) ---
const messagePayload = { const messagePayload = {
type: 'flic-action', // Custom message type type: 'flic-action', // Custom message type
action: pushData.data.action, // e.g., 'SingleClick', 'DoubleClick', 'Hold' action: pushData.data.action || 'Unknown', // e.g., 'SingleClick', 'DoubleClick', 'Hold'
button: pushData.data.button, // e.g., the button name button: pushData.data.button || 'Unknown', // e.g., the button name
timestamp: pushData.data.timestamp // e.g., the timestamp of the action timestamp: pushData.data.timestamp || new Date().toISOString(), // e.g., the timestamp of the action
batteryLevel: pushData.data.batteryLevel // e.g., the battery level percentage
}; };
// Send message to all open PWA windows controlled by this SW console.log('[ServiceWorker] Preparing message payload:', messagePayload);
event.waitUntil( event.waitUntil(
self.clients.matchAll({ self.clients.matchAll({
type: 'window', // Only target window clients type: 'window',
includeUncontrolled: true // Include clients that might not be fully controlled yet includeUncontrolled: true
}).then(clientList => { }).then(clientList => {
if (!clientList || clientList.length === 0) { if (!clientList || clientList.length === 0) {
console.log('[ServiceWorker] No client windows found to send message to.'); // No clients available, just resolve
// If no window is open, we MUST show a notification return Promise.resolve();
return self.registration.showNotification(pushData.title, {
body: pushData.body,
icon: '/icons/icon-192x192.png', // Optional: path to an icon
data: pushData.data // Pass data if needed when notification is clicked
});
} }
// Post message to each client // Post message to each client with improved reliability
let messageSent = false; let messageSent = false;
clientList.forEach(client => { const sendPromises = clientList.map(client => {
console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload); console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload);
try {
// Try to send the message and mark it as sent
client.postMessage(messagePayload); client.postMessage(messagePayload);
messageSent = true; // Mark that we at least tried to send a message 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);
}
}); });
// Decide whether to still show a notification even if a window is open. return Promise.all(sendPromises).then(() => {
// Generally good practice unless you are SURE the app will handle it visibly. // No notifications will be shown for any action, including low battery
// You might choose *not* to show a notification if a client was found and focused. return Promise.resolve();
// For simplicity here, we'll still show one. Adjust as needed.
if (!messageSent) { // Only show notification if no message was sent? Or always show?
return self.registration.showNotification(pushData.title, {
body: pushData.body,
icon: '/icons/icon-192x192.png',
data: pushData.data
}); });
}
}) })
); );
});
// --- Show a notification (Important!) --- // Listen for messages from client
// Push notifications generally REQUIRE showing a notification to the user self.addEventListener('message', event => {
// unless the PWA is already in the foreground AND handles the event visually. const message = event.data;
// It's safer to always show one unless you have complex foreground detection.
/* This part is now handled inside the clients.matchAll promise */ if (!message || typeof message !== 'object') {
/* return;
const notificationOptions = { }
body: pushData.body,
icon: '/icons/icon-192x192.png', // Optional: path to an icon // No battery-related handling
data: pushData.data // Attach data if needed when notification is clicked
};
event.waitUntil(
self.registration.showNotification(pushData.title, notificationOptions)
);
*/
}); });
// This helps with navigation after app is installed // This helps with navigation after app is installed

30
virt-game-timer.service Normal file
View File

@@ -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