@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.
- package/KNOWN-BUGS.md +121 -0
- package/MSGER-API-SUMMARY.md +162 -0
- package/MSGER-API.md +376 -0
- package/README.md +93 -0
- package/SESSION-2025-11-06.md +191 -0
- package/SESSION-NOTES.md +678 -0
- package/clihandler.d.ts.map +1 -1
- package/clihandler.js +62 -2
- package/clihandler.js.map +1 -1
- package/clihandler.ts +60 -2
- package/icon.png +0 -0
- package/icon1.png +0 -0
- package/msger-native/Cargo.toml +1 -0
- package/msger-native/bin/msgernative.exe +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Breadcrumbs +12 -118
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/BrowserMetrics/{BrowserMetrics-690552AF-DCD4.pma → BrowserMetrics-690B9AD3-657C.pma} +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/BrowserMetrics/{BrowserMetrics-69055373-F88C.pma → BrowserMetrics-690BA05A-501C.pma} +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Crashpad/settings.dat +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/BrowsingTopicsState +1 -1
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/{BrowserMetrics/BrowserMetrics-69055587-A65C.pma → Default/Cache/Cache_Data/data_2} +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/data_3 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000001 +383 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000002 +1091 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000003 +2153 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000004 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000005 +626 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/f_000006 +393 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Cache/Cache_Data/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/01241693cfdc32b9_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/0ba1eea781f3552c_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/323aa210eebefe2c_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/4608446ac118e77a_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/6938205dc2f77841_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/6de12299dc89e5f3_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/8f403c112eaa455b_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/9a3aceb491137f07_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/aedb266cbaf9c28f_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/ca526fdda86d0b9d_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/f5d11d783c9fdf69_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Code Cache/js/index-dir/the-real-index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Collections/collectionsSQLite +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Collections/collectionsSQLite-journal +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DIPS +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnGraphiteCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnGraphiteCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnWebGPUCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/DawnWebGPUCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Extension State/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Extension State/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Favicons +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/data_2 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/GPUCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/History +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Local Storage/leveldb/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/MediaDeviceSalts +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/MediaDeviceSalts-journal +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Network/Network Persistent State +1 -1
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Network/TransportSecurity +1 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Preferences +1 -1
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/CacheStorage/14e849fa8522d406112ea607cf7fd6342b71b987/249ee9af-c3df-4a86-89a8-2c51f3370ee0/index +0 -0
- 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
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/CacheStorage/14e849fa8522d406112ea607cf7fd6342b71b987/index.txt +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/CURRENT +1 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOCK +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOG +3 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/LOG.old +3 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/Database/MANIFEST-000001 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/2cc80dabc69f58b6_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/2cc80dabc69f58b6_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Service Worker/ScriptCache/index-dir/the-real-index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Session Storage/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Site Characteristics Database/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/Sync Data/LevelDB/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/WebStorage/QuotaManager-journal +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/favorites_diagnostic.log +27 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/000003.log +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOG +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Default/shared_proto_db/metadata/LOG.old +3 -3
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_0 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/data_3 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/f_000003 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/f_000004 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GrShaderCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GraphiteDawnCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/GraphiteDawnCache/index +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/Local State +1 -1
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/RevisitationBloomfilter +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/ShaderCache/data_1 +0 -0
- package/msger-native/bin/msgernative.exe.WebView2/EBWebView/ShaderCache/index +0 -0
- package/msger-native/src/main.rs +343 -37
- package/msger-native/src/template.html +103 -28
- package/msger-storage-demo.html +290 -0
- package/msger.code-workspace +3 -0
- package/msgerdefs/README.md +122 -0
- package/msgerdefs/msgerdefs.d.ts +322 -0
- package/msgerdefs/msgerdefs.d.ts.map +1 -0
- package/msgerdefs/msgerdefs.js +110 -0
- package/msgerdefs/msgerdefs.js.map +1 -0
- package/msgerdefs/msgerdefs.ts +427 -0
- package/msgerdefs/package.json +38 -0
- package/msgerdefs/samples.html +431 -0
- package/msgerdefs/test1.cmd +1 -0
- package/msgerdefs/tsconfig.json +17 -0
- package/msgernative-linux-x64 +0 -0
- package/package.json +5 -1
- package/shower.d.ts +2 -0
- package/shower.d.ts.map +1 -1
- package/shower.js +17 -0
- package/shower.js.map +1 -1
- package/shower.ts +24 -0
- package/test-data-persistence.html +315 -0
- package/test-htmlfrom.html +29 -0
- package/test-ipc-reach.html +113 -0
- package/test-msger-api.html +120 -0
- package/test-msger-functions.html +325 -0
- 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
|