From 774dd7ecb80889ef12b1f8a62def5dda425bbf71 Mon Sep 17 00:00:00 2001 From: cpu Date: Wed, 7 May 2025 15:50:01 +0200 Subject: [PATCH] working --- README.md | 144 +++++---- README.md.orig | 126 ++++++++ README.md.rej | 13 + assets/default-avatar.svg | 4 + assets/favicon-16x16.png | Bin 0 -> 398 bytes assets/favicon-32x32.png | Bin 0 -> 770 bytes assets/icon-192x192.png | Bin 0 -> 6035 bytes assets/icon-512x512.png | Bin 0 -> 17887 bytes favicon.ico | Bin 0 -> 15406 bytes index.html | 87 +++++ index.html.orig | 86 +++++ index.html.rej | 65 ++++ manifest.json | 24 ++ patch.diff | 86 +++++ script.js | 649 ++++++++++++++++++++++++++++++++++++++ style.css | 345 ++++++++++++++++++++ sw.js | 50 +++ 17 files changed, 1616 insertions(+), 63 deletions(-) create mode 100644 README.md.orig create mode 100644 README.md.rej create mode 100644 assets/default-avatar.svg create mode 100644 assets/favicon-16x16.png create mode 100644 assets/favicon-32x32.png create mode 100644 assets/icon-192x192.png create mode 100644 assets/icon-512x512.png create mode 100644 favicon.ico create mode 100644 index.html create mode 100644 index.html.orig create mode 100644 index.html.rej create mode 100644 manifest.json create mode 100644 patch.diff create mode 100644 script.js create mode 100644 style.css create mode 100644 sw.js diff --git a/README.md b/README.md index 734320d..33448ee 100644 --- a/README.md +++ b/README.md @@ -1,108 +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. - -![OrreryTimer Mockup/Screenshot Placeholder](placeholder_screenshot.png) -*(Replace with an actual screenshot or GIF once available)* +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 -Inspired by an orrery (a mechanical model of the solar system), OrreryTimer visualizes players in a circular sequence. The "Current Player" is centrally featured, with the "Previous Player" and "Next Player" flanking them, clearly indicating the flow of turns. This tool is perfect for board games, round-robin discussions, timed presentations, or any scenario needing structured turn management with individual countdowns. +Nexus Timer visualizes players in a circular sequence. The **Current Player** is prominently displayed in the top half of the screen, and the **Next Player** (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**. - * Adjacent displays for **Previous** and **Next Players**. - * Clear visual connection between these three roles. - * Animated indicator showing the **direction of play** (clockwise/counter-clockwise). + * Central focus on the **Current Player** (top) and **Next Player** (bottom). * **Individual Player Timers:** - * Each player has a customizable countdown timer (MM:SS). - * Timers continue into **negative time** if the limit is reached (up to -59:59). - * Players whose timers reach max negative time are "skipped" but remain visually present. - * Visual feedback: Red pulsating effect for active timers (background for positive, text for negative). + * 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 (Single Active Timer):** - * Only the Current Player's timer is active. - * Pass turns via swipe gestures (left/right) or player-specific hotkeys. - * Tap Current Player to pause/resume their timer. + 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 player timers start and run simultaneously. - * Ideal for scenarios where everyone has a global time limit or races against each other. - * Players can pause their *own* timer when they are current. - * Global "Start All / Stop All" control. - * App-wide visual (pulsating background) and audio (continuous ticking) cues when active. + * 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 (minimum 3, up to 10). - * Upload player photos or use default avatars. - * Set initial timer values per player. - * Assign unique "Next Turn" hotkeys and non-unique "Stop My Timer" hotkeys. - * Easily re-order players (drag-and-drop preferred). + * 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 gestures for changing Current Player and turn direction in Normal Mode. - * Tap interactions for timer control. - * Global hotkey for "Stop All Timers." + * **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." - * 3-second alert tick when a timer becomes active in "Normal Mode." - * Option to mute all sounds. -* **Persistence:** Player configurations, current timer states, and game settings are saved locally, so your setup is ready when you reopen the app. -* **Global Reset:** A "Reset Game" button to revert all timers to initial values and reset the game state. + * 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 - git clone https://gitea.virtonline.eu/2HoursProject/OrreryTimer.git - cd OrreryTimer - + # 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:** - * Click "Manage Players." - * Add at least 3 players. For each player: - * Enter a name. - * Optionally, upload a photo. - * Set their initial timer duration. - * Optionally, assign a "Next Turn" hotkey (must be unique) and a "Stop My Timer" hotkey. - * Arrange players in the desired turn order. + * 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, Previous, and Next players will be displayed. - * The turn direction indicator shows the flow. + * 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. - * Swipe left or right on the Current Player's area to pass the turn and set the direction. The new Current Player's timer will start/resume. - * If the Current Player has a "Next Turn" hotkey, pressing it will pass the turn. + * 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:** - * Click "Start All Timers." All player timers will begin counting down. - * The app background will pulse red, and a ticking sound will play. - * When it's a player's "focus" as Current Player, they can tap their area or use their "Stop My Timer" hotkey to pause their *own* timer. The focus will then shift to the next player with a running timer. - * Click "Stop All Timers" (or use the global hotkey) to pause all running timers. + * 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:** - * Click "Reset Game" and confirm to restore all timers to their initial values and reset player positions. + * Tap "Reset Game" and confirm to restore all timers to their initial values. ## Configuration -* **Player Hotkeys:** Configured in the "Add/Edit Player" form. - * "Next Turn" Key: Advances to the next player in Normal Mode. - * "Stop My Timer" Key: Pauses the current player's timer in All Timers Running Mode. -* **Global "Stop All" Hotkey:** [Specify default, e.g., 'Escape' or 'S'] - this pauses all timers in any mode. -* **Audio Mute:** Look for a settings icon or option to mute all sounds. +* **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. -* Ability to save and load different game setups (player lists & timer configurations). +* Game statistics (e.g., average turn time). \ No newline at end of file diff --git a/README.md.orig b/README.md.orig new file mode 100644 index 0000000..33448ee --- /dev/null +++ b/README.md.orig @@ -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). \ No newline at end of file diff --git a/README.md.rej b/README.md.rej new file mode 100644 index 0000000..331ceb7 --- /dev/null +++ b/README.md.rej @@ -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. + diff --git a/assets/default-avatar.svg b/assets/default-avatar.svg new file mode 100644 index 0000000..e5c534b --- /dev/null +++ b/assets/default-avatar.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/favicon-16x16.png b/assets/favicon-16x16.png new file mode 100644 index 0000000000000000000000000000000000000000..8d52034c62a6445ee292243a68123ff04849b538 GIT binary patch literal 398 zcmV;90df9`P)Px$NJ&INR5(v#l$-Omj){S>nt_3Vje#`q>mS4aRz`(6?|zb@8D`ZlkOBX(SuDZD z#2~@V2$uQ>!v7)o@#lXGpMN2=U>IO1#==l%!O37C%7WEHG21^g9DVi&E{9@(t_U;3 zEEgVzwPx%yh%hsR9HvVS4n6SVHkb?WSXT(8a39o#uD+Ol2SxOD;A+r>cLj*#a$HbLA-hp zYeNrWy%j_#7>b}?S~nDmT32XQirOLsal05}8^t7=t%;dtMrI}%YiXLyq)haKVK#o= z`@Z+Rex zz@@GHzY^hj`|U8?{*)|f@^XPiM=;57U_?wQcv*&bSwIo-Q|*|LIVnQQ`OT7uDi3qYs)(&bLz z?2C9V9A!;7+o&dc2`)66q+iE}Vz~J+sUCsOIs*bPQf~fa6GpK+6krd@tkFQJmc1`-$&aez6xU0LjV8(07*qoM6N<$f;~H9 A6951J literal 0 HcmV?d00001 diff --git a/assets/icon-192x192.png b/assets/icon-192x192.png new file mode 100644 index 0000000000000000000000000000000000000000..7fabb3868741e613c459cce621525e917d36b76e GIT binary patch literal 6035 zcmb_gS5#9`lubgwgkGgXLhsTP1cXpSCsYCH9YHCErXT@Ds)&em0qMOXU5cRy(o0Zj z(!2CtCeF-S^D!UuGav8WkNx)B=iYbTJ?HL7lrEBtn28tw0FY^@s~F(N_J0?I2!A)2 zcE5!mfX@t&N`Ue~mURGtUPnVk(a7Iw+Y*X4Qf<24IF8?O=W^!&#sfrR3L^vsA{=a{ z2+@J^M`wYd(u0LV1uXT9Jx_=-W2NQ22ts8~orsK3lpS;^eMTx(6-THICFJLa9o^BO z&DNWIdnH|^=S9Sg>urPUA94+k9?n!;|DA0zzTu!WexH5QGI}V(Fk{=>p-T{8&#qDZ^hy%AMl>!+h_e8X-x(E-32RFq90dP(_#*}JJesB83 zBMF`RKyoqSd^sfK(8=)~ty6gcBaol_xPy|JGX5{)8`CPc=iO`(Um}O^0dr~ZpE*8M z?tKI#1y%oVK@V7slxuzVNG;1A28+^mz4jfdn@N;pANr$*d67F(p{J2y%Wy}zFN4*7SB_W> z0R$x1eWrzl28Ht-5sA3S|N(iN=q^T=(VG=I_=R_;j zK*LXmI!7G=JiDoF$^*<4YOJ6`j+Shj=PRmY&B{k$m+6f_6H9BMNUF6gh`N`~x7$Uk znCZ(=cW5K1U4s?fD<`Ce;0Db>BB0Gq4m-Cov_B8DD(Hz;cHv2yF=i&W)L<~3>82M> zD`WWY7B2!XFy+f_X&|6T4)2EWsUnE0#;FpE$R@1 zSXIWfv}$r3XKn@Jh8`xYK}3PnmITz7=P3YaIUw@onKniRhXJ>}UA<>izb9L)6^!e5J1@E!JfC$x z{aQ6^dnLKu7P+OC65gL=JNBK3MDx6PFk$yrNHr6Uk8rn5EY*^)OE)+Iw1lp2_evpm zC}3Pu&NMog_SB9-IK?BL#{-ePQbN34y!-JBk6zyk=`)XHFVPOHz7x?kH<4A@Cog0J zs#OFSoc}S?joDc3j$SO6N>^nKcp;BOP`qX4)%L5 zlv;_9{t%sCcoaB3KBa%V)3~I#@*R}&t}ik#1jjEbj|&UF3A^=kooIAA^h$X#Ir46^ zo0wY}1zlYWIEp~6RcF0>c7oIMr3d}X7sa;-Bv?xiiD4B`jmtAb1*_{X-Om-jC~Guu zqtxQt8(5_BNzscj_UV@#B`7VwgmBNJVgWZ~uL!p-1mSn3A7t4OP)OfsNy;S~K!U7` zzjn-F*YhBsTG`RV&8?!;}V34Z=VlU$e_`iWWl} zOrq@KvzZ1v6gAO^Os2=V`FN($%i{w1C6A&J4(ozpT`f$8Rmh8QZhHr7dY6LmXnFAQ zg|@}lYU1X;@4cN?d1F-V+W_6|KiF^IKD__1Q;Bn*Lf7{DjW3+ApP?LUmh}oAfkr-E z_bEQTT%oO~8~CoO0CL#({L3qUxp-mzsP*z5Y|J$SP}}#Jd#HACx@Bpk;zZ%3vSb?D zR$ht)W(IwcPwO1xhYZiS%@3_|+iQO*VJ*mL<``R*#TnbLADjOUwS56PRKK^|Q`5?j zw)keOa$9cd>3m2QHhOMy{?E`=;#`PjbQu(3l1idhFh6Z?S$er$7&75Q{mz5fG=J!@ z`BU^0C}J}0Br`3>m0$)*7QEldagl_86hbw}QxTTx=Hwi;ch%=8{%)&CdAPsJA5eu0 z&6?Q%RX}Jwjgh zN2}<>{@|!(@7JrbD@AuXO6=r9oa4$SNjycVie=%faAnGE!vr>r_c)am4LFPH? z_x-0|#1^w^K5s*Y2LyLl63~a5u}{oKn34}Uknj8rc0Vn{Tk7k{|Nbqj**UFBpfb~K z5xpmzn6FSDu-^}0yXu@P+G+3tW`C|KgCKUIPAIgx^LV-VLqgg9)}ntJuyo?WL#Mp? zUVCUMP44q#D7o!9?;Ch2W=_>Rx(-~;JIl+RyuI3TTj(fn{rxKnu#|p1)k}*$!up-u zIo2u_F8!7|<1=MWtzw4AuGXFTm6-TIFGf?fURpQDbw+Q6@Q|%tl^yg24rnc$@y*AH zyTv*!>uE8YPbLs*y9*H8b`6>b?i->Wjf0q0OC?&8#inSIYIIx?3;8D{l`pMPb6YhI z7s<+g8q~=W9E1T^zpPA#qxs8b>RE^v(iYWCr zm`lsh(H}p#;GdXG82#Y3UrjsIvD*Y9ZYO!Xm zV0b!51kwhC33&IUSXuZ*AtSNKoAsXQI|P~zRdJJ4HXy#ySdph8!P?q2p{vDw5;!B< zfeabCY}0NkN<3)r?tPHO>1Ho&9Ou_4@dc0NDYu+c8G-UMmtH)I_*8m_f0mMQwFtti z`NmnD~*S2s)>#*-sYHu?HM%pLp$3D%)UiW;#z zB?Wn$_0g#_?A4a3Us^!ctadbzpqfd(lq7^#wl-D1>xH2){J`D(=5OQ(h2n%XwJJFc zY%t}kUouX!{Bv=j87=mQU$MT!n6zxMr^}uD+8F7$e96KJT?4;URpo$^tlRY!2WIjO z7Kfbe;0S`nniu!?9IOIk?+i3sH*jNauBSa5Etay`fjkHn20ixl*T09fP3$h!zQLr_ zHN`Z!=11l6CEX5-FEjem-O)sa(ON3~Z06U);t^UGF#Id2R_zT*@&688k7MD_>s(t@ zBHeS~tOfRIoS3fG_&_p1U)f}C{)d&W5L;5dB$s_Fy(u^O*%=|M+cmO@kp}j-cZ0dG z_R50+6i{b*sg~#4w#;y+(eR!<2rF9Vu-Ew9oSw87HGe*l8yp4pJLz#Jp#7GBoqi3a zBtY}}`q*?Opt1kv4ja6#kk^UTgH?Z8%! zz7fwOe|&POM9x=KTl)K}MC}i6B{=SV@nIq2?pu#&mZ5^rHd_{fu4n$2n*i=$vqOUHm8nuzhri*_|LAw7>Ps42Sy49+Ue=Rm+{7NFg5gyS&N{k* zMk!@VZO1R%T%HeT_A|$5UHr>Lz(L0hYcKn!D0^Do)%`7cct+rs`}(dwJXqw^}9_9lsl<7_cHZ3ydGTN+5m>Qv4;=;w6>-bYt-2x1>Ql2i41CYtfhG-GzW@v(r?<9`AS#l zZCIz&I6mMp%^qi&O0)sfnnqSr0#2RO5?d5!w8->Ujt9Z)qQ6w%qn5Ao$`$J)wL`Ih z6O^Viz+zkrd%-RQM#RDXXOS9AS+}fyn!IY3Ul8RpZ-0y8qsb!6GSSh(c>~mDX4^W# zSEopXl#C|~-$FgX7W}J!y9LD)nicM$115?EN(Gl#VGoK~Q;c}psXUo};=)$D3#BkQ zA_VYAjf#hMFBp4(QQF2w<8~kxAkM7d#a=UfW0d{w_G6E{$o_{W3&=sD3SyS#JiAoQp|O;uy@}ZQ)p(fup*KEcn|lXKT+;z2-iIz~Q+7XN&8IJcvYkjs z(jPFZDB&_qU)#h6WV(JXeN^gwra{G$S&*r{qP__O(Y`;R?fmp9YG%B>B1k`Hu6 z$Bh*OAoL=C0^6$uN;iD9F-{TkpOQkWn5q%?&7?7xq#C90f~AV zJ&{bESyJeO4T`!3AK^?Owy>fvO$?Zd_$AoYKF6E;*&~&WmtkY0XaIQWm}7mWQr$j< zubGk^+e@Bom<*TaEF5S%t5Wm$AnEe zLg1)dP48&z(4^a|+^9vl;po3PoCc!BnQRI+8 zE~fiLqUNh;M4Eh|k}Kv}_G5zknjP90QJW^#KSgQv-~LeY3lS&9hWv|!0)2i_4|Roc ztO-;+spw*tKkj(g10D|Zs>P>|E!Ml4!9U1bW_0EpKSyd7)z>+_o)$9r9tAJQlJp1A zs3ddr03eF*quH*fDJE}XKVM1`^C0|1-W;RN*HJ!nO{)smVeB{GfL=a|EFUa*5DZ_a zf-L<1JUq7prcfu+tw~sWNk$|~8XI!ru_^20XSN+KLEu7(kXs6irE#f;(oR+Q(9w&} zEd0gw*nw+ZC)Z>zg;wI$!~{!X-{7HQ8Hr9q`X$nvo_9f}oqHJcn%58f-)g%i!nW4U zNxv3Nq;JXVwfdZWcIrE@%5Ktxj4TDPeF!cr{n%h{dDq0-fRyYpOS3v&z8TsmA}6Lf zCyV(==k(<6?Cij-O;t(Bc_LmW-@e*1`9v!S%vbT=L>uKBZd8HYQ+EKp9+9oDr-|-f zmxTzHyl+^3^|=<-pI~Ncnxd_xC=W4&->Gw_&mbLGlDPGTDEBi-@e4(xR>{54pNwTEqASi}2hlDmkD07w(K4NN+h0e`emWf|6RaUKd(8gop-&B1-*m$H}DOR z{dTda*XsFW_i1(qUIwzh5+yF0ENIlXnsk462jZsZc77x!WE!+*dCPzs>0lX_9KTm_36K;-8FVA7D!L1bm6>MJ-FR%EjCmDJGA*`V0chIg zpLKCgU;s1-fOdrSg|NA3U7;oPVXjqnQyHGq8Dgr}$pG315 z9#@Lui?#!Ag#0_%-&8Rp zx{a_aC#>BaD3&k>{pFm|K)(!0rrsyv_rhhkHJ7<+Jl}i?I8o;m+#E(E{qYC4b1f^| zhs0oB{*UwO(X{3I>&fp}(YKFs>p=|d;g09dZ(l@^{I`Mo&1Cg`Zk-O`v2b3mrRn65 z*0vBQr?fuo`^I$re%7x~CdBYiR$_7uNyES0`^C3Z<3NoS>(jP2d5jtdm zbw9QP;O~LJJ0G$KzAq5(!C~+}Ngzki6dW@^y)a{=GjqFs%&hjH=?8Jv%XbR#`@vgB W6@G)1C5CvH9iXAAt5U9H6Z#)KUH5hX literal 0 HcmV?d00001 diff --git a/assets/icon-512x512.png b/assets/icon-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..6c47bef641900744a3fb17630b5af64549b71cd2 GIT binary patch literal 17887 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 V4oQ0eez678xU6%j=z`U~{{<5pF}nZ& literal 0 HcmV?d00001 diff --git a/favicon.ico b/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..d8045d922e571db84ff130ce2bf1309d55f3c119 GIT binary patch literal 15406 zcmeHOYitx%6doV{#UK8~Em6d%JbVE0iDF{Jx6#BzOo&esAMvgDXd0tGF#5-+3DA~T zEm&G;iwG@HD32nK2sTZrJfyrzsqH*=W_EXGcjkD`-OjW-yRe8CZ2I)!_merU<2sI;f^Col zDU#2z%28ox!a z9dF2qUf%i!;9Ota~6Q`p6$& zGkSUJ>tm;9^IgW;6JG!mB8R>oGriRK1Q^N%(2lPMb@w7L!e@ZSA-BE?^7c1bolOSe z=4T*2aF}7w^e6oLi(|qg*3M=+OnNLeJqc3d;~;IA2g0h`ob(yjKsKDi@AFxMj5ecD zaPg4rObD+zc#(a&RDlZa%E6`Ae}TCB#yB>ooO1cyM?4wtO2#{vKJum+O!D?~+)m8J z#^1s8I=cC>E4wUu}L8!Uicf5(AZrk*j(IK=M(|pBXsQos7x*@yjI=U$B z@B7MMSTz-OAZ|m5b}sRiKds~Vh5a^v+D8{+ZM*419rhsz1nukH(7I_${w802V_N^> zn!C{jF>D!R*t@6ExZ!KH)q7i+uSWi&+EEVTulL#gT7DRT@aj7(uDyrJ*B&U|&^MUA zL-GED_G9f-3&gr>Ks#0s+CRU6hPd@J+aY@R$DI94O9mwC(Y$P?W4s}s+v+Rlx~dG6 z9q*w3_tzM?jx;OuH-h9rHi-0r)`7b3E4(W3_m`mV{T!4%i$K}^2`IZh!hPxo5Z?G) z{^NHa0}2w)V){VL56xLG^PCrl5A3HL`pEYs|NByLf&a^Mij!c7mRo?=<>Dmxn&X#D zx@K_M#QCT%9eh?eL%D3+BzPrWW!{|qZ)w&Wfp?3N4> zv#m4Mv*6XGt?$|7S;}iQ8}{+{LE6h)601-%1GE#3kQh1&dfz^Xp&u<_JfmQ?(qlc5 zTV4h;CXQ^Z@y?8^c%JiV-advOGN3pC;dK`BmxPtKmi#=BUqR=F&O*K*^OHTE|9t02 zK|L`OQ73%R&+W!o{*2O>|IYXD>^5D0$Tt>$xwGW)%dM}0sYz+_PYiYgor&fSF=_MH$S&eq&Qxt z;wl%Sh;}x5}YKu@C#@sN5`G5!RQ!|_uBpys((W|ZNau{ zBRYuqlleV`;^S36vA^qV+#@$82(>cYeiOuw)}-N2@I4vN$_mN5P<>Gzfzy{UWZcJ%I$tXk zFE9F$nw|nfy_hlwCfd;OrWXRCLpKl(Nb`Z!+5%ZonM1B+Aeqe7t#`WEpQ z`8!rU8N_uDWz7u}YiHqBLQfe(ta~-^%O@9g4oHVdmnp{~e(?}QaBnF!Jd*Tr9oV#! z%_;b`Q(JJ{Q;UjUSa}PcgEyq$qPP#)e3CDn+sa!OfVBQmP}<(cm{7!S%Y6B|IT(+) z1H?76(H`9oQvF;ue#&!^8ZloOWkKu53!S$5qR@q1{aicx_-(j$wt;@S74u-WU_Q(y&`xe-v3L6agBa>0`4Evmf55oc zEZ^~8ZfxW4T>r0AaON+_XOP4B1Oe91WG+OI^9L{oBFOn0Rt}1t3sK7JVOZZs=g$!S L@iH!fTqW=yU4u4Y literal 0 HcmV?d00001 diff --git a/index.html b/index.html new file mode 100644 index 0000000..4a8784d --- /dev/null +++ b/index.html @@ -0,0 +1,87 @@ + + + + + + Nexus Timer + + + + + +
+
+
+ Current Player Photo +
+
+

Current Player

+
00:00
+
+
+ +
+
+

Next Player

+
00:00
+
+
+ Next Player Photo +
+
+ +
+ + + + +
+
+ + + + + + \ No newline at end of file diff --git a/index.html.orig b/index.html.orig new file mode 100644 index 0000000..07d6020 --- /dev/null +++ b/index.html.orig @@ -0,0 +1,86 @@ + + + + + + Nexus Timer + + + + + +
+
+
+ Current Player Photo +
+
+

Current Player

+
00:00
+
+
+ +
+
+

Next Player

+
00:00
+
+
+ Next Player Photo +
+
+ +
+ + + + +
+
+ + + + + + \ No newline at end of file diff --git a/index.html.rej b/index.html.rej new file mode 100644 index 0000000..c40ecb2 --- /dev/null +++ b/index.html.rej @@ -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(); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..de526b8 --- /dev/null +++ b/manifest.json @@ -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" + } + ] +} \ No newline at end of file diff --git a/patch.diff b/patch.diff new file mode 100644 index 0000000..2ddece9 --- /dev/null +++ b/patch.diff @@ -0,0 +1,86 @@ +--- a/index.html ++++ b/index.html +@@ -144,6 +144,7 @@ +
+ +
++
+
+ + +@@ -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. \ No newline at end of file diff --git a/script.js b/script.js new file mode 100644 index 0000000..ed286aa --- /dev/null +++ b/script.js @@ -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 = '

No players yet. Add some!

'; + } + 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 = ` + P + ${index + 1}. ${player.name} (${formatTime(player.initialTime)}) ${player.isAdmin ? '(Admin)' : ''} ${player.hotkey ? `[${player.hotkey}]`: ''} +
+ + + ${index > 0 ? `` : ''} + ${index < players.length - 1 ? `` : ''} +
+ `; + 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(); +}); \ No newline at end of file diff --git a/style.css b/style.css new file mode 100644 index 0000000..0e3fff4 --- /dev/null +++ b/style.css @@ -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; +} \ No newline at end of file diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..b5ae236 --- /dev/null +++ b/sw.js @@ -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); + } + }) + ); + }) + ); +}); \ No newline at end of file