Compare commits
24 Commits
main
...
832f19235f
| Author | SHA1 | Date | |
|---|---|---|---|
| 832f19235f | |||
| 5763407edc | |||
| 08632ee711 | |||
| 451a61d357 | |||
| 0414bdb217 | |||
| e14c12ce66 | |||
| 2e47461f34 | |||
| d1ad962ec3 | |||
| 96aeb22210 | |||
| 75b8dc5d15 | |||
| 98ffee7764 | |||
| 429c1dd557 | |||
| 80989a248e | |||
| bade9c0a15 | |||
| 3561db616c | |||
| fc278ed256 | |||
| a0f3489656 | |||
| d959f4929d | |||
| df1e316930 | |||
| 2838df5e05 | |||
| 8a6947f4ea | |||
| 1cfcd628d4 | |||
| 21cb105cd0 | |||
| fdb6e5e618 |
58
.dockerignore
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# Version control
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Node.js
|
||||||
|
node_modules/
|
||||||
|
npm-debug.log
|
||||||
|
yarn-debug.log
|
||||||
|
yarn-error.log
|
||||||
|
Dockerfile
|
||||||
|
|
||||||
|
# Development files
|
||||||
|
.dockerignore
|
||||||
|
.editorconfig
|
||||||
|
.eslintrc
|
||||||
|
.stylelintrc
|
||||||
|
.prettierrc
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# docs/
|
||||||
|
README.md
|
||||||
|
LICENSE
|
||||||
|
CHANGELOG.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
__tests__/
|
||||||
|
test/
|
||||||
|
tests/
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# Build artifacts
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
# We need .env for our application
|
||||||
|
#.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
|
||||||
|
# Project specific files
|
||||||
|
labels.example
|
||||||
|
virt-game-timer.service
|
||||||
|
package.json
|
||||||
|
package-lock.json
|
||||||
12
.env.example
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# Environment Variables Example for Game Timer Application
|
||||||
|
# Copy this file to .env and fill in your own values
|
||||||
|
|
||||||
|
# Public VAPID key for push notifications
|
||||||
|
# Generate your own VAPID keys for production:
|
||||||
|
# https://github.com/web-push-libs/web-push#generatevapidkeys
|
||||||
|
PUBLIC_VAPID_KEY=your_public_vapid_key_here
|
||||||
|
|
||||||
|
# Backend URL for your push notification server
|
||||||
|
BACKEND_URL=https://your-push-server.example.com
|
||||||
|
|
||||||
|
# Other environment variables can be added here
|
||||||
39
.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS specific files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
.docker/
|
||||||
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Use a lightweight server
|
||||||
|
FROM nginx:alpine
|
||||||
|
|
||||||
|
# Set working directory
|
||||||
|
WORKDIR /usr/share/nginx/html
|
||||||
|
|
||||||
|
# Copy all the application files
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Copy the .env file
|
||||||
|
COPY .env .
|
||||||
|
|
||||||
|
# Expose port 80
|
||||||
|
EXPOSE 80
|
||||||
159
README.md
@@ -1,3 +1,158 @@
|
|||||||
# 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 at runtime.
|
||||||
|
|
||||||
|
### Setting Up Environment Variables
|
||||||
|
|
||||||
|
1. Copy `.env.example` to `.env`:
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Edit the `.env` file with your own values:
|
||||||
|
```
|
||||||
|
# Public VAPID key for push notifications
|
||||||
|
PUBLIC_VAPID_KEY=your_public_vapid_key_here
|
||||||
|
|
||||||
|
# Backend URL for push notifications
|
||||||
|
BACKEND_URL=https://your-push-server.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
3. For security, never commit your `.env` file to version control. It's already included in `.gitignore`.
|
||||||
|
|
||||||
|
### Generating VAPID Keys
|
||||||
|
|
||||||
|
For push notifications, you need to generate your own VAPID keys:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx web-push generate-vapid-keys
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the public key in your `.env` file and keep the private key secure for your backend server.
|
||||||
|
|
||||||
|
# PWA Containerized Deployment
|
||||||
|
|
||||||
|
This document provides step-by-step instructions to pull the source code and deploy the Progressive Web App (PWA) using Docker on a production server.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- **Git:** Installed on your production server.
|
||||||
|
- **Docker:** Installed and running on your production server.
|
||||||
|
- **Basic Knowledge:** Familiarity with the command line.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
Log in to your production server and navigate to the directory where you want to store the project. Then run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://gitea.virtonline.eu/2HoursProject/game-timer.git
|
||||||
|
cd game-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Build the Docker image
|
||||||
|
|
||||||
|
From the repository root, run the following command to build your Docker image:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker build -t 'game-timer:latest' .
|
||||||
|
```
|
||||||
|
|
||||||
|
or use the npm script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run docker:build
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Run the Docker Container
|
||||||
|
|
||||||
|
Once the image is built, run the container on port 80 with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d -p 80:80 --name game-timer game-timer:latest
|
||||||
|
```
|
||||||
|
|
||||||
|
or use the npm script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Verify the Deployment
|
||||||
|
|
||||||
|
Check if it's running:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker ps
|
||||||
|
```
|
||||||
|
|
||||||
|
View logs (if needed):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker logs game-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
After running the container, open your web browser and navigate to:
|
||||||
|
|
||||||
|
```
|
||||||
|
http://localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Terminate
|
||||||
|
|
||||||
|
To stop your running game-timer container, use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker stop game-timer
|
||||||
|
docker rm game-timer
|
||||||
|
```
|
||||||
|
|
||||||
|
or use the npm script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
For local development without Docker, you can use any static file server such as:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m http.server
|
||||||
|
```
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx serve
|
||||||
|
```
|
||||||
|
|
||||||
|
This will start a local development server and you can access the application in your browser.
|
||||||
370
css/styles.css
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
color: #333;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-container {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
width: 100%;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-controls {
|
||||||
|
text-align: center;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.game-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-buttons {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button {
|
||||||
|
background-color: transparent;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel-container {
|
||||||
|
margin-top: 70px;
|
||||||
|
margin-bottom: 60px; /* Add some space for the footer */
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
|
touch-action: pan-x;
|
||||||
|
}
|
||||||
|
|
||||||
|
.carousel {
|
||||||
|
display: flex;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
height: calc(100vh - 70px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust the preview image in the modal to maintain consistency */
|
||||||
|
#imagePreview.player-image {
|
||||||
|
width: 180px; /* Slightly smaller than the main display but still larger than original 120px */
|
||||||
|
height: 180px;
|
||||||
|
margin: 0.5rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-card {
|
||||||
|
min-width: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 2rem; /* Increased from 1rem for more spacing */
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-player {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inactive-player {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* New styles for timer active state */
|
||||||
|
.player-timer {
|
||||||
|
font-size: 4rem;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 1rem 0;
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer background effect when game is running */
|
||||||
|
.timer-active {
|
||||||
|
background-color: #ffecee; /* Light red base color */
|
||||||
|
box-shadow: 0 0 15px rgba(231, 76, 60, 0.5);
|
||||||
|
animation: pulsate 1.5s ease-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timer of a player that has run out of time */
|
||||||
|
.timer-finished {
|
||||||
|
color: #e74c3c;
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulsating animation */
|
||||||
|
@keyframes pulsate {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
|
||||||
|
background-color: #ffecee;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow: 0 0 20px 0 rgba(231, 76, 60, 0.5);
|
||||||
|
background-color: #ffe0e0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(231, 76, 60, 0.7);
|
||||||
|
background-color: #ffecee;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-image {
|
||||||
|
width: 240px; /* Doubled from 120px */
|
||||||
|
height: 240px; /* Doubled from 120px */
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #ddd;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 2rem; /* Increased from 1rem */
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2); /* Added shadow for better visual presence */
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-image img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-image i {
|
||||||
|
font-size: 6rem; /* Doubled from 3rem */
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.player-name {
|
||||||
|
font-size: 3rem; /* Doubled from 1.5rem */
|
||||||
|
margin-bottom: 1rem; /* Increased from 0.5rem */
|
||||||
|
font-weight: bold;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 20;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal.active {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: white;
|
||||||
|
padding: 2rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-buttons {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-buttons button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
margin: 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-buttons button:first-child {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-buttons button:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button-container {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
background-color: #27ae60;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cancel-button {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add these styles to your styles.css file */
|
||||||
|
|
||||||
|
.image-input-container {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-button {
|
||||||
|
background-color: #3498db;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-button:hover {
|
||||||
|
background-color: #2980b9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optional: Hide the default file input appearance and use a custom button */
|
||||||
|
input[type="file"] {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
z-index: 30;
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-container.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-view {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-view video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-controls {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-around;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #222;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-button-large {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: #fff;
|
||||||
|
border: 3px solid #3498db;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-button-cancel {
|
||||||
|
background-color: #e74c3c;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-footer {
|
||||||
|
background-color: #2c3e50;
|
||||||
|
color: white;
|
||||||
|
padding: 1rem;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-top: auto; /* This pushes the footer to the bottom when possible */
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
BIN
favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
icons/android-chrome-192x192.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
icons/android-chrome-512x512.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
icons/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
BIN
icons/favicon-16x16.png
Normal file
|
After Width: | Height: | Size: 398 B |
BIN
icons/favicon-32x32.png
Normal file
|
After Width: | Height: | Size: 770 B |
BIN
images/screenshot1.png
Normal file
|
After Width: | Height: | Size: 427 KiB |
BIN
images/screenshot2.png
Normal file
|
After Width: | Height: | Size: 340 KiB |
141
index.html
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="theme-color" content="#2c3e50">
|
||||||
|
<title>Game Timer</title>
|
||||||
|
|
||||||
|
<!-- 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="/css/styles.css">
|
||||||
|
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app-container">
|
||||||
|
<header class="header">
|
||||||
|
<div class="game-controls">
|
||||||
|
<button id="gameButton" class="game-button">Start Game</button>
|
||||||
|
</div>
|
||||||
|
<div class="header-buttons">
|
||||||
|
<button id="resetButton" class="header-button" title="Reset All Data">
|
||||||
|
<i class="fas fa-redo-alt"></i>
|
||||||
|
</button>
|
||||||
|
<button id="addPlayerButton" class="header-button" title="Add New Player">
|
||||||
|
<i class="fas fa-user-plus"></i>
|
||||||
|
</button>
|
||||||
|
<button id="setupButton" class="header-button" title="Setup Current Player">
|
||||||
|
<i class="fas fa-cog"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="carousel-container">
|
||||||
|
<div id="carousel" class="carousel">
|
||||||
|
<!-- Player cards will be dynamically inserted here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Edit Modal -->
|
||||||
|
<div id="playerModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2 id="modalTitle">Edit Player</h2>
|
||||||
|
<form id="playerForm">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playerName">Name</label>
|
||||||
|
<input type="text" id="playerName" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="playerImage">Image</label>
|
||||||
|
<div class="image-input-container">
|
||||||
|
<input type="file" id="playerImage" accept="image/*">
|
||||||
|
<button type="button" id="cameraButton" class="camera-button">
|
||||||
|
<i class="fas fa-camera"></i> Take Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="imagePreview" class="player-image" style="margin-top: 0.5rem;">
|
||||||
|
<i class="fas fa-user"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="playerTimeContainer" class="form-group">
|
||||||
|
<label for="playerTime">Time (minutes)</label>
|
||||||
|
<input type="number" id="playerTime" min="1" max="180" value="5" required>
|
||||||
|
</div>
|
||||||
|
<div id="remainingTimeContainer" class="form-group" style="display: none;">
|
||||||
|
<label for="playerRemainingTime">Remaining Time (MM:SS)</label>
|
||||||
|
<input type="text" id="playerRemainingTime" pattern="[0-9]{2}:[0-9]{2}" placeholder="05:00">
|
||||||
|
<small>Format: Minutes:Seconds (e.g., 05:30)</small>
|
||||||
|
</div>
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="button" id="cancelButton" class="cancel-button">Cancel</button>
|
||||||
|
<button type="submit" class="save-button">Save</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-buttons delete-button-container">
|
||||||
|
<button type="button" id="deletePlayerButton" class="delete-button">Delete Player</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Reset Confirmation Modal -->
|
||||||
|
<div id="resetModal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h2>Reset All Data</h2>
|
||||||
|
<p>Are you sure you want to reset all players and timers? This action cannot be undone.</p>
|
||||||
|
<div class="form-buttons">
|
||||||
|
<button type="button" id="resetCancelButton" class="cancel-button">Cancel</button>
|
||||||
|
<button type="button" id="resetConfirmButton" class="save-button">Reset</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera Capture UI -->
|
||||||
|
<div id="cameraContainer" class="camera-container">
|
||||||
|
<div class="camera-view">
|
||||||
|
<video id="cameraView" autoplay playsinline></video>
|
||||||
|
<canvas id="cameraCanvas" style="display: none;"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="camera-controls">
|
||||||
|
<button id="cameraCancelButton" class="camera-button-cancel">Cancel</button>
|
||||||
|
<button id="cameraCaptureButton" class="camera-button-large"></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main application script -->
|
||||||
|
<script type="module" src="/js/app.js"></script>
|
||||||
|
<script>
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/sw.js")
|
||||||
|
.then(() => console.log("Service Worker Registered"))
|
||||||
|
.catch((err) => console.log("Service Worker Failed", err));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script>
|
||||||
|
// Request notification permission on page load
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
if ('Notification' in window) {
|
||||||
|
Notification.requestPermission().then(permission => {
|
||||||
|
console.log('Notification permission:', permission);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<footer class="app-footer">
|
||||||
|
<div class="author-info">
|
||||||
|
<p>Vibe coded by Martin</p>
|
||||||
|
<p>Version 0.0.1</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
labels.example
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Enable Traefik for this container
|
||||||
|
traefik.enable=true
|
||||||
|
|
||||||
|
# Docker Network
|
||||||
|
traefik.docker.network=traefik
|
||||||
|
|
||||||
|
# Route requests based on Host
|
||||||
|
traefik.http.routers.game-timer.rule=Host(`game-timer.virtonline.eu`)
|
||||||
|
# Specify the entrypoint ('websecure' for HTTPS)
|
||||||
|
traefik.http.routers.game-timer.entrypoints=web-secure
|
||||||
|
traefik.http.routers.game-timer.tls=true
|
||||||
|
traefik.http.routers.game-timer.tls.certResolver=default
|
||||||
|
# Link the router to the service defined below
|
||||||
|
traefik.http.routers.game-timer.service=game-timer
|
||||||
|
|
||||||
|
# Point the service to the container's port
|
||||||
|
traefik.http.services.game-timer.loadbalancer.server.port=80
|
||||||
|
|
||||||
|
# Declaring the user list
|
||||||
|
#
|
||||||
|
# Note: when used in docker-compose.yml all dollar signs in the hash need to be doubled for escaping.
|
||||||
|
# To create a user:password pair, the following command can be used:
|
||||||
|
# echo $(htpasswd -nb user password) | sed -e s/\\$/\\$\\$/g
|
||||||
|
#
|
||||||
|
# Also note that dollar signs should NOT be doubled when they are not evaluated (e.g. Ansible docker_container module).
|
||||||
|
# for docker lables use
|
||||||
|
# `htpasswd -nb user password`
|
||||||
|
traefik.http.middlewares.game-timer-auth.basicauth.users=user:$apr1$rFge2lVe$DpoqxMsxSVJubFLXu4OMr1
|
||||||
|
|
||||||
|
# Apply the middleware to the router
|
||||||
|
traefik.http.routers.game-timer.middlewares=game-timer-auth
|
||||||
51
manifest.json
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
{
|
||||||
|
"name": "Game Timer PWA",
|
||||||
|
"short_name": "Game Timer",
|
||||||
|
"description": "Multi-player chess-like timer with carousel navigation",
|
||||||
|
"start_url": "/index.html",
|
||||||
|
"id": "/index.html",
|
||||||
|
"display": "standalone",
|
||||||
|
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
|
||||||
|
"background_color": "#f5f5f5",
|
||||||
|
"theme_color": "#2c3e50",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/icons/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/apple-touch-icon.png",
|
||||||
|
"sizes": "180x180",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/favicon-32x32.png",
|
||||||
|
"sizes": "32x32",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/icons/favicon-16x16.png",
|
||||||
|
"sizes": "16x16",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
{
|
||||||
|
"src": "/images/screenshot1.png",
|
||||||
|
"sizes": "2560x1860",
|
||||||
|
"type": "image/png",
|
||||||
|
"form_factor": "wide"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/images/screenshot2.png",
|
||||||
|
"sizes": "750x1594",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
24
package.json
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"timer",
|
||||||
|
"game",
|
||||||
|
"chess",
|
||||||
|
"pwa"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "SEE LICENSE IN LICENSE",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://gitea.virtonline.eu/2HoursProject/game-timer.git"
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/js/app.js
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
// app.js - Main Application Orchestrator
|
||||||
|
import * as config from './config.js';
|
||||||
|
import * as state from './core/state.js';
|
||||||
|
import * as ui from './ui/ui.js';
|
||||||
|
import * as timer from './core/timer.js';
|
||||||
|
import camera from './ui/camera.js'; // Default export
|
||||||
|
import audioManager from './ui/audio.js';
|
||||||
|
import * as pushFlic from './services/pushFlicIntegration.js';
|
||||||
|
import { initEnv } from './env-loader.js';
|
||||||
|
|
||||||
|
// Import externalized modules
|
||||||
|
import * as gameActions from './core/gameActions.js';
|
||||||
|
import * as playerManager from './core/playerManager.js';
|
||||||
|
import * as eventHandlers from './core/eventHandlers.js';
|
||||||
|
import * as serviceWorkerManager from './services/serviceWorkerManager.js';
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
|
||||||
|
async function initialize() {
|
||||||
|
console.log("Initializing Game Timer App...");
|
||||||
|
|
||||||
|
// 0. Wait for environment variables to load
|
||||||
|
try {
|
||||||
|
await initEnv();
|
||||||
|
console.log("Environment variables loaded");
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to load environment variables, using defaults:", error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Load saved state or defaults
|
||||||
|
state.loadData();
|
||||||
|
|
||||||
|
// 2. Initialize UI (pass carousel swipe handler)
|
||||||
|
ui.initUI({
|
||||||
|
onCarouselSwipe: (direction) => {
|
||||||
|
if (direction > 0) playerManager.nextPlayer(); else playerManager.previousPlayer();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Initialize Timer (pass callbacks for UI updates/state changes)
|
||||||
|
timer.initTimer({
|
||||||
|
onTimerTick: eventHandlers.handleTimerTick,
|
||||||
|
onPlayerSwitch: eventHandlers.handlePlayerSwitchOnTimer,
|
||||||
|
onGameOver: gameActions.handleGameOver
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Initialize Camera (pass elements and capture callback)
|
||||||
|
camera.init(
|
||||||
|
{ // Pass relevant DOM elements
|
||||||
|
cameraContainer: ui.elements.cameraContainer,
|
||||||
|
cameraView: ui.elements.cameraView,
|
||||||
|
cameraCanvas: ui.elements.cameraCanvas,
|
||||||
|
cameraCaptureButton: ui.elements.cameraCaptureButton,
|
||||||
|
cameraCancelButton: ui.elements.cameraCancelButton
|
||||||
|
},
|
||||||
|
{ // Pass options/callbacks
|
||||||
|
onCapture: eventHandlers.handleCameraCapture
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// 5. Set up UI Event Listeners that trigger actions
|
||||||
|
ui.elements.gameButton.addEventListener('click', eventHandlers.handleGameButtonClick);
|
||||||
|
ui.elements.setupButton.addEventListener('click', eventHandlers.handleSetupButtonClick);
|
||||||
|
ui.elements.addPlayerButton.addEventListener('click', eventHandlers.handleAddPlayerButtonClick);
|
||||||
|
ui.elements.resetButton.addEventListener('click', eventHandlers.handleResetButtonClick);
|
||||||
|
ui.elements.playerForm.addEventListener('submit', playerManager.handlePlayerFormSubmit);
|
||||||
|
ui.elements.cancelButton.addEventListener('click', eventHandlers.handlePlayerModalCancel);
|
||||||
|
ui.elements.deletePlayerButton.addEventListener('click', playerManager.handleDeletePlayer);
|
||||||
|
ui.elements.resetConfirmButton.addEventListener('click', eventHandlers.handleResetConfirm);
|
||||||
|
ui.elements.resetCancelButton.addEventListener('click', eventHandlers.handleResetCancel);
|
||||||
|
ui.elements.cameraButton.addEventListener('click', eventHandlers.handleCameraButtonClick);
|
||||||
|
|
||||||
|
// 6. Setup Flic action handlers
|
||||||
|
const flicActionHandlers = {
|
||||||
|
[config.FLIC_ACTIONS.SINGLE_CLICK]: playerManager.nextPlayer,
|
||||||
|
[config.FLIC_ACTIONS.DOUBLE_CLICK]: playerManager.previousPlayer,
|
||||||
|
[config.FLIC_ACTIONS.HOLD]: gameActions.togglePauseResume,
|
||||||
|
};
|
||||||
|
serviceWorkerManager.setFlicActionHandlers(flicActionHandlers);
|
||||||
|
|
||||||
|
// 7. Setup Service Worker (which also initializes Flic)
|
||||||
|
serviceWorkerManager.setupServiceWorker(serviceWorkerManager.handleServiceWorkerMessage);
|
||||||
|
|
||||||
|
// 8. Initial UI Update based on loaded state
|
||||||
|
ui.renderPlayers();
|
||||||
|
ui.updateGameButton();
|
||||||
|
|
||||||
|
// 9. Reset running state to paused on load
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
console.log("Game was running on load, setting to paused.");
|
||||||
|
state.setGameState(config.GAME_STATES.PAUSED);
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("App Initialized.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Start the application ---
|
||||||
|
// We need to use an async IIFE to await the async initialize function
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error initializing application:", error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
49
src/js/config.js
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
// config.js
|
||||||
|
import { getEnv } from './env-loader.js';
|
||||||
|
|
||||||
|
export function getPublicVapidKey() {
|
||||||
|
// Get the VAPID key from environment variables through the env-loader
|
||||||
|
return getEnv('PUBLIC_VAPID_KEY', 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The VAPID key should not be exposed directly in the source code
|
||||||
|
// Use the getter function instead: getPublicVapidKey()
|
||||||
|
// export const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
|
||||||
|
|
||||||
|
// Get backend URL from environment variables
|
||||||
|
export const BACKEND_URL = getEnv('BACKEND_URL', 'https://webpush.virtonline.eu');
|
||||||
|
export const FLIC_BUTTON_ID = 'game-button'; // Example ID, might need configuration
|
||||||
|
export const LOCAL_STORAGE_KEY = 'gameTimerData';
|
||||||
|
export const FLIC_BATTERY_THRESHOLD = 50; // Battery percentage threshold for low battery warning
|
||||||
|
|
||||||
|
// 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
src/js/core/eventHandlers.js
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
// eventHandlers.js - UI event handlers
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
import camera from '../ui/camera.js';
|
||||||
|
import { togglePauseResume, fullResetApp } from './gameActions.js';
|
||||||
|
import { handlePlayerFormSubmit, handleDeletePlayer } from './playerManager.js';
|
||||||
|
|
||||||
|
export function handleGameButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
togglePauseResume();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleSetupButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
alert('Please pause the game before editing players.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const currentPlayer = state.getCurrentPlayer();
|
||||||
|
if (!currentPlayer) {
|
||||||
|
console.warn("Edit clicked but no current player?");
|
||||||
|
return; // Or show Add Player modal?
|
||||||
|
}
|
||||||
|
camera.stopStream(); // Ensure camera is off before opening modal
|
||||||
|
ui.showPlayerModal(false, currentPlayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleAddPlayerButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
alert('Please pause the game before adding players.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
camera.stopStream(); // Ensure camera is off before opening modal
|
||||||
|
ui.showPlayerModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetButtonClick() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
alert('Please pause the game before resetting.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.showResetModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayerModalCancel() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
ui.hidePlayerModal();
|
||||||
|
camera.stopStream(); // Make sure camera turns off
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetConfirm() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
fullResetApp();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleResetCancel() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
ui.hideResetModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleCameraButtonClick(event) {
|
||||||
|
event.preventDefault(); // Prevent form submission if inside form
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
camera.open(); // Open the camera interface
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Timer Callbacks ---
|
||||||
|
export function handleTimerTick() {
|
||||||
|
// Timer module already updated the state, just need to redraw UI
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayerSwitchOnTimer(newPlayerIndex) {
|
||||||
|
// Timer detected current player ran out, found next player
|
||||||
|
console.log(`Timer switching to player index: ${newPlayerIndex}`);
|
||||||
|
// Import switchToPlayer dynamically to avoid circular dependency
|
||||||
|
import('./playerManager.js').then(module => {
|
||||||
|
module.switchToPlayer(newPlayerIndex);
|
||||||
|
});
|
||||||
|
// Sound is handled in switchToPlayer
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Camera Callback ---
|
||||||
|
export function handleCameraCapture(imageDataUrl) {
|
||||||
|
console.log("Image captured");
|
||||||
|
ui.updateImagePreviewFromDataUrl(imageDataUrl);
|
||||||
|
// Camera module already closed the camera UI
|
||||||
|
}
|
||||||
91
src/js/core/gameActions.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
// gameActions.js - Core game action functions
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import * as timer from './timer.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
|
||||||
|
// --- Core Game Actions ---
|
||||||
|
|
||||||
|
// Declare handleGameOver at the top level to avoid referencing before definition
|
||||||
|
export function handleGameOver() {
|
||||||
|
state.setGameState(config.GAME_STATES.OVER);
|
||||||
|
audioManager.play('gameOver');
|
||||||
|
timer.stopTimer(); // Ensure timer is stopped
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers(); // Update to show final state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startGame() {
|
||||||
|
if (state.getPlayers().length < 2) {
|
||||||
|
alert('You need at least 2 players to start.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (state.getGameState() === config.GAME_STATES.SETUP || state.getGameState() === config.GAME_STATES.PAUSED) {
|
||||||
|
state.setGameState(config.GAME_STATES.RUNNING);
|
||||||
|
audioManager.play('gameStart');
|
||||||
|
timer.startTimer();
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers(); // Ensure active timer styling is applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pauseGame() {
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
state.setGameState(config.GAME_STATES.PAUSED);
|
||||||
|
audioManager.play('gamePause');
|
||||||
|
timer.stopTimer();
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers(); // Ensure active timer styling is removed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resumeGame() {
|
||||||
|
if (state.getGameState() === config.GAME_STATES.PAUSED) {
|
||||||
|
// Check if there's actually a player with time left
|
||||||
|
if (state.findNextPlayerWithTime() === -1) {
|
||||||
|
console.log("Cannot resume, no players have time left.");
|
||||||
|
// Optionally set state to OVER here
|
||||||
|
handleGameOver();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.setGameState(config.GAME_STATES.RUNNING);
|
||||||
|
audioManager.play('gameResume');
|
||||||
|
timer.startTimer();
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers(); // Ensure active timer styling is applied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function togglePauseResume() {
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
if (currentGameState === config.GAME_STATES.RUNNING) {
|
||||||
|
pauseGame();
|
||||||
|
} else if (currentGameState === config.GAME_STATES.PAUSED) {
|
||||||
|
resumeGame();
|
||||||
|
} else if (currentGameState === config.GAME_STATES.SETUP) {
|
||||||
|
startGame();
|
||||||
|
} else if (currentGameState === config.GAME_STATES.OVER) {
|
||||||
|
resetGame(); // Or just go back to setup? Let's reset.
|
||||||
|
startGame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetGame() {
|
||||||
|
timer.stopTimer(); // Stop timer if running/paused
|
||||||
|
state.resetPlayersTime();
|
||||||
|
state.setGameState(config.GAME_STATES.SETUP);
|
||||||
|
state.setCurrentPlayerIndex(0); // Go back to first player
|
||||||
|
audioManager.play('buttonClick'); // Or a specific reset sound?
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fullResetApp() {
|
||||||
|
timer.stopTimer();
|
||||||
|
state.resetToDefaults();
|
||||||
|
audioManager.play('gameOver'); // Use game over sound for full reset
|
||||||
|
ui.hideResetModal();
|
||||||
|
ui.updateGameButton();
|
||||||
|
ui.renderPlayers();
|
||||||
|
}
|
||||||
154
src/js/core/playerManager.js
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
// playerManager.js - Player-related operations
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as state from './state.js';
|
||||||
|
import * as ui from '../ui/ui.js';
|
||||||
|
import * as timer from './timer.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
import camera from '../ui/camera.js';
|
||||||
|
|
||||||
|
export function switchToPlayer(index) {
|
||||||
|
if (index >= 0 && index < state.getPlayers().length) {
|
||||||
|
const previousIndex = state.getCurrentPlayerIndex();
|
||||||
|
if(index !== previousIndex) {
|
||||||
|
state.setCurrentPlayerIndex(index);
|
||||||
|
audioManager.play('playerSwitch');
|
||||||
|
ui.renderPlayers(); // Update UI immediately
|
||||||
|
|
||||||
|
// If the game is running, restart the timer for the new player
|
||||||
|
// The timer interval callback will handle the decrementing
|
||||||
|
if (state.getGameState() === config.GAME_STATES.RUNNING) {
|
||||||
|
timer.startTimer(); // This clears the old interval and starts anew
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nextPlayer() {
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
let newIndex = -1;
|
||||||
|
|
||||||
|
if (currentGameState === config.GAME_STATES.RUNNING) {
|
||||||
|
newIndex = state.findNextPlayerWithTimeCircular(1); // Find next with time
|
||||||
|
} else {
|
||||||
|
// Allow cycling through all players if not running
|
||||||
|
const playerCount = state.getPlayers().length;
|
||||||
|
if(playerCount > 0) {
|
||||||
|
newIndex = (state.getCurrentPlayerIndex() + 1) % playerCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIndex !== -1) {
|
||||||
|
switchToPlayer(newIndex);
|
||||||
|
} else if (currentGameState === config.GAME_STATES.RUNNING) {
|
||||||
|
console.log("NextPlayer: No other player has time remaining.");
|
||||||
|
// Optionally handle game over immediately? Timer logic should catch this too.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function previousPlayer() {
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
let newIndex = -1;
|
||||||
|
|
||||||
|
if (currentGameState === config.GAME_STATES.RUNNING) {
|
||||||
|
newIndex = state.findNextPlayerWithTimeCircular(-1); // Find previous with time
|
||||||
|
} else {
|
||||||
|
// Allow cycling through all players if not running
|
||||||
|
const playerCount = state.getPlayers().length;
|
||||||
|
if (playerCount > 0) {
|
||||||
|
newIndex = (state.getCurrentPlayerIndex() - 1 + playerCount) % playerCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newIndex !== -1) {
|
||||||
|
switchToPlayer(newIndex);
|
||||||
|
} else if (currentGameState === config.GAME_STATES.RUNNING) {
|
||||||
|
console.log("PreviousPlayer: No other player has time remaining.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handlePlayerFormSubmit(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
|
||||||
|
const name = ui.elements.playerNameInput.value.trim();
|
||||||
|
const timeInMinutes = parseInt(ui.elements.playerTimeInput.value, 10);
|
||||||
|
let remainingTimeSeconds = 0; // Default
|
||||||
|
const isNewPlayer = ui.elements.modalTitle.textContent === 'Add New Player';
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
|
||||||
|
if (!name || isNaN(timeInMinutes) || timeInMinutes <= 0) {
|
||||||
|
alert('Please enter a valid name and positive time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get remaining time ONLY if editing and game is paused/over
|
||||||
|
if (!isNewPlayer && (currentGameState === config.GAME_STATES.PAUSED || currentGameState === config.GAME_STATES.OVER)) {
|
||||||
|
const remainingTimeString = ui.elements.playerRemainingTimeInput.value;
|
||||||
|
const parsedSeconds = ui.parseTimeString(remainingTimeString);
|
||||||
|
if (parsedSeconds === null) { // Check if parsing failed
|
||||||
|
alert('Please enter remaining time in MM:SS format (e.g., 05:30).');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
remainingTimeSeconds = parsedSeconds;
|
||||||
|
// Validate remaining time against total time? Optional.
|
||||||
|
if (remainingTimeSeconds > timeInMinutes * 60) {
|
||||||
|
alert('Remaining time cannot be greater than the total time.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For new players or when editing in setup, remaining time matches total time
|
||||||
|
remainingTimeSeconds = timeInMinutes * 60;
|
||||||
|
}
|
||||||
|
|
||||||
|
let imageDataUrl = ui.elements.playerImageInput.dataset.capturedImage || null;
|
||||||
|
const imageFile = ui.elements.playerImageInput.files[0];
|
||||||
|
|
||||||
|
const saveAction = (finalImageData) => {
|
||||||
|
if (isNewPlayer) {
|
||||||
|
state.addPlayer(name, timeInMinutes, finalImageData);
|
||||||
|
audioManager.play('playerAdded');
|
||||||
|
} else {
|
||||||
|
const playerIndex = state.getCurrentPlayerIndex();
|
||||||
|
// Use 'undefined' for image if no new image is provided, so state.updatePlayer keeps the old one
|
||||||
|
const imageArg = finalImageData !== null ? finalImageData : (isNewPlayer ? null : undefined);
|
||||||
|
state.updatePlayer(playerIndex, name, timeInMinutes, remainingTimeSeconds, imageArg);
|
||||||
|
audioManager.play('playerEdited');
|
||||||
|
}
|
||||||
|
ui.hidePlayerModal();
|
||||||
|
ui.renderPlayers();
|
||||||
|
ui.updateGameButton(); // Update in case player count changed for setup state
|
||||||
|
camera.stopStream(); // Ensure camera is stopped
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!imageDataUrl && imageFile) {
|
||||||
|
// Handle file upload: Read file as Data URL
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => saveAction(e.target.result);
|
||||||
|
reader.onerror = (e) => {
|
||||||
|
console.error("Error reading image file:", e);
|
||||||
|
alert("Error processing image file.");
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(imageFile);
|
||||||
|
} else {
|
||||||
|
// Handle captured image or no image change
|
||||||
|
const currentImage = isNewPlayer ? null : state.getCurrentPlayer()?.image;
|
||||||
|
// If imageDataUrl has content (from camera), use it.
|
||||||
|
// If not, and no file was selected, keep the current image (by passing undefined to updatePlayer later).
|
||||||
|
// If it's a new player and no image, pass null.
|
||||||
|
saveAction(imageDataUrl ?? (isNewPlayer ? null : currentImage));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleDeletePlayer() {
|
||||||
|
audioManager.play('buttonClick');
|
||||||
|
const success = state.deletePlayer(state.getCurrentPlayerIndex());
|
||||||
|
if (success) {
|
||||||
|
audioManager.play('playerDeleted');
|
||||||
|
ui.hidePlayerModal();
|
||||||
|
ui.renderPlayers();
|
||||||
|
ui.updateGameButton(); // Update in case player count dropped below 2
|
||||||
|
} else {
|
||||||
|
alert('Cannot delete player. Minimum of 2 players required.');
|
||||||
|
}
|
||||||
|
camera.stopStream();
|
||||||
|
}
|
||||||
230
src/js/core/state.js
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
// state.js
|
||||||
|
import { LOCAL_STORAGE_KEY, DEFAULT_PLAYERS, GAME_STATES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
|
||||||
|
|
||||||
|
let players = [];
|
||||||
|
let currentPlayerIndex = 0;
|
||||||
|
let gameState = GAME_STATES.SETUP;
|
||||||
|
|
||||||
|
// --- State Accessors ---
|
||||||
|
|
||||||
|
export function getPlayers() {
|
||||||
|
return [...players]; // Return a copy to prevent direct mutation
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPlayer() {
|
||||||
|
if (players.length === 0) return null;
|
||||||
|
return players[currentPlayerIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPlayerById(id) {
|
||||||
|
return players.find(p => p.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentPlayerIndex() {
|
||||||
|
return currentPlayerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getGameState() {
|
||||||
|
return gameState;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- State Mutators ---
|
||||||
|
|
||||||
|
export function setPlayers(newPlayers) {
|
||||||
|
players = newPlayers;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setCurrentPlayerIndex(index) {
|
||||||
|
if (index >= 0 && index < players.length) {
|
||||||
|
currentPlayerIndex = index;
|
||||||
|
saveData();
|
||||||
|
} else {
|
||||||
|
console.error(`Invalid player index: ${index}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setGameState(newState) {
|
||||||
|
if (Object.values(GAME_STATES).includes(newState)) {
|
||||||
|
gameState = newState;
|
||||||
|
saveData();
|
||||||
|
} else {
|
||||||
|
console.error(`Invalid game state: ${newState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlayerTime(index, remainingTime) {
|
||||||
|
if (index >= 0 && index < players.length) {
|
||||||
|
players[index].remainingTime = Math.max(0, remainingTime); // Ensure time doesn't go below 0
|
||||||
|
saveData(); // Save data whenever time updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addPlayer(name, timeInMinutes, image = null) {
|
||||||
|
const timeInSeconds = timeInMinutes * 60;
|
||||||
|
const newId = Date.now();
|
||||||
|
players.push({
|
||||||
|
id: newId,
|
||||||
|
name: name,
|
||||||
|
timeInSeconds: timeInSeconds,
|
||||||
|
remainingTime: timeInSeconds,
|
||||||
|
image: image
|
||||||
|
});
|
||||||
|
currentPlayerIndex = players.length - 1; // Focus new player
|
||||||
|
saveData();
|
||||||
|
return players[players.length - 1]; // Return the newly added player
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updatePlayer(index, name, timeInMinutes, remainingTimeSeconds, image) {
|
||||||
|
if (index >= 0 && index < players.length) {
|
||||||
|
const player = players[index];
|
||||||
|
const timeInSeconds = timeInMinutes * 60;
|
||||||
|
|
||||||
|
player.name = name;
|
||||||
|
player.timeInSeconds = timeInSeconds;
|
||||||
|
|
||||||
|
// Update remaining time carefully based on game state
|
||||||
|
if (gameState === GAME_STATES.SETUP) {
|
||||||
|
player.remainingTime = timeInSeconds;
|
||||||
|
} else if (gameState === GAME_STATES.PAUSED || gameState === GAME_STATES.OVER) {
|
||||||
|
// Allow direct setting of remaining time only when paused or over
|
||||||
|
player.remainingTime = remainingTimeSeconds;
|
||||||
|
}
|
||||||
|
// If running, remaining time is managed by the timer, don't override here unless intended
|
||||||
|
|
||||||
|
if (image !== undefined) { // Allow updating image (null means remove image)
|
||||||
|
player.image = image;
|
||||||
|
}
|
||||||
|
saveData();
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePlayer(index) {
|
||||||
|
if (players.length <= 2) {
|
||||||
|
console.warn('Cannot delete player, minimum 2 players required.');
|
||||||
|
return false; // Indicate deletion failed
|
||||||
|
}
|
||||||
|
if (index >= 0 && index < players.length) {
|
||||||
|
players.splice(index, 1);
|
||||||
|
if (currentPlayerIndex >= players.length) {
|
||||||
|
currentPlayerIndex = players.length - 1;
|
||||||
|
} else if (currentPlayerIndex > index) {
|
||||||
|
// Adjust index if deleting someone before the current player
|
||||||
|
// No adjustment needed if deleting current or after current
|
||||||
|
}
|
||||||
|
saveData();
|
||||||
|
return true; // Indicate success
|
||||||
|
}
|
||||||
|
return false; // Indicate deletion failed
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetPlayersTime() {
|
||||||
|
players.forEach(player => {
|
||||||
|
player.remainingTime = player.timeInSeconds;
|
||||||
|
});
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetToDefaults() {
|
||||||
|
// Deep copy default players to avoid modifying the constant
|
||||||
|
players = JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
|
||||||
|
gameState = GAME_STATES.SETUP;
|
||||||
|
currentPlayerIndex = 0;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areAllTimersFinished() {
|
||||||
|
return players.every(player => player.remainingTime <= 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns the index of the next player with time > 0, or -1 if none
|
||||||
|
export function findNextPlayerWithTime() {
|
||||||
|
if (players.length === 0) return -1;
|
||||||
|
const startIndex = (currentPlayerIndex + 1) % players.length;
|
||||||
|
let index = startIndex;
|
||||||
|
|
||||||
|
do {
|
||||||
|
if (players[index].remainingTime > 0) {
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
index = (index + 1) % players.length;
|
||||||
|
} while (index !== startIndex);
|
||||||
|
|
||||||
|
// Check current player last if no others found
|
||||||
|
if(players[currentPlayerIndex].remainingTime > 0) {
|
||||||
|
return currentPlayerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1; // No player has time left
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find next player with time in specified direction (1 for next, -1 for prev)
|
||||||
|
export function findNextPlayerWithTimeCircular(direction) {
|
||||||
|
if (players.length === 0) return -1;
|
||||||
|
let index = currentPlayerIndex;
|
||||||
|
|
||||||
|
for (let i = 0; i < players.length; i++) {
|
||||||
|
index = (index + direction + players.length) % players.length;
|
||||||
|
if (players[index]?.remainingTime > 0) { // Check if player exists and has time
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no other player found, check if current player has time (only relevant if direction search fails)
|
||||||
|
if (players[currentPlayerIndex]?.remainingTime > 0) {
|
||||||
|
return currentPlayerIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return -1; // No player has time left
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Persistence ---
|
||||||
|
|
||||||
|
export function saveData() {
|
||||||
|
const dataToSave = {
|
||||||
|
players,
|
||||||
|
gameState,
|
||||||
|
currentPlayerIndex
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(dataToSave));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving data to localStorage:", error);
|
||||||
|
// Maybe notify the user that settings won't be saved
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadData() {
|
||||||
|
const savedData = localStorage.getItem(LOCAL_STORAGE_KEY);
|
||||||
|
if (savedData) {
|
||||||
|
try {
|
||||||
|
const parsedData = JSON.parse(savedData);
|
||||||
|
players = parsedData.players || JSON.parse(JSON.stringify(DEFAULT_PLAYERS));
|
||||||
|
gameState = parsedData.gameState || GAME_STATES.SETUP;
|
||||||
|
currentPlayerIndex = parsedData.currentPlayerIndex || 0;
|
||||||
|
|
||||||
|
// Basic validation/migration if needed
|
||||||
|
if (currentPlayerIndex >= players.length) {
|
||||||
|
currentPlayerIndex = 0;
|
||||||
|
}
|
||||||
|
// Ensure all players have necessary properties
|
||||||
|
players = players.map(p => ({
|
||||||
|
id: p.id || Date.now() + Math.random(), // Ensure ID exists
|
||||||
|
name: p.name || 'Player',
|
||||||
|
timeInSeconds: p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS,
|
||||||
|
remainingTime: p.remainingTime !== undefined ? p.remainingTime : (p.timeInSeconds || DEFAULT_PLAYER_TIME_SECONDS),
|
||||||
|
image: p.image || null
|
||||||
|
}));
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error parsing data from localStorage:", error);
|
||||||
|
resetToDefaults(); // Reset to defaults if stored data is corrupt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetToDefaults(); // Use defaults if no saved data
|
||||||
|
}
|
||||||
|
// No saveData() here, loadData just loads the state
|
||||||
|
}
|
||||||
104
src/js/core/timer.js
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// timer.js
|
||||||
|
import * as state from './state.js';
|
||||||
|
import { GAME_STATES } from '../config.js';
|
||||||
|
import audioManager from '../ui/audio.js';
|
||||||
|
|
||||||
|
let timerInterval = null;
|
||||||
|
let onTimerTickCallback = null; // Callback for UI updates
|
||||||
|
let onPlayerSwitchCallback = null; // Callback for when player switches due to time running out
|
||||||
|
let onGameOverCallback = null; // Callback for when all players run out of time
|
||||||
|
let timeExpiredFlagsById = new Map(); // Track which players have had their timeout sound played
|
||||||
|
|
||||||
|
export function initTimer(options) {
|
||||||
|
onTimerTickCallback = options.onTimerTick;
|
||||||
|
onPlayerSwitchCallback = options.onPlayerSwitch;
|
||||||
|
onGameOverCallback = options.onGameOver;
|
||||||
|
timeExpiredFlagsById.clear(); // Reset flags on init
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startTimer() {
|
||||||
|
if (timerInterval) clearInterval(timerInterval); // Clear existing interval if any
|
||||||
|
|
||||||
|
// Stop any previous sounds (like low time warning) before starting fresh
|
||||||
|
audioManager.stopAllSounds();
|
||||||
|
|
||||||
|
// Reset the expired sound flags when starting a new timer
|
||||||
|
timeExpiredFlagsById.clear();
|
||||||
|
|
||||||
|
timerInterval = setInterval(() => {
|
||||||
|
const currentPlayerIndex = state.getCurrentPlayerIndex();
|
||||||
|
const currentPlayer = state.getCurrentPlayer(); // Get player data after index
|
||||||
|
|
||||||
|
if (!currentPlayer) {
|
||||||
|
console.warn("Timer running but no current player found.");
|
||||||
|
stopTimer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only decrease time if the current player has time left
|
||||||
|
if (currentPlayer.remainingTime > 0) {
|
||||||
|
const newTime = currentPlayer.remainingTime - 1;
|
||||||
|
state.updatePlayerTime(currentPlayerIndex, newTime); // Update state
|
||||||
|
|
||||||
|
// Play timer sounds - ensure we're not leaking audio resources
|
||||||
|
audioManager.playTimerSound(newTime);
|
||||||
|
|
||||||
|
// Notify UI to update
|
||||||
|
if (onTimerTickCallback) onTimerTickCallback();
|
||||||
|
|
||||||
|
} else { // Current player's time just hit 0 or was already 0
|
||||||
|
// Ensure time is exactly 0 if it somehow went negative
|
||||||
|
if(currentPlayer.remainingTime < 0) {
|
||||||
|
state.updatePlayerTime(currentPlayerIndex, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Play time expired sound (only once per player per game)
|
||||||
|
if (!timeExpiredFlagsById.has(currentPlayer.id)) {
|
||||||
|
audioManager.playTimerExpired();
|
||||||
|
timeExpiredFlagsById.set(currentPlayer.id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the game should end or switch player
|
||||||
|
if (state.areAllTimersFinished()) {
|
||||||
|
stopTimer();
|
||||||
|
if (onGameOverCallback) onGameOverCallback();
|
||||||
|
} else {
|
||||||
|
// Find the *next* player who still has time
|
||||||
|
const nextPlayerIndex = state.findNextPlayerWithTime(); // This finds ANY player with time
|
||||||
|
if (nextPlayerIndex !== -1 && nextPlayerIndex !== currentPlayerIndex) {
|
||||||
|
// Switch player and ensure we stop any sounds from current player
|
||||||
|
audioManager.stopTimerSounds(); // Stop specific timer sounds before switching
|
||||||
|
|
||||||
|
if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
|
||||||
|
|
||||||
|
// Immediately update UI after switch
|
||||||
|
if (onTimerTickCallback) onTimerTickCallback();
|
||||||
|
} else if (nextPlayerIndex === -1) {
|
||||||
|
// This case shouldn't be reached if areAllTimersFinished is checked first, but as a safeguard:
|
||||||
|
console.warn("Timer tick: Current player out of time, but no next player found, yet not all timers finished?");
|
||||||
|
stopTimer(); // Stop timer if state is inconsistent
|
||||||
|
if (onGameOverCallback) onGameOverCallback(); // Treat as game over
|
||||||
|
}
|
||||||
|
// If nextPlayerIndex is the same as currentPlayerIndex, means others are out of time, let this timer continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopTimer() {
|
||||||
|
clearInterval(timerInterval);
|
||||||
|
timerInterval = null;
|
||||||
|
// Stop all timer-related sounds to prevent them from continuing to play
|
||||||
|
audioManager.stopTimerSounds();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTimerRunning() {
|
||||||
|
return timerInterval !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up resources when the application is closing or component unmounts
|
||||||
|
export function cleanup() {
|
||||||
|
stopTimer();
|
||||||
|
timeExpiredFlagsById.clear();
|
||||||
|
audioManager.stopAllSounds();
|
||||||
|
}
|
||||||
88
src/js/env-loader.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
// env-loader.js
|
||||||
|
// This module is responsible for loading environment variables from .env file
|
||||||
|
|
||||||
|
// Store environment variables in a global object
|
||||||
|
window.ENV_CONFIG = {};
|
||||||
|
|
||||||
|
// Function to load environment variables from .env file
|
||||||
|
async function loadEnvVariables() {
|
||||||
|
try {
|
||||||
|
// Fetch the .env file as text
|
||||||
|
const response = await fetch('/.env');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
console.warn('Could not load .env file. Using default values.');
|
||||||
|
setDefaultEnvValues();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const envText = await response.text();
|
||||||
|
|
||||||
|
// Parse the .env file content
|
||||||
|
const envVars = parseEnvFile(envText);
|
||||||
|
|
||||||
|
// Store in the global ENV_CONFIG object
|
||||||
|
window.ENV_CONFIG = { ...window.ENV_CONFIG, ...envVars };
|
||||||
|
|
||||||
|
console.log('Environment variables loaded successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading environment variables:', error);
|
||||||
|
setDefaultEnvValues();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse .env file content into key-value pairs
|
||||||
|
function parseEnvFile(envText) {
|
||||||
|
const envVars = {};
|
||||||
|
|
||||||
|
// Split by lines and process each line
|
||||||
|
envText.split('\n').forEach(line => {
|
||||||
|
// Skip empty lines and comments
|
||||||
|
if (!line || line.trim().startsWith('#')) return;
|
||||||
|
|
||||||
|
// Extract key-value pairs
|
||||||
|
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
|
||||||
|
if (match) {
|
||||||
|
const key = match[1];
|
||||||
|
let value = match[2] || '';
|
||||||
|
|
||||||
|
// Remove quotes if present
|
||||||
|
if (value.startsWith('"') && value.endsWith('"')) {
|
||||||
|
value = value.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
envVars[key] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return envVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default values for required environment variables
|
||||||
|
function setDefaultEnvValues() {
|
||||||
|
window.ENV_CONFIG = {
|
||||||
|
...window.ENV_CONFIG,
|
||||||
|
PUBLIC_VAPID_KEY: 'your_public_vapid_key_here',
|
||||||
|
BACKEND_URL: 'https://your-push-server.example.com'
|
||||||
|
};
|
||||||
|
console.log('Using default environment values');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export function to initialize environment variables
|
||||||
|
export async function initEnv() {
|
||||||
|
await loadEnvVariables();
|
||||||
|
return window.ENV_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-initialize when imported
|
||||||
|
initEnv();
|
||||||
|
|
||||||
|
// Export access functions for environment variables
|
||||||
|
export function getEnv(key, defaultValue = '') {
|
||||||
|
return window.ENV_CONFIG[key] || defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
initEnv,
|
||||||
|
getEnv
|
||||||
|
};
|
||||||
283
src/js/services/pushFlicIntegration.js
Normal file
@@ -0,0 +1,283 @@
|
|||||||
|
// pushFlicIntegration.js
|
||||||
|
import { getPublicVapidKey, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS, FLIC_BATTERY_THRESHOLD } from '../config.js';
|
||||||
|
|
||||||
|
let pushSubscription = null; // Keep track locally if needed
|
||||||
|
let actionHandlers = {}; // Store handlers for different Flic actions
|
||||||
|
let lastBatteryWarningTimestamp = 0; // Track when last battery warning was shown
|
||||||
|
|
||||||
|
// --- 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt the user for credentials after permissions are granted
|
||||||
|
function promptForCredentials() {
|
||||||
|
console.log('Prompting user for auth credentials.');
|
||||||
|
const username = prompt('Please enter your username for backend authentication:');
|
||||||
|
if (!username) return null;
|
||||||
|
const password = prompt('Please enter your password:');
|
||||||
|
if (!password) return null;
|
||||||
|
|
||||||
|
const credentials = { username, password };
|
||||||
|
localStorage.setItem('basicAuthCredentials', JSON.stringify(credentials));
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(/=+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show a popup notification to the user
|
||||||
|
function showBatteryWarning(batteryLevel) {
|
||||||
|
// Only show warning once every 4 hours (to avoid annoying users)
|
||||||
|
const now = Date.now();
|
||||||
|
const fourHoursInMs = 4 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
if (now - lastBatteryWarningTimestamp < fourHoursInMs) {
|
||||||
|
console.log(`[PushFlic] Battery warning suppressed (shown recently): ${batteryLevel}%`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastBatteryWarningTimestamp = now;
|
||||||
|
|
||||||
|
// Show the notification
|
||||||
|
const message = `Flic button battery is low (${batteryLevel}%). Please replace the battery soon.`;
|
||||||
|
|
||||||
|
// Show browser notification if permission granted
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification('Flic Button Low Battery', {
|
||||||
|
body: message,
|
||||||
|
icon: '/public/favicon.ico'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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.');
|
||||||
|
|
||||||
|
// After permission is granted, check for stored credentials or prompt user
|
||||||
|
let credentials = getBasicAuthCredentials();
|
||||||
|
if (!credentials) {
|
||||||
|
const confirmAuth = confirm('Do you want to set up credentials for push notifications now?');
|
||||||
|
if (!confirmAuth) {
|
||||||
|
console.log('User declined to provide auth credentials.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = promptForCredentials();
|
||||||
|
if (!credentials) {
|
||||||
|
console.log('User canceled credential input.');
|
||||||
|
alert('Authentication required to set up push notifications.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const registration = await navigator.serviceWorker.ready;
|
||||||
|
let existingSubscription = await registration.pushManager.getSubscription();
|
||||||
|
let needsResubscribe = !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());
|
||||||
|
finalSubscription = await registration.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: applicationServerKey
|
||||||
|
});
|
||||||
|
console.log('New push subscription obtained:', finalSubscription);
|
||||||
|
pushSubscription = finalSubscription; // Store it
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// One more chance to enter credentials if needed
|
||||||
|
const confirmAuth = confirm('Authentication required to complete setup. Provide credentials now?');
|
||||||
|
if (!confirmAuth) {
|
||||||
|
alert('Authentication required to save button link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newCredentials = promptForCredentials();
|
||||||
|
if (!newCredentials) {
|
||||||
|
alert('Authentication required to save button link.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
credentials = newCredentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`${BACKEND_URL}/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);
|
||||||
|
alert('Push notification setup completed successfully!');
|
||||||
|
} 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}`);
|
||||||
|
|
||||||
|
// Check if battery is below threshold and show warning if needed
|
||||||
|
if (batteryLevel < FLIC_BATTERY_THRESHOLD) {
|
||||||
|
showBatteryWarning(batteryLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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}`);
|
||||||
|
// Execute the handler registered in app.js
|
||||||
|
handler(); // Use the handler function directly instead of hardcoded function calls
|
||||||
|
} else {
|
||||||
|
console.warn(`[PushFlic] No handler registered for action: ${action}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Initialization ---
|
||||||
|
|
||||||
|
export function initPushFlic(handlers) {
|
||||||
|
actionHandlers = handlers; // Store the handlers passed from app.js
|
||||||
|
// Example: handlers = { SingleClick: handleNextPlayer, Hold: handleTogglePause }
|
||||||
|
|
||||||
|
// Attempt to subscribe immediately if permission might already be granted
|
||||||
|
// Or trigger subscription on a user action (e.g., a "Link Flic Button" button)
|
||||||
|
// For simplicity, let's try subscribing if SW is ready and permission allows
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
Notification.requestPermission().then(permission => {
|
||||||
|
if (permission === 'granted') {
|
||||||
|
console.log('[PushFlic] Permission granted, attempting subscription.');
|
||||||
|
subscribeToPush();
|
||||||
|
} else {
|
||||||
|
console.log('[PushFlic] Notification permission not granted.');
|
||||||
|
// Optionally provide a button for the user to trigger subscription later
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/js/services/serviceWorkerManager.js
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
// serviceWorkerManager.js - Service worker registration and Flic integration
|
||||||
|
import * as config from '../config.js';
|
||||||
|
import * as pushFlic from './pushFlicIntegration.js';
|
||||||
|
|
||||||
|
// Store the action handlers passed from app.js
|
||||||
|
let flicActionHandlers = {};
|
||||||
|
|
||||||
|
export function setFlicActionHandlers(handlers) {
|
||||||
|
flicActionHandlers = handlers;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Flic Integration Setup ---
|
||||||
|
export function initFlic() {
|
||||||
|
// This function is used by setupServiceWorker and relies on
|
||||||
|
// flicActionHandlers being set before this is called
|
||||||
|
pushFlic.initPushFlic(flicActionHandlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleServiceWorkerMessage(event) {
|
||||||
|
console.log('[App] Message received from Service Worker:', event.data);
|
||||||
|
if (event.data?.type === 'flic-action') {
|
||||||
|
const { action, button, timestamp, batteryLevel } = event.data;
|
||||||
|
if (flicActionHandlers[action]) {
|
||||||
|
flicActionHandlers[action]();
|
||||||
|
} else {
|
||||||
|
pushFlic.handleFlicAction(action, button, timestamp, batteryLevel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Service Worker and PWA Setup ---
|
||||||
|
export function setupServiceWorker(messageHandler) {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('ServiceWorker registered successfully.');
|
||||||
|
|
||||||
|
// Listen for messages FROM the Service Worker (e.g., Flic actions)
|
||||||
|
navigator.serviceWorker.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
|
// Initialize Flic integration (which will try to subscribe)
|
||||||
|
initFlic();
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('ServiceWorker registration failed:', error);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
// Listen for SW controller changes
|
||||||
|
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||||
|
console.log('Service Worker controller changed, potentially updated.');
|
||||||
|
// window.location.reload(); // Consider prompting user to reload
|
||||||
|
});
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.warn('ServiceWorker not supported.');
|
||||||
|
}
|
||||||
|
}
|
||||||
323
src/js/ui/audio.js
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
// Audio Manager using Web Audio API
|
||||||
|
const audioManager = {
|
||||||
|
audioContext: null,
|
||||||
|
muted: false,
|
||||||
|
sounds: {},
|
||||||
|
lowTimeThreshold: 10, // Seconds threshold for low time warning
|
||||||
|
lastTickTime: 0, // Track when we started continuous ticking
|
||||||
|
tickFadeoutTime: 3, // Seconds after which tick sound fades out
|
||||||
|
|
||||||
|
// Initialize the audio context
|
||||||
|
init() {
|
||||||
|
try {
|
||||||
|
// Create AudioContext (with fallback for older browsers)
|
||||||
|
this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
||||||
|
|
||||||
|
// Check for saved mute preference
|
||||||
|
const savedMute = localStorage.getItem('gameTimerMuted');
|
||||||
|
this.muted = savedMute === 'true';
|
||||||
|
|
||||||
|
// Create all the sounds
|
||||||
|
this.createSounds();
|
||||||
|
|
||||||
|
console.log('Web Audio API initialized successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Web Audio API initialization failed:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Create all the sound generators
|
||||||
|
createSounds() {
|
||||||
|
// Game sounds
|
||||||
|
this.sounds.tick = this.createTickSound();
|
||||||
|
this.sounds.lowTime = this.createLowTimeSound();
|
||||||
|
this.sounds.timeUp = this.createTimeUpSound();
|
||||||
|
this.sounds.gameStart = this.createGameStartSound();
|
||||||
|
this.sounds.gamePause = this.createGamePauseSound();
|
||||||
|
this.sounds.gameResume = this.createGameResumeSound();
|
||||||
|
this.sounds.gameOver = this.createGameOverSound();
|
||||||
|
this.sounds.playerSwitch = this.createPlayerSwitchSound();
|
||||||
|
|
||||||
|
// UI sounds
|
||||||
|
this.sounds.buttonClick = this.createButtonClickSound();
|
||||||
|
this.sounds.modalOpen = this.createModalOpenSound();
|
||||||
|
this.sounds.modalClose = this.createModalCloseSound();
|
||||||
|
this.sounds.playerAdded = this.createPlayerAddedSound();
|
||||||
|
this.sounds.playerEdited = this.createPlayerEditedSound();
|
||||||
|
this.sounds.playerDeleted = this.createPlayerDeletedSound();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Helper function to create an oscillator
|
||||||
|
createOscillator(type, frequency, startTime, duration, gain = 1.0, ramp = false) {
|
||||||
|
if (this.audioContext === null) this.init();
|
||||||
|
|
||||||
|
const oscillator = this.audioContext.createOscillator();
|
||||||
|
const gainNode = this.audioContext.createGain();
|
||||||
|
|
||||||
|
oscillator.type = type;
|
||||||
|
oscillator.frequency.value = frequency;
|
||||||
|
gainNode.gain.value = gain;
|
||||||
|
|
||||||
|
oscillator.connect(gainNode);
|
||||||
|
gainNode.connect(this.audioContext.destination);
|
||||||
|
|
||||||
|
oscillator.start(startTime);
|
||||||
|
|
||||||
|
if (ramp) {
|
||||||
|
gainNode.gain.exponentialRampToValueAtTime(0.001, startTime + duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
oscillator.stop(startTime + duration);
|
||||||
|
|
||||||
|
return { oscillator, gainNode };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sound creators
|
||||||
|
createTickSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
const currentTime = Date.now() / 1000;
|
||||||
|
|
||||||
|
// Initialize lastTickTime if it's not set
|
||||||
|
if (this.lastTickTime === 0) {
|
||||||
|
this.lastTickTime = currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate how long we've been ticking continuously
|
||||||
|
const tickDuration = currentTime - this.lastTickTime;
|
||||||
|
|
||||||
|
// Determine volume based on duration
|
||||||
|
let volume = 0.1; // Default/initial volume
|
||||||
|
|
||||||
|
if (tickDuration <= this.tickFadeoutTime) {
|
||||||
|
// Linear fade from 0.1 to 0 over tickFadeoutTime seconds
|
||||||
|
volume = 0.1 * (1 - (tickDuration / this.tickFadeoutTime));
|
||||||
|
} else {
|
||||||
|
// After tickFadeoutTime, don't play any sound
|
||||||
|
return; // Exit without playing sound
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only play if volume is significant
|
||||||
|
if (volume > 0.001) {
|
||||||
|
this.createOscillator('sine', 800, now, 0.03, volume);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createLowTimeSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
// Low time warning is always audible
|
||||||
|
this.createOscillator('triangle', 660, now, 0.1, 0.2);
|
||||||
|
// Reset tick fade timer on low time warning
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createTimeUpSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// First note
|
||||||
|
this.createOscillator('sawtooth', 440, now, 0.2, 0.3);
|
||||||
|
|
||||||
|
// Second note (lower)
|
||||||
|
this.createOscillator('sawtooth', 220, now + 0.25, 0.3, 0.4);
|
||||||
|
|
||||||
|
// Reset tick fade timer
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createGameStartSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Rising sequence
|
||||||
|
this.createOscillator('sine', 440, now, 0.1, 0.3);
|
||||||
|
this.createOscillator('sine', 554, now + 0.1, 0.1, 0.3);
|
||||||
|
this.createOscillator('sine', 659, now + 0.2, 0.3, 0.3, true);
|
||||||
|
|
||||||
|
// Reset tick fade timer
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createGamePauseSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Two notes pause sound
|
||||||
|
this.createOscillator('sine', 659, now, 0.1, 0.3);
|
||||||
|
this.createOscillator('sine', 523, now + 0.15, 0.2, 0.3, true);
|
||||||
|
|
||||||
|
// Reset tick fade timer
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createGameResumeSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Rising sequence (opposite of pause)
|
||||||
|
this.createOscillator('sine', 523, now, 0.1, 0.3);
|
||||||
|
this.createOscillator('sine', 659, now + 0.15, 0.2, 0.3, true);
|
||||||
|
|
||||||
|
// Reset tick fade timer
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createGameOverSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Fanfare
|
||||||
|
this.createOscillator('square', 440, now, 0.1, 0.3);
|
||||||
|
this.createOscillator('square', 554, now + 0.1, 0.1, 0.3);
|
||||||
|
this.createOscillator('square', 659, now + 0.2, 0.1, 0.3);
|
||||||
|
this.createOscillator('square', 880, now + 0.3, 0.4, 0.3, true);
|
||||||
|
|
||||||
|
// Reset tick fade timer
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerSwitchSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this.createOscillator('sine', 1200, now, 0.05, 0.2);
|
||||||
|
|
||||||
|
// Reset tick fade timer on player switch
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createButtonClickSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
this.createOscillator('sine', 700, now, 0.04, 0.1);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createModalOpenSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Ascending sound
|
||||||
|
this.createOscillator('sine', 400, now, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 600, now + 0.1, 0.1, 0.2);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createModalCloseSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Descending sound
|
||||||
|
this.createOscillator('sine', 600, now, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 400, now + 0.1, 0.1, 0.2);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerAddedSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Positive ascending notes
|
||||||
|
this.createOscillator('sine', 440, now, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 523, now + 0.1, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 659, now + 0.2, 0.2, 0.2, true);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerEditedSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Two note confirmation
|
||||||
|
this.createOscillator('sine', 440, now, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 523, now + 0.15, 0.15, 0.2);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
createPlayerDeletedSound() {
|
||||||
|
return () => {
|
||||||
|
const now = this.audioContext.currentTime;
|
||||||
|
|
||||||
|
// Descending notes
|
||||||
|
this.createOscillator('sine', 659, now, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 523, now + 0.1, 0.1, 0.2);
|
||||||
|
this.createOscillator('sine', 392, now + 0.2, 0.2, 0.2, true);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
// Play a sound if not muted
|
||||||
|
play(soundName) {
|
||||||
|
if (this.muted || !this.sounds[soundName]) return;
|
||||||
|
|
||||||
|
// Resume audio context if it's suspended (needed for newer browsers)
|
||||||
|
if (this.audioContext.state === 'suspended') {
|
||||||
|
this.audioContext.resume();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.sounds[soundName]();
|
||||||
|
},
|
||||||
|
|
||||||
|
// Toggle mute state
|
||||||
|
toggleMute() {
|
||||||
|
this.muted = !this.muted;
|
||||||
|
localStorage.setItem('gameTimerMuted', this.muted);
|
||||||
|
return this.muted;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Play timer sounds based on remaining time
|
||||||
|
playTimerSound(remainingSeconds) {
|
||||||
|
if (remainingSeconds <= 0) {
|
||||||
|
// Reset tick fade timer when timer stops
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
return; // Don't play sounds for zero time
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remainingSeconds <= this.lowTimeThreshold) {
|
||||||
|
// Play low time warning sound (this resets the tick fade timer)
|
||||||
|
this.play('lowTime');
|
||||||
|
} else if (remainingSeconds % 1 === 0) {
|
||||||
|
// Normal tick sound on every second
|
||||||
|
this.play('tick');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Play timer expired sound
|
||||||
|
playTimerExpired() {
|
||||||
|
this.play('timeUp');
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop all sounds and reset the tick fading
|
||||||
|
stopAllSounds() {
|
||||||
|
// Reset tick fade timer when stopping sounds
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Stop timer-specific sounds (tick, low time warning)
|
||||||
|
stopTimerSounds() {
|
||||||
|
// Reset tick fade timer when stopping timer sounds
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
// In this implementation, sounds are so short-lived that
|
||||||
|
// they don't need to be explicitly stopped, just fade prevention is enough
|
||||||
|
},
|
||||||
|
|
||||||
|
// Reset the tick fading (call this when timer is paused or player changes)
|
||||||
|
resetTickFade() {
|
||||||
|
this.lastTickTime = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize audio on module load
|
||||||
|
audioManager.init();
|
||||||
|
|
||||||
|
// Export the audio manager
|
||||||
|
export default audioManager;
|
||||||
116
src/js/ui/camera.js
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
// camera.js
|
||||||
|
import { CSS_CLASSES } from '../config.js';
|
||||||
|
|
||||||
|
let stream = null;
|
||||||
|
let elements = {}; // To store references to DOM elements passed during init
|
||||||
|
let onCaptureCallback = null; // Callback when image is captured
|
||||||
|
|
||||||
|
export function initCamera(cameraElements, options) {
|
||||||
|
elements = cameraElements; // Store refs like { cameraContainer, cameraView, etc. }
|
||||||
|
onCaptureCallback = options.onCapture;
|
||||||
|
|
||||||
|
// Add internal listeners for capture/cancel buttons
|
||||||
|
elements.cameraCaptureButton?.addEventListener('click', handleCapture);
|
||||||
|
elements.cameraCancelButton?.addEventListener('click', closeCamera);
|
||||||
|
|
||||||
|
// Handle orientation change to potentially reset stream dimensions
|
||||||
|
window.addEventListener('orientationchange', handleOrientationChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCamera() {
|
||||||
|
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
|
||||||
|
alert('Camera access not supported or available on this device.');
|
||||||
|
return false; // Indicate failure
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
video: {
|
||||||
|
facingMode: 'user', // Prefer front camera
|
||||||
|
width: { ideal: 1280 },
|
||||||
|
height: { ideal: 720 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.cameraContainer?.classList.add(CSS_CLASSES.CAMERA_ACTIVE);
|
||||||
|
if (elements.cameraView) {
|
||||||
|
elements.cameraView.srcObject = stream;
|
||||||
|
// Wait for video metadata to load to get correct dimensions
|
||||||
|
elements.cameraView.onloadedmetadata = () => {
|
||||||
|
elements.cameraView.play(); // Start playing the video stream
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return true; // Indicate success
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error accessing camera:', error);
|
||||||
|
alert('Could not access camera: ' + error.message);
|
||||||
|
closeCamera(); // Ensure cleanup if opening failed
|
||||||
|
return false; // Indicate failure
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCapture() {
|
||||||
|
if (!elements.cameraView || !elements.cameraCanvas || !stream) return;
|
||||||
|
|
||||||
|
// Set canvas dimensions to match video's actual dimensions
|
||||||
|
elements.cameraCanvas.width = elements.cameraView.videoWidth;
|
||||||
|
elements.cameraCanvas.height = elements.cameraView.videoHeight;
|
||||||
|
|
||||||
|
// Draw the current video frame to the canvas
|
||||||
|
const context = elements.cameraCanvas.getContext('2d');
|
||||||
|
// Flip horizontally for front camera to make it mirror-like
|
||||||
|
if (stream.getVideoTracks()[0]?.getSettings()?.facingMode === 'user') {
|
||||||
|
context.translate(elements.cameraCanvas.width, 0);
|
||||||
|
context.scale(-1, 1);
|
||||||
|
}
|
||||||
|
context.drawImage(elements.cameraView, 0, 0, elements.cameraCanvas.width, elements.cameraCanvas.height);
|
||||||
|
|
||||||
|
// Convert canvas to data URL (JPEG format)
|
||||||
|
const imageDataUrl = elements.cameraCanvas.toDataURL('image/jpeg', 0.9); // Quality 0.9
|
||||||
|
|
||||||
|
// Call the callback provided during init with the image data
|
||||||
|
if (onCaptureCallback) {
|
||||||
|
onCaptureCallback(imageDataUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop stream and hide UI after capture
|
||||||
|
closeCamera();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCameraStream() {
|
||||||
|
if (stream) {
|
||||||
|
stream.getTracks().forEach(track => track.stop());
|
||||||
|
stream = null;
|
||||||
|
}
|
||||||
|
// Also clear the srcObject
|
||||||
|
if (elements.cameraView) {
|
||||||
|
elements.cameraView.srcObject = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCamera() {
|
||||||
|
stopCameraStream();
|
||||||
|
elements.cameraContainer?.classList.remove(CSS_CLASSES.CAMERA_ACTIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOrientationChange() {
|
||||||
|
// If camera is active, restart stream to potentially adjust aspect ratio/resolution
|
||||||
|
if (elements.cameraContainer?.classList.contains(CSS_CLASSES.CAMERA_ACTIVE) && stream) {
|
||||||
|
console.log("Orientation changed, re-evaluating camera stream...");
|
||||||
|
// Short delay to allow layout to settle
|
||||||
|
setTimeout(async () => {
|
||||||
|
// Stop existing stream before requesting new one
|
||||||
|
// This might cause a flicker but ensures constraints are re-evaluated
|
||||||
|
stopCameraStream();
|
||||||
|
await openCamera(); // Attempt to reopen with potentially new constraints
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API for camera module
|
||||||
|
export default {
|
||||||
|
init: initCamera,
|
||||||
|
open: openCamera,
|
||||||
|
close: closeCamera,
|
||||||
|
stopStream: stopCameraStream // Expose if needed externally, e.g., when modal closes
|
||||||
|
};
|
||||||
289
src/js/ui/ui.js
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
// ui.js
|
||||||
|
import * as state from '../core/state.js';
|
||||||
|
import { GAME_STATES, CSS_CLASSES, DEFAULT_PLAYER_TIME_SECONDS } from '../config.js';
|
||||||
|
import audioManager from './audio.js';
|
||||||
|
|
||||||
|
// --- DOM Elements ---
|
||||||
|
export const elements = {
|
||||||
|
carousel: document.getElementById('carousel'),
|
||||||
|
gameButton: document.getElementById('gameButton'),
|
||||||
|
setupButton: document.getElementById('setupButton'),
|
||||||
|
addPlayerButton: document.getElementById('addPlayerButton'),
|
||||||
|
resetButton: document.getElementById('resetButton'),
|
||||||
|
playerModal: document.getElementById('playerModal'),
|
||||||
|
resetModal: document.getElementById('resetModal'),
|
||||||
|
playerForm: document.getElementById('playerForm'),
|
||||||
|
modalTitle: document.getElementById('modalTitle'),
|
||||||
|
playerNameInput: document.getElementById('playerName'),
|
||||||
|
playerTimeInput: document.getElementById('playerTime'),
|
||||||
|
playerImageInput: document.getElementById('playerImage'),
|
||||||
|
imagePreview: document.getElementById('imagePreview'),
|
||||||
|
playerTimeContainer: document.getElementById('playerTimeContainer'), // Parent of playerTimeInput
|
||||||
|
remainingTimeContainer: document.getElementById('remainingTimeContainer'),
|
||||||
|
playerRemainingTimeInput: document.getElementById('playerRemainingTime'),
|
||||||
|
deletePlayerButton: document.getElementById('deletePlayerButton'),
|
||||||
|
cancelButton: document.getElementById('cancelButton'), // Modal cancel
|
||||||
|
resetCancelButton: document.getElementById('resetCancelButton'),
|
||||||
|
resetConfirmButton: document.getElementById('resetConfirmButton'),
|
||||||
|
cameraButton: document.getElementById('cameraButton'),
|
||||||
|
// Camera elements needed by camera.js, but listed here for central management if desired
|
||||||
|
cameraContainer: document.getElementById('cameraContainer'),
|
||||||
|
cameraView: document.getElementById('cameraView'),
|
||||||
|
cameraCanvas: document.getElementById('cameraCanvas'),
|
||||||
|
cameraCaptureButton: document.getElementById('cameraCaptureButton'),
|
||||||
|
cameraCancelButton: document.getElementById('cameraCancelButton'),
|
||||||
|
// Header buttons container for sound toggle
|
||||||
|
headerButtons: document.querySelector('.header-buttons')
|
||||||
|
};
|
||||||
|
|
||||||
|
let isDragging = false;
|
||||||
|
let startX = 0;
|
||||||
|
let currentX = 0;
|
||||||
|
let carouselSwipeHandler = null; // To store the bound function for removal
|
||||||
|
|
||||||
|
// --- Rendering Functions ---
|
||||||
|
|
||||||
|
export function renderPlayers() {
|
||||||
|
const players = state.getPlayers();
|
||||||
|
const currentIndex = state.getCurrentPlayerIndex();
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
elements.carousel.innerHTML = '';
|
||||||
|
|
||||||
|
if (players.length === 0) {
|
||||||
|
// Optionally display a message if there are no players
|
||||||
|
elements.carousel.innerHTML = '<p style="text-align: center; width: 100%;">Add players to start</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
players.forEach((player, index) => {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
const isActive = index === currentIndex;
|
||||||
|
card.className = `player-card ${isActive ? CSS_CLASSES.ACTIVE_PLAYER : CSS_CLASSES.INACTIVE_PLAYER}`;
|
||||||
|
|
||||||
|
const minutes = Math.floor(player.remainingTime / 60);
|
||||||
|
const seconds = player.remainingTime % 60;
|
||||||
|
const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
|
||||||
|
const timerClasses = [];
|
||||||
|
if (isActive && currentGameState === GAME_STATES.RUNNING) {
|
||||||
|
timerClasses.push(CSS_CLASSES.TIMER_ACTIVE);
|
||||||
|
}
|
||||||
|
if (player.remainingTime <= 0) {
|
||||||
|
timerClasses.push(CSS_CLASSES.TIMER_FINISHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="player-image">
|
||||||
|
${player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>'}
|
||||||
|
</div>
|
||||||
|
<div class="player-name">${player.name}</div>
|
||||||
|
<div class="player-timer ${timerClasses.join(' ')}">${timeString}</div>
|
||||||
|
`;
|
||||||
|
elements.carousel.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateCarouselPosition();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateCarouselPosition() {
|
||||||
|
const currentIndex = state.getCurrentPlayerIndex();
|
||||||
|
elements.carousel.style.transform = `translateX(${-100 * currentIndex}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateGameButton() {
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
switch (currentGameState) {
|
||||||
|
case GAME_STATES.SETUP:
|
||||||
|
elements.gameButton.textContent = 'Start Game';
|
||||||
|
break;
|
||||||
|
case GAME_STATES.RUNNING:
|
||||||
|
elements.gameButton.textContent = 'Pause Game';
|
||||||
|
break;
|
||||||
|
case GAME_STATES.PAUSED:
|
||||||
|
elements.gameButton.textContent = 'Resume Game';
|
||||||
|
break;
|
||||||
|
case GAME_STATES.OVER:
|
||||||
|
elements.gameButton.textContent = 'Game Over';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Disable button if less than 2 players in setup
|
||||||
|
elements.gameButton.disabled = currentGameState === GAME_STATES.SETUP && state.getPlayers().length < 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Modal Functions ---
|
||||||
|
|
||||||
|
export function showPlayerModal(isNewPlayer, player = null) {
|
||||||
|
const currentGameState = state.getGameState();
|
||||||
|
if (isNewPlayer) {
|
||||||
|
elements.modalTitle.textContent = 'Add New Player';
|
||||||
|
elements.playerNameInput.value = `Player ${state.getPlayers().length + 1}`;
|
||||||
|
elements.playerTimeInput.value = DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time from config
|
||||||
|
elements.remainingTimeContainer.style.display = 'none';
|
||||||
|
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible for new players
|
||||||
|
elements.imagePreview.innerHTML = '<i class="fas fa-user"></i>';
|
||||||
|
elements.deletePlayerButton.style.display = 'none';
|
||||||
|
} else if (player) {
|
||||||
|
elements.modalTitle.textContent = 'Edit Player';
|
||||||
|
elements.playerNameInput.value = player.name;
|
||||||
|
elements.playerTimeInput.value = player.timeInSeconds / 60;
|
||||||
|
|
||||||
|
if (currentGameState === GAME_STATES.PAUSED || currentGameState === GAME_STATES.OVER) {
|
||||||
|
elements.remainingTimeContainer.style.display = 'block';
|
||||||
|
elements.playerTimeContainer.style.display = 'none'; // Hide Time field when Remaining Time is shown
|
||||||
|
const minutes = Math.floor(player.remainingTime / 60);
|
||||||
|
const seconds = player.remainingTime % 60;
|
||||||
|
elements.playerRemainingTimeInput.value = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
} else {
|
||||||
|
elements.remainingTimeContainer.style.display = 'none';
|
||||||
|
elements.playerTimeContainer.style.display = 'block'; // Ensure Time field is visible otherwise
|
||||||
|
}
|
||||||
|
|
||||||
|
elements.imagePreview.innerHTML = player.image ? `<img src="${player.image}" alt="${player.name}">` : '<i class="fas fa-user"></i>';
|
||||||
|
elements.deletePlayerButton.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input and captured image data
|
||||||
|
elements.playerImageInput.value = '';
|
||||||
|
elements.playerImageInput.dataset.capturedImage = '';
|
||||||
|
|
||||||
|
elements.playerModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
|
||||||
|
audioManager.play('modalOpen');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hidePlayerModal() {
|
||||||
|
elements.playerModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
|
||||||
|
audioManager.play('modalClose');
|
||||||
|
// Potentially call camera cleanup here if it wasn't done elsewhere
|
||||||
|
}
|
||||||
|
|
||||||
|
export function showResetModal() {
|
||||||
|
elements.resetModal.classList.add(CSS_CLASSES.MODAL_ACTIVE);
|
||||||
|
audioManager.play('modalOpen');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hideResetModal() {
|
||||||
|
elements.resetModal.classList.remove(CSS_CLASSES.MODAL_ACTIVE);
|
||||||
|
audioManager.play('modalClose');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateImagePreviewFromFile(file) {
|
||||||
|
if (file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (event) => {
|
||||||
|
elements.imagePreview.innerHTML = `<img src="${event.target.result}" alt="Player Preview">`;
|
||||||
|
// Clear any previously captured image data if a file is selected
|
||||||
|
elements.playerImageInput.dataset.capturedImage = '';
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateImagePreviewFromDataUrl(dataUrl) {
|
||||||
|
elements.imagePreview.innerHTML = `<img src="${dataUrl}" alt="Player Preview">`;
|
||||||
|
// Store data URL and clear file input
|
||||||
|
elements.playerImageInput.dataset.capturedImage = dataUrl;
|
||||||
|
elements.playerImageInput.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// --- Carousel Touch Handling ---
|
||||||
|
|
||||||
|
function handleTouchStart(e) {
|
||||||
|
startX = e.touches[0].clientX;
|
||||||
|
currentX = startX;
|
||||||
|
isDragging = true;
|
||||||
|
// Optional: Add a class to the carousel for visual feedback during drag
|
||||||
|
elements.carousel.style.transition = 'none'; // Disable transition during drag
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
currentX = e.touches[0].clientX;
|
||||||
|
const diff = currentX - startX;
|
||||||
|
const currentIndex = state.getCurrentPlayerIndex();
|
||||||
|
const currentTranslate = -100 * currentIndex + (diff / elements.carousel.offsetWidth * 100);
|
||||||
|
|
||||||
|
elements.carousel.style.transform = `translateX(${currentTranslate}%)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchEnd(e) {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
isDragging = false;
|
||||||
|
elements.carousel.style.transition = ''; // Re-enable transition
|
||||||
|
|
||||||
|
const diff = currentX - startX;
|
||||||
|
const threshold = elements.carousel.offsetWidth * 0.1; // 10% swipe threshold
|
||||||
|
|
||||||
|
if (Math.abs(diff) > threshold) {
|
||||||
|
// Call the handler passed during initialization
|
||||||
|
if (carouselSwipeHandler) {
|
||||||
|
carouselSwipeHandler(diff < 0 ? 1 : -1); // Pass direction: 1 for next, -1 for prev
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Snap back if swipe wasn't enough
|
||||||
|
updateCarouselPosition();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UI Initialization ---
|
||||||
|
|
||||||
|
// Add sound toggle button
|
||||||
|
function createSoundToggleButton() {
|
||||||
|
const soundButton = document.createElement('button');
|
||||||
|
soundButton.id = 'soundToggleButton';
|
||||||
|
soundButton.className = 'header-button';
|
||||||
|
soundButton.title = 'Toggle Sound';
|
||||||
|
soundButton.innerHTML = audioManager.muted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
|
||||||
|
|
||||||
|
soundButton.addEventListener('click', () => {
|
||||||
|
const isMuted = audioManager.toggleMute();
|
||||||
|
soundButton.innerHTML = isMuted ? '<i class="fas fa-volume-mute"></i>' : '<i class="fas fa-volume-up"></i>';
|
||||||
|
if (!isMuted) audioManager.play('buttonClick'); // Feedback only when unmuting
|
||||||
|
});
|
||||||
|
|
||||||
|
elements.headerButtons.prepend(soundButton); // Add to the beginning
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse time string (MM:SS) to seconds - Helper needed for form processing
|
||||||
|
export function parseTimeString(timeString) {
|
||||||
|
if (!/^\d{1,2}:\d{2}$/.test(timeString)) {
|
||||||
|
console.error('Invalid time format:', timeString);
|
||||||
|
return null; // Indicate error
|
||||||
|
}
|
||||||
|
const parts = timeString.split(':');
|
||||||
|
const minutes = parseInt(parts[0], 10);
|
||||||
|
const seconds = parseInt(parts[1], 10);
|
||||||
|
if (isNaN(minutes) || isNaN(seconds) || seconds > 59) {
|
||||||
|
console.error('Invalid time value:', timeString);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (minutes * 60) + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sets up basic UI elements and listeners that primarily affect the UI itself
|
||||||
|
export function initUI(options) {
|
||||||
|
// Store the swipe handler provided by app.js
|
||||||
|
carouselSwipeHandler = options.onCarouselSwipe;
|
||||||
|
|
||||||
|
createSoundToggleButton();
|
||||||
|
|
||||||
|
// Carousel touch events
|
||||||
|
elements.carousel.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
elements.carousel.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||||
|
elements.carousel.addEventListener('touchend', handleTouchEnd);
|
||||||
|
|
||||||
|
// Image file input preview
|
||||||
|
elements.playerImageInput.addEventListener('change', (e) => {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
updateImagePreviewFromFile(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial render
|
||||||
|
renderPlayers();
|
||||||
|
updateGameButton();
|
||||||
|
}
|
||||||
205
sw.js
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
// Service Worker version
|
||||||
|
const CACHE_VERSION = 'v1.0.2';
|
||||||
|
const CACHE_NAME = `game-timer-${CACHE_VERSION}`;
|
||||||
|
|
||||||
|
// Files to cache
|
||||||
|
const CACHE_FILES = [
|
||||||
|
'/',
|
||||||
|
'/sw.js',
|
||||||
|
'/index.html',
|
||||||
|
'/manifest.json',
|
||||||
|
'/css/styles.css',
|
||||||
|
'/favicon.ico',
|
||||||
|
'/icons/android-chrome-192x192.png',
|
||||||
|
'/icons/android-chrome-512x512.png',
|
||||||
|
'/icons/apple-touch-icon.png',
|
||||||
|
'/icons/favicon-32x32.png',
|
||||||
|
'/icons/favicon-16x16.png',
|
||||||
|
'/js/app.js',
|
||||||
|
'/js/config.js',
|
||||||
|
'/js/env-loader.js',
|
||||||
|
'/js/ui/audio.js',
|
||||||
|
'/js/ui/camera.js',
|
||||||
|
'/js/ui/ui.js',
|
||||||
|
'/js/core/state.js',
|
||||||
|
'/js/core/timer.js',
|
||||||
|
'/js/core/gameActions.js',
|
||||||
|
'/js/core/playerManager.js',
|
||||||
|
'/js/core/eventHandlers.js',
|
||||||
|
'/js/services/pushFlicIntegration.js',
|
||||||
|
'/js/services/serviceWorkerManager.js'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Install event - Cache files
|
||||||
|
self.addEventListener('install', event => {
|
||||||
|
console.log('[ServiceWorker] Install');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.open(CACHE_NAME)
|
||||||
|
.then(cache => {
|
||||||
|
console.log('[ServiceWorker] Caching app shell');
|
||||||
|
return cache.addAll(CACHE_FILES);
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[ServiceWorker] Skip waiting on install');
|
||||||
|
return self.skipWaiting();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Activate event - Clean old caches
|
||||||
|
self.addEventListener('activate', event => {
|
||||||
|
console.log('[ServiceWorker] Activate');
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then(keyList => {
|
||||||
|
return Promise.all(keyList.map(key => {
|
||||||
|
if (key !== CACHE_NAME) {
|
||||||
|
console.log('[ServiceWorker] Removing old cache', key);
|
||||||
|
return caches.delete(key);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
console.log('[ServiceWorker] Claiming clients');
|
||||||
|
return self.clients.claim();
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to determine if a response should be cached
|
||||||
|
function shouldCacheResponse(request, response) {
|
||||||
|
// Only cache GET requests
|
||||||
|
if (request.method !== 'GET') return false;
|
||||||
|
|
||||||
|
// Don't cache errors
|
||||||
|
if (!response || response.status !== 200) return false;
|
||||||
|
|
||||||
|
// Check if URL should be cached
|
||||||
|
const url = new URL(request.url);
|
||||||
|
|
||||||
|
// Don't cache query parameters (except common ones for content)
|
||||||
|
if (url.search && !url.search.match(/\?(v|version|cache)=/)) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.addEventListener('push', event => {
|
||||||
|
console.log('[ServiceWorker] Push received');
|
||||||
|
|
||||||
|
let pushData = {
|
||||||
|
title: 'Flic Action',
|
||||||
|
body: 'Button pressed!',
|
||||||
|
data: {
|
||||||
|
action: 'Unknown',
|
||||||
|
button: 'Unknown',
|
||||||
|
batteryLevel: undefined
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Attempt to parse data payload ---
|
||||||
|
if (event.data) {
|
||||||
|
try {
|
||||||
|
const parsedData = event.data.json();
|
||||||
|
console.log('[ServiceWorker] Push data:', parsedData);
|
||||||
|
|
||||||
|
// Use parsed data for notification and message
|
||||||
|
pushData = {
|
||||||
|
title: parsedData.title || pushData.title,
|
||||||
|
body: parsedData.body || pushData.body,
|
||||||
|
data: parsedData.data || pushData.data // Expecting { action: 'SingleClick', button: 'game-button', batteryLevel: 75 }
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (e) {
|
||||||
|
console.error('[ServiceWorker] Error parsing push data:', e);
|
||||||
|
// Use default notification if parsing fails
|
||||||
|
pushData.body = event.data.text() || pushData.body; // Fallback to text
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('[ServiceWorker] Push event but no data');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Send message to client(s) ---
|
||||||
|
const messagePayload = {
|
||||||
|
type: 'flic-action', // Custom message type
|
||||||
|
action: pushData.data.action, // e.g., 'SingleClick', 'DoubleClick', 'Hold'
|
||||||
|
button: pushData.data.button, // e.g., the button name
|
||||||
|
timestamp: pushData.data.timestamp, // 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
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({
|
||||||
|
type: 'window', // Only target window clients
|
||||||
|
includeUncontrolled: true // Include clients that might not be fully controlled yet
|
||||||
|
}).then(clientList => {
|
||||||
|
if (!clientList || clientList.length === 0) {
|
||||||
|
console.log('[ServiceWorker] No client windows found to send message to.');
|
||||||
|
// If no window is open, we MUST show a notification
|
||||||
|
return self.registration.showNotification(pushData.title, {
|
||||||
|
body: pushData.body,
|
||||||
|
icon: '/icons/android-chrome-192x192.png', // Updated path
|
||||||
|
data: pushData.data // Pass data if needed when notification is clicked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post message to each client
|
||||||
|
let messageSent = false;
|
||||||
|
clientList.forEach(client => {
|
||||||
|
console.log(`[ServiceWorker] Posting message to client: ${client.id}`, messagePayload);
|
||||||
|
client.postMessage(messagePayload);
|
||||||
|
messageSent = true; // Mark that we at least tried to send a message
|
||||||
|
});
|
||||||
|
|
||||||
|
// Decide whether to still show a notification even if a window is open.
|
||||||
|
// Generally good practice unless you are SURE the app will handle it visibly.
|
||||||
|
// You might choose *not* to show a notification if a client was found and focused.
|
||||||
|
// 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/android-chrome-192x192.png',
|
||||||
|
data: pushData.data
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// --- Show a notification (Important!) ---
|
||||||
|
// Push notifications generally REQUIRE showing a notification to the user
|
||||||
|
// unless the PWA is already in the foreground AND handles the event visually.
|
||||||
|
// It's safer to always show one unless you have complex foreground detection.
|
||||||
|
/* This part is now handled inside the clients.matchAll promise */
|
||||||
|
/*
|
||||||
|
const notificationOptions = {
|
||||||
|
body: pushData.body,
|
||||||
|
icon: './icons/android-chrome-192x192.png', // Optional: path to an icon
|
||||||
|
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
|
||||||
|
self.addEventListener('notificationclick', event => {
|
||||||
|
console.log('[ServiceWorker] Notification click received');
|
||||||
|
|
||||||
|
event.notification.close();
|
||||||
|
|
||||||
|
// Handle the notification click
|
||||||
|
event.waitUntil(
|
||||||
|
self.clients.matchAll({ type: 'window' })
|
||||||
|
.then(clientList => {
|
||||||
|
for (const client of clientList) {
|
||||||
|
if (client.url.startsWith(self.location.origin) && 'focus' in client) {
|
||||||
|
return client.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (self.clients.openWindow) {
|
||||||
|
return self.clients.openWindow('/');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
36
test.html
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Service Worker Test</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Service Worker Test</h1>
|
||||||
|
<p>This page tests if the service worker is registered correctly.</p>
|
||||||
|
<div id="status">Checking service worker registration...</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const statusDiv = document.getElementById('status');
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
statusDiv.innerHTML = 'Service worker registered successfully!<br>' +
|
||||||
|
'Scope: ' + registration.scope;
|
||||||
|
console.log('ServiceWorker registration successful with scope: ', registration.scope);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
statusDiv.innerHTML = 'Service worker registration failed: ' + error;
|
||||||
|
console.error('ServiceWorker registration failed: ', error);
|
||||||
|
});
|
||||||
|
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
statusDiv.innerHTML += '<br><br>Service worker is ready!';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
statusDiv.innerHTML = 'Service workers are not supported in this browser.';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
30
virt-game-timer.service
Normal 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
|
||||||