Spaces:
Running
Running
Upload 9 files
Browse files- alert_actions.js +178 -0
- alert_tones_registry.json +210 -0
- islamic_radio.css +27 -5
- islamic_radio.js +222 -0
- islamic_radio_registry.json +54 -1
- quran_audio.js +33 -2
- quran_audio_registry.json +193 -9
- quran_reader_registry.json +32 -2
- registry_app.js +21 -4
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-
|
| 3 |
-
.islamic-radio-station{display:grid;grid-template-columns:1fr auto
|
| 4 |
-
.islamic-radio-station.is-playing{outline:2px solid currentColor}
|
| 5 |
-
.islamic-radio-
|
| 6 |
-
.islamic-radio-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 31 |
-
"name": "عبد ال
|
| 32 |
-
"folder": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
},
|
| 34 |
{
|
| 35 |
"id": "shuraym",
|
|
@@ -37,19 +104,134 @@
|
|
| 37 |
"folder": "Saood_ash-Shuraym_128kbps"
|
| 38 |
},
|
| 39 |
{
|
| 40 |
-
"id": "
|
| 41 |
-
"name": "
|
| 42 |
-
"folder": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 120 |
-
"name": "
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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();
|