Asem75 commited on
Commit
924868d
·
verified ·
1 Parent(s): 7fae3bf

Upload 7 files

Browse files
quran_audio.js CHANGED
@@ -32,7 +32,7 @@
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. */ }
@@ -40,15 +40,20 @@
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(release = false) {
51
- clearTimer(); token++; playRequest++; wantsPlay = false;
52
  removeEvents(); removeEvents = () => {};
53
  if (media) {media.pause(); media.removeAttribute('src'); media.load(); if (release) media = null;}
54
  }
@@ -78,7 +83,7 @@
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
  // Reuse the element unlocked by the user's first play gesture.
84
  const activeMedia = media || new Audio(); media = activeMedia;
@@ -103,18 +108,26 @@
103
  if (activeMedia.ended || !activeMedia.paused) return;
104
  if (phase === 'playing') {wantsPlay = false; clearTimer(); phase = 'paused'; emit();}
105
  });
106
- on('loadedmetadata', () => {duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0; emit();});
 
 
 
 
107
  on('durationchange', () => {duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0; emit();});
108
  on('timeupdate', () => {currentTime = activeMedia.currentTime || 0; emit();});
109
  on('error', () => {if (activeMedia.error) fail();});
110
  on('ended', () => {
111
  if (!wantsPlay || !activeMedia.ended) return;
112
  clearTimer();
 
113
  if (repetitions === 0 || repetitions > 1) {
114
  if (repetitions > 1) repetitions--;
115
- playAt(verses[index].id, false);
116
- } else if (settings.advance && index < verses.length - 1) playAt(verses[index + 1].id);
117
- else {wantsPlay = false; phase = 'ended'; emit();}
 
 
 
118
  });
119
  try {activeMedia.src = urlFor(verses[index]); startMedia();} catch {fail();}
120
  }
@@ -124,11 +137,12 @@
124
  }
125
  function pause() {
126
  if (!media || disposed) return;
127
- wantsPlay = false; playRequest++; clearTimer(); media.pause(); phase = 'paused'; emit();
128
  }
129
  function stop() {detach(); phase = 'stopped'; currentTime = 0; duration = 0; emit();}
130
  function toggle() {
131
  if (phase === 'playing' || phase === 'loading') pause();
 
132
  else if (media && ['paused','blocked'].includes(phase)) startMedia();
133
  else playAt(verses[index].id);
134
  }
@@ -138,6 +152,7 @@
138
  if (c.rates.includes(patch.rate)) settings.rate = patch.rate;
139
  if (c.repeat_options.some(r => r.value === patch.repeat)) {settings.repeat = patch.repeat;repetitions = patch.repeat;}
140
  if (typeof patch.advance === 'boolean') settings.advance = patch.advance;
 
141
  if (oldReciter !== settings.reciter) {stop(); if (running) playAt(verses[index].id);}
142
  if (media) media.playbackRate = settings.rate;
143
  let stored = true;
@@ -149,13 +164,19 @@
149
  try {media.currentTime = Math.min(duration, Math.max(0, seconds));currentTime = media.currentTime;emit();} catch { /* Metadata may be changing. */ }
150
  }
151
  }
152
- return {playAt, select, pause, stop, toggle, configure, seek, snapshot,
 
 
 
 
 
 
153
  playContinuous: () => {configure({advance:true,repeat:1});playAt(verses[index].id);},
154
  previous: () => {if (index > 0) playAt(verses[index - 1].id);},
155
- next: () => {if (index < verses.length - 1) playAt(verses[index + 1].id);},
156
  dispose: () => {disposed = true;detach(true);}};
157
  }
158
- async function mount(host, {verses, surahs, initialId, onState, isActive = () => host.isConnected}) {
159
  const c = await configuration();
160
  if (!isActive()) return null;
161
  const ui = c.ui, wrap = element('section', null, 'quran-audio'); wrap.setAttribute('aria-label',ui.title);
@@ -171,34 +192,44 @@
171
  const reciter = select(ui.reciter,c.reciters.map(r=>[r.id,r.name]),value=>set({reciter:value}));
172
  const rate = select(ui.rate,c.rates.map(r=>[r,r+'×']),value=>set({rate:Number(value)}));
173
  const repeat = select(ui.repeat,c.repeat_options.map(r=>[r.value,r.label]),value=>set({repeat:Number(value)}));
 
174
  const advanceLabel=element('label',ui.advance), advance=element('input');advance.type='checkbox';
175
  advance.addEventListener('change',()=>set({advance:advance.checked}));advanceLabel.prepend(advance);
176
  const actions=element('div',null,'quran-audio-actions');
177
  const previous=button(ui.previous,()=>player.previous()), play=button(ui.play,()=>player.toggle());
178
  const stop=button(ui.stop,()=>player.stop()), next=button(ui.next,()=>player.next());actions.append(previous,play,stop,next);
179
  const continuous=button(ui.play_continuous,()=>player.playContinuous());
180
- const current=element('p',null,'quran-meta'), state=element('p',null,'quran-meta');state.setAttribute('role','status');
 
 
181
  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;
182
  seek.addEventListener('change',()=>player.seek(Number(seek.value)));seekLabel.append(seek);
183
- const clock=element('output');clock.dir='ltr';
184
- const time=s=>Math.floor(s/60)+':'+String(Math.floor(s%60)).padStart(2,'0');
185
  const source=element('a',ui.source);source.href=c.source.url;source.target='_blank';source.rel='noopener noreferrer';source.className='quran-meta';
186
  let lastStatus='';
187
  player=createPlayer(c,verses,s=>{
188
  if (!isActive()) return;
189
  reciter.value=s.settings.reciter;rate.value=s.settings.rate;repeat.value=s.settings.repeat;advance.checked=s.settings.advance;
190
- current.textContent=interpolate(ui.current,{surah:surahs[s.verse.surah-1].name,ayah:s.verse.ayah});
191
- if (lastStatus !== s.phase) {state.textContent=ui[s.phase]||ui.idle;lastStatus=s.phase;}
 
 
 
 
 
 
192
  play.textContent=['playing','loading'].includes(s.phase)?ui.pause:s.phase==='paused'?ui.resume:ui.play;
193
  previous.disabled=s.verse.id===verses[0].id;next.disabled=s.verse.id===verses[verses.length-1].id;
194
  seek.max=s.duration;seek.disabled=s.duration<=0;
195
  if (document.activeElement!==seek) seek.value=s.currentTime;
196
- clock.textContent=time(s.currentTime)+' / '+time(s.duration);
 
197
  onState(s);
198
- });
199
  const options=element('details'),summary=element('summary',ui.options);
200
  options.append(summary,controls,source);
201
- wrap.append(current,continuous,actions,advanceLabel,seekLabel,clock,state,options,storageStatus);host.replaceChildren(wrap);
202
  player.select(initialId);
203
  // Initialize controls even when the requested verse is already the first one.
204
  player.configure({});
 
32
  })().catch(error => {configPromise = null; throw error;});
33
  return configPromise;
34
  }
35
+ function createPlayer(c, verses, notify, endId) {
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. */ }
 
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
+ gap: (c.gap_options || []).some(g => g.value === saved?.gap) ? saved.gap : (c.defaults.gap_ms ?? 250)
45
  };
46
  let index = 0, media = null, token = 0, playRequest = 0, disposed = false, wantsPlay = false, repetitions = settings.repeat;
47
  let phase = 'idle', currentTime = 0, duration = 0, loadTimer, removeEvents = () => {};
48
+ // played: the person actually started playback (used by the reader to save a resume point).
49
+ let played = false, gapTimer = null, pending = null, seekOnLoad = 0;
50
+ const lastIndex = Number.isInteger(byId.get(endId)) ? byId.get(endId) : verses.length - 1;
51
+ const gapMs = () => Number.isFinite(settings.gap) ? settings.gap : 250;
52
+ const snapshot = () => ({verse:verses[index], phase, currentTime, duration, played, settings:{...settings}});
53
  const emit = () => {if (!disposed) notify(snapshot());};
54
  const clearTimer = () => {clearTimeout(loadTimer);loadTimer = null;};
55
  function detach(release = false) {
56
+ clearTimer(); clearTimeout(gapTimer); gapTimer = null; pending = null; token++; playRequest++; wantsPlay = false;
57
  removeEvents(); removeEvents = () => {};
58
  if (media) {media.pause(); media.removeAttribute('src'); media.load(); if (release) media = null;}
59
  }
 
83
  }
84
  function playAt(id, resetRepeat = true) {
85
  if (disposed || !byId.has(id)) return;
86
+ detach(); index = byId.get(id); currentTime = 0; duration = 0; played = true;
87
  if (resetRepeat) repetitions = settings.repeat;
88
  // Reuse the element unlocked by the user's first play gesture.
89
  const activeMedia = media || new Audio(); media = activeMedia;
 
108
  if (activeMedia.ended || !activeMedia.paused) return;
109
  if (phase === 'playing') {wantsPlay = false; clearTimer(); phase = 'paused'; emit();}
110
  });
111
+ on('loadedmetadata', () => {
112
+ duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0;
113
+ if (seekOnLoad > 0 && seekOnLoad < duration - 0.5) {try {activeMedia.currentTime = seekOnLoad;} catch { /* ignore */ }}
114
+ seekOnLoad = 0; emit();
115
+ });
116
  on('durationchange', () => {duration = Number.isFinite(activeMedia.duration) ? activeMedia.duration : 0; emit();});
117
  on('timeupdate', () => {currentTime = activeMedia.currentTime || 0; emit();});
118
  on('error', () => {if (activeMedia.error) fail();});
119
  on('ended', () => {
120
  if (!wantsPlay || !activeMedia.ended) return;
121
  clearTimer();
122
+ let step = null;
123
  if (repetitions === 0 || repetitions > 1) {
124
  if (repetitions > 1) repetitions--;
125
+ step = {id: verses[index].id, reset: false};
126
+ } else if (settings.advance && index < lastIndex) step = {id: verses[index + 1].id, reset: true};
127
+ if (!step) {wantsPlay = false; phase = 'ended'; emit(); return;}
128
+ const go = () => {gapTimer = null; pending = null; if (!disposed) playAt(step.id, step.reset);};
129
+ if (gapMs() <= 0) go();
130
+ else {pending = step; gapTimer = setTimeout(go, gapMs());}
131
  });
132
  try {activeMedia.src = urlFor(verses[index]); startMedia();} catch {fail();}
133
  }
 
137
  }
138
  function pause() {
139
  if (!media || disposed) return;
140
+ wantsPlay = false; playRequest++; clearTimer(); clearTimeout(gapTimer); gapTimer = null; media.pause(); phase = 'paused'; emit();
141
  }
142
  function stop() {detach(); phase = 'stopped'; currentTime = 0; duration = 0; emit();}
143
  function toggle() {
144
  if (phase === 'playing' || phase === 'loading') pause();
145
+ else if (pending && phase === 'paused') {const step = pending; pending = null; playAt(step.id, step.reset);}
146
  else if (media && ['paused','blocked'].includes(phase)) startMedia();
147
  else playAt(verses[index].id);
148
  }
 
152
  if (c.rates.includes(patch.rate)) settings.rate = patch.rate;
153
  if (c.repeat_options.some(r => r.value === patch.repeat)) {settings.repeat = patch.repeat;repetitions = patch.repeat;}
154
  if (typeof patch.advance === 'boolean') settings.advance = patch.advance;
155
+ if ((c.gap_options || []).some(g => g.value === patch.gap)) settings.gap = patch.gap;
156
  if (oldReciter !== settings.reciter) {stop(); if (running) playAt(verses[index].id);}
157
  if (media) media.playbackRate = settings.rate;
158
  let stored = true;
 
164
  try {media.currentTime = Math.min(duration, Math.max(0, seconds));currentTime = media.currentTime;emit();} catch { /* Metadata may be changing. */ }
165
  }
166
  }
167
+ function resumeFrom(point) {
168
+ if (!point || !byId.has(point.verseId)) return;
169
+ if (c.reciters.some(r => r.id === point.reciter) && point.reciter !== settings.reciter) configure({reciter:point.reciter});
170
+ playAt(point.verseId);
171
+ seekOnLoad = Number.isFinite(point.time) ? point.time : 0;
172
+ }
173
+ return {playAt, select, pause, stop, toggle, configure, seek, snapshot, resumeFrom,
174
  playContinuous: () => {configure({advance:true,repeat:1});playAt(verses[index].id);},
175
  previous: () => {if (index > 0) playAt(verses[index - 1].id);},
176
+ next: () => {if (index < lastIndex) playAt(verses[index + 1].id);},
177
  dispose: () => {disposed = true;detach(true);}};
178
  }
179
+ async function mount(host, {verses, surahs, initialId, endId, onState, isActive = () => host.isConnected}) {
180
  const c = await configuration();
181
  if (!isActive()) return null;
182
  const ui = c.ui, wrap = element('section', null, 'quran-audio'); wrap.setAttribute('aria-label',ui.title);
 
192
  const reciter = select(ui.reciter,c.reciters.map(r=>[r.id,r.name]),value=>set({reciter:value}));
193
  const rate = select(ui.rate,c.rates.map(r=>[r,r+'×']),value=>set({rate:Number(value)}));
194
  const repeat = select(ui.repeat,c.repeat_options.map(r=>[r.value,r.label]),value=>set({repeat:Number(value)}));
195
+ const gap=select(ui.gap||'الفاصل بين الآيات',(c.gap_options||[{value:250,label:'ربع ثانية'}]).map(g=>[g.value,g.label]),value=>set({gap:Number(value)}));
196
  const advanceLabel=element('label',ui.advance), advance=element('input');advance.type='checkbox';
197
  advance.addEventListener('change',()=>set({advance:advance.checked}));advanceLabel.prepend(advance);
198
  const actions=element('div',null,'quran-audio-actions');
199
  const previous=button(ui.previous,()=>player.previous()), play=button(ui.play,()=>player.toggle());
200
  const stop=button(ui.stop,()=>player.stop()), next=button(ui.next,()=>player.next());actions.append(previous,play,stop,next);
201
  const continuous=button(ui.play_continuous,()=>player.playContinuous());
202
+ const current=element('p',null,'quran-meta'), state=element('p',null,'quran-meta');
203
+ state.setAttribute('aria-live','off');
204
+ const announcer=element('p',null,'quran-sr-only');announcer.setAttribute('role','status');announcer.setAttribute('aria-live','polite');announcer.setAttribute('aria-atomic','true');
205
  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;
206
  seek.addEventListener('change',()=>player.seek(Number(seek.value)));seekLabel.append(seek);
207
+ const clock=element('output');clock.dir='ltr';clock.setAttribute('role','timer');clock.setAttribute('aria-live','off');
208
+ const time=s=>(s=Number.isFinite(s)?s:0,Math.floor(s/60))+':'+String(Math.floor(s%60)).padStart(2,'0');
209
  const source=element('a',ui.source);source.href=c.source.url;source.target='_blank';source.rel='noopener noreferrer';source.className='quran-meta';
210
  let lastStatus='';
211
  player=createPlayer(c,verses,s=>{
212
  if (!isActive()) return;
213
  reciter.value=s.settings.reciter;rate.value=s.settings.rate;repeat.value=s.settings.repeat;advance.checked=s.settings.advance;
214
+ const label=interpolate(ui.current,{surah:surahs[s.verse.surah-1].name,ayah:s.verse.ayah});
215
+ if (current.textContent!==label) current.textContent=label;
216
+ gap.value=s.settings.gap;
217
+ if (lastStatus !== s.phase) {
218
+ state.textContent=ui[s.phase]||ui.idle;lastStatus=s.phase;
219
+ // Spoken only for states the person must know about; not on every verse change.
220
+ announcer.textContent=['paused','stopped','ended','error','blocked'].includes(s.phase)?(ui[s.phase]||''):'';
221
+ }
222
  play.textContent=['playing','loading'].includes(s.phase)?ui.pause:s.phase==='paused'?ui.resume:ui.play;
223
  previous.disabled=s.verse.id===verses[0].id;next.disabled=s.verse.id===verses[verses.length-1].id;
224
  seek.max=s.duration;seek.disabled=s.duration<=0;
225
  if (document.activeElement!==seek) seek.value=s.currentTime;
226
+ const clockText=time(s.currentTime)+' / '+time(s.duration);
227
+ clock.textContent=clockText;seek.setAttribute('aria-valuetext',clockText);
228
  onState(s);
229
+ },endId);
230
  const options=element('details'),summary=element('summary',ui.options);
231
  options.append(summary,controls,source);
232
+ wrap.append(current,continuous,actions,advanceLabel,seekLabel,clock,state,announcer,options,storageStatus);host.replaceChildren(wrap);
233
  player.select(initialId);
234
  // Initialize controls even when the requested verse is already the first one.
235
  player.configure({});
quran_audio_registry.json CHANGED
@@ -11,19 +11,86 @@
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": "husary", "name": "محمود خليل الحصري", "folder": "Husary_128kbps"},
15
- {"id": "minshawy", "name": "محمد صديق المنشاوي", "folder": "Minshawy_Murattal_128kbps"},
16
- {"id": "abdulbasit", "name": "عبد الباسط عبد الصمد — مرتل", "folder": "Abdul_Basit_Murattal_192kbps"},
17
- {"id": "sudais", "name": "عبد الرحمن السديس", "folder": "Abdurrahmaan_As-Sudais_192kbps"},
18
- {"id": "shuraym", "name": "سعود الشريم", "folder": "Saood_ash-Shuraym_128kbps"},
19
- {"id": "maher", "name": "ماهر المعيقلي", "folder": "MaherAlMuaiqly128kbps"},
20
- {"id": "hudhaify", "name": "علي الحذيفي", "folder": "Hudhaify_128kbps"},
21
- {"id": "dussary", "name": "ياسر الدوسري", "folder": "Yasser_Ad-Dussary_128kbps"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
  ],
23
- "defaults": {"reciter": "husary", "rate": 1, "repeat": 1, "advance": true},
24
- "rates": [0.75, 1, 1.25, 1.5],
25
- "repeat_options": [{"value":1,"label":"مرة واحدة"},{"value":2,"label":"مرتان"},{"value":3,"label":"3 مرات"},{"value":5,"label":"5 مرات"},{"value":0,"label":"تكرار مستمر"}],
26
- "limits": {"request_timeout_ms": 15000, "loading_timeout_ms": 30000},
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  "ui": {
28
  "title": "الاستماع",
29
  "options": "القارئ وخيارات التلاوة",
@@ -49,6 +116,29 @@
49
  "error": "تعذّر تشغيل المقطع. تحقق من الإنترنت أو اختر قارئًا آخر ثم اضغط تشغيل.",
50
  "blocked": "اضغط تشغيل لاستكمال التلاوة؛ المتصفح يحتاج ضغطة منك.",
51
  "storage_error": "تعذّر حفظ تفضيلات الصوت على هذا الجهاز.",
52
- "source": "مصدر التلاوة: EveryAyah"
53
- }
54
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ {
15
+ "id": "husary",
16
+ "name": "محمود خليل الحصري",
17
+ "folder": "Husary_128kbps"
18
+ },
19
+ {
20
+ "id": "minshawy",
21
+ "name": "محمد صديق المنشاوي",
22
+ "folder": "Minshawy_Murattal_128kbps"
23
+ },
24
+ {
25
+ "id": "abdulbasit",
26
+ "name": "عبد الباسط عبد الصمد — مرتل",
27
+ "folder": "Abdul_Basit_Murattal_192kbps"
28
+ },
29
+ {
30
+ "id": "sudais",
31
+ "name": "عبد الرحمن السديس",
32
+ "folder": "Abdurrahmaan_As-Sudais_192kbps"
33
+ },
34
+ {
35
+ "id": "shuraym",
36
+ "name": "سعود الشريم",
37
+ "folder": "Saood_ash-Shuraym_128kbps"
38
+ },
39
+ {
40
+ "id": "maher",
41
+ "name": "ماهر المعيقلي",
42
+ "folder": "MaherAlMuaiqly128kbps"
43
+ },
44
+ {
45
+ "id": "hudhaify",
46
+ "name": "علي الحذيفي",
47
+ "folder": "Hudhaify_128kbps"
48
+ },
49
+ {
50
+ "id": "dussary",
51
+ "name": "ياسر الدوسري",
52
+ "folder": "Yasser_Ad-Dussary_128kbps"
53
+ }
54
  ],
55
+ "defaults": {
56
+ "reciter": "husary",
57
+ "rate": 1,
58
+ "repeat": 1,
59
+ "advance": true,
60
+ "gap_ms": 250
61
+ },
62
+ "rates": [
63
+ 0.75,
64
+ 1,
65
+ 1.25,
66
+ 1.5
67
+ ],
68
+ "repeat_options": [
69
+ {
70
+ "value": 1,
71
+ "label": "مرة واحدة"
72
+ },
73
+ {
74
+ "value": 2,
75
+ "label": "مرتان"
76
+ },
77
+ {
78
+ "value": 3,
79
+ "label": "3 مرات"
80
+ },
81
+ {
82
+ "value": 5,
83
+ "label": "5 مرات"
84
+ },
85
+ {
86
+ "value": 0,
87
+ "label": "تكرار مستمر"
88
+ }
89
+ ],
90
+ "limits": {
91
+ "request_timeout_ms": 15000,
92
+ "loading_timeout_ms": 30000
93
+ },
94
  "ui": {
95
  "title": "الاستماع",
96
  "options": "القارئ وخيارات التلاوة",
 
116
  "error": "تعذّر تشغيل المقطع. تحقق من الإنترنت أو اختر قارئًا آخر ثم اضغط تشغيل.",
117
  "blocked": "اضغط تشغيل لاستكمال التلاوة؛ المتصفح يحتاج ضغطة منك.",
118
  "storage_error": "تعذّر حفظ تفضيلات الصوت على هذا الجهاز.",
119
+ "source": "مصدر التلاوة: EveryAyah",
120
+ "gap": "الفاصل بين الآيات"
121
+ },
122
+ "gap_options": [
123
+ {
124
+ "value": 0,
125
+ "label": "بدون فاصل"
126
+ },
127
+ {
128
+ "value": 250,
129
+ "label": "ربع ثانية"
130
+ },
131
+ {
132
+ "value": 500,
133
+ "label": "نصف ثانية"
134
+ },
135
+ {
136
+ "value": 1000,
137
+ "label": "ثانية"
138
+ },
139
+ {
140
+ "value": 2000,
141
+ "label": "ثانيتان"
142
+ }
143
+ ]
144
+ }
quran_khatma.js CHANGED
@@ -50,59 +50,77 @@
50
  function savePosition(ctx,kind,value){if(!settings||!ctx)return true;try{const s=read(),p=s.plan;if(p?.id!==ctx.planId||!valid(p))return true;const r=resolvedRange(p,ctx.dayIndex);if(!Number.isInteger(value.verseId)||value.verseId<r.startId||value.verseId>r.endId)return true;p.positions??={};p.positions[ctx.dayIndex]??={};p.positions[ctx.dayIndex][kind]=value;write(s);ctx.progress??={};ctx.progress[kind]=value;return true;}catch{return false;}}
51
  async function openReminder(item,{at,back}={}){const mode=item.openQuran===undefined?(item.type==='quran'?'wird':''):item.openQuran;if(mode==='wird')return openWird({at,back});return window.MuslimClockQuran.open({resumeReading:true,back});}
52
  async function sync(plan){if(typeof RegistryApp==='undefined'||!RegistryApp.syncQuranPlan)throw Error('reminder');RegistryApp.syncQuranPlan(plan,settings.ui.reminder_title);}
53
- async function show({back}={}){
54
  const request=revision;let c;try{c=await ready();}catch{if(request===revision)await window.MuslimClockQuran.open({back,khatmaUnavailable:true});return;}
55
  if(request!==revision)return;
56
  const ui=c.ui,root=el('div');root.className='quran-khatma';let state;
 
 
 
 
 
 
57
  try{state=read();}catch{root.append(el('p',ui.storage_error));RegistryApp.openPanel('worship',{title:ui.title,compact:true},()=>document.getElementById('adaptivePopupBody').append(root));return;}
58
  const plan=valid(state.plan)?state.plan:null;
59
- const status=el('p');status.setAttribute('role','status');
60
  function rangeLabel(r,day){return fmt(ui.range_exact,{day,start:r.startKey,end:r.endKey,first:r.startPage,last:r.endPage});}
61
- function paint(){root.replaceChildren();root.append(el('p',ui.note));
62
- if(plan){root.append(el('p',plan.layout?fmt(ui.summary_exact,{days:plan.days,quantity:plan.amount||'',unit:source.partitions.units.find(u=>u.id===plan.unit)?.label||'',start:plan.startDate}):fmt(ui.summary,{days:plan.days,page:plan.startPage,start:plan.startDate})));if(!plan.layout)root.append(el('p',ui.legacy_note));
 
63
  const idx=current(plan),r=resolvedRange(plan,Math.max(0,Math.min(plan.days-1,idx)));
64
- root.append(el('p',plan.paused?ui.paused:idx<0?ui.before:idx>=plan.days?ui.after:rangeLabel(r,idx+1)));
65
- if(!plan.paused)root.append(button(ui.open,()=>openWird({back:()=>show({back})})));
66
- const label=el('label',ui.choose_day),select=el('select');for(let i=0;i<plan.days;i++){const r=resolvedRange(plan,i),o=el('option',rangeLabel(r,i+1));o.value=i;select.append(o);}select.value=Math.max(0,Math.min(plan.days-1,idx));label.append(select);root.append(label,button(ui.open,async()=>{const dayIndex=Number(select.value),r=resolvedRange(plan,dayIndex);window.MuslimClockQuran.open({wird:{planId:plan.id,dayIndex,...r,progress:plan.positions?.[dayIndex]||{}},page:r.startPage,back:()=>show({back})});}));
67
- root.append(button(plan.paused?ui.resume:ui.stop,async()=>{try{const s=read();s.plan.paused=!s.plan.paused;write(s);try{await sync(s.plan);}catch{status.textContent=ui.reminder_error;return;}show({back});}catch{status.textContent=ui.storage_error;}}));
68
- root.append(button(ui.edit,()=>form(plan)),button(ui.new,()=>form(null)));
69
- }else {root.append(el('p',ui.empty));form(null);return;}
70
- root.append(status);
 
71
  }
72
- function form(old){root.replaceChildren(el('p',ui.note));
73
- function field(name,type,value){const l=el('label',ui[name]),input=el('input');input.type=type;input.value=value;l.append(input);root.append(l);return input;}
 
 
74
  const unitLabel=el('label',ui.unit),unit=el('select');
75
  if(old&&!old.layout){const opt=el('option',ui.legacy_note);opt.value='legacy_pages';unit.append(opt);}
76
- for(const u of source.partitions.units){const opt=el('option',u.label);opt.value=u.id;unit.append(opt);}unit.value=old?(old.layout?old.unit:'legacy_pages'):'balanced_pages';unitLabel.append(unit);root.append(unitLabel);
77
- const days=field('days','number',old?.days??c.defaults.days);days.min=1;days.max=6236;days.step=1;
78
- const start=field('start','date',old?.startDate??dateKey()),page=field('page','number',old?.startPage??c.defaults.start_page);page.min=1;page.max=604;page.step=1;
79
- const amount=field('amount','number',old?.amount??1),startUnit=field('unit_start','number',old?.startUnit??1);amount.min=startUnit.min=1;amount.step=startUnit.step=1;
 
80
  const customStart=field('custom_start','text',old?.startKey??'1:1'),customEnd=field('custom_end','text',old?.endKey??'114:6');customStart.dir=customEnd.dir='ltr';
81
- const hint=el('p',ui.custom_hint);root.append(hint);
82
- function controls(){const balanced=['balanced_pages','legacy_pages'].includes(unit.value),custom=unit.value==='custom',u=source.partitions.units.find(u=>u.id===unit.value);days.parentElement.hidden=!balanced&&!custom;page.parentElement.hidden=!balanced;amount.parentElement.hidden=startUnit.parentElement.hidden=balanced||custom;customStart.parentElement.hidden=customEnd.parentElement.hidden=hint.hidden=!custom;if(u){amount.max=startUnit.max=u.count;}status.textContent='';}
83
- unit.onchange=controls;controls();
84
  const reminder=field('reminder','checkbox','');reminder.checked=old?.reminder===true;
85
- const time=field('time','time',old?.time??c.defaults.reminder_time);
86
- root.append(el('p',ui.scope),button(ui.save,async()=>{
 
 
 
87
  const next={id:old?.id||('khatma-'+Date.now().toString(36)),days:Number(days.value),startDate:start.value,startPage:Number(page.value),time:time.value,reminder:reminder.checked,paused:false,positions:old?.positions||{}};
88
  if(unit.value!=='legacy_pages'){
89
  next.layout=source.partitions.revision;next.unit=unit.value;
90
  if(unit.value==='custom'){next.startKey=customStart.value.trim();next.endKey=customEnd.value.trim();next.startPage=source.byKey.get(next.startKey)?.page;}
91
  else if(unit.value!=='balanced_pages'){
92
  const u=source.partitions.units.find(u=>u.id===unit.value);next.amount=Number(amount.value);next.startUnit=Number(startUnit.value);
93
- if(!u||!Number.isInteger(next.startUnit)||next.startUnit<1||next.startUnit>u.count||!Number.isInteger(next.amount)||next.amount<1||next.amount>u.count){status.textContent=ui.invalid;return;}
94
  next.days=Math.ceil((u.count-next.startUnit+1)/next.amount);next.startPage=source.verses[unitRange(next.unit,next.startUnit).startId-1].page;
95
  }
96
  }
97
- if(!valid(next)){status.textContent=ui.invalid;return;}
98
  const reset=plan&&(!old||['days','startDate','startPage','layout','unit','amount','startUnit','startKey','endKey'].some(k=>plan[k]!==next[k]));
99
  if(reset&&!window.confirm(ui.replace_confirm))return;if(reset)next.positions={};
100
- try{const s=read();s.plan=next;write(s);}catch{status.textContent=ui.storage_error;return;}
101
- try{await sync(next);}catch{status.textContent=ui.reminder_error;return;}
102
- show({back});
103
- }),status);
 
 
104
  }
105
- RegistryApp.openPanel('worship',{title:ui.title,compact:true},()=>{document.getElementById('adaptivePopupBody').append(root);if(back)document.getElementById('adaptivePopupActions').append(button(ui.back,back));});paint();
 
 
106
  }
107
  window.MuslimClockKhatma=Object.freeze({configuration,show,openWird,openReminder,savePosition,context,range,dateKey,dayNumber});
108
  })();
 
50
  function savePosition(ctx,kind,value){if(!settings||!ctx)return true;try{const s=read(),p=s.plan;if(p?.id!==ctx.planId||!valid(p))return true;const r=resolvedRange(p,ctx.dayIndex);if(!Number.isInteger(value.verseId)||value.verseId<r.startId||value.verseId>r.endId)return true;p.positions??={};p.positions[ctx.dayIndex]??={};p.positions[ctx.dayIndex][kind]=value;write(s);ctx.progress??={};ctx.progress[kind]=value;return true;}catch{return false;}}
51
  async function openReminder(item,{at,back}={}){const mode=item.openQuran===undefined?(item.type==='quran'?'wird':''):item.openQuran;if(mode==='wird')return openWird({at,back});return window.MuslimClockQuran.open({resumeReading:true,back});}
52
  async function sync(plan){if(typeof RegistryApp==='undefined'||!RegistryApp.syncQuranPlan)throw Error('reminder');RegistryApp.syncQuranPlan(plan,settings.ui.reminder_title);}
53
+ async function show({back,message,focus}={}){
54
  const request=revision;let c;try{c=await ready();}catch{if(request===revision)await window.MuslimClockQuran.open({back,khatmaUnavailable:true});return;}
55
  if(request!==revision)return;
56
  const ui=c.ui,root=el('div');root.className='quran-khatma';let state;
57
+ // Persistent live region: it is never removed, so changes are always announced.
58
+ const status=el('p');status.setAttribute('role','status');status.setAttribute('aria-live','polite');status.setAttribute('aria-atomic','true');status.tabIndex=-1;
59
+ const view=el('div');
60
+ const say=(text,{moveFocus=false}={})=>{status.textContent='';setTimeout(()=>{status.textContent=text||'';if(moveFocus&&text)status.focus();},40);};
61
+ // After every re-render, put the screen reader on the new heading instead of losing its place.
62
+ const landOn=heading=>{heading.tabIndex=-1;setTimeout(()=>heading.focus(),60);};
63
  try{state=read();}catch{root.append(el('p',ui.storage_error));RegistryApp.openPanel('worship',{title:ui.title,compact:true},()=>document.getElementById('adaptivePopupBody').append(root));return;}
64
  const plan=valid(state.plan)?state.plan:null;
65
+ root.append(view,status);
66
  function rangeLabel(r,day){return fmt(ui.range_exact,{day,start:r.startKey,end:r.endKey,first:r.startPage,last:r.endPage});}
67
+ function paint(land=true){view.replaceChildren();
68
+ const h=el('h3',ui.plan_heading||ui.title);view.append(h,el('p',ui.note));
69
+ if(plan){view.append(el('p',plan.layout?fmt(ui.summary_exact,{days:plan.days,quantity:plan.amount||'',unit:source.partitions.units.find(u=>u.id===plan.unit)?.label||'',start:plan.startDate}):fmt(ui.summary,{days:plan.days,page:plan.startPage,start:plan.startDate})));if(!plan.layout)view.append(el('p',ui.legacy_note));
70
  const idx=current(plan),r=resolvedRange(plan,Math.max(0,Math.min(plan.days-1,idx)));
71
+ view.append(el('p',plan.paused?ui.paused:idx<0?ui.before:idx>=plan.days?ui.after:rangeLabel(r,idx+1)));
72
+ if(!plan.paused)view.append(button(ui.open,()=>openWird({back:()=>show({back})})));
73
+ const label=el('label',ui.choose_day),select=el('select');for(let i=0;i<plan.days;i++){const rr=resolvedRange(plan,i),o=el('option',rangeLabel(rr,i+1));o.value=i;select.append(o);}select.value=Math.max(0,Math.min(plan.days-1,idx));label.append(select);
74
+ view.append(label,button(ui.open_selected||ui.open,async()=>{const dayIndex=Number(select.value),rr=resolvedRange(plan,dayIndex);window.MuslimClockQuran.open({wird:{planId:plan.id,dayIndex,...rr,progress:plan.positions?.[dayIndex]||{}},page:rr.startPage,back:()=>show({back})});}));
75
+ view.append(button(plan.paused?ui.resume:ui.stop,async()=>{try{const s=read();s.plan.paused=!s.plan.paused;write(s);try{await sync(s.plan);}catch{say(ui.reminder_error,{moveFocus:true});return;}show({back,message:s.plan.paused?ui.stopped_done:ui.resumed_done});}catch{say(ui.storage_error,{moveFocus:true});}}));
76
+ view.append(button(ui.edit,()=>form(plan)),button(ui.new,()=>form(null)));
77
+ }else {view.append(el('p',ui.empty));form(null,land);return;}
78
+ if(land)landOn(h);
79
  }
80
+ function form(old,land=true){view.replaceChildren();
81
+ const h=el('h3',old?ui.edit:ui.new);view.append(h,el('p',ui.note));
82
+ const described=(input,text,id)=>{const d=el('p',text);d.id=id;d.className='quran-meta';input.setAttribute('aria-describedby',id);return d;};
83
+ function field(name,type,value,hintText){const l=el('label',ui[name]),input=el('input');input.type=type;input.value=value;l.append(input);view.append(l);if(hintText){const d=described(input,hintText,'khatma-hint-'+name);l.append(d);}return input;}
84
  const unitLabel=el('label',ui.unit),unit=el('select');
85
  if(old&&!old.layout){const opt=el('option',ui.legacy_note);opt.value='legacy_pages';unit.append(opt);}
86
+ for(const u of source.partitions.units){const opt=el('option',u.label);opt.value=u.id;unit.append(opt);}unit.value=old?(old.layout?old.unit:'legacy_pages'):'balanced_pages';unitLabel.append(unit);view.append(unitLabel);
87
+ const days=field('days','number',old?.days??c.defaults.days);days.min=1;days.max=6236;days.step=1;days.inputMode='numeric';
88
+ const start=field('start','date',old?.startDate??dateKey(),ui.date_hint);
89
+ const page=field('page','number',old?.startPage??c.defaults.start_page);page.min=1;page.max=604;page.step=1;page.inputMode='numeric';
90
+ const amount=field('amount','number',old?.amount??1),startUnit=field('unit_start','number',old?.startUnit??1);amount.min=startUnit.min=1;amount.step=startUnit.step=1;amount.inputMode=startUnit.inputMode='numeric';
91
  const customStart=field('custom_start','text',old?.startKey??'1:1'),customEnd=field('custom_end','text',old?.endKey??'114:6');customStart.dir=customEnd.dir='ltr';
92
+ const hint=el('p',ui.custom_hint);view.append(hint);
93
+ function controls(){const balanced=['balanced_pages','legacy_pages'].includes(unit.value),custom=unit.value==='custom',u=source.partitions.units.find(u=>u.id===unit.value);days.parentElement.hidden=!balanced&&!custom;page.parentElement.hidden=!balanced;amount.parentElement.hidden=startUnit.parentElement.hidden=balanced||custom;customStart.parentElement.hidden=customEnd.parentElement.hidden=hint.hidden=!custom;if(u){amount.max=startUnit.max=u.count;amount.setAttribute('aria-description',fmt(ui.range_hint||'',{max:u.count}));startUnit.setAttribute('aria-description',fmt(ui.range_hint||'',{max:u.count}));}say('');}
94
+ unit.onchange=()=>{controls();say(fmt(ui.fields_changed||'',{unit:unit.selectedOptions[0]?.textContent||''}));};controls();
95
  const reminder=field('reminder','checkbox','');reminder.checked=old?.reminder===true;
96
+ const time=field('time','time',old?.time??c.defaults.reminder_time,ui.time_hint);
97
+ view.append(el('p',ui.scope));
98
+ const fail=(text,input)=>{say(text,{moveFocus:false});setTimeout(()=>{(input||status).focus();},80);};
99
+ view.append(button(ui.save,async()=>{
100
+ if(!start.value){fail(ui.invalid,start);return;}
101
  const next={id:old?.id||('khatma-'+Date.now().toString(36)),days:Number(days.value),startDate:start.value,startPage:Number(page.value),time:time.value,reminder:reminder.checked,paused:false,positions:old?.positions||{}};
102
  if(unit.value!=='legacy_pages'){
103
  next.layout=source.partitions.revision;next.unit=unit.value;
104
  if(unit.value==='custom'){next.startKey=customStart.value.trim();next.endKey=customEnd.value.trim();next.startPage=source.byKey.get(next.startKey)?.page;}
105
  else if(unit.value!=='balanced_pages'){
106
  const u=source.partitions.units.find(u=>u.id===unit.value);next.amount=Number(amount.value);next.startUnit=Number(startUnit.value);
107
+ if(!u||!Number.isInteger(next.startUnit)||next.startUnit<1||next.startUnit>u.count||!Number.isInteger(next.amount)||next.amount<1||next.amount>u.count){fail(ui.invalid,amount);return;}
108
  next.days=Math.ceil((u.count-next.startUnit+1)/next.amount);next.startPage=source.verses[unitRange(next.unit,next.startUnit).startId-1].page;
109
  }
110
  }
111
+ if(!valid(next)){fail(ui.invalid,days);return;}
112
  const reset=plan&&(!old||['days','startDate','startPage','layout','unit','amount','startUnit','startKey','endKey'].some(k=>plan[k]!==next[k]));
113
  if(reset&&!window.confirm(ui.replace_confirm))return;if(reset)next.positions={};
114
+ try{const s=read();s.plan=next;write(s);}catch{fail(ui.storage_error);return;}
115
+ try{await sync(next);}catch{fail(ui.reminder_error);return;}
116
+ show({back,message:ui.saved});
117
+ }));
118
+ if(plan)view.append(button(ui.cancel||ui.back,()=>{paint();say(ui.cancelled||'');}));
119
+ if(land)landOn(h);
120
  }
121
+ RegistryApp.openPanel('worship',{title:ui.title,compact:true},()=>{document.getElementById('adaptivePopupBody').append(root);if(back)document.getElementById('adaptivePopupActions').append(button(ui.back,back));});
122
+ paint(!message);
123
+ if(message){const heading=view.querySelector('h3');if(heading)landOn(heading);say(message);}
124
  }
125
  window.MuslimClockKhatma=Object.freeze({configuration,show,openWird,openReminder,savePosition,context,range,dateKey,dayNumber});
126
  })();
quran_khatma_registry.json CHANGED
@@ -43,6 +43,16 @@
43
  "retry": "إعادة المحاولة",
44
  "load_error": "تعذّر تحميل إعدادات الختمة.",
45
  "choose_day": "عرض ورد يوم آخر",
46
- "scope": "التذكير يستخدم نظام الساعة المحلي الحالي؛ لا يضمن التنبيه بعد إغلاق المتصفح."
 
 
 
 
 
 
 
 
 
 
47
  }
48
- }
 
43
  "retry": "إعادة المحاولة",
44
  "load_error": "تعذّر تحميل إعدادات الختمة.",
45
  "choose_day": "عرض ورد يوم آخر",
46
+ "scope": "التذكير يستخدم نظام الساعة المحلي الحالي؛ لا يضمن التنبيه بعد إغلاق المتصفح.",
47
+ "plan_heading": "خطة الختمة",
48
+ "open_selected": "فتح ورد اليوم المحدد في القائمة",
49
+ "cancel": "إلغاء التعديل والعودة للخطة",
50
+ "cancelled": "أُلغي التعديل.",
51
+ "stopped_done": "أُوقفت الخطة وتذكيرها.",
52
+ "resumed_done": "استؤنفت الخطة.",
53
+ "date_hint": "أدخل التاريخ بصيغة سنة-شهر-يوم، مثل 2026-10-15، أو استخدم منتقي التاريخ.",
54
+ "time_hint": "أدخل الوقت بصيغة ساعات:دقائق بنظام 24 ساعة، مثل 18:00.",
55
+ "range_hint": "الحد الأعلى {max}.",
56
+ "fields_changed": "تغيّرت الحقول حسب الطريقة المختارة: {unit}."
57
  }
58
+ }
quran_reader.css CHANGED
@@ -47,13 +47,13 @@
47
  #adaptivePopupBody .quran-nav button{flex:1;text-align:center;min-width:0}
48
  #adaptivePopupBody .quran-reader button:disabled{opacity:.5;cursor:default}
49
  #adaptivePopupBody .quran-page-title{font-size:1.15rem;text-align:center;margin:18px 0 8px}
50
- #adaptivePopupBody .quran-page-text{font-family:"Noto Naskh Arabic","Amiri",serif;font-size:var(--quran-font-size,26px);line-height:2.3;margin:8px 0;overflow-wrap:break-word}
51
  #adaptivePopupBody .quran-verse{scroll-margin-block:20px}
52
  #adaptivePopupBody .quran-verse:focus{outline:2px solid currentColor;outline-offset:3px;border-radius:3px}
53
  #adaptivePopupBody .quran-verse-number{font-family:inherit;font-size:.8em;white-space:nowrap}
54
- #adaptivePopupBody .quran-basmala{text-align:center;font-family:"Noto Naskh Arabic","Amiri",serif;font-size:var(--quran-font-size,26px);line-height:2}
55
  #adaptivePopupBody .quran-meta{font-size:.85rem;line-height:1.6;margin:10px 0}
56
- #adaptivePopupBody .quran-result-text{display:block;font-family:"Noto Naskh Arabic","Amiri",serif;font-size:var(--quran-font-size,26px);line-height:2}
57
  #adaptivePopupBody .quran-settings input[type="range"]{width:100%;padding:0;min-height:44px}
58
  #adaptivePopupBody .quran-settings input[type="checkbox"]{width:auto}
59
  #adaptivePopupCard[data-quran-theme]{background:var(--quran-bg);color:var(--quran-fg);border-color:var(--quran-border);color-scheme:light}
@@ -86,3 +86,22 @@
86
  #adaptivePopupBody .quran-nav button{font-size:14px;padding:8px}
87
  #adaptivePopupBody .quran-toolbar{gap:6px}
88
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  #adaptivePopupBody .quran-nav button{flex:1;text-align:center;min-width:0}
48
  #adaptivePopupBody .quran-reader button:disabled{opacity:.5;cursor:default}
49
  #adaptivePopupBody .quran-page-title{font-size:1.15rem;text-align:center;margin:18px 0 8px}
50
+ #adaptivePopupBody .quran-page-text{font-size:var(--quran-font-size,26px);margin:8px 0}
51
  #adaptivePopupBody .quran-verse{scroll-margin-block:20px}
52
  #adaptivePopupBody .quran-verse:focus{outline:2px solid currentColor;outline-offset:3px;border-radius:3px}
53
  #adaptivePopupBody .quran-verse-number{font-family:inherit;font-size:.8em;white-space:nowrap}
54
+ #adaptivePopupBody .quran-basmala{text-align:center;font-size:var(--quran-font-size,26px)}
55
  #adaptivePopupBody .quran-meta{font-size:.85rem;line-height:1.6;margin:10px 0}
56
+ #adaptivePopupBody .quran-result-text{display:block;font-size:var(--quran-font-size,26px)}
57
  #adaptivePopupBody .quran-settings input[type="range"]{width:100%;padding:0;min-height:44px}
58
  #adaptivePopupBody .quran-settings input[type="checkbox"]{width:auto}
59
  #adaptivePopupCard[data-quran-theme]{background:var(--quran-bg);color:var(--quran-fg);border-color:var(--quran-border);color-scheme:light}
 
86
  #adaptivePopupBody .quran-nav button{font-size:14px;padding:8px}
87
  #adaptivePopupBody .quran-toolbar{gap:6px}
88
  }
89
+
90
+
91
+ /* ===== إضافات: روابط التفسير وأسباب النزول ومعاني الكلمات + إتاحة قارئ الشاشة ===== */
92
+ .quran-sr-only{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
93
+ #adaptivePopupBody .quran-link-group{margin:6px 0}
94
+ #adaptivePopupBody .quran-link-group summary{cursor:pointer;min-height:44px;padding:10px 0;font-weight:bold}
95
+ #adaptivePopupBody .quran-link-list{list-style:none;margin:0;padding:0}
96
+ #adaptivePopupBody .quran-link-list li{margin:0}
97
+ #adaptivePopupBody .quran-link-list a{display:block;min-height:44px;padding:10px 4px;text-decoration:underline;overflow-wrap:anywhere}
98
+ #adaptivePopupBody .quran-khatma h3[tabindex="-1"]:focus,
99
+ #adaptivePopupBody .quran-khatma p[tabindex="-1"]:focus{outline:2px solid currentColor;outline-offset:3px}
100
+ #adaptivePopupBody .quran-khatma button,
101
+ #adaptivePopupBody .quran-khatma input,
102
+ #adaptivePopupBody .quran-khatma select{min-height:44px}
103
+ #adaptivePopupBody .quran-khatma [hidden]{display:none!important}
104
+ #adaptivePopupBody .quran-khatma :focus-visible,
105
+ #adaptivePopupBody .quran-ayah-actions :focus-visible,
106
+ #adaptivePopupBody .quran-audio :focus-visible{outline:2px solid currentColor;outline-offset:2px}
107
+ @media (prefers-reduced-motion:reduce){#adaptivePopupBody *{scroll-behavior:auto!important;transition:none!important}}
quran_reader.js CHANGED
@@ -227,11 +227,11 @@
227
  let audioAttempt = 0;
228
  cleanup = () => {saveAudio(player?.snapshot(),true);mounted=false;audioAttempt++;player?.dispose();window.removeEventListener('pagehide',flushAudio);document.removeEventListener('visibilitychange',flushAudio);};
229
  const toolbar = node('div', null, 'quran-toolbar');
230
- const navigator = node('details', null, 'quran-navigator');
231
  const navigatorTitle = node('summary', ui.navigate);
232
  const navigatorGrid = node('div', null, 'quran-navigator-grid');
233
- navigator.append(navigatorTitle, navigatorGrid);
234
- toolbar.append(navigator);
235
  const field = (label, input) => { const wrap = node('label', label); wrap.append(input); navigatorGrid.append(wrap); return input; };
236
  const select = (items, label) => {
237
  const input = node('select');
@@ -243,13 +243,20 @@
243
  const surahSearchLabel=node('label',ui.search_surah);surahSearchLabel.append(surahSearch);
244
  navigatorGrid.insertBefore(surahSearchLabel,surahSelect.parentElement);
245
  surahSearch.addEventListener('input',()=>{
246
- const term=normalize(surahSearch.value);
247
- for(const option of surahSelect.options) option.hidden=Boolean(term)&&!normalize(option.textContent).includes(term);
 
 
 
 
 
 
 
248
  });
249
  const juzSelect = select(Array.from({length: source.counts.juz}, (_, i) => [i + 1, i + 1]), ui.juz);
250
  const hizbSelect=select(source.partitions.hizb.map(r=>[r.id,r.id]),ui.hizb);
251
  const rubSelect=select(source.partitions.rub_el_hizb.map(r=>[r.id,r.id]),ui.rub_el_hizb);
252
- const position=node('p',null,'quran-meta');position.setAttribute('aria-live','polite');
253
  function positionText(v){return format(ui.partition_position,{juz:v.juz,hizb:v.hizb,rub:v.rub_el_hizb,page:v.page});}
254
  const pageForm = node('form');
255
  const pageLabel = node('label', ui.page);
@@ -284,16 +291,31 @@
284
  const planOK=window.MuslimClockKhatma?.savePosition(wird,'reading',point)!==false;
285
  actionStatus.textContent=ok&&planOK?ui.bookmark_saved:ui.progress_error;updateBookmarks();
286
  });
287
- const tafsir=node('details'),tafsirTitle=node('summary',ui.tafsir),tafsirLinks=node('div',null,'quran-tafsir-links');
288
- tafsir.append(tafsirTitle,node('p',ui.tafsir_note,'quran-meta'),tafsirLinks);
289
- actions.append(actionTitle,listen,copy,bookmark,tafsir,button(ui.close_actions,()=>{actions.hidden=true;actionOrigin?.focus();}),actionStatus);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
  function selectVerse(verse, origin) {
291
  actionVerse=verse;actionOrigin=origin;
292
- tafsirLinks.replaceChildren();tafsir.open=false;
293
- for(const item of c.tafsirs||[]){
294
- try{const url=new URL(format(item.url,{surah:verse.surah,ayah:verse.ayah}));if(url.protocol!=='https:')continue;
295
- const link=node('a',item.name+' ↗');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';link.addEventListener('click',()=>player?.pause());tafsirLinks.append(link);}catch{}
296
- }
297
  actionTitle.textContent=source.surahs[verse.surah-1].name+' · '+ui.ayah+' '+verse.ayah+' · '+positionText(verse);
298
  actionStatus.replaceChildren();actions.hidden=false;(listen.disabled?copy:listen).focus();
299
  }
@@ -401,7 +423,7 @@
401
  }
402
  const span = node('span', verse.text, 'quran-verse');
403
  span.id = 'quran-verse-' + verse.id; span.tabIndex = 0;span.setAttribute('role','button');
404
- span.setAttribute('aria-label',verse.text+' · '+ui.ayah+' '+verse.ayah+' · '+ui.verse_actions);
405
  span.addEventListener('click',()=>selectVerse(verse,span));
406
  span.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectVerse(verse,span);}});
407
  const number = node('span', ' (' + verse.ayah + ') ', 'quran-verse-number');
 
227
  let audioAttempt = 0;
228
  cleanup = () => {saveAudio(player?.snapshot(),true);mounted=false;audioAttempt++;player?.dispose();window.removeEventListener('pagehide',flushAudio);document.removeEventListener('visibilitychange',flushAudio);};
229
  const toolbar = node('div', null, 'quran-toolbar');
230
+ const navPanel = node('details', null, 'quran-navigator');
231
  const navigatorTitle = node('summary', ui.navigate);
232
  const navigatorGrid = node('div', null, 'quran-navigator-grid');
233
+ navPanel.append(navigatorTitle, navigatorGrid);
234
+ toolbar.append(navPanel);
235
  const field = (label, input) => { const wrap = node('label', label); wrap.append(input); navigatorGrid.append(wrap); return input; };
236
  const select = (items, label) => {
237
  const input = node('select');
 
243
  const surahSearchLabel=node('label',ui.search_surah);surahSearchLabel.append(surahSearch);
244
  navigatorGrid.insertBefore(surahSearchLabel,surahSelect.parentElement);
245
  surahSearch.addEventListener('input',()=>{
246
+ const term=normalize(surahSearch.value),keep=surahSelect.value;
247
+ surahSelect.replaceChildren();
248
+ for(const sr of source.surahs){
249
+ const text=sr.id+'. '+sr.name;
250
+ if(term&&!normalize(text).includes(term))continue;
251
+ const option=node('option',text);option.value=sr.id;surahSelect.append(option);
252
+ }
253
+ if([...surahSelect.options].some(o=>o.value===keep))surahSelect.value=keep;
254
+ status.textContent=surahSelect.options.length?'':ui.no_results;
255
  });
256
  const juzSelect = select(Array.from({length: source.counts.juz}, (_, i) => [i + 1, i + 1]), ui.juz);
257
  const hizbSelect=select(source.partitions.hizb.map(r=>[r.id,r.id]),ui.hizb);
258
  const rubSelect=select(source.partitions.rub_el_hizb.map(r=>[r.id,r.id]),ui.rub_el_hizb);
259
+ const position=node('p',null,'quran-meta');position.setAttribute('aria-live','off');
260
  function positionText(v){return format(ui.partition_position,{juz:v.juz,hizb:v.hizb,rub:v.rub_el_hizb,page:v.page});}
261
  const pageForm = node('form');
262
  const pageLabel = node('label', ui.page);
 
291
  const planOK=window.MuslimClockKhatma?.savePosition(wird,'reading',point)!==false;
292
  actionStatus.textContent=ok&&planOK?ui.bookmark_saved:ui.progress_error;updateBookmarks();
293
  });
294
+ const linkGroups=[
295
+ {key:'tafsir',items:c.tafsirs,note:'tafsir_note'},
296
+ {key:'asbab',items:c.asbab_nuzul,note:'asbab_note'},
297
+ {key:'meanings',items:c.word_meanings,note:'meanings_note'}
298
+ ].filter(g=>Array.isArray(g.items)&&g.items.length).map(g=>{
299
+ const box=node('details',null,'quran-link-group'),title=node('summary',ui[g.key]),list=node('ul',null,'quran-link-list');
300
+ box.append(title,node('p',ui[g.note],'quran-meta'),list);return {...g,box,list};
301
+ });
302
+ actions.append(actionTitle,listen,copy,bookmark,...linkGroups.map(g=>g.box),button(ui.close_actions,()=>{actions.hidden=true;actionOrigin?.focus();}),actionStatus);
303
+ function fillLinks(verse){
304
+ for(const g of linkGroups){
305
+ g.list.replaceChildren();g.box.open=false;
306
+ for(const item of g.items){
307
+ try{
308
+ const url=new URL(format(item.url,{surah:verse.surah,ayah:verse.ayah}));if(url.protocol!=='https:')continue;
309
+ const li=node('li'),link=node('a',item.name+' ↗');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';
310
+ link.setAttribute('aria-label',ui[g.key]+' — '+source.surahs[verse.surah-1].name+' '+ui.ayah+' '+verse.ayah+' — '+item.name+' — '+ui.external_link);
311
+ link.addEventListener('click',()=>player?.pause());li.append(link);g.list.append(li);
312
+ }catch{}
313
+ }
314
+ }
315
+ }
316
  function selectVerse(verse, origin) {
317
  actionVerse=verse;actionOrigin=origin;
318
+ fillLinks(verse);
 
 
 
 
319
  actionTitle.textContent=source.surahs[verse.surah-1].name+' · '+ui.ayah+' '+verse.ayah+' · '+positionText(verse);
320
  actionStatus.replaceChildren();actions.hidden=false;(listen.disabled?copy:listen).focus();
321
  }
 
423
  }
424
  const span = node('span', verse.text, 'quran-verse');
425
  span.id = 'quran-verse-' + verse.id; span.tabIndex = 0;span.setAttribute('role','button');
426
+ span.setAttribute('aria-label',verse.text+' — '+ui.ayah+' '+verse.ayah+' — '+ui.verse_actions);span.setAttribute('aria-haspopup','true');
427
  span.addEventListener('click',()=>selectVerse(verse,span));
428
  span.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectVerse(verse,span);}});
429
  const number = node('span', ' (' + verse.ayah + ') ', 'quran-verse-number');
quran_reader_registry.json CHANGED
@@ -3,12 +3,38 @@
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": "القرآن الكريم",
14
  "open": "قراءة القرآن الكريم",
@@ -52,6 +78,66 @@
52
  "close_actions": "إغلاق إجراءات الآية",
53
  "audio_loading": "جارٍ تجهيز مشغل التلاوة…",
54
  "audio_unavailable": "تعذّر تجهيز الصوت. القراءة متاحة؛ أعد محاولة تحميل المشغّل.",
55
- "search_surah": "بحث عن سورة"
56
- }
57
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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": {
7
+ "font_size": 26,
8
+ "remember_page": true,
9
+ "theme": "auto",
10
+ "follow_audio": true,
11
+ "show_partition_status": true,
12
+ "show_swipe_hint": true
13
+ },
14
  "themes": {
15
+ "light": {
16
+ "bg": "#ffffff",
17
+ "fg": "#19241d",
18
+ "surface": "#f1f5f2",
19
+ "border": "#7b8980",
20
+ "highlight": "#c5e9d0",
21
+ "highlight_fg": "#102b1a"
22
+ },
23
+ "dark": {
24
+ "bg": "#171b18",
25
+ "fg": "#f0f5f1",
26
+ "surface": "#252c27",
27
+ "border": "#87978c",
28
+ "highlight": "#365a40",
29
+ "highlight_fg": "#ffffff"
30
+ }
31
+ },
32
+ "limits": {
33
+ "font_min": 20,
34
+ "font_max": 40,
35
+ "search_batch": 40,
36
+ "request_timeout_ms": 30000
37
  },
 
38
  "ui": {
39
  "title": "القرآن الكريم",
40
  "open": "قراءة القرآن الكريم",
 
78
  "close_actions": "إغلاق إجراءات الآية",
79
  "audio_loading": "جارٍ تجهيز مشغل التلاوة…",
80
  "audio_unavailable": "تعذّر تجهيز الصوت. القراءة متاحة؛ أعد محاولة تحميل المشغّل.",
81
+ "search_surah": "بحث عن سورة",
82
+ "navigate": "التنقل بين السور والأجزاء والصفحات",
83
+ "settings_short": "الإعدادات",
84
+ "bookmark": "حفظ علامة قراءة هنا",
85
+ "bookmark_saved": "حُفظت علامة القراءة عند هذه الآية.",
86
+ "progress_error": "تعذّر حفظ موضعك على هذا الجهاز.",
87
+ "tafsir": "التفسير",
88
+ "tafsir_note": "روابط خارجية تفتح على هذه الآية نفسها، وتتوقف التلاوة عند فتحها.",
89
+ "asbab": "أسباب النزول",
90
+ "asbab_note": "رابط خارجي يفتح على أسباب نزول هذه الآية. لا يوجد سبب نزول مروي لكل آية؛ إن لم يظهر شيء فالآية ليس لها سبب نزول مأثور.",
91
+ "meanings": "معاني الكلمات",
92
+ "meanings_note": "رابط خارجي يفتح على هذه الآية ويبيّن معنى كل كلمة.",
93
+ "external_link": "يفتح في نافذة جديدة",
94
+ "resume_reading": "المتابعة من علامة القراءة",
95
+ "resume_audio": "استكمال الاستماع من آخر موضع",
96
+ "khatma": "خطة الختمة وورد اليوم",
97
+ "reading_position": "علامة القراءة: {surah} · الآية {ayah} · الصفحة {page}",
98
+ "saved_page": "آخر صفحة محفوظة: {page}",
99
+ "audio_position": "آخر استماع: {surah} · الآية {ayah} · بعد {seconds} ثانية",
100
+ "quran_features_error": "تعذّر تحميل ميزات الختمة. القراءة متاحة.",
101
+ "wird_audio_end": "انتهى ورد اليوم.",
102
+ "wird_range": "ورد اليوم {day}: الصفحات {start}–{end}",
103
+ "wird_resume_audio": "استكمال الاستماع من آخر موضع في ورد اليوم",
104
+ "swipe_hint": "للتنقل بين الصفحات اسحب أفقيًا، أو استخدم زرَّي الصفحة السابقة والتالية.",
105
+ "show_partition_status": "إظهار موضع القراءة (الجزء والحزب والربع)",
106
+ "show_swipe_hint": "إظهار تلميح السحب بين الصفحات",
107
+ "verse_no": "الآية {ayah}",
108
+ "page_changed": "الصفحة {page}"
109
+ },
110
+ "progress_storage_key": "muslim_clock_quran_progress_v1",
111
+ "swipe": {
112
+ "min_distance_px": 70,
113
+ "horizontal_ratio": 2,
114
+ "max_duration_ms": 700,
115
+ "click_suppression_ms": 400
116
+ },
117
+ "tafsirs": [
118
+ {
119
+ "id": "quran_com_tafsir",
120
+ "name": "التفسير (Quran.com) — يفتح على هذه الآية",
121
+ "url": "https://quran.com/{surah}:{ayah}/tafsirs"
122
+ }
123
+ ],
124
+ "asbab_nuzul": [
125
+ {
126
+ "id": "altafsir_asbab",
127
+ "name": "أسباب النزول — التفسير الكبير (altafsir) لهذه الآية",
128
+ "url": "https://www.altafsir.com/AsbabAlnuzol.asp?SoraName={surah}&Ayah={ayah}&search=yes&img=A&LanguageID=1"
129
+ }
130
+ ],
131
+ "word_meanings": [
132
+ {
133
+ "id": "corpus_wbw",
134
+ "name": "معاني الكلمات كلمةً كلمة (Quranic Arabic Corpus)",
135
+ "url": "https://corpus.quran.com/wordbyword.jsp?chapter={surah}&verse={ayah}"
136
+ },
137
+ {
138
+ "id": "quran_com_verse",
139
+ "name": "صفحة ��لآية مع معاني الكلمات (Quran.com)",
140
+ "url": "https://quran.com/{surah}:{ayah}"
141
+ }
142
+ ]
143
+ }