Compare commits

..

2 Commits

Author SHA1 Message Date
cpu
2e47461f34 clean up 2025-03-28 05:50:28 +01:00
cpu
d1ad962ec3 refactoring 2025-03-28 05:04:25 +01:00
10 changed files with 1359 additions and 1352 deletions

1369
app.js

File diff suppressed because it is too large Load Diff

116
camera.js Normal file
View File

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

37
config.js Normal file
View File

@@ -0,0 +1,37 @@
// config.js
export const PUBLIC_VAPID_KEY = 'BKfRJXjSQmAJ452gLwlK_8scGrW6qMU1mBRp39ONtcQHkSsQgmLAaODIyGbgHyRpnDEv3HfXV1oGh3SC0fHxY0E';
export const BACKEND_URL = 'https://webpush.virtonline.eu';
export const FLIC_BUTTON_ID = 'game-button'; // Example ID, might need configuration
export const LOCAL_STORAGE_KEY = 'gameTimerData';
// Default player settings
export const DEFAULT_PLAYER_TIME_SECONDS = 300; // 5 minutes
export const DEFAULT_PLAYERS = [
{ id: 1, name: 'Player 1', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null },
{ id: 2, name: 'Player 2', timeInSeconds: DEFAULT_PLAYER_TIME_SECONDS, remainingTime: DEFAULT_PLAYER_TIME_SECONDS, image: null }
];
// CSS Classes (optional, but can help consistency)
export const CSS_CLASSES = {
ACTIVE_PLAYER: 'active-player',
INACTIVE_PLAYER: 'inactive-player',
TIMER_ACTIVE: 'timer-active',
TIMER_FINISHED: 'timer-finished',
MODAL_ACTIVE: 'active',
CAMERA_ACTIVE: 'active'
};
// Game States
export const GAME_STATES = {
SETUP: 'setup',
RUNNING: 'running',
PAUSED: 'paused',
OVER: 'over'
};
// Flic Actions
export const FLIC_ACTIONS = {
SINGLE_CLICK: 'SingleClick',
DOUBLE_CLICK: 'DoubleClick',
HOLD: 'Hold'
};

View File

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

View File

@@ -9,14 +9,6 @@
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/css/all.min.css">
<link rel="stylesheet" href="styles.css"> <link rel="stylesheet" href="styles.css">
<!-- Deep Linking - App Links for Android -->
<link rel="alternate" href="android-app://eu.virtonline.gametimer/https://game-timer.virtonline.eu" />
<!-- Deep Linking - Universal Links for iOS -->
<meta name="apple-itunes-app" content="app-id=yourAppID, app-argument=https://game-timer.virtonline.eu">
<!-- Deep Linking - Web App URL Handling -->
<link rel="alternate" href="web+gametimer://action" />
</head> </head>
<body> <body>
<div class="app-container"> <div class="app-container">
@@ -109,52 +101,6 @@
</div> </div>
</div> </div>
<!-- Script for handling URL scheme and deep links -->
<script type="module">
// Register the custom URL protocol handler (web+gametimer://)
if ('registerProtocolHandler' in navigator) {
try {
navigator.registerProtocolHandler(
'web+gametimer',
'https://game-timer.virtonline.eu/?action=%s',
'Game Timer'
);
console.log('Protocol handler registered');
} catch (e) {
console.error('Failed to register protocol handler:', e);
}
}
// Function to parse URL parameters
function getUrlParams() {
const searchParams = new URLSearchParams(window.location.search);
const hashParams = new URLSearchParams(window.location.hash.substring(1));
// Check search parameters first (for direct links)
const action = searchParams.get('action');
if (action) {
// Clean the action parameter (remove 'web+gametimer://' if present)
return action.replace('web+gametimer://', '');
}
// Then check hash parameters (for deep links)
return hashParams.get('action');
}
// Initialize URL handling
function initUrlHandling() {
const action = getUrlParams();
if (action) {
console.log('URL action detected:', action);
// Set the action in the hash to be processed by the main app
window.location.hash = `action=${action}`;
}
}
// Run initialization when DOM is fully loaded
document.addEventListener('DOMContentLoaded', initUrlHandling);
</script>
<!-- Main application script --> <!-- Main application script -->
<script type="module" src="app.js"></script> <script type="module" src="app.js"></script>
<script> <script>

206
pushFlicIntegration.js Normal file
View File

@@ -0,0 +1,206 @@
// pushFlicIntegration.js
import { PUBLIC_VAPID_KEY, BACKEND_URL, FLIC_BUTTON_ID, FLIC_ACTIONS } from './config.js';
let pushSubscription = null; // Keep track locally if needed
let actionHandlers = {}; // Store handlers for different Flic actions
// --- Helper Functions ---
// Get stored basic auth credentials or prompt user for them
function getBasicAuthCredentials() {
const storedAuth = localStorage.getItem('basicAuthCredentials');
if (storedAuth) {
try { return JSON.parse(storedAuth); } catch (error) { console.error('Failed to parse stored credentials:', error); }
}
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(/=+$/, '');
}
// --- 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 {
const permission = await Notification.requestPermission();
if (permission !== 'granted') {
console.warn('Notification permission denied.');
alert('Please enable notifications to link the Flic button.');
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) !== PUBLIC_VAPID_KEY) {
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(PUBLIC_VAPID_KEY);
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) {
alert('Authentication required to save button link.');
return;
}
const headers = { 'Content-Type': 'application/json' };
const authHeader = createBasicAuthHeader(credentials);
if (authHeader) headers['Authorization'] = authHeader;
try {
const response = await fetch(`${BACKEND_URL}/subscribe`, {
method: 'POST',
body: JSON.stringify({ button_id: buttonId, subscription: subscription }),
headers: headers
});
if (response.ok) {
const result = await response.json();
console.log('Subscription sent successfully:', result.message);
// Maybe show a success message to the user
} 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) {
console.log(`[PushFlic] Received Action: ${action} from Button: ${buttonId} at ${timestamp}`);
// Ignore actions from buttons other than the configured one
if (buttonId !== FLIC_BUTTON_ID) {
console.warn(`[PushFlic] Ignoring action from unknown button: ${buttonId}`);
return;
}
// Find the registered handler for this action
const handler = actionHandlers[action];
if (handler && typeof handler === 'function') {
console.log(`[PushFlic] Executing handler for ${action}`);
// Execute the handler registered in app.js
switch(action) {
case FLIC_ACTIONS.SINGLE_CLICK:
nextPlayer();
break;
case FLIC_ACTIONS.DOUBLE_CLICK:
previousPlayer();
break;
case FLIC_ACTIONS.HOLD:
togglePauseResume();
}
} 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
}
});
});
}
}

230
state.js Normal file
View File

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

79
sw.js
View File

@@ -8,7 +8,6 @@ const CACHE_FILES = [
'/index.html', '/index.html',
'/app.js', '/app.js',
'/audio.js', '/audio.js',
'/deeplinks.js',
'/styles.css', '/styles.css',
'/manifest.json', '/manifest.json',
'/icons/android-chrome-192x192.png', '/icons/android-chrome-192x192.png',
@@ -18,9 +17,6 @@ const CACHE_FILES = [
'/icons/favicon-16x16.png' '/icons/favicon-16x16.png'
]; ];
// Valid deep link actions
const VALID_ACTIONS = ['start', 'pause', 'toggle', 'nextplayer', 'reset'];
// Install event - Cache files // Install event - Cache files
self.addEventListener('install', event => { self.addEventListener('install', event => {
console.log('[ServiceWorker] Install'); console.log('[ServiceWorker] Install');
@@ -56,57 +52,6 @@ self.addEventListener('activate', event => {
); );
}); });
// Fetch event - Serve from cache, fallback to network
self.addEventListener('fetch', event => {
console.log('[ServiceWorker] Fetch', event.request.url);
// For navigation requests that include our deep link parameters,
// skip the cache and go straight to network
if (event.request.mode === 'navigate') {
const url = new URL(event.request.url);
// Check if request has action parameter or hash
if (url.searchParams.has('action') || url.hash.includes('action=')) {
console.log('[ServiceWorker] Processing deep link navigation');
// Verify the action is valid
const action = url.searchParams.get('action') ||
new URLSearchParams(url.hash.substring(1)).get('action');
if (action && VALID_ACTIONS.includes(action)) {
console.log('[ServiceWorker] Valid action found:', action);
// For navigation requests with valid actions, let the request go through
// so our app can handle the deep link
return;
}
}
}
event.respondWith(
caches.match(event.request)
.then(response => {
return response || fetch(event.request)
.then(res => {
// Check if we should cache this response
if (shouldCacheResponse(event.request, res)) {
return caches.open(CACHE_NAME)
.then(cache => {
console.log('[ServiceWorker] Caching new resource:', event.request.url);
cache.put(event.request, res.clone());
return res;
});
}
return res;
});
})
.catch(error => {
console.log('[ServiceWorker] Fetch failed; returning offline page', error);
// You could return a custom offline page here
})
);
});
// Helper function to determine if a response should be cached // Helper function to determine if a response should be cached
function shouldCacheResponse(request, response) { function shouldCacheResponse(request, response) {
// Only cache GET requests // Only cache GET requests
@@ -124,30 +69,6 @@ function shouldCacheResponse(request, response) {
return true; return true;
} }
// Handle deep links from other apps (including Flic)
self.addEventListener('message', event => {
if (event.data && event.data.type === 'PROCESS_ACTION') {
const action = event.data.action;
console.log('[ServiceWorker] Received action message:', action);
// Validate the action
if (!VALID_ACTIONS.includes(action)) {
console.warn('[ServiceWorker] Invalid action received:', action);
return;
}
// Broadcast the action to all clients
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'ACTION',
action: action
});
});
});
}
});
self.addEventListener('push', event => { self.addEventListener('push', event => {
console.log('[ServiceWorker] Push received'); console.log('[ServiceWorker] Push received');

99
timer.js Normal file
View File

@@ -0,0 +1,99 @@
// timer.js
import * as state from './state.js';
import { GAME_STATES } from './config.js';
import audioManager from './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
export function initTimer(options) {
onTimerTickCallback = options.onTimerTick;
onPlayerSwitchCallback = options.onPlayerSwitch;
onGameOverCallback = options.onGameOver;
}
export function startTimer() {
if (timerInterval) clearInterval(timerInterval); // Clear existing interval if any
// Stop any previous sounds (like low time warning) before starting fresh
audioManager.stopAllSounds(); // Consider if this is too aggressive
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
audioManager.playTimerSound(newTime);
// Notify UI to update
if (onTimerTickCallback) onTimerTickCallback();
} else { // Current player's time just hit 0 or was already 0
// Ensure time is exactly 0 if it somehow went negative (unlikely with check above)
if(currentPlayer.remainingTime < 0) {
state.updatePlayerTime(currentPlayerIndex, 0);
}
// Stop this player's timer tick sound if it was playing
// audioManager.stop('timerTick'); // Or specific low time sound
// Play time expired sound (only once)
// Check if we just hit zero to avoid playing repeatedly
// This logic might be complex, audioManager could handle idempotency
if (currentPlayer.remainingTime === 0 && !currentPlayer.timeExpiredSoundPlayed) {
audioManager.playTimerExpired();
// We need a way to mark that the sound played for this player this turn.
// This might require adding a temporary flag to the player state,
// or handling it within the audioManager. Let's assume audioManager handles it for now.
}
// 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
if (onPlayerSwitchCallback) onPlayerSwitchCallback(nextPlayerIndex);
// Play switch sound (might be handled in app.js based on state change)
// audioManager.play('playerSwitch'); // Or let app.js handle sounds based on actions
// Immediately update UI after switch
if (onTimerTickCallback) onTimerTickCallback();
} else if (nextPlayerIndex === -1) {
// 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 (or rather, stop ticking down as remainingTime is 0)
}
}
}, 1000);
}
export function stopTimer() {
clearInterval(timerInterval);
timerInterval = null;
// Optionally stop timer sounds here if needed
// audioManager.stop('timerTick');
}
export function isTimerRunning() {
return timerInterval !== null;
}

286
ui.js Normal file
View File

@@ -0,0 +1,286 @@
// ui.js
import * as state from './state.js';
import { GAME_STATES, CSS_CLASSES } 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 = state.DEFAULT_PLAYER_TIME_SECONDS / 60; // Use default time
elements.remainingTimeContainer.style.display = 'none';
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';
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.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();
}