@genesislcap/pbc-notify-ui 15.8.3 → 15.9.0
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/README.md +39 -0
- package/custom-elements-manifest.config.js +5 -0
- package/dist/custom-elements.json +1771 -321
- package/dist/dts/components/components.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts +2 -0
- package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.styles.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.template.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.types.d.ts +4 -1
- package/dist/dts/components/foundation-inbox/inbox.types.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/alert-sound-attachment.d.ts +86 -0
- package/dist/dts/components/foundation-notification-dashboard/alert-sound-attachment.d.ts.map +1 -0
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.d.ts +39 -1
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.d.ts +8 -0
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/rules/rule-management.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.d.ts +41 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.d.ts +2 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/components/templates/template-management.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/styles/dynamic-rule.styles.d.ts.map +1 -1
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.d.ts +73 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.d.ts.map +1 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.styles.d.ts +2 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.styles.d.ts.map +1 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.template.d.ts +3 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds-panel.template.d.ts.map +1 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds.d.ts +34 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds.d.ts.map +1 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds.styles.d.ts +3 -0
- package/dist/dts/components/foundation-sounds/foundation-sounds.styles.d.ts.map +1 -0
- package/dist/dts/index.d.ts +4 -0
- package/dist/dts/index.d.ts.map +1 -1
- package/dist/dts/react.d.ts +31 -11
- package/dist/dts/services/inbox.service.d.ts +6 -0
- package/dist/dts/services/inbox.service.d.ts.map +1 -1
- package/dist/dts/services/rule.service.d.ts +2 -1
- package/dist/dts/services/rule.service.d.ts.map +1 -1
- package/dist/dts/utils/alert-sound.d.ts +177 -0
- package/dist/dts/utils/alert-sound.d.ts.map +1 -0
- package/dist/dts/utils/alert-sound.test.d.ts +2 -0
- package/dist/dts/utils/alert-sound.test.d.ts.map +1 -0
- package/dist/esm/components/components.js +4 -0
- package/dist/esm/components/foundation-inbox/inbox-base/inbox-base.js +6 -0
- package/dist/esm/components/foundation-inbox/inbox.styles.js +15 -0
- package/dist/esm/components/foundation-inbox/inbox.template.js +16 -0
- package/dist/esm/components/foundation-inbox/inbox.types.js +1 -0
- package/dist/esm/components/foundation-notification-dashboard/alert-sound-attachment.js +218 -0
- package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.js +17 -2
- package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.js +3 -0
- package/dist/esm/components/foundation-notification-dashboard/components/rules/rule-management.js +5 -1
- package/dist/esm/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.js +23 -2
- package/dist/esm/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.js +3 -0
- package/dist/esm/components/foundation-notification-dashboard/components/templates/template-management.js +8 -1
- package/dist/esm/components/foundation-notification-dashboard/styles/dynamic-rule.styles.js +31 -1
- package/dist/esm/components/foundation-sounds/foundation-sounds-panel.js +283 -0
- package/dist/esm/components/foundation-sounds/foundation-sounds-panel.styles.js +200 -0
- package/dist/esm/components/foundation-sounds/foundation-sounds-panel.template.js +179 -0
- package/dist/esm/components/foundation-sounds/foundation-sounds.js +206 -0
- package/dist/esm/components/foundation-sounds/foundation-sounds.styles.js +7 -0
- package/dist/esm/index.js +3 -0
- package/dist/esm/services/inbox.service.js +6 -2
- package/dist/esm/services/rule.service.js +35 -0
- package/dist/esm/utils/alert-sound.js +955 -0
- package/dist/esm/utils/alert-sound.test.js +25 -0
- package/dist/esm/utils/index.js +3 -0
- package/dist/pbc-notify-ui.api.json +2859 -332
- package/dist/pbc-notify-ui.d.ts +281 -3
- package/dist/react.cjs +19 -5
- package/dist/react.mjs +16 -4
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -23
- package/src/components/components.ts +4 -0
- package/src/components/foundation-inbox/inbox-base/inbox-base.ts +7 -0
- package/src/components/foundation-inbox/inbox.styles.ts +15 -0
- package/src/components/foundation-inbox/inbox.template.ts +16 -0
- package/src/components/foundation-inbox/inbox.types.ts +3 -0
- package/src/components/foundation-notification-dashboard/alert-sound-attachment.ts +283 -0
- package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.template.ts +3 -0
- package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.ts +21 -2
- package/src/components/foundation-notification-dashboard/components/rules/rule-dialog/rule-dialog.types.ts +9 -0
- package/src/components/foundation-notification-dashboard/components/rules/rule-management.ts +5 -1
- package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.template.ts +3 -0
- package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.ts +25 -2
- package/src/components/foundation-notification-dashboard/components/templates/template-dialog/template-dialog.types.ts +2 -1
- package/src/components/foundation-notification-dashboard/components/templates/template-management.ts +8 -1
- package/src/components/foundation-notification-dashboard/styles/dynamic-rule.styles.ts +31 -1
- package/src/components/foundation-sounds/foundation-sounds-panel.styles.ts +201 -0
- package/src/components/foundation-sounds/foundation-sounds-panel.template.ts +188 -0
- package/src/components/foundation-sounds/foundation-sounds-panel.ts +280 -0
- package/src/components/foundation-sounds/foundation-sounds.styles.ts +8 -0
- package/src/components/foundation-sounds/foundation-sounds.ts +222 -0
- package/src/index.ts +8 -0
- package/src/services/inbox.service.ts +12 -2
- package/src/services/rule.service.ts +37 -1
- package/src/utils/alert-sound.test.ts +32 -0
- package/src/utils/alert-sound.ts +1135 -0
- package/src/utils/index.ts +3 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
import { __awaiter } from "tslib";
|
|
2
|
+
import { showNotification } from '@genesislcap/foundation-notifications';
|
|
3
|
+
import debounce from 'lodash.debounce';
|
|
4
|
+
import { logger } from './logger';
|
|
5
|
+
const DB_NAME = 'pbc-notify-ui';
|
|
6
|
+
const DB_VERSION = 1;
|
|
7
|
+
const STORE_NAME = 'alert-sound';
|
|
8
|
+
/** Match config (no audio) for every active rule — cheap to rewrite on every edit. */
|
|
9
|
+
const RULES_KEY = 'user-alert-sound-rules';
|
|
10
|
+
/** Global volume / mute for all alert sounds. */
|
|
11
|
+
const SETTINGS_KEY = 'user-alert-sound-settings';
|
|
12
|
+
/** Per-rule audio, keyed by the rule's sticky id (`user-alert-sound-audio:1` … `:10`). */
|
|
13
|
+
const AUDIO_KEY_PREFIX = 'user-alert-sound-audio:';
|
|
14
|
+
/** Maximum number of sound rules (MESSAGE/HEADER pattern, or notification rule). */
|
|
15
|
+
export const MAX_SOUND_RULES = 10;
|
|
16
|
+
/** Slider and display use whole percents. */
|
|
17
|
+
const VOLUME_PERCENT_MAX = 100;
|
|
18
|
+
/** Default playback level (100%). */
|
|
19
|
+
const DEFAULT_VOLUME = 1;
|
|
20
|
+
function audioKey(ruleId) {
|
|
21
|
+
return `${AUDIO_KEY_PREFIX}${ruleId}`;
|
|
22
|
+
}
|
|
23
|
+
/** Sticky ids `1`…`MAX_SOUND_RULES` — stay with the rule, not its list position. */
|
|
24
|
+
function allRuleIds() {
|
|
25
|
+
return Array.from({ length: MAX_SOUND_RULES }, (_, index) => String(index + 1));
|
|
26
|
+
}
|
|
27
|
+
const BYTES_PER_KB = 1024;
|
|
28
|
+
const BYTES_PER_MB = BYTES_PER_KB * BYTES_PER_KB;
|
|
29
|
+
/** Rounds megabyte sizes to one decimal place for display. */
|
|
30
|
+
const MB_PRECISION = 10;
|
|
31
|
+
/**
|
|
32
|
+
* Every rule's audio is held in one IndexedDB row, so a generous per-file limit
|
|
33
|
+
* still risks the origin storage quota once multiplied by {@link MAX_SOUND_RULES}.
|
|
34
|
+
*/
|
|
35
|
+
export const MAX_SOUND_FILE_BYTES = 2 * BYTES_PER_MB;
|
|
36
|
+
/** Encodes a File/Blob to a base64 string (no data-URL prefix). */
|
|
37
|
+
export function blobToBase64(blob) {
|
|
38
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
39
|
+
const buffer = yield blob.arrayBuffer();
|
|
40
|
+
const bytes = new Uint8Array(buffer);
|
|
41
|
+
const chunkSize = 0x8000;
|
|
42
|
+
let binary = '';
|
|
43
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
44
|
+
const chunk = bytes.subarray(i, i + chunkSize);
|
|
45
|
+
binary += String.fromCharCode(...chunk);
|
|
46
|
+
}
|
|
47
|
+
return btoa(binary);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
/** Decodes a base64 string to a Blob. */
|
|
51
|
+
export function base64ToBlob(base64, mimeType) {
|
|
52
|
+
const binary = atob(base64);
|
|
53
|
+
const bytes = new Uint8Array(binary.length);
|
|
54
|
+
for (let i = 0; i < binary.length; i += 1) {
|
|
55
|
+
bytes[i] = binary.charCodeAt(i);
|
|
56
|
+
}
|
|
57
|
+
return new Blob([bytes], { type: mimeType || 'audio/mpeg' });
|
|
58
|
+
}
|
|
59
|
+
/** Coalesces the writes produced while typing into a pattern field. */
|
|
60
|
+
const PERSIST_DEBOUNCE_MS = 400;
|
|
61
|
+
/**
|
|
62
|
+
* Resolves a playable MIME type. macOS often leaves `.aif` / `.aiff` with an
|
|
63
|
+
* empty `File.type`; falling back to `audio/mpeg` breaks restore + decode.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveAudioMimeType(name, type) {
|
|
66
|
+
const lowerName = (name !== null && name !== void 0 ? name : '').toLowerCase();
|
|
67
|
+
const normalizedType = (type !== null && type !== void 0 ? type : '').trim().toLowerCase();
|
|
68
|
+
const isAiff = lowerName.endsWith('.aif') || lowerName.endsWith('.aiff');
|
|
69
|
+
if (isAiff) {
|
|
70
|
+
if (!normalizedType ||
|
|
71
|
+
normalizedType === 'application/octet-stream' ||
|
|
72
|
+
normalizedType === 'audio/mpeg') {
|
|
73
|
+
return 'audio/aiff';
|
|
74
|
+
}
|
|
75
|
+
return normalizedType;
|
|
76
|
+
}
|
|
77
|
+
if (normalizedType && normalizedType !== 'application/octet-stream') {
|
|
78
|
+
return normalizedType;
|
|
79
|
+
}
|
|
80
|
+
if (lowerName.endsWith('.mp3'))
|
|
81
|
+
return 'audio/mpeg';
|
|
82
|
+
if (lowerName.endsWith('.wav'))
|
|
83
|
+
return 'audio/wav';
|
|
84
|
+
if (lowerName.endsWith('.m4a') || lowerName.endsWith('.mp4'))
|
|
85
|
+
return 'audio/mp4';
|
|
86
|
+
if (lowerName.endsWith('.ogg') || lowerName.endsWith('.oga'))
|
|
87
|
+
return 'audio/ogg';
|
|
88
|
+
if (lowerName.endsWith('.aac'))
|
|
89
|
+
return 'audio/aac';
|
|
90
|
+
if (lowerName.endsWith('.flac'))
|
|
91
|
+
return 'audio/flac';
|
|
92
|
+
return normalizedType || 'application/octet-stream';
|
|
93
|
+
}
|
|
94
|
+
/** MIME → canPlayType result; probing only depends on the type string. */
|
|
95
|
+
const canPlayMimeCache = new Map();
|
|
96
|
+
function browserCanPlayMime(mimeType) {
|
|
97
|
+
if (typeof Audio === 'undefined') {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
const cached = canPlayMimeCache.get(mimeType);
|
|
101
|
+
if (cached !== undefined) {
|
|
102
|
+
return cached;
|
|
103
|
+
}
|
|
104
|
+
const probe = new Audio();
|
|
105
|
+
const candidates = mimeType === 'audio/aiff' || mimeType === 'audio/x-aiff'
|
|
106
|
+
? ['audio/aiff', 'audio/x-aiff', 'audio/aif']
|
|
107
|
+
: [mimeType];
|
|
108
|
+
const canPlay = candidates.some((candidate) => Boolean(probe.canPlayType(candidate)));
|
|
109
|
+
canPlayMimeCache.set(mimeType, canPlay);
|
|
110
|
+
return canPlay;
|
|
111
|
+
}
|
|
112
|
+
/** Formats a byte count for user-facing size limits (e.g. "2 MB"). */
|
|
113
|
+
export function formatBytes(bytes) {
|
|
114
|
+
const megabytes = bytes / BYTES_PER_MB;
|
|
115
|
+
return megabytes >= 1
|
|
116
|
+
? `${Math.round(megabytes * MB_PRECISION) / MB_PRECISION} MB`
|
|
117
|
+
: `${Math.max(1, Math.round(bytes / BYTES_PER_KB))} KB`;
|
|
118
|
+
}
|
|
119
|
+
function notifyPlaybackIssue(title, body) {
|
|
120
|
+
showNotification({
|
|
121
|
+
title,
|
|
122
|
+
body,
|
|
123
|
+
config: {
|
|
124
|
+
toast: { type: 'warning', autoClose: true, closeTimeout: 6000 },
|
|
125
|
+
},
|
|
126
|
+
}, 'rapid');
|
|
127
|
+
}
|
|
128
|
+
function createEmptySlot(id) {
|
|
129
|
+
return {
|
|
130
|
+
id,
|
|
131
|
+
matchMode: 'message',
|
|
132
|
+
pattern: '',
|
|
133
|
+
templateId: null,
|
|
134
|
+
templateName: null,
|
|
135
|
+
fileName: null,
|
|
136
|
+
mimeType: 'audio/mpeg',
|
|
137
|
+
muted: false,
|
|
138
|
+
audio: null,
|
|
139
|
+
blob: null,
|
|
140
|
+
objectUrl: null,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
function normalizeMatchMode(value) {
|
|
144
|
+
if (value === 'rule') {
|
|
145
|
+
return 'rule';
|
|
146
|
+
}
|
|
147
|
+
if (value === 'header') {
|
|
148
|
+
return 'header';
|
|
149
|
+
}
|
|
150
|
+
return 'message';
|
|
151
|
+
}
|
|
152
|
+
function slotHasMatchConfig(slot) {
|
|
153
|
+
var _a;
|
|
154
|
+
if (slot.matchMode === 'rule') {
|
|
155
|
+
return Boolean((_a = slot.templateId) === null || _a === void 0 ? void 0 : _a.trim());
|
|
156
|
+
}
|
|
157
|
+
return Boolean(slot.pattern.trim());
|
|
158
|
+
}
|
|
159
|
+
function storedIsMeaningful(rule, audio) {
|
|
160
|
+
var _a, _b;
|
|
161
|
+
if (audio === null || audio === void 0 ? void 0 : audio.buffer) {
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
if (!rule) {
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const matchMode = normalizeMatchMode(rule.matchMode);
|
|
168
|
+
if (matchMode === 'rule') {
|
|
169
|
+
return Boolean((_a = rule.templateId) === null || _a === void 0 ? void 0 : _a.trim());
|
|
170
|
+
}
|
|
171
|
+
return Boolean((_b = rule.pattern) === null || _b === void 0 ? void 0 : _b.trim());
|
|
172
|
+
}
|
|
173
|
+
function emptySlots() {
|
|
174
|
+
return allRuleIds().map((id) => createEmptySlot(id));
|
|
175
|
+
}
|
|
176
|
+
function openDb() {
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
|
179
|
+
request.onupgradeneeded = () => {
|
|
180
|
+
const db = request.result;
|
|
181
|
+
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
|
182
|
+
db.createObjectStore(STORE_NAME);
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
// Without this, a version-upgrade blocked by another open tab never settles
|
|
186
|
+
// the request at all, wedging persistChain (via idbPut/idbGet) indefinitely.
|
|
187
|
+
request.onblocked = () => {
|
|
188
|
+
reject(new Error('IndexedDB open blocked by another connection'));
|
|
189
|
+
};
|
|
190
|
+
request.onsuccess = () => resolve(request.result);
|
|
191
|
+
request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error('Failed to open IndexedDB')); };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
function idbGet(key) {
|
|
195
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
196
|
+
let db = null;
|
|
197
|
+
try {
|
|
198
|
+
db = yield openDb();
|
|
199
|
+
return yield new Promise((resolve, reject) => {
|
|
200
|
+
const tx = db.transaction(STORE_NAME, 'readonly');
|
|
201
|
+
const store = tx.objectStore(STORE_NAME);
|
|
202
|
+
const request = store.get(key);
|
|
203
|
+
let result = null;
|
|
204
|
+
request.onsuccess = () => {
|
|
205
|
+
var _a;
|
|
206
|
+
result = (_a = request.result) !== null && _a !== void 0 ? _a : null;
|
|
207
|
+
};
|
|
208
|
+
request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error(`Failed to read ${key}`)); };
|
|
209
|
+
// Resolve on transaction complete so close() in finally cannot race the read.
|
|
210
|
+
tx.oncomplete = () => resolve(result);
|
|
211
|
+
tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Failed to read ${key}`)); };
|
|
212
|
+
tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Read aborted for ${key}`)); };
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
catch (error) {
|
|
216
|
+
logger.error(`Failed to read ${key} from IndexedDB`, error);
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
db === null || db === void 0 ? void 0 : db.close();
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
function idbPut(key, value) {
|
|
225
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
226
|
+
let db = null;
|
|
227
|
+
try {
|
|
228
|
+
db = yield openDb();
|
|
229
|
+
yield new Promise((resolve, reject) => {
|
|
230
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
231
|
+
const store = tx.objectStore(STORE_NAME);
|
|
232
|
+
const request = store.put(value, key);
|
|
233
|
+
request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error(`Failed to store ${key}`)); };
|
|
234
|
+
// Wait for commit — resolving on request.onsuccess alone can lose the write
|
|
235
|
+
// if the connection is closed before the transaction finishes.
|
|
236
|
+
tx.oncomplete = () => resolve();
|
|
237
|
+
tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Failed to store ${key}`)); };
|
|
238
|
+
tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Write aborted for ${key}`)); };
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
finally {
|
|
242
|
+
db === null || db === void 0 ? void 0 : db.close();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function idbDelete(key) {
|
|
247
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
248
|
+
let db = null;
|
|
249
|
+
try {
|
|
250
|
+
db = yield openDb();
|
|
251
|
+
yield new Promise((resolve, reject) => {
|
|
252
|
+
const tx = db.transaction(STORE_NAME, 'readwrite');
|
|
253
|
+
const store = tx.objectStore(STORE_NAME);
|
|
254
|
+
const request = store.delete(key);
|
|
255
|
+
request.onerror = () => { var _a; return reject((_a = request.error) !== null && _a !== void 0 ? _a : new Error(`Failed to delete ${key}`)); };
|
|
256
|
+
tx.oncomplete = () => resolve();
|
|
257
|
+
tx.onerror = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Failed to delete ${key}`)); };
|
|
258
|
+
tx.onabort = () => { var _a; return reject((_a = tx.error) !== null && _a !== void 0 ? _a : new Error(`Delete aborted for ${key}`)); };
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
finally {
|
|
262
|
+
db === null || db === void 0 ? void 0 : db.close();
|
|
263
|
+
}
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
function persistFailureBody(error) {
|
|
267
|
+
const name = error && typeof error === 'object' && 'name' in error
|
|
268
|
+
? String(error.name)
|
|
269
|
+
: '';
|
|
270
|
+
if (name === 'QuotaExceededError') {
|
|
271
|
+
return 'These rules will not survive a reload. Browser storage is full — try a smaller sound file.';
|
|
272
|
+
}
|
|
273
|
+
if (name === 'VersionError') {
|
|
274
|
+
return 'These rules will not survive a reload. Clear site data for this origin and try again.';
|
|
275
|
+
}
|
|
276
|
+
return 'These rules will not survive a reload. Check the browser console for details.';
|
|
277
|
+
}
|
|
278
|
+
function asNonEmptyString(value) {
|
|
279
|
+
if (typeof value === 'string' && value.trim()) {
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
283
|
+
return String(value);
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* True when `text` contains `phrase` as a whole phrase (case-insensitive).
|
|
289
|
+
* Single words and multi-word phrases both work; edges use word boundaries so
|
|
290
|
+
* e.g. `g1` matches "test g1 test" but not "test g1h test".
|
|
291
|
+
*
|
|
292
|
+
* `\b` only asserts a word/non-word transition, so anchoring an edge whose own
|
|
293
|
+
* character is punctuation can never match — `\b\$100` fails on "cost $100".
|
|
294
|
+
* Each edge is therefore anchored only when the pattern ends in a word character.
|
|
295
|
+
*
|
|
296
|
+
* @internal Exported for unit tests.
|
|
297
|
+
*/
|
|
298
|
+
export function textContainsWholeWord(text, phrase) {
|
|
299
|
+
const needle = phrase.trim();
|
|
300
|
+
if (!needle) {
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
304
|
+
const start = /\w/.test(needle[0]) ? '\\b' : '';
|
|
305
|
+
const end = /\w/.test(needle[needle.length - 1]) ? '\\b' : '';
|
|
306
|
+
return new RegExp(`${start}${escaped}${end}`, 'i').test(text);
|
|
307
|
+
}
|
|
308
|
+
function readAlertStringField(row, field) {
|
|
309
|
+
if (!row) {
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
const direct = asNonEmptyString(row[field]);
|
|
313
|
+
if (direct) {
|
|
314
|
+
return direct;
|
|
315
|
+
}
|
|
316
|
+
const details = row.DETAILS;
|
|
317
|
+
return asNonEmptyString(details === null || details === void 0 ? void 0 : details[field]);
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Plays user-selected alert sounds matched by MESSAGE/HEADER text (word or phrase)
|
|
321
|
+
* or notification rule DYNAMIC_RULE_ID. Persists up to {@link MAX_SOUND_RULES}
|
|
322
|
+
* rules in IndexedDB.
|
|
323
|
+
*/
|
|
324
|
+
export class AlertSoundPlayer {
|
|
325
|
+
constructor() {
|
|
326
|
+
this.slots = emptySlots();
|
|
327
|
+
this.activeCount = 1;
|
|
328
|
+
/** Linear gain 0–1 applied to every HTMLAudioElement. */
|
|
329
|
+
this.volume = DEFAULT_VOLUME;
|
|
330
|
+
this.muted = false;
|
|
331
|
+
this.listeners = new Set();
|
|
332
|
+
/** Coalesces concurrent restores from layout + Sounds tab mounts. */
|
|
333
|
+
this.restorePromise = null;
|
|
334
|
+
this.restored = false;
|
|
335
|
+
this.persistChain = Promise.resolve();
|
|
336
|
+
/** In-memory cache of rule-attached sounds fetched from NOTIFY_ALERT_SOUND. */
|
|
337
|
+
this.serverSounds = new Map();
|
|
338
|
+
/** Debounces config writes so holding down a key does not thrash IndexedDB. */
|
|
339
|
+
this.debouncedPersistConfig = debounce(() => {
|
|
340
|
+
this.queuePersist(() => this.persistConfig());
|
|
341
|
+
}, PERSIST_DEBOUNCE_MS);
|
|
342
|
+
}
|
|
343
|
+
get rules() {
|
|
344
|
+
return this.slots.slice(0, this.activeCount).map((slot, index) => {
|
|
345
|
+
var _a;
|
|
346
|
+
return ({
|
|
347
|
+
index,
|
|
348
|
+
matchMode: slot.matchMode,
|
|
349
|
+
pattern: slot.pattern,
|
|
350
|
+
templateId: slot.templateId,
|
|
351
|
+
templateName: slot.templateName,
|
|
352
|
+
fileName: slot.fileName,
|
|
353
|
+
hasSound: Boolean((_a = slot.audio) === null || _a === void 0 ? void 0 : _a.src),
|
|
354
|
+
muted: slot.muted,
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
get canAddRule() {
|
|
359
|
+
return this.activeCount < MAX_SOUND_RULES;
|
|
360
|
+
}
|
|
361
|
+
get canRemoveRule() {
|
|
362
|
+
return this.activeCount > 1;
|
|
363
|
+
}
|
|
364
|
+
/** Whole-number percent for the Sounds tab slider (0–100). */
|
|
365
|
+
get volumePercent() {
|
|
366
|
+
return Math.round(this.volume * VOLUME_PERCENT_MAX);
|
|
367
|
+
}
|
|
368
|
+
get isMuted() {
|
|
369
|
+
return this.muted;
|
|
370
|
+
}
|
|
371
|
+
get isReady() {
|
|
372
|
+
return this.slots
|
|
373
|
+
.slice(0, this.activeCount)
|
|
374
|
+
.some((slot) => { var _a; return Boolean((_a = slot.audio) === null || _a === void 0 ? void 0 : _a.src) && slotHasMatchConfig(slot); });
|
|
375
|
+
}
|
|
376
|
+
subscribe(listener) {
|
|
377
|
+
this.listeners.add(listener);
|
|
378
|
+
return () => this.listeners.delete(listener);
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* Loads rules from IndexedDB once per page session. Concurrent calls (layout
|
|
382
|
+
* listener + Sounds tab) share one promise so a late restore cannot wipe
|
|
383
|
+
* in-memory edits the user already made.
|
|
384
|
+
*/
|
|
385
|
+
restore() {
|
|
386
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
387
|
+
if (this.restored) {
|
|
388
|
+
return this.isReady;
|
|
389
|
+
}
|
|
390
|
+
if (this.restorePromise) {
|
|
391
|
+
return this.restorePromise;
|
|
392
|
+
}
|
|
393
|
+
// Set in `finally` (not `.then`) so a failed first attempt still counts as
|
|
394
|
+
// the one shot — otherwise `restored` stays false and a later restore() call
|
|
395
|
+
// (e.g. from the panel, after the layout listener's restore rejected) can
|
|
396
|
+
// wipe in-memory edits the user already made.
|
|
397
|
+
this.restorePromise = this.restoreFromDb().finally(() => {
|
|
398
|
+
this.restored = true;
|
|
399
|
+
this.restorePromise = null;
|
|
400
|
+
});
|
|
401
|
+
return this.restorePromise;
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
restoreFromDb() {
|
|
405
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
406
|
+
var _a;
|
|
407
|
+
const [stored, settings] = yield Promise.all([
|
|
408
|
+
idbGet(RULES_KEY),
|
|
409
|
+
idbGet(SETTINGS_KEY),
|
|
410
|
+
]);
|
|
411
|
+
this.applyStoredSettings(settings);
|
|
412
|
+
const configList = Array.isArray(stored) ? stored : [];
|
|
413
|
+
const candidates = (yield Promise.all(configList.slice(0, MAX_SOUND_RULES).map((raw) => __awaiter(this, void 0, void 0, function* () {
|
|
414
|
+
var _a;
|
|
415
|
+
const id = (_a = raw === null || raw === void 0 ? void 0 : raw.id) === null || _a === void 0 ? void 0 : _a.trim();
|
|
416
|
+
if (!id) {
|
|
417
|
+
return null;
|
|
418
|
+
}
|
|
419
|
+
const audio = yield idbGet(audioKey(id));
|
|
420
|
+
if (!storedIsMeaningful(raw, audio)) {
|
|
421
|
+
return null;
|
|
422
|
+
}
|
|
423
|
+
return { rule: raw, audio };
|
|
424
|
+
})))).filter((entry) => entry != null);
|
|
425
|
+
if (!candidates.length) {
|
|
426
|
+
this.slots = emptySlots();
|
|
427
|
+
this.activeCount = 1;
|
|
428
|
+
this.notify();
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
const count = Math.min(candidates.length, MAX_SOUND_RULES);
|
|
432
|
+
const usedIds = new Set(candidates.slice(0, count).map(({ rule }) => String(rule.id).trim()));
|
|
433
|
+
const freeIds = allRuleIds().filter((id) => !usedIds.has(id));
|
|
434
|
+
this.slots = [];
|
|
435
|
+
yield Promise.all(candidates.slice(0, count).map((_a, index_1) => __awaiter(this, [_a, index_1], void 0, function* ({ rule, audio }, index) {
|
|
436
|
+
const id = String(rule.id).trim();
|
|
437
|
+
this.slots[index] = createEmptySlot(id);
|
|
438
|
+
this.applyStoredMatch(index, rule);
|
|
439
|
+
if (audio === null || audio === void 0 ? void 0 : audio.buffer) {
|
|
440
|
+
const name = audio.name || `alert-sound-${index}`;
|
|
441
|
+
const file = new File([audio.buffer], name, {
|
|
442
|
+
type: resolveAudioMimeType(name, audio.type),
|
|
443
|
+
});
|
|
444
|
+
yield this.applyFile(index, file, { warnOnUnsupported: false });
|
|
445
|
+
}
|
|
446
|
+
})));
|
|
447
|
+
while (this.slots.length < MAX_SOUND_RULES) {
|
|
448
|
+
this.slots.push(createEmptySlot((_a = freeIds.shift()) !== null && _a !== void 0 ? _a : String(this.slots.length + 1)));
|
|
449
|
+
}
|
|
450
|
+
this.activeCount = count;
|
|
451
|
+
this.notify();
|
|
452
|
+
return this.isReady;
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
/** Sets global playback volume from a 0–100 percent (Sounds tab slider). */
|
|
456
|
+
setVolumePercent(percent) {
|
|
457
|
+
const clamped = Math.max(0, Math.min(VOLUME_PERCENT_MAX, Math.round(percent)));
|
|
458
|
+
// FAST Slider emits `change` on programmatic `:value` writes (mount midpoint →
|
|
459
|
+
// bound percent, and again after restore). Those carry the player's own percent
|
|
460
|
+
// and are dropped here — no pre-restore gate, so real drags / public API calls
|
|
461
|
+
// during restore are not silently discarded.
|
|
462
|
+
if (clamped === this.volumePercent) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
this.volume = clamped / VOLUME_PERCENT_MAX;
|
|
466
|
+
// Moving the slider implies the user wants audible output again.
|
|
467
|
+
if (clamped > 0 && this.muted) {
|
|
468
|
+
this.muted = false;
|
|
469
|
+
}
|
|
470
|
+
this.applyVolumeToSlots();
|
|
471
|
+
this.notify();
|
|
472
|
+
this.flushSettings();
|
|
473
|
+
}
|
|
474
|
+
setMuted(muted) {
|
|
475
|
+
this.muted = Boolean(muted);
|
|
476
|
+
this.applyVolumeToSlots();
|
|
477
|
+
this.notify();
|
|
478
|
+
this.flushSettings();
|
|
479
|
+
}
|
|
480
|
+
toggleMute() {
|
|
481
|
+
this.setMuted(!this.muted);
|
|
482
|
+
}
|
|
483
|
+
addRule() {
|
|
484
|
+
if (!this.canAddRule) {
|
|
485
|
+
return false;
|
|
486
|
+
}
|
|
487
|
+
this.activeCount += 1;
|
|
488
|
+
this.notify();
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
removeRule(index) {
|
|
492
|
+
if (!this.isValidIndex(index)) {
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
// Keep at least one rule row — reset it to blank instead of deleting.
|
|
496
|
+
if (!this.canRemoveRule) {
|
|
497
|
+
this.resetRule(index);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
const removedId = this.slots[index].id;
|
|
501
|
+
this.revokeObjectUrl(index);
|
|
502
|
+
this.slots.splice(index, 1);
|
|
503
|
+
// Recycle the sticky id onto the new blank tail slot
|
|
504
|
+
this.slots.push(createEmptySlot(removedId));
|
|
505
|
+
this.activeCount -= 1;
|
|
506
|
+
this.notify();
|
|
507
|
+
this.flushPersist();
|
|
508
|
+
this.queuePersist(() => __awaiter(this, void 0, void 0, function* () {
|
|
509
|
+
try {
|
|
510
|
+
yield idbDelete(audioKey(removedId));
|
|
511
|
+
}
|
|
512
|
+
catch (error) {
|
|
513
|
+
logger.error('Failed to delete alert sound audio for removed rule', error);
|
|
514
|
+
}
|
|
515
|
+
}));
|
|
516
|
+
}
|
|
517
|
+
/** Clears match criteria and sound file for a rule without removing the row. */
|
|
518
|
+
resetRule(index) {
|
|
519
|
+
if (!this.isValidIndex(index)) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const { id } = this.slots[index];
|
|
523
|
+
this.revokeObjectUrl(index);
|
|
524
|
+
this.slots[index] = createEmptySlot(id);
|
|
525
|
+
this.notify();
|
|
526
|
+
this.flushAudioPersist(index);
|
|
527
|
+
}
|
|
528
|
+
setMatchMode(index, matchMode) {
|
|
529
|
+
if (!this.isValidIndex(index)) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
this.slots[index].matchMode = normalizeMatchMode(matchMode);
|
|
533
|
+
this.notify();
|
|
534
|
+
this.flushPersist();
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Deliberately does not notify: the value came from the pattern field itself,
|
|
538
|
+
* so re-rendering the rule rows mid-keystroke would only disturb the caret.
|
|
539
|
+
*/
|
|
540
|
+
setPattern(index, pattern) {
|
|
541
|
+
if (!this.isValidIndex(index)) {
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
const mode = this.slots[index].matchMode;
|
|
545
|
+
if (mode !== 'message' && mode !== 'header') {
|
|
546
|
+
this.slots[index].matchMode = 'message';
|
|
547
|
+
}
|
|
548
|
+
this.slots[index].pattern = pattern !== null && pattern !== void 0 ? pattern : '';
|
|
549
|
+
this.schedulePersist();
|
|
550
|
+
}
|
|
551
|
+
setRule(index, rule) {
|
|
552
|
+
var _a, _b;
|
|
553
|
+
if (!this.isValidIndex(index)) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
this.slots[index].matchMode = 'rule';
|
|
557
|
+
this.slots[index].templateId = (_a = rule === null || rule === void 0 ? void 0 : rule.id) !== null && _a !== void 0 ? _a : null;
|
|
558
|
+
this.slots[index].templateName = (_b = rule === null || rule === void 0 ? void 0 : rule.name) !== null && _b !== void 0 ? _b : null;
|
|
559
|
+
this.notify();
|
|
560
|
+
this.flushPersist();
|
|
561
|
+
}
|
|
562
|
+
setSoundFromFile(index, file) {
|
|
563
|
+
if (!this.isValidIndex(index) || !file) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (file.size > MAX_SOUND_FILE_BYTES) {
|
|
567
|
+
notifyPlaybackIssue('Sound file too large', `${file.name} is ${formatBytes(file.size)}. The limit is ${formatBytes(MAX_SOUND_FILE_BYTES)} — try a shorter clip.`);
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
void this.applyFile(index, file, { warnOnUnsupported: true }).then(() => {
|
|
571
|
+
this.notify();
|
|
572
|
+
this.flushAudioPersist(index);
|
|
573
|
+
});
|
|
574
|
+
}
|
|
575
|
+
clearSound(index) {
|
|
576
|
+
if (!this.isValidIndex(index)) {
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
this.revokeObjectUrl(index);
|
|
580
|
+
this.slots[index].audio = null;
|
|
581
|
+
this.slots[index].blob = null;
|
|
582
|
+
this.slots[index].fileName = null;
|
|
583
|
+
this.slots[index].mimeType = 'audio/mpeg';
|
|
584
|
+
this.notify();
|
|
585
|
+
this.flushAudioPersist(index);
|
|
586
|
+
}
|
|
587
|
+
setRuleMuted(index, muted) {
|
|
588
|
+
if (!this.isValidIndex(index)) {
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
this.slots[index].muted = Boolean(muted);
|
|
592
|
+
this.notify();
|
|
593
|
+
this.flushPersist();
|
|
594
|
+
}
|
|
595
|
+
toggleRuleMute(index) {
|
|
596
|
+
if (!this.isValidIndex(index)) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
this.setRuleMuted(index, !this.slots[index].muted);
|
|
600
|
+
}
|
|
601
|
+
/** Plays the sound file for a specific rule (for UI testing). */
|
|
602
|
+
playRule(index) {
|
|
603
|
+
var _a, _b, _c;
|
|
604
|
+
if (!this.isValidIndex(index)) {
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
const slot = this.slots[index];
|
|
608
|
+
const audio = slot.audio;
|
|
609
|
+
if (!(audio === null || audio === void 0 ? void 0 : audio.src)) {
|
|
610
|
+
return false;
|
|
611
|
+
}
|
|
612
|
+
if (!browserCanPlayMime(slot.mimeType)) {
|
|
613
|
+
const formatHint = (_c = (_b = (_a = slot.fileName) === null || _a === void 0 ? void 0 : _a.match(/\.[^.]+$/)) === null || _b === void 0 ? void 0 : _b[0]) !== null && _c !== void 0 ? _c : slot.mimeType;
|
|
614
|
+
notifyPlaybackIssue('Sound format not supported', `This browser cannot play ${formatHint}. Try MP3 or WAV.`);
|
|
615
|
+
return false;
|
|
616
|
+
}
|
|
617
|
+
audio.currentTime = 0;
|
|
618
|
+
this.applyVolumeToAudio(audio);
|
|
619
|
+
void audio.play().catch((error) => {
|
|
620
|
+
logger.warn('Alert sound playback blocked or failed', error);
|
|
621
|
+
notifyPlaybackIssue('Sound playback failed', 'The browser could not play this file. Try MP3 or WAV.');
|
|
622
|
+
});
|
|
623
|
+
return true;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Plays the first configured rule that matches the alert:
|
|
627
|
+
* - message: MESSAGE contains the pattern as a whole phrase (case-insensitive)
|
|
628
|
+
* - header: HEADER contains the pattern as a whole phrase (case-insensitive)
|
|
629
|
+
* - rule: alert DYNAMIC_RULE_ID equals the selected id
|
|
630
|
+
*/
|
|
631
|
+
playForAlert(alert) {
|
|
632
|
+
var _a, _b, _c, _d, _e;
|
|
633
|
+
const message = (_a = alert.message) !== null && _a !== void 0 ? _a : '';
|
|
634
|
+
const header = (_b = alert.header) !== null && _b !== void 0 ? _b : '';
|
|
635
|
+
const dynamicRuleId = ((_c = alert.dynamicRuleId) !== null && _c !== void 0 ? _c : '').trim();
|
|
636
|
+
for (let index = 0; index < this.activeCount; index += 1) {
|
|
637
|
+
const slot = this.slots[index];
|
|
638
|
+
if (!((_d = slot.audio) === null || _d === void 0 ? void 0 : _d.src) || slot.muted) {
|
|
639
|
+
continue;
|
|
640
|
+
}
|
|
641
|
+
let matched;
|
|
642
|
+
if (slot.matchMode === 'rule') {
|
|
643
|
+
const expected = (_e = slot.templateId) === null || _e === void 0 ? void 0 : _e.trim();
|
|
644
|
+
matched = Boolean(expected && dynamicRuleId && dynamicRuleId === expected);
|
|
645
|
+
}
|
|
646
|
+
else {
|
|
647
|
+
const pattern = slot.pattern.trim();
|
|
648
|
+
if (!pattern) {
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
matched =
|
|
652
|
+
slot.matchMode === 'header'
|
|
653
|
+
? textContainsWholeWord(header, pattern)
|
|
654
|
+
: textContainsWholeWord(message, pattern);
|
|
655
|
+
}
|
|
656
|
+
// A matching-but-unplayable rule (e.g. an unsupported format) shouldn't stop
|
|
657
|
+
// the scan — a later rule that also matches may still have a playable file.
|
|
658
|
+
if (matched && this.playRule(index)) {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Caches a server-backed rule sound (from NOTIFY_ALERT_SOUND) for playback.
|
|
666
|
+
* Replaces any previous cache entry for the same dynamicRuleId.
|
|
667
|
+
*/
|
|
668
|
+
setServerSound(dynamicRuleId, options) {
|
|
669
|
+
var _a;
|
|
670
|
+
const id = dynamicRuleId === null || dynamicRuleId === void 0 ? void 0 : dynamicRuleId.trim();
|
|
671
|
+
if (!id || !options.blob) {
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
this.revokeServerSound(id);
|
|
675
|
+
const mimeType = resolveAudioMimeType(options.fileName, options.mimeType || options.blob.type);
|
|
676
|
+
const blob = options.blob.type === mimeType ? options.blob : new Blob([options.blob], { type: mimeType });
|
|
677
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
678
|
+
const audio = new Audio(objectUrl);
|
|
679
|
+
audio.preload = 'auto';
|
|
680
|
+
this.applyVolumeToAudio(audio);
|
|
681
|
+
this.serverSounds.set(id, {
|
|
682
|
+
objectUrl,
|
|
683
|
+
audio,
|
|
684
|
+
mimeType,
|
|
685
|
+
fileName: (_a = options.fileName) !== null && _a !== void 0 ? _a : null,
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
/** True when a server sound for this rule is already cached. */
|
|
689
|
+
hasServerSound(dynamicRuleId) {
|
|
690
|
+
return this.serverSounds.has(dynamicRuleId === null || dynamicRuleId === void 0 ? void 0 : dynamicRuleId.trim());
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Drops a cached server sound so the next alert re-fetches from
|
|
694
|
+
* NOTIFY_ALERT_SOUND (e.g. after replace/clear on the rule dialog).
|
|
695
|
+
*/
|
|
696
|
+
clearServerSound(dynamicRuleId) {
|
|
697
|
+
this.revokeServerSound(dynamicRuleId === null || dynamicRuleId === void 0 ? void 0 : dynamicRuleId.trim());
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Clears every cached server-backed rule sound. Used when a template sound
|
|
701
|
+
* changes — subscribed instance ids are unknown on the client, and alerts
|
|
702
|
+
* look up by those instance ids.
|
|
703
|
+
*/
|
|
704
|
+
clearAllServerSounds() {
|
|
705
|
+
for (const id of [...this.serverSounds.keys()]) {
|
|
706
|
+
this.revokeServerSound(id);
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Plays a cached server sound for the given DYNAMIC_RULE_ID.
|
|
711
|
+
* Respects global mute / volume. Returns false if missing or muted with no play.
|
|
712
|
+
*/
|
|
713
|
+
playServerSound(dynamicRuleId) {
|
|
714
|
+
var _a;
|
|
715
|
+
if (this.muted) {
|
|
716
|
+
return false;
|
|
717
|
+
}
|
|
718
|
+
const entry = this.serverSounds.get(dynamicRuleId === null || dynamicRuleId === void 0 ? void 0 : dynamicRuleId.trim());
|
|
719
|
+
if (!((_a = entry === null || entry === void 0 ? void 0 : entry.audio) === null || _a === void 0 ? void 0 : _a.src)) {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
if (!browserCanPlayMime(entry.mimeType)) {
|
|
723
|
+
notifyPlaybackIssue('Sound format not supported', `This browser cannot play the rule alert sound. Try MP3 or WAV.`);
|
|
724
|
+
return false;
|
|
725
|
+
}
|
|
726
|
+
entry.audio.currentTime = 0;
|
|
727
|
+
this.applyVolumeToAudio(entry.audio);
|
|
728
|
+
void entry.audio.play().catch((error) => {
|
|
729
|
+
logger.warn('Server alert sound playback blocked or failed', error);
|
|
730
|
+
notifyPlaybackIssue('Sound playback failed', 'The browser could not play this rule alert sound. Try MP3 or WAV.');
|
|
731
|
+
});
|
|
732
|
+
return true;
|
|
733
|
+
}
|
|
734
|
+
revokeServerSound(dynamicRuleId) {
|
|
735
|
+
if (!dynamicRuleId) {
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const entry = this.serverSounds.get(dynamicRuleId);
|
|
739
|
+
if (!entry) {
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
entry.audio.pause();
|
|
743
|
+
URL.revokeObjectURL(entry.objectUrl);
|
|
744
|
+
this.serverSounds.delete(dynamicRuleId);
|
|
745
|
+
}
|
|
746
|
+
applyStoredMatch(index, rule) {
|
|
747
|
+
var _a, _b, _c, _d;
|
|
748
|
+
if ((_a = rule === null || rule === void 0 ? void 0 : rule.id) === null || _a === void 0 ? void 0 : _a.trim()) {
|
|
749
|
+
this.slots[index].id = rule.id.trim();
|
|
750
|
+
}
|
|
751
|
+
this.slots[index].matchMode = normalizeMatchMode(rule === null || rule === void 0 ? void 0 : rule.matchMode);
|
|
752
|
+
this.slots[index].pattern = (_b = rule === null || rule === void 0 ? void 0 : rule.pattern) !== null && _b !== void 0 ? _b : '';
|
|
753
|
+
this.slots[index].templateId = (_c = rule === null || rule === void 0 ? void 0 : rule.templateId) !== null && _c !== void 0 ? _c : null;
|
|
754
|
+
this.slots[index].templateName = (_d = rule === null || rule === void 0 ? void 0 : rule.templateName) !== null && _d !== void 0 ? _d : null;
|
|
755
|
+
this.slots[index].muted = Boolean(rule === null || rule === void 0 ? void 0 : rule.muted);
|
|
756
|
+
}
|
|
757
|
+
matchFields(slot) {
|
|
758
|
+
var _a, _b;
|
|
759
|
+
return {
|
|
760
|
+
id: slot.id,
|
|
761
|
+
matchMode: slot.matchMode,
|
|
762
|
+
pattern: slot.pattern,
|
|
763
|
+
templateId: (_a = slot.templateId) !== null && _a !== void 0 ? _a : undefined,
|
|
764
|
+
templateName: (_b = slot.templateName) !== null && _b !== void 0 ? _b : undefined,
|
|
765
|
+
muted: slot.muted || undefined,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
/** Schedules a debounced config write (pattern typing). */
|
|
769
|
+
schedulePersist() {
|
|
770
|
+
this.debouncedPersistConfig();
|
|
771
|
+
}
|
|
772
|
+
/** Writes config now, including anything a pending debounce had not yet flushed. */
|
|
773
|
+
flushPersist() {
|
|
774
|
+
this.debouncedPersistConfig();
|
|
775
|
+
this.debouncedPersistConfig.flush();
|
|
776
|
+
}
|
|
777
|
+
flushSettings() {
|
|
778
|
+
this.queuePersist(() => this.persistSettings());
|
|
779
|
+
}
|
|
780
|
+
/** Flushes config plus this rule's audio (sound file added/cleared/reset). */
|
|
781
|
+
flushAudioPersist(index) {
|
|
782
|
+
this.flushPersist();
|
|
783
|
+
const slot = this.slots[index];
|
|
784
|
+
if (!(slot === null || slot === void 0 ? void 0 : slot.id)) {
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
// Capture at schedule time — persistChain may drain after a splice, and
|
|
788
|
+
// resolving `this.slots[index]` then would retarget the wrong rule.
|
|
789
|
+
const snapshot = {
|
|
790
|
+
id: slot.id,
|
|
791
|
+
blob: slot.blob,
|
|
792
|
+
fileName: slot.fileName,
|
|
793
|
+
mimeType: slot.mimeType,
|
|
794
|
+
};
|
|
795
|
+
this.queuePersist(() => this.persistAudio(snapshot));
|
|
796
|
+
}
|
|
797
|
+
/** Chained so overlapping writes cannot land out of order. */
|
|
798
|
+
queuePersist(task) {
|
|
799
|
+
this.persistChain = this.persistChain.then(() => task());
|
|
800
|
+
}
|
|
801
|
+
applyStoredSettings(settings) {
|
|
802
|
+
if (!settings) {
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
if (typeof settings.volume === 'number' && Number.isFinite(settings.volume)) {
|
|
806
|
+
this.volume = Math.max(0, Math.min(DEFAULT_VOLUME, settings.volume));
|
|
807
|
+
}
|
|
808
|
+
if (typeof settings.muted === 'boolean') {
|
|
809
|
+
this.muted = settings.muted;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
applyVolumeToSlots() {
|
|
813
|
+
for (const slot of this.slots) {
|
|
814
|
+
if (slot.audio) {
|
|
815
|
+
this.applyVolumeToAudio(slot.audio);
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
for (const entry of this.serverSounds.values()) {
|
|
819
|
+
this.applyVolumeToAudio(entry.audio);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
applyVolumeToAudio(audio) {
|
|
823
|
+
audio.muted = this.muted;
|
|
824
|
+
audio.volume = this.muted ? 0 : this.volume;
|
|
825
|
+
}
|
|
826
|
+
persistSettings() {
|
|
827
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
828
|
+
try {
|
|
829
|
+
const payload = {
|
|
830
|
+
volume: this.volume,
|
|
831
|
+
muted: this.muted,
|
|
832
|
+
};
|
|
833
|
+
yield idbPut(SETTINGS_KEY, payload);
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
logger.error('Failed to persist alert sound settings', error);
|
|
837
|
+
notifyPlaybackIssue('Could not save volume settings', persistFailureBody(error));
|
|
838
|
+
}
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
/** Match config only — cheap enough to rewrite on every keystroke. */
|
|
842
|
+
persistConfig() {
|
|
843
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
844
|
+
try {
|
|
845
|
+
const payload = this.slots
|
|
846
|
+
.slice(0, this.activeCount)
|
|
847
|
+
.map((slot) => this.matchFields(slot));
|
|
848
|
+
yield idbPut(RULES_KEY, payload);
|
|
849
|
+
}
|
|
850
|
+
catch (error) {
|
|
851
|
+
logger.error('Failed to persist alert sound rules', error);
|
|
852
|
+
notifyPlaybackIssue('Could not save alert sounds', persistFailureBody(error));
|
|
853
|
+
}
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
/** One rule's audio, stored separately so a pattern edit never touches it. */
|
|
857
|
+
persistAudio(snapshot) {
|
|
858
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
859
|
+
try {
|
|
860
|
+
if (snapshot.blob && snapshot.fileName) {
|
|
861
|
+
const payload = {
|
|
862
|
+
name: snapshot.fileName,
|
|
863
|
+
type: snapshot.mimeType,
|
|
864
|
+
buffer: yield snapshot.blob.arrayBuffer(),
|
|
865
|
+
};
|
|
866
|
+
yield idbPut(audioKey(snapshot.id), payload);
|
|
867
|
+
}
|
|
868
|
+
else {
|
|
869
|
+
yield idbDelete(audioKey(snapshot.id));
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch (error) {
|
|
873
|
+
logger.error(`Failed to persist alert sound audio for rule ${snapshot.id}`, error);
|
|
874
|
+
notifyPlaybackIssue('Could not save alert sound', persistFailureBody(error));
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
}
|
|
878
|
+
applyFile(index, file, options) {
|
|
879
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
880
|
+
const mimeType = resolveAudioMimeType(file.name, file.type);
|
|
881
|
+
let blob;
|
|
882
|
+
try {
|
|
883
|
+
// Read bytes once and own them as a Blob — never hold onto the original
|
|
884
|
+
// File handle. A later persist() re-reading it via arrayBuffer() would
|
|
885
|
+
// throw NotFoundError (losing every rule's write, not just this one) if
|
|
886
|
+
// the source file has since moved, been renamed, or been deleted.
|
|
887
|
+
const buffer = yield file.arrayBuffer();
|
|
888
|
+
blob = new Blob([buffer], { type: mimeType });
|
|
889
|
+
}
|
|
890
|
+
catch (error) {
|
|
891
|
+
logger.error(`Failed to read sound file ${file.name}`, error);
|
|
892
|
+
notifyPlaybackIssue('Could not read sound file', `${file.name} could not be read. It may have been moved or deleted.`);
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
this.revokeObjectUrl(index);
|
|
896
|
+
const objectUrl = URL.createObjectURL(blob);
|
|
897
|
+
const audio = new Audio(objectUrl);
|
|
898
|
+
audio.preload = 'auto';
|
|
899
|
+
this.applyVolumeToAudio(audio);
|
|
900
|
+
this.slots[index].objectUrl = objectUrl;
|
|
901
|
+
this.slots[index].audio = audio;
|
|
902
|
+
this.slots[index].blob = blob;
|
|
903
|
+
this.slots[index].fileName = file.name;
|
|
904
|
+
this.slots[index].mimeType = mimeType;
|
|
905
|
+
if (options.warnOnUnsupported && !browserCanPlayMime(mimeType)) {
|
|
906
|
+
notifyPlaybackIssue('Sound format not supported', `${file.name} may not play in this browser. Prefer MP3 or WAV.`);
|
|
907
|
+
}
|
|
908
|
+
});
|
|
909
|
+
}
|
|
910
|
+
isValidIndex(index) {
|
|
911
|
+
return Number.isInteger(index) && index >= 0 && index < this.activeCount;
|
|
912
|
+
}
|
|
913
|
+
notify() {
|
|
914
|
+
this.listeners.forEach((listener) => listener());
|
|
915
|
+
}
|
|
916
|
+
revokeObjectUrl(index) {
|
|
917
|
+
var _a;
|
|
918
|
+
const url = (_a = this.slots[index]) === null || _a === void 0 ? void 0 : _a.objectUrl;
|
|
919
|
+
if (url) {
|
|
920
|
+
URL.revokeObjectURL(url);
|
|
921
|
+
this.slots[index].objectUrl = null;
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
/** Shared player so the layout listener and inbox Sounds tab stay in sync. */
|
|
926
|
+
AlertSoundPlayer.shared = new AlertSoundPlayer();
|
|
927
|
+
/** Reads ALERT_ID from a streamed alert row. */
|
|
928
|
+
export function getAlertId(row) {
|
|
929
|
+
if (!row) {
|
|
930
|
+
return undefined;
|
|
931
|
+
}
|
|
932
|
+
const direct = row.ALERT_ID;
|
|
933
|
+
if (typeof direct === 'string' && direct) {
|
|
934
|
+
return direct;
|
|
935
|
+
}
|
|
936
|
+
const details = row.DETAILS;
|
|
937
|
+
const nested = details === null || details === void 0 ? void 0 : details.ALERT_ID;
|
|
938
|
+
if (typeof nested === 'string' && nested) {
|
|
939
|
+
return nested;
|
|
940
|
+
}
|
|
941
|
+
return undefined;
|
|
942
|
+
}
|
|
943
|
+
/** Reads MESSAGE from a streamed alert row. */
|
|
944
|
+
export function getAlertMessage(row) {
|
|
945
|
+
return readAlertStringField(row, 'MESSAGE');
|
|
946
|
+
}
|
|
947
|
+
/** Reads HEADER from a streamed alert row. */
|
|
948
|
+
export function getAlertHeader(row) {
|
|
949
|
+
return readAlertStringField(row, 'HEADER');
|
|
950
|
+
}
|
|
951
|
+
/** Reads DYNAMIC_RULE_ID from a streamed alert row. */
|
|
952
|
+
export function getAlertDynamicRuleId(row) {
|
|
953
|
+
const value = readAlertStringField(row, 'DYNAMIC_RULE_ID');
|
|
954
|
+
return (value === null || value === void 0 ? void 0 : value.trim()) ? value : undefined;
|
|
955
|
+
}
|