Asem75 commited on
Commit
90ce47c
·
verified ·
1 Parent(s): 924868d

Upload 9 files

Browse files
alert_actions.js ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ ساعة المسلم — إجراءات التنبيه: اختيار النغمة، وفتح الإذاعة عند دخول وقت الصلاة (الأذان).
3
+ البيانات في alert_tones_registry.json، والتخزين محلي على الجهاز فقط.
4
+ */
5
+ (function () {
6
+ 'use strict';
7
+ const base = new URL('.', document.currentScript.src);
8
+ let cfg = null, configPromise, ctx = null;
9
+ const node = (tag, text, cls) => { const n = document.createElement(tag); if (text != null) n.textContent = text; if (cls) n.className = cls; return n; };
10
+ const button = (text, action) => { const b = node('button', text); b.type = 'button'; b.addEventListener('click', action); return b; };
11
+ const fmt = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
12
+
13
+ function config() {
14
+ if (!configPromise) configPromise = (async () => {
15
+ const ctl = new AbortController(), t = setTimeout(() => ctl.abort(), 15000);
16
+ try {
17
+ const r = await fetch(new URL('alert_tones_registry.json', base), { cache: 'no-store', signal: ctl.signal });
18
+ if (!r.ok) throw Error('alert_tones_registry.json HTTP ' + r.status);
19
+ const c = await r.json();
20
+ if (c.version !== 1 || !c.storage_key || !c.defaults || !c.ui || !Array.isArray(c.tones)) throw Error('Invalid alert registry');
21
+ cfg = c; return c;
22
+ } finally { clearTimeout(t); }
23
+ })().catch(e => { configPromise = null; throw e; });
24
+ return configPromise;
25
+ }
26
+ const tonesList = () => (cfg?.tones || []).filter(t => t.kind !== 'file' || (t.url && String(t.url).trim()));
27
+ const clamp = (v, a, b) => Math.min(b, Math.max(a, Number.isFinite(v) ? v : a));
28
+ function read() {
29
+ const d = cfg?.defaults || { general: {}, adhan: {} };
30
+ let saved = null; try { saved = JSON.parse(localStorage.getItem(cfg?.storage_key)); } catch { /* ignore */ }
31
+ return {
32
+ general: { ...d.general, ...(saved?.general || {}) },
33
+ adhan: { ...d.adhan, ...(saved?.adhan || {}), prayers: { ...(d.adhan?.prayers || {}), ...(saved?.adhan?.prayers || {}) } }
34
+ };
35
+ }
36
+ function write(state) { try { localStorage.setItem(cfg.storage_key, JSON.stringify(state)); return true; } catch { return false; } }
37
+
38
+ // ---- tones ----
39
+ function audioContext() {
40
+ const AC = window.AudioContext || window.webkitAudioContext; if (!AC) return null;
41
+ if (!ctx) ctx = new AC();
42
+ return ctx;
43
+ }
44
+ document.addEventListener('pointerdown', () => { try { audioContext()?.resume(); } catch { /* ignore */ } }, { passive: true });
45
+ async function playToneById(id, volume) {
46
+ await config().catch(() => null);
47
+ const tone = (cfg?.tones || []).find(t => t.id === id);
48
+ if (!tone || tone.kind === 'legacy') return false;
49
+ const vol = clamp(Number(volume), 0.05, 1);
50
+ if (tone.kind === 'file') {
51
+ try { const a = new Audio(new URL(tone.url, base).href); a.volume = vol; await a.play(); return true; } catch { return false; }
52
+ }
53
+ try {
54
+ const c = audioContext(); if (!c) return false;
55
+ if (c.state !== 'running') await c.resume();
56
+ if (c.state !== 'running') return false;
57
+ const now = c.currentTime;
58
+ for (const n of tone.notes || []) {
59
+ const o = c.createOscillator(), g = c.createGain(), at = now + (n.t || 0), peak = Math.max(0.001, (n.g || 0.4) * vol);
60
+ o.type = 'sine'; o.frequency.value = n.f;
61
+ g.gain.setValueAtTime(0.0001, at); g.gain.exponentialRampToValueAtTime(peak, at + 0.03);
62
+ g.gain.exponentialRampToValueAtTime(0.0001, at + (n.d || 0.8));
63
+ o.connect(g); g.connect(c.destination); o.start(at); o.stop(at + (n.d || 0.8) + 0.05);
64
+ }
65
+ return true;
66
+ } catch { return false; }
67
+ }
68
+ const playTone = async kind => { await config().catch(() => null); const s = read(); const x = kind === 'adhan' ? s.adhan : s.general; return playToneById(x.tone, x.volume); };
69
+ // True only when the person picked something other than the original beep.
70
+ const hasCustomTone = kind => { if (!cfg) return false; const x = read()[kind === 'adhan' ? 'adhan' : 'general']; const t = cfg.tones.find(t => t.id === x.tone); return Boolean(t && t.kind !== 'legacy'); };
71
+
72
+ // ---- adhan ----
73
+ const adhanEnabled = () => Boolean(cfg && read().adhan.enabled);
74
+ const shouldFire = id => read().adhan.prayers[id] !== false;
75
+ const getAdhanStation = () => read().adhan.station || '';
76
+ function setAdhanStation(id) { const s = read(); s.adhan.station = id; return write(s); }
77
+ const panelClosed = () => { const p = document.getElementById('adaptivePopup'); return !p || p.hidden; };
78
+
79
+ async function fire(info, { test = false } = {}) {
80
+ await config().catch(() => null); if (!cfg) return;
81
+ const ui = cfg.ui, s = read().adhan, text = fmt(ui.due_text, { name: info.title, time: info.time || '' });
82
+ let radioResult = null;
83
+ if (s.action === 'radio') {
84
+ if (!window.MuslimClockRadio) radioResult = { ok: false, reason: 'unavailable' };
85
+ else radioResult = await window.MuslimClockRadio.play(s.station, { stopAfterMin: clamp(Number(s.stop_after_minutes), 0, cfg.limits?.stop_after_max_minutes || 180) });
86
+ } else if (s.action === 'tone') await playToneById(s.tone, s.volume);
87
+ try { RegistryApp.notifySystem?.(info.title, text, { kind: 'worship' }); } catch { /* optional */ }
88
+ if (document.hidden || (!panelClosed() && !test)) return;
89
+ const needsHelp = radioResult && !radioResult.ok;
90
+ RegistryApp.openPanel('worship', { title: info.title, text, compact: true }, () => {
91
+ const body = document.getElementById('adaptivePopupBody'), actions = document.getElementById('adaptivePopupActions');
92
+ if (s.action === 'radio') {
93
+ const note = node('p', null); note.setAttribute('role', 'status');
94
+ if (needsHelp) {
95
+ note.textContent = radioResult.reason === 'no_stream' ? ui.radio_external : radioResult.reason === 'blocked' ? ui.radio_blocked : ui.radio_failed;
96
+ body.append(note);
97
+ if (radioResult.reason === 'no_stream' && radioResult.station?.officialUrl) {
98
+ const a = node('a', ui.open_radio_site); a.href = radioResult.station.officialUrl; a.target = '_blank'; a.rel = 'noopener noreferrer'; body.append(a);
99
+ } else actions.append(button(ui.start_radio, () => window.MuslimClockRadio?.play(s.station, { stopAfterMin: Number(s.stop_after_minutes) })));
100
+ } else if (radioResult?.ok) { note.textContent = radioResult.station.name; body.append(note); actions.append(button(ui.stop_radio, () => window.MuslimClockRadio.stop())); }
101
+ actions.append(button(ui.open_radio, () => window.MuslimClockRadio?.open({})));
102
+ }
103
+ });
104
+ }
105
+
106
+ // ---- settings panel ----
107
+ async function openSettings({ back } = {}) {
108
+ let c; try { c = await config(); } catch (e) { return; }
109
+ const ui = c.ui, state = read(), root = node('div', null, 'alert-settings');
110
+ const status = node('p'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite'); status.tabIndex = -1;
111
+ const field = (label, input, host = root) => { const l = node('label', label); l.append(input); host.append(l); return l; };
112
+ const select = (options, value) => { const s = node('select'); for (const [v, t] of options) { const o = node('option', t); o.value = v; s.append(o); } s.value = value; return s; };
113
+ const toneOptions = () => tonesList().map(t => [t.id, t.name]);
114
+
115
+ root.append(node('p', ui.scope, 'quran-meta'), node('h3', ui.adhan_heading));
116
+ const enable = node('input'); enable.type = 'checkbox'; enable.checked = state.adhan.enabled === true;
117
+ const enableLabel = node('label', ui.adhan_enable); enableLabel.prepend(enable); root.append(enableLabel);
118
+ const action = select([['popup', ui.action_popup], ['tone', ui.action_tone], ['radio', ui.action_radio]], state.adhan.action);
119
+ field(ui.adhan_action, action);
120
+ const adhanTone = select(toneOptions().filter(([id]) => id !== 'classic_beep'), state.adhan.tone); const toneLabel = field(ui.tone, adhanTone);
121
+ const adhanVolume = node('input'); adhanVolume.type = 'range'; adhanVolume.min = 0.1; adhanVolume.max = 1; adhanVolume.step = 0.05; adhanVolume.value = state.adhan.volume;
122
+ const volLabel = field(ui.volume, adhanVolume);
123
+ const stationSelect = select([['', ui.pick_station]], state.adhan.station || ''); const stationLabel = field(ui.station, stationSelect);
124
+ const stopAfter = node('input'); stopAfter.type = 'number'; stopAfter.min = 0; stopAfter.max = c.limits?.stop_after_max_minutes || 180; stopAfter.step = 1; stopAfter.inputMode = 'numeric'; stopAfter.value = state.adhan.stop_after_minutes;
125
+ const stopLabel = field(ui.stop_after, stopAfter);
126
+ window.MuslimClockRadio?.stations().then(list => {
127
+ for (const s of list) { const o = node('option', s.name + (s.streamUrl ? '' : ui.no_stream_tag)); o.value = s.id; stationSelect.append(o); }
128
+ stationSelect.value = state.adhan.station || '';
129
+ }).catch(() => {});
130
+ const group = node('fieldset'), legend = node('legend', ui.prayers); group.append(legend);
131
+ const prayerBoxes = (RegistryApp.adhanPrayers?.() || []).map(p => {
132
+ const box = node('input'); box.type = 'checkbox'; box.checked = state.adhan.prayers[p.id] !== false;
133
+ const l = node('label', p.title); l.prepend(box); group.append(l); return { id: p.id, box };
134
+ });
135
+ root.append(group);
136
+ const sync = () => { toneLabel.hidden = volLabel.hidden = action.value !== 'tone'; stationLabel.hidden = stopLabel.hidden = action.value !== 'radio'; };
137
+ action.addEventListener('change', () => { sync(); status.textContent = ''; }); sync();
138
+
139
+ root.append(node('h3', ui.general_heading));
140
+ const generalTone = select(toneOptions(), state.general.tone); field(ui.general_tone, generalTone);
141
+ const generalVolume = node('input'); generalVolume.type = 'range'; generalVolume.min = 0.1; generalVolume.max = 1; generalVolume.step = 0.05; generalVolume.value = state.general.volume; field(ui.volume, generalVolume);
142
+
143
+ const collect = () => {
144
+ const prayers = {}; prayerBoxes.forEach(p => { prayers[p.id] = p.box.checked; });
145
+ return {
146
+ general: { tone: generalTone.value, volume: Number(generalVolume.value) },
147
+ adhan: { enabled: enable.checked, action: action.value, tone: adhanTone.value, station: stationSelect.value, volume: Number(adhanVolume.value),
148
+ stop_after_minutes: clamp(Math.round(Number(stopAfter.value)), 0, c.limits?.stop_after_max_minutes || 180), prayers }
149
+ };
150
+ };
151
+ const say = text => { status.textContent = ''; setTimeout(() => { status.textContent = text; }, 40); };
152
+ root.append(
153
+ button(ui.test + ' — ' + ui.adhan_heading, async () => {
154
+ const draft = collect().adhan;
155
+ if (draft.action === 'tone') { say((await playToneById(draft.tone, draft.volume)) ? ui.test_done : ui.test_failed); return; }
156
+ if (draft.action === 'radio') {
157
+ const r = await window.MuslimClockRadio?.play(draft.station, { stopAfterMin: draft.stop_after_minutes });
158
+ say(!r ? ui.radio_unavailable : r.ok ? ui.test_done : r.reason === 'no_stream' ? ui.radio_external : r.reason === 'blocked' ? ui.radio_blocked : ui.radio_failed); return;
159
+ }
160
+ say(ui.test_done);
161
+ }),
162
+ button(ui.test + ' — ' + ui.general_heading, async () => { say((await playToneById(generalTone.value, generalVolume.value)) ? ui.test_done : ui.test_failed); }),
163
+ button(ui.save, () => { say(write(collect()) ? ui.saved : ui.storage_error); }),
164
+ status);
165
+ RegistryApp.openPanel('worship', { title: ui.title, compact: true }, () => {
166
+ document.getElementById('adaptivePopupBody').append(root);
167
+ if (back) document.getElementById('adaptivePopupActions').append(button(ui.back, back));
168
+ });
169
+ document.getElementById('adaptivePopupCard').dataset.size = 'long';
170
+ }
171
+ async function mountSettingsEntry(parent, back) {
172
+ const entry = button('…', () => {}); entry.disabled = true; parent.append(entry);
173
+ try { const c = await config(); if (!entry.isConnected) return; entry.textContent = c.ui.open; entry.disabled = false; entry.onclick = () => openSettings({ back }); }
174
+ catch (e) { if (entry.isConnected) { entry.textContent = 'alert_tones_registry.json: ' + e.message; entry.disabled = false; entry.onclick = () => { entry.remove(); mountSettingsEntry(parent, back); }; } }
175
+ }
176
+ config().catch(() => null);
177
+ window.MuslimClockAlerts = Object.freeze({ playTone, hasCustomTone, adhanEnabled, shouldFire, fire, getAdhanStation, setAdhanStation, openSettings, mountSettingsEntry, tones: () => tonesList() });
178
+ })();
alert_tones_registry.json ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": 1,
3
+ "storage_key": "muslim_clock_alert_actions_v1",
4
+ "note": "لإضافة نغمة من ملف: ضع ملف mp3/ogg داخل مجلد tones/ ثم اكتب مسارها في url لأحد عناصر kind=file (مثال: tones/adhan1.mp3). النغمات ذات url فارغ لا تظهر للمستخدم.",
5
+ "defaults": {
6
+ "general": {
7
+ "tone": "classic_beep",
8
+ "volume": 0.8
9
+ },
10
+ "adhan": {
11
+ "enabled": false,
12
+ "action": "popup",
13
+ "tone": "soft_bell",
14
+ "station": "",
15
+ "stop_after_minutes": 0,
16
+ "volume": 0.9,
17
+ "prayers": {}
18
+ }
19
+ },
20
+ "limits": {
21
+ "stop_after_max_minutes": 180,
22
+ "adhan_due_window_seconds": 90
23
+ },
24
+ "tones": [
25
+ {
26
+ "id": "classic_beep",
27
+ "name": "النغمة الأساسية (الافتراضية)",
28
+ "kind": "legacy"
29
+ },
30
+ {
31
+ "id": "soft_bell",
32
+ "name": "جرس هادئ",
33
+ "kind": "synth",
34
+ "notes": [
35
+ {
36
+ "f": 880,
37
+ "t": 0,
38
+ "d": 1.6,
39
+ "g": 0.5
40
+ },
41
+ {
42
+ "f": 1318.5,
43
+ "t": 0,
44
+ "d": 1.1,
45
+ "g": 0.22
46
+ }
47
+ ]
48
+ },
49
+ {
50
+ "id": "double_chime",
51
+ "name": "رنين مزدوج",
52
+ "kind": "synth",
53
+ "notes": [
54
+ {
55
+ "f": 659.25,
56
+ "t": 0,
57
+ "d": 0.7,
58
+ "g": 0.5
59
+ },
60
+ {
61
+ "f": 880,
62
+ "t": 0.4,
63
+ "d": 1.0,
64
+ "g": 0.5
65
+ }
66
+ ]
67
+ },
68
+ {
69
+ "id": "rising_three",
70
+ "name": "ثلاث نغمات صاعدة",
71
+ "kind": "synth",
72
+ "notes": [
73
+ {
74
+ "f": 523.25,
75
+ "t": 0,
76
+ "d": 0.7,
77
+ "g": 0.45
78
+ },
79
+ {
80
+ "f": 659.25,
81
+ "t": 0.35,
82
+ "d": 0.7,
83
+ "g": 0.45
84
+ },
85
+ {
86
+ "f": 783.99,
87
+ "t": 0.7,
88
+ "d": 1.1,
89
+ "g": 0.45
90
+ }
91
+ ]
92
+ },
93
+ {
94
+ "id": "calm_low",
95
+ "name": "نغمة منخفضة هادئة",
96
+ "kind": "synth",
97
+ "notes": [
98
+ {
99
+ "f": 392,
100
+ "t": 0,
101
+ "d": 1.8,
102
+ "g": 0.55
103
+ },
104
+ {
105
+ "f": 523.25,
106
+ "t": 0.6,
107
+ "d": 1.8,
108
+ "g": 0.4
109
+ }
110
+ ]
111
+ },
112
+ {
113
+ "id": "soft_pulse",
114
+ "name": "نبضات خفيفة",
115
+ "kind": "synth",
116
+ "notes": [
117
+ {
118
+ "f": 740,
119
+ "t": 0,
120
+ "d": 0.3,
121
+ "g": 0.4
122
+ },
123
+ {
124
+ "f": 740,
125
+ "t": 0.45,
126
+ "d": 0.3,
127
+ "g": 0.4
128
+ },
129
+ {
130
+ "f": 740,
131
+ "t": 0.9,
132
+ "d": 0.3,
133
+ "g": 0.4
134
+ },
135
+ {
136
+ "f": 988,
137
+ "t": 1.35,
138
+ "d": 0.6,
139
+ "g": 0.4
140
+ }
141
+ ]
142
+ },
143
+ {
144
+ "id": "file_1",
145
+ "name": "نغمة من ملف ١",
146
+ "kind": "file",
147
+ "url": ""
148
+ },
149
+ {
150
+ "id": "file_2",
151
+ "name": "نغمة من ملف ٢",
152
+ "kind": "file",
153
+ "url": ""
154
+ },
155
+ {
156
+ "id": "file_3",
157
+ "name": "نغمة من ملف ٣",
158
+ "kind": "file",
159
+ "url": ""
160
+ },
161
+ {
162
+ "id": "file_4",
163
+ "name": "نغمة من ملف ٤",
164
+ "kind": "file",
165
+ "url": ""
166
+ },
167
+ {
168
+ "id": "file_5",
169
+ "name": "نغمة من ملف ٥",
170
+ "kind": "file",
171
+ "url": ""
172
+ }
173
+ ],
174
+ "ui": {
175
+ "title": "التنبيهات: النغمات والإذاعة والأذان",
176
+ "open": "🔔 النغمات وفتح الإذاعة عند الأذان",
177
+ "back": "رجوع",
178
+ "save": "حفظ الإعدادات",
179
+ "saved": "حُفظت الإعدادات.",
180
+ "storage_error": "تعذّر الحفظ على هذا الجهاز.",
181
+ "adhan_heading": "تنبيه دخول وقت الصلاة (الأذان)",
182
+ "adhan_enable": "تفعيل التنبيه عند دخول وقت الصلاة",
183
+ "adhan_action": "ماذا يحدث عند دخول الوقت؟",
184
+ "action_popup": "إشعار فقط (بدون صوت)",
185
+ "action_tone": "تشغيل نغمة",
186
+ "action_radio": "فتح الإذاعة التي اخترتها",
187
+ "tone": "النغمة",
188
+ "station": "الإذاعة",
189
+ "stop_after": "إيقاف الإذاعة تلقائيًا بعد (بالدقائق، 0 = لا تتوقف)",
190
+ "volume": "مستوى صوت النغمة",
191
+ "prayers": "الصلوات التي يعمل لها التنبيه",
192
+ "no_stream_tag": " (بدون بث مباشر: يفتح موقعها فقط)",
193
+ "pick_station": "اختر إذاعة",
194
+ "general_heading": "نغمة التذكيرات والمناسبات العامة",
195
+ "general_tone": "نغمة التذكيرات",
196
+ "test": "تجربة الآن",
197
+ "test_done": "جرى تنفيذ التجربة.",
198
+ "test_failed": "تعذّر تشغيل الصوت. اضغط تجربة مرة أخرى أو تحقق من الصوت.",
199
+ "radio_unavailable": "وحدة الإذاعة غير متاحة.",
200
+ "due_text": "حان وقت {name} ({time}).",
201
+ "radio_blocked": "المتصفح يحتاج ضغطة منك لبدء الإذاعة.",
202
+ "radio_failed": "تعذّر تشغيل الإذاعة المختارة.",
203
+ "radio_external": "هذه الإذاعة لا تملك بثًا مباشرًا مضمونًا؛ افتح موقعها.",
204
+ "start_radio": "تشغيل الإذاعة الآن",
205
+ "open_radio_site": "فتح موقع الإذاعة ↗",
206
+ "stop_radio": "إيقاف الإذاعة",
207
+ "open_radio": "فتح ركن الإذاعات",
208
+ "scope": "الأذان يُحسب من موقعك والصفحة يجب أن تبقى مفتوحة. المتصفح لا يضمن تشغيل الصوت بعد إغلاقه أو عند إطفاء الشاشة."
209
+ }
210
+ }
islamic_radio.css CHANGED
@@ -1,6 +1,28 @@
 
1
  .islamic-radio-section{display:block;width:100%}
2
- .islamic-radio-tabs{display:flex;gap:.5rem;overflow-x:auto;padding:.5rem 0}
3
- .islamic-radio-station{display:grid;grid-template-columns:1fr auto auto;gap:.6rem;align-items:center;padding:.8rem;border-radius:14px;margin-bottom:.5rem}
4
- .islamic-radio-station.is-playing{outline:2px solid currentColor}
5
- .islamic-radio-favorite{border:0;background:transparent;font-size:1.25rem;cursor:pointer}
6
- .islamic-radio-player{position:sticky;bottom:0;z-index:20;display:grid;grid-template-columns:1fr auto auto;gap:.6rem;align-items:center;padding:.7rem;backdrop-filter:blur(12px)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ركن الإذاعات + إعدادات النغمات والأذان — يعتمد على متغيرات الواجهة (--cell-surface, --cell-border) */
2
  .islamic-radio-section{display:block;width:100%}
3
+ #adaptivePopupBody .islamic-radio-list{list-style:none;margin:10px 0;padding:0}
4
+ #adaptivePopupBody .islamic-radio-station{display:grid;grid-template-columns:1fr auto;gap:6px;align-items:stretch;margin:0 0 8px;padding:6px;border:1px solid var(--cell-border,#888);border-radius:14px;background:var(--cell-surface,transparent)}
5
+ #adaptivePopupBody .islamic-radio-station.is-playing{outline:2px solid currentColor;outline-offset:1px}
6
+ #adaptivePopupBody .islamic-radio-station button{width:auto;margin:0;min-height:48px}
7
+ #adaptivePopupBody .islamic-radio-play{display:flex;flex-direction:column;align-items:flex-start;text-align:start;gap:2px;overflow-wrap:anywhere}
8
+ #adaptivePopupBody .islamic-radio-name{font-weight:bold}
9
+ #adaptivePopupBody .islamic-radio-meta{font-size:.82rem;opacity:.8}
10
+ #adaptivePopupBody .islamic-radio-favorite{min-width:48px;font-size:1.3rem;text-align:center;background:transparent}
11
+ #adaptivePopupBody .islamic-radio-alert{grid-column:1/-1;font-size:.9rem;text-align:center}
12
+ #adaptivePopupBody .islamic-radio-alert[aria-pressed="true"]{font-weight:bold;outline:2px solid currentColor;outline-offset:-3px}
13
+ #adaptivePopupBody .islamic-radio-player{position:sticky;top:0;z-index:5;display:grid;grid-template-columns:1fr;gap:6px;padding:10px;margin:8px 0;border:2px solid currentColor;border-radius:14px;background:var(--cell-surface,#222);backdrop-filter:blur(12px)}
14
+ #adaptivePopupBody .islamic-radio-player[hidden]{display:none}
15
+ #adaptivePopupBody .islamic-radio-player p{margin:0}
16
+ #adaptivePopupBody .islamic-radio-status:empty{display:none}
17
+ #adaptivePopupBody .alert-settings h3{margin:18px 0 6px;font-size:1.05rem}
18
+ #adaptivePopupBody .alert-settings fieldset{border:1px solid var(--cell-border,#888);border-radius:12px;margin:10px 0;padding:8px}
19
+ #adaptivePopupBody .alert-settings legend{padding:0 6px}
20
+ #adaptivePopupBody .alert-settings input[type="range"]{width:100%;padding:0;min-height:44px}
21
+ #adaptivePopupBody .alert-settings input[type="checkbox"]{width:auto;display:inline-block;margin-inline-start:12px}
22
+ #adaptivePopupBody .alert-settings button,
23
+ #adaptivePopupBody .alert-settings select,
24
+ #adaptivePopupBody .alert-settings input{min-height:44px}
25
+ #adaptivePopupBody .alert-settings [hidden]{display:none!important}
26
+ #adaptivePopupBody .alert-settings p[tabindex="-1"]:focus{outline:2px solid currentColor;outline-offset:3px}
27
+ #adaptivePopupBody .islamic-radio-section :focus-visible,
28
+ #adaptivePopupBody .alert-settings :focus-visible{outline:2px solid currentColor;outline-offset:2px}
islamic_radio.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /*
2
+ ساعة المسلم — ركن الإذاعات الإسلامية (نسخة تعمل كسكربت عادي، بدون export).
3
+ - مشغل واحد فقط: الإذاعة توقف القرآن والعكس (حدث muslim-clock-audio-start).
4
+ - الصوت يبقى شغالًا بعد إغلاق النافذة لأن عنصر الصوت خارجها.
5
+ - الأذان/التنبيهات تستدعي MuslimClockRadio.play(id) من alert_actions.js.
6
+ */
7
+ (function () {
8
+ 'use strict';
9
+ const base = new URL('.', document.currentScript.src);
10
+ let configPromise, audio = null, current = null, state = 'idle', loadTimer = null, stopTimer = null;
11
+ const listeners = new Set();
12
+ const node = (tag, text, cls) => { const n = document.createElement(tag); if (text != null) n.textContent = text; if (cls) n.className = cls; return n; };
13
+ const button = (text, action) => { const b = node('button', text); b.type = 'button'; b.addEventListener('click', action); return b; };
14
+ const fmt = (s, v) => String(s).replace(/\{(\w+)\}/g, (_, k) => String(v[k] ?? ''));
15
+
16
+ async function config() {
17
+ if (!configPromise) configPromise = (async () => {
18
+ const ctl = new AbortController(), t = setTimeout(() => ctl.abort(), 15000);
19
+ try {
20
+ const r = await fetch(new URL('islamic_radio_registry.json', base), { cache: 'no-store', signal: ctl.signal });
21
+ if (!r.ok) throw Error('islamic_radio_registry.json HTTP ' + r.status);
22
+ const c = await r.json();
23
+ if (c.version !== 1 || !c.ui || !Array.isArray(c.stations) || !c.stations.length) throw Error('Invalid radio registry');
24
+ for (const s of c.stations) if (!s.id || !s.name || !s.category) throw Error('Invalid station');
25
+ return c;
26
+ } finally { clearTimeout(t); }
27
+ })().catch(e => { configPromise = null; throw e; });
28
+ return configPromise;
29
+ }
30
+ const readFavs = key => { try { return new Set(JSON.parse(localStorage.getItem(key) || '[]')); } catch { return new Set(); } };
31
+ const writeFavs = (key, set) => { try { localStorage.setItem(key, JSON.stringify([...set])); return true; } catch { return false; } };
32
+ const emit = () => { for (const fn of [...listeners]) { try { fn(snapshot()); } catch { /* listener error */ } } };
33
+ const snapshot = () => ({ station: current, state });
34
+ const clearTimers = () => { clearTimeout(loadTimer); loadTimer = null; };
35
+
36
+ function ensureAudio() {
37
+ if (audio) return audio;
38
+ audio = document.createElement('audio');
39
+ audio.preload = 'none'; audio.setAttribute('playsinline', ''); audio.hidden = true;
40
+ // No crossOrigin: most radio streams send no CORS headers and would fail with it.
41
+ document.body.appendChild(audio);
42
+ const on = (name, fn) => audio.addEventListener(name, () => { if (current) fn(); });
43
+ on('playing', () => { clearTimers(); state = 'playing'; emit(); });
44
+ on('waiting', () => { if (state !== 'paused') { state = 'loading'; emit(); } });
45
+ on('pause', () => { if (state === 'playing' || state === 'loading') { state = 'paused'; emit(); } });
46
+ on('error', () => { clearTimers(); state = 'error'; emit(); });
47
+ return audio;
48
+ }
49
+ function releaseAudio() {
50
+ clearTimers(); clearTimeout(stopTimer); stopTimer = null;
51
+ current = null;
52
+ if (audio) { try { audio.pause(); } catch { /* ignore */ } audio.removeAttribute('src'); try { audio.load(); } catch { /* ignore */ } }
53
+ }
54
+ function mediaSession(c, station) {
55
+ if (!('mediaSession' in navigator) || typeof MediaMetadata === 'undefined') return;
56
+ try { navigator.mediaSession.metadata = new MediaMetadata({ title: station.name, artist: c.ui.media_artist, album: c.ui.media_album }); } catch { /* ignore */ }
57
+ for (const [name, fn] of [['play', () => resume()], ['pause', () => pause()], ['stop', () => stop()]]) {
58
+ try { navigator.mediaSession.setActionHandler(name, fn); } catch { /* unsupported action */ }
59
+ }
60
+ }
61
+ function streamOf(station) {
62
+ try { const u = new URL(station.streamUrl); return u.protocol === 'https:' ? u.href : null; } catch { return null; }
63
+ }
64
+ // opts.stopAfterMin: stop automatically (used by the adhan alert).
65
+ async function play(id, opts = {}) {
66
+ const c = await config();
67
+ const station = c.stations.find(s => s.id === id);
68
+ if (!station) return { ok: false, reason: 'missing' };
69
+ const url = station.streamUrl && streamOf(station);
70
+ if (!url) return { ok: false, reason: 'no_stream', station };
71
+ window.dispatchEvent(new CustomEvent('muslim-clock-audio-start', { detail: { source: 'radio' } }));
72
+ releaseAudio();
73
+ const a = ensureAudio();
74
+ current = station; state = 'loading'; a.src = url; mediaSession(c, station); emit();
75
+ loadTimer = setTimeout(() => { if (state === 'loading') { state = 'timeout'; emit(); } }, c.limits?.load_timeout_ms || 20000);
76
+ try {
77
+ await a.play();
78
+ const minutes = Number(opts.stopAfterMin);
79
+ if (minutes > 0) stopTimer = setTimeout(() => stop(), minutes * 60000);
80
+ return { ok: true, station };
81
+ } catch (error) {
82
+ clearTimers();
83
+ state = error?.name === 'NotAllowedError' ? 'blocked' : 'error'; emit();
84
+ return { ok: false, reason: state, station };
85
+ }
86
+ }
87
+ function stop() { const had = Boolean(current); releaseAudio(); state = had ? 'stopped' : 'idle'; emit(); }
88
+ function pause() { if (audio && current) { audio.pause(); state = 'paused'; emit(); } }
89
+ async function resume() {
90
+ if (!audio || !current) return false;
91
+ try { await audio.play(); return true; } catch { state = 'blocked'; emit(); return false; }
92
+ }
93
+ window.addEventListener('muslim-clock-audio-start', e => { if (e.detail?.source !== 'radio' && current) stop(); });
94
+
95
+ function panel(title, root, back, ui) {
96
+ RegistryApp.openPanel('worship', { title, compact: true }, () => {
97
+ document.getElementById('adaptivePopupBody').append(root);
98
+ if (back) document.getElementById('adaptivePopupActions').append(button(ui.back, back));
99
+ });
100
+ document.getElementById('adaptivePopupCard').dataset.size = 'long';
101
+ }
102
+
103
+ async function open(options = {}) {
104
+ let c;
105
+ try { c = await config(); }
106
+ catch (error) {
107
+ const root = node('div', 'islamic_radio_registry.json: ' + error.message);
108
+ root.append(button('↻', () => open(options)));
109
+ panel('islamic_radio_registry.json', root, options.back, { back: '←' });
110
+ return;
111
+ }
112
+ const ui = c.ui, root = node('div', null, 'islamic-radio-section');
113
+ const status = node('p', null, 'islamic-radio-status'); status.setAttribute('role', 'status'); status.setAttribute('aria-live', 'polite');
114
+ const nowBox = node('section', null, 'islamic-radio-player'); nowBox.setAttribute('aria-label', ui.title);
115
+ const nowText = node('p', null, 'islamic-radio-now');
116
+ const toggle = button(ui.pause, () => { state === 'paused' || state === 'blocked' ? resume() : pause(); });
117
+ const stopBtn = button(ui.stop, () => stop());
118
+ nowBox.append(nowText, toggle, stopBtn);
119
+ const categoryLabel = node('label', ui.category), category = node('select');
120
+ for (const [value, text] of [['', ui.all], ...Object.entries(c.categories || {})]) { const o = node('option', text); o.value = value; category.append(o); }
121
+ categoryLabel.append(category);
122
+ const listHost = node('ul', null, 'islamic-radio-list'); listHost.setAttribute('aria-label', ui.list_label);
123
+ const alertsApi = window.MuslimClockAlerts;
124
+ const favKey = c.storage?.favorites_key || 'muslim_clock_radio_favorites_v1';
125
+ let favs = readFavs(favKey);
126
+
127
+ function stateText() {
128
+ const name = current?.name || '';
129
+ switch (state) {
130
+ case 'loading': return ui.state_loading;
131
+ case 'playing': return fmt(ui.state_playing, { name });
132
+ case 'paused': return ui.state_paused;
133
+ case 'stopped': return ui.state_stopped;
134
+ case 'blocked': return ui.state_blocked;
135
+ case 'error': return ui.state_error;
136
+ case 'timeout': return ui.state_timeout;
137
+ default: return ui.idle;
138
+ }
139
+ }
140
+ function refresh() {
141
+ const text = stateText();
142
+ if (nowText.textContent !== text) { nowText.textContent = text; status.textContent = text; }
143
+ const active = Boolean(current) && state !== 'stopped';
144
+ nowBox.hidden = !active; toggle.hidden = stopBtn.hidden = !active;
145
+ toggle.textContent = ['paused', 'blocked'].includes(state) ? ui.resume : ui.pause;
146
+ for (const row of listHost.querySelectorAll('li')) {
147
+ const on = active && current.id === row.dataset.id && ['playing', 'loading'].includes(state);
148
+ row.classList.toggle('is-playing', on);
149
+ const b = row.querySelector('.islamic-radio-play');
150
+ if (b && !b.dataset.external) b.setAttribute('aria-label', (on ? ui.playing_row + ' — ' : ui.play + ' ') + row.dataset.name);
151
+ }
152
+ }
153
+ function paintList(focusId) {
154
+ favs = readFavs(favKey);
155
+ const chosen = alertsApi?.getAdhanStation?.();
156
+ const rows = c.stations.filter(s => !category.value || s.category === category.value)
157
+ .sort((a, b) => Number(favs.has(b.id)) - Number(favs.has(a.id)));
158
+ listHost.replaceChildren();
159
+ for (const s of rows) {
160
+ const li = node('li', null, 'islamic-radio-station'); li.dataset.id = s.id; li.dataset.name = s.name;
161
+ const hasStream = Boolean(s.streamUrl && streamOf(s));
162
+ const play = node('button', null, 'islamic-radio-play'); play.type = 'button';
163
+ play.append(node('span', s.name, 'islamic-radio-name'));
164
+ if (hasStream) {
165
+ play.append(node('span', c.categories?.[s.category] || '', 'islamic-radio-meta'));
166
+ play.setAttribute('aria-label', ui.play + ' ' + s.name);
167
+ play.addEventListener('click', async () => { const r = await play_(s.id); if (!r.ok && r.reason === 'no_stream') window.open(s.officialUrl, '_blank', 'noopener'); });
168
+ } else {
169
+ play.append(node('span', ui.no_stream_hint, 'islamic-radio-meta'));
170
+ play.dataset.external = '1'; play.setAttribute('aria-label', ui.external + ' — ' + s.name + ' — ' + ui.no_stream_hint);
171
+ play.addEventListener('click', () => { if (s.officialUrl) window.open(s.officialUrl, '_blank', 'noopener'); });
172
+ }
173
+ const pin = node('button', favs.has(s.id) ? '⭐' : '☆', 'islamic-radio-favorite'); pin.type = 'button'; pin.id = 'radio-pin-' + s.id;
174
+ pin.setAttribute('aria-pressed', String(favs.has(s.id)));
175
+ pin.setAttribute('aria-label', fmt(favs.has(s.id) ? ui.unpin : ui.pin, { name: s.name }));
176
+ pin.addEventListener('click', () => {
177
+ const set = readFavs(favKey), was = set.has(s.id); was ? set.delete(s.id) : set.add(s.id);
178
+ if (!writeFavs(favKey, set)) { status.textContent = ui.storage_error; return; }
179
+ paintList(s.id); status.textContent = fmt(was ? ui.unpinned : ui.pinned, { name: s.name });
180
+ });
181
+ li.append(play, pin);
182
+ if (alertsApi?.setAdhanStation) {
183
+ const isChosen = chosen === s.id;
184
+ const mark = node('button', isChosen ? ui.alert_station_selected : ui.alert_station, 'islamic-radio-alert'); mark.type = 'button';
185
+ mark.setAttribute('aria-pressed', String(isChosen)); mark.setAttribute('aria-label', ui.alert_station + ' — ' + s.name + (isChosen ? ' — ✓' : ''));
186
+ mark.addEventListener('click', () => { alertsApi.setAdhanStation(s.id); paintList(s.id); status.textContent = fmt(ui.alert_station_set, { name: s.name }); });
187
+ li.append(mark);
188
+ }
189
+ listHost.append(li);
190
+ }
191
+ refresh();
192
+ if (focusId) document.getElementById('radio-pin-' + focusId)?.focus();
193
+ }
194
+ const play_ = id => play(id);
195
+ category.addEventListener('change', () => paintList());
196
+ root.append(node('p', ui.pinned_note, 'quran-meta'), node('p', ui.single_note, 'quran-meta'), status, nowBox, categoryLabel, listHost, node('p', ui.background_note, 'quran-meta'));
197
+ if (alertsApi?.openSettings) root.append(button(ui.alert_settings, () => alertsApi.openSettings({ back: () => open(options) })));
198
+ panel(ui.title, root, options.back, ui);
199
+ const off = fn => { listeners.delete(fn); };
200
+ const listener = () => { if (!root.isConnected) { off(listener); return; } refresh(); };
201
+ listeners.add(listener);
202
+ window.addEventListener('muslim-clock-popup-leave', () => off(listener), { once: true });
203
+ paintList();
204
+ }
205
+
206
+ async function mountEntry(parent, back) {
207
+ const entry = button('…', () => {}); entry.disabled = true; entry.dataset.radioEntry = '1'; parent.append(entry);
208
+ try {
209
+ const c = await config(); if (!entry.isConnected) return;
210
+ entry.textContent = c.ui.open; entry.disabled = false; entry.onclick = () => open({ back });
211
+ } catch (error) {
212
+ if (!entry.isConnected) return;
213
+ entry.textContent = 'islamic_radio_registry.json: ' + error.message; entry.disabled = false;
214
+ entry.onclick = () => { entry.remove(); mountEntry(parent, back); };
215
+ }
216
+ }
217
+ window.MuslimClockRadio = Object.freeze({
218
+ open, mountEntry, play, stop, pause, resume, snapshot,
219
+ stations: async () => (await config()).stations,
220
+ onChange: fn => { listeners.add(fn); return () => listeners.delete(fn); }
221
+ });
222
+ })();
islamic_radio_registry.json CHANGED
@@ -64,5 +64,58 @@
64
  "officialUrl": "https://radioyaqeen.com/en",
65
  "streamUrl": ""
66
  }
67
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  }
 
64
  "officialUrl": "https://radioyaqeen.com/en",
65
  "streamUrl": ""
66
  }
67
+ ],
68
+ "limits": {
69
+ "load_timeout_ms": 20000
70
+ },
71
+ "ui": {
72
+ "title": "الإذاعات الإسلامية",
73
+ "open": "📻 الإذاعات الإسلامية",
74
+ "loading": "جارٍ تحميل الإذاعات…",
75
+ "load_error": "تعذّر تحميل قائمة الإذاعات.",
76
+ "retry": "إعادة المحاولة",
77
+ "back": "رجوع",
78
+ "category": "التصنيف",
79
+ "all": "كل الإذاعات",
80
+ "list_label": "قائمة الإذاعات",
81
+ "pinned_note": "الإذاعات المثبتة ⭐ تظهر أولًا. التثبيت محفوظ على هذا الجهاز فقط.",
82
+ "single_note": "تشغيل الإذاعة يوقف التلاوة القرآنية، وتشغيل التلاوة يوقف الإذاعة.",
83
+ "background_note": "قد يتوقف الصوت عند إطفاء الشاشة أو إغلاق المتصفح؛ هذا يعتمد على المتصفح أو التطبيق.",
84
+ "play": "تشغيل",
85
+ "pause": "إيقاف مؤقت",
86
+ "resume": "استكمال",
87
+ "stop": "إيقاف الإذاعة",
88
+ "playing_row": "يعمل الآن",
89
+ "external": "فتح موقع الإذاعة ↗",
90
+ "no_stream_hint": "لا يتوفر بث مباشر مضمون؛ يفتح الموقع الرسمي",
91
+ "now_playing": "الإذاعة الحالية: {name}",
92
+ "idle": "لا توجد إذاعة قيد التشغيل.",
93
+ "state_loading": "جارٍ الاتصال بالإذاعة…",
94
+ "state_playing": "تُبثّ الآن: {name}",
95
+ "state_paused": "الإذاعة متوقفة مؤقتًا.",
96
+ "state_stopped": "توقفت الإذاعة.",
97
+ "state_blocked": "اضغط تشغيل لبدء الصوت؛ المتصفح يحتاج ضغطة منك.",
98
+ "state_error": "تعذّر تشغيل البث. جرّب إذاعة أخرى أو افتح موقع الإذاعة.",
99
+ "state_timeout": "تأخر الاتصال بالإذاعة. جرّب إذاعة أخرى أو افتح موقع الإذاعة.",
100
+ "pin": "تثبيت {name}",
101
+ "unpin": "إلغاء تثبيت {name}",
102
+ "pinned": "ثُبّتت {name}.",
103
+ "unpinned": "أُلغي تثبيت {name}.",
104
+ "alert_station": "اجعلها إذاعة الأذان",
105
+ "alert_station_selected": "إذاعة الأذان ✓",
106
+ "alert_station_set": "أصبحت {name} إذاعة تنبيه الأذان.",
107
+ "alert_settings": "إعدادات التنبيه: النغمات وفتح الإذاعة وقت الأذان",
108
+ "storage_error": "تعذّر الحفظ على هذا الجهاز.",
109
+ "media_artist": "ساعة المسلم",
110
+ "media_album": "الإذاعات الإسلامية"
111
+ },
112
+ "categories": {
113
+ "quran": "القرآن الكريم",
114
+ "seerah_history": "السيرة والتاريخ",
115
+ "faith_hadith_history": "الحديث والمحتوى الإيماني",
116
+ "other": "أخرى"
117
+ },
118
+ "storage": {
119
+ "favorites_key": "muslim_clock_radio_favorites_v1"
120
+ }
121
  }
quran_audio.js CHANGED
@@ -12,6 +12,32 @@
12
  const node = element('button', label); node.type = 'button'; node.addEventListener('click', action); return node;
13
  };
14
  const interpolate = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
  async function configuration() {
16
  if (!configPromise) configPromise = (async () => {
17
  const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 15000);
@@ -21,8 +47,9 @@
21
  const c = await response.json();
22
  if (c.schema_version !== 1 || !Array.isArray(c.reciters) || !c.reciters.length || !c.ui || !c.defaults ||
23
  !Array.isArray(c.rates) || !c.repeat_options?.length || !c.limits?.loading_timeout_ms) throw Error('Invalid audio registry');
24
- for (const r of c.reciters) if (!r.id || !r.name || !/^[A-Za-z0-9_.-]+$/.test(r.folder)) throw Error('Invalid reciter');
25
  if (!c.reciters.some(r => r.id === c.defaults.reciter)) throw Error('Missing default reciter');
 
26
  for (const key of ['title','options','reciter','rate','repeat','advance','play','pause','resume','stop','previous','next','seek',
27
  'current','idle','loading','playing','paused','stopped','ended','error','blocked','storage_error','source','play_continuous']) {
28
  if (typeof c.ui[key] !== 'string') throw Error('Missing audio UI: ' + key);
@@ -76,6 +103,8 @@
76
  if (disposed) return;
77
  const activeMedia = media, request = ++playRequest;
78
  wantsPlay = true; phase = 'loading'; watchLoading(); emit();
 
 
79
  try { await activeMedia.play(); }
80
  catch (error) {
81
  if (!disposed && activeMedia === media && request === playRequest && wantsPlay) fail(error.name === 'NotAllowedError' ? 'blocked' : 'error');
@@ -131,6 +160,8 @@
131
  });
132
  try {activeMedia.src = urlFor(verses[index]); startMedia();} catch {fail();}
133
  }
 
 
134
  function select(id) {
135
  if (disposed || !byId.has(id) || verses[index].id === id) return;
136
  detach(); index = byId.get(id); phase = 'idle'; currentTime = 0; duration = 0; emit();
@@ -174,7 +205,7 @@
174
  playContinuous: () => {configure({advance:true,repeat:1});playAt(verses[index].id);},
175
  previous: () => {if (index > 0) playAt(verses[index - 1].id);},
176
  next: () => {if (index < lastIndex) playAt(verses[index + 1].id);},
177
- dispose: () => {disposed = true;detach(true);}};
178
  }
179
  async function mount(host, {verses, surahs, initialId, endId, onState, isActive = () => host.isConnected}) {
180
  const c = await configuration();
 
12
  const node = element('button', label); node.type = 'button'; node.addEventListener('click', action); return node;
13
  };
14
  const interpolate = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
15
+ // Optional: add reciters published in EveryAyah's catalog. Never blocks or breaks the built-in list.
16
+ async function mergeCatalog(c) {
17
+ const key = c.storage_key + '_catalog', ttl = (c.source.catalog_ttl_hours || 168) * 3600000;
18
+ let rows = null;
19
+ try {const cached = JSON.parse(localStorage.getItem(key)); if (cached && Date.now() - cached.at < ttl && Array.isArray(cached.rows)) rows = cached.rows;} catch { /* optional */ }
20
+ if (!rows && c.source.catalog_url) {
21
+ const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 6000);
22
+ try {
23
+ const response = await fetch(c.source.catalog_url, {signal:controller.signal});
24
+ if (response.ok) {
25
+ const raw = JSON.parse(await response.text());
26
+ rows = Object.values(raw).filter(v => v && typeof v === 'object' && typeof v.subfolder === 'string' && /^[A-Za-z0-9_.\/-]+$/.test(v.subfolder))
27
+ .map(v => ({name: String(v.name || v.subfolder), folder: v.subfolder}));
28
+ try {localStorage.setItem(key, JSON.stringify({at: Date.now(), rows}));} catch { /* optional */ }
29
+ }
30
+ } catch { /* offline or blocked by CORS: keep the built-in list */ }
31
+ finally {clearTimeout(timer);}
32
+ }
33
+ if (!rows) return;
34
+ const known = new Set(c.reciters.map(r => r.folder));
35
+ for (const row of rows) {
36
+ if (known.has(row.folder)) continue;
37
+ known.add(row.folder);
38
+ c.reciters.push({id: 'ea_' + row.folder.replace(/[^A-Za-z0-9]/g, '_'), name: row.name + ' ' + (c.ui.catalog_added || ''), folder: row.folder});
39
+ }
40
+ }
41
  async function configuration() {
42
  if (!configPromise) configPromise = (async () => {
43
  const controller = new AbortController(), timer = setTimeout(() => controller.abort(), 15000);
 
47
  const c = await response.json();
48
  if (c.schema_version !== 1 || !Array.isArray(c.reciters) || !c.reciters.length || !c.ui || !c.defaults ||
49
  !Array.isArray(c.rates) || !c.repeat_options?.length || !c.limits?.loading_timeout_ms) throw Error('Invalid audio registry');
50
+ for (const r of c.reciters) if (!r.id || !r.name || !/^[A-Za-z0-9_.\/-]+$/.test(r.folder)) throw Error('Invalid reciter');
51
  if (!c.reciters.some(r => r.id === c.defaults.reciter)) throw Error('Missing default reciter');
52
+ await mergeCatalog(c);
53
  for (const key of ['title','options','reciter','rate','repeat','advance','play','pause','resume','stop','previous','next','seek',
54
  'current','idle','loading','playing','paused','stopped','ended','error','blocked','storage_error','source','play_continuous']) {
55
  if (typeof c.ui[key] !== 'string') throw Error('Missing audio UI: ' + key);
 
103
  if (disposed) return;
104
  const activeMedia = media, request = ++playRequest;
105
  wantsPlay = true; phase = 'loading'; watchLoading(); emit();
106
+ // One audio source at a time: tell the radio to stop.
107
+ try {window.dispatchEvent(new CustomEvent('muslim-clock-audio-start', {detail:{source:'quran'}}));} catch { /* ignore */ }
108
  try { await activeMedia.play(); }
109
  catch (error) {
110
  if (!disposed && activeMedia === media && request === playRequest && wantsPlay) fail(error.name === 'NotAllowedError' ? 'blocked' : 'error');
 
160
  });
161
  try {activeMedia.src = urlFor(verses[index]); startMedia();} catch {fail();}
162
  }
163
+ const onOtherSource = event => {if (event.detail?.source !== 'quran' && (wantsPlay || pending)) pause();};
164
+ window.addEventListener('muslim-clock-audio-start', onOtherSource);
165
  function select(id) {
166
  if (disposed || !byId.has(id) || verses[index].id === id) return;
167
  detach(); index = byId.get(id); phase = 'idle'; currentTime = 0; duration = 0; emit();
 
205
  playContinuous: () => {configure({advance:true,repeat:1});playAt(verses[index].id);},
206
  previous: () => {if (index > 0) playAt(verses[index - 1].id);},
207
  next: () => {if (index < lastIndex) playAt(verses[index + 1].id);},
208
+ dispose: () => {disposed = true;window.removeEventListener('muslim-clock-audio-start', onOtherSource);detach(true);}};
209
  }
210
  async function mount(host, {verses, surahs, initialId, endId, onState, isActive = () => host.isConnected}) {
211
  const c = await configuration();
quran_audio_registry.json CHANGED
@@ -8,7 +8,9 @@
8
  "url_pattern": "https://everyayah.com/data/{folder}/{surah3}{ayah3}.mp3",
9
  "checked_on": "2026-09-29",
10
  "delivery": "remote_stream_on_user_play",
11
- "note": "Verse filenames use three digits for the surah and three for the ayah. Recordings are linked, not redistributed or licensed as part of AL-Khatma."
 
 
12
  },
13
  "reciters": [
14
  {
@@ -16,20 +18,85 @@
16
  "name": "محمود خليل الحصري",
17
  "folder": "Husary_128kbps"
18
  },
 
 
 
 
 
 
 
 
 
 
19
  {
20
  "id": "minshawy",
21
- "name": "محمد صديق المنشاوي",
22
  "folder": "Minshawy_Murattal_128kbps"
23
  },
 
 
 
 
 
 
 
 
 
 
24
  {
25
  "id": "abdulbasit",
26
  "name": "عبد الباسط عبد الصمد — مرتل",
27
  "folder": "Abdul_Basit_Murattal_192kbps"
28
  },
29
  {
30
- "id": "sudais",
31
- "name": "عبد الرحمن السديس",
32
- "folder": "Abdurrahmaan_As-Sudais_192kbps"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
  },
34
  {
35
  "id": "shuraym",
@@ -37,19 +104,134 @@
37
  "folder": "Saood_ash-Shuraym_128kbps"
38
  },
39
  {
40
- "id": "maher",
41
- "name": "ماهر المعيقلي",
42
- "folder": "MaherAlMuaiqly128kbps"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  },
44
  {
45
  "id": "hudhaify",
46
  "name": "علي الحذيفي",
47
  "folder": "Hudhaify_128kbps"
48
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  {
50
  "id": "dussary",
51
  "name": "ياسر الدوسري",
52
  "folder": "Yasser_Ad-Dussary_128kbps"
 
 
 
 
 
 
 
 
 
 
53
  }
54
  ],
55
  "defaults": {
@@ -117,7 +299,9 @@
117
  "blocked": "اضغط تشغيل لاستكمال التلاوة؛ المتصفح يحتاج ضغطة منك.",
118
  "storage_error": "تعذّر حفظ تفضيلات الصوت على هذا الجهاز.",
119
  "source": "مصدر التلاوة: EveryAyah",
120
- "gap": "الفاصل بين الآيات"
 
 
121
  },
122
  "gap_options": [
123
  {
 
8
  "url_pattern": "https://everyayah.com/data/{folder}/{surah3}{ayah3}.mp3",
9
  "checked_on": "2026-09-29",
10
  "delivery": "remote_stream_on_user_play",
11
+ "note": "Verse filenames use three digits for the surah and three for the ayah. Recordings are linked, not redistributed or licensed as part of AL-Khatma.",
12
+ "catalog_ttl_hours": 168,
13
+ "catalog_note": "القائمة مأخوذة من فهرس EveryAyah. شغّل tools/verify_reciters.py للتحقق من وجود كل مجلد وحذف ما لا يعمل."
14
  },
15
  "reciters": [
16
  {
 
18
  "name": "محمود خليل الحصري",
19
  "folder": "Husary_128kbps"
20
  },
21
+ {
22
+ "id": "husary_mujawwad",
23
+ "name": "محمود خليل الحصري — مجوّد",
24
+ "folder": "Husary_Mujawwad_128kbps"
25
+ },
26
+ {
27
+ "id": "husary_muallim",
28
+ "name": "محمود خليل الحصري — المعلّم",
29
+ "folder": "Husary_Muallim_128kbps"
30
+ },
31
  {
32
  "id": "minshawy",
33
+ "name": "محمد صديق المنشاوي — مرتل",
34
  "folder": "Minshawy_Murattal_128kbps"
35
  },
36
+ {
37
+ "id": "minshawy_mujawwad",
38
+ "name": "محمد صديق المنشاوي — مجوّد",
39
+ "folder": "Minshawy_Mujawwad_192kbps"
40
+ },
41
+ {
42
+ "id": "minshawy_teacher",
43
+ "name": "محمد صديق المنشاوي — المعلّم",
44
+ "folder": "Minshawy_Teacher_128kbps"
45
+ },
46
  {
47
  "id": "abdulbasit",
48
  "name": "عبد الباسط عبد الصمد — مرتل",
49
  "folder": "Abdul_Basit_Murattal_192kbps"
50
  },
51
  {
52
+ "id": "abdulbasit_mujawwad",
53
+ "name": "عبد الباسط عبد الصمد — مجوّد",
54
+ "folder": "Abdul_Basit_Mujawwad_128kbps"
55
+ },
56
+ {
57
+ "id": "shatri",
58
+ "name": "أبو بكر الشاطري",
59
+ "folder": "Abu_Bakr_Ash-Shaatree_128kbps"
60
+ },
61
+ {
62
+ "id": "ajamy",
63
+ "name": "أحمد بن علي العجمي",
64
+ "folder": "Ahmed_ibn_Ali_al-Ajamy_128kbps_ketaballah.net"
65
+ },
66
+ {
67
+ "id": "ahmed_neana_128kbps",
68
+ "name": "أحمد نعينع",
69
+ "folder": "Ahmed_Neana_128kbps"
70
+ },
71
+ {
72
+ "id": "sowaid",
73
+ "name": "أيمن سويد",
74
+ "folder": "Ayman_Sowaid_64kbps"
75
+ },
76
+ {
77
+ "id": "akhdar",
78
+ "name": "إبراهيم الأخضر",
79
+ "folder": "Ibrahim_Akhdar_32kbps"
80
+ },
81
+ {
82
+ "id": "warsh_dosary",
83
+ "name": "إبراهيم الدوسري — رواية ورش",
84
+ "folder": "warsh/warsh_ibrahim_aldosary_128kbps"
85
+ },
86
+ {
87
+ "id": "khaalid_abdullaah_al_qahtaanee_192kbps",
88
+ "name": "خالد عبد الله القحطاني",
89
+ "folder": "Khaalid_Abdullaah_al-Qahtaanee_192kbps"
90
+ },
91
+ {
92
+ "id": "tunaiji",
93
+ "name": "خليفة الطنيجي",
94
+ "folder": "Khalefa_Al_Tunaiji_64kbps"
95
+ },
96
+ {
97
+ "id": "ghamadi",
98
+ "name": "سعد الغامدي",
99
+ "folder": "Ghamadi_40kbps"
100
  },
101
  {
102
  "id": "shuraym",
 
104
  "folder": "Saood_ash-Shuraym_128kbps"
105
  },
106
  {
107
+ "id": "sahl",
108
+ "name": "سهل ياسين",
109
+ "folder": "Sahl_Yassin_128kbps"
110
+ },
111
+ {
112
+ "id": "budair",
113
+ "name": "صلاح البدير",
114
+ "folder": "Salah_Al_Budair_128kbps"
115
+ },
116
+ {
117
+ "id": "bukhatir",
118
+ "name": "صلاح بوخاطر",
119
+ "folder": "Salaah_AbdulRahman_Bukhatir_128kbps"
120
+ },
121
+ {
122
+ "id": "sudais",
123
+ "name": "عبد الرحمن السديس",
124
+ "folder": "Abdurrahmaan_As-Sudais_192kbps"
125
+ },
126
+ {
127
+ "id": "basfar",
128
+ "name": "عبد الله بصفر",
129
+ "folder": "Abdullah_Basfar_192kbps"
130
+ },
131
+ {
132
+ "id": "juhany",
133
+ "name": "عبد الله عواد الجهني",
134
+ "folder": "Abdullaah_3awwaad_Al-Juhaynee_128kbps"
135
+ },
136
+ {
137
+ "id": "matroud",
138
+ "name": "عبد الله مطرود",
139
+ "folder": "Abdullah_Matroud_128kbps"
140
  },
141
  {
142
  "id": "hudhaify",
143
  "name": "علي الحذيفي",
144
  "folder": "Hudhaify_128kbps"
145
  },
146
+ {
147
+ "id": "ali_jaber",
148
+ "name": "علي جابر",
149
+ "folder": "Ali_Jaber_64kbps"
150
+ },
151
+ {
152
+ "id": "hajjaj",
153
+ "name": "علي حجاج السويسي",
154
+ "folder": "Ali_Hajjaj_AlSuesy_128kbps"
155
+ },
156
+ {
157
+ "id": "fares",
158
+ "name": "فارس عباد",
159
+ "folder": "Fares_Abbad_64kbps"
160
+ },
161
+ {
162
+ "id": "karim_mansoori_40kbps",
163
+ "name": "كريم منصوري",
164
+ "folder": "Karim_Mansoori_40kbps"
165
+ },
166
+ {
167
+ "id": "maher",
168
+ "name": "ماهر المعيقلي",
169
+ "folder": "MaherAlMuaiqly128kbps"
170
+ },
171
+ {
172
+ "id": "qasim",
173
+ "name": "محسن القاسم",
174
+ "folder": "Muhsin_Al_Qasim_192kbps"
175
+ },
176
+ {
177
+ "id": "ayyoub",
178
+ "name": "محمد أيوب",
179
+ "folder": "Muhammad_Ayyoub_128kbps"
180
+ },
181
+ {
182
+ "id": "tablawi",
183
+ "name": "محمد الطبلاوي",
184
+ "folder": "Mohammad_al_Tablaway_128kbps"
185
+ },
186
+ {
187
+ "id": "jibreel",
188
+ "name": "محمد جبريل",
189
+ "folder": "Muhammad_Jibreel_128kbps"
190
+ },
191
+ {
192
+ "id": "muhammad_abdulkareem_128kbps",
193
+ "name": "محمد عبد الكريم",
194
+ "folder": "Muhammad_AbdulKareem_128kbps"
195
+ },
196
+ {
197
+ "id": "alafasy",
198
+ "name": "مشاري العفاسي",
199
+ "folder": "Alafasy_128kbps"
200
+ },
201
+ {
202
+ "id": "mustafa_ismail",
203
+ "name": "مصطفى إسماعيل",
204
+ "folder": "Mustafa_Ismail_48kbps"
205
+ },
206
+ {
207
+ "id": "qatami",
208
+ "name": "ناصر القطامي",
209
+ "folder": "Nasser_Alqatami_128kbps"
210
+ },
211
+ {
212
+ "id": "nabil",
213
+ "name": "نبيل الرفاعي",
214
+ "folder": "Nabil_Rifa3i_48kbps"
215
+ },
216
+ {
217
+ "id": "rifai",
218
+ "name": "هاني الرفاعي",
219
+ "folder": "Hani_Rifai_192kbps"
220
+ },
221
  {
222
  "id": "dussary",
223
  "name": "ياسر الدوسري",
224
  "folder": "Yasser_Ad-Dussary_128kbps"
225
+ },
226
+ {
227
+ "id": "yaser_salamah_128kbps",
228
+ "name": "ياسر سلامة",
229
+ "folder": "Yaser_Salamah_128kbps"
230
+ },
231
+ {
232
+ "id": "warsh_yassin",
233
+ "name": "ياسين الجزائري — رواية ورش",
234
+ "folder": "warsh/warsh_yassin_al_jazaery_64kbps"
235
  }
236
  ],
237
  "defaults": {
 
299
  "blocked": "اضغط تشغيل لاستكمال التلاوة؛ المتصفح يحتاج ضغطة منك.",
300
  "storage_error": "تعذّر حفظ تفضيلات الصوت على هذا الجهاز.",
301
  "source": "مصدر التلاوة: EveryAyah",
302
+ "gap": "الفاصل بين الآيات",
303
+ "catalog_added": "(من فهرس EveryAyah)",
304
+ "reciter_missing": "تعذّر تشغيل هذا القارئ لهذه الآية؛ جرّب قارئًا آخر."
305
  },
306
  "gap_options": [
307
  {
quran_reader_registry.json CHANGED
@@ -116,8 +116,38 @@
116
  },
117
  "tafsirs": [
118
  {
119
- "id": "quran_com_tafsir",
120
- "name": "التفسير (Quran.com) — يفتح على هذه الآية",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
121
  "url": "https://quran.com/{surah}:{ayah}/tafsirs"
122
  }
123
  ],
 
116
  },
117
  "tafsirs": [
118
  {
119
+ "id": "tabari",
120
+ "name": "تفسير الطبري (جامع البيان)",
121
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafsir-al-tabari"
122
+ },
123
+ {
124
+ "id": "ibn_kathir",
125
+ "name": "تفسير ابن كثير",
126
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafsir-ibn-kathir"
127
+ },
128
+ {
129
+ "id": "saadi",
130
+ "name": "تفسير السعدي (تيسير الكريم الرحمن)",
131
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafseer-al-saddi"
132
+ },
133
+ {
134
+ "id": "muyassar",
135
+ "name": "التفسير الميسر",
136
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafsir-muyassar"
137
+ },
138
+ {
139
+ "id": "qurtubi",
140
+ "name": "تفسير القرطبي",
141
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafseer-al-qurtubi"
142
+ },
143
+ {
144
+ "id": "baghawi",
145
+ "name": "تفسير البغوي (معالم التنزيل)",
146
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs/ar-tafsir-al-baghawi"
147
+ },
148
+ {
149
+ "id": "all",
150
+ "name": "كل التفاسير لهذه الآية (Quran.com)",
151
  "url": "https://quran.com/{surah}:{ayah}/tafsirs"
152
  }
153
  ],
registry_app.js CHANGED
@@ -50,7 +50,11 @@ const RegistryApp = (() => {
50
  function close(){ window.dispatchEvent(new Event('muslim-clock-popup-leave')); $('adaptivePopup').hidden=true;document.body.style.overflow='';lastFocus?.focus(); }
51
  function back(fn){$('adaptivePopupActions').appendChild(button(ui.back,fn));}
52
  function home(kind){show(kind,{title:screen(kind).title,text:ui.catalogue},()=>{
53
- screen(kind).groups.forEach(group=>$('adaptivePopupBody').appendChild(button(ui.groupTitles[group],()=>list(kind,group))));
 
 
 
 
54
  });}
55
  function records(group){
56
  switch(group){
@@ -522,7 +526,7 @@ const RegistryApp = (() => {
522
  function tickSolar(){
523
  if(lastSolarDay!==dateKey(Date.now())){recalculate();return;}
524
  const now=Date.now();updateCurrentMarker(now);
525
- tickNotices(now);
526
  const status=$('solarStatus');
527
  if(!observer){status.textContent=ui.chooseLocation;return;}
528
  if(!profile()){status.textContent=ui.missingSolar;return;}
@@ -559,6 +563,18 @@ const RegistryApp = (() => {
559
  timedNotices.sort((a,b)=>a.at-b.at||b.priority-a.priority);
560
  for(const [key,pending] of pendingTimedNotices)if(!timedNotices.some(n=>n.key===key&&n.at===pending.at))pendingTimedNotices.delete(key);
561
  }
 
 
 
 
 
 
 
 
 
 
 
 
562
  function tickNotices(now){
563
  const runtime=profile()?.notice_runtime;if(!runtime)return;
564
  const ledger=getSaved('clock_popup_impressions_v1',{});
@@ -581,7 +597,7 @@ const RegistryApp = (() => {
581
 
582
  let personal=null,personalReady=false,reminderAudio=null,notificationRegistration=null,personalNoticeKeys=new Set();
583
  async function enableReminderSound(){try{const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return false;if(!reminderAudio)reminderAudio=new Audio();await reminderAudio.resume();return reminderAudio.state==='running';}catch{return false;}}
584
- async function playReminderSound(){if(!reminderAudio||!visual.reminder_audio)return false;try{if(reminderAudio.state!=='running')await reminderAudio.resume();if(reminderAudio.state!=='running')return false;const a=visual.reminder_audio,o=reminderAudio.createOscillator(),g=reminderAudio.createGain();o.frequency.value=a.frequency_hz;g.gain.value=a.gain;o.connect(g);g.connect(reminderAudio.destination);o.start();g.gain.exponentialRampToValueAtTime(0.001,reminderAudio.currentTime+a.duration_seconds);o.stop(reminderAudio.currentTime+a.duration_seconds);return true;}catch{return false;}}
585
  async function initNotifications(){
586
  if(window.isSecureContext!==true||!('serviceWorker' in navigator))return null;
587
  try{notificationRegistration=await navigator.serviceWorker.register('./sw.js');navigator.serviceWorker.addEventListener('message',e=>openNotificationData(e.data));return notificationRegistration;}catch{return null;}
@@ -691,6 +707,7 @@ const RegistryApp = (() => {
691
  }
692
  function reminderSettings(){const s=reminderAction(()=>personal.read());if(!s)return;show('worship',{title:ru().settings,text:ru().scope},()=>{
693
  window.MuslimClockQuran?.mountSettingsEntry($('adaptivePopupBody'),reminderSettings);
 
694
  const sound=reminderField(ru().sound,'checkbox','');sound.checked=s.settings.sound_enabled===true;
695
  $('adaptivePopupBody').appendChild(button(ru().enableAlerts,async()=>{const result=await requestAlertPermission();if(result.permission==='denied')para(ru().notificationDenied);else if(result.sound&&result.permission==='granted')para(ru().alertsReady);else if(result.permission==='unsupported')para(ru().notificationUnavailable);else para(ru().alertsPartlyReady);}));
696
  $('adaptivePopupBody').appendChild(button(ru().testSound,async()=>{if(await enableReminderSound())playReminderSound();else para(ru().soundUnavailable);}));para(ru().closedBrowserNote);
@@ -806,7 +823,7 @@ const RegistryApp = (() => {
806
  }
807
  });
808
  }
809
- return {openPanel:show,start,home,close,qiblaSettings,celestialRegistry:()=>registry('sky_simulation'),records,detail,boundaryInfo,ruleText,remindersHome,reminderChoose,reminderForm,reminderDetail,reminderSettings,prayerEntries,indicatorState,twelveHourLabel,hourDetails,hourSettings,periodDetails,recalculate,configureLocation,tickPersonal,calendarText,applyPrayerMethod,solarState:()=>({observer,madhhab,solarDays,solarPeriods}),resolveLegacy(id){return registry('religious_content').legacy_rule_map.find(x=>x.legacy_id===id);}};
810
  })();
811
  window.PopupManager={open:(kind)=>RegistryApp.home(kind),close:()=>RegistryApp.close()};
812
  window.registryReady=RegistryApp.start();
 
50
  function close(){ window.dispatchEvent(new Event('muslim-clock-popup-leave')); $('adaptivePopup').hidden=true;document.body.style.overflow='';lastFocus?.focus(); }
51
  function back(fn){$('adaptivePopupActions').appendChild(button(ui.back,fn));}
52
  function home(kind){show(kind,{title:screen(kind).title,text:ui.catalogue},()=>{
53
+ screen(kind).groups.forEach(group=>{
54
+ $('adaptivePopupBody').appendChild(button(ui.groupTitles[group],()=>list(kind,group)));
55
+ // Radio corner sits right after the Quran corner, opened the same way.
56
+ if(group==='quran'&&window.MuslimClockRadio)window.MuslimClockRadio.mountEntry($('adaptivePopupBody'),()=>home(kind));
57
+ });
58
  });}
59
  function records(group){
60
  switch(group){
 
526
  function tickSolar(){
527
  if(lastSolarDay!==dateKey(Date.now())){recalculate();return;}
528
  const now=Date.now();updateCurrentMarker(now);
529
+ tickNotices(now);tickAdhan(now);
530
  const status=$('solarStatus');
531
  if(!observer){status.textContent=ui.chooseLocation;return;}
532
  if(!profile()){status.textContent=ui.missingSolar;return;}
 
563
  timedNotices.sort((a,b)=>a.at-b.at||b.priority-a.priority);
564
  for(const [key,pending] of pendingTimedNotices)if(!timedNotices.some(n=>n.key===key&&n.at===pending.at))pendingTimedNotices.delete(key);
565
  }
566
+ // Prayer-time (adhan) alert: tone or the user's chosen radio station. Settings live in alert_actions.js.
567
+ function tickAdhan(now){
568
+ const A=window.MuslimClockAlerts;if(!A||!A.adhanEnabled())return;
569
+ const ledger=getSaved('clock_adhan_fired_v1',{});let changed=false;
570
+ for(const p of prayerEntries()){
571
+ if(now<p.start||now-p.start>90000||!A.shouldFire(p.id))continue;
572
+ const key=p.id+':'+p.start;if(ledger[key])continue;
573
+ ledger[key]=now;changed=true;A.fire({id:p.id,title:ruleTitle(p.rule),time:timeText(p.start)});
574
+ }
575
+ if(changed){for(const k of Object.keys(ledger))if(now-ledger[k]>86400000)delete ledger[k];save('clock_adhan_fired_v1',ledger);}
576
+ }
577
+ function adhanPrayers(){const ids=visual?.runtime_styles?.prayer_entry_markers?.rule_ids||[];return ids.map(id=>({id,title:ruleTitle(rule(id)||{id})}));}
578
  function tickNotices(now){
579
  const runtime=profile()?.notice_runtime;if(!runtime)return;
580
  const ledger=getSaved('clock_popup_impressions_v1',{});
 
597
 
598
  let personal=null,personalReady=false,reminderAudio=null,notificationRegistration=null,personalNoticeKeys=new Set();
599
  async function enableReminderSound(){try{const Audio=window.AudioContext||window.webkitAudioContext;if(!Audio)return false;if(!reminderAudio)reminderAudio=new Audio();await reminderAudio.resume();return reminderAudio.state==='running';}catch{return false;}}
600
+ async function playReminderSound(){try{if(window.MuslimClockAlerts?.hasCustomTone('general')&&await window.MuslimClockAlerts.playTone('general'))return true;}catch{}if(!reminderAudio||!visual.reminder_audio)return false;try{if(reminderAudio.state!=='running')await reminderAudio.resume();if(reminderAudio.state!=='running')return false;const a=visual.reminder_audio,o=reminderAudio.createOscillator(),g=reminderAudio.createGain();o.frequency.value=a.frequency_hz;g.gain.value=a.gain;o.connect(g);g.connect(reminderAudio.destination);o.start();g.gain.exponentialRampToValueAtTime(0.001,reminderAudio.currentTime+a.duration_seconds);o.stop(reminderAudio.currentTime+a.duration_seconds);return true;}catch{return false;}}
601
  async function initNotifications(){
602
  if(window.isSecureContext!==true||!('serviceWorker' in navigator))return null;
603
  try{notificationRegistration=await navigator.serviceWorker.register('./sw.js');navigator.serviceWorker.addEventListener('message',e=>openNotificationData(e.data));return notificationRegistration;}catch{return null;}
 
707
  }
708
  function reminderSettings(){const s=reminderAction(()=>personal.read());if(!s)return;show('worship',{title:ru().settings,text:ru().scope},()=>{
709
  window.MuslimClockQuran?.mountSettingsEntry($('adaptivePopupBody'),reminderSettings);
710
+ window.MuslimClockAlerts?.mountSettingsEntry($('adaptivePopupBody'),reminderSettings);
711
  const sound=reminderField(ru().sound,'checkbox','');sound.checked=s.settings.sound_enabled===true;
712
  $('adaptivePopupBody').appendChild(button(ru().enableAlerts,async()=>{const result=await requestAlertPermission();if(result.permission==='denied')para(ru().notificationDenied);else if(result.sound&&result.permission==='granted')para(ru().alertsReady);else if(result.permission==='unsupported')para(ru().notificationUnavailable);else para(ru().alertsPartlyReady);}));
713
  $('adaptivePopupBody').appendChild(button(ru().testSound,async()=>{if(await enableReminderSound())playReminderSound();else para(ru().soundUnavailable);}));para(ru().closedBrowserNote);
 
823
  }
824
  });
825
  }
826
+ return {openPanel:show,start,home,close,notifySystem,adhanPrayers,ruleTitle,qiblaSettings,celestialRegistry:()=>registry('sky_simulation'),records,detail,boundaryInfo,ruleText,remindersHome,reminderChoose,reminderForm,reminderDetail,reminderSettings,prayerEntries,indicatorState,twelveHourLabel,hourDetails,hourSettings,periodDetails,recalculate,configureLocation,tickPersonal,calendarText,applyPrayerMethod,solarState:()=>({observer,madhhab,solarDays,solarPeriods}),resolveLegacy(id){return registry('religious_content').legacy_rule_map.find(x=>x.legacy_id===id);}};
827
  })();
828
  window.PopupManager={open:(kind)=>RegistryApp.home(kind),close:()=>RegistryApp.close()};
829
  window.registryReady=RegistryApp.start();