Initial commit

This commit is contained in:
cpu
2025-03-22 22:31:00 +01:00
parent 91d2f8bc6e
commit 1cd819938f
37 changed files with 4154 additions and 2 deletions

323
js/ui/audio.js Normal file
View 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
js/ui/camera.js Normal file
View File

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

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

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

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

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