Compare commits

...

3 Commits

Author SHA1 Message Date
cpu
774dd7ecb8 working 2025-05-07 15:50:01 +02:00
cpu
4ec23960cc update 2025-05-06 20:23:39 +02:00
cpu
9a9e9344cc initial 2025-05-06 20:19:38 +02:00
17 changed files with 1660 additions and 2 deletions

127
README.md
View File

@@ -1,3 +1,126 @@
# 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.
## Core Concept
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** (their immediate successor) is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns.
## Hardware Recommendations (Optional Enhancement)
For an enhanced tactile experience, Nexus Timer supports a 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 (Example):**
* **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:**
* Central focus on the **Current Player** (top) and **Next Player** (bottom).
* **Individual Player Timers:**
* Customizable countdown timer (MM:SS) for each player.
* Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59).
* Players reaching max negative time are **skipped** until reset or timer edit.
* 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:**
1. **Normal Mode (Default):**
* Only the **Current Player's** timer runs.
* Pass the turn via **Swipe Up** or the Current Player's "Pass Turn / My Pause" hotkey.
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
2. **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
* App-wide visual pulsing and continuous ticking sound when active.
* **Swipe Up** to change which active player is "in focus" in the Current Player display.
* Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.
* 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:**
* Add, edit, and delete players (2-10 players).
* Upload photos, use device camera, or default avatars.
* Set initial timer values per player (Default: 60:00).
* Assign unique "Pass Turn / My Pause" hotkeys.
* Optionally designate one player as "Game Admin" for special hotkey functions.
* Easily re-order (drag-and-drop planned), reverse, or shuffle player order.
* **Intuitive Controls:**
* **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode) or changing focus (All Timers Mode).
* **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually.
* **Audio Feedback:**
* Continuous ticking in "All Timers Running Mode" when active.
* Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses.
* Global mute option.
* **Persistence:** Player setups, timer states, and settings are saved locally.
* **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state.
## Tech Stack (Planned/Example)
* Progressive Web App (PWA) for smartphone screens.
* Modular codebase.
* Simple sounds using pure Web Audio API.
* CSS for styling, animations, and placeholder avatars.
* Local Storage/IndexedDB for persistence.
## Getting Started
**(Details to be added once development begins)**
1. **Prerequisites:**
* A modern web browser on a smartphone or tablet.
* (Optional) Bluetooth-enabled microcontroller for hardware buttons.
2. **Installation:**
```bash
# Placeholder for PWA installation instructions or link
```
3. **Running the Application:**
```bash
# Placeholder for how to access/run the app
```
## Usage Guide
1. **Manage Players:**
* Tap "Manage Players."
* Add players: Enter name, set initial timer. Optionally, add a photo, assign a "Pass Turn / My Pause" hotkey, and designate an admin.
* Edit existing players or change their order.
* Save changes.
2. **Main Screen:**
* The **Current Player** appears in the top half, **Next Player** in the bottom. Effective use of the phone's screen. No additional elements like header or footer.
3. **Normal Mode (Default):**
* Tap the Current Player's area to start/pause their timer.
* 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.
* Click the "All Timers Mode" button to switch modes (this will also start all timers).
4. **All Timers Running Mode:**
* All active player timers run. The app background pulses, and a ticking sound plays (if unmuted).
* The "Current Player" area shows one of the players with an active timer. **Swipe Up** to cycle focus to other players with active timers.
* A player can pause their *own* timer by:
* Tapping their display area (if they are the focused Current Player).
* Pressing their "Pass Turn / My Pause" hotkey.
* If all players pause their timers, the app reverts to Normal Mode.
* The main control button will say "Stop All Timers." Clicking it (or using the "Global Stop/Pause All" hotkey) pauses all timers and returns to Normal Mode. If all timers are already paused in this mode, it says "Start All Timers."
5. **Reset Game:**
* Tap "Reset Game" and confirm to restore all timers to their initial values.
## Configuration
* **Player Hotkeys (in "Manage Players"):**
* **"Pass Turn / My Pause" Key:**
* Normal Mode: If pressed by Current Player, passes the turn.
* All Timers Mode: Pauses/resumes the respective player's own timer.
* **Global Hotkeys (configured in settings or player management for admin):**
* **"Global Stop/Pause All" Hotkey:**
* Normal Mode: Pauses the Current Player's timer.
* All Timers Mode (timers running): Pauses all timers and returns to Normal Mode.
* All Timers Mode (all timers paused): Resumes all timers in All Timers Mode.
* **Audio Mute:** Look for a mute/unmute icon or setting.
## Future Enhancements 🚀
* Light/Dark theme options.
* Game statistics (e.g., average turn time).

126
README.md.orig Normal file
View File

@@ -0,0 +1,126 @@
# Nexus Timer 🕰️✨
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.
## Core Concept
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** (their immediate successor) is in the bottom half. This clear visual pairing indicates the flow of turns, making it perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns.
## Hardware Recommendations (Optional Enhancement)
For an enhanced tactile experience, Nexus Timer supports a 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 (Example):**
* **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:**
* Central focus on the **Current Player** (top) and **Next Player** (bottom).
* **Individual Player Timers:**
* Customizable countdown timer (MM:SS) for each player.
* Timers continue into **negative time** (e.g., -MM:SS) up to a limit (e.g., -59:59).
* Players reaching max negative time are **skipped** until reset or timer edit.
* 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:**
1. **Normal Mode (Default):**
* Only the **Current Player's** timer runs.
* Pass the turn via **Swipe Up** or the Current Player's "Pass Turn / My Pause" hotkey.
* Tap Current Player's area or use "Global Stop/Pause All" hotkey to pause/resume their timer without passing the turn.
2. **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
* App-wide visual pulsing and continuous ticking sound when active.
* **Swipe Up** to change which active player is "in focus" in the Current Player display.
* Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.
* 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:**
* Add, edit, and delete players (2-10 players).
* Upload photos, use device camera, or default avatars.
* Set initial timer values per player (Default: 60:00).
* Assign unique "Pass Turn / My Pause" hotkeys.
* Optionally designate one player as "Game Admin" for special hotkey functions.
* Easily re-order (drag-and-drop planned), reverse, or shuffle player order.
* **Intuitive Controls:**
* **Swipe Up:** (On the Next Player's area). Primary gesture for passing turns (Normal Mode) or changing focus (All Timers Mode).
* **On-Screen Taps:** (On the Current Player's area). For pausing/resuming timers contextually.
* **Audio Feedback:**
* Continuous ticking in "All Timers Running Mode" when active.
* Brief 3-second tick when a timer starts in Normal Mode. Cancel the sound when the timer pauses.
* Global mute option.
* **Persistence:** Player setups, timer states, and settings are saved locally.
* **Global Reset:** "Reset Game" button restores all timers to initial values and resets game state.
## Tech Stack (Planned/Example)
* Progressive Web App (PWA) for smartphone screens.
* Modular codebase.
* Simple sounds using pure Web Audio API.
* CSS for styling, animations, and placeholder avatars.
* Local Storage/IndexedDB for persistence.
## Getting Started
**(Details to be added once development begins)**
1. **Prerequisites:**
* A modern web browser on a smartphone or tablet.
* (Optional) Bluetooth-enabled microcontroller for hardware buttons.
2. **Installation:**
```bash
# Placeholder for PWA installation instructions or link
```
3. **Running the Application:**
```bash
# Placeholder for how to access/run the app
```
## Usage Guide
1. **Manage Players:**
* Tap "Manage Players."
* Add players: Enter name, set initial timer. Optionally, add a photo, assign a "Pass Turn / My Pause" hotkey, and designate an admin.
* Edit existing players or change their order.
* Save changes.
2. **Main Screen:**
* The **Current Player** appears in the top half, **Next Player** in the bottom. Effective use of the phone's screen. No additional elements like header or footer.
3. **Normal Mode (Default):**
* Tap the Current Player's area to start/pause their timer.
* 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.
* Click the "All Timers Mode" button to switch modes (this will also start all timers).
4. **All Timers Running Mode:**
* All active player timers run. The app background pulses, and a ticking sound plays (if unmuted).
* The "Current Player" area shows one of the players with an active timer. **Swipe Up** to cycle focus to other players with active timers.
* A player can pause their *own* timer by:
* Tapping their display area (if they are the focused Current Player).
* Pressing their "Pass Turn / My Pause" hotkey.
* If all players pause their timers, the app reverts to Normal Mode.
* The main control button will say "Stop All Timers." Clicking it (or using the "Global Stop/Pause All" hotkey) pauses all timers and returns to Normal Mode. If all timers are already paused in this mode, it says "Start All Timers."
5. **Reset Game:**
* Tap "Reset Game" and confirm to restore all timers to their initial values.
## Configuration
* **Player Hotkeys (in "Manage Players"):**
* **"Pass Turn / My Pause" Key:**
* Normal Mode: If pressed by Current Player, passes the turn.
* All Timers Mode: Pauses/resumes the respective player's own timer.
* **Global Hotkeys (configured in settings or player management for admin):**
* **"Global Stop/Pause All" Hotkey:**
* Normal Mode: Pauses the Current Player's timer.
* All Timers Mode (timers running): Pauses all timers and returns to Normal Mode.
* All Timers Mode (all timers paused): Resumes all timers in All Timers Mode.
* **Audio Mute:** Look for a mute/unmute icon or setting.
## Future Enhancements 🚀
* Light/Dark theme options.
* Game statistics (e.g., average turn time).

13
README.md.rej Normal file
View File

@@ -0,0 +1,13 @@
--- README.md
+++ README.md
@@ -104,6 +104,10 @@
* **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
+ * **All Timers Player List:** A list of all players with running timers is displayed in the "Next Player" area. Players are removed from the list as their timers pause.
+ * The focused player on the top is a first player with a running timer.
+ * The list is updated dynamically as timers start and stop.
+
* Tap Current Player's area to pause/resume their *own* timer.
* Swipe Up to change which active player is "in focus" in the Current Player display.

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

87
index.html Normal file
View File

@@ -0,0 +1,87 @@
<!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 id="all-timers-player-list"></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>

86
index.html.orig 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>

65
index.html.rej Normal file
View File

@@ -0,0 +1,65 @@
--- index.html
+++ index.html
@@ -262,6 +263,7 @@
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 allTimersPlayerListEl = document.getElementById('all-timers-player-list');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
@@ -670,6 +671,11 @@
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
+ // Update the all timers player list
+ renderAllTimersPlayerList();
+ allTimersPlayerListEl.style.display = 'block';
+ } else {
+ allTimersPlayerListEl.style.display = 'none';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
@@ -702,6 +708,26 @@
}
// --- Player Management ---
+ function renderAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ const activePlayers = players.filter(p => !p.isSkipped && playerTimers[p.id] !== null);
+ activePlayers.forEach(player => {
+ const entry = document.createElement('div');
+ entry.className = 'all-timers-player-entry';
+ entry.textContent = `${player.name} (${formatTime(player.currentTime)})`;
+ allTimersPlayerListEl.appendChild(entry);
+ });
+ }
+
+ function updateAllTimersPlayerList() {
+ renderAllTimersPlayerList();
+ }
+
+ function clearAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ }
+
+
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
@@ -1040,6 +1066,7 @@
}
// --- Initialization ---
+
function init() {
initAudio();
loadState();
@@ -1048,6 +1075,7 @@
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
+
}
}
init();

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

86
patch.diff Normal file
View File

@@ -0,0 +1,86 @@
--- a/index.html
+++ b/index.html
@@ -144,6 +144,7 @@
<div id="player-list-editor">
<!-- Player entries will be dynamically added here -->
</div>
+ <div id="all-timers-player-list"></div>
<div class="player-form-buttons">
<button id="add-player-form-btn">Add New Player</button>
<button id="shuffle-players-btn">Shuffle Order</button>
@@ -261,6 +262,7 @@
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 allTimersPlayerListEl = document.getElementById('all-timers-player-list');
const reversePlayersBtn = document.getElementById('reverse-players-btn');
const addEditPlayerForm = document.getElementById('add-edit-player-form');
const playerFormTitle = document.getElementById('player-form-title');
@@ -669,6 +670,11 @@
function updateGameModeUI() {
if (gameMode === 'allTimersRunning') {
gameModeBtn.textContent = 'Stop All Timers';
+ // Update the all timers player list
+ renderAllTimersPlayerList();
+ allTimersPlayerListEl.style.display = 'block';
+ } else {
+ allTimersPlayerListEl.style.display = 'none';
let anyTimerRunning = Object.values(playerTimers).some(id => id !== null);
if (anyTimerRunning) {
appContainer.classList.add('pulsating-background');
@@ -701,6 +707,26 @@
}
// --- Player Management ---
+ function renderAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ const activePlayers = players.filter(p => !p.isSkipped && playerTimers[p.id] !== null);
+ activePlayers.forEach(player => {
+ const entry = document.createElement('div');
+ entry.className = 'all-timers-player-entry';
+ entry.textContent = `${player.name} (${formatTime(player.currentTime)})`;
+ allTimersPlayerListEl.appendChild(entry);
+ });
+ }
+
+ function updateAllTimersPlayerList() {
+ renderAllTimersPlayerList();
+ }
+
+ function clearAllTimersPlayerList() {
+ allTimersPlayerListEl.innerHTML = '';
+ }
+
+
function renderPlayerManagementList() {
playerListEditor.innerHTML = '';
if (players.length === 0) {
@@ -1039,6 +1065,7 @@
}
// --- Initialization ---
+
function init() {
initAudio();
loadState();
@@ -1047,6 +1074,7 @@
navigator.serviceWorker.register('sw.js')
.then(reg => console.log('SW registered:', reg))
.catch(err => console.error('SW registration failed:', err));
+
}
}
init();
--- a/README.md
+++ b/README.md
@@ -104,6 +104,10 @@
* **All Timers Running Mode:**
* All active player timers run simultaneously.
* Enter by clicking "All Timers Mode" (starts all timers).
+ * **All Timers Player List:** A list of all players with running timers is displayed in the "Next Player" area. Players are removed from the list as their timers pause.
+ * The focused player on the top is a first player with a running timer.
+ * The list is updated dynamically as timers start and stop.
+
* Tap Current Player's area to pause/resume their *own* timer.
* Swipe Up to change which active player is "in focus" in the Current Player display.
* Focused player can tap their area to pause their *own* timer. Any player can use their "Pass Turn / My Pause" hotkey to pause their *own* timer.

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);
}
})
);
})
);
});