Islamic_clock / quran_reader.js
Asem75's picture
Upload 7 files
924868d verified
Raw History Blame Contribute Delete
34.6 kB
(function () {
'use strict';
const base = new URL('.', document.currentScript.src);
const configFile = 'quran_reader_registry.json';
let configPromise, corePromise, preferences, epoch = 0, cleanup = () => {};
const node = (tag, text, className) => {
const element = document.createElement(tag);
if (text != null) element.textContent = text;
if (className) element.className = className;
return element;
};
const button = (text, action) => {
const element = node('button', text);
element.type = 'button';
element.addEventListener('click', action);
return element;
};
const format = (text, values) => text.replace(/\{(\w+)\}/g, (_, key) => String(values[key] ?? ''));
// Normalize only the search index; the displayed Quran text is never transformed.
const normalize = text => String(text).normalize('NFD').replace(/[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06ed\u0300-\u036f\u0640]/g, '')
.replace(/[\u0622\u0623\u0625\u0671]/g, '\u0627').replace(/\u0649/g, '\u064a').replace(/\s+/g, ' ').trim();
async function fetchJSON(file, timeout = 30000) {
const url = new URL(file, base);
if (url.origin !== base.origin) throw Error('Cross-origin Quran registry');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
try {
const response = await fetch(url, {signal: controller.signal, cache:file===configFile?'no-store':'default'});
if (!response.ok) throw Error(file + ' HTTP ' + response.status);
return await response.json();
} finally { clearTimeout(timer); }
}
function config() {
if (!configPromise) configPromise = fetchJSON(configFile).then(value => {
if (value.schema_version !== 1 || !value.ui || !value.core_file || !value.defaults || !value.limits) throw Error('Invalid reader registry');
for (const key of ['title','open','settings','loading','load_error','retry','surah','ayah','page','juz','go','previous','next',
'search','search_submit','search_empty','no_results','results','more','reader','page_status','invalid_page','font_size',
'remember_page','save','saved','storage_error','back','source','theme','theme_auto','theme_light','theme_dark',
'follow_audio','verse_actions','listen','copy','copied','copy_failed','close_actions','audio_loading','audio_unavailable','search_surah']) {
if (typeof value.ui[key] !== 'string') throw Error('Missing UI: ' + key);
}
if (!Number.isInteger(value.limits.search_batch) || value.limits.search_batch < 1) throw Error('Invalid search limit');
return value;
}).catch(error => { configPromise = null; throw error; });
return configPromise;
}
function data(c) {
if (!corePromise) corePromise = fetchJSON(c.core_file, c.limits.request_timeout_ms).then(async value => {
if (value.schema_version !== 1 || value.counts?.ayahs !== 6236 || value.counts?.pages !== 604 || value.counts?.juz !== 30 ||
value.counts?.surahs !== 114 || value.surahs?.length !== 114 || value.verses?.length !== 6236) throw Error('Invalid Quran registry');
const pages = Array.from({length: value.counts.pages + 1}, () => []);
const juzPages = [];
const nextAyah = Array(115).fill(1);
value.surahs.forEach((s, i) => {
if (s.id !== i + 1 || typeof s.name !== 'string' || !Number.isInteger(s.ayah_count)) throw Error('Invalid surah');
});
let previousPage = 1, previousSurah = 1, previousJuz = 1;
value.verses.forEach((v, i) => {
if (v.id !== i + 1 || !Number.isInteger(v.page) || !pages[v.page] || v.page < 1 || v.page < previousPage ||
!Number.isInteger(v.juz) || v.juz < previousJuz || v.juz > 30 ||
!Number.isInteger(v.surah) || v.surah < previousSurah || v.surah > 114 ||
v.ayah !== nextAyah[v.surah]++ || typeof v.text !== 'string' || !v.text || typeof v.search_text !== 'string') throw Error('Invalid ayah: ' + (i + 1));
previousPage = v.page; previousSurah = v.surah; previousJuz = v.juz;
pages[v.page].push(v);
if (!juzPages[v.juz]) juzPages[v.juz] = v.page;
});
value.surahs.forEach(s => {
if (nextAyah[s.id] - 1 !== s.ayah_count || pages[s.first_page]?.find(v => v.surah === s.id)?.ayah !== 1) throw Error('Surah count mismatch');
});
if (pages.slice(1).some(p => !p.length) || Array.from({length:30}, (_, i) => juzPages[i + 1]).some(p => !p)) throw Error('Missing page or juz');
// Keep old page boundaries for existing plans; overlay metadata by verse key only.
const legacyPages=pages.map(row=>row.map(v=>v.id));
const partitions=await fetchJSON('quran_partitions_registry.json');
if(partitions.schema_version!==1||partitions.revision!=='tanzil-20260929'||partitions.surah_ayah_counts?.length!==114||
value.surahs.some((s,i)=>partitions.surah_ayah_counts[i]!==s.ayah_count)||!partitions.reader_ui||!partitions.khatma_ui||!Array.isArray(partitions.units))throw Error('Invalid partitions registry');
const byKey=new Map(value.verses.map(v=>[v.surah+':'+v.ayah,v]));
const mapped={};
for(const [field,count] of Object.entries({page:604,juz:30,hizb:60,rub_el_hizb:240})){
const rows=partitions[field];if(!Array.isArray(rows)||rows.length!==count)throw Error('Invalid partition count: '+field);
let previous=0;
mapped[field]=rows.map((row,i)=>{
const start=byKey.get(row.start),end=byKey.get(row.end);
if(row.id!==i+1||!start||!end||start.id!==previous+1||end.id<start.id)throw Error('Invalid partition boundary: '+field);
previous=end.id;return {...row,startId:start.id,endId:end.id};
});
if(previous!==6236)throw Error('Incomplete partition coverage: '+field);
}
mapped.rub_el_hizb.forEach((r,i)=>{
if(r.juz!==Math.floor(i/8)+1||r.hizb!==Math.floor(i/4)+1||
r.startId<mapped.juz[r.juz-1].startId||r.endId>mapped.juz[r.juz-1].endId||
r.startId<mapped.hizb[r.hizb-1].startId||r.endId>mapped.hizb[r.hizb-1].endId)throw Error('Partition parent mismatch');
});
mapped.hizb.forEach((h,i)=>{if(h.juz!==Math.floor(i/2)+1||JSON.stringify(h.rub_el_hizb)!==JSON.stringify([1,2,3,4].map(n=>i*4+n)))throw Error('Invalid hizb children');});
mapped.juz.forEach((j,i)=>{if(JSON.stringify(j.hizb)!==JSON.stringify([i*2+1,i*2+2])||JSON.stringify(j.rub_el_hizb)!==JSON.stringify(Array.from({length:8},(_,n)=>i*8+n+1)))throw Error('Invalid juz children');});
value.verses.forEach(v=>{v.verse_key=v.surah+':'+v.ayah;});
for(const [field,rows] of Object.entries(mapped))for(const row of rows){
for(let id=row.startId;id<=row.endId;id++){const v=value.verses[id-1];byKey.get(v.verse_key)[field]=row.id;}
}
for(const row of pages)row.length=0;juzPages.length=0;
value.verses.forEach(v=>{pages[v.page].push(v);if(!juzPages[v.juz])juzPages[v.juz]=v.page;});
value.surahs.forEach(s=>{s.first_page=byKey.get(s.id+':1').page;});
Object.assign(c.ui,partitions.reader_ui);
return {...value, pages, juzPages, legacyPages, partitions:{...partitions,...mapped}, byKey, searchIndex: value.verses.map(v => normalize(v.search_text))};
}).catch(error => { corePromise = null; throw error; });
return corePromise;
}
function prefs(c) {
if (preferences) return preferences;
let saved;
try { saved = JSON.parse(localStorage.getItem(c.storage_key)); } catch { /* Storage is optional. */ }
preferences = {
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,
remember_page: typeof saved?.remember_page === 'boolean' ? saved.remember_page : c.defaults.remember_page,
metadata_revision: saved?.metadata_revision,
page: Number.isInteger(saved?.page) && saved.page >= 1 && saved.page <= 604 ? saved.page : 1,
theme: ['auto','light','dark'].includes(saved?.theme) ? saved.theme : c.defaults.theme,
follow_audio: typeof saved?.follow_audio === 'boolean' ? saved.follow_audio : c.defaults.follow_audio,
show_partition_status: typeof saved?.show_partition_status === 'boolean' ? saved.show_partition_status : c.defaults.show_partition_status,
show_swipe_hint: typeof saved?.show_swipe_hint === 'boolean' ? saved.show_swipe_hint : c.defaults.show_swipe_hint
};
return preferences;
}
function persist(c) {
try { localStorage.setItem(c.storage_key, JSON.stringify(preferences)); return true; }
catch { return false; }
}
function progress(c) {
try {const value=JSON.parse(localStorage.getItem(c.progress_storage_key));return value&&typeof value==='object'?value:{};} catch {return {};}
}
function saveProgress(c,kind,value) {
try {const raw=localStorage.getItem(c.progress_storage_key),state=raw?JSON.parse(raw):{};if(!state||typeof state!=='object')throw Error('storage');state[kind]=value;localStorage.setItem(c.progress_storage_key,JSON.stringify(state));return true;} catch {return false;}
}
function panel(title, root, back, ui) {
RegistryApp.openPanel('worship', {title, compact: true}, () => {
document.getElementById('adaptivePopupBody').append(root);
if (back) document.getElementById('adaptivePopupActions').append(button(ui.back, back));
});
document.getElementById('adaptivePopupCard').dataset.size = 'long';
}
function applyTheme(c, p) {
const card = document.getElementById('adaptivePopupCard');
const mode = p.theme === 'auto' ? (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light') : p.theme;
card.dataset.quranTheme = mode;
for (const [key,value] of Object.entries(c.themes[mode])) card.style.setProperty('--quran-'+key,value);
}
function themeField(c, p, onChange) {
const label = node('label',c.ui.theme), select = node('select');
for (const key of ['auto','light','dark']) {const option=node('option',c.ui['theme_'+key]);option.value=key;select.append(option);}
select.value=p.theme;select.addEventListener('change',()=>onChange(select.value));label.append(select);return label;
}
async function mount(parent, back, settingsOnly) {
const entry = button('…', () => {});
entry.disabled = true;
entry.dataset.quranEntry = settingsOnly ? 'settings' : 'reader';
parent.append(entry);
try {
const c = await config();
if (!entry.isConnected) return;
entry.textContent = settingsOnly ? c.ui.settings : c.ui.open;
entry.disabled = false;
entry.onclick = () => settingsOnly ? openSettings(back) : open({back});
} catch (error) {
if (!entry.isConnected) return;
entry.textContent = configFile + ': ' + error.message;
entry.disabled = false;
entry.onclick = () => { entry.remove(); mount(parent, back, settingsOnly); };
}
}
async function open(options = {}) {
const request = ++epoch;
let c;
try { c = await config(); }
catch (error) {
if (request !== epoch) return;
const root = node('div', configFile + ': ' + error.message);
root.append(button('↻', () => open(options)));
panel(configFile, root, options.back, {back: '←'});
return;
}
if (request !== epoch) return;
const root = node('div', null, 'quran-reader');
const loading = node('p', c.ui.loading);
loading.setAttribute('role', 'status');
root.append(loading);
panel(c.ui.title, root, options.back, c.ui);
const token = epoch;
try {
const source = await data(c);
if (token !== epoch || !root.isConnected) return;
renderReader(root, source, c, options);
} catch (error) {
if (token !== epoch || !root.isConnected) return;
loading.textContent = c.ui.load_error;
root.append(node('p', error.message, 'quran-meta'), button(c.ui.retry, () => open(options)));
}
}
function renderReader(root, source, c, options) {
const ui = c.ui, p = prefs(c);
let legacyRememberedPage=null;
if(p.metadata_revision!==source.partitions.revision){
legacyRememberedPage=p.page;
const firstId=source.legacyPages[p.page]?.[0];
if(firstId)p.page=source.verses[firstId-1].page;
p.metadata_revision=source.partitions.revision;persist(c);
}
let page = p.remember_page ? p.page : 1;
const savedProgress=progress(c),wird=options.wird;
let focusVerse = null;
const savedReading=source.verses[(wird?.progress?.reading?.verseId||savedProgress.reading?.verseId)-1];
if(!options.page&&!options.surah&&savedReading&&(options.resumeReading||p.remember_page&&(savedReading.page===page||savedProgress.reading?.page===legacyRememberedPage))){focusVerse=savedReading;page=focusVerse.page;}
if (Number.isInteger(options.page) && source.pages[options.page]?.length) page = options.page;
if (Number.isInteger(options.surah)) {
focusVerse = source.verses.find(v => v.surah === options.surah && v.ayah === (options.ayah || 1));
if (focusVerse) page = focusVerse.page;
}
if(wird){
const point=wird.progress?.reading||wird.progress?.page;
const v=source.verses[point?.verseId-1];
const startId=wird.startId||source.pages[wird.startPage][0].id,endId=wird.endId||source.pages[wird.endPage].at(-1).id;
focusVerse=v&&v.id>=startId&&v.id<=endId?v:source.verses[startId-1];page=focusVerse.page;
}
root.replaceChildren();
root.style.setProperty('--quran-font-size', p.font_size + 'px');
applyTheme(c,p);
let player = null, mounted = true, audioState = null, lastFollowed = null, actionVerse = null, actionOrigin = null;
let audioAttempt = 0;
cleanup = () => {saveAudio(player?.snapshot(),true);mounted=false;audioAttempt++;player?.dispose();window.removeEventListener('pagehide',flushAudio);document.removeEventListener('visibilitychange',flushAudio);};
const toolbar = node('div', null, 'quran-toolbar');
const navPanel = node('details', null, 'quran-navigator');
const navigatorTitle = node('summary', ui.navigate);
const navigatorGrid = node('div', null, 'quran-navigator-grid');
navPanel.append(navigatorTitle, navigatorGrid);
toolbar.append(navPanel);
const field = (label, input) => { const wrap = node('label', label); wrap.append(input); navigatorGrid.append(wrap); return input; };
const select = (items, label) => {
const input = node('select');
for (const [value, text] of items) { const option = node('option', text); option.value = value; input.append(option); }
return field(label, input);
};
const surahSelect = select(source.surahs.map(s => [s.id, s.id + '. ' + s.name]), ui.surah);
const surahSearch = node('input');surahSearch.type='search';surahSearch.placeholder=ui.search_surah;surahSearch.setAttribute('aria-label',ui.search_surah);
const surahSearchLabel=node('label',ui.search_surah);surahSearchLabel.append(surahSearch);
navigatorGrid.insertBefore(surahSearchLabel,surahSelect.parentElement);
surahSearch.addEventListener('input',()=>{
const term=normalize(surahSearch.value),keep=surahSelect.value;
surahSelect.replaceChildren();
for(const sr of source.surahs){
const text=sr.id+'. '+sr.name;
if(term&&!normalize(text).includes(term))continue;
const option=node('option',text);option.value=sr.id;surahSelect.append(option);
}
if([...surahSelect.options].some(o=>o.value===keep))surahSelect.value=keep;
status.textContent=surahSelect.options.length?'':ui.no_results;
});
const juzSelect = select(Array.from({length: source.counts.juz}, (_, i) => [i + 1, i + 1]), ui.juz);
const hizbSelect=select(source.partitions.hizb.map(r=>[r.id,r.id]),ui.hizb);
const rubSelect=select(source.partitions.rub_el_hizb.map(r=>[r.id,r.id]),ui.rub_el_hizb);
const position=node('p',null,'quran-meta');position.setAttribute('aria-live','off');
function positionText(v){return format(ui.partition_position,{juz:v.juz,hizb:v.hizb,rub:v.rub_el_hizb,page:v.page});}
const pageForm = node('form');
const pageLabel = node('label', ui.page);
const pageInput = node('input');
pageInput.type = 'number'; pageInput.min = 1; pageInput.max = source.counts.pages; pageInput.required = true;
pageLabel.append(pageInput); pageForm.append(pageLabel);
const go = node('button', ui.go); go.type = 'submit'; pageForm.append(go); navigatorGrid.append(pageForm);
toolbar.append(button(ui.settings_short, () => openSettings(() => open({...options, page, surah: undefined, ayah: undefined}))));
const searchForm = node('form', null, 'quran-search'); searchForm.setAttribute('role', 'search');
const query = node('input'); query.type = 'search'; query.setAttribute('aria-label', ui.search); query.placeholder = ui.search;
const submit = node('button', ui.search_submit); submit.type = 'submit'; searchForm.append(query, submit);
const status = node('p', null, 'quran-meta'); status.setAttribute('role', 'status');
const content = node('div',null,'quran-pages');
const audioHost=node('div');
const actions=node('section',null,'quran-ayah-actions');actions.hidden=true;actions.setAttribute('aria-label',ui.verse_actions);
const actionTitle=node('h3'), actionStatus=node('p');actionStatus.setAttribute('role','status');
const listen=button(ui.listen,()=>{if(player&&actionVerse){player.playAt(actionVerse.id);actions.hidden=true;actionOrigin?.focus();}});
listen.disabled=true;
const copy=button(ui.copy,async()=>{
if(!actionVerse)return;
const text=actionVerse.text+'\n['+source.surahs[actionVerse.surah-1].name+': '+actionVerse.ayah+']';
try{await navigator.clipboard.writeText(text);actionStatus.textContent=ui.copied;}
catch{
const input=node('textarea',text);input.readOnly=true;input.setAttribute('aria-label',ui.copy);
actionStatus.replaceChildren(node('span',ui.copy_failed),input);input.focus();input.select();
}
});
const bookmark=button(ui.bookmark,()=>{
if(!actionVerse)return;
const point={verseId:actionVerse.id,page:actionVerse.page,at:Date.now()};
const ok=saveProgress(c,'reading',point);
const planOK=window.MuslimClockKhatma?.savePosition(wird,'reading',point)!==false;
actionStatus.textContent=ok&&planOK?ui.bookmark_saved:ui.progress_error;updateBookmarks();
});
const linkGroups=[
{key:'tafsir',items:c.tafsirs,note:'tafsir_note'},
{key:'asbab',items:c.asbab_nuzul,note:'asbab_note'},
{key:'meanings',items:c.word_meanings,note:'meanings_note'}
].filter(g=>Array.isArray(g.items)&&g.items.length).map(g=>{
const box=node('details',null,'quran-link-group'),title=node('summary',ui[g.key]),list=node('ul',null,'quran-link-list');
box.append(title,node('p',ui[g.note],'quran-meta'),list);return {...g,box,list};
});
actions.append(actionTitle,listen,copy,bookmark,...linkGroups.map(g=>g.box),button(ui.close_actions,()=>{actions.hidden=true;actionOrigin?.focus();}),actionStatus);
function fillLinks(verse){
for(const g of linkGroups){
g.list.replaceChildren();g.box.open=false;
for(const item of g.items){
try{
const url=new URL(format(item.url,{surah:verse.surah,ayah:verse.ayah}));if(url.protocol!=='https:')continue;
const li=node('li'),link=node('a',item.name+' ↗');link.href=url.href;link.target='_blank';link.rel='noopener noreferrer';
link.setAttribute('aria-label',ui[g.key]+' — '+source.surahs[verse.surah-1].name+' '+ui.ayah+' '+verse.ayah+' — '+item.name+' — '+ui.external_link);
link.addEventListener('click',()=>player?.pause());li.append(link);g.list.append(li);
}catch{}
}
}
}
function selectVerse(verse, origin) {
actionVerse=verse;actionOrigin=origin;
fillLinks(verse);
actionTitle.textContent=source.surahs[verse.surah-1].name+' · '+ui.ayah+' '+verse.ayah+' · '+positionText(verse);
actionStatus.replaceChildren();actions.hidden=false;(listen.disabled?copy:listen).focus();
}
const nav = node('div', null, 'quran-nav');
const previous = button(ui.previous, () => showPage(page - 1));
const next = button(ui.next, () => showPage(page + 1)); nav.append(previous, next);
const attribution = node('a', ui.source); attribution.href = new URL(c.source_file, base).href;
attribution.target = '_blank'; attribution.rel = 'noopener'; attribution.className = 'quran-meta';
const bookmarks=node('div',null,'quran-bookmarks'),readingStatus=node('p',null,'quran-meta'),audioStatus=node('p',null,'quran-meta');
const resumeReading=button(ui.resume_reading,()=>{const point=progress(c).reading,v=source.verses[point?.verseId-1];if(v)showPage(v.page,v);});
const resumeAudio=button(ui.resume_audio,()=>{const point=progress(c).audio,v=source.verses[point?.verseId-1];if(v&&player){showPage(v.page,v);player.resumeFrom(point);}});
const planButton=button(ui.khatma,()=>{player?.pause();if(window.MuslimClockKhatma)window.MuslimClockKhatma.show({back:()=>open({...options,page})});else status.textContent=ui.quran_features_error;});
bookmarks.append(readingStatus,resumeReading,audioStatus,resumeAudio,planButton);
if(wird){
bookmarks.append(node('p',wird.startId?format(ui.wird_exact,{day:wird.dayIndex+1,start:source.verses[wird.startId-1].verse_key,end:source.verses[wird.endId-1].verse_key,first:wird.startPage,last:wird.endPage}):format(ui.wird_range,{day:wird.dayIndex+1,start:wird.startPage,end:wird.endPage})));
const point=wird.progress?.audio;
if(point)bookmarks.append(button(ui.wird_resume_audio,()=>{const v=source.verses[point.verseId-1];if(v&&player){showPage(v.page,v);player.resumeFrom(point);}}));
}
const metadataLink=node('a',ui.partition_source);metadataLink.href=source.partitions.sources[0].url;metadataLink.target='_blank';metadataLink.rel='noopener noreferrer';metadataLink.className='quran-meta';navigatorGrid.append(metadataLink);
const swipeHint=node('p',ui.swipe_hint,'quran-meta');swipeHint.hidden=!p.show_swipe_hint;position.hidden=!p.show_partition_status;
root.append(toolbar,position,bookmarks,audioHost,searchForm,status,actions,swipeHint,content,nav,attribution);
function updateBookmarks(){
const saved=progress(c),v=source.verses[saved.reading?.verseId-1],a=source.verses[saved.audio?.verseId-1];
readingStatus.textContent=v?format(ui.reading_position,{surah:source.surahs[v.surah-1].name,ayah:v.ayah,page:v.page}):format(ui.saved_page,{page:p.page});
resumeReading.hidden=!v;
audioStatus.textContent=a?format(ui.audio_position,{surah:source.surahs[a.surah-1].name,ayah:a.ayah,seconds:Math.floor(saved.audio.time||0)}):'';
resumeAudio.hidden=!a;resumeAudio.disabled=!player;
}
let lastSavedAudio='';
function saveAudio(state,force=false){
if(!state?.played||!['playing','paused','ended','loading'].includes(state.phase))return;
const signature=[state.verse.id,state.settings.reciter,Math.floor(state.currentTime/2)].join(':');
if(!force&&signature===lastSavedAudio)return;
const point={verseId:state.verse.id,page:state.verse.page,time:Math.max(0,state.currentTime||0),reciter:state.settings.reciter,rate:state.settings.rate,at:Date.now()};
const ok=saveProgress(c,'audio',point),planOK=window.MuslimClockKhatma?.savePosition(wird,'audio',point)!==false;
if(ok&&planOK)lastSavedAudio=signature;else status.textContent=ui.progress_error;
updateBookmarks();
}
function flushAudio(){saveAudio(player?.snapshot(),true);}
window.addEventListener('pagehide',flushAudio);document.addEventListener('visibilitychange',flushAudio);
let touch=null,suppressClickUntil=0;
content.addEventListener('touchstart',e=>{touch=e.touches.length===1?{x:e.touches[0].clientX,y:e.touches[0].clientY,at:Date.now()}:null;},{passive:true});
content.addEventListener('touchcancel',()=>{touch=null;},{passive:true});
content.addEventListener('touchmove',e=>{if(e.touches.length!==1)touch=null;},{passive:true});
content.addEventListener('touchend',e=>{
const start=touch;touch=null;if(!start||e.changedTouches.length!==1||!content.querySelector('.quran-page-text')||String(window.getSelection?.()||'').trim())return;
const dx=e.changedTouches[0].clientX-start.x,dy=e.changedTouches[0].clientY-start.y,rule=c.swipe;
if(!rule||Date.now()-start.at>rule.max_duration_ms||Math.abs(dx)<rule.min_distance_px||Math.abs(dx)<Math.abs(dy)*rule.horizontal_ratio)return;
const target=page+(dx>0?1:-1);if(!source.pages[target]?.length)return;
suppressClickUntil=Date.now()+rule.click_suppression_ms;showPage(target);
},{passive:true});
content.addEventListener('click',e=>{if(Date.now()<suppressClickUntil){e.preventDefault();e.stopImmediatePropagation();}},true);
updateBookmarks();
function paintPlaying() {
for(const verse of content.querySelectorAll('.quran-verse')) {
const playing=audioState?.phase==='playing'&&verse.id==='quran-verse-'+audioState.verse.id;
verse.classList.toggle('quran-playing',playing);
if(playing)verse.setAttribute('aria-current','true');else verse.removeAttribute('aria-current');
}
}
function audioChanged(state) {
if(!mounted)return;
audioState=state;saveAudio(state,state.phase==='paused'||state.phase==='ended');
if(wird&&state.phase==='ended'&&state.verse.id===(wird.endId||source.pages[wird.endPage].at(-1).id))status.textContent=ui.wird_audio_end;
if(state.phase==='playing'&&state.verse.id!==lastFollowed&&p.follow_audio) {
lastFollowed=state.verse.id;
if(page!==state.verse.page||!content.querySelector('.quran-page-text'))showPage(state.verse.page,null,true);
document.getElementById('quran-verse-'+state.verse.id)?.scrollIntoView({block:'nearest'});
}
if(state.phase==='playing')position.textContent=positionText(state.verse);
paintPlaying();
}
async function mountAudio() {
const attempt=++audioAttempt;
audioHost.replaceChildren(node('p',ui.audio_loading,'quran-meta'));
try {
if(!window.MuslimClockQuranAudio)throw Error('quran_audio.js');
const nextPlayer=await window.MuslimClockQuranAudio.mount(audioHost,{
verses:source.verses,surahs:source.surahs,initialId:focusVerse?.id||source.pages[page][0].id,endId:wird?(wird.endId||source.pages[wird.endPage].at(-1).id):undefined,
isActive:()=>mounted&&attempt===audioAttempt&&root.isConnected,onState:audioChanged
});
if(!mounted||attempt!==audioAttempt){nextPlayer?.dispose();return;}
player=nextPlayer;listen.disabled=!player;updateBookmarks();
} catch(error) {
if(!mounted||attempt!==audioAttempt)return;
audioHost.replaceChildren(node('p',ui.audio_unavailable,'quran-meta'),button(ui.retry,mountAudio));
}
}
function showPage(value, selectedVerse, fromAudio=false) {
if (!Number.isInteger(value) || !source.pages[value]?.length) { status.textContent = format(ui.invalid_page, {total:source.counts.pages}); return; }
page = value;
const verses = source.pages[page];
content.replaceChildren(); nav.hidden = false;actions.hidden=true;
pageInput.value = page; surahSelect.value = selectedVerse?.surah || verses[0].surah; juzSelect.value = selectedVerse?.juz || verses[0].juz;
const currentVerse=selectedVerse||verses[0];hizbSelect.value=currentVerse.hizb;rubSelect.value=currentVerse.rub_el_hizb;position.textContent=positionText(currentVerse);
const juz = [...new Set(verses.map(v => v.juz))].join('، ');
status.textContent = format(ui.page_status, {page, total: source.counts.pages, juz});
previous.disabled = page === 1; next.disabled = page === source.counts.pages;
let lastSurah, paragraph;
for (const verse of verses) {
if (verse.surah !== lastSurah) {
content.append(node('h3', source.surahs[verse.surah - 1].name, 'quran-page-title'));
if (verse.ayah === 1 && verse.surah !== 1 && verse.surah !== 9) content.append(node('p', source.verses[0].text, 'quran-basmala'));
paragraph = node('p', null, 'quran-page-text'); content.append(paragraph); lastSurah = verse.surah;
}
const span = node('span', verse.text, 'quran-verse');
span.id = 'quran-verse-' + verse.id; span.tabIndex = 0;span.setAttribute('role','button');
span.setAttribute('aria-label',verse.text+' — '+ui.ayah+' '+verse.ayah+' — '+ui.verse_actions);span.setAttribute('aria-haspopup','true');
span.addEventListener('click',()=>selectVerse(verse,span));
span.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();selectVerse(verse,span);}});
const number = node('span', ' (' + verse.ayah + ') ', 'quran-verse-number');
number.setAttribute('aria-label', ui.ayah + ' ' + verse.ayah);
span.append(number); paragraph.append(span, document.createTextNode(' '));
}
if (p.remember_page&&!fromAudio) {
p.page = page;
if (!persist(c)) status.textContent += ' ' + ui.storage_error;
}
if(!fromAudio&&wird&&window.MuslimClockKhatma?.savePosition(wird,'page',{page,verseId:selectedVerse?.id||verses[0].id})===false)status.textContent=ui.progress_error;
if(!fromAudio){document.getElementById('adaptivePopupBody').scrollTop = 0;player?.select(selectedVerse?.id||verses[0].id);}
paintPlaying();updateBookmarks();
if (selectedVerse) {
const target = document.getElementById('quran-verse-' + selectedVerse.id);
target?.focus({preventScroll: true}); target?.scrollIntoView({block:'center'});
}
}
pageForm.addEventListener('submit', event => { event.preventDefault(); showPage(Number(pageInput.value)); });
surahSelect.addEventListener('change', () => {
const verse = source.verses.find(v => v.surah === Number(surahSelect.value)); showPage(verse.page, verse);
});
juzSelect.addEventListener('change', () => {
const verse = source.verses.find(v => v.juz === Number(juzSelect.value)); showPage(verse.page, verse);
});
for(const [input,kind] of [[hizbSelect,'hizb'],[rubSelect,'rub_el_hizb']])input.addEventListener('change',()=>{const v=source.byKey.get(source.partitions[kind][Number(input.value)-1].start);showPage(v.page,v);});
searchForm.addEventListener('submit', event => {
event.preventDefault();
const text = normalize(query.value);
if (!text) { status.textContent = ui.search_empty; query.focus(); return; }
const matches = source.verses.filter((_, i) => source.searchIndex[i].includes(text));
player?.pause();actions.hidden=true;
content.replaceChildren(); nav.hidden = true;
status.textContent = matches.length ? format(ui.results, {count: matches.length}) : ui.no_results;
content.append(button(ui.reader, () => showPage(page)));
let shown = 0;
const more = button(ui.more, appendResults);
function appendResults() {
more.remove();
for (const verse of matches.slice(shown, shown + c.limits.search_batch)) {
const result = button(source.surahs[verse.surah - 1].name + ' · ' + ui.ayah + ' ' + verse.ayah + ' · ' + ui.page + ' ' + verse.page,
() => showPage(verse.page, verse));
result.className = 'quran-result';
result.append(node('span', verse.text, 'quran-result-text')); content.append(result);
}
shown += c.limits.search_batch;
if (shown < matches.length) content.append(more);
}
appendResults();
});
showPage(page, focusVerse);
if(options.khatmaUnavailable)status.textContent=ui.quran_features_error;
mountAudio();
}
async function openSettings(back) {
const request = ++epoch;
let c;
try { c = await config(); } catch { return open({back}); }
if (request !== epoch) return;
const ui = c.ui, p = prefs(c), root = node('div', null, 'quran-settings');
const label = node('label', ui.font_size), font = node('input'), output = node('output', p.font_size);
font.type = 'range'; font.min = c.limits.font_min; font.max = c.limits.font_max; font.step = 1; font.value = p.font_size;
label.append(font, output);
font.addEventListener('input', () => { output.value = font.value; });
const rememberLabel = node('label', ui.remember_page), remember = node('input');
remember.type = 'checkbox'; remember.checked = p.remember_page; rememberLabel.prepend(remember);
const status = node('p'); status.setAttribute('role', 'status');
const theme=themeField(c,p,value=>{p.theme=value;applyTheme(c,p);});
const followLabel=node('label',ui.follow_audio),follow=node('input');follow.type='checkbox';follow.checked=p.follow_audio;followLabel.prepend(follow);
const partitionLabel=node('label',ui.show_partition_status),partitionToggle=node('input');partitionToggle.type='checkbox';partitionToggle.checked=p.show_partition_status;partitionLabel.prepend(partitionToggle);
const swipeLabel=node('label',ui.show_swipe_hint),swipeToggle=node('input');swipeToggle.type='checkbox';swipeToggle.checked=p.show_swipe_hint;swipeLabel.prepend(swipeToggle);
root.append(label, rememberLabel, theme, followLabel, partitionLabel, swipeLabel, button(ui.save, () => {
p.font_size = Number(font.value); p.remember_page = remember.checked;
p.follow_audio=follow.checked; p.show_partition_status=partitionToggle.checked; p.show_swipe_hint=swipeToggle.checked;
if (!p.remember_page) p.page = 1;
status.textContent = persist(c) ? ui.saved : ui.storage_error;
}), status);
panel(ui.settings, root, back, ui);
applyTheme(c,p);
}
window.addEventListener('muslim-clock-popup-leave', () => {
epoch++;cleanup();cleanup=()=>{};
const card=document.getElementById('adaptivePopupCard');
if(card){delete card.dataset.quranTheme;for(const key of ['bg','fg','surface','border','highlight','highlight_fg'])card.style.removeProperty('--quran-'+key);}
});
window.addEventListener('muslim-clock-quran-open', event => open(event.detail || {}));
window.MuslimClockQuran = Object.freeze({open, openSettings, loadData:async()=>data(await config()),
mountEntry: (parent, back) => mount(parent, back, false),
mountSettingsEntry: (parent, back) => mount(parent, back, true)});
})();