Spaces:
Running
Running
Upload 8 files
Browse files- QURAN_INTEGRATION.md +28 -6
- index.html +4 -3
- quran_audio.js +200 -0
- quran_audio_registry.json +48 -0
- quran_reader.css +24 -0
- quran_reader.js +104 -11
- quran_reader_registry.json +20 -2
- registry_app.js +1 -1
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 |
-
-
|
| 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-
|
| 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="
|
| 229 |
-
<script src="
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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', () => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
}
|