Compare commits
3 Commits
main
...
774dd7ecb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 774dd7ecb8 | |||
| 4ec23960cc | |||
| 9a9e9344cc |
127
README.md
127
README.md
@@ -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
126
README.md.orig
Normal 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
13
README.md.rej
Normal 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.
|
||||||
|
|
||||||
4
assets/default-avatar.svg
Normal file
4
assets/default-avatar.svg
Normal 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
BIN
assets/favicon-16x16.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 398 B |
BIN
assets/favicon-32x32.png
Normal file
BIN
assets/favicon-32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 770 B |
BIN
assets/icon-192x192.png
Normal file
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
BIN
assets/icon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
favicon.ico
Normal file
BIN
favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
87
index.html
Normal file
87
index.html
Normal 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
86
index.html.orig
Normal 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
65
index.html.rej
Normal 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
24
manifest.json
Normal 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
86
patch.diff
Normal 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
649
script.js
Normal 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
345
style.css
Normal 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
50
sw.js
Normal 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);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user