This commit is contained in:
cpu
2025-05-07 15:50:01 +02:00
parent 4ec23960cc
commit f7e5969f52
12 changed files with 1265 additions and 90 deletions

197
README.md
View File

@@ -1,108 +1,125 @@
# OrreryTimer 🕰️✨ # Nexus Timer 🕰️✨
OrreryTimer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion. It provides a clear visual focus on the current participant, their immediate predecessor and successor, and the direction of play, ensuring everyone stays engaged and aware of the flow. Nexus Timer is a dynamic multi-player timer designed for games, workshops, or any activity where turns pass sequentially in a circular fashion. It provides a clear visual focus on the current participant and their immediate successor, ensuring everyone stays engaged and aware of who is next. This document serves as a detailed specification for a Progressive Web App (PWA) prototype aimed at game enthusiasts.
![OrreryTimer Mockup/Screenshot Placeholder](placeholder_screenshot.png)
*(Replace with an actual screenshot or GIF once available)*
## Core Concept ## Core Concept
Inspired by an orrery (a mechanical model of the solar system), OrreryTimer visualizes players in a circular sequence. The "Current Player" is centrally featured, with the "Previous Player" and "Next Player" flanking them, clearly indicating the flow of turns. This tool is perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns. Nexus Timer visualizes players in a circular sequence. The **Current Player** is prominently displayed in the top half of the screen, and the **Next Player** is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, role-playing games, timed presentations, or any scenario needing structured turn management with individual countdowns.
## Key Features ✨ ## Target Audience
Game enthusiasts who play turn-based games (board games, tabletop RPGs, card games) and need a visually clear and customizable timer solution.
## Tech Stack
* **HTML5:** For structuring the user interface.
* **CSS3:** For styling and visual presentation, including animations. Consider a CSS framework like Tailwind CSS for rapid prototyping.
* **JavaScript:** For application logic, timer functionality, and event handling.
* **Web Audio API:** For audio feedback (ticking sounds, alerts).
* **Browser API:** For capturing Players' photo.
* **Local Storage/IndexedDB:** For persistent storage of player data, timer states, and settings.
* **Service Worker:** Essential for PWA functionality (offline access, push notifications - potential future feature).
* **Manifest File:** Defines the PWA's metadata (name, icons, theme color).
* **Web Framework:** Use Vue.js
* **(Optional) Tailwind CSS:** Utility-first CSS framework for rapid UI development.
## Hardware Recommendations (Optional Enhancement)
For an enhanced tactile experience, Nexus Timer supports Smart Buttons based on Bluetooth-connected microcontroller (e.g., XIAO nRF52840) implementing HID (Human Interface Device) protocol.
* **Buttons:** Connect 3 physical buttons, potentially extended (e.g., via 1.5m wires) for easy player access.
* **Configuration:**
* **Player 1's Button:** Single Click: Emulates a key press (e.g., 'a'). Configure this as Player 1's "Pass Turn / My Pause" hotkey in the app.
* **Player 2's Button:** Single Click: Emulates a key press (e.g., 'b'). Configure as Player 2's "Pass Turn / My Pause" hotkey.
* **Player 3's Button:** Single Click: Emulates a key press (e.g., 'c'). Configure as Player 3's "Pass Turn / My Pause" hotkey. Long Press (if Player 3 is Game Admin): Emulates a key press (e.g., 's'). Configure as the "Global Stop/Pause All" hotkey in the app.
## Key Features
* **Circular Player Display:** * **Circular Player Display:**
* Central focus on the **Current Player**. 1. **Normal Mode (Default):** Central focus on the **Current Player** (top) and **Next Player** (bottom).
* Adjacent displays for **Previous** and **Next Players**. 2. **All Timers Running Mode:** List of players with running timers.
* Clear visual connection between these three roles.
* Animated indicator showing the **direction of play** (clockwise/counter-clockwise).
* **Individual Player Timers:** * **Individual Player Timers:**
* Each player has a customizable countdown timer (MM:SS). * Customizable countdown timer (MM:SS) for each player.
* Timers continue into **negative time** if the limit is reached (up to -59:59). * Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59).
* Players whose timers reach max negative time are "skipped" but remain visually present. * Players reaching max negative time are **skipped** until reset or timer edit.
* Visual feedback: Red pulsating effect for active timers (background for positive, text for negative). * Visual feedback: Pulsating effect for active timers (background for positive, text for negative). Skipped players are visually distinct (e.g., greyed out).
* **Two Game Modes:** * **Two Game Modes:**
1. **Normal Mode (Single Active Timer):** 1. **Normal Mode (Default):**
* Only the Current Player's timer is active. * Only the **Current Player's** timer runs.
* Pass turns via swipe gestures (left/right) or player-specific hotkeys. * Pass the turn via **Swipe Up** on the Next Player's area or the Current Player's "Pass Turn / My Pause" hotkey.
* Tap Current Player to pause/resume their timer. * 3-seconds ticking sound when the timer starts to alert players about the "Pass Turn".
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
* To pass the turn: **Swipe Up** on the Next Player's area or have the Current Player press their "Pass Turn / My Pause" hotkey. The current timer pauses, the next player becomes Current, and their timer starts.
2. **All Timers Running Mode:** 2. **All Timers Running Mode:**
* All player timers start and run simultaneously. * All active player timers run simultaneously.
* Ideal for scenarios where everyone has a global time limit or races against each other. * Enter by clicking "All Timers Mode" (starts all timers).
* Players can pause their *own* timer when they are current. * Continuous ticking sound when active.
* Global "Start All / Stop All" control. * Initially, all players are shown in a list with their photo, name and timer value.
* App-wide visual (pulsating background) and audio (continuous ticking) cues when active. * Tapping on a player in the list pauses its timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.
* Only players with a running timer are shown in the list.
* If all players pause their timers, automatically reverts to Normal Mode.
* Main button: "Stop All Timers" (pauses all, returns to Normal Mode) or "Start All Timers" (resumes all in this mode). Can also be triggered by "Global Stop/Pause All" hotkey.
* **Player Management:** * **Player Management:**
* Add, edit, and delete players (minimum 3, up to 10). * Add, edit, and delete players (2-7 players).
* Upload player photos or use default avatars. * Use device camera (access via browser API) or default avatars for the player's picture.
* Set initial timer values per player. * Set initial timer values per player (Default: 60:00).
* Assign unique "Next Turn" hotkeys and non-unique "Stop My Timer" hotkeys. * Assign unique "Pass Turn / My Pause" hotkeys (single keypresses). E.g.: Use the Player's 1 "single click" action to insert the key.
* Easily re-order players (drag-and-drop preferred). * Assign the "Global Stop/Pause All" hotkey (single keypresses). E.g.: Use the Player's 3 "long press" action to insert the key.
* Re-order players (drag-and-drop planned), reverse, shuffle.
* **Intuitive Controls:** * **Intuitive Controls:**
* Swipe gestures for changing Current Player and turn direction in Normal Mode. * **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode).
* Tap interactions for timer control. * **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually.
* Global hotkey for "Stop All Timers."
* **Audio Feedback:** * **Audio Feedback:**
* Continuous ticking in "All Timers Running Mode." * Continuous ticking in "All Timers Running Mode" when active.
* 3-second alert tick when a timer becomes active in "Normal Mode." * Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses.
* Option to mute all sounds. * Global mute option.
* **Persistence:** Player configurations, current timer states, and game settings are saved locally, so your setup is ready when you reopen the app. * **Visuals:**
* **Global Reset:** A "Reset Game" button to revert all timers to initial values and reset the game state. * Designed for mobile phone screens (portrait orientation).
* Light/Dark theme options.
* **Persistence:** Player setups, timer states, and settings are saved locally using browser Local Storage.
* **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state.
## Tech Stack (Planned/Example) ## UI/UX Considerations (For AI Generation)
## Getting Started * **Minimalist Design:** Focus on clarity and ease of use. Avoid clutter.
* **Large, Clear Timers:** Timers should be easily readable at a glance.
* **Color Coding:** Use color to indicate timer state (e.g., green for running, red for negative time, grey for skipped).
* **Responsive Layout:** The UI should adapt to different (mobile phone) screen sizes.
* **Touch-Friendly:** Buttons and interactive elements should be large enough for easy tapping.
**(Details to be added once development begins)** ## Data Model (For AI Generation)
1. **Prerequisites:** ```json
* {
2. **Installation:** "players": [
```bash {
git clone https://gitea.virtonline.eu/2HoursProject/OrreryTimer.git "id": "1",
cd OrreryTimer "name": "Alice",
"avatar": "image",
"initialTimer": "60:00",
"currentTimer": "60:00",
"hotkey": "a",
"isSkipped": false
},
{
"id": "2",
"name": "Bob",
"avatar": "image",
"initialTimer": "60:00",
"currentTimer": "60:00",
"hotkey": "b",
"isSkipped": false
}
],
"globalHotkey": "s",
"currentPlayerIndex": 0,
"gameMode": "normal", // "normal" or "allTimers"
"isMuted": false
}
```
## Installation
Build the app with docker
``` ```bash
3. **Running the Application:** docker build -t nexus-timer .
```bash ```
```
## Usage Guide
1. **Manage Players:**
* Click "Manage Players."
* Add at least 3 players. For each player:
* Enter a name.
* Optionally, upload a photo.
* Set their initial timer duration.
* Optionally, assign a "Next Turn" hotkey (must be unique) and a "Stop My Timer" hotkey.
* Arrange players in the desired turn order.
* Save changes.
2. **Main Screen:**
* The Current, Previous, and Next players will be displayed.
* The turn direction indicator shows the flow.
3. **Normal Mode (Default):**
* Tap the Current Player's area to start/pause their timer.
* Swipe left or right on the Current Player's area to pass the turn and set the direction. The new Current Player's timer will start/resume.
* If the Current Player has a "Next Turn" hotkey, pressing it will pass the turn.
4. **All Timers Running Mode:**
* Click "Start All Timers." All player timers will begin counting down.
* The app background will pulse red, and a ticking sound will play.
* When it's a player's "focus" as Current Player, they can tap their area or use their "Stop My Timer" hotkey to pause their *own* timer. The focus will then shift to the next player with a running timer.
* Click "Stop All Timers" (or use the global hotkey) to pause all running timers.
5. **Reset Game:**
* Click "Reset Game" and confirm to restore all timers to their initial values and reset player positions.
## Configuration
* **Player Hotkeys:** Configured in the "Add/Edit Player" form.
* "Next Turn" Key: Advances to the next player in Normal Mode.
* "Stop My Timer" Key: Pauses the current player's timer in All Timers Running Mode.
* **Global "Stop All" Hotkey:** [Specify default, e.g., 'Escape' or 'S'] - this pauses all timers in any mode.
* **Audio Mute:** Look for a settings icon or option to mute all sounds.
## Future Enhancements 🚀
* Light/Dark theme options.
* Ability to save and load different game setups (player lists & timer configurations).

View File

@@ -0,0 +1,4 @@
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="7" r="4" stroke="#000000" stroke-width="2"/>
<path d="M4 21C4 17.134 7.58172 14 12 14C16.4183 14 20 17.134 20 21" stroke="#000000" stroke-width="2" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 273 B

BIN
assets/favicon-16x16.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 B

BIN
assets/favicon-32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 770 B

BIN
assets/icon-192x192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
assets/icon-512x512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

86
index.html Normal file
View File

@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-T">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Nexus Timer</title>
<link rel="stylesheet" href="style.css">
<link rel="manifest" href="manifest.json">
<meta name="theme-color" content="#2A2A3E">
</head>
<body>
<div id="app-container">
<div id="current-player-area" class="player-area">
<div class="player-photo-container">
<img src="assets/default-avatar.svg" alt="Current Player Photo" id="current-player-photo" class="player-photo">
</div>
<div class="player-info">
<h2 id="current-player-name">Current Player</h2>
<div id="current-player-timer" class="timer-display">00:00</div>
</div>
</div>
<div id="next-player-area" class="player-area">
<div class="player-info">
<h3 id="next-player-name">Next Player</h3>
<div id="next-player-timer" class="timer-display small-timer">00:00</div>
</div>
<div class="player-photo-container">
<img src="assets/default-avatar.svg" alt="Next Player Photo" id="next-player-photo" class="player-photo">
</div>
</div>
<div id="controls">
<button id="manage-players-btn" class="control-button">Manage Players</button>
<button id="game-mode-btn" class="control-button">All Timers Mode</button>
<button id="reset-game-btn" class="control-button">Reset Game</button>
<button id="mute-btn" class="control-button">🔇 Mute</button>
</div>
</div>
<div id="manage-players-modal" class="modal" style="display: none;">
<div class="modal-content">
<span class="close-modal-btn">×</span>
<h2>Manage Players (2-10)</h2>
<div id="player-list-editor">
<!-- Player entries will be dynamically added here -->
</div>
<div class="player-form-buttons">
<button id="add-player-form-btn">Add New Player</button>
<button id="shuffle-players-btn">Shuffle Order</button>
<button id="reverse-players-btn">Reverse Order</button>
</div>
<div id="add-edit-player-form" style="display: none;">
<h3 id="player-form-title">Add Player</h3>
<input type="hidden" id="edit-player-id">
<label for="player-name-input">Name:</label>
<input type="text" id="player-name-input" placeholder="Player Name" required>
<label for="player-time-input">Initial Time (MM:SS):</label>
<input type="text" id="player-time-input" placeholder="60:00" value="60:00" pattern="\d{1,2}:\d{2}">
<label for="player-photo-capture-input">Player Photo (Tap to use camera):</label>
<input type="file" id="player-photo-capture-input" accept="image/*" capture="user">
<img id="player-photo-preview" src="assets/default-avatar.svg" alt="Photo Preview" class="photo-preview-modal">
<button id="remove-photo-btn" type="button" style="display:none;">Remove Photo</button>
<label for="player-hotkey-input">"Pass/My Pause" Hotkey (single char):</label>
<input type="text" id="player-hotkey-input" placeholder="e.g., a" maxlength="1">
<label>
<input type="checkbox" id="player-admin-input"> Game Admin (for global hotkeys)
</label>
<div class="player-form-actions">
<button id="save-player-btn">Save Player</button>
<button id="cancel-edit-player-btn" type="button">Cancel</button>
</div>
</div>
<button id="save-player-management-btn" class="modal-main-action">Done</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>

24
manifest.json Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "Nexus Timer",
"short_name": "NexusTimer",
"description": "Dynamic multi-player timer for games and workshops.",
"start_url": "index.html",
"display": "standalone",
"background_color": "#1E1E2F",
"theme_color": "#2A2A3E",
"orientation": "portrait",
"icons": [
{
"src": "assets/icon-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "assets/icon-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

649
script.js Normal file
View File

@@ -0,0 +1,649 @@
document.addEventListener('DOMContentLoaded', () => {
// DOM Elements
const currentPlayerArea = document.getElementById('current-player-area');
const currentPlayerNameEl = document.getElementById('current-player-name');
const currentPlayerTimerEl = document.getElementById('current-player-timer');
const currentPlayerPhotoEl = document.getElementById('current-player-photo');
const nextPlayerArea = document.getElementById('next-player-area');
const nextPlayerNameEl = document.getElementById('next-player-name');
const nextPlayerTimerEl = document.getElementById('next-player-timer');
const nextPlayerPhotoEl = document.getElementById('next-player-photo');
const managePlayersBtn = document.getElementById('manage-players-btn');
const gameModeBtn = document.getElementById('game-mode-btn');
const resetGameBtn = document.getElementById('reset-game-btn');
const muteBtn = document.getElementById('mute-btn');
const managePlayersModal = document.getElementById('manage-players-modal');
const closeModalBtn = document.querySelector('.close-modal-btn');
const playerListEditor = document.getElementById('player-list-editor');
const addPlayerFormBtn = document.getElementById('add-player-form-btn'); // <<<< ENSURED DEFINITION
const shufflePlayersBtn = document.getElementById('shuffle-players-btn');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
const editPlayerIdInput = document.getElementById('edit-player-id');
const playerNameInput = document.getElementById('player-name-input');
const playerTimeInput = document.getElementById('player-time-input');
// playerPhotoInput (for URL) is removed, new elements for camera:
const playerPhotoCaptureInput = document.getElementById('player-photo-capture-input');
const playerPhotoPreviewEl = document.getElementById('player-photo-preview');
const removePhotoBtn = document.getElementById('remove-photo-btn');
const playerHotkeyInput = document.getElementById('player-hotkey-input');
const playerAdminInput = document.getElementById('player-admin-input');
const savePlayerBtn = document.getElementById('save-player-btn');
const cancelEditPlayerBtn = document.getElementById('cancel-edit-player-btn');
const savePlayerManagementBtn = document.getElementById('save-player-management-btn');
const appContainer = document.getElementById('app-container');
// Web Audio API
let audioContext;
let continuousTickIntervalId = null;
let shortTickIntervalId = null;
let shortTickTimeoutId = null;
// Game State
let players = [];
let currentPlayerIndex = 0;
let gameMode = 'normal';
let isMuted = false;
let playerTimers = {};
let focusedPlayerIndexInAllTimersMode = 0;
let currentPhotoDataUrl = null; // Temp store for new photo in form
const DEFAULT_PHOTO_URL = 'assets/default-avatar.svg';
const MAX_NEGATIVE_TIME_SECONDS = -3599;
const TICK_FREQUENCY_HZ = 1200;
const TICK_DURATION_S = 0.05;
const CONTINUOUS_TICK_INTERVAL_MS = 750;
const SHORT_TICK_DURATION_MS = 3000;
// --- Web Audio API Initialization ---
function initAudio() {
try {
audioContext = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error("Web Audio API is not supported in this browser", e);
isMuted = true;
updateMuteButton();
}
}
function resumeAudioContext() {
if (audioContext && audioContext.state === 'suspended') {
audioContext.resume().then(() => {
// console.log("AudioContext resumed successfully"); // Kept for debugging if needed
}).catch(e => console.error("Error resuming AudioContext:", e));
}
}
// --- Persistence ---
function saveState() {
const state = {
players: players.map(p => ({ ...p, timerInstance: undefined })),
currentPlayerIndex,
gameMode,
isMuted,
focusedPlayerIndexInAllTimersMode
};
localStorage.setItem('nexusTimerState', JSON.stringify(state));
}
function loadState() {
const savedState = localStorage.getItem('nexusTimerState');
if (savedState) {
const state = JSON.parse(savedState);
players = state.players.map(p => ({
...p,
currentTime: parseInt(p.currentTime, 10),
initialTime: parseInt(p.initialTime, 10),
isSkipped: p.isSkipped || false,
}));
currentPlayerIndex = state.currentPlayerIndex || 0;
gameMode = state.gameMode || 'normal';
isMuted = state.isMuted || false;
focusedPlayerIndexInAllTimersMode = state.focusedPlayerIndexInAllTimersMode || 0;
if (players.length === 0) setupDefaultPlayers();
} else {
setupDefaultPlayers();
}
renderPlayerManagementList();
updateDisplay();
updateGameModeUI();
}
function setupDefaultPlayers() {
players = [
{ id: Date.now(), name: 'Player 1', initialTime: 3600, currentTime: 3600, photo: DEFAULT_PHOTO_URL, hotkey: 'a', isAdmin: true, isSkipped: false },
{ id: Date.now() + 1, name: 'Player 2', initialTime: 3600, currentTime: 3600, photo: DEFAULT_PHOTO_URL, hotkey: 'b', isAdmin: false, isSkipped: false },
];
currentPlayerIndex = 0;
}
// --- Time Formatting ---
function formatTime(totalSeconds) {
const isNegative = totalSeconds < 0;
if (isNegative) totalSeconds = -totalSeconds;
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const sign = isNegative ? '-' : '';
return `${sign}${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}
function parseTimeInput(timeStr) {
const parts = timeStr.split(':');
if (parts.length === 2) {
const minutes = parseInt(parts[0], 10);
const seconds = parseInt(parts[1], 10);
if (!isNaN(minutes) && !isNaN(seconds) && seconds < 60 && seconds >= 0 && minutes >= 0) {
return (minutes * 60) + seconds;
}
}
return 3600;
}
// --- UI Updates ---
function updatePlayerDisplay(player, nameEl, timerEl, photoEl, isSmallTimer = false) {
if (player) {
nameEl.textContent = player.name;
timerEl.textContent = formatTime(player.currentTime);
timerEl.className = `timer-display ${isSmallTimer ? 'small-timer' : ''} ${player.currentTime < 0 ? 'negative' : ''}`;
photoEl.src = player.photo || DEFAULT_PHOTO_URL; // player.photo can be data URL
photoEl.alt = `${player.name}'s Photo`;
const parentArea = nameEl.closest('.player-area');
if (parentArea) {
parentArea.classList.toggle('player-skipped', !!player.isSkipped);
parentArea.classList.remove('pulsating-background', 'pulsating-text', 'pulsating-negative-text');
if (playerTimers[player.id] && !player.isSkipped) {
if (player.currentTime >= 0) parentArea.classList.add('pulsating-background');
else parentArea.classList.add('pulsating-negative-text');
}
}
} else {
nameEl.textContent = '---';
timerEl.textContent = '00:00';
timerEl.className = `timer-display ${isSmallTimer ? 'small-timer' : ''}`;
photoEl.src = DEFAULT_PHOTO_URL;
photoEl.alt = 'Player Photo';
const parentArea = nameEl.closest('.player-area');
if (parentArea) parentArea.classList.remove('player-skipped', 'pulsating-background', 'pulsating-text', 'pulsating-negative-text');
}
}
function updateDisplay() {
if (players.length === 0) {
updatePlayerDisplay(null, currentPlayerNameEl, currentPlayerTimerEl, currentPlayerPhotoEl);
updatePlayerDisplay(null, nextPlayerNameEl, nextPlayerTimerEl, nextPlayerPhotoEl, true);
return;
}
let currentP, nextP;
if (gameMode === 'allTimersRunning') {
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0) {
focusedPlayerIndexInAllTimersMode = focusedPlayerIndexInAllTimersMode % activePlayers.length;
currentP = activePlayers[focusedPlayerIndexInAllTimersMode];
nextP = activePlayers.length > 1 ? activePlayers[(focusedPlayerIndexInAllTimersMode + 1) % activePlayers.length] : null;
} else {
currentP = players[0]; nextP = players.length > 1 ? players[1] : null;
}
} else {
currentP = players[currentPlayerIndex];
nextP = players.length > 1 ? players[(currentPlayerIndex + 1) % players.length] : null;
}
updatePlayerDisplay(currentP, currentPlayerNameEl, currentPlayerTimerEl, currentPlayerPhotoEl);
updatePlayerDisplay(nextP, nextPlayerNameEl, nextPlayerTimerEl, nextPlayerPhotoEl, true);
saveState();
}
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
if (!isMuted) playContinuousTick(true);
} else {
gameModeBtn.textContent = 'Start All Timers';
appContainer.classList.remove('pulsating-background');
playContinuousTick(false);
}
} else {
gameModeBtn.textContent = 'All Timers Mode';
appContainer.classList.remove('pulsating-background');
playContinuousTick(false);
}
}
// --- Timer Logic ---
function startTimer(playerId) {
const player = players.find(p => p.id === playerId);
if (!player || player.isSkipped || playerTimers[playerId]) return;
if (gameMode === 'normal' && !isMuted) playShortTick();
playerTimers[playerId] = setInterval(() => {
player.currentTime--;
if (player.currentTime < MAX_NEGATIVE_TIME_SECONDS) {
player.currentTime = MAX_NEGATIVE_TIME_SECONDS;
player.isSkipped = true;
pauseTimer(playerId, false);
if (gameMode === 'normal' && players[currentPlayerIndex].id === playerId) passTurn();
}
updateDisplay();
}, 1000);
updateDisplay();
}
function pauseTimer(playerId, checkAllTimersModeRevert = true) {
if (playerTimers[playerId]) {
clearInterval(playerTimers[playerId]);
playerTimers[playerId] = null;
}
if (gameMode === 'normal' && players[currentPlayerIndex]?.id === playerId) stopShortTick();
updateDisplay();
if (gameMode === 'allTimersRunning' && checkAllTimersModeRevert) {
const allPaused = players.every(p => p.isSkipped || !playerTimers[p.id]);
if (allPaused) switchToNormalMode();
else updateGameModeUI();
}
}
function resetPlayerTimer(player) {
player.currentTime = player.initialTime;
player.isSkipped = false;
if (playerTimers[player.id]) pauseTimer(player.id);
}
// --- Game Flow & Modes ---
function passTurn() {
if (players.length < 1 || gameMode !== 'normal') return;
const currentP = players[currentPlayerIndex];
const currentTimerWasActive = !!playerTimers[currentP.id];
pauseTimer(currentP.id);
let nextIndex = (currentPlayerIndex + 1) % players.length;
let attempts = 0;
while (players[nextIndex].isSkipped && attempts < players.length) {
nextIndex = (nextIndex + 1) % players.length;
attempts++;
}
if (players[nextIndex].isSkipped && attempts >= players.length) {
currentPlayerIndex = nextIndex;
console.log("All subsequent players are skipped. Turn passed to a skipped player.");
} else {
currentPlayerIndex = nextIndex;
const nextP = players[currentPlayerIndex];
if (currentTimerWasActive) {
startTimer(nextP.id);
}
}
updateDisplay();
}
function switchToNormalMode() {
gameMode = 'normal';
players.forEach(p => pauseTimer(p.id, false));
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0 && focusedPlayerIndexInAllTimersMode < activePlayers.length) {
const focusedPlayer = activePlayers[focusedPlayerIndexInAllTimersMode];
const newCurrentIndex = players.findIndex(p => p.id === focusedPlayer.id);
if (newCurrentIndex !== -1) currentPlayerIndex = newCurrentIndex;
} else if (activePlayers.length > 0) {
currentPlayerIndex = players.findIndex(p => p.id === activePlayers[0].id);
}
updateGameModeUI(); updateDisplay();
}
function switchToAllTimersMode(startTimers = true) {
gameMode = 'allTimersRunning';
if (startTimers) {
players.forEach(p => { if (!p.isSkipped) startTimer(p.id); });
const currentActualPlayer = players[currentPlayerIndex];
const activePlayers = players.filter(p => !p.isSkipped);
const focusIdx = activePlayers.findIndex(p => p.id === currentActualPlayer.id);
focusedPlayerIndexInAllTimersMode = (focusIdx !== -1) ? focusIdx : 0;
}
updateGameModeUI(); updateDisplay();
}
function changeFocusInAllTimersMode() {
if (gameMode !== 'allTimersRunning' || players.length === 0) return;
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length <= 1) return;
focusedPlayerIndexInAllTimersMode = (focusedPlayerIndexInAllTimersMode + 1) % activePlayers.length;
updateDisplay();
}
function resetGame() {
if (!confirm("Reset game? All timers will be restored to initial values.")) return;
players.forEach(resetPlayerTimer);
currentPlayerIndex = 0; focusedPlayerIndexInAllTimersMode = 0;
if (gameMode === 'allTimersRunning') switchToNormalMode();
updateDisplay(); saveState();
}
// --- Player Management ---
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
playerListEditor.innerHTML = '<p>No players yet. Add some!</p>';
}
players.forEach((player, index) => {
const entry = document.createElement('div');
entry.className = 'player-editor-entry';
const photoSrc = (player.photo && player.photo.startsWith('data:image')) ? player.photo : DEFAULT_PHOTO_URL;
entry.innerHTML = `
<img src="${photoSrc}" alt="P" style="width:20px; height:20px; border-radius:50%; margin-right: 5px; object-fit:cover;">
<span>${index + 1}. ${player.name} (${formatTime(player.initialTime)}) ${player.isAdmin ? '(Admin)' : ''} ${player.hotkey ? `[${player.hotkey}]`: ''}</span>
<div>
<button class="edit-player-btn" data-id="${player.id}">Edit</button>
<button class="delete-player-btn" data-id="${player.id}">Del</button>
${index > 0 ? `<button class="move-player-up-btn" data-index="${index}">↑</button>` : ''}
${index < players.length - 1 ? `<button class="move-player-down-btn" data-index="${index}">↓</button>` : ''}
</div>
`;
playerListEditor.appendChild(entry);
});
document.querySelectorAll('.edit-player-btn').forEach(btn => btn.addEventListener('click', handleEditPlayerForm));
document.querySelectorAll('.delete-player-btn').forEach(btn => btn.addEventListener('click', handleDeletePlayer));
document.querySelectorAll('.move-player-up-btn').forEach(btn => btn.addEventListener('click', handleMovePlayerUp));
document.querySelectorAll('.move-player-down-btn').forEach(btn => btn.addEventListener('click', handleMovePlayerDown));
}
function openPlayerForm(playerToEdit = null) {
addEditPlayerForm.style.display = 'block';
currentPhotoDataUrl = null;
playerPhotoCaptureInput.value = '';
if (playerToEdit) {
playerFormTitle.textContent = 'Edit Player';
editPlayerIdInput.value = playerToEdit.id;
playerNameInput.value = playerToEdit.name;
playerTimeInput.value = formatTime(playerToEdit.initialTime).replace('-', '');
playerPhotoPreviewEl.src = playerToEdit.photo || DEFAULT_PHOTO_URL;
currentPhotoDataUrl = (playerToEdit.photo && playerToEdit.photo.startsWith('data:image')) ? playerToEdit.photo : null;
playerHotkeyInput.value = playerToEdit.hotkey || '';
playerAdminInput.checked = playerToEdit.isAdmin || false;
} else {
playerFormTitle.textContent = 'Add Player';
editPlayerIdInput.value = '';
playerNameInput.value = '';
playerTimeInput.value = '60:00';
playerPhotoPreviewEl.src = DEFAULT_PHOTO_URL;
playerHotkeyInput.value = '';
playerAdminInput.checked = false;
}
removePhotoBtn.style.display = (playerPhotoPreviewEl.src !== DEFAULT_PHOTO_URL && playerPhotoPreviewEl.src !== '') ? 'block' : 'none';
playerNameInput.focus();
}
playerPhotoCaptureInput.addEventListener('change', (event) => {
const file = event.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (e) => {
currentPhotoDataUrl = e.target.result;
playerPhotoPreviewEl.src = currentPhotoDataUrl;
removePhotoBtn.style.display = 'block';
};
reader.readAsDataURL(file);
}
});
removePhotoBtn.addEventListener('click', () => {
currentPhotoDataUrl = null;
playerPhotoPreviewEl.src = DEFAULT_PHOTO_URL;
playerPhotoCaptureInput.value = '';
removePhotoBtn.style.display = 'none';
});
addPlayerFormBtn.addEventListener('click', () => { // Event listener for addPlayerFormBtn
if (players.length >= 10) { alert("Max 10 players."); return; }
openPlayerForm();
});
cancelEditPlayerBtn.addEventListener('click', () => {
addEditPlayerForm.style.display = 'none';
});
savePlayerBtn.addEventListener('click', () => {
const id = editPlayerIdInput.value;
const name = playerNameInput.value.trim();
const initialTimeSeconds = parseTimeInput(playerTimeInput.value);
let photoToSave;
if (currentPhotoDataUrl) {
photoToSave = currentPhotoDataUrl;
} else if (id) {
const existingPlayer = players.find(p => p.id === parseInt(id));
if (playerPhotoPreviewEl.src === DEFAULT_PHOTO_URL && !currentPhotoDataUrl) {
photoToSave = DEFAULT_PHOTO_URL;
} else {
photoToSave = existingPlayer.photo;
}
} else {
photoToSave = DEFAULT_PHOTO_URL;
}
const hotkey = playerHotkeyInput.value.trim().toLowerCase();
const isAdmin = playerAdminInput.checked;
if (!name) { alert('Name empty.'); return; }
if (hotkey.length > 1) { alert('Hotkey single char.'); return; }
if (hotkey && players.some(p => p.hotkey === hotkey && p.id !== (id ? parseInt(id) : null))) { alert(`Hotkey '${hotkey}' taken.`); return; }
if (isAdmin) { players.forEach(p => { if (p.id !== (id ? parseInt(id) : null)) p.isAdmin = false; }); }
if (id) {
const player = players.find(p => p.id === parseInt(id));
if (player) {
player.name = name;
player.initialTime = initialTimeSeconds;
if (player.initialTime !== initialTimeSeconds && !playerTimers[player.id]) player.currentTime = initialTimeSeconds;
player.photo = photoToSave;
player.hotkey = hotkey;
player.isAdmin = isAdmin;
}
} else {
if (players.length >= 10) { alert("Max 10 players."); return; }
players.push({ id: Date.now(), name, initialTime: initialTimeSeconds, currentTime: initialTimeSeconds, photo: photoToSave, hotkey, isAdmin, isSkipped: false });
}
addEditPlayerForm.style.display = 'none';
renderPlayerManagementList();
updateDisplay();
saveState();
});
function handleEditPlayerForm(event) { openPlayerForm(players.find(p => p.id === parseInt(event.target.dataset.id))); }
function handleDeletePlayer(event) {
const playerId = parseInt(event.target.dataset.id);
if (players.length <= 2) { alert("Min 2 players."); return; }
if (confirm("Delete player?")) {
const playerIndex = players.findIndex(p => p.id === playerId);
if (playerIndex > -1) {
if (playerIndex === currentPlayerIndex) {
if (gameMode === 'normal') pauseTimer(players[currentPlayerIndex].id);
} else if (playerIndex < currentPlayerIndex) {
currentPlayerIndex--;
}
if (playerTimers[playerId]) { pauseTimer(playerId, false); delete playerTimers[playerId]; }
players.splice(playerIndex, 1);
if (players.length > 0) {
currentPlayerIndex = Math.max(0, Math.min(currentPlayerIndex, players.length - 1));
} else {
currentPlayerIndex = 0;
}
focusedPlayerIndexInAllTimersMode = Math.min(focusedPlayerIndexInAllTimersMode, players.filter(p => !p.isSkipped).length -1);
if (focusedPlayerIndexInAllTimersMode < 0) focusedPlayerIndexInAllTimersMode = 0;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
}
function handleMovePlayerUp(event) {
const index = parseInt(event.target.dataset.index);
if (index > 0) {
[players[index-1], players[index]] = [players[index], players[index-1]];
if (currentPlayerIndex === index) currentPlayerIndex = index - 1; else if (currentPlayerIndex === index - 1) currentPlayerIndex = index;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
function handleMovePlayerDown(event) {
const index = parseInt(event.target.dataset.index);
if (index < players.length - 1) {
[players[index+1], players[index]] = [players[index], players[index+1]];
if (currentPlayerIndex === index) currentPlayerIndex = index + 1; else if (currentPlayerIndex === index + 1) currentPlayerIndex = index;
renderPlayerManagementList(); updateDisplay(); saveState();
}
}
shufflePlayersBtn.addEventListener('click', () => {
for (let i = players.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1)); [players[i], players[j]] = [players[j], players[i]]; }
currentPlayerIndex = 0; focusedPlayerIndexInAllTimersMode = 0; renderPlayerManagementList(); updateDisplay(); saveState();
});
reversePlayersBtn.addEventListener('click', () => {
players.reverse(); if (players.length > 0) currentPlayerIndex = (players.length - 1) - currentPlayerIndex;
focusedPlayerIndexInAllTimersMode = 0; renderPlayerManagementList(); updateDisplay(); saveState();
});
// --- Audio (Synthesized Ticks) ---
function playSingleTick() {
if (!audioContext || isMuted) return;
resumeAudioContext();
const time = audioContext.currentTime;
const osc = audioContext.createOscillator();
const gain = audioContext.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(TICK_FREQUENCY_HZ, time);
gain.gain.setValueAtTime(0, time);
gain.gain.linearRampToValueAtTime(0.3, time + TICK_DURATION_S / 2);
gain.gain.linearRampToValueAtTime(0, time + TICK_DURATION_S);
osc.connect(gain);
gain.connect(audioContext.destination);
osc.start(time);
osc.stop(time + TICK_DURATION_S);
}
function playContinuousTick(play) {
if (!audioContext) return;
resumeAudioContext();
if (continuousTickIntervalId) { clearInterval(continuousTickIntervalId); continuousTickIntervalId = null; }
if (play && !isMuted) {
playSingleTick();
continuousTickIntervalId = setInterval(playSingleTick, CONTINUOUS_TICK_INTERVAL_MS);
}
}
function playShortTick() {
if (!audioContext || isMuted) return;
resumeAudioContext();
stopShortTick();
playSingleTick();
shortTickIntervalId = setInterval(playSingleTick, CONTINUOUS_TICK_INTERVAL_MS);
shortTickTimeoutId = setTimeout(stopShortTick, SHORT_TICK_DURATION_MS);
}
function stopShortTick() {
if (shortTickIntervalId) { clearInterval(shortTickIntervalId); shortTickIntervalId = null; }
if (shortTickTimeoutId) { clearTimeout(shortTickTimeoutId); shortTickTimeoutId = null; }
}
function updateMuteButton() {
muteBtn.textContent = isMuted ? '🔊 Unmute' : '🔇 Mute';
if (isMuted) {
playContinuousTick(false);
stopShortTick();
} else {
if (gameMode === 'allTimersRunning' && Object.values(playerTimers).some(id => id !== null)) {
playContinuousTick(true);
}
}
}
// --- Event Listeners ---
function handleUserInteractionForAudio() {
resumeAudioContext();
}
document.addEventListener('click', handleUserInteractionForAudio, { once: true });
document.addEventListener('touchstart', handleUserInteractionForAudio, { once: true });
document.addEventListener('keydown', handleUserInteractionForAudio, { once: true });
managePlayersBtn.addEventListener('click', () => {
managePlayersModal.style.display = 'block'; renderPlayerManagementList(); addEditPlayerForm.style.display = 'none';
});
closeModalBtn.addEventListener('click', () => managePlayersModal.style.display = 'none');
savePlayerManagementBtn.addEventListener('click', () => {
if (players.length < 2) { alert("Min 2 players."); return; }
managePlayersModal.style.display = 'none'; updateDisplay(); saveState();
});
window.addEventListener('click', (event) => { if (event.target === managePlayersModal) managePlayersModal.style.display = 'none'; });
gameModeBtn.addEventListener('click', () => {
if (players.length < 2) { alert("Min 2 players."); return; }
if (gameMode === 'normal') switchToAllTimersMode(true);
else {
const anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) switchToNormalMode(); else switchToAllTimersMode(true);
}
});
resetGameBtn.addEventListener('click', resetGame);
muteBtn.addEventListener('click', () => { isMuted = !isMuted; updateMuteButton(); saveState(); });
currentPlayerArea.addEventListener('click', () => {
if (players.length === 0) return;
if (gameMode === 'normal') {
const currentP = players[currentPlayerIndex];
if (playerTimers[currentP.id]) pauseTimer(currentP.id); else if (!currentP.isSkipped) startTimer(currentP.id);
} else {
const activePlayers = players.filter(p => !p.isSkipped);
if (activePlayers.length > 0) {
const focusedPlayer = activePlayers[focusedPlayerIndexInAllTimersMode];
if (playerTimers[focusedPlayer.id]) pauseTimer(focusedPlayer.id); else if (!focusedPlayer.isSkipped) startTimer(focusedPlayer.id);
}
}
});
let touchStartY = 0;
nextPlayerArea.addEventListener('touchstart', (e) => { if (e.touches.length === 1) touchStartY = e.touches[0].clientY; }, { passive: true });
nextPlayerArea.addEventListener('touchend', (e) => {
if (players.length === 0 || touchStartY === 0 || e.changedTouches.length === 0) return;
if ((touchStartY - e.changedTouches[0].clientY) > 50) { // Swipe Up
if (gameMode === 'normal') passTurn(); else changeFocusInAllTimersMode();
}
touchStartY = 0;
});
document.addEventListener('keydown', (event) => {
const key = event.key.toLowerCase();
if (document.activeElement && (document.activeElement.tagName === 'INPUT' || document.activeElement.tagName === 'TEXTAREA')) return;
const triggeredPlayer = players.find(p => p.hotkey === key);
if (triggeredPlayer) {
event.preventDefault();
if (gameMode === 'normal') { if (players[currentPlayerIndex].id === triggeredPlayer.id) passTurn(); }
else { if (playerTimers[triggeredPlayer.id]) pauseTimer(triggeredPlayer.id); else if (!triggeredPlayer.isSkipped) startTimer(triggeredPlayer.id); }
return;
}
const adminPlayer = players.find(p => p.isAdmin);
if (adminPlayer && key === 's') {
event.preventDefault();
if (gameMode === 'normal') {
const currentP = players[currentPlayerIndex];
if (playerTimers[currentP.id]) pauseTimer(currentP.id); else if (!currentP.isSkipped) startTimer(currentP.id);
} else {
const anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) switchToNormalMode(); else switchToAllTimersMode(true);
}
}
});
// --- Initialization ---
function init() {
initAudio();
loadState();
updateMuteButton();
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
}
}
init();
});

345
style.css Normal file
View File

@@ -0,0 +1,345 @@
body, html {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
overflow: hidden;
background-color: #1E1E2F; /* Dark background for the app */
color: #E0E0E0;
-webkit-tap-highlight-color: transparent; /* Disable tap highlight */
}
#app-container {
display: flex;
flex-direction: column;
height: 100vh; /* Full viewport height */
width: 100vw; /* Full viewport width */
}
.player-area {
flex: 1;
display: flex;
align-items: center;
justify-content: space-around; /* Distribute photo and info */
padding: 20px;
box-sizing: border-box;
text-align: center;
position: relative; /* For potential absolute positioned elements inside */
}
#current-player-area {
background-color: #2A2A3E; /* Slightly lighter dark shade for current player */
border-bottom: 2px solid #4A4A5E;
}
#next-player-area {
background-color: #242434; /* Slightly different shade for next player */
cursor: pointer; /* For swipe up indication */
}
.player-photo-container {
flex-shrink: 0;
}
.player-photo {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #E0E0E0;
background-color: #555; /* Placeholder if image fails */
}
#current-player-area .player-photo {
width: 120px;
height: 120px;
}
.player-info {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
#current-player-name {
font-size: 2.5em;
margin: 10px 0;
font-weight: bold;
}
#next-player-name {
font-size: 1.8em;
margin: 5px 0;
}
.timer-display {
font-size: 3.5em;
font-weight: bold;
font-family: 'Courier New', Courier, monospace;
letter-spacing: 2px;
}
.small-timer {
font-size: 2em;
}
.timer-display.negative {
color: #FF6B6B; /* Red for negative time */
}
.pulsating-background {
animation: pulse-bg 1.5s infinite ease-in-out;
}
@keyframes pulse-bg {
0% { background-color: #2A2A3E; }
50% { background-color: #3A3A4E; }
100% { background-color: #2A2A3E; }
}
.pulsating-text {
animation: pulse-text 1.5s infinite ease-in-out;
}
@keyframes pulse-text {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
.pulsating-negative-text .timer-display.negative {
animation: pulse-negative-text 1s infinite ease-in-out;
}
@keyframes pulse-negative-text {
0% { color: #FF6B6B; transform: scale(1); }
50% { color: #FF4040; transform: scale(1.05); }
100% { color: #FF6B6B; transform: scale(1); }
}
.player-skipped {
opacity: 0.4;
background-color: #333 !important; /* Distinctly greyed out */
}
.player-skipped .player-name, .player-skipped .timer-display {
color: #888 !important;
}
#controls {
display: flex;
justify-content: space-around;
padding: 10px 0;
background-color: #1A1A2A; /* Darker bar for controls */
position: fixed;
bottom: 0;
left: 0;
width: 100%;
box-shadow: 0 -2px 5px rgba(0,0,0,0.3);
}
.control-button {
padding: 10px 15px;
font-size: 0.9em;
background-color: #4A90E2;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
transition: background-color 0.2s;
}
.control-button:hover {
background-color: #357ABD;
}
.control-button:active {
transform: scale(0.98);
}
/* Modal Styles */
.modal {
display: none; /* Hidden by default */
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0,0,0,0.6);
color: #333; /* Text color inside modal */
}
.modal-content {
background-color: #fefefe;
margin: 5% auto;
padding: 20px;
border: 1px solid #888;
width: 90%;
max-width: 500px;
border-radius: 8px;
box-shadow: 0 4px 8px rgba(0,0,0,0.2);
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-content h2, .modal-content h3 {
color: #333;
text-align: center;
}
.close-modal-btn {
color: #aaa;
float: right;
font-size: 28px;
font-weight: bold;
align-self: flex-end;
}
.close-modal-btn:hover,
.close-modal-btn:focus {
color: black;
text-decoration: none;
cursor: pointer;
}
#player-list-editor {
margin-bottom: 20px;
max-height: 30vh; /* Limit height and make scrollable */
overflow-y: auto;
border: 1px solid #ccc;
padding: 10px;
}
.player-editor-entry {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
border-bottom: 1px solid #eee;
}
.player-editor-entry:last-child {
border-bottom: none;
}
.player-editor-entry span {
flex-grow: 1;
}
.player-editor-entry button {
margin-left: 5px;
padding: 5px 8px;
font-size: 0.8em;
}
#add-edit-player-form label {
display: block;
margin-top: 10px;
font-weight: bold;
color: #555;
}
#add-edit-player-form input[type="text"],
#add-edit-player-form input[type="file"],
#add-edit-player-form input[type="checkbox"] {
width: calc(100% - 22px);
padding: 10px;
margin-top: 5px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box;
}
#add-edit-player-form input[type="checkbox"] {
width: auto;
margin-right: 5px;
}
#add-edit-player-form input[type="file"] {
padding: 3px; /* Less padding for file input appearance */
}
.photo-preview-modal {
display: block;
max-width: 100px;
max-height: 100px;
margin: 10px auto;
border: 1px solid #ddd;
border-radius: 4px;
object-fit: cover;
}
#remove-photo-btn {
display: block;
margin: 5px auto 10px auto;
padding: 6px 10px;
font-size: 0.8em;
background-color: #e07070;
color: white;
border: none;
border-radius: 3px;
}
.player-form-buttons, .player-form-actions {
margin-top: 15px;
display: flex;
justify-content: space-around;
}
.player-form-actions button {
padding: 10px 15px;
}
.modal-main-action {
margin-top: 20px;
padding: 12px 20px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
font-size: 1.1em;
cursor: pointer;
}
.modal-main-action:hover {
background-color: #45a049;
}
/* Responsive adjustments */
@media (max-width: 600px) {
#current-player-name {
font-size: 2em;
}
#next-player-name {
font-size: 1.5em;
}
.timer-display {
font-size: 2.8em;
}
.small-timer {
font-size: 1.8em;
}
.player-photo {
width: 80px;
height: 80px;
}
#current-player-area .player-photo {
width: 100px;
height: 100px;
}
.control-button {
font-size: 0.8em;
padding: 8px 10px;
}
.modal-content {
margin: 2% auto;
width: 95%;
max-height: 95vh;
}
}
body {
padding-bottom: 60px;
}

50
sw.js Normal file
View File

@@ -0,0 +1,50 @@
const CACHE_NAME = 'nexus-timer-cache-v3'; // Increment cache version
const URLS_TO_CACHE = [
'index.html',
'style.css',
'script.js',
'manifest.json',
'assets/default-avatar.svg',
// MP3 files removed as they are no longer used
'assets/icon-192x192.png',
'assets/icon-512x512.png'
];
self.addEventListener('install', event => {
event.waitUntil(
caches.open(CACHE_NAME)
.then(cache => {
console.log('Opened cache');
return cache.addAll(URLS_TO_CACHE);
})
);
});
self.addEventListener('fetch', event => {
event.respondWith(
caches.match(event.request)
.then(response => {
if (response) {
return response;
}
return fetch(event.request);
}
)
);
});
self.addEventListener('activate', event => {
const cacheWhitelist = [CACHE_NAME];
event.waitUntil(
caches.keys().then(cacheNames => {
return Promise.all(
cacheNames.map(cacheName => {
if (cacheWhitelist.indexOf(cacheName) === -1) {
console.log('Deleting old cache:', cacheName);
return caches.delete(cacheName);
}
})
);
})
);
});