Asem75 commited on
Commit
ee43214
·
verified ·
1 Parent(s): 0e102c9

Upload 8 files

Browse files
QURAN_INTEGRATION.md CHANGED
@@ -5,7 +5,7 @@
5
 
6
  ## الملفات
7
 
8
- تكون الملفات السبعة الآتية بجوار ملفات الساعة الحالية، بنفس الأسماء تمامًا:
9
 
10
  | الملف | المسؤولية |
11
  |---|---|
@@ -14,6 +14,8 @@
14
  | `quran_reader.js` | التحميل، العرض، البحث، التنقل وإعدادات القراءة |
15
  | `quran_reader.css` | تنسيق القارئ داخل نافذة الساعة الحالية |
16
  | `quran_reader_registry.json` | عناوين الواجهة، إعدادات العرض ومسارات البيانات |
 
 
17
  | `quran_core_registry.json` | السور والآيات والأجزاء والصفحات من المصدر |
18
  | `al_khatma_source_attribution.md` | نسبة المصدر ونص ترخيصه |
19
 
@@ -25,15 +27,20 @@
25
 
26
  1. اضغط على خانة ساعة، ثم «قراءة القرآن الكريم».
27
  2. اختر سورة أو جزءًا، أو أدخل رقم صفحة. يعرض القارئ الصفحة المرتبطة بذلك الموضع، ويحدد أول آية مطلوبة.
28
- 3. البحث يشمل القرآن كله؛ الضغط على النتيجة يفتح صفحتها ويحدد الآية.
29
- 4. إعدادات حجم الخط وتذكر آخر صفحة موجودة داخل إعدادات القراءة، ويمكن الوصول إليها أيضًا من إعدادات الساعة الموجودة.
30
- 5. زر الإغلاق الموجود يغلق النافذة فقط؛ لا يسجل إتمام عبادة أو ختمة.
 
 
 
 
 
31
 
32
  يحمّل الملف الصغير الخاص بواجهة القارئ عند فتح مدخله، وبيانات القرآن عند فتح القراءة لأول مرة.
33
  بعد تحميل البيانات يستخدمها القارئ من ذاكرة الصفحة دون اتصال إضافي. لا تضمن هذه المرحلة فتح التطبيق من جديد دون إنترنت؛ ذلك يحتاج تخزينًا دائمًا وآلية عمل دون اتصال لاحقًا.
34
  إعدادات القراءة وآخر صفحة فقط تحفظ محليًا على الجهاز؛ لا حساب ولا إرسال لبحث المستخدم إلى خدمة خارجية.
35
 
36
- النسخة الحالية قراءة نصية؛ لا صور مصحف أو صوت أو تفسير أو خطة ختمة بعد، ولا ذكاء اصطناعي في القرآن.
37
  هذه الأقسام يمكن ربطها لاحقًا بملفات مستقلة. لا تتضمن الإضافة نقل كود القبلة والمحاكاة الموجود مسبقًا داخل الاندكس.
38
 
39
  ## عقد الربط للصيانة
@@ -60,17 +67,32 @@ node maintenance/test_quran_reader.cjs
60
 
61
  الاختبار يحتاج Node.js وPlaywright وChromium. يستخدم مدير النافذة ومدخل الساعة الفعليين مع بيانات اختبار للأجزاء غير المرفقة من الساعة؛ ليس اختبارًا كاملًا للمواقيت والتنبيهات أو للمساحة المنشورة.
62
 
 
 
 
 
 
 
 
 
 
 
 
 
63
  ## نتيجة التحقق لهذه النسخة
64
 
65
  - نجح فحص مطابقة النصوص والحقول لكل الآيات الـ6236 مع الملف الأصلي، وترتيب 114 سورة و604 صفحات و30 جزءًا.
66
- - نجح التحقق من بقاء محتوى الاندكس الأصلي كما هو بعد استثناء روابط القارئ وإصدار رابط `registry_app.js`.
67
  - نجح اختبار السلوك بواسطة jsdom: فتح القارئ من الساعة، السورة والجزء والصفحة، البحث العربي، صفحات نتائج البحث، الإعدادات، التخزين المحلي، الإغلاق، إعادة استخدام البيانات، فتح آية من حدث، إعادة المحاولة بعد خطأ بيانات أو إعدادات، وإلغاء عرض نتيجة تحميل متأخر بعد الإغلاق.
68
  - لم يعمل اختبار Playwright البصري لعدم توفر Chromium وتعذر تنزيله في بيئة التنفيذ. لم تُراجع هيئة العرض على متصفح هاتف فعلي ولم تختبر المساحة المنشورة.
 
 
69
 
70
  لاختبار السلوك دون Chromium، مع إتاحة حزمة `jsdom` لـNode:
71
 
72
  ```bash
73
  node maintenance/test_quran_dom.cjs
 
74
  ```
75
 
76
  بدائل TXT تطابق الملفات الأصلية بايتًا ببايت؛ هي بدائل للتنزيل فقط ولا يحتاج التطبيق النسختين معًا.
 
5
 
6
  ## الملفات
7
 
8
+ تكون الملفات التسعة الآتية بجوار ملفات الساعة الحالية، بنفس الأسماء تمامًا:
9
 
10
  | الملف | المسؤولية |
11
  |---|---|
 
14
  | `quran_reader.js` | التحميل، العرض، البحث، التنقل وإعدادات القراءة |
15
  | `quran_reader.css` | تنسيق القارئ داخل نافذة الساعة الحالية |
16
  | `quran_reader_registry.json` | عناوين الواجهة، إعدادات العرض ومسارات البيانات |
17
+ | `quran_audio.js` | مشغّل التلاوة المستقل والتحكم بالصوت وأحداث التظليل |
18
+ | `quran_audio_registry.json` | مصدر التلاوات والقرّاء وخيارات التشغيل ونصوص الواجهة |
19
  | `quran_core_registry.json` | السور والآيات والأجزاء والصفحات من المصدر |
20
  | `al_khatma_source_attribution.md` | نسبة المصدر ونص ترخيصه |
21
 
 
27
 
28
  1. اضغط على خانة ساعة، ثم «قراءة القرآن الكريم».
29
  2. اختر سورة أو جزءًا، أو أدخل رقم صفحة. يعرض القارئ الصفحة المرتبطة بذلك الموضع، ويحدد أول آية مطلوبة.
30
+ 3. البحث يشمل القرآن كله؛ الضغط على النتيجة يفتح صفحتها ويحدد الآية. حقل «بحث عن سورة» يرشح فهرس السور.
31
+ 4. اضغط الآية لفتح إجراءاتها: الاستماع منها أو نسخ نصها مع مرجعها. لوحة المفاتيح تعمل عبر Tab ثم Enter أو المسافة.
32
+ 5. التحكم بالصوت: تشغيل، إيقاف مؤقت، استكمال، إيقاف، الآية السابقة والتالية، وشريط تقدم قابل للتقديم والتأخير.
33
+ 6. افتح «القارئ وخيارات التلاوة» لاختيار العفاسي أو الحصري أو المنشاوي، وتغيير السرعة والتكرار والمتابعة التلقائية.
34
+ 7. تظلل الآية عند حدث التشغيل الفعلي `playing` فقط. تتوقف العلامة عند الإيقاف المؤقت أو الخطأ أو الانتظار، وتنتقل تلقائيًا بين الصفحات عند تفعيل المتابعة.
35
+ 8. مظهر القراءة: فاتح، داكن، أو حسب الجهاز. يحفظ الخيار محليًا ويقتصر على نافذة القرآن وإعداداتها.
36
+ 9. إعدادات حجم الخط وتذكر آخر صفحة ومتابعة الآية موجودة داخل إعدادات القراءة، ويمكن الوصول إليها أيضًا من إعدادات الساعة الموجودة.
37
+ 10. زر الإغلاق الموجود يغلق النافذة ويوقف التلاوة ويلغي أي تشغيل متأخر؛ لا يسجل إتمام عبادة أو ختمة. الانتقال من نافذة القراءة إلى إعداداتها يوقف الصوت أيضًا.
38
 
39
  يحمّل الملف الصغير الخاص بواجهة القارئ عند فتح مدخله، وبيانات القرآن عند فتح القراءة لأول مرة.
40
  بعد تحميل البيانات يستخدمها القارئ من ذاكرة الصفحة دون اتصال إضافي. لا تضمن هذه المرحلة فتح التطبيق من جديد دون إنترنت؛ ذلك يحتاج تخزينًا دائمًا وآلية عمل دون اتصال لاحقًا.
41
  إعدادات القراءة وآخر صفحة فقط تحفظ محليًا على الجهاز؛ لا حساب ولا إرسال لبحث المستخدم إلى خدمة خارجية.
42
 
43
+ النسخة الحالية قراءة نصية وتلاوة آيةً آية؛ لا صور مصحف أو تفسير أو خطة ختمة بعد، ولا ذكاء اصطناعي في القرآن.
44
  هذه الأقسام يمكن ربطها لاحقًا بملفات مستقلة. لا تتضمن الإضافة نقل كود القبلة والمحاكاة الموجود مسبقًا داخل الاندكس.
45
 
46
  ## عقد الربط للصيانة
 
67
 
68
  الاختبار يحتاج Node.js وPlaywright وChromium. يستخدم مدير النافذة ومدخل الساعة الفعليين مع بيانات اختبار للأجزاء غير المرفقة من الساعة؛ ليس اختبارًا كاملًا للمواقيت والتنبيهات أو للمساحة المنشورة.
69
 
70
+ ## مصدر التلاوات والتحقق
71
+
72
+ - المصدر المعتمد للتشغيل: https://everyayah.com/ وفهرس القرّاء الرسمي https://everyayah.com/data/recitations.js
73
+ - تمت مراجعة MP3Quran أيضًا: https://www.mp3quran.net/ar/api وhttps://www.mp3quran.net/eng/timing-api . لم يُضف كمزود ثانٍ في هذه المرحلة؛ الاختيار هو المقاطع المنفصلة لكل آية من EveryAyah.
74
+ - نمط الرابط: `https://everyayah.com/data/{folder}/{surah3}{ayah3}.mp3`، مثل `002255.mp3` للبقرة 255. مجلدات القرّاء مأخوذة من فهرس المصدر، لا من تخمين أسماء روابط.
75
+ - في 2026-09-28 طوبقت أسماء الملفات في فهارس `Alafasy_128kbps` و`Husary_128kbps` و`Minshawy_Murattal_128kbps` مع بياناتنا: وُجدت أسماء المقاطع لكل الآيات الـ6236 عند كل قارئ. هذا تحقق فهرسة، وليس استماعًا وتدقيقًا لكل تسجيل.
76
+ - استجابت عينات العفاسي 1:1 والحصري 114:6 والمنشاوي 2:255 بحالة HTTP 200 وبنوع `audio/mpeg`، ويدعم الخادم الطلبات الجزئية. نُزّلت عينة العفاسي للفحص التقني وتعرّف عليها ffprobe كملف MP3 مدته نحو 6.09 ثوان.
77
+ - نص القرآن يظل من AL-Khatma كما هو. لم يتم استبدال `quran_core_registry.json` أو تغيير نصوصه بهذه الإضافة.
78
+ - التسجيلات تُبث من المصدر عند طلب المستخدم، ولا تدخل ضمن ترخيص بيانات AL-Khatma ولا ترفع ضمن ملفات المساحة. لا يوجد تنزيل كامل للتلاوات أو تخزين دائم دون إنترنت في هذه المرحلة.
79
+ - قد يفصل بين المقاطع توقف قصير. إذا منع المتصفح استكمال التشغيل التلقائي يظهر طلب ضغط «تشغيل»؛ لا يُدّعى دعم تشغيل مضمون بعد إغلاق الصفحة أو التطبيق.
80
+ - تأخذ كل آية التسجيل المقابل لرقمها؛ لا نضيف بسملة صوتية مصطنعة أو نفترض وجود توقيتات كلمات داخل التسجيل.
81
+
82
  ## نتيجة التحقق لهذه النسخة
83
 
84
  - نجح فحص مطابقة النصوص والحقول لكل الآيات الـ6236 مع الملف الأصلي، وترتيب 114 سورة و604 صفحات و30 جزءًا.
85
+ - ظل تعديل الاندكس مقتصرًا على روابط ملفات القرآن وأرقام إصدارات التحميل؛ كود الصوت خارج الاندكس. تعديل `registry_app.js` الإضافي يتيح التنقل بلوحة المفاتيح بين الآيات وإجراءات الصوت ويتجاوز الحقول المعطلة أو المطوية.
86
  - نجح اختبار السلوك بواسطة jsdom: فتح القارئ من الساعة، السورة والجزء والصفحة، البحث العربي، صفحات نتائج البحث، الإعدادات، التخزين المحلي، الإغلاق، إعادة استخدام البيانات، فتح آية من حدث، إعادة المحاولة بعد خطأ بيانات أو إعدادات، وإلغاء عرض نتيجة تحميل متأخر بعد الإغلاق.
87
  - لم يعمل اختبار Playwright البصري لعدم توفر Chromium وتعذر تنزيله في بيئة التنفيذ. لم تُراجع هيئة العرض على متصفح هاتف فعلي ولم تختبر المساحة المنشورة.
88
+ - نجح اختبار الصوت بمحاكاة أحداث الوسائط: التظليل عند التشغيل فقط، السابق والتالي وعبر الصفحات والسور، التكرار المحدود والمستمر، السرعة وشريط التقدم، تغيير القارئ، إغلاق وإيقاف تشغيل متأخر، خطأ المصدر وإعادة المحاولة، منع التشغيل التلقائي، ونهاية القرآن.
89
+ - نجح فحص الوضع الداكن وحفظه محليًا، نسخ الآية ومرجعها، وبقاء ميزات القراءة السابقة. لا تمثل محاكاة أحداث الوسائط تجربة استماع فعلية داخل متصفح.
90
 
91
  لاختبار السلوك دون Chromium، مع إتاحة حزمة `jsdom` لـNode:
92
 
93
  ```bash
94
  node maintenance/test_quran_dom.cjs
95
+ node maintenance/test_quran_audio.cjs
96
  ```
97
 
98
  بدائل TXT تطابق الملفات الأصلية بايتًا ببايت؛ هي بدائل للتنزيل فقط ولا يحتاج التطبيق النسختين معًا.
index.html CHANGED
@@ -4,7 +4,7 @@
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
  <title></title>
7
- <link rel="stylesheet" href="quran_reader.css?v=20260928-quran1">
8
  <style>
9
  *{box-sizing:border-box}
10
  html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,"Noto Sans Arabic",sans-serif}
@@ -225,8 +225,9 @@ body{padding-bottom:190px}
225
  <script src="personal_reminder_engine.js?v=20260927-markers2"></script>
226
  <script src="hijri_calendar_engine.js?v=20260926-worship1"></script>
227
  <script src="worship_event_engine.js?v=20260926-worship1"></script>
228
- <script src="quran_reader.js?v=20260928-quran1"></script>
229
- <script src="registry_app.js?v=20260928-quran1"></script>
 
230
  <script>
231
  (function () {
232
  'use strict';
 
4
  <meta charset="utf-8">
5
  <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
  <title></title>
7
+ <link rel="stylesheet" href="quran_reader.css?v=20260928-audio2">
8
  <style>
9
  *{box-sizing:border-box}
10
  html,body{margin:0;min-height:100%;font-family:system-ui,-apple-system,"Noto Sans Arabic",sans-serif}
 
225
  <script src="personal_reminder_engine.js?v=20260927-markers2"></script>
226
  <script src="hijri_calendar_engine.js?v=20260926-worship1"></script>
227
  <script src="worship_event_engine.js?v=20260926-worship1"></script>
228
+ <script src="quran_audio.js?v=20260928-audio2"></script>
229
+ <script src="quran_reader.js?v=20260928-audio2"></script>
230
+ <script src="registry_app.js?v=20260928-audio2"></script>
231
  <script>
232
  (function () {
233
  'use strict';
quran_audio.js ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ (function () {
2
+ 'use strict';
3
+ const base = new URL('.', document.currentScript.src);
4
+ let configPromise;
5
+ const element = (tag, text, className) => {
6
+ const node = document.createElement(tag);
7
+ if (text != null) node.textContent = text;
8
+ if (className) node.className = className;
9
+ return node;
10
+ };
11
+ const button = (label, action) => {
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);
18
+ try {
19
+ const response = await fetch(new URL('quran_audio_registry.json', base), {signal:controller.signal,cache:'no-store'});
20
+ if (!response.ok) throw Error('quran_audio_registry.json HTTP ' + response.status);
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']) {
28
+ if (typeof c.ui[key] !== 'string') throw Error('Missing audio UI: ' + key);
29
+ }
30
+ return c;
31
+ } finally {clearTimeout(timer);}
32
+ })().catch(error => {configPromise = null; throw error;});
33
+ return configPromise;
34
+ }
35
+ function createPlayer(c, verses, notify) {
36
+ const byId = new Map(verses.map((v, index) => [v.id, index]));
37
+ let saved;
38
+ try {saved = JSON.parse(localStorage.getItem(c.storage_key));} catch { /* Preferences are optional. */ }
39
+ const settings = {
40
+ reciter: c.reciters.some(r => r.id === saved?.reciter) ? saved.reciter : c.defaults.reciter,
41
+ rate: c.rates.includes(saved?.rate) ? saved.rate : c.defaults.rate,
42
+ repeat: c.repeat_options.some(r => r.value === saved?.repeat) ? saved.repeat : c.defaults.repeat,
43
+ advance: typeof saved?.advance === 'boolean' ? saved.advance : c.defaults.advance
44
+ };
45
+ let index = 0, media = null, token = 0, playRequest = 0, disposed = false, wantsPlay = false, repetitions = settings.repeat;
46
+ let phase = 'idle', currentTime = 0, duration = 0, loadTimer, removeEvents = () => {};
47
+ const snapshot = () => ({verse:verses[index], phase, currentTime, duration, settings:{...settings}});
48
+ const emit = () => {if (!disposed) notify(snapshot());};
49
+ const clearTimer = () => {clearTimeout(loadTimer);loadTimer = null;};
50
+ function detach() {
51
+ clearTimer(); token++; playRequest++; wantsPlay = false;
52
+ removeEvents(); removeEvents = () => {};
53
+ if (media) {media.pause(); media.removeAttribute('src'); media.load(); media = null;}
54
+ }
55
+ function fail(kind = 'error') {
56
+ clearTimer(); wantsPlay = false; playRequest++;
57
+ if (media) media.pause(); phase = kind; emit();
58
+ }
59
+ function watchLoading() {
60
+ clearTimer();
61
+ loadTimer = setTimeout(() => {if (wantsPlay) fail();}, c.limits.loading_timeout_ms);
62
+ }
63
+ function urlFor(verse) {
64
+ const reciter = c.reciters.find(r => r.id === settings.reciter);
65
+ const raw = interpolate(c.source.url_pattern, {folder:reciter.folder, surah3:String(verse.surah).padStart(3,'0'), ayah3:String(verse.ayah).padStart(3,'0')});
66
+ const url = new URL(raw);
67
+ if (url.protocol !== 'https:' || url.hostname !== new URL(c.source.url).hostname) throw Error('Invalid audio URL');
68
+ return url.href;
69
+ }
70
+ async function startMedia() {
71
+ if (disposed) return;
72
+ const activeMedia = media, request = ++playRequest;
73
+ wantsPlay = true; phase = 'loading'; watchLoading(); emit();
74
+ try { await activeMedia.play(); }
75
+ catch (error) {
76
+ if (!disposed && activeMedia === media && request === playRequest && wantsPlay) fail(error.name === 'NotAllowedError' ? 'blocked' : 'error');
77
+ }
78
+ }
79
+ function playAt(id, resetRepeat = true) {
80
+ if (disposed || !byId.has(id)) return;
81
+ detach(); index = byId.get(id); currentTime = 0; duration = 0;
82
+ if (resetRepeat) repetitions = settings.repeat;
83
+ const activeMedia = new Audio(); media = activeMedia;
84
+ activeMedia.preload = 'none'; activeMedia.playbackRate = settings.rate;
85
+ activeMedia.setAttribute('playsinline', '');
86
+ const generation = token;
87
+ const listeners = [];
88
+ const on = (name, fn) => {
89
+ const handler = () => {if (!disposed && generation === token && activeMedia === media) fn();};
90
+ activeMedia.addEventListener(name, handler); listeners.push([name,handler]);
91
+ };
92
+ removeEvents = () => listeners.forEach(([name,handler]) => activeMedia.removeEventListener(name,handler));
93
+ on('playing', () => {
94
+ if (!wantsPlay) {activeMedia.pause(); return;}
95
+ clearTimer(); phase = 'playing'; emit();
96
+ });
97
+ on('waiting', () => {if (wantsPlay) {phase = 'loading'; watchLoading(); emit();}});
98
+ on('stalled', () => {if (wantsPlay) {phase = 'loading'; watchLoading(); emit();}});
99
+ on('pause', () => {if (phase === 'playing') {wantsPlay = false; clearTimer(); phase = 'paused'; emit();}});
100
+ on('loadedmetadata', () => {duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0; emit();});
101
+ on('durationchange', () => {duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0; emit();});
102
+ on('timeupdate', () => {currentTime = activeMedia.currentTime || 0; emit();});
103
+ on('error', () => fail());
104
+ on('ended', () => {
105
+ if (!wantsPlay) return;
106
+ clearTimer();
107
+ if (repetitions === 0 || repetitions > 1) {
108
+ if (repetitions > 1) repetitions--;
109
+ playAt(verses[index].id, false);
110
+ } else if (settings.advance && index < verses.length - 1) playAt(verses[index + 1].id);
111
+ else {wantsPlay = false; phase = 'ended'; emit();}
112
+ });
113
+ try {activeMedia.src = urlFor(verses[index]); startMedia();} catch {fail();}
114
+ }
115
+ function select(id) {
116
+ if (disposed || !byId.has(id) || verses[index].id === id) return;
117
+ detach(); index = byId.get(id); phase = 'idle'; currentTime = 0; duration = 0; emit();
118
+ }
119
+ function pause() {
120
+ if (!media || disposed) return;
121
+ wantsPlay = false; playRequest++; clearTimer(); media.pause(); phase = 'paused'; emit();
122
+ }
123
+ function stop() {detach(); phase = 'stopped'; currentTime = 0; duration = 0; emit();}
124
+ function toggle() {
125
+ if (phase === 'playing' || phase === 'loading') pause();
126
+ else if (media && ['paused','blocked'].includes(phase)) startMedia();
127
+ else playAt(verses[index].id);
128
+ }
129
+ function configure(patch) {
130
+ const oldReciter = settings.reciter, running = wantsPlay;
131
+ if (c.reciters.some(r => r.id === patch.reciter)) settings.reciter = patch.reciter;
132
+ if (c.rates.includes(patch.rate)) settings.rate = patch.rate;
133
+ if (c.repeat_options.some(r => r.value === patch.repeat)) {settings.repeat = patch.repeat;repetitions = patch.repeat;}
134
+ if (typeof patch.advance === 'boolean') settings.advance = patch.advance;
135
+ if (oldReciter !== settings.reciter) {stop(); if (running) playAt(verses[index].id);}
136
+ if (media) media.playbackRate = settings.rate;
137
+ let stored = true;
138
+ try {localStorage.setItem(c.storage_key, JSON.stringify(settings));} catch {stored = false;}
139
+ emit(); return stored;
140
+ }
141
+ function seek(seconds) {
142
+ if (media && Number.isFinite(seconds) && duration > 0) {
143
+ try {media.currentTime = Math.min(duration, Math.max(0, seconds));currentTime = media.currentTime;emit();} catch { /* Metadata may be changing. */ }
144
+ }
145
+ }
146
+ return {playAt, select, pause, stop, toggle, configure, seek, snapshot,
147
+ previous: () => {if (index > 0) playAt(verses[index - 1].id);},
148
+ next: () => {if (index < verses.length - 1) playAt(verses[index + 1].id);},
149
+ dispose: () => {disposed = true;detach();}};
150
+ }
151
+ async function mount(host, {verses, surahs, initialId, onState, isActive = () => host.isConnected}) {
152
+ const c = await configuration();
153
+ if (!isActive()) return null;
154
+ const ui = c.ui, wrap = element('section', null, 'quran-audio'); wrap.setAttribute('aria-label',ui.title);
155
+ const controls = element('div', null, 'quran-audio-options');
156
+ const select = (label, options, action) => {
157
+ const box = element('label',label), input = element('select');
158
+ options.forEach(([value,text]) => {const item = element('option',text);item.value=value;input.append(item);});
159
+ input.addEventListener('change', () => action(input.value));box.append(input);controls.append(box);return input;
160
+ };
161
+ const storageStatus = element('p',null,'quran-meta');storageStatus.setAttribute('role','status');
162
+ let player;
163
+ const set = patch => {storageStatus.textContent = player.configure(patch) ? '' : ui.storage_error;};
164
+ const reciter = select(ui.reciter,c.reciters.map(r=>[r.id,r.name]),value=>set({reciter:value}));
165
+ const rate = select(ui.rate,c.rates.map(r=>[r,r+'×']),value=>set({rate:Number(value)}));
166
+ const repeat = select(ui.repeat,c.repeat_options.map(r=>[r.value,r.label]),value=>set({repeat:Number(value)}));
167
+ const advanceLabel=element('label',ui.advance), advance=element('input');advance.type='checkbox';
168
+ advance.addEventListener('change',()=>set({advance:advance.checked}));advanceLabel.prepend(advance);
169
+ const actions=element('div',null,'quran-audio-actions');
170
+ const previous=button(ui.previous,()=>player.previous()), play=button(ui.play,()=>player.toggle());
171
+ const stop=button(ui.stop,()=>player.stop()), next=button(ui.next,()=>player.next());actions.append(previous,play,stop,next);
172
+ const current=element('p',null,'quran-meta'), state=element('p',null,'quran-meta');state.setAttribute('role','status');
173
+ const seekLabel=element('label',ui.seek), seek=element('input');seek.type='range';seek.min=0;seek.max=0;seek.step=0.1;seek.value=0;seek.disabled=true;
174
+ seek.addEventListener('change',()=>player.seek(Number(seek.value)));seekLabel.append(seek);
175
+ const clock=element('output');clock.dir='ltr';
176
+ const time=s=>Math.floor(s/60)+':'+String(Math.floor(s%60)).padStart(2,'0');
177
+ const source=element('a',ui.source);source.href=c.source.url;source.target='_blank';source.rel='noopener noreferrer';source.className='quran-meta';
178
+ let lastStatus='';
179
+ player=createPlayer(c,verses,s=>{
180
+ if (!isActive()) return;
181
+ reciter.value=s.settings.reciter;rate.value=s.settings.rate;repeat.value=s.settings.repeat;advance.checked=s.settings.advance;
182
+ current.textContent=interpolate(ui.current,{surah:surahs[s.verse.surah-1].name,ayah:s.verse.ayah});
183
+ if (lastStatus !== s.phase) {state.textContent=ui[s.phase]||ui.idle;lastStatus=s.phase;}
184
+ play.textContent=['playing','loading'].includes(s.phase)?ui.pause:s.phase==='paused'?ui.resume:ui.play;
185
+ previous.disabled=s.verse.id===verses[0].id;next.disabled=s.verse.id===verses[verses.length-1].id;
186
+ seek.max=s.duration;seek.disabled=s.duration<=0;
187
+ if (document.activeElement!==seek) seek.value=s.currentTime;
188
+ clock.textContent=time(s.currentTime)+' / '+time(s.duration);
189
+ onState(s);
190
+ });
191
+ const options=element('details'),summary=element('summary',ui.options);
192
+ options.append(summary,controls,advanceLabel,source);
193
+ wrap.append(current,actions,seekLabel,clock,state,options,storageStatus);host.replaceChildren(wrap);
194
+ player.select(initialId);
195
+ // Initialize controls even when the requested verse is already the first one.
196
+ player.configure({});
197
+ return player;
198
+ }
199
+ window.MuslimClockQuranAudio=Object.freeze({mount,createPlayer});
200
+ })();
quran_audio_registry.json ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "schema_version": 1,
3
+ "storage_key": "muslim_clock_quran_audio_v1",
4
+ "source": {
5
+ "name": "EveryAyah",
6
+ "url": "https://everyayah.com/",
7
+ "catalog_url": "https://everyayah.com/data/recitations.js",
8
+ "url_pattern": "https://everyayah.com/data/{folder}/{surah3}{ayah3}.mp3",
9
+ "checked_on": "2026-09-28",
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
+ {"id": "alafasy", "name": "مشاري راشد العفاسي", "folder": "Alafasy_128kbps"},
15
+ {"id": "husary", "name": "محمود خليل الحصري", "folder": "Husary_128kbps"},
16
+ {"id": "minshawy", "name": "محمد صديق المنشاوي", "folder": "Minshawy_Murattal_128kbps"}
17
+ ],
18
+ "defaults": {"reciter": "alafasy", "rate": 1, "repeat": 1, "advance": true},
19
+ "rates": [0.75, 1, 1.25, 1.5],
20
+ "repeat_options": [{"value":1,"label":"مرة واحدة"},{"value":2,"label":"مرتان"},{"value":3,"label":"3 مرات"},{"value":5,"label":"5 مرات"},{"value":0,"label":"تكرار مستمر"}],
21
+ "limits": {"request_timeout_ms": 15000, "loading_timeout_ms": 30000},
22
+ "ui": {
23
+ "title": "الاستماع",
24
+ "options": "القارئ وخيارات التلاوة",
25
+ "reciter": "القارئ",
26
+ "rate": "سرعة التلاوة",
27
+ "repeat": "تكرار كل آية",
28
+ "advance": "متابعة الآيات والسور تلقائيًا",
29
+ "play": "تشغيل",
30
+ "pause": "إيقاف مؤقت",
31
+ "resume": "استكمال",
32
+ "stop": "إيقاف التلاوة",
33
+ "previous": "الآية السابقة",
34
+ "next": "الآية التالية",
35
+ "seek": "موضع التلاوة في الآية",
36
+ "current": "{surah} · الآية {ayah}",
37
+ "idle": "اختر آية للاستماع",
38
+ "loading": "جارٍ تحميل التلاوة…",
39
+ "playing": "تُتلى الآن",
40
+ "paused": "التلاوة متوقفة مؤقتًا",
41
+ "stopped": "توقفت التلاوة",
42
+ "ended": "انتهت التلاوة",
43
+ "error": "تعذّر تشغيل المقطع. تحقق من الإنترنت أو اختر قارئًا آخر ثم اضغط تشغيل.",
44
+ "blocked": "اضغط تشغيل لاستكمال التلاوة؛ المتصفح يحتاج ضغطة منك.",
45
+ "storage_error": "تعذّر حفظ تفضيلات الصوت على هذا الجهاز.",
46
+ "source": "مصدر التلاوة: EveryAyah"
47
+ }
48
+ }
quran_reader.css CHANGED
@@ -21,6 +21,30 @@
21
  #adaptivePopupBody .quran-result-text{display:block;font-family:"Noto Naskh Arabic","Amiri",serif;font-size:var(--quran-font-size,26px);line-height:2}
22
  #adaptivePopupBody .quran-settings input[type="range"]{width:100%;padding:0;min-height:44px}
23
  #adaptivePopupBody .quran-settings input[type="checkbox"]{width:auto}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
  #adaptivePopupBody .quran-reader :focus-visible,
25
  #adaptivePopupBody .quran-settings :focus-visible{outline:2px solid currentColor;outline-offset:2px}
26
  @media(max-width:400px){
 
21
  #adaptivePopupBody .quran-result-text{display:block;font-family:"Noto Naskh Arabic","Amiri",serif;font-size:var(--quran-font-size,26px);line-height:2}
22
  #adaptivePopupBody .quran-settings input[type="range"]{width:100%;padding:0;min-height:44px}
23
  #adaptivePopupBody .quran-settings input[type="checkbox"]{width:auto}
24
+ #adaptivePopupCard[data-quran-theme]{background:var(--quran-bg);color:var(--quran-fg);border-color:var(--quran-border);color-scheme:light}
25
+ #adaptivePopupCard[data-quran-theme="dark"]{color-scheme:dark}
26
+ #adaptivePopupCard[data-quran-theme] button,
27
+ #adaptivePopupCard[data-quran-theme] input,
28
+ #adaptivePopupCard[data-quran-theme] select,
29
+ #adaptivePopupCard[data-quran-theme] textarea{background:var(--quran-surface);color:var(--quran-fg);border:1px solid var(--quran-border)}
30
+ #adaptivePopupCard[data-quran-theme] a{color:var(--quran-fg)}
31
+ #adaptivePopupBody .quran-verse{cursor:pointer;border-radius:3px;box-decoration-break:clone;-webkit-box-decoration-break:clone}
32
+ #adaptivePopupBody .quran-verse.quran-playing{background:var(--quran-highlight);color:var(--quran-highlight_fg);outline:2px solid var(--quran-border);outline-offset:2px}
33
+ #adaptivePopupBody .quran-ayah-actions{border-block:1px solid var(--quran-border);padding:8px 0;margin:8px 0}
34
+ #adaptivePopupBody .quran-ayah-actions[hidden]{display:none}
35
+ #adaptivePopupBody .quran-ayah-actions h3{font-size:1rem;margin:4px 0}
36
+ #adaptivePopupBody .quran-audio{border-block:1px solid var(--quran-border);padding:10px 0;margin:12px 0}
37
+ #adaptivePopupBody .quran-audio-options{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;align-items:end}
38
+ #adaptivePopupBody .quran-audio-options label{margin:0;min-width:0}
39
+ #adaptivePopupBody .quran-audio-options label:first-child{grid-column:1/-1}
40
+ #adaptivePopupBody .quran-audio-options select{min-width:0;max-width:100%}
41
+ #adaptivePopupBody .quran-audio-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px}
42
+ #adaptivePopupBody .quran-audio-actions button{min-width:0;margin:0;text-align:center;padding:8px}
43
+ #adaptivePopupBody .quran-audio input[type="range"]{width:100%;min-width:0;padding:0;min-height:44px;accent-color:var(--quran-fg)}
44
+ #adaptivePopupBody .quran-audio input[type="checkbox"]{width:auto}
45
+ #adaptivePopupBody .quran-audio output{display:block;text-align:center;font-variant-numeric:tabular-nums}
46
+ #adaptivePopupBody .quran-audio p:empty{display:none}
47
+ #adaptivePopupBody .quran-audio summary{cursor:pointer;min-height:44px;padding:8px 0}
48
  #adaptivePopupBody .quran-reader :focus-visible,
49
  #adaptivePopupBody .quran-settings :focus-visible{outline:2px solid currentColor;outline-offset:2px}
50
  @media(max-width:400px){
quran_reader.js CHANGED
@@ -2,7 +2,7 @@
2
  'use strict';
3
  const base = new URL('.', document.currentScript.src);
4
  const configFile = 'quran_reader_registry.json';
5
- let configPromise, corePromise, preferences, epoch = 0;
6
  const node = (tag, text, className) => {
7
  const element = document.createElement(tag);
8
  if (text != null) element.textContent = text;
@@ -26,7 +26,7 @@
26
  const controller = new AbortController();
27
  const timer = setTimeout(() => controller.abort(), timeout);
28
  try {
29
- const response = await fetch(url, {signal: controller.signal});
30
  if (!response.ok) throw Error(file + ' HTTP ' + response.status);
31
  return await response.json();
32
  } finally { clearTimeout(timer); }
@@ -36,7 +36,8 @@
36
  if (value.schema_version !== 1 || !value.ui || !value.core_file || !value.defaults || !value.limits) throw Error('Invalid reader registry');
37
  for (const key of ['title','open','settings','loading','load_error','retry','surah','ayah','page','juz','go','previous','next',
38
  'search','search_submit','search_empty','no_results','results','more','reader','page_status','invalid_page','font_size',
39
- 'remember_page','save','saved','storage_error','back','source']) {
 
40
  if (typeof value.ui[key] !== 'string') throw Error('Missing UI: ' + key);
41
  }
42
  if (!Number.isInteger(value.limits.search_batch) || value.limits.search_batch < 1) throw Error('Invalid search limit');
@@ -79,7 +80,9 @@
79
  preferences = {
80
  font_size: Number.isFinite(saved?.font_size) ? Math.max(c.limits.font_min, Math.min(c.limits.font_max, saved.font_size)) : c.defaults.font_size,
81
  remember_page: typeof saved?.remember_page === 'boolean' ? saved.remember_page : c.defaults.remember_page,
82
- page: Number.isInteger(saved?.page) && saved.page >= 1 && saved.page <= 604 ? saved.page : 1
 
 
83
  };
84
  return preferences;
85
  }
@@ -94,6 +97,17 @@
94
  });
95
  document.getElementById('adaptivePopupCard').dataset.size = 'long';
96
  }
 
 
 
 
 
 
 
 
 
 
 
97
  async function mount(parent, back, settingsOnly) {
98
  const entry = button('…', () => {});
99
  entry.disabled = true;
@@ -151,6 +165,10 @@
151
  }
152
  root.replaceChildren();
153
  root.style.setProperty('--quran-font-size', p.font_size + 'px');
 
 
 
 
154
  const toolbar = node('div', null, 'quran-toolbar');
155
  const field = (label, input) => { const wrap = node('label', label); wrap.append(input); toolbar.append(wrap); return input; };
156
  const select = (items, label) => {
@@ -159,6 +177,13 @@
159
  return field(label, input);
160
  };
161
  const surahSelect = select(source.surahs.map(s => [s.id, s.id + '. ' + s.name]), ui.surah);
 
 
 
 
 
 
 
162
  const juzSelect = select(Array.from({length: source.counts.juz}, (_, i) => [i + 1, i + 1]), ui.juz);
163
  const pageForm = node('form');
164
  const pageLabel = node('label', ui.page);
@@ -167,22 +192,76 @@
167
  pageLabel.append(pageInput); pageForm.append(pageLabel);
168
  const go = node('button', ui.go); go.type = 'submit'; pageForm.append(go); toolbar.append(pageForm);
169
  toolbar.append(button(ui.settings, () => openSettings(() => open({...options, page, surah: undefined, ayah: undefined}))));
 
170
  const searchForm = node('form', null, 'quran-search'); searchForm.setAttribute('role', 'search');
171
  const query = node('input'); query.type = 'search'; query.setAttribute('aria-label', ui.search); query.placeholder = ui.search;
172
  const submit = node('button', ui.search_submit); submit.type = 'submit'; searchForm.append(query, submit);
173
  const status = node('p', null, 'quran-meta'); status.setAttribute('role', 'status');
174
  const content = node('div');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  const nav = node('div', null, 'quran-nav');
176
  const previous = button(ui.previous, () => showPage(page - 1));
177
  const next = button(ui.next, () => showPage(page + 1)); nav.append(previous, next);
178
  const attribution = node('a', ui.source); attribution.href = new URL(c.source_file, base).href;
179
  attribution.target = '_blank'; attribution.rel = 'noopener'; attribution.className = 'quran-meta';
180
- root.append(toolbar, searchForm, status, content, nav, attribution);
181
- function showPage(value, selectedVerse) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  if (!Number.isInteger(value) || !source.pages[value]?.length) { status.textContent = format(ui.invalid_page, {total:source.counts.pages}); return; }
183
  page = value;
184
  const verses = source.pages[page];
185
- content.replaceChildren(); nav.hidden = false;
186
  pageInput.value = page; surahSelect.value = selectedVerse?.surah || verses[0].surah; juzSelect.value = selectedVerse?.juz || verses[0].juz;
187
  const juz = [...new Set(verses.map(v => v.juz))].join('، ');
188
  status.textContent = format(ui.page_status, {page, total: source.counts.pages, juz});
@@ -195,7 +274,10 @@
195
  paragraph = node('p', null, 'quran-page-text'); content.append(paragraph); lastSurah = verse.surah;
196
  }
197
  const span = node('span', verse.text, 'quran-verse');
198
- span.id = 'quran-verse-' + verse.id; span.tabIndex = -1;
 
 
 
199
  const number = node('span', ' (' + verse.ayah + ') ', 'quran-verse-number');
200
  number.setAttribute('aria-label', ui.ayah + ' ' + verse.ayah);
201
  span.append(number); paragraph.append(span, document.createTextNode(' '));
@@ -204,7 +286,8 @@
204
  p.page = page;
205
  if (!persist(c)) status.textContent += ' ' + ui.storage_error;
206
  }
207
- document.getElementById('adaptivePopupBody').scrollTop = 0;
 
208
  if (selectedVerse) {
209
  const target = document.getElementById('quran-verse-' + selectedVerse.id);
210
  target?.focus({preventScroll: true}); target?.scrollIntoView({block:'center'});
@@ -222,6 +305,7 @@
222
  const text = normalize(query.value);
223
  if (!text) { status.textContent = ui.search_empty; query.focus(); return; }
224
  const matches = source.verses.filter((_, i) => source.searchIndex[i].includes(text));
 
225
  content.replaceChildren(); nav.hidden = true;
226
  status.textContent = matches.length ? format(ui.results, {count: matches.length}) : ui.no_results;
227
  content.append(button(ui.reader, () => showPage(page)));
@@ -241,6 +325,7 @@
241
  appendResults();
242
  });
243
  showPage(page, focusVerse);
 
244
  }
245
  async function openSettings(back) {
246
  const request = ++epoch;
@@ -255,14 +340,22 @@
255
  const rememberLabel = node('label', ui.remember_page), remember = node('input');
256
  remember.type = 'checkbox'; remember.checked = p.remember_page; rememberLabel.prepend(remember);
257
  const status = node('p'); status.setAttribute('role', 'status');
258
- root.append(label, rememberLabel, button(ui.save, () => {
 
 
259
  p.font_size = Number(font.value); p.remember_page = remember.checked;
 
260
  if (!p.remember_page) p.page = 1;
261
  status.textContent = persist(c) ? ui.saved : ui.storage_error;
262
  }), status);
263
  panel(ui.settings, root, back, ui);
 
264
  }
265
- window.addEventListener('muslim-clock-popup-leave', () => { epoch++; });
 
 
 
 
266
  window.addEventListener('muslim-clock-quran-open', event => open(event.detail || {}));
267
  window.MuslimClockQuran = Object.freeze({open, openSettings,
268
  mountEntry: (parent, back) => mount(parent, back, false),
 
2
  'use strict';
3
  const base = new URL('.', document.currentScript.src);
4
  const configFile = 'quran_reader_registry.json';
5
+ let configPromise, corePromise, preferences, epoch = 0, cleanup = () => {};
6
  const node = (tag, text, className) => {
7
  const element = document.createElement(tag);
8
  if (text != null) element.textContent = text;
 
26
  const controller = new AbortController();
27
  const timer = setTimeout(() => controller.abort(), timeout);
28
  try {
29
+ const response = await fetch(url, {signal: controller.signal, cache:file===configFile?'no-store':'default'});
30
  if (!response.ok) throw Error(file + ' HTTP ' + response.status);
31
  return await response.json();
32
  } finally { clearTimeout(timer); }
 
36
  if (value.schema_version !== 1 || !value.ui || !value.core_file || !value.defaults || !value.limits) throw Error('Invalid reader registry');
37
  for (const key of ['title','open','settings','loading','load_error','retry','surah','ayah','page','juz','go','previous','next',
38
  'search','search_submit','search_empty','no_results','results','more','reader','page_status','invalid_page','font_size',
39
+ 'remember_page','save','saved','storage_error','back','source','theme','theme_auto','theme_light','theme_dark',
40
+ 'follow_audio','verse_actions','listen','copy','copied','copy_failed','close_actions','audio_loading','audio_unavailable','search_surah']) {
41
  if (typeof value.ui[key] !== 'string') throw Error('Missing UI: ' + key);
42
  }
43
  if (!Number.isInteger(value.limits.search_batch) || value.limits.search_batch < 1) throw Error('Invalid search limit');
 
80
  preferences = {
81
  font_size: Number.isFinite(saved?.font_size) ? Math.max(c.limits.font_min, Math.min(c.limits.font_max, saved.font_size)) : c.defaults.font_size,
82
  remember_page: typeof saved?.remember_page === 'boolean' ? saved.remember_page : c.defaults.remember_page,
83
+ page: Number.isInteger(saved?.page) && saved.page >= 1 && saved.page <= 604 ? saved.page : 1,
84
+ theme: ['auto','light','dark'].includes(saved?.theme) ? saved.theme : c.defaults.theme,
85
+ follow_audio: typeof saved?.follow_audio === 'boolean' ? saved.follow_audio : c.defaults.follow_audio
86
  };
87
  return preferences;
88
  }
 
97
  });
98
  document.getElementById('adaptivePopupCard').dataset.size = 'long';
99
  }
100
+ function applyTheme(c, p) {
101
+ const card = document.getElementById('adaptivePopupCard');
102
+ const mode = p.theme === 'auto' ? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : p.theme;
103
+ card.dataset.quranTheme = mode;
104
+ for (const [key,value] of Object.entries(c.themes[mode])) card.style.setProperty('--quran-'+key,value);
105
+ }
106
+ function themeField(c, p, onChange) {
107
+ const label = node('label',c.ui.theme), select = node('select');
108
+ for (const key of ['auto','light','dark']) {const option=node('option',c.ui['theme_'+key]);option.value=key;select.append(option);}
109
+ select.value=p.theme;select.addEventListener('change',()=>onChange(select.value));label.append(select);return label;
110
+ }
111
  async function mount(parent, back, settingsOnly) {
112
  const entry = button('…', () => {});
113
  entry.disabled = true;
 
165
  }
166
  root.replaceChildren();
167
  root.style.setProperty('--quran-font-size', p.font_size + 'px');
168
+ applyTheme(c,p);
169
+ let player = null, mounted = true, audioState = null, lastFollowed = null, actionVerse = null, actionOrigin = null;
170
+ let audioAttempt = 0;
171
+ cleanup = () => {mounted=false;audioAttempt++;player?.dispose();};
172
  const toolbar = node('div', null, 'quran-toolbar');
173
  const field = (label, input) => { const wrap = node('label', label); wrap.append(input); toolbar.append(wrap); return input; };
174
  const select = (items, label) => {
 
177
  return field(label, input);
178
  };
179
  const surahSelect = select(source.surahs.map(s => [s.id, s.id + '. ' + s.name]), ui.surah);
180
+ const surahSearch = node('input');surahSearch.type='search';surahSearch.placeholder=ui.search_surah;surahSearch.setAttribute('aria-label',ui.search_surah);
181
+ const surahSearchLabel=node('label',ui.search_surah);surahSearchLabel.append(surahSearch);
182
+ toolbar.insertBefore(surahSearchLabel,surahSelect.parentElement);
183
+ surahSearch.addEventListener('input',()=>{
184
+ const term=normalize(surahSearch.value);
185
+ for(const option of surahSelect.options) option.hidden=Boolean(term)&&!normalize(option.textContent).includes(term);
186
+ });
187
  const juzSelect = select(Array.from({length: source.counts.juz}, (_, i) => [i + 1, i + 1]), ui.juz);
188
  const pageForm = node('form');
189
  const pageLabel = node('label', ui.page);
 
192
  pageLabel.append(pageInput); pageForm.append(pageLabel);
193
  const go = node('button', ui.go); go.type = 'submit'; pageForm.append(go); toolbar.append(pageForm);
194
  toolbar.append(button(ui.settings, () => openSettings(() => open({...options, page, surah: undefined, ayah: undefined}))));
195
+ toolbar.append(themeField(c,p,value=>{p.theme=value;applyTheme(c,p);if(!persist(c))status.textContent=ui.storage_error;}));
196
  const searchForm = node('form', null, 'quran-search'); searchForm.setAttribute('role', 'search');
197
  const query = node('input'); query.type = 'search'; query.setAttribute('aria-label', ui.search); query.placeholder = ui.search;
198
  const submit = node('button', ui.search_submit); submit.type = 'submit'; searchForm.append(query, submit);
199
  const status = node('p', null, 'quran-meta'); status.setAttribute('role', 'status');
200
  const content = node('div');
201
+ const audioHost=node('div');
202
+ const actions=node('section',null,'quran-ayah-actions');actions.hidden=true;actions.setAttribute('aria-label',ui.verse_actions);
203
+ const actionTitle=node('h3'), actionStatus=node('p');actionStatus.setAttribute('role','status');
204
+ const listen=button(ui.listen,()=>{if(player&&actionVerse){player.playAt(actionVerse.id);actions.hidden=true;actionOrigin?.focus();}});
205
+ listen.disabled=true;
206
+ const copy=button(ui.copy,async()=>{
207
+ if(!actionVerse)return;
208
+ const text=actionVerse.text+'\n['+source.surahs[actionVerse.surah-1].name+': '+actionVerse.ayah+']';
209
+ try{await navigator.clipboard.writeText(text);actionStatus.textContent=ui.copied;}
210
+ catch{
211
+ const input=node('textarea',text);input.readOnly=true;input.setAttribute('aria-label',ui.copy);
212
+ actionStatus.replaceChildren(node('span',ui.copy_failed),input);input.focus();input.select();
213
+ }
214
+ });
215
+ actions.append(actionTitle,listen,copy,button(ui.close_actions,()=>{actions.hidden=true;actionOrigin?.focus();}),actionStatus);
216
+ function selectVerse(verse, origin) {
217
+ actionVerse=verse;actionOrigin=origin;
218
+ actionTitle.textContent=source.surahs[verse.surah-1].name+' · '+ui.ayah+' '+verse.ayah;
219
+ actionStatus.replaceChildren();actions.hidden=false;(listen.disabled?copy:listen).focus();
220
+ }
221
  const nav = node('div', null, 'quran-nav');
222
  const previous = button(ui.previous, () => showPage(page - 1));
223
  const next = button(ui.next, () => showPage(page + 1)); nav.append(previous, next);
224
  const attribution = node('a', ui.source); attribution.href = new URL(c.source_file, base).href;
225
  attribution.target = '_blank'; attribution.rel = 'noopener'; attribution.className = 'quran-meta';
226
+ root.append(toolbar, audioHost, searchForm, status, actions, content, nav, attribution);
227
+ function paintPlaying() {
228
+ for(const verse of content.querySelectorAll('.quran-verse')) {
229
+ const playing=audioState?.phase==='playing'&&verse.id==='quran-verse-'+audioState.verse.id;
230
+ verse.classList.toggle('quran-playing',playing);
231
+ if(playing)verse.setAttribute('aria-current','true');else verse.removeAttribute('aria-current');
232
+ }
233
+ }
234
+ function audioChanged(state) {
235
+ if(!mounted)return;
236
+ audioState=state;
237
+ if(state.phase==='playing'&&state.verse.id!==lastFollowed&&p.follow_audio) {
238
+ lastFollowed=state.verse.id;
239
+ if(page!==state.verse.page||!content.querySelector('.quran-page-text'))showPage(state.verse.page,null,true);
240
+ document.getElementById('quran-verse-'+state.verse.id)?.scrollIntoView({block:'nearest'});
241
+ }
242
+ paintPlaying();
243
+ }
244
+ async function mountAudio() {
245
+ const attempt=++audioAttempt;
246
+ audioHost.replaceChildren(node('p',ui.audio_loading,'quran-meta'));
247
+ try {
248
+ if(!window.MuslimClockQuranAudio)throw Error('quran_audio.js');
249
+ const nextPlayer=await window.MuslimClockQuranAudio.mount(audioHost,{
250
+ verses:source.verses,surahs:source.surahs,initialId:focusVerse?.id||source.pages[page][0].id,
251
+ isActive:()=>mounted&&attempt===audioAttempt&&root.isConnected,onState:audioChanged
252
+ });
253
+ if(!mounted||attempt!==audioAttempt){nextPlayer?.dispose();return;}
254
+ player=nextPlayer;listen.disabled=!player;
255
+ } catch(error) {
256
+ if(!mounted||attempt!==audioAttempt)return;
257
+ audioHost.replaceChildren(node('p',ui.audio_unavailable,'quran-meta'),button(ui.retry,mountAudio));
258
+ }
259
+ }
260
+ function showPage(value, selectedVerse, fromAudio=false) {
261
  if (!Number.isInteger(value) || !source.pages[value]?.length) { status.textContent = format(ui.invalid_page, {total:source.counts.pages}); return; }
262
  page = value;
263
  const verses = source.pages[page];
264
+ content.replaceChildren(); nav.hidden = false;actions.hidden=true;
265
  pageInput.value = page; surahSelect.value = selectedVerse?.surah || verses[0].surah; juzSelect.value = selectedVerse?.juz || verses[0].juz;
266
  const juz = [...new Set(verses.map(v => v.juz))].join('، ');
267
  status.textContent = format(ui.page_status, {page, total: source.counts.pages, juz});
 
274
  paragraph = node('p', null, 'quran-page-text'); content.append(paragraph); lastSurah = verse.surah;
275
  }
276
  const span = node('span', verse.text, 'quran-verse');
277
+ span.id = 'quran-verse-' + verse.id; span.tabIndex = 0;span.setAttribute('role','button');
278
+ span.setAttribute('aria-label',verse.text+' · '+ui.ayah+' '+verse.ayah+' · '+ui.verse_actions);
279
+ span.addEventListener('click',()=>selectVerse(verse,span));
280
+ span.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectVerse(verse,span);}});
281
  const number = node('span', ' (' + verse.ayah + ') ', 'quran-verse-number');
282
  number.setAttribute('aria-label', ui.ayah + ' ' + verse.ayah);
283
  span.append(number); paragraph.append(span, document.createTextNode(' '));
 
286
  p.page = page;
287
  if (!persist(c)) status.textContent += ' ' + ui.storage_error;
288
  }
289
+ if(!fromAudio){document.getElementById('adaptivePopupBody').scrollTop = 0;player?.select(selectedVerse?.id||verses[0].id);}
290
+ paintPlaying();
291
  if (selectedVerse) {
292
  const target = document.getElementById('quran-verse-' + selectedVerse.id);
293
  target?.focus({preventScroll: true}); target?.scrollIntoView({block:'center'});
 
305
  const text = normalize(query.value);
306
  if (!text) { status.textContent = ui.search_empty; query.focus(); return; }
307
  const matches = source.verses.filter((_, i) => source.searchIndex[i].includes(text));
308
+ player?.pause();actions.hidden=true;
309
  content.replaceChildren(); nav.hidden = true;
310
  status.textContent = matches.length ? format(ui.results, {count: matches.length}) : ui.no_results;
311
  content.append(button(ui.reader, () => showPage(page)));
 
325
  appendResults();
326
  });
327
  showPage(page, focusVerse);
328
+ mountAudio();
329
  }
330
  async function openSettings(back) {
331
  const request = ++epoch;
 
340
  const rememberLabel = node('label', ui.remember_page), remember = node('input');
341
  remember.type = 'checkbox'; remember.checked = p.remember_page; rememberLabel.prepend(remember);
342
  const status = node('p'); status.setAttribute('role', 'status');
343
+ const theme=themeField(c,p,value=>{p.theme=value;applyTheme(c,p);});
344
+ const followLabel=node('label',ui.follow_audio),follow=node('input');follow.type='checkbox';follow.checked=p.follow_audio;followLabel.prepend(follow);
345
+ root.append(label, rememberLabel, theme, followLabel, button(ui.save, () => {
346
  p.font_size = Number(font.value); p.remember_page = remember.checked;
347
+ p.follow_audio=follow.checked;
348
  if (!p.remember_page) p.page = 1;
349
  status.textContent = persist(c) ? ui.saved : ui.storage_error;
350
  }), status);
351
  panel(ui.settings, root, back, ui);
352
+ applyTheme(c,p);
353
  }
354
+ window.addEventListener('muslim-clock-popup-leave', () => {
355
+ epoch++;cleanup();cleanup=()=>{};
356
+ const card=document.getElementById('adaptivePopupCard');
357
+ if(card){delete card.dataset.quranTheme;for(const key of ['bg','fg','surface','border','highlight','highlight_fg'])card.style.removeProperty('--quran-'+key);}
358
+ });
359
  window.addEventListener('muslim-clock-quran-open', event => open(event.detail || {}));
360
  window.MuslimClockQuran = Object.freeze({open, openSettings,
361
  mountEntry: (parent, back) => mount(parent, back, false),
quran_reader_registry.json CHANGED
@@ -3,7 +3,11 @@
3
  "core_file": "quran_core_registry.json",
4
  "source_file": "al_khatma_source_attribution.md",
5
  "storage_key": "muslim_clock_quran_reader_v1",
6
- "defaults": {"font_size": 26, "remember_page": true},
 
 
 
 
7
  "limits": {"font_min": 20, "font_max": 40, "search_batch": 40, "request_timeout_ms": 30000},
8
  "ui": {
9
  "title": "القرآن الكريم",
@@ -34,6 +38,20 @@
34
  "saved": "حُفظت إعدادات القراءة.",
35
  "storage_error": "تعذّر الحفظ على هذا الجهاز. ستبقى الإعدادات لهذه الجلسة فقط.",
36
  "back": "رجوع",
37
- "source": "مصدر النص والترخيص"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
38
  }
39
  }
 
3
  "core_file": "quran_core_registry.json",
4
  "source_file": "al_khatma_source_attribution.md",
5
  "storage_key": "muslim_clock_quran_reader_v1",
6
+ "defaults": {"font_size": 26, "remember_page": true, "theme": "auto", "follow_audio": true},
7
+ "themes": {
8
+ "light": {"bg":"#ffffff","fg":"#19241d","surface":"#f1f5f2","border":"#7b8980","highlight":"#c5e9d0","highlight_fg":"#102b1a"},
9
+ "dark": {"bg":"#171b18","fg":"#f0f5f1","surface":"#252c27","border":"#87978c","highlight":"#365a40","highlight_fg":"#ffffff"}
10
+ },
11
  "limits": {"font_min": 20, "font_max": 40, "search_batch": 40, "request_timeout_ms": 30000},
12
  "ui": {
13
  "title": "القرآن الكريم",
 
38
  "saved": "حُفظت إعدادات القراءة.",
39
  "storage_error": "تعذّر الحفظ على هذا الجهاز. ستبقى الإعدادات لهذه الجلسة فقط.",
40
  "back": "رجوع",
41
+ "source": "مصدر النص والترخيص",
42
+ "theme": "مظهر القراءة",
43
+ "theme_auto": "حسب الجهاز",
44
+ "theme_light": "فاتح",
45
+ "theme_dark": "داكن",
46
+ "follow_audio": "متابعة الآية المتلوة والتنقّل معها",
47
+ "verse_actions": "إجراءات الآية",
48
+ "listen": "استماع من هذه الآية",
49
+ "copy": "نسخ الآية",
50
+ "copied": "نُسخت الآية مع مرجعها.",
51
+ "copy_failed": "تعذّر النسخ التلقائي؛ يمكنك تحديد النص ونسخه.",
52
+ "close_actions": "إغلاق إجراءات الآية",
53
+ "audio_loading": "جارٍ تجهيز مشغل التلاوة…",
54
+ "audio_unavailable": "تعذّر تجهيز الصوت. القراءة متاحة؛ أعد محاولة تحميل المشغّل.",
55
+ "search_surah": "بحث عن سورة"
56
  }
57
  }
registry_app.js CHANGED
@@ -800,7 +800,7 @@ const RegistryApp = (() => {
800
  if($('adaptivePopup').hidden)return;
801
  if(e.key==='Escape'){e.preventDefault();close();}
802
  if(e.key==='Tab'){
803
- const nodes=[...$('adaptivePopup').querySelectorAll('button:not([hidden]),a[href],input,select,textarea')].filter(e=>!e.closest('[hidden]')),first=nodes[0],last=nodes[nodes.length-1];
804
  if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
805
  else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
806
  }
 
800
  if($('adaptivePopup').hidden)return;
801
  if(e.key==='Escape'){e.preventDefault();close();}
802
  if(e.key==='Tab'){
803
+ const nodes=[...$('adaptivePopup').querySelectorAll('button,a[href],input,select,textarea,summary,[tabindex="0"]')].filter(e=>!e.disabled&&!e.closest('[hidden]')&&e.tabIndex>=0&&(!e.closest('details:not([open])')||e.tagName==='SUMMARY')),first=nodes[0],last=nodes[nodes.length-1];
804
  if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
805
  else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
806
  }