@bobfrankston/msger 0.1.74 → 0.1.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/KNOWN-BUGS.md +121 -0
  2. package/MSGER-API-SUMMARY.md +162 -0
  3. package/MSGER-API.md +376 -0
  4. package/README.md +93 -0
  5. package/SESSION-2025-11-06.md +191 -0
  6. package/SESSION-NOTES.md +678 -0
  7. package/clihandler.d.ts.map +1 -1
  8. package/clihandler.js +62 -2
  9. package/clihandler.js.map +1 -1
  10. package/clihandler.ts +60 -2
  11. package/icon.png +0 -0
  12. package/icon1.png +0 -0
  13. package/msger-native/Cargo.toml +1 -0
  14. package/msger-native/bin/msgernative.exe +0 -0
  15. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Breadcrumbs +12 -118
  16. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/BrowserMetrics/{BrowserMetrics-690552AF-DCD4.pma → BrowserMetrics-690B9AD3-657C.pma} +0 -0
  17. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/BrowserMetrics/{BrowserMetrics-69055373-F88C.pma → BrowserMetrics-690BA05A-501C.pma} +0 -0
  18. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Crashpad/settings.dat +0 -0
  19. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/BrowsingTopicsState +1 -1
  20. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_0 +0 -0
  21. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_1 +0 -0
  22. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/{BrowserMetrics/BrowserMetrics-69055587-A65C.pma → Default/Cache/Cache_Data/data_2} +0 -0
  23. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_3 +0 -0
  24. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000001 +383 -0
  25. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000002 +1091 -0
  26. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000003 +2153 -0
  27. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000004 +0 -0
  28. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000005 +626 -0
  29. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000006 +393 -0
  30. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/index +0 -0
  31. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/01241693cfdc32b9_0 +0 -0
  32. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/0ba1eea781f3552c_0 +0 -0
  33. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/323aa210eebefe2c_0 +0 -0
  34. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/4608446ac118e77a_0 +0 -0
  35. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/6938205dc2f77841_0 +0 -0
  36. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/6de12299dc89e5f3_0 +0 -0
  37. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/8f403c112eaa455b_0 +0 -0
  38. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/9a3aceb491137f07_0 +0 -0
  39. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/aedb266cbaf9c28f_0 +0 -0
  40. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/ca526fdda86d0b9d_0 +0 -0
  41. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/f5d11d783c9fdf69_0 +0 -0
  42. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/index-dir/the-real-index +0 -0
  43. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Collections/collectionsSQLite +0 -0
  44. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Collections/collectionsSQLite-journal +0 -0
  45. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DIPS +0 -0
  46. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnGraphiteCache/data_1 +0 -0
  47. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnGraphiteCache/index +0 -0
  48. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnWebGPUCache/data_1 +0 -0
  49. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnWebGPUCache/index +0 -0
  50. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Extension State/LOG +3 -3
  51. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Extension State/LOG.old +3 -3
  52. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Favicons +0 -0
  53. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_0 +0 -0
  54. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_1 +0 -0
  55. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_2 +0 -0
  56. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/index +0 -0
  57. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/History +0 -0
  58. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/000003.log +0 -0
  59. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOG +3 -3
  60. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOG.old +3 -3
  61. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/MediaDeviceSalts +0 -0
  62. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/MediaDeviceSalts-journal +0 -0
  63. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Network/Network Persistent State +1 -1
  64. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Network/TransportSecurity +1 -0
  65. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Preferences +1 -1
  66. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/CacheStorage/14e849fa8522d406112ea607cf7fd6342b71b987/249ee9af-c3df-4a86-89a8-2c51f3370ee0/index +0 -0
  67. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/CacheStorage/14e849fa8522d406112ea607cf7fd6342b71b987/249ee9af-c3df-4a86-89a8-2c51f3370ee0/index-dir/the-real-index +0 -0
  68. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/CacheStorage/14e849fa8522d406112ea607cf7fd6342b71b987/index.txt +0 -0
  69. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/000003.log +0 -0
  70. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/CURRENT +1 -0
  71. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOCK +0 -0
  72. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOG +3 -0
  73. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOG.old +3 -0
  74. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/MANIFEST-000001 +0 -0
  75. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/2cc80dabc69f58b6_0 +0 -0
  76. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/2cc80dabc69f58b6_1 +0 -0
  77. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/index +0 -0
  78. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/index-dir/the-real-index +0 -0
  79. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/000003.log +0 -0
  80. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/LOG +3 -3
  81. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/LOG.old +3 -3
  82. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/000003.log +0 -0
  83. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOG +3 -3
  84. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOG.old +3 -3
  85. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOG +3 -3
  86. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOG.old +3 -3
  87. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager +0 -0
  88. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager-journal +0 -0
  89. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/favorites_diagnostic.log +27 -0
  90. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/000003.log +0 -0
  91. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/LOG +3 -3
  92. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/LOG.old +3 -3
  93. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/000003.log +0 -0
  94. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOG +3 -3
  95. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOG.old +3 -3
  96. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_0 +0 -0
  97. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_1 +0 -0
  98. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_3 +0 -0
  99. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/f_000003 +0 -0
  100. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/f_000004 +0 -0
  101. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/index +0 -0
  102. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GraphiteDawnCache/data_1 +0 -0
  103. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GraphiteDawnCache/index +0 -0
  104. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Local State +1 -1
  105. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/RevisitationBloomfilter +0 -0
  106. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/ShaderCache/data_1 +0 -0
  107. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/ShaderCache/index +0 -0
  108. package/msger-native/src/main.rs +343 -37
  109. package/msger-native/src/template.html +103 -28
  110. package/msger-storage-demo.html +290 -0
  111. package/msger.code-workspace +3 -0
  112. package/msgerdefs/README.md +122 -0
  113. package/msgerdefs/msgerdefs.d.ts +322 -0
  114. package/msgerdefs/msgerdefs.d.ts.map +1 -0
  115. package/msgerdefs/msgerdefs.js +110 -0
  116. package/msgerdefs/msgerdefs.js.map +1 -0
  117. package/msgerdefs/msgerdefs.ts +427 -0
  118. package/msgerdefs/package.json +38 -0
  119. package/msgerdefs/samples.html +431 -0
  120. package/msgerdefs/test1.cmd +1 -0
  121. package/msgerdefs/tsconfig.json +17 -0
  122. package/msgernative-linux-x64 +0 -0
  123. package/package.json +5 -1
  124. package/shower.d.ts +2 -0
  125. package/shower.d.ts.map +1 -1
  126. package/shower.js +17 -0
  127. package/shower.js.map +1 -1
  128. package/shower.ts +24 -0
  129. package/test-data-persistence.html +315 -0
  130. package/test-htmlfrom.html +29 -0
  131. package/test-ipc-reach.html +113 -0
  132. package/test-msger-api.html +120 -0
  133. package/test-msger-functions.html +325 -0
  134. package/msger-native/bin/msgernative.exe.WebView2/EBWebView/BrowserMetrics/BrowserMetrics-69055419-C8A0.pma +0 -0
@@ -0,0 +1,2153 @@
1
+ import { VoiceController, DEFAULT_VOICE_CONFIG } from './voice-controller.js';
2
+ import { AIParser } from './ai-parser.js';
3
+ import { addToDebugLog, addToDebugWarn, addToDebugErr, addToDebugPriority, toggleDebugLog, clearDebugLog, copyLogToClipboard, saveLogToFile, setAppVersion, isLogVisible } from './logging.js';
4
+ import { loadSettings, saveSettings, applyLoadedSettings, resetSettings, applyTheme, initializeSettings, getShowDigitalTime, getSilentMode, getListeningTimeout, setShowDigitalTime, setSilentMode, setTheme, setListeningTimeout, setWasFullscreen } from './settings.js';
5
+ import { initializeWeather, getCurrentTemperature } from './weather.js';
6
+ import { watchForDSTChange } from './time-utils.js';
7
+ // Helper to reduce repetitive getElementById calls
8
+ function dv(id) {
9
+ return document.getElementById(id);
10
+ }
11
+ // Type-safe CSS class helpers
12
+ function classAdd(element, ...classes) {
13
+ element.classList.add(...classes);
14
+ }
15
+ function classRemove(element, ...classes) {
16
+ element.classList.remove(...classes);
17
+ }
18
+ function classToggle(element, className, force) {
19
+ return element.classList.toggle(className, force);
20
+ }
21
+ // Get all DOM elements once at module level since script loads after HTML
22
+ // Note: Banner elements accessed dynamically via banner-content container
23
+ const startTimerBtn = dv('start-timer');
24
+ const cancelTimerBtn = dv('cancel-timer');
25
+ const floatingCancelBtn = dv('timer-cancel-floating');
26
+ const timerCountdown = dv('timer-countdown');
27
+ const timerMinutesInput = dv('timer-minutes');
28
+ const timerSecondsInput = dv('timer-seconds');
29
+ const hourMarkers = dv('hour-markers');
30
+ const digitalTimeOverlay = dv('digital-time-overlay');
31
+ const secondHand = dv('second-hand');
32
+ const minuteHand = dv('minute-hand');
33
+ const hourHand = dv('hour-hand');
34
+ const countdownDots = dv('countdown-dots');
35
+ const ampmIndicator = dv('ampm-indicator');
36
+ const showDigitalCheckbox = dv('show-digital');
37
+ const silentModeCheckbox = dv('silent-mode');
38
+ const themeSelect = dv('theme-select');
39
+ const voiceToggleBtn = dv('voice-toggle');
40
+ // Module-level state
41
+ let userTimer = {
42
+ active: false,
43
+ totalSeconds: 0,
44
+ remainingSeconds: 0,
45
+ startTime: 0
46
+ };
47
+ let timerAnnounced = false;
48
+ let voice = {
49
+ enabled: true // Re-enabled for wall clock use
50
+ };
51
+ let voiceManuallyStopped = false;
52
+ let lastVoiceActivity = Date.now();
53
+ let voiceActivityTimeouts = 0;
54
+ let lastCommand = '';
55
+ let lastCommandTime = 0;
56
+ let lastSpokenText = '';
57
+ let lastSpokenTime = 0;
58
+ let clockInterval = 0;
59
+ let userTimerInterval = 0;
60
+ let bannerCountdownInterval;
61
+ let voiceListeningTimeoutInterval;
62
+ let listeningTimeoutStart = 0;
63
+ let listeningTimeoutDuration = 0;
64
+ let isSpeakingHelp = false;
65
+ let helpTimeoutInterval;
66
+ // DST change tracking
67
+ let dstChangeTimestamp = 0; // Timestamp of last DST change
68
+ let dstStopWatcher; // Function to stop DST watcher
69
+ // AI parsing configuration
70
+ let aiParser;
71
+ // App version tracking
72
+ let appVersion = 'v0.0.0'; // Will be loaded from sw.js
73
+ // Command canonicalization to prevent duplicate/misparsed commands
74
+ let recentCommands = [];
75
+ const COMMAND_DEDUP_WINDOW = 3000; // 3 seconds to prevent duplicates
76
+ // Default inactivity policy - easy to modify
77
+ const INACTIVITY_POLICY = {
78
+ hideAfterSeconds: 30, // Hide after 30 seconds
79
+ hideCursor: true, // Hide cursor when inactive
80
+ hideBanner: true, // Hide banner when inactive
81
+ fadeTransitionMs: 500, // 500ms fade transition
82
+ showOnWakeWord: true, // Show UI on wake word
83
+ showOnVoiceCommand: true // Show UI on voice commands
84
+ };
85
+ let inactivityTimer;
86
+ let isUIHidden = false;
87
+ let lastActivityTime = Date.now();
88
+ let lastHideTime = 0;
89
+ let inactivityCountdownInterval;
90
+ // DST and timezone functions
91
+ function getTimezoneAbbreviation() {
92
+ const now = new Date();
93
+ const dateStr = now.toLocaleString('en-US', { timeZoneName: 'short' });
94
+ const match = dateStr.match(/\b([A-Z]{3,5})\b/);
95
+ return match ? match[1] : '';
96
+ }
97
+ function shouldShowDSTNotification() {
98
+ if (dstChangeTimestamp === 0)
99
+ return false;
100
+ const hoursSinceChange = (Date.now() - dstChangeTimestamp) / (1000 * 60 * 60);
101
+ return hoursSinceChange < 24;
102
+ }
103
+ function handleDSTChange(newOffsetMinutes, oldOffsetMinutes) {
104
+ const tzAbbr = getTimezoneAbbreviation();
105
+ const offsetChange = oldOffsetMinutes - newOffsetMinutes; // Positive = spring forward
106
+ const changeType = offsetChange > 0 ? 'forward' : 'back';
107
+ addToDebugPriority(`[DST] Clock adjusted: ${changeType} (${Math.abs(offsetChange)} minutes) - Now ${tzAbbr}`);
108
+ // Store the timestamp for 24-hour notification
109
+ dstChangeTimestamp = Date.now();
110
+ try {
111
+ localStorage.setItem('wallclock-dst-change', dstChangeTimestamp.toString());
112
+ }
113
+ catch (error) {
114
+ addToDebugErr(`[DST] Error saving timestamp: ${error.message}`);
115
+ }
116
+ // Force UI update to show new timezone notification
117
+ updateAmPmIndicator(new Date());
118
+ }
119
+ // Inactivity management functions
120
+ function resetInactivityTimer() {
121
+ lastActivityTime = Date.now();
122
+ if (isUIHidden) {
123
+ showUI();
124
+ }
125
+ // Clear existing timers
126
+ if (inactivityTimer !== null) {
127
+ clearTimeout(inactivityTimer);
128
+ }
129
+ if (inactivityCountdownInterval !== null) {
130
+ clearInterval(inactivityCountdownInterval);
131
+ }
132
+ // Update countdown display
133
+ const countdownEl = dv('inactivity-countdown');
134
+ if (countdownEl) {
135
+ countdownEl.textContent = `${INACTIVITY_POLICY.hideAfterSeconds}s`;
136
+ }
137
+ if (INACTIVITY_POLICY.hideAfterSeconds > 0) {
138
+ // Start countdown interval
139
+ inactivityCountdownInterval = window.setInterval(() => {
140
+ const elapsed = (Date.now() - lastActivityTime) / 1000;
141
+ const remaining = Math.max(0, INACTIVITY_POLICY.hideAfterSeconds - elapsed);
142
+ if (countdownEl) {
143
+ countdownEl.textContent = `${Math.ceil(remaining)}s`;
144
+ }
145
+ if (remaining <= 0) {
146
+ clearInterval(inactivityCountdownInterval);
147
+ inactivityCountdownInterval = null;
148
+ }
149
+ }, 100); // Update every 100ms for smooth countdown
150
+ inactivityTimer = window.setTimeout(() => {
151
+ hideUI();
152
+ }, INACTIVITY_POLICY.hideAfterSeconds * 1000);
153
+ }
154
+ }
155
+ function showUI() {
156
+ if (!isUIHidden)
157
+ return;
158
+ // Log the transition with stack trace to see what triggered it
159
+ const timestamp = new Date().toISOString();
160
+ addToDebugWarn(`[UI WAKE] UI transitioning from HIDDEN to VISIBLE at ${timestamp}`);
161
+ console.trace('[UI WAKE] Stack trace for UI activation:');
162
+ isUIHidden = false;
163
+ document.body.classList.remove('hide-cursor');
164
+ // Show fader elements
165
+ const faders = document.querySelectorAll('.fader');
166
+ faders.forEach(el => {
167
+ el.classList.remove('hidden');
168
+ });
169
+ }
170
+ function hideUI() {
171
+ if (isUIHidden)
172
+ return;
173
+ // Don't hide if timer is active or voice is actively listening
174
+ if (userTimer.active || microphoneState === 'listening') {
175
+ addToDebugLog(`[UI] Not hiding - timer active: ${userTimer.active}, mic state: ${microphoneState}`);
176
+ // Reset the timer to check again later
177
+ resetInactivityTimer();
178
+ return;
179
+ }
180
+ const timestamp = new Date().toISOString();
181
+ addToDebugWarn(`[UI HIDE] UI transitioning from VISIBLE to HIDDEN at ${timestamp}`);
182
+ isUIHidden = true;
183
+ lastHideTime = Date.now();
184
+ if (INACTIVITY_POLICY.hideCursor) {
185
+ document.body.classList.add('hide-cursor');
186
+ addToDebugLog('[UI] Cursor hidden');
187
+ }
188
+ if (INACTIVITY_POLICY.hideBanner) {
189
+ // Hide fader elements
190
+ const faders = document.querySelectorAll('.fader');
191
+ addToDebugLog(`[UI] Hiding ${faders.length} fader elements`);
192
+ faders.forEach(el => {
193
+ el.classList.add('hidden');
194
+ addToDebugLog(`[UI] Added 'hidden' to ${el.id || el.className}`);
195
+ });
196
+ }
197
+ // Ensure voice controller is still listening for wake words
198
+ if (voice.controller && voice.enabled) {
199
+ addToDebugLog(`[UI] Voice controller status: enabled=${voice.enabled}, state=${microphoneState}`);
200
+ if (microphoneState === 'waiting') {
201
+ addToDebugLog('[UI] Voice controller should be listening for wake words');
202
+ }
203
+ else {
204
+ addToDebugLog(`[UI] Voice controller state is ${microphoneState}, may need to restart wake word listening`);
205
+ // Ensure we're back to waiting/listening for wake words (direct update, don't wake UI)
206
+ microphoneState = 'waiting';
207
+ updateBannerDisplay();
208
+ voice.controller.startListening();
209
+ }
210
+ }
211
+ else {
212
+ addToDebugWarn('[UI] Voice controller not available while hiding UI');
213
+ }
214
+ addToDebugLog('[UI] UI hidden due to inactivity');
215
+ }
216
+ // Green indicator functions removed - wake word showing UI is sufficient feedback
217
+ function cleanVoiceStatus() {
218
+ // Removed - not needed
219
+ }
220
+ function checkVoiceController() {
221
+ if (!voice.enabled) {
222
+ return; // Voice control is disabled, nothing to check
223
+ }
224
+ if (!voice.controller) {
225
+ addToDebugWarn('[VOICE CHECK] Voice controller missing - attempting to reinitialize');
226
+ initializeVoiceController();
227
+ return;
228
+ }
229
+ // Check if we should be in waiting state but aren't
230
+ if (microphoneState !== 'listening' && microphoneState !== 'waiting' && !userTimer.active) {
231
+ addToDebugWarn(`[VOICE CHECK] Voice controller in unexpected state: ${microphoneState} - resetting to waiting`);
232
+ // Directly update state without waking UI (this is automatic health check, not user activity)
233
+ microphoneState = 'waiting';
234
+ updateBannerDisplay();
235
+ voice.controller.startListening();
236
+ return;
237
+ }
238
+ // Critical: Check for silent voice controller death
239
+ const timeSinceActivity = Date.now() - lastVoiceActivity;
240
+ const maxSilentTime = 300000; // 5 minutes with no voice events = likely dead
241
+ if (timeSinceActivity > maxSilentTime && microphoneState === 'waiting') {
242
+ voiceActivityTimeouts++;
243
+ addToDebugPriority(`[VOICE CHECK] *** VOICE CONTROLLER APPEARS DEAD *** - No activity for ${Math.round(timeSinceActivity / 1000)}s (timeout #${voiceActivityTimeouts})`);
244
+ addToDebugPriority(`[VOICE CHECK] Attempting to restart voice controller...`);
245
+ // Force restart the voice controller - properly destroy old instance first
246
+ (async () => {
247
+ try {
248
+ // Properly shutdown the old controller before creating a new one
249
+ await shutdownVoiceController();
250
+ // Wait a bit for cleanup to complete
251
+ setTimeout(async () => {
252
+ try {
253
+ await initializeVoiceController();
254
+ // Reset lastVoiceActivity to prevent immediate re-detection of death
255
+ lastVoiceActivity = Date.now();
256
+ addToDebugPriority(`[VOICE CHECK] Voice controller restarted after death detection`);
257
+ }
258
+ catch (restartError) {
259
+ addToDebugErr(`[VOICE CHECK] Failed to restart voice controller: ${restartError.message}`);
260
+ }
261
+ }, 1000);
262
+ }
263
+ catch (error) {
264
+ addToDebugErr(`[VOICE CHECK] Error stopping dead voice controller: ${error.message}`);
265
+ }
266
+ })();
267
+ return;
268
+ }
269
+ // Only log when there's been significant silence (reduces log spam during normal operation)
270
+ if (timeSinceActivity > 120000) { // Only log if silent for over 2 minutes
271
+ addToDebugLog(`[VOICE CHECK] Voice controller status: enabled=${voice.enabled}, state=${microphoneState}, controller=${!!voice.controller}, silent=${Math.round(timeSinceActivity / 1000)}s`);
272
+ }
273
+ }
274
+ function monitorVoiceStatus() {
275
+ // Removed - not needed
276
+ }
277
+ async function init() {
278
+ // Initialize AI parser
279
+ try {
280
+ await loadVersionFromServiceWorker();
281
+ aiParser = new AIParser({ enabled: false, service: 'openai', apiKey: '' }, (message) => addToDebugLog(message));
282
+ // Note: Removed console.log override to avoid line number confusion and violations
283
+ initializeSettings(aiParser, voice); // Initialize settings with dependencies
284
+ loadSettings(); // Load settings before initializing
285
+ addToDebugLog('WallClock app starting...');
286
+ // Check voice controller health periodically
287
+ setInterval(checkVoiceController, 10000); // Check voice controller every 10 seconds
288
+ // Initialize weather system
289
+ await initializeWeather();
290
+ // Load and start DST watcher
291
+ try {
292
+ const savedDSTTimestamp = localStorage.getItem('wallclock-dst-change');
293
+ if (savedDSTTimestamp) {
294
+ dstChangeTimestamp = parseInt(savedDSTTimestamp, 10);
295
+ }
296
+ addToDebugLog(`[DST] Starting DST watcher (last change: ${dstChangeTimestamp ? new Date(dstChangeTimestamp).toLocaleString() : 'never'})`);
297
+ dstStopWatcher = watchForDSTChange(handleDSTChange, 60000); // Check every minute
298
+ }
299
+ catch (error) {
300
+ addToDebugErr(`[DST] Error initializing DST watcher: ${error.message}`);
301
+ }
302
+ // addToDebugLog(`[VERSION] WallClock v${app_version}`, '#e8f5e8'); // Light green background
303
+ // Debug AI parser configuration
304
+ const aiConfig = aiParser.getConfig();
305
+ addToDebugLog(`[AI INIT] AI Parser - enabled: ${aiConfig.enabled}, hasApiKey: ${!!aiConfig.apiKey}, service: ${aiConfig.service}, available: ${aiParser.isAvailable()}`);
306
+ if (aiConfig.apiKey && aiConfig.apiKey.length > 10) {
307
+ addToDebugLog(`[AI INIT] API Key: ${aiConfig.apiKey.substring(0, 10)}...`);
308
+ }
309
+ // Initialize async components first
310
+ // await initializeElements();
311
+ initializeClock();
312
+ setupEventListeners();
313
+ startClockAnimation();
314
+ addToDebugLog('Clock animation started');
315
+ await initializeVoiceController();
316
+ addToDebugLog('Voice controller initialized');
317
+ // Refresh microphone list after voice controller is initialized
318
+ if (voice.controller && voice.enabled) {
319
+ await refreshMicrophoneList();
320
+ }
321
+ // Check initial install button state
322
+ updateInstallButtonVisibility();
323
+ // Fullscreen restoration handled by PWA manifest display mode
324
+ }
325
+ catch (error) {
326
+ addToDebugErr(`[VOICE ERROR] Failed to initialize: ${error.message}`);
327
+ alert(`Error initializing ${error.message}`);
328
+ }
329
+ }
330
+ async function initializeElements() {
331
+ // Read version from service worker file
332
+ await loadVersionFromServiceWorker();
333
+ document.title = `Wall Clock - ${appVersion}`;
334
+ addToDebugPriority(`Version ${appVersion}`, '#2e7d32');
335
+ // Update version display in HTML
336
+ dv('version-box').textContent = appVersion;
337
+ addToDebugLog('Element references initialized');
338
+ }
339
+ async function loadVersionFromServiceWorker() {
340
+ try {
341
+ addToDebugLog('[SW] Loading version from sw.js');
342
+ const response = await fetch('./sw.js', {
343
+ cache: 'no-cache'
344
+ });
345
+ if (!response.ok) {
346
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`);
347
+ }
348
+ const swCode = await response.text();
349
+ // Extract version from service worker code
350
+ const versionMatch = swCode.match(/const CACHE_VERSION = '([^']+)'/);
351
+ if (versionMatch) {
352
+ appVersion = `v${versionMatch[1]}`;
353
+ setAppVersion(appVersion);
354
+ dv('version-box').textContent = appVersion;
355
+ document.title = `Wall Clock ${appVersion}`;
356
+ addToDebugPriority(`[SW] Version loaded: ${appVersion}`);
357
+ }
358
+ else {
359
+ appVersion = 'v?.???';
360
+ setAppVersion(appVersion);
361
+ addToDebugWarn('[SW] Could not extract version from service worker');
362
+ }
363
+ }
364
+ catch (error) {
365
+ // NON-FATAL: App can work without version from service worker
366
+ appVersion = 'v1.308'; // Fallback to hardcoded version
367
+ setAppVersion(appVersion);
368
+ dv('version-box').textContent = appVersion + ' (offline)';
369
+ document.title = `Wall Clock ${appVersion}`;
370
+ // Log appropriate error message based on error type
371
+ if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
372
+ addToDebugWarn('[SW] Cannot load version - app running offline or no server available');
373
+ }
374
+ else {
375
+ addToDebugErr(`[SW] Failed to load version from service worker: ${error.message}`);
376
+ }
377
+ // App continues to work normally in offline mode
378
+ addToDebugLog('[SW] App will continue in offline mode with fallback version');
379
+ }
380
+ }
381
+ async function handleFatalError(reason) {
382
+ try {
383
+ addToDebugErr(`[FATAL] ${reason} - Unregistering service worker`);
384
+ if ('serviceWorker' in navigator) {
385
+ const registrations = await navigator.serviceWorker.getRegistrations();
386
+ for (const registration of registrations) {
387
+ addToDebugLog(`[FATAL] Unregistering service worker: ${registration.scope}`);
388
+ await registration.unregister();
389
+ }
390
+ addToDebugLog(`[FATAL] All service workers unregistered`);
391
+ }
392
+ }
393
+ catch (error) {
394
+ addToDebugErr(`[FATAL] Failed to unregister service worker: ${error.message}`);
395
+ }
396
+ }
397
+ function initializeClock() {
398
+ const hourMarkers = dv('hour-markers');
399
+ for (let i = 1; i <= 12; i++) {
400
+ const angle = (i * 30 - 90) * Math.PI / 180;
401
+ const x = 200 + 160 * Math.cos(angle);
402
+ const y = 200 + 160 * Math.sin(angle);
403
+ const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
404
+ text.setAttribute('x', x.toString());
405
+ text.setAttribute('y', (y + 10).toString());
406
+ text.setAttribute('text-anchor', 'middle');
407
+ text.setAttribute('font-size', '28');
408
+ text.setAttribute('font-family', 'var(--clock-font)');
409
+ text.setAttribute('fill', 'var(--clock-numbers)');
410
+ text.textContent = i.toString();
411
+ hourMarkers.appendChild(text);
412
+ const tickLength = i % 3 === 0 ? 15 : 8;
413
+ const tickStart = 190 - tickLength;
414
+ const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
415
+ tick.setAttribute('x1', (200 + tickStart * Math.cos(angle)).toString());
416
+ tick.setAttribute('y1', (200 + tickStart * Math.sin(angle)).toString());
417
+ tick.setAttribute('x2', (200 + 190 * Math.cos(angle)).toString());
418
+ tick.setAttribute('y2', (200 + 190 * Math.sin(angle)).toString());
419
+ tick.setAttribute('stroke-width', i % 3 === 0 ? '3' : '1');
420
+ tick.classList.add('hour-markers');
421
+ hourMarkers.appendChild(tick);
422
+ }
423
+ for (let i = 0; i < 60; i++) {
424
+ if (i % 5 !== 0) {
425
+ const angle = (i * 6 - 90) * Math.PI / 180;
426
+ const tick = document.createElementNS('http://www.w3.org/2000/svg', 'line');
427
+ tick.setAttribute('x1', (200 + 185 * Math.cos(angle)).toString());
428
+ tick.setAttribute('y1', (200 + 185 * Math.sin(angle)).toString());
429
+ tick.setAttribute('x2', (200 + 190 * Math.cos(angle)).toString());
430
+ tick.setAttribute('y2', (200 + 190 * Math.sin(angle)).toString());
431
+ tick.setAttribute('stroke-width', '0.5');
432
+ tick.classList.add('minute-markers');
433
+ hourMarkers.appendChild(tick);
434
+ }
435
+ }
436
+ // Initialize static timer dots
437
+ initializeStaticDots();
438
+ }
439
+ // PWA Install functionality
440
+ let deferredPrompt = null;
441
+ function setupEventListeners() {
442
+ const onclick = (id, handler) => { dv(id).addEventListener('click', handler); };
443
+ // Track user activity with debouncing to prevent immediate re-trigger
444
+ const handleActivity = (e) => {
445
+ const timeSinceHide = Date.now() - lastHideTime;
446
+ const eventType = e.type;
447
+ const eventTarget = e.target?.tagName || 'unknown';
448
+ // Ignore ALL activity when debug log is visible (except Ctrl+L to close it)
449
+ if (isLogVisible()) {
450
+ // Only special case is Ctrl+L which toggles the log
451
+ if (eventType === 'keydown') {
452
+ const keyEvent = e;
453
+ if ((keyEvent.ctrlKey || keyEvent.metaKey) && keyEvent.key.toLowerCase() === 'l') {
454
+ // Allow Ctrl+L through but don't reset inactivity timer
455
+ return;
456
+ }
457
+ }
458
+ // Ignore all other activity while log is visible
459
+ return;
460
+ }
461
+ // Don't reset if we just hid the UI (within 1 second)
462
+ if (isUIHidden && timeSinceHide < 1000) {
463
+ addToDebugLog(`[UI] Ignoring ${eventType} on ${eventTarget} - too soon after hide (${timeSinceHide}ms)`);
464
+ return;
465
+ }
466
+ // Only log if UI was hidden (state change)
467
+ if (isUIHidden) {
468
+ addToDebugLog(`[UI] Activity detected: ${eventType} on ${eventTarget} - showing UI (${timeSinceHide}ms after hide)`);
469
+ }
470
+ resetInactivityTimer();
471
+ };
472
+ document.addEventListener('mousemove', handleActivity);
473
+ document.addEventListener('keydown', handleActivity);
474
+ document.addEventListener('touchstart', handleActivity);
475
+ document.addEventListener('click', handleActivity);
476
+ // Start inactivity timer
477
+ resetInactivityTimer();
478
+ // const settingsToggle = dv('settings-toggle');
479
+ const settingsPanel = dv('settings-panel');
480
+ const showDigitalCheckbox = dv('show-digital');
481
+ const silentModeCheckbox = dv('silent-mode');
482
+ const themeSelect = dv('theme-select');
483
+ const helpDialog = dv('help-dialog');
484
+ // settingsToggle.addEventListener('click', () => {
485
+ // settingsPanel.classList.toggle('collapsed');
486
+ // });
487
+ onclick('settings-toggle', () => settingsPanel.classList.toggle('collapsed'));
488
+ onclick('start-timer', () => {
489
+ const minutes = parseInt(timerMinutesInput.value || '0') || 0;
490
+ const seconds = parseInt(timerSecondsInput.value || '0') || 0;
491
+ startUserTimer(minutes * 60 + seconds);
492
+ });
493
+ onclick('cancel-timer', () => cancelUserTimer());
494
+ onclick('timer-cancel-floating', () => cancelUserTimer());
495
+ onclick('voice-toggle', () => toggleVoiceControl());
496
+ onclick('install-button', () => handleInstallClick());
497
+ // Microphone selection event listeners
498
+ dv('microphone-select').addEventListener('change', () => {
499
+ handleMicrophoneChange();
500
+ });
501
+ dv('refresh-microphones').addEventListener('click', () => {
502
+ refreshMicrophoneList();
503
+ });
504
+ showDigitalCheckbox.addEventListener('change', () => {
505
+ setShowDigitalTime(showDigitalCheckbox.checked);
506
+ if (dv('digital-time-overlay')) {
507
+ dv('digital-time-overlay').style.display = getShowDigitalTime() ? 'block' : 'none';
508
+ }
509
+ saveSettings();
510
+ });
511
+ silentModeCheckbox.addEventListener('change', () => {
512
+ setSilentMode(silentModeCheckbox.checked);
513
+ saveSettings();
514
+ });
515
+ themeSelect.addEventListener('change', () => {
516
+ setTheme(themeSelect.value);
517
+ applyTheme();
518
+ saveSettings();
519
+ });
520
+ const listeningTimeoutInput = dv('listening-timeout');
521
+ listeningTimeoutInput.addEventListener('change', () => {
522
+ setListeningTimeout(parseInt(listeningTimeoutInput.value) || 15);
523
+ saveSettings();
524
+ });
525
+ // Click events are now handled by VoiceController module
526
+ onclick('log-toggle', () => toggleDebugLog());
527
+ onclick('clear-log', () => clearDebugLog());
528
+ // Help dialog listeners
529
+ onclick('help-close', () => closeHelpDialog());
530
+ helpDialog.addEventListener('click', (event) => {
531
+ if (event.target === helpDialog) {
532
+ closeHelpDialog();
533
+ }
534
+ });
535
+ onclick('banner-listening', () => {
536
+ if (microphoneState === 'listening') {
537
+ showHelpDialog();
538
+ }
539
+ });
540
+ // Reset settings button
541
+ onclick('reset-settings', () => {
542
+ if (confirm('Reset all settings to default values? This cannot be undone.')) {
543
+ resetSettings();
544
+ }
545
+ });
546
+ // Apply loaded settings to UI after event listeners are set up
547
+ applyLoadedSettings();
548
+ // AI Configuration event listeners
549
+ const aiParsingCheckbox = dv('ai-parsing');
550
+ const aiConfigDiv = dv('ai-config');
551
+ const aiServiceSelect = dv('ai-service');
552
+ const aiApiKeyInput = dv('ai-api-key');
553
+ if (aiParsingCheckbox) {
554
+ aiParsingCheckbox.addEventListener('change', () => {
555
+ aiParser.updateConfig({ enabled: aiParsingCheckbox.checked });
556
+ if (aiConfigDiv) {
557
+ aiConfigDiv.classList.toggle('hidden', !aiParsingCheckbox.checked);
558
+ }
559
+ saveSettings();
560
+ addToDebugLog(`[AI CONFIG] AI parsing ${aiParsingCheckbox.checked ? 'enabled' : 'disabled'}`);
561
+ });
562
+ }
563
+ if (aiServiceSelect) {
564
+ aiServiceSelect.addEventListener('change', () => {
565
+ aiParser.updateConfig({ service: aiServiceSelect.value });
566
+ saveSettings();
567
+ addToDebugLog(`[AI CONFIG] Service changed to ${aiServiceSelect.value}`);
568
+ });
569
+ }
570
+ if (aiApiKeyInput) {
571
+ aiApiKeyInput.addEventListener('input', () => {
572
+ aiParser.updateConfig({ apiKey: aiApiKeyInput.value });
573
+ saveSettings();
574
+ addToDebugLog(`[AI CONFIG] API key ${aiApiKeyInput.value ? 'set' : 'cleared'}`);
575
+ });
576
+ }
577
+ // Click outside settings panel to close it
578
+ document.addEventListener('click', (event) => {
579
+ const settingsPanel = dv('settings-panel');
580
+ const settingsToggle = dv('settings-toggle');
581
+ const debugLog = dv('debug-log');
582
+ const logToggle = dv('log-toggle');
583
+ // Close settings panel
584
+ if (!settingsPanel.contains(event.target) &&
585
+ !settingsToggle.contains(event.target) &&
586
+ !settingsPanel.classList.contains('collapsed')) {
587
+ settingsPanel.classList.add('collapsed');
588
+ }
589
+ // Close debug log
590
+ if (!debugLog.contains(event.target) &&
591
+ !logToggle.contains(event.target) &&
592
+ isLogVisible()) {
593
+ toggleDebugLog();
594
+ }
595
+ });
596
+ document.addEventListener('fullscreenchange', () => {
597
+ const isFullscreen = !!document.fullscreenElement;
598
+ if (isFullscreen) {
599
+ settingsPanel.classList.add('collapsed');
600
+ addToDebugLog('[FULLSCREEN] Entered fullscreen mode');
601
+ }
602
+ else {
603
+ addToDebugLog('[FULLSCREEN] Exited fullscreen mode');
604
+ }
605
+ // Save fullscreen state for next startup
606
+ setWasFullscreen(isFullscreen);
607
+ saveSettings();
608
+ });
609
+ document.addEventListener('dblclick', () => {
610
+ if (!document.fullscreenElement) {
611
+ addToDebugLog('[FULLSCREEN] Double-click: attempting to enter fullscreen');
612
+ // Check for browser support
613
+ const docEl = document.documentElement;
614
+ const requestFullscreen = docEl.requestFullscreen ||
615
+ docEl.webkitRequestFullscreen ||
616
+ docEl.mozRequestFullScreen ||
617
+ docEl.msRequestFullscreen;
618
+ if (requestFullscreen) {
619
+ requestFullscreen.call(docEl).then(() => {
620
+ addToDebugLog('[FULLSCREEN] Double-click: successfully entered fullscreen');
621
+ }).catch((error) => {
622
+ addToDebugErr(`[FULLSCREEN] Double-click: failed to enter fullscreen: ${error.message}`);
623
+ });
624
+ }
625
+ else {
626
+ addToDebugErr('[FULLSCREEN] Double-click: Fullscreen API not supported');
627
+ }
628
+ }
629
+ else {
630
+ addToDebugLog('[FULLSCREEN] Double-click: exiting fullscreen');
631
+ document.exitFullscreen();
632
+ }
633
+ });
634
+ // Keyboard shortcuts - Ctrl+L opens/toggles log, Ctrl+C/F work when visible, ESC closes
635
+ // Use capture phase to handle before the activity listener
636
+ document.addEventListener('keydown', (event) => {
637
+ if ((event.ctrlKey || event.metaKey) && event.key === 'l') {
638
+ event.preventDefault(); // Prevent browser address bar focus
639
+ event.stopPropagation(); // Don't let this trigger handleActivity
640
+ event.stopImmediatePropagation(); // Really stop it from reaching other handlers
641
+ // Ctrl+L always works to toggle the log open/closed
642
+ if (!isLogVisible()) {
643
+ addToDebugLog('[CTRL+L] Opening debug log');
644
+ }
645
+ else {
646
+ addToDebugLog('[CTRL+L] Closing debug log');
647
+ }
648
+ toggleDebugLog();
649
+ }
650
+ else if ((event.ctrlKey || event.metaKey) && event.key === 'c') {
651
+ // Only handle if debug log is visible
652
+ if (isLogVisible()) {
653
+ addToDebugLog('[CLIPBOARD] Ctrl+C pressed - calling copyLogToClipboard');
654
+ copyLogToClipboard();
655
+ }
656
+ }
657
+ else if ((event.ctrlKey || event.metaKey) && event.key === 'f') {
658
+ // Only handle if debug log is visible
659
+ if (isLogVisible()) {
660
+ event.preventDefault(); // Prevent browser find dialog
661
+ addToDebugLog('[FILE] Ctrl+F pressed - calling saveLogToFile');
662
+ saveLogToFile();
663
+ // Don't close the dialog - let user continue viewing
664
+ }
665
+ }
666
+ else if (event.key === 'Escape') {
667
+ // Close debug log if it's visible, or exit fullscreen
668
+ if (isLogVisible()) {
669
+ event.preventDefault();
670
+ addToDebugLog('[ESC] Closing debug log');
671
+ toggleDebugLog();
672
+ }
673
+ else if (document.fullscreenElement) {
674
+ event.preventDefault();
675
+ addToDebugLog('[ESC] Exiting fullscreen');
676
+ document.exitFullscreen();
677
+ }
678
+ }
679
+ }, { capture: true }); // Handle in capture phase before activity listener
680
+ }
681
+ function startClockAnimation() {
682
+ // Initialize clock hands with CSS animations
683
+ initializeClockHandsCSS();
684
+ // Update digital display and dots every second
685
+ clockInterval = window.setInterval(() => {
686
+ updateDigitalDisplay();
687
+ updateAmPmIndicator(new Date());
688
+ }, 1000);
689
+ // Initial update
690
+ updateDigitalDisplay();
691
+ updateAmPmIndicator(new Date());
692
+ // Force service worker update in development
693
+ if ('serviceWorker' in navigator) {
694
+ navigator.serviceWorker.getRegistration().then(registration => {
695
+ if (registration) {
696
+ registration.update();
697
+ }
698
+ });
699
+ }
700
+ }
701
+ function initializeClockHandsCSS() {
702
+ const now = new Date();
703
+ const hours = now.getHours() % 12;
704
+ const minutes = now.getMinutes();
705
+ const seconds = now.getSeconds() + now.getMilliseconds() / 1000;
706
+ // Just set CSS custom properties for the initial time offsets
707
+ // Let CSS handle all the animations
708
+ const clockSvg = dv('analog-clock');
709
+ clockSvg.style.setProperty('--seconds-offset', `-${seconds}s`);
710
+ clockSvg.style.setProperty('--minutes-offset', `-${minutes * 60 + seconds}s`);
711
+ clockSvg.style.setProperty('--hours-offset', `-${(hours * 3600) + (minutes * 60) + seconds}s`);
712
+ }
713
+ function updateDigitalDisplay() {
714
+ const now = new Date();
715
+ const hours = now.getHours();
716
+ const minutes = now.getMinutes();
717
+ const seconds = now.getSeconds();
718
+ if (getShowDigitalTime() && dv('digital-time-overlay')) {
719
+ const hoursStr = hours.toString().padStart(2, '0');
720
+ const minutesStr = minutes.toString().padStart(2, '0');
721
+ const secondsStr = seconds.toString().padStart(2, '0');
722
+ dv('digital-time-overlay').textContent = `${hoursStr}:${minutesStr}:${secondsStr}`;
723
+ }
724
+ }
725
+ function updateAmPmIndicator(now) {
726
+ const ampmElement = ampmIndicator;
727
+ if (ampmElement) {
728
+ const hours24 = now.getHours();
729
+ const ampm = hours24 >= 12 ? 'PM' : 'AM';
730
+ ampmElement.textContent = ampm;
731
+ // Add distinctive styling classes
732
+ if (hours24 >= 12) {
733
+ ampmElement.classList.remove('am');
734
+ ampmElement.classList.add('pm');
735
+ }
736
+ else {
737
+ ampmElement.classList.remove('pm');
738
+ ampmElement.classList.add('am');
739
+ }
740
+ }
741
+ }
742
+ function startUserTimer(seconds) {
743
+ addToDebugPriority(`[TIMER] *** START TIMER CALLED *** - seconds: ${seconds}`);
744
+ if (seconds <= 0) {
745
+ addToDebugPriority(`[TIMER] *** TIMER START REJECTED *** - invalid seconds: ${seconds}`);
746
+ return;
747
+ }
748
+ userTimer = {
749
+ active: true,
750
+ totalSeconds: seconds,
751
+ remainingSeconds: seconds,
752
+ startTime: Date.now()
753
+ };
754
+ addToDebugPriority(`[TIMER] *** TIMER STARTED SUCCESSFULLY *** - ${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, '0')} (${seconds}s total)`);
755
+ if (startTimerBtn)
756
+ startTimerBtn.disabled = true;
757
+ if (cancelTimerBtn)
758
+ cancelTimerBtn.disabled = false;
759
+ // Show floating cancel button and countdown display
760
+ floatingCancelBtn.classList.remove('hidden');
761
+ floatingCancelBtn.classList.add('active');
762
+ timerCountdown.classList.add('visible');
763
+ createCountdownDots();
764
+ updateCountdownDisplay();
765
+ // Start updating the banner with a live countdown
766
+ updateBannerCountdown();
767
+ // Update timer every second instead of 60fps
768
+ if (userTimerInterval)
769
+ clearInterval(userTimerInterval);
770
+ userTimerInterval = window.setInterval(() => {
771
+ if (userTimer.active) {
772
+ updateUserTimer();
773
+ }
774
+ else {
775
+ clearInterval(userTimerInterval);
776
+ userTimerInterval = 0;
777
+ }
778
+ }, 1000);
779
+ addToDebugPriority('[TIMER] *** TIMER INTERVAL STARTED ***');
780
+ }
781
+ function cancelUserTimer() {
782
+ addToDebugPriority(`[TIMER] *** TIMER CANCELLED *** - was active: ${userTimer.active}, remaining: ${userTimer.remainingSeconds}s`);
783
+ userTimer.active = false;
784
+ timerAnnounced = false; // Reset announcement flag
785
+ clearCountdownDots();
786
+ // Clear timer interval
787
+ if (userTimerInterval) {
788
+ clearInterval(userTimerInterval);
789
+ userTimerInterval = 0;
790
+ }
791
+ // Hide floating cancel button and countdown display
792
+ floatingCancelBtn.classList.add('hidden');
793
+ floatingCancelBtn.classList.remove('active');
794
+ timerCountdown.classList.remove('visible');
795
+ if (startTimerBtn)
796
+ startTimerBtn.disabled = false;
797
+ if (cancelTimerBtn)
798
+ cancelTimerBtn.disabled = true;
799
+ // Clear banner countdown interval if present
800
+ if (bannerCountdownInterval) {
801
+ clearInterval(bannerCountdownInterval);
802
+ bannerCountdownInterval = null;
803
+ }
804
+ // Clear progress bar display
805
+ // Resume wake word listening after timer cancel
806
+ if (voice.controller && voice.enabled) {
807
+ voice.controller.startListening();
808
+ addToDebugLog('[VOICE] Resumed wake word listening after timer cancel');
809
+ setBannerState('waiting');
810
+ }
811
+ }
812
+ let lastTimerUpdateTime = 0;
813
+ function updateUserTimer() {
814
+ const elapsed = (Date.now() - userTimer.startTime) / 1000;
815
+ userTimer.remainingSeconds = Math.max(0, userTimer.totalSeconds - elapsed);
816
+ // Use <= instead of === to handle floating point precision issues
817
+ if (userTimer.remainingSeconds <= 0) {
818
+ userTimerComplete();
819
+ }
820
+ else {
821
+ // Throttle timer updates to reduce performance impact
822
+ const now = Date.now();
823
+ if (now - lastTimerUpdateTime > 100) { // Update max 10 times per second
824
+ updateCountdownDots();
825
+ updateCountdownDisplay();
826
+ lastTimerUpdateTime = now;
827
+ }
828
+ }
829
+ }
830
+ function initializeStaticDots() {
831
+ countdownDots.innerHTML = '';
832
+ // Create all 60 dots positioned at second marks
833
+ for (let i = 0; i < 60; i++) {
834
+ const angle = (i * 6 - 90) * Math.PI / 180; // Every 6 degrees starting at 12 o'clock
835
+ const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
836
+ dot.setAttribute('cx', (200 + 195 * Math.cos(angle)).toString());
837
+ dot.setAttribute('cy', (200 + 195 * Math.sin(angle)).toString());
838
+ dot.setAttribute('r', '3');
839
+ dot.classList.add('countdown-dot');
840
+ dot.dataset.second = i.toString();
841
+ countdownDots.appendChild(dot);
842
+ }
843
+ addToDebugLog(`[DOTS INIT] Created 60 static dots`);
844
+ }
845
+ function createCountdownDots() {
846
+ // Just set the CSS custom property - CSS will compute everything else
847
+ countdownDots.style.setProperty('--timer-remaining', userTimer.totalSeconds.toString());
848
+ addToDebugLog(`[DOTS] Timer started - set --timer-remaining: ${userTimer.totalSeconds}s`);
849
+ }
850
+ function updateCountdownDots() {
851
+ const container = countdownDots;
852
+ const remainingSeconds = Math.ceil(userTimer.remainingSeconds);
853
+ const dots = container.querySelectorAll('.countdown-dot');
854
+ if (remainingSeconds > 60) {
855
+ // Minutes mode: show red dots for remaining minutes starting from 12 o'clock
856
+ const remainingMinutes = Math.floor(remainingSeconds / 60);
857
+ dots.forEach((dot) => {
858
+ const second = parseInt(dot.getAttribute('data-second') || '0');
859
+ // Show exactly remainingMinutes dots: positions 0, 1, 2, ... up to remainingMinutes-1
860
+ if (second < remainingMinutes) {
861
+ dot.style.setProperty('--dot-opacity', '1');
862
+ dot.style.setProperty('--dot-color', 'var(--timer-minutes-color)');
863
+ }
864
+ else {
865
+ dot.style.setProperty('--dot-opacity', '0.1');
866
+ dot.style.setProperty('--dot-color', 'var(--timer-seconds-color)');
867
+ }
868
+ });
869
+ // Don't clutter the log
870
+ // addToDebugLog(`[DOTS] Minutes mode: ${remainingMinutes} minutes remaining`);
871
+ }
872
+ else {
873
+ // Seconds mode: show yellow dots for remaining seconds
874
+ dots.forEach((dot) => {
875
+ const second = parseInt(dot.getAttribute('data-second') || '0');
876
+ if (second < remainingSeconds) {
877
+ dot.style.setProperty('--dot-opacity', '1');
878
+ dot.style.setProperty('--dot-color', 'var(--timer-seconds-color)');
879
+ }
880
+ else {
881
+ dot.style.setProperty('--dot-opacity', '0.1');
882
+ dot.style.setProperty('--dot-color', 'var(--timer-seconds-color)');
883
+ }
884
+ });
885
+ // DOn't clutter the log
886
+ // addToDebugLog(`[DOTS] Seconds mode: ${remainingSeconds} seconds remaining`);
887
+ }
888
+ }
889
+ function clearCountdownDots() {
890
+ const container = countdownDots;
891
+ if (container) {
892
+ // Clear individual dot properties
893
+ const dots = container.querySelectorAll('.countdown-dot');
894
+ dots.forEach((dot) => {
895
+ dot.style.removeProperty('--dot-opacity');
896
+ dot.style.removeProperty('--dot-color');
897
+ });
898
+ addToDebugLog('[DOTS] Cleared all dot properties');
899
+ }
900
+ }
901
+ function updateCountdownDisplay() {
902
+ const countdownElement = timerCountdown;
903
+ if (!countdownElement)
904
+ return;
905
+ const remaining = Math.ceil(userTimer.remainingSeconds);
906
+ if (remaining > 0) {
907
+ const minutes = Math.floor(remaining / 60);
908
+ const seconds = remaining % 60;
909
+ // Always show mm:ss format
910
+ countdownElement.textContent = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
911
+ // Change color in final 10 seconds
912
+ if (remaining <= 10) {
913
+ countdownElement.setAttribute('fill', 'var(--timer-seconds-color)');
914
+ }
915
+ else {
916
+ countdownElement.setAttribute('fill', 'var(--timer-minutes-color)');
917
+ }
918
+ }
919
+ }
920
+ function formatMmSs(seconds) {
921
+ const m = Math.floor(seconds / 60);
922
+ const s = Math.floor(seconds % 60);
923
+ const mm = m.toString().padStart(2, '0');
924
+ const ss = s.toString().padStart(2, '0');
925
+ return `${mm}:${ss}`;
926
+ }
927
+ let lastBannerUpdate = 0;
928
+ // Banner system (see md/stopfuckingup.md for principles):
929
+ // - All banners are in the same div
930
+ // - Only ONE banner is active at a time
931
+ // - Coloring is done using CSS classes with NO JavaScript
932
+ // - This routine has NO parameters and chooses the appropriate banner for current conditions
933
+ let microphoneState = 'unavailable';
934
+ function updateBannerDisplay() {
935
+ // Show UI on state changes if configured
936
+ if (INACTIVITY_POLICY.showOnVoiceCommand && isUIHidden) {
937
+ showUI();
938
+ }
939
+ const banners = dv('banner-content').getElementsByTagName('div');
940
+ [...banners].forEach(b => b.classList.remove('active'));
941
+ // CRITICAL: Always clear any existing listening timeout to prevent interval leaks
942
+ // This must happen even if state hasn't changed, because listeningStarted can fire multiple times
943
+ if (voiceListeningTimeoutInterval) {
944
+ clearInterval(voiceListeningTimeoutInterval);
945
+ voiceListeningTimeoutInterval = null;
946
+ addToDebugLog('[BANNER] Cleared existing listening timeout interval');
947
+ }
948
+ // Hide progress bar when changing states
949
+ const progressContainer = dv('progress-container');
950
+ if (progressContainer) {
951
+ progressContainer.classList.add('hidden');
952
+ }
953
+ // Set active on the appropriate banner using the same dynamic approach
954
+ const bannerId = microphoneState === 'denied' ? 'banner-unavailable' : `banner-${microphoneState}`;
955
+ const targetBanner = dv(bannerId);
956
+ if (targetBanner) {
957
+ targetBanner.classList.add('active');
958
+ // Start listening timeout only for listening state
959
+ if (microphoneState === 'listening') {
960
+ startListeningTimeout(getListeningTimeout(), bannerId);
961
+ }
962
+ }
963
+ }
964
+ function setBannerState(state) {
965
+ const previousState = microphoneState;
966
+ // Special case: if setting to 'listening' state (even if already listening),
967
+ // allow it through so updateBannerDisplay() can restart the progress bar properly
968
+ // The interval clearing is handled in updateBannerDisplay()
969
+ if (state === 'listening' && previousState === 'listening') {
970
+ addToDebugLog(`[BANNER] ${previousState} → ${state} (duplicate listening state - will restart progress bar)`, 'orange');
971
+ // Don't return early - let updateBannerDisplay() handle it properly
972
+ }
973
+ else if (previousState === state) {
974
+ // Don't update if state hasn't changed (prevents spurious UI wake)
975
+ addToDebugLog(`[BANNER] ${previousState} → ${state} (no change)`, 'blue');
976
+ return;
977
+ }
978
+ microphoneState = state;
979
+ // Log banner state change
980
+ addToDebugLog(`[BANNER] ${previousState} → ${state}`, 'blue');
981
+ updateBannerDisplay();
982
+ }
983
+ function startListeningTimeout(durationSeconds, bannerId, continueFromPrevious = false) {
984
+ // Clear any existing timeout interval
985
+ if (voiceListeningTimeoutInterval) {
986
+ clearInterval(voiceListeningTimeoutInterval);
987
+ }
988
+ // Get the current active banner based on state, not hardcode based on bannerId
989
+ const bannerMap = {
990
+ 'banner-unavailable': dv('banner-unavailable'),
991
+ 'banner-waiting': dv('banner-waiting'),
992
+ 'banner-listening': dv('banner-listening'),
993
+ 'banner-speech-blocked': dv('banner-speech-blocked'),
994
+ 'banner-disabled': dv('banner-disabled')
995
+ };
996
+ const banner = bannerMap[bannerId];
997
+ if (!banner)
998
+ return;
999
+ // Find the shared progress bar container
1000
+ const progressContainer = dv('progress-container');
1001
+ const progressBar = progressContainer?.querySelector('.timeout-progress-bar');
1002
+ if (!progressContainer || !progressBar)
1003
+ return;
1004
+ // Show the progress container only for listening banner
1005
+ if (bannerId === 'banner-listening') {
1006
+ progressContainer.classList.remove('hidden');
1007
+ }
1008
+ // If continuing, keep the original start time and duration
1009
+ if (!continueFromPrevious || listeningTimeoutStart === 0) {
1010
+ listeningTimeoutStart = Date.now();
1011
+ listeningTimeoutDuration = durationSeconds * 1000;
1012
+ addToDebugLog(`[LISTENING TIMEOUT] Started ${durationSeconds}s timeout at ${new Date().toLocaleTimeString()}`);
1013
+ // Show initial progress (full width, will decrease)
1014
+ if (progressBar) {
1015
+ progressBar.style.width = '100%';
1016
+ }
1017
+ }
1018
+ else {
1019
+ const elapsed = Date.now() - listeningTimeoutStart;
1020
+ addToDebugLog(`[LISTENING TIMEOUT] Continuing with ${Math.ceil((listeningTimeoutDuration - elapsed) / 1000)}s remaining`);
1021
+ }
1022
+ // Update progress bar every 100ms
1023
+ voiceListeningTimeoutInterval = window.setInterval(() => {
1024
+ // Safety check: if we're not in listening state anymore, clear the interval
1025
+ if (microphoneState !== 'listening') {
1026
+ const elapsed = Date.now() - listeningTimeoutStart;
1027
+ addToDebugWarn(`[VOICE TIMEOUT] ABORTED EARLY after ${(elapsed / 1000).toFixed(1)}s - state changed to ${microphoneState}`);
1028
+ if (voiceListeningTimeoutInterval) {
1029
+ clearInterval(voiceListeningTimeoutInterval);
1030
+ voiceListeningTimeoutInterval = null;
1031
+ }
1032
+ return;
1033
+ }
1034
+ const elapsed = Date.now() - listeningTimeoutStart;
1035
+ const remainingPercent = Math.max(0, 100 - (elapsed / listeningTimeoutDuration) * 100);
1036
+ const remainingSeconds = Math.ceil((listeningTimeoutDuration - elapsed) / 1000);
1037
+ if (progressBar) {
1038
+ progressBar.style.width = `${remainingPercent}%`;
1039
+ }
1040
+ // Log when we hit 0 seconds to track the "blue banner with 0s" issue
1041
+ if (remainingSeconds === 0 && elapsed < listeningTimeoutDuration) {
1042
+ addToDebugLog(`[LISTENING TIMEOUT] Progress at 0s (elapsed: ${(elapsed / 1000).toFixed(1)}s, banner state: ${microphoneState})`);
1043
+ }
1044
+ // Do not update banner text - keep it static per md/stopfuckingup.md
1045
+ if (elapsed >= listeningTimeoutDuration) {
1046
+ // Timeout reached - clean up first, then transition
1047
+ const actualDuration = elapsed / 1000;
1048
+ const expectedDuration = listeningTimeoutDuration / 1000;
1049
+ if (voiceListeningTimeoutInterval) {
1050
+ clearInterval(voiceListeningTimeoutInterval);
1051
+ voiceListeningTimeoutInterval = null;
1052
+ }
1053
+ // Hide progress bar immediately
1054
+ progressBar.style.width = '0%';
1055
+ progressContainer.classList.add('hidden');
1056
+ listeningTimeoutStart = 0;
1057
+ addToDebugLog(`[LISTENING TIMEOUT] Completed after ${actualDuration.toFixed(1)}s (expected ${expectedDuration}s)`);
1058
+ // Transition back to waiting state (blue banner) - do this AFTER hiding progress
1059
+ setBannerState('waiting');
1060
+ // Resume listening for wake words after timeout
1061
+ if (voice.controller) {
1062
+ voice.controller.startListening();
1063
+ addToDebugLog('[VOICE] Resumed wake word listening after timeout');
1064
+ }
1065
+ // Hide UI and cursor after listening timeout expires
1066
+ hideUI();
1067
+ addToDebugLog('[UI] Hidden after listening timeout expired');
1068
+ }
1069
+ }, 100);
1070
+ }
1071
+ function updateBannerCountdown() {
1072
+ // This method is no longer needed since we don't show timer in the banner
1073
+ // Keeping it empty for backward compatibility
1074
+ }
1075
+ function isCanonicalCommand(commandType, seconds) {
1076
+ // Check for duplicate/similar timer commands within the deduplication window
1077
+ // This prevents "timer 5 minutes" and "timer 300 seconds" from being parsed as separate commands
1078
+ const now = Date.now();
1079
+ const canonical = `timer:${seconds}`;
1080
+ // Clean up old commands
1081
+ recentCommands = recentCommands.filter(cmd => now - cmd.timestamp < COMMAND_DEDUP_WINDOW);
1082
+ // Check if we've seen this exact timer duration recently
1083
+ const isDuplicate = recentCommands.some(cmd => cmd.command === canonical && Math.abs(cmd.seconds - seconds) < 2 // Allow 1-2 second variance
1084
+ );
1085
+ if (isDuplicate) {
1086
+ addToDebugLog(`[CANONICAL] Ignoring duplicate command: ${canonical}`);
1087
+ return false;
1088
+ }
1089
+ // Record this command
1090
+ recentCommands.push({
1091
+ timestamp: now,
1092
+ command: canonical,
1093
+ seconds: seconds
1094
+ });
1095
+ addToDebugLog(`[CANONICAL] Accepted new command: ${canonical}`);
1096
+ return true;
1097
+ }
1098
+ function userTimerComplete() {
1099
+ addToDebugPriority(`[TIMER] *** TIMER COMPLETION STARTED *** - was active: ${userTimer.active}, remaining: ${userTimer.remainingSeconds}s`);
1100
+ try {
1101
+ userTimer.active = false;
1102
+ timerAnnounced = false; // Reset announcement flag
1103
+ addToDebugLog('[TIMER] Timer state reset to inactive');
1104
+ clearCountdownDots();
1105
+ addToDebugLog('[TIMER] Countdown dots cleared');
1106
+ // Clear timer interval
1107
+ if (userTimerInterval) {
1108
+ clearInterval(userTimerInterval);
1109
+ userTimerInterval = 0;
1110
+ }
1111
+ // Hide floating cancel button and countdown display
1112
+ floatingCancelBtn.classList.add('hidden');
1113
+ floatingCancelBtn.classList.remove('active');
1114
+ timerCountdown.classList.remove('visible');
1115
+ if (startTimerBtn)
1116
+ startTimerBtn.disabled = false;
1117
+ if (cancelTimerBtn)
1118
+ cancelTimerBtn.disabled = true;
1119
+ // Clear banner countdown interval if present
1120
+ if (bannerCountdownInterval) {
1121
+ clearInterval(bannerCountdownInterval);
1122
+ bannerCountdownInterval = null;
1123
+ }
1124
+ // Update status to show completion
1125
+ const status = dv('voice-status');
1126
+ if (status)
1127
+ status.textContent = 'Timer completed';
1128
+ // Play timer sound
1129
+ dv('timer-sound')?.play();
1130
+ // Timer alerts always play audio regardless of silent mode
1131
+ if ('speechSynthesis' in window) {
1132
+ const utterance = new SpeechSynthesisUtterance('Timer complete!');
1133
+ speechSynthesis.speak(utterance);
1134
+ addToDebugLog('[SPEECH] Timer complete!');
1135
+ }
1136
+ // Resume wake word listening after timer completes
1137
+ addToDebugLog('[TIMER] Attempting to resume voice controller...');
1138
+ if (voice.controller && voice.enabled) {
1139
+ voice.controller.startListening();
1140
+ addToDebugLog('[TIMER] Voice controller resumed - listening for wake words');
1141
+ setBannerState('waiting');
1142
+ addToDebugLog('[TIMER] Banner state set to waiting');
1143
+ }
1144
+ else {
1145
+ addToDebugWarn(`[TIMER] Cannot resume voice - controller: ${!!voice.controller}, enabled: ${voice.enabled}`);
1146
+ }
1147
+ addToDebugPriority('[TIMER] *** TIMER COMPLETION FINISHED SUCCESSFULLY ***');
1148
+ }
1149
+ catch (error) {
1150
+ addToDebugPriority(`[TIMER] *** TIMER COMPLETION FAILED *** - Error: ${error.message}`);
1151
+ addToDebugErr(`[TIMER] Error stack: ${error.stack}`);
1152
+ // Ensure timer is marked as inactive even if completion fails
1153
+ userTimer.active = false;
1154
+ // Try to resume voice controller even if other things failed
1155
+ try {
1156
+ if (voice.controller && voice.enabled) {
1157
+ voice.controller.startListening();
1158
+ setBannerState('waiting');
1159
+ addToDebugLog('[TIMER] Voice controller resumed after error');
1160
+ }
1161
+ }
1162
+ catch (voiceError) {
1163
+ addToDebugErr(`[TIMER] Failed to resume voice controller: ${voiceError.message}`);
1164
+ }
1165
+ }
1166
+ }
1167
+ async function initializeVoiceController() {
1168
+ const voiceConfig = {
1169
+ ...DEFAULT_VOICE_CONFIG,
1170
+ // Remove accessKey - not part of VoiceConfig interface
1171
+ };
1172
+ const voiceEvents = {
1173
+ // Fix: Use correct event name from VoiceEvents interface
1174
+ wakeWordDetected: () => {
1175
+ const timestamp = Date.now();
1176
+ lastVoiceActivity = timestamp; // Track voice activity
1177
+ const timeSinceInit = timestamp - performance.timeOrigin;
1178
+ addToDebugLog(`[VOICE] Wake word detected at ${timeSinceInit.toFixed(0)}ms after page load - starting command recognition`);
1179
+ handleWakeWordDetected();
1180
+ },
1181
+ listeningStarted: () => {
1182
+ addToDebugLog('[VOICE] listeningStarted event fired');
1183
+ // Don't call setBannerState here - handleWakeWordDetected already did it
1184
+ // This prevents duplicate banner state changes that restart the progress bar
1185
+ // NOTE: No safety timeout needed here - the listening timeout progress bar
1186
+ // already handles timing out after the configured duration (default 15s).
1187
+ },
1188
+ listeningEnded: () => {
1189
+ // Don't update lastVoiceActivity here - only on actual user interaction
1190
+ addToDebugLog('[VOICE] Speech recognition ended');
1191
+ setBannerState('waiting');
1192
+ },
1193
+ commandReceived: (transcript, confidence) => {
1194
+ lastVoiceActivity = Date.now(); // Track actual user voice command
1195
+ addToDebugLog(`[VOICE] Final result: "${transcript}" (confidence: ${confidence.toFixed(2)})`);
1196
+ processVoiceCommand(transcript);
1197
+ },
1198
+ error: (error) => {
1199
+ addToDebugLog(`[VOICE ERROR] ${error}`);
1200
+ // Show network error state specifically
1201
+ if (error.toLowerCase().includes('network')) {
1202
+ setBannerState('network-error');
1203
+ // Auto-recover to waiting state after 3 seconds
1204
+ setTimeout(() => {
1205
+ if (microphoneState === 'network-error') {
1206
+ setBannerState('waiting');
1207
+ }
1208
+ }, 3000);
1209
+ }
1210
+ else {
1211
+ setBannerState('unavailable');
1212
+ }
1213
+ },
1214
+ statusChanged: (status) => {
1215
+ const now = Date.now();
1216
+ const timeSinceLast = (now - lastVoiceActivity) / 1000;
1217
+ addToDebugLog(`[VOICE STATUS] ${status} (last activity: ${timeSinceLast.toFixed(1)}s ago)`);
1218
+ // Update voice activity timestamp to prevent false "dead" detection
1219
+ // during normal Web Speech API auto-restarts
1220
+ lastVoiceActivity = now;
1221
+ }
1222
+ };
1223
+ try {
1224
+ voice.controller = new VoiceController(voiceConfig, voiceEvents);
1225
+ await voice.controller.initialize();
1226
+ // Fix: Check if controller exists and initialized, not if actively listening
1227
+ if (voice.controller) {
1228
+ addToDebugLog(`[VOICE] Controller initialized successfully`);
1229
+ // Start listening for wake words
1230
+ await voice.controller.startListening();
1231
+ addToDebugLog(`[VOICE] Started listening for wake words`);
1232
+ // Only set banner state if not already waiting (prevents redundant state changes)
1233
+ if (microphoneState !== 'waiting') {
1234
+ setBannerState('waiting');
1235
+ }
1236
+ // Refresh microphone list now that we have permissions
1237
+ await refreshMicrophoneList();
1238
+ }
1239
+ else {
1240
+ addToDebugLog(`[VOICE] Controller initialization failed`);
1241
+ setBannerState('unavailable');
1242
+ }
1243
+ }
1244
+ catch (error) {
1245
+ addToDebugErr(`[VOICE ERROR] Initialization failed: ${error.message}`);
1246
+ setBannerState('unavailable');
1247
+ }
1248
+ }
1249
+ async function shutdownVoiceController() {
1250
+ if (voice.controller) {
1251
+ // Fix: Use destroy() method instead of shutdown()
1252
+ voice.controller.destroy();
1253
+ voice.controller = undefined;
1254
+ addToDebugLog('[VOICE] Voice controller shutdown complete');
1255
+ }
1256
+ }
1257
+ function handleWakeWordDetected() {
1258
+ addToDebugLog(`[WAKE WORD] Wake word detected! UI hidden: ${isUIHidden}, showOnWakeWord: ${INACTIVITY_POLICY.showOnWakeWord}`);
1259
+ // Show UI when wake word detected if configured
1260
+ if (INACTIVITY_POLICY.showOnWakeWord && isUIHidden) {
1261
+ addToDebugLog('[WAKE WORD] Showing UI due to wake word detection');
1262
+ showUI();
1263
+ resetInactivityTimer();
1264
+ }
1265
+ // Show blue "Listening..." banner after wake word detection
1266
+ setBannerState('listening');
1267
+ if (voice.controller) {
1268
+ voice.controller.startCommandListening();
1269
+ addToDebugLog('[WAKE WORD] Started command listening');
1270
+ }
1271
+ else {
1272
+ addToDebugWarn('[WAKE WORD] No voice controller available for command listening');
1273
+ }
1274
+ }
1275
+ // Voice initialization is now handled by VoiceController module
1276
+ async function toggleVoiceControl() {
1277
+ const button = dv('voice-toggle');
1278
+ const status = dv('voice-status');
1279
+ if (!button || !status)
1280
+ return;
1281
+ if (voice.enabled) {
1282
+ voiceManuallyStopped = true;
1283
+ await shutdownVoiceController();
1284
+ voice.enabled = false;
1285
+ button.textContent = 'Enable Voice Control';
1286
+ button.classList.remove('enabled');
1287
+ status.textContent = '';
1288
+ setBannerState('disabled');
1289
+ saveSettings();
1290
+ }
1291
+ else {
1292
+ addToDebugLog('User requesting voice control activation');
1293
+ try {
1294
+ await initializeVoiceController();
1295
+ voice.enabled = true;
1296
+ button.textContent = 'Disable Voice Control';
1297
+ button.classList.add('enabled');
1298
+ status.textContent = 'Voice control active';
1299
+ addToDebugLog('Voice control started successfully');
1300
+ // Refresh microphone list after successful initialization
1301
+ await refreshMicrophoneList();
1302
+ saveSettings();
1303
+ }
1304
+ catch (error) {
1305
+ addToDebugErr(`Voice control start failed: ${error.message}`);
1306
+ status.textContent = `Voice control failed: ${error.message}. Check microphone and try again.`;
1307
+ setBannerState('unavailable');
1308
+ }
1309
+ }
1310
+ }
1311
+ // Microphone management methods
1312
+ async function refreshMicrophoneList() {
1313
+ addToDebugLog('[MIC] Refreshing microphone list...');
1314
+ if (!voice.controller)
1315
+ return;
1316
+ try {
1317
+ // Request microphone permission first to get device labels
1318
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
1319
+ stream.getTracks().forEach(track => track.stop());
1320
+ const microphones = await voice.controller.getAvailableMicrophones();
1321
+ const select = dv('microphone-select');
1322
+ const currentValue = select.value;
1323
+ // Clear existing options except default
1324
+ select.innerHTML = '<option value="">Default</option>';
1325
+ // Add microphone options
1326
+ microphones.forEach(mic => {
1327
+ const option = document.createElement('option');
1328
+ option.value = mic.deviceId;
1329
+ option.textContent = mic.label;
1330
+ select.appendChild(option);
1331
+ });
1332
+ // Restore selection or set saved selection
1333
+ const savedDeviceId = voice.controller.getSelectedMicrophoneId();
1334
+ if (savedDeviceId) {
1335
+ select.value = savedDeviceId;
1336
+ }
1337
+ else {
1338
+ select.value = currentValue;
1339
+ }
1340
+ addToDebugLog(`[MIC] Refreshed microphone list: ${microphones.length} devices`);
1341
+ }
1342
+ catch (error) {
1343
+ addToDebugErr(`[MIC] Failed to refresh microphones: ${error.message}`);
1344
+ }
1345
+ }
1346
+ function handleMicrophoneChange() {
1347
+ if (!voice.controller)
1348
+ return;
1349
+ const selectedDeviceId = dv('microphone-select').value;
1350
+ voice.controller.setSelectedMicrophone(selectedDeviceId);
1351
+ const deviceName = selectedDeviceId ?
1352
+ dv('microphone-select').options[dv('microphone-select').selectedIndex].text :
1353
+ 'Default';
1354
+ addToDebugLog(`[MIC] Selected microphone: ${deviceName}`);
1355
+ // If voice control is active, restart it to use the new microphone
1356
+ if (voice.enabled) {
1357
+ addToDebugLog(`[MIC] Restarting voice control with new microphone...`);
1358
+ restartVoiceControlWithNewMicrophone();
1359
+ }
1360
+ }
1361
+ async function restartVoiceControlWithNewMicrophone() {
1362
+ try {
1363
+ await shutdownVoiceController();
1364
+ await new Promise(resolve => setTimeout(resolve, 500)); // Brief delay
1365
+ await initializeVoiceController();
1366
+ voice.enabled = true;
1367
+ // Update button state
1368
+ const button = dv('voice-toggle');
1369
+ if (button) {
1370
+ button.textContent = 'Disable Voice Control';
1371
+ button.classList.add('enabled');
1372
+ }
1373
+ addToDebugLog(`[MIC] Voice control restarted with new microphone`);
1374
+ }
1375
+ catch (error) {
1376
+ addToDebugErr(`[MIC] Failed to restart voice control: ${error.message}`);
1377
+ }
1378
+ }
1379
+ async function processVoiceCommand(transcript) {
1380
+ const status = dv('voice-status');
1381
+ if (!status)
1382
+ return;
1383
+ const now = Date.now();
1384
+ // Enhanced duplicate command prevention
1385
+ if (transcript === lastCommand && (now - lastCommandTime) < 5000) { // Increased to 5 seconds
1386
+ addToDebugLog(`[VOICE DUP] Ignoring duplicate: "${transcript}" (${now - lastCommandTime}ms ago)`);
1387
+ return;
1388
+ }
1389
+ lastCommand = transcript;
1390
+ lastCommandTime = now;
1391
+ // Debug: Show what was heard
1392
+ addToDebugLog(`[VOICE CMD] Processing: "${transcript}"`);
1393
+ status.textContent = `Processing: "${transcript}"`;
1394
+ // Filter out wake words - don't process them as commands
1395
+ const normalizedTranscript = transcript.toLowerCase().trim();
1396
+ if (normalizedTranscript === 'hey timer' || normalizedTranscript === 'go timer' ||
1397
+ normalizedTranscript === 'hey timer.' || normalizedTranscript === 'go timer.') {
1398
+ addToDebugLog(`[VOICE CMD] Ignoring wake word: "${transcript}"`);
1399
+ // Continue listening, don't treat as failed command
1400
+ return;
1401
+ }
1402
+ // Try local parsing first
1403
+ const localResult = parseVoiceCommandLocal(transcript);
1404
+ addToDebugLog(`[LOCAL PARSE] Result: success=${localResult.success}, action=${localResult.action}, value=${localResult.value}, reason=${localResult.reason}`);
1405
+ if (localResult.success) {
1406
+ addToDebugLog(`[LOCAL PARSE] Success: ${JSON.stringify(localResult)}`);
1407
+ executeCommand(localResult, transcript);
1408
+ return;
1409
+ }
1410
+ addToDebugLog(`[LOCAL PARSE] Failed: ${localResult.reason}`);
1411
+ // Try AI parsing if enabled and local parsing failed
1412
+ if (aiParser.isAvailable()) {
1413
+ try {
1414
+ status.textContent = `Trying AI parsing...`;
1415
+ const aiCommand = await aiParser.parseCommand(transcript);
1416
+ if (aiCommand) {
1417
+ addToDebugLog(`[AI PARSE] Success: "${aiCommand}"`);
1418
+ const aiResult = parseVoiceCommandLocal(aiCommand);
1419
+ if (aiResult.success) {
1420
+ aiResult.source = 'ai';
1421
+ executeCommand(aiResult, `${transcript} → ${aiCommand}`);
1422
+ return;
1423
+ }
1424
+ else {
1425
+ addToDebugLog(`[AI PARSE] AI result failed local parsing: ${aiResult.reason}`);
1426
+ }
1427
+ }
1428
+ else {
1429
+ addToDebugLog(`[AI PARSE] AI returned null (not a timer command)`);
1430
+ }
1431
+ }
1432
+ catch (error) {
1433
+ addToDebugErr(`[AI PARSE] Error: ${error.message}`);
1434
+ status.textContent = `AI parsing failed, check API key`;
1435
+ }
1436
+ }
1437
+ else {
1438
+ const config = aiParser.getConfig();
1439
+ addToDebugLog(`[AI PARSE] Not available - enabled: ${config.enabled}, hasApiKey: ${!!config.apiKey}, service: ${config.service}`);
1440
+ }
1441
+ // Both local and AI parsing failed - also stop listening here
1442
+ if (voice.controller?.isActive()) {
1443
+ addToDebugLog('[VOICE] Stopping speech recognition after failed command parsing');
1444
+ voice.controller.stopListening();
1445
+ // Resume wake word listening after failed parse
1446
+ setTimeout(() => {
1447
+ if (voice.controller && voice.enabled && !voice.controller.isActive()) {
1448
+ try {
1449
+ voice.controller.startListening();
1450
+ addToDebugLog('[VOICE] Resumed wake word listening after failed parse');
1451
+ setBannerState('waiting');
1452
+ }
1453
+ catch (error) {
1454
+ addToDebugErr(`[VOICE] Failed to resume listening: ${error.message}`);
1455
+ }
1456
+ }
1457
+ else if (voice.controller?.isActive()) {
1458
+ addToDebugLog('[VOICE] Already listening, skipping restart after failed parse');
1459
+ setBannerState('waiting');
1460
+ }
1461
+ }, 100);
1462
+ }
1463
+ status.textContent = `Command not recognized: "${transcript}"`;
1464
+ addToDebugLog(`[PARSE FAILED] Both local and AI parsing failed`);
1465
+ }
1466
+ function parseVoiceCommandLocal(transcript) {
1467
+ addToDebugLog(`[PARSE] Starting parse of: "${transcript}"`);
1468
+ // Try pattern matching first - if it fails, LLM can handle complex phrases
1469
+ // More flexible pattern matching
1470
+ const timePatterns = [
1471
+ /(\d+)\s*(?:minute|minutes|min)/,
1472
+ /(\d+)\s*(?:second|seconds|sec)/,
1473
+ /(\d+)\s*(?:hour|hours|hr)/
1474
+ ];
1475
+ const cancelMatch = /(?:cancel|stop|clear|reset|off)/.test(transcript);
1476
+ const timerMatch = /(?:timer|alarm|countdown)/.test(transcript);
1477
+ const showDigitalMatch = /(?:show|display).*(?:digital|time|24)/.test(transcript);
1478
+ const hideDigitalMatch = /(?:hide|remove).*(?:digital|time)/.test(transcript);
1479
+ const silentMatch = /(?:silent|quiet|mute)/.test(transcript);
1480
+ const stopVoiceMatch = /(?:stop voice|disable voice|voice off)/.test(transcript);
1481
+ const fullscreenMatch = /(?:full\s*screen|fullscreen|maximize)/.test(transcript);
1482
+ const exitFullscreenMatch = /(?:exit|leave|minimize|normal)/.test(transcript);
1483
+ const lightModeMatch = /(?:light mode|light theme|bright mode)/.test(transcript);
1484
+ const darkModeMatch = /(?:dark mode|dark theme|night mode)/.test(transcript);
1485
+ const systemThemeMatch = /(?:system theme|system mode|auto theme)/.test(transcript);
1486
+ const helpMatch = /(?:help|commands|what can you do)/.test(transcript);
1487
+ const stopMatch = /^stop$/i.test(transcript);
1488
+ const whatTimeMatch = /(?:what time|time is it|current time)/.test(transcript);
1489
+ const weatherMatch = /(?:weather|temperature|how hot|how cold|what's the temperature)/.test(transcript);
1490
+ const showBannerMatch = /(?:show banner|show ui|show interface|display banner)/.test(transcript);
1491
+ const hideBannerMatch = /(?:hide banner|hide ui|hide interface|remove banner)/.test(transcript);
1492
+ // Handle cancel/stop
1493
+ if (cancelMatch) {
1494
+ return { action: 'cancel', success: true, source: 'local' };
1495
+ }
1496
+ // Handle digital time display
1497
+ if (showDigitalMatch) {
1498
+ return { action: 'show-digital', success: true, source: 'local' };
1499
+ }
1500
+ if (hideDigitalMatch) {
1501
+ return { action: 'hide-digital', success: true, source: 'local' };
1502
+ }
1503
+ // Handle silent mode toggle
1504
+ if (silentMatch) {
1505
+ return { action: 'silent', success: true, source: 'local' };
1506
+ }
1507
+ // Handle stop voice command
1508
+ if (stopVoiceMatch) {
1509
+ return { action: 'stop-voice', success: true, source: 'local' };
1510
+ }
1511
+ // Handle fullscreen
1512
+ if (fullscreenMatch) {
1513
+ return { action: 'fullscreen', success: true, source: 'local' };
1514
+ }
1515
+ if (exitFullscreenMatch) {
1516
+ return { action: 'exit-fullscreen', success: true, source: 'local' };
1517
+ }
1518
+ // Handle theme changes
1519
+ if (lightModeMatch) {
1520
+ return { action: 'light-mode', success: true, source: 'local' };
1521
+ }
1522
+ if (darkModeMatch) {
1523
+ return { action: 'dark-mode', success: true, source: 'local' };
1524
+ }
1525
+ if (systemThemeMatch) {
1526
+ return { action: 'system-theme', success: true, source: 'local' };
1527
+ }
1528
+ // Handle help command
1529
+ if (helpMatch) {
1530
+ return { action: 'help', success: true, source: 'local' };
1531
+ }
1532
+ // Handle stop command (for stopping help speech)
1533
+ if (stopMatch && isSpeakingHelp) {
1534
+ return { action: 'stop-speaking', success: true, source: 'local' };
1535
+ }
1536
+ // Handle cancel during help (should also stop help speech and close dialog)
1537
+ if (cancelMatch && isSpeakingHelp) {
1538
+ return { action: 'stop-speaking', success: true, source: 'local' };
1539
+ }
1540
+ // Handle what time is it
1541
+ if (whatTimeMatch) {
1542
+ return { action: 'what-time', success: true, source: 'local' };
1543
+ }
1544
+ // Handle weather/temperature requests
1545
+ if (weatherMatch) {
1546
+ return { action: 'weather', success: true, source: 'local' };
1547
+ }
1548
+ // Handle show/hide banner commands
1549
+ if (showBannerMatch) {
1550
+ return { action: 'show-banner', success: true, source: 'local' };
1551
+ }
1552
+ if (hideBannerMatch) {
1553
+ return { action: 'hide-banner', success: true, source: 'local' };
1554
+ }
1555
+ // Handle "time till" or "time until" patterns
1556
+ const timeTillMatch = transcript.match(/(?:time\s+)?(?:till|until)\s+([\d:]+)\s*(am|pm)?/i);
1557
+ if (timeTillMatch) {
1558
+ const timeStr = timeTillMatch[1];
1559
+ const ampm = timeTillMatch[2];
1560
+ try {
1561
+ const targetTime = parseTimeString(timeStr, ampm);
1562
+ if (targetTime) {
1563
+ const now = new Date();
1564
+ const diffMs = targetTime.getTime() - now.getTime();
1565
+ if (diffMs > 0) {
1566
+ const diffSeconds = Math.floor(diffMs / 1000);
1567
+ return {
1568
+ action: 'timer',
1569
+ value: diffSeconds,
1570
+ unit: 'seconds',
1571
+ success: true,
1572
+ source: 'local'
1573
+ };
1574
+ }
1575
+ }
1576
+ }
1577
+ catch (error) {
1578
+ addToDebugErr(`[LOCAL PARSE] Time parsing error: ${error.message}`);
1579
+ }
1580
+ }
1581
+ // Handle timer setting
1582
+ if (timerMatch || timePatterns.some(p => p.test(transcript))) {
1583
+ addToDebugLog(`[PARSE] Timer command detected - timerMatch: ${timerMatch}, timePatterns match: ${timePatterns.some(p => p.test(transcript))}`);
1584
+ const result = parseTimerCommand(transcript);
1585
+ addToDebugLog(`[PARSE] parseTimerCommand result: ${result.totalSeconds} seconds`);
1586
+ if (result.totalSeconds > 0) {
1587
+ return {
1588
+ action: 'timer',
1589
+ value: result.totalSeconds,
1590
+ unit: 'seconds',
1591
+ success: true,
1592
+ source: 'local'
1593
+ };
1594
+ }
1595
+ }
1596
+ return { action: '', success: false, reason: 'Command pattern not recognized', source: 'local' };
1597
+ }
1598
+ function parseTimerCommand(transcript) {
1599
+ addToDebugLog(`[TIMER PARSE] Input: "${transcript}"`);
1600
+ let totalSeconds = 0;
1601
+ let foundTimeUnit = false;
1602
+ // Enhanced word-to-number conversion including fractions
1603
+ const wordToNumber = {
1604
+ 'one': '1', 'two': '2', 'three': '3', 'four': '4', 'five': '5',
1605
+ 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9', 'ten': '10',
1606
+ 'eleven': '11', 'twelve': '12', 'thirteen': '13', 'fourteen': '14', 'fifteen': '15',
1607
+ 'twenty': '20', 'thirty': '30', 'forty': '40', 'fifty': '50'
1608
+ };
1609
+ let processedTranscript = transcript;
1610
+ for (const [word, num] of Object.entries(wordToNumber)) {
1611
+ processedTranscript = processedTranscript.replace(new RegExp(`\\b${word}\\b`, 'gi'), num);
1612
+ }
1613
+ addToDebugLog(`[TIMER PARSE] Original: "${transcript}" -> Processed: "${processedTranscript}"`);
1614
+ // Handle special fractional expressions first
1615
+ // Handle "X and a half" (e.g., "one and a half minutes")
1616
+ const andHalfMatch = processedTranscript.match(/(\d+)\s+and\s+a\s+half\s+(minute|minutes|min|second|seconds|sec|hour|hours|hr)/i);
1617
+ if (andHalfMatch) {
1618
+ const baseNumber = parseInt(andHalfMatch[1]);
1619
+ const unit = andHalfMatch[2].toLowerCase();
1620
+ let multiplier = 60; // Default to minutes
1621
+ if (unit.startsWith('sec'))
1622
+ multiplier = 1;
1623
+ if (unit.startsWith('hour') || unit.startsWith('hr'))
1624
+ multiplier = 3600;
1625
+ totalSeconds = (baseNumber + 0.5) * multiplier;
1626
+ foundTimeUnit = true;
1627
+ addToDebugLog(`Matched: "${andHalfMatch[0]}" -> Base: ${baseNumber}, Unit: ${unit}, Multiplier: ${multiplier}, Total: ${totalSeconds} seconds`);
1628
+ }
1629
+ // Handle "X and three quarters" (e.g., "one and three quarters minutes")
1630
+ if (!foundTimeUnit) {
1631
+ const andThreeQuartersMatch = processedTranscript.match(/(\d+)\s+and\s+three\s+quarters?\s+(minute|minutes|min|second|seconds|sec|hour|hours|hr)/i);
1632
+ if (andThreeQuartersMatch) {
1633
+ const baseNumber = parseInt(andThreeQuartersMatch[1]);
1634
+ const unit = andThreeQuartersMatch[2].toLowerCase();
1635
+ let multiplier = 60; // Default to minutes
1636
+ if (unit.startsWith('sec'))
1637
+ multiplier = 1;
1638
+ if (unit.startsWith('hour') || unit.startsWith('hr'))
1639
+ multiplier = 3600;
1640
+ totalSeconds = (baseNumber + 0.75) * multiplier;
1641
+ foundTimeUnit = true;
1642
+ addToDebugLog(`Matched: "${andThreeQuartersMatch[0]}" -> Base: ${baseNumber}, Unit: ${unit}, Multiplier: ${multiplier}, Total: ${totalSeconds} seconds`);
1643
+ }
1644
+ }
1645
+ // Handle "X 3/4" notation (e.g., "1 3/4 minutes")
1646
+ if (!foundTimeUnit) {
1647
+ const threeQuartersFractionMatch = processedTranscript.match(/(\d+)\s+3\/4\s+(minute|minutes|min|second|seconds|sec|hour|hours|hr)/i);
1648
+ if (threeQuartersFractionMatch) {
1649
+ const baseNumber = parseInt(threeQuartersFractionMatch[1]);
1650
+ const unit = threeQuartersFractionMatch[2].toLowerCase();
1651
+ let multiplier = 60; // Default to minutes
1652
+ if (unit.startsWith('sec'))
1653
+ multiplier = 1;
1654
+ if (unit.startsWith('hour') || unit.startsWith('hr'))
1655
+ multiplier = 3600;
1656
+ totalSeconds = (baseNumber + 0.75) * multiplier;
1657
+ foundTimeUnit = true;
1658
+ addToDebugLog(`Matched fraction: "${threeQuartersFractionMatch[0]}" -> Base: ${baseNumber}, Unit: ${unit}, Multiplier: ${multiplier}, Total: ${totalSeconds} seconds`);
1659
+ }
1660
+ }
1661
+ // Handle "X 1/2" notation (e.g., "1 1/2 minutes")
1662
+ if (!foundTimeUnit) {
1663
+ const fractionMatch = processedTranscript.match(/(\d+)\s+1\/2\s+(minute|minutes|min|second|seconds|sec|hour|hours|hr)/i);
1664
+ if (fractionMatch) {
1665
+ const baseNumber = parseInt(fractionMatch[1]);
1666
+ const unit = fractionMatch[2].toLowerCase();
1667
+ let multiplier = 60; // Default to minutes
1668
+ if (unit.startsWith('sec'))
1669
+ multiplier = 1;
1670
+ if (unit.startsWith('hour') || unit.startsWith('hr'))
1671
+ multiplier = 3600;
1672
+ totalSeconds = (baseNumber + 0.5) * multiplier;
1673
+ foundTimeUnit = true;
1674
+ addToDebugLog(`Matched fraction: "${fractionMatch[0]}" -> Base: ${baseNumber}, Unit: ${unit}, Multiplier: ${multiplier}, Total: ${totalSeconds} seconds`);
1675
+ }
1676
+ }
1677
+ // Handle "X point Y" (e.g., "one point five minutes")
1678
+ if (!foundTimeUnit) {
1679
+ const pointMatch = processedTranscript.match(/(\d+)\s+point\s+(\d+)\s+(minute|minutes|min|second|seconds|sec|hour|hours|hr)/i);
1680
+ if (pointMatch) {
1681
+ const wholeNumber = parseInt(pointMatch[1]);
1682
+ const fractionalDigit = parseInt(pointMatch[2]);
1683
+ const unit = pointMatch[3].toLowerCase();
1684
+ let multiplier = 60; // Default to minutes
1685
+ if (unit.startsWith('sec'))
1686
+ multiplier = 1;
1687
+ if (unit.startsWith('hour') || unit.startsWith('hr'))
1688
+ multiplier = 3600;
1689
+ const fractionalValue = wholeNumber + (fractionalDigit / 10);
1690
+ totalSeconds = fractionalValue * multiplier;
1691
+ foundTimeUnit = true;
1692
+ addToDebugLog(`Parsed "${pointMatch[0]}" as ${totalSeconds} seconds`);
1693
+ }
1694
+ }
1695
+ // Handle standard parsing if no special fraction found
1696
+ if (!foundTimeUnit) {
1697
+ // Check for multiple units in priority order, but allow combining
1698
+ let hours = 0, minutes = 0, seconds = 0;
1699
+ // Check for hours
1700
+ const hourMatch = processedTranscript.match(/(\d+)\s*(?:hour|hours|hr)\b/);
1701
+ if (hourMatch && !processedTranscript.includes('and a half') && !processedTranscript.includes('point')) {
1702
+ hours = parseInt(hourMatch[1]);
1703
+ foundTimeUnit = true;
1704
+ addToDebugLog(`Standard hour parsing: ${hours} hours`);
1705
+ }
1706
+ // Check for minutes (can coexist with hours/seconds)
1707
+ const minuteMatch = processedTranscript.match(/(\d+)\s*(?:minute|minutes|min)\b/);
1708
+ if (minuteMatch) {
1709
+ minutes = parseInt(minuteMatch[1]);
1710
+ foundTimeUnit = true;
1711
+ addToDebugLog(`Standard minute parsing: ${minutes} minutes`);
1712
+ }
1713
+ // Check for seconds (can coexist with hours/minutes)
1714
+ const secondMatch = processedTranscript.match(/(\d+)\s*(?:second|seconds|sec)\b/);
1715
+ if (secondMatch) {
1716
+ seconds = parseInt(secondMatch[1]);
1717
+ foundTimeUnit = true;
1718
+ addToDebugLog(`Standard second parsing: ${seconds} seconds`);
1719
+ }
1720
+ // Calculate total seconds by combining all units
1721
+ totalSeconds = hours * 3600 + minutes * 60 + seconds;
1722
+ addToDebugLog(`Combined total: ${hours}h + ${minutes}m + ${seconds}s = ${totalSeconds} seconds`);
1723
+ }
1724
+ addToDebugLog(`[TIMER PARSE] Final result: ${totalSeconds} seconds (found time unit: ${foundTimeUnit})`);
1725
+ return { totalSeconds };
1726
+ }
1727
+ function executeCommand(command, originalTranscript) {
1728
+ const status = dv('voice-status');
1729
+ if (!status)
1730
+ return;
1731
+ // Command understood - no need for separate accepted state
1732
+ // Keep listening for additional commands instead of stopping
1733
+ // The timeout will handle when to stop listening
1734
+ addToDebugLog('[VOICE] Continuing to listen for additional commands');
1735
+ switch (command.action) {
1736
+ case 'cancel':
1737
+ if (userTimer.active) {
1738
+ addToDebugLog(`Cancel command detected, timer active: ${userTimer.active}`);
1739
+ cancelUserTimer();
1740
+ speak('Timer cancelled', true);
1741
+ status.textContent = 'Timer cancelled';
1742
+ }
1743
+ else {
1744
+ addToDebugLog(`Cancel command detected but timer not active: ${userTimer.active}`);
1745
+ status.textContent = 'No timer to cancel';
1746
+ }
1747
+ break;
1748
+ case 'show-digital':
1749
+ setShowDigitalTime(true);
1750
+ if (showDigitalCheckbox)
1751
+ showDigitalCheckbox.checked = true;
1752
+ if (dv('digital-time-overlay'))
1753
+ dv('digital-time-overlay').style.display = 'block';
1754
+ saveSettings();
1755
+ speak('Showing digital time', false);
1756
+ status.textContent = 'Digital time enabled';
1757
+ break;
1758
+ case 'hide-digital':
1759
+ setShowDigitalTime(false);
1760
+ if (showDigitalCheckbox)
1761
+ showDigitalCheckbox.checked = false;
1762
+ if (dv('digital-time-overlay'))
1763
+ dv('digital-time-overlay').style.display = 'none';
1764
+ saveSettings();
1765
+ speak('Hiding digital time', false);
1766
+ status.textContent = 'Digital time disabled';
1767
+ break;
1768
+ case 'silent':
1769
+ setSilentMode(!getSilentMode());
1770
+ if (silentModeCheckbox)
1771
+ silentModeCheckbox.checked = getSilentMode();
1772
+ saveSettings();
1773
+ if (!getSilentMode()) {
1774
+ speak('Silent mode off', false);
1775
+ }
1776
+ status.textContent = getSilentMode() ? 'Silent mode enabled' : 'Silent mode disabled';
1777
+ break;
1778
+ case 'stop-voice':
1779
+ toggleVoiceControl();
1780
+ status.textContent = 'Voice control disabled';
1781
+ // This command should stop listening immediately
1782
+ return;
1783
+ case 'show-banner':
1784
+ case 'show-ui':
1785
+ showUI();
1786
+ resetInactivityTimer();
1787
+ speak('Banner shown', false);
1788
+ status.textContent = 'UI shown';
1789
+ break;
1790
+ case 'hide-banner':
1791
+ case 'hide-ui':
1792
+ hideUI();
1793
+ speak('Banner hidden', false);
1794
+ status.textContent = 'UI hidden';
1795
+ break;
1796
+ case 'fullscreen':
1797
+ if (!document.fullscreenElement) {
1798
+ addToDebugLog('[FULLSCREEN] Voice command - attempting fullscreen');
1799
+ const docEl = document.documentElement;
1800
+ const requestFullscreen = docEl.requestFullscreen ||
1801
+ docEl.webkitRequestFullscreen ||
1802
+ docEl.mozRequestFullScreen ||
1803
+ docEl.msRequestFullscreen;
1804
+ if (requestFullscreen) {
1805
+ const promise = requestFullscreen.call(docEl);
1806
+ if (promise && promise.then) {
1807
+ promise.then(() => {
1808
+ addToDebugLog('[FULLSCREEN] Successfully entered fullscreen');
1809
+ speak('Fullscreen mode active', false);
1810
+ status.textContent = 'Fullscreen mode';
1811
+ }).catch((error) => {
1812
+ addToDebugErr(`[FULLSCREEN] Voice command failed: ${error.name} - ${error.message}`);
1813
+ speak('Fullscreen not available via voice', false);
1814
+ status.textContent = 'Fullscreen blocked by browser';
1815
+ });
1816
+ }
1817
+ else {
1818
+ addToDebugLog('[FULLSCREEN] Fullscreen request sent (no promise)');
1819
+ status.textContent = 'Fullscreen requested';
1820
+ }
1821
+ }
1822
+ else {
1823
+ addToDebugErr('[FULLSCREEN] Fullscreen API not supported');
1824
+ speak('Fullscreen not supported', false);
1825
+ status.textContent = 'Fullscreen not supported';
1826
+ }
1827
+ }
1828
+ else {
1829
+ addToDebugLog('[FULLSCREEN] Already in fullscreen mode');
1830
+ speak('Already in fullscreen', false);
1831
+ status.textContent = 'Already in fullscreen';
1832
+ }
1833
+ break;
1834
+ case 'exit-fullscreen':
1835
+ if (document.fullscreenElement) {
1836
+ document.exitFullscreen();
1837
+ speak('Exiting fullscreen', false);
1838
+ status.textContent = 'Normal mode';
1839
+ }
1840
+ break;
1841
+ case 'light-mode':
1842
+ addToDebugLog('[VOICE CMD] Light mode command matched');
1843
+ setTheme('light');
1844
+ if (themeSelect)
1845
+ themeSelect.value = 'light';
1846
+ applyTheme();
1847
+ saveSettings();
1848
+ speak('Light mode enabled', false);
1849
+ status.textContent = 'Theme: Light mode';
1850
+ break;
1851
+ case 'dark-mode':
1852
+ addToDebugLog('[VOICE CMD] Dark mode command matched');
1853
+ setTheme('dark');
1854
+ if (themeSelect)
1855
+ themeSelect.value = 'dark';
1856
+ applyTheme();
1857
+ saveSettings();
1858
+ speak('Dark mode enabled', false);
1859
+ status.textContent = 'Theme: Dark mode';
1860
+ break;
1861
+ case 'system-theme':
1862
+ addToDebugLog('[VOICE CMD] System theme command matched');
1863
+ setTheme('system');
1864
+ if (themeSelect)
1865
+ themeSelect.value = 'system';
1866
+ applyTheme();
1867
+ saveSettings();
1868
+ speak('System theme enabled', false);
1869
+ status.textContent = 'Theme: System automatic';
1870
+ break;
1871
+ case 'help':
1872
+ addToDebugLog('[VOICE CMD] Help command matched');
1873
+ showHelpDialog();
1874
+ speakHelp();
1875
+ status.textContent = 'Showing help';
1876
+ // Keep listening for commands during help (like "stop" or "cancel")
1877
+ if (voice.controller) {
1878
+ voice.controller.startCommandListening();
1879
+ addToDebugLog('[VOICE] Keeping command listening active during help');
1880
+ }
1881
+ break;
1882
+ case 'stop-speaking':
1883
+ addToDebugLog('[VOICE CMD] Stop speaking command matched');
1884
+ stopSpeaking();
1885
+ status.textContent = '';
1886
+ break;
1887
+ case 'what-time':
1888
+ addToDebugLog('[VOICE CMD] What time is it command matched');
1889
+ const now = new Date();
1890
+ const hours = now.getHours();
1891
+ const minutes = now.getMinutes();
1892
+ const ampm = hours >= 12 ? 'PM' : 'AM';
1893
+ const displayHours = hours === 0 ? 12 : hours > 12 ? hours - 12 : hours;
1894
+ const timeStr = `${displayHours}:${minutes.toString().padStart(2, '0')} ${ampm}`;
1895
+ speak(`The time is ${timeStr}`, false);
1896
+ status.textContent = `Time: ${timeStr}`;
1897
+ break;
1898
+ case 'timer':
1899
+ if (command.value && command.value > 0) {
1900
+ // Check for canonical command to prevent duplicates (e.g., "5 minutes" vs "300 seconds")
1901
+ if (!isCanonicalCommand('timer', command.value)) {
1902
+ addToDebugLog(`[TIMER] Ignoring duplicate timer command: ${command.value} seconds`);
1903
+ status.textContent = `Duplicate timer command ignored`;
1904
+ break;
1905
+ }
1906
+ // Reset inactivity timer when user sets a timer via voice
1907
+ resetInactivityTimer();
1908
+ if (userTimer.active) {
1909
+ cancelUserTimer();
1910
+ }
1911
+ startUserTimer(command.value);
1912
+ const minutes = Math.floor(command.value / 60);
1913
+ const seconds = command.value % 60;
1914
+ const timeStr = minutes > 0 ? `${minutes} minute${minutes !== 1 ? 's' : ''}` +
1915
+ (seconds > 0 ? ` ${seconds} second${seconds !== 1 ? 's' : ''}` : '') :
1916
+ `${seconds} second${seconds !== 1 ? 's' : ''}`;
1917
+ speak(`Timer set for ${timeStr}`, true);
1918
+ status.textContent = `Timer: ${timeStr} (${command.source} parsing)`;
1919
+ addToDebugLog(`[TIMER START] ${command.value} seconds using ${command.source} parsing`);
1920
+ }
1921
+ else {
1922
+ status.textContent = 'Invalid timer duration';
1923
+ }
1924
+ break;
1925
+ case 'weather':
1926
+ const temperature = getCurrentTemperature();
1927
+ speak(`The current temperature is ${temperature}`, false);
1928
+ status.textContent = `Weather: ${temperature}`;
1929
+ addToDebugLog(`[WEATHER] Spoken: ${temperature}`);
1930
+ break;
1931
+ default:
1932
+ status.textContent = `Unknown command: ${command.action}`;
1933
+ }
1934
+ // After command execution, keep the listening banner active and continue listening for more commands
1935
+ // The listening timeout will handle returning to wake word listening
1936
+ addToDebugLog('[VOICE] Continuing to listen for additional commands until timeout');
1937
+ // Don't change banner state or restart wake word listening - let the timeout handle it
1938
+ }
1939
+ function speak(text, isTimerRelated = false) {
1940
+ // Prevent duplicate speech announcements
1941
+ const now = Date.now();
1942
+ if (text === lastSpokenText && (now - lastSpokenTime) < 3000) {
1943
+ addToDebugLog(`[SPEECH DUP] Ignoring duplicate speech: "${text}"`);
1944
+ return;
1945
+ }
1946
+ lastSpokenText = text;
1947
+ lastSpokenTime = now;
1948
+ // Only speak if not in silent mode, or if it's timer-related
1949
+ if (('speechSynthesis' in window) && (!getSilentMode() || isTimerRelated)) {
1950
+ // CRITICAL: Stop listening before speaking to prevent echo loop
1951
+ // The microphone will pick up our own speech and trigger commands
1952
+ if (voice.controller && microphoneState === 'waiting') {
1953
+ voice.controller.stopListening();
1954
+ addToDebugLog('[SPEECH] Stopped listening to prevent echo');
1955
+ }
1956
+ const utterance = new SpeechSynthesisUtterance(text);
1957
+ // Resume listening after speech completes
1958
+ utterance.onend = () => {
1959
+ if (voice.controller && voice.enabled) {
1960
+ setTimeout(() => {
1961
+ if (!voice.controller.isActive()) {
1962
+ voice.controller?.startListening();
1963
+ setBannerState('waiting');
1964
+ addToDebugLog('[SPEECH] Resumed listening after speech');
1965
+ }
1966
+ else {
1967
+ addToDebugLog('[SPEECH] Already listening, skipping resume');
1968
+ }
1969
+ }, 500); // Small delay to ensure clean state
1970
+ }
1971
+ };
1972
+ speechSynthesis.speak(utterance);
1973
+ addToDebugLog(`[SPEECH] Speaking: "${text}" (timer-related: ${isTimerRelated}, silent mode: ${getSilentMode()})`);
1974
+ }
1975
+ else {
1976
+ addToDebugLog(`[SPEECH BLOCKED] Not speaking: "${text}" (speechSynthesis available: ${('speechSynthesis' in window)}, silent mode: ${getSilentMode()}, timer-related: ${isTimerRelated})`);
1977
+ // Show orange banner when speech is blocked
1978
+ setBannerState('speech-blocked');
1979
+ // Hide the speech blocked banner after 3 seconds
1980
+ setTimeout(() => {
1981
+ if (microphoneState === 'speech-blocked') {
1982
+ setBannerState('waiting');
1983
+ }
1984
+ }, 3000);
1985
+ }
1986
+ }
1987
+ function parseTimeString(timeStr, ampm) {
1988
+ try {
1989
+ // Handle formats like "1:00", "13:00", "1", etc.
1990
+ let hours;
1991
+ let minutes = 0;
1992
+ if (timeStr.includes(':')) {
1993
+ const [hoursStr, minutesStr] = timeStr.split(':');
1994
+ hours = parseInt(hoursStr);
1995
+ minutes = parseInt(minutesStr) || 0;
1996
+ }
1997
+ else {
1998
+ hours = parseInt(timeStr);
1999
+ }
2000
+ // Handle 12-hour format
2001
+ if (ampm) {
2002
+ if (ampm.toLowerCase() === 'pm' && hours !== 12) {
2003
+ hours += 12;
2004
+ }
2005
+ else if (ampm.toLowerCase() === 'am' && hours === 12) {
2006
+ hours = 0;
2007
+ }
2008
+ }
2009
+ // Create target time
2010
+ const now = new Date();
2011
+ const target = new Date(now);
2012
+ target.setHours(hours, minutes, 0, 0);
2013
+ // If the target time is in the past today, assume it's for tomorrow
2014
+ if (target.getTime() <= now.getTime()) {
2015
+ target.setDate(target.getDate() + 1);
2016
+ }
2017
+ return target;
2018
+ }
2019
+ catch (error) {
2020
+ return null;
2021
+ }
2022
+ }
2023
+ // Browser compatibility checking is now handled by VoiceController module
2024
+ function showHelpDialog() {
2025
+ const helpDialog = dv('help-dialog');
2026
+ helpDialog.classList.remove('hidden');
2027
+ // Auto-close help dialog after 30 seconds
2028
+ if (helpTimeoutInterval) {
2029
+ clearTimeout(helpTimeoutInterval);
2030
+ }
2031
+ helpTimeoutInterval = window.setTimeout(() => {
2032
+ closeHelpDialog();
2033
+ addToDebugLog('[HELP] Help dialog auto-closed after 30 seconds');
2034
+ }, 30000);
2035
+ }
2036
+ function closeHelpDialog() {
2037
+ const helpDialog = dv('help-dialog');
2038
+ helpDialog.classList.add('hidden');
2039
+ stopSpeaking();
2040
+ // Clear the auto-close timeout
2041
+ if (helpTimeoutInterval) {
2042
+ clearTimeout(helpTimeoutInterval);
2043
+ helpTimeoutInterval = undefined;
2044
+ }
2045
+ }
2046
+ function speakHelp() {
2047
+ if (!('speechSynthesis' in window))
2048
+ return;
2049
+ isSpeakingHelp = true;
2050
+ const helpText = `Voice commands available.
2051
+ Timer commands: Say timer 5 minutes, timer 30 seconds, or cancel userTimer.
2052
+ Display commands: Say show digital time, hide digital time, fullscreen, or exit fullscreen.
2053
+ Theme commands: Say light mode, dark mode, or system theme.
2054
+ Other commands: Say silent to toggle silent mode, or stop voice to disable voice control.
2055
+ Say stop at any time to stop this help.`;
2056
+ const utterance = new SpeechSynthesisUtterance(helpText);
2057
+ utterance.rate = 0.9; // Slightly slower for clarity
2058
+ utterance.onend = () => {
2059
+ isSpeakingHelp = false;
2060
+ addToDebugLog('[SPEECH] Help speech completed');
2061
+ };
2062
+ speechSynthesis.speak(utterance);
2063
+ addToDebugLog('[SPEECH] Speaking help commands');
2064
+ }
2065
+ function stopSpeaking() {
2066
+ if ('speechSynthesis' in window) {
2067
+ speechSynthesis.cancel();
2068
+ isSpeakingHelp = false;
2069
+ addToDebugLog('[SPEECH] Stopped speaking');
2070
+ }
2071
+ // Also close the help dialog when stopping speech
2072
+ const helpDialog = dv('help-dialog');
2073
+ if (!helpDialog.classList.contains('hidden')) {
2074
+ closeHelpDialog();
2075
+ }
2076
+ }
2077
+ // PWA Install Functions
2078
+ function handleInstallClick() {
2079
+ if (deferredPrompt) {
2080
+ deferredPrompt.prompt();
2081
+ deferredPrompt.userChoice.then((choiceResult) => {
2082
+ if (choiceResult.outcome === 'accepted') {
2083
+ addToDebugLog('[PWA] User accepted the install prompt');
2084
+ }
2085
+ else {
2086
+ addToDebugLog('[PWA] User dismissed the install prompt');
2087
+ }
2088
+ deferredPrompt = null;
2089
+ updateInstallButtonVisibility();
2090
+ });
2091
+ }
2092
+ else {
2093
+ addToDebugLog('[PWA] Install not available - app may already be installed');
2094
+ }
2095
+ }
2096
+ function enterFullscreen() {
2097
+ if (!document.fullscreenElement) {
2098
+ const docEl = document.documentElement;
2099
+ const requestFullscreen = docEl.requestFullscreen ||
2100
+ docEl.webkitRequestFullscreen ||
2101
+ docEl.mozRequestFullScreen ||
2102
+ docEl.msRequestFullscreen;
2103
+ if (requestFullscreen) {
2104
+ requestFullscreen.call(docEl).then(() => {
2105
+ addToDebugLog('[FULLSCREEN] Successfully entered fullscreen mode');
2106
+ }).catch((error) => {
2107
+ addToDebugWarn(`[FULLSCREEN] Failed to enter fullscreen: ${error.message}`);
2108
+ });
2109
+ }
2110
+ else {
2111
+ addToDebugWarn('[FULLSCREEN] Fullscreen API not supported');
2112
+ }
2113
+ }
2114
+ }
2115
+ function updateInstallButtonVisibility() {
2116
+ const installButton = dv('install-button');
2117
+ if (installButton) {
2118
+ // Check if app is running in standalone mode (already installed)
2119
+ const isStandalone = window.matchMedia('(display-mode: standalone)').matches;
2120
+ const isInWebApp = window.navigator.standalone === true; // iOS
2121
+ if (deferredPrompt && !isStandalone && !isInWebApp) {
2122
+ installButton.classList.remove('hidden');
2123
+ addToDebugLog('[PWA] Install button shown - app can be installed');
2124
+ }
2125
+ else {
2126
+ installButton.classList.add('hidden');
2127
+ if (isStandalone || isInWebApp) {
2128
+ addToDebugLog('[PWA] Install button hidden - app is already installed');
2129
+ }
2130
+ else {
2131
+ addToDebugLog('[PWA] Install button hidden - install not available');
2132
+ }
2133
+ }
2134
+ // Debug: Log current state
2135
+ addToDebugLog(`[PWA] Button state: classes="${installButton.className}", hasPrompt=${!!deferredPrompt}`);
2136
+ }
2137
+ }
2138
+ // PWA Event Listeners
2139
+ window.addEventListener('beforeinstallprompt', (e) => {
2140
+ e.preventDefault();
2141
+ deferredPrompt = e;
2142
+ updateInstallButtonVisibility();
2143
+ addToDebugLog('[PWA] Install prompt available');
2144
+ });
2145
+ window.addEventListener('appinstalled', () => {
2146
+ deferredPrompt = null;
2147
+ updateInstallButtonVisibility();
2148
+ addToDebugLog('[PWA] App installed successfully');
2149
+ });
2150
+ document.addEventListener('DOMContentLoaded', async () => {
2151
+ await init();
2152
+ });
2153
+ //# sourceMappingURL=clock.js.map