Spaces:
Running
Running
Download quran_reader.js from Asem75/Islamic_clock: direct link, hf CLI and curl.
- Browser
- Download file 34.6 kB
-
https://huggingface.co/spaces/Asem75/Islamic_clock/resolve/main/quran_reader.js
- Command line
-
hf download hf://spaces/Asem75/Islamic_clock/quran_reader.js
-
curl -L -o quran_reader.js https://huggingface.co/spaces/Asem75/Islamic_clock/resolve/main/quran_reader.js
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)}); | |
| })(); | |