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