jQuery(function($){
const root=document.getElementById('emh-ept-root');
if(!root||typeof EMH_EPT_2026==='undefined') return;
const view=root.getAttribute('data-view')||'exam';
const main=document.getElementById('emh-ept-main');
const api=async (action, data, isForm=false)=> {
try {
let body;
if(isForm){
body=data;
body.append('action', action);
body.append('nonce', EMH_EPT_2026.nonce);
}else{
const payload=Object.assign({ action, nonce: EMH_EPT_2026.nonce }, data||{});
body=new URLSearchParams();
Object.keys(payload).forEach(k=> {
const v=payload[k];
if(v===undefined||v===null) return;
body.append(k, typeof v==='object' ? JSON.stringify(v):String(v));
});
}
const res=await fetch(EMH_EPT_2026.ajax, {
method: 'POST',
body,
credentials: 'same-origin'
});
const text=await res.text();
let json=null;
try { json=JSON.parse(text); } catch(e){}
if(json&&typeof json==='object') return json;
const snippet=(text||'').toString().trim().slice(0, 300);
return { success:false, data:{ message: snippet||'Network error' }};} catch(e){
return { success:false, data:{ message: e.message||'Network error' }};}};
function drawChart(canvas, points){
if(!canvas) return;
const ctx=canvas.getContext('2d');
const w=canvas.width=canvas.clientWidth * (window.devicePixelRatio||1);
const h=canvas.height=canvas.clientHeight * (window.devicePixelRatio||1);
ctx.clearRect(0,0,w,h);
const pad=26 * (window.devicePixelRatio||1);
const innerW=w - pad*2;
const innerH=h - pad*2;
ctx.globalAlpha=1;
ctx.lineWidth=1 * (window.devicePixelRatio||1);
ctx.strokeStyle='#ddd';
ctx.beginPath();
ctx.moveTo(pad, pad);
ctx.lineTo(pad, h-pad);
ctx.lineTo(w-pad, h-pad);
ctx.stroke();
if(!points||points.length < 1){
ctx.fillStyle='#666';
ctx.font=`${12*(window.devicePixelRatio||1)}px sans-serif`;
ctx.fillText('No data yet', pad, pad+16*(window.devicePixelRatio||1));
return;
}
const ys=points.map(p=>p.y);
const minY=0;
const maxY=120;
const n=points.length;
ctx.strokeStyle='#eee';
for(let i=0;i<=4;i++){
const gy=pad + innerH * (i/4);
ctx.beginPath();
ctx.moveTo(pad, gy);
ctx.lineTo(w-pad, gy);
ctx.stroke();
ctx.fillStyle='#999';
ctx.font=`${11*(window.devicePixelRatio||1)}px sans-serif`;
const label=String(Math.round(maxY - (maxY-minY)*(i/4)));
ctx.fillText(label, 4*(window.devicePixelRatio||1), gy+4*(window.devicePixelRatio||1));
}
const scaleX=(i)=> pad + (n===1? innerW/2:(innerW * (i/(n-1))));
const scaleY=(y)=> pad + innerH * (1 - ((y-minY)/(maxY-minY)));
ctx.strokeStyle='#111';
ctx.lineWidth=2.2 * (window.devicePixelRatio||1);
ctx.beginPath();
points.forEach((p,i)=>{
const x=scaleX(i);
const y=scaleY(p.y);
if(i===0) ctx.moveTo(x,y); else ctx.lineTo(x,y);
});
ctx.stroke();
ctx.fillStyle='#111';
points.forEach((p,i)=>{
const x=scaleX(i);
const y=scaleY(p.y);
ctx.beginPath();
ctx.arc(x,y, 3.2*(window.devicePixelRatio||1), 0, Math.PI*2);
ctx.fill();
});
}
const dash={ history: [], page: 1, perPage: 5 };
function renderDashboardHistory(){
const histDiv=document.getElementById('emh-ept-history');
if(!histDiv) return;
const history=dash.history||[];
if(history.length===0){
histDiv.innerHTML=`<div class="row">No attempts yet. Take your first mock exam to see progress.</div>`;
return;
}
const statusLabel=(st)=>{
if(!st) return '';
if(st==='submitted') return 'Submitted';
if(st==='in_progress') return '';
return st.replace(/_/g,' ');
};
const totalPages=Math.max(1, Math.ceil(history.length / dash.perPage));
if(dash.page > totalPages) dash.page=totalPages;
if(dash.page < 1) dash.page=1;
const start=(dash.page-1) * dash.perPage;
const pageItems=history.slice(start, start + dash.perPage);
const listHtml=pageItems.map(h=>{
const stLabel=statusLabel(h.status);
if(!h.scores){
return `<div class="row">
<div><strong>Attempt #${h.id}</strong> — ${escapeHtml(stLabel)}</div>
<div class="emh-ept-note">Started: ${escapeHtml(h.started_at||'')}</div>
</div>`;
}
const s=h.scores;
const tips=(s.tips||[]).slice(0,3).map(t=>`<li>${escapeHtml(t)}</li>`).join('');
const d=s.details||{};
const placement=d.placement||{};
const confidencePct=Math.round((Number(placement.confidence||0)||0) * 100);
const placementHtml=placement.cefr ? `
<div class="emh-ept-note" style="margin-top:10px">
<strong>Estimated English Level (CEFR):</strong> ${escapeHtml(placement.cefr)} <span style="opacity:.8">(confidence ${confidencePct}%)</span><br>
<span style="opacity:.9">${escapeHtml(placement.summary||'')}</span>
</div>
`:'';
const fbBlock=(label, txt)=>{
const safe=escapeHtml((txt||'').toString()).replace(/\n/g,'<br>');
if(!safe) return '';
return `<div style="margin:6px 0"><strong>${escapeHtml(label)}:</strong><div class="emh-ept-note" style="margin-top:4px">${safe}</div></div>`;
};
const sectionFeedbackHtml=[
fbBlock('Reading', d.reading&&d.reading.feedback),
fbBlock('Listening', d.listening&&d.listening.feedback),
fbBlock('Writing', d.writing&&(d.writing.feedback||d.writing.overall_feedback)),
fbBlock('Speaking', d.speaking&&(d.speaking.feedback||d.speaking.overall_feedback)),
].filter(Boolean).join('')||'<div class="emh-ept-note">No section feedback available.</div>';
return `<div class="row">
<div><strong>Attempt #${h.id}</strong>${stLabel ? ``:""}</div>
<div class="emh-ept-note">Submitted: ${escapeHtml(h.submitted_at||'')}</div>
${placementHtml}
<details style="margin-top:8px">
<summary>Section Feedback (AI)</summary>
<div style="margin-top:8px">${sectionFeedbackHtml}</div>
</details>
<details style="margin-top:8px">
<summary>AI Advice</summary>
<ul>${tips||'<li>No advice available.</li>'}</ul>
</details>
</div>`;
}).join('');
const pager=(totalPages > 1) ? `
<div class="emh-ept-controls" style="justify-content:center;gap:10px;margin-top:10px">
<button class="emh-btn emh-accent-outline" id="emh-hist-prev" ${dash.page<=1?'disabled':''}>Previous</button>
<span class="emh-ept-pill">Page ${dash.page} / ${totalPages}</span>
<button class="emh-btn emh-accent-outline" id="emh-hist-next" ${dash.page>=totalPages?'disabled':''}>Next</button>
</div>
`:'';
histDiv.innerHTML=listHtml + pager;
}
async function loadDashboard(){
const histDiv=document.getElementById('emh-ept-history');
const canvas=document.getElementById('emh-ept-chart');
const r=await api(EMH_EPT_2026.actions.history, {});
if(!r.success){
histDiv.innerHTML=`<div class="row">Failed: ${escapeHtml(r.data?.message||'error')}</div>`;
return;
}
dash.history=r.data.history||[];
dash.page=1;
const history=dash.history;
if(history.length===0){
histDiv.innerHTML=`<div class="row">No attempts yet. Take your first mock exam to see progress.</div>`;
if(canvas) drawChart(canvas, []);
return;
}
const points=history
.filter(h=>h.scores&&h.status==='submitted')
.slice().reverse()
.map((h,idx)=>({ x: idx, y: h.scores.total_0_120 }));
if(canvas) drawChart(canvas, points);
renderDashboardHistory();
}
$(document).on('click', '#emh-hist-prev', function(){
dash.page=Math.max(1, (dash.page||1) - 1);
renderDashboardHistory();
});
$(document).on('click', '#emh-hist-next', function(){
const totalPages=Math.max(1, Math.ceil((dash.history||[]).length / dash.perPage));
dash.page=Math.min(totalPages, (dash.page||1) + 1);
renderDashboardHistory();
});
const state={
attemptId: null,
exam: null,
section: 'intro',
qIndex: 0,
remaining: 0,
timerInt: null,
settings: null,
answers: { reading:{}, listening:{}, speaking:{}, writing:{}},
transcripts: {},
};
function escapeHtml(s){
return String(s||'').replace(/[&<>"']/g, c=>({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;' }[c]));
}
function linkify(text){
const esc=escapeHtml(text||'');
return esc.replace(/(https?:\/\/[^\s<]+[^<\s\.)])/g, function(m){
return `<a href="${m}" target="_blank" rel="noopener">${m}</a>`;
});
}
function fmt(sec){
sec=Math.max(0, Math.floor(sec));
const m=Math.floor(sec/60), r=sec%60;
return String(m).padStart(2,'0')+":"+String(r).padStart(2,'0');
}
const funFacts=[
"Tip: Use EMH Magical Dictionary to tap any word and see meaning in your native language.",
"Reading tip: skim first for the main idea, then answer detail questions.",
"Listening tip: focus on the speaker’s purpose and transitions (however, therefore, in contrast).",
"Speaking tip: keep your structure simple—Point → Reason → Example.",
"Writing tip: clear paragraphs beat complex sentences. Aim for clarity.",
"EnglishMasteryHub.com tip: practice 10 minutes daily—consistency wins."
];
const icons=["📘","🎧","🎤","✍️","🚀","🌟","🧠","📈"];
function renderIntroCard(name, attempts, subUrl){
const n=name ? escapeHtml(name):'there';
const used=attempts?.used ?? 0;
const limit=attempts?.limit ?? 0;
const left=attempts?.left ?? 0;
const subscribed = !!(attempts&&typeof attempts.limit==='number'&&attempts.limit > (EMH_EPT_2026.freeLimit||0)) ? null:null;
const cta=subUrl ? `<a class="emh-btn emh-primary" href="${escapeHtml(subUrl)}" target="_blank" rel="noopener">Subscribe to get more attempts</a>`:"";
const attemptsLine=(limit > 0)
? `<div class="emh-ept-kv"><span>This month: ${used}/${limit} attempts used</span><span>${left} left</span></div>`
: `<div class="emh-ept-note">Free attempts are disabled. Please subscribe to take the exam.</div>`;
return `
<div class="emh-ept-card">
<div class="emh-ept-section-title">
<div>
<h3 style="margin:0">Welcome ${n} 👋</h3>
<div class="emh-ept-note">Welcome to the TOEFL Mock Exam experience by EnglishMasteryHub.com</div>
</div>
<div class="emh-ept-pill">${icons[Math.floor(Math.random()*icons.length)]} Ready?</div>
</div>
${attemptsLine}
<div class="emh-ept-grid" style="margin-top:10px">
<div class="emh-ept-card" style="padding:12px">
<h4 style="margin:0 0 6px 0">Sections (quick guide)</h4>
<ul style="margin:0 0 0 18px;padding:0">
<li><strong>Reading</strong>: passage + questions. Manage time.</li>
<li><strong>Listening</strong>: lecture audio + questions. Take mental notes.</li>
<li><strong>Speaking</strong>: record answers. Clear structure helps.</li>
<li><strong>Writing</strong>: two tasks. Clarity & examples matter.</li>
</ul>
</div>
<div class="emh-ept-card" style="padding:12px">
<h4 style="margin:0 0 6px 0">Extra tips while you wait</h4>
<div id="emh-tip-rotator" class="emh-ept-note">${escapeHtml(funFacts[0])}</div>
<div class="emh-ept-note">Vocabulary boost: Try <strong>EMH Magical Dictionary</strong> to learn words instantly while reading.</div>
</div>
</div>
<div style="margin-top:12px">
<div class="emh-ept-note"><strong>When you click Start</strong>, we prepare your exam questions and audio. Please stay on this tab.</div>
<button class="emh-btn emh-primary" id="emh-ept-start-btn">Start Exam</button>
${cta ? `<div class="emh-ept-controls" style="margin-top:10px">${cta}</div>`:""}
<div id="emh-ept-msg" class="emh-ept-msg"></div>
</div>
<div class="emh-ept-note" style="margin-top:10px">✨ ${icons[Math.floor(Math.random()*icons.length)]} ${escapeHtml(funFacts[Math.floor(Math.random()*funFacts.length)])}</div>
</div>
`;
}
function renderLoading(stageText, pct){
const p=Math.max(0, Math.min(100, pct||0));
return `
<div class="emh-ept-card">
<div class="emh-ept-section-title">
<div><h3 style="margin:0">Preparing your exam…</h3></div>
<div class="emh-ept-pill">${icons[Math.floor(Math.random()*icons.length)]} ${p}%</div>
</div>
<div class="emh-ept-progress" style="margin-top:10px"><div style="width:${p}%"></div></div>
<div class="emh-ept-note" style="margin-top:10px">${escapeHtml(stageText||'Loading…')}</div>
<div class="emh-ept-note">Pro tip: Use <strong>EnglishMasteryHub.com</strong> and <strong>EMH Magical Dictionary</strong> to level up vocabulary fast.</div>
</div>
`;
}
function setTimerText(){
const el=document.getElementById('emh-ept-timer');
if(el) el.textContent=fmt(state.remaining);
}
function stopTimer(){
if(state.timerInt){ clearInterval(state.timerInt); state.timerInt=null; }}
function startTimer(seconds, onEnd){
stopTimer();
state.remaining=seconds;
setTimerText();
state.timerInt=setInterval(()=>{
state.remaining -=1;
setTimerText();
if(state.remaining <=0){
stopTimer();
if(typeof onEnd==='function') onEnd();
}}, 1000);
}
function bestEffortExamMode(){
const el=document.documentElement;
if(el.requestFullscreen){
}
document.addEventListener('visibilitychange', ()=>{
if(document.hidden){
showToast('Warning: You left the exam tab. Time continues.');
}});
window.onbeforeunload=()=> 'Exam in progress. Are you sure you want to leave?';
}
const modal=document.createElement('div');
modal.className='emh-ept-modal';
modal.innerHTML=`<div class="box emh-ept-card"><div id="emh-ept-modal-text"></div><div class="emh-ept-controls"><button class="emh-btn emh-primary" id="emh-ept-modal-ok">OK</button></div></div>`;
document.body.appendChild(modal);
function showToast(text){
document.getElementById('emh-ept-modal-text').innerHTML=`<p>${linkify(text)}</p>`;
modal.classList.add('show');
}
function formatMissingPools(missing){
if(!missing||typeof missing!=='object') return '';
const label={reading:'Reading', listening:'Listening', writing:'Writing', speaking:'Speaking'};
const parts=[];
['reading','listening','writing','speaking'].forEach(sec=>{
if(!missing[sec]) return;
const need=missing[sec].need ?? missing[sec].required ?? '';
const have=missing[sec].have ?? missing[sec].available ?? '';
parts.push(`${label[sec]||sec}: need ${need}, have ${have}`);
});
return parts.length ? (' Missing: ' + parts.join(' | ')):'';
}
$(document).on('click','#emh-ept-modal-ok', ()=> modal.classList.remove('show'));
const submitModal=document.createElement('div');
submitModal.className='emh-ept-modal';
submitModal.innerHTML=`
<div class="box emh-ept-card">
<h3 style="margin:0 0 8px 0">Submitting your exam…</h3>
<div id="emh-submit-sub" class="emh-ept-note">Please keep this tab open.</div>
<div class="emh-ept-progressline" style="margin-top:12px"><div id="emh-submit-bar" style="width:0%"></div></div>
<div id="emh-submit-pct" class="emh-ept-note" style="margin-top:8px;font-weight:700">0%</div>
</div>`;
document.body.appendChild(submitModal);
let submitTick=null;
function showSubmitProgress(label){
const bar=document.getElementById('emh-submit-bar');
const pct=document.getElementById('emh-submit-pct');
const sub=document.getElementById('emh-submit-sub');
if(sub) sub.textContent=label||'Please keep this tab open.';
let p=3;
if(bar) bar.style.width=p+'%';
if(pct) pct.textContent=p+'%';
submitModal.classList.add('show');
if(submitTick) clearInterval(submitTick);
submitTick=setInterval(()=>{
p=Math.min(90, p + Math.max(1, Math.round((90-p)/12)));
if(bar) bar.style.width=p+'%';
if(pct) pct.textContent=p+'%';
}, 650);
return ()=>{
if(submitTick) clearInterval(submitTick);
submitTick=null;
if(bar) bar.style.width='100%';
if(pct) pct.textContent='100%';
setTimeout(()=> submitModal.classList.remove('show'), 350);
};}
async function autosave(){
if(!state.attemptId) return;
const progress={
section: state.section,
qIndex: state.qIndex,
remaining: state.remaining,
answers: state.answers,
transcripts: state.transcripts,
savedAt: Date.now()
};
await api(EMH_EPT_2026.actions.save, { attempt_id: state.attemptId, progress: JSON.stringify(progress) });
}
function sectionHeader(title, pill){
return `<div class="emh-ept-section-title">
<div><h3 style="margin:0">${escapeHtml(title)}</h3></div>
<div class="emh-ept-pill">${escapeHtml(pill||'')}</div>
</div>`;
}
function progressBar(current, total){
const pct=total>0 ? Math.round((current/total)*100):0;
return `<div class="emh-ept-progress" aria-label="progress"><div style="width:${pct}%"></div></div>`;
}
function renderReading(){
const passage=state.exam.reading?.passage||'';
const qs=state.exam.reading?.questions||[];
const q=qs[state.qIndex];
const total=qs.length;
const qid=q?.id||'';
const qType=(q?.type||'mcq');
let stimulus=q?.stimulus||'';
const stimulusHtml=q?.stimulus_html||'';
const stimulusTitle=q?.stimulus_title||'';
const hasStimulus = !!((stimulusHtml&&String(stimulusHtml).trim().length)||(stimulus&&String(stimulus).trim().length));
if(qType==='fill'&&stimulus&&q?.blank_meta&&typeof q.blank_meta==='object'){
try{
const meta=q.blank_meta;
stimulus=String(stimulus).replace(/\{\{(\d+)\}\}/g, (m, num)=>{
const n=String(num);
const len=Math.max(1, parseInt(meta[n]||'1', 10)||1);
const underscores=Array.from({length: len}).map(()=> '_').join(' ');
return `${underscores} {{${n}}}`;
});
}catch(e){  }}
const fillValue=(qid&&typeof state.answers.reading[qid]==='string') ? state.answers.reading[qid]:'';
const html=`
<div class="emh-ept-card">
${sectionHeader('Reading', `${state.qIndex+1}/${total}`)}
${progressBar(state.qIndex+1, total)}
<div class="emh-ept-note">Tip: read for gist first, then detail. No copy/paste.</div>
<div class="emh-ept-q">
${hasStimulus ? `
<div class="emh-ept-note" style="margin-bottom:10px">
${stimulusTitle ? `<strong>${escapeHtml(stimulusTitle)}</strong><br/>`:``}
${stimulusHtml ? `<div class="emh-ept-stimulus">${stimulusHtml}</div>`:`<div style="white-space:pre-wrap">${escapeHtml(stimulus)}</div>`}
</div>
`:''}
<div class="prompt">${escapeHtml(q?.prompt||'')}</div>
${qType==='fill' ? `
<input class="emh-ept-input" id="reading_fill_${escapeHtml(qid)}" data-qid="${escapeHtml(qid)}" type="text" placeholder="Type your answer" value="${escapeHtml(fillValue)}" autocomplete="off" />
<div class="emh-ept-note">Type the missing word/letters. Spelling matters.</div>
`:(q?.choices||[]).map((c,i)=>{
const checked=(state.answers.reading[qid]===i) ? 'checked':'';
return `<label class="emh-ept-choice">
<input type="radio" name="reading_${escapeHtml(qid)}" value="${i}" ${checked}/>
${escapeHtml(c)}
</label>`;
}).join('')
}
</div>
${(passage&&String(passage).trim().length) ? `
<details>
<summary>Reading Passage</summary>
<div class="emh-ept-passage">${escapeHtml(passage)}</div>
</details>
`:''}
<div class="emh-ept-controls">
<button class="emh-btn emh-accent-outline" id="emh-prev" ${state.qIndex===0?'disabled':''}>Previous</button>
<button class="emh-btn emh-accent" id="emh-next">${state.qIndex===total-1?'Finish Reading':'Next'}</button>
</div>
</div>
`;
main.innerHTML=html;
}
function renderListening(){
const qs=state.exam.listening?.questions||[];
const q=qs[state.qIndex];
const audioUrl=(q&&q.audio_url) ? q.audio_url:'';
const groupKey=(q&&q.group_key) ? String(q.group_key):'';
state.listeningPlayedGroups=state.listeningPlayedGroups||{};
const prevGroupKey=(state.qIndex > 0&&qs[state.qIndex-1]&&qs[state.qIndex-1].group_key) ? String(qs[state.qIndex-1].group_key):'';
const isFirstInGroup=(groupKey&&groupKey!==prevGroupKey);
const alreadyPlayed=(groupKey&&state.listeningPlayedGroups[groupKey]) ? true:false;
const total=qs.length;
const html=`
<div class="emh-ept-card">
${sectionHeader('Listening', `${state.qIndex+1}/${total}`)}
${progressBar(state.qIndex+1, total)}
<div class="emh-ept-note">Listen carefully. Use headphones if possible.</div>
<div class="emh-ept-q">
<div class="prompt">Lecture / Conversation Audio</div>
${isFirstInGroup ? (
alreadyPlayed ? `
<div class="emh-ept-note">Audio already played (one-time listening).</div>
`:(audioUrl ? `
<audio id="emh-listening-audio" controls preload="auto" style="width:100%" src="${escapeHtml(audioUrl)}" controlsList="nodownload noplaybackrate"></audio>
<div class="emh-ept-note" style="margin-top:8px">You can listen only once.</div>
`:`
<div class="emh-ept-note">Audio is not available for this item.</div>
`)
):`
<div class="emh-ept-note">Audio was played at the start of this set.</div>
`}
</div>
<div class="emh-ept-q">
<div class="prompt">${escapeHtml(q?.prompt||'')}</div>
${(q?.choices||[]).map((c,i)=>{
const checked=(state.answers.listening[q.id]===i) ? 'checked':'';
return `<label class="emh-ept-choice">
<input type="radio" name="listening_${escapeHtml(q.id)}" value="${i}" ${checked}/>
${escapeHtml(c)}
</label>`;
}).join('')}
</div>
<div class="emh-ept-controls">
<button class="emh-btn emh-accent-outline" id="emh-prev" ${state.qIndex===0?'disabled':''}>Previous</button>
<button class="emh-btn emh-accent" id="emh-next">${state.qIndex===total-1?'Finish Listening':'Next'}</button>
</div>
</div>
`;
main.innerHTML=html;
try {
if(isFirstInGroup&&groupKey&&!alreadyPlayed){
const a=document.getElementById('emh-listening-audio');
if(a){
let lastTime=0;
a.addEventListener('timeupdate', function(){
if(!a.seeking) lastTime=a.currentTime;
});
a.addEventListener('seeking', function(){
if(a.currentTime < lastTime) a.currentTime=lastTime;
});
a.addEventListener('ended', function(){
state.listeningPlayedGroups[groupKey]=true;
autosave();
render();
});
}}
} catch(e){}}
function pickMimeType(){
const candidates=[
'audio/webm;codecs=opus',
'audio/webm',
'audio/mp4',
'audio/ogg;codecs=opus',
'audio/ogg'
];
if(!window.MediaRecorder) return null;
for(const t of candidates){
try{
if(MediaRecorder.isTypeSupported&&MediaRecorder.isTypeSupported(t)) return t;
} catch(e){}}
return '';
}
async function getMicStream(){
if(!navigator.mediaDevices||!navigator.mediaDevices.getUserMedia){
throw new Error('Microphone recording is not supported in this browser.');
}
return await navigator.mediaDevices.getUserMedia({ audio:true });
}
function renderSpeaking(){
const tasks=state.exam.speaking?.tasks||[];
const t=tasks[state.qIndex];
const total=tasks.length;
const tid=t?.id||'';
const transcript=state.transcripts[tid]||'';
if(tid&&rec.audioByTask&&rec.audioByTask[tid]?.blob){
rec.blob=rec.audioByTask[tid].blob;
rec.mime=rec.audioByTask[tid].mime||rec.mime||'';
}else{
rec.blob=null;
rec.mime='';
}
const maxSec=(EMH_EPT_2026.limits?.maxRecordSeconds||75);
const isListenRepeat=(t?.type==='listen_repeat');
const spAudio=(t?.audio_url)
? t.audio_url
: ((tid&&state.exam.speaking?.audio_urls&&state.exam.speaking.audio_urls[tid]) ? state.exam.speaking.audio_urls[tid]:'');
const played = !!(state.speakingPlayed&&tid&&state.speakingPlayed[tid]);
const html=`
<div class="emh-ept-card">
${sectionHeader('Speaking', `${state.qIndex+1}/${total}`)}
${progressBar(state.qIndex+1, total)}
<div class="emh-ept-note">Record your answer. Then <strong>EMH AI Agent</strong> will transcribe and score your speaking automatically.</div>
<div class="emh-ept-q">
<div class="prompt">${escapeHtml(t?.prompt||'')}</div>
${isListenRepeat ? `
<div class="emh-ept-q" style="margin-top:10px">
<div class="emh-ept-note">Listen once, then repeat. (Audio can be played only one time.)</div>
<audio id="emh-speak-prompt-audio" preload="auto" ${spAudio?`src="${escapeHtml(spAudio)}"`:''}></audio>
<button class="emh-btn emh-accent" id="emh-speak-play" ${played?'disabled':''} style="margin-top:8px">${played?'Played':'Play (1x)'}</button>
</div>
`:''}
<div class="emh-ept-kv" style="margin-top:8px">
<span>Prep: ${escapeHtml(String(t?.prep_seconds||15))}s</span>
<span>Speak: ${escapeHtml(String(t?.speak_seconds||45))}s</span>
<span>Max record: ${escapeHtml(String(maxSec))}s</span>
</div>
<div class="emh-ept-audio" style="margin-top:10px">
<button class="emh-btn emh-accent" id="emh-rec-start">Start Recording</button>
<button class="emh-btn emh-danger" id="emh-rec-stop" disabled>Stop</button>
<span id="emh-rec-status" class="emh-ept-pill">Idle</span>
</div>
<audio id="emh-audio-preview" controls style="width:100%;margin-top:10px;display:none"></audio>
<div class="emh-ept-note" style="margin-top:10px">We keep the transcript internally for scoring (not shown on screen).</div>
</div>
<div class="emh-ept-controls">
<button class="emh-btn emh-accent-outline" id="emh-prev" ${state.qIndex===0?'disabled':''}>Previous</button>
<button class="emh-btn emh-accent" id="emh-next">${state.qIndex===total-1?'Finish Speaking':'Next'}</button>
</div>
</div>
`;
main.innerHTML=html;
}
async function ensureSpeakingPromptAudio(){
const tasks=state.exam.speaking?.tasks||[];
const t=tasks[state.qIndex];
if(!t||t.type!=='listen_repeat') return;
const tid=t.id;
if(!tid) return;
if(t.audio_url) return;
if(state.exam.speaking?.audio_urls&&state.exam.speaking.audio_urls[tid]) return;
const r=await api(EMH_EPT_2026.actions.tts, { attempt_id: state.attemptId, section: 'speaking', item_id: tid, text: (t.listen_text||t.prompt||'') });
if(r.success){
state.exam.speaking=state.exam.speaking||{};
state.exam.speaking.audio_urls=state.exam.speaking.audio_urls||{};
state.exam.speaking.audio_urls[tid]=r.data.audio_url;
const a=document.getElementById('emh-speak-prompt-audio');
if(a){ a.src=r.data.audio_url + (r.data.audio_url.includes('?')?'&':'?') + 'v=' + Date.now(); a.load(); }
await autosave();
}}
$(document).on('click', '#emh-speak-play', async function(){
const tasks=state.exam.speaking?.tasks||[];
const t=tasks[state.qIndex];
if(!t||t.type!=='listen_repeat') return;
const tid=t.id;
state.speakingPlayed=state.speakingPlayed||{};
if(state.speakingPlayed[tid]) return;
await ensureSpeakingPromptAudio();
const a=document.getElementById('emh-speak-prompt-audio');
if(!a) return;
try{
this.disabled=true;
this.textContent='Playing…';
await a.play();
state.speakingPlayed[tid]=true;
this.textContent='Played';
this.disabled=true;
await autosave();
}catch(e){
this.disabled=false;
this.textContent='Play (1x)';
}});
function renderWriting(){
const tasks=state.exam.writing?.tasks||[];
const t=tasks[state.qIndex];
const total=tasks.length;
const tid=t?.id||'';
const current=state.answers.writing[tid]||'';
const isBuild=(t?.type==='build_sentence');
const words=Array.isArray(t?.words) ? t.words:null;
const built=(typeof current==='string') ? current:'';
const stimTitle=t?.stimulus_title ? `<div class="emh-ept-label">${escapeHtml(t.stimulus_title)}</div>`:'';
const stimBody=t?.stimulus_html ? `<div class="emh-ept-stimulus">${t.stimulus_html}</div>`:(t?.stimulus ? `<div class="emh-ept-stimulus">${escapeHtml(t.stimulus)}</div>`:'');
const html=`
<div class="emh-ept-card">
${sectionHeader('Writing', `${state.qIndex+1}/${total}`)}
${progressBar(state.qIndex+1, total)}
<div class="emh-ept-q">
${stimTitle}
${stimBody}
<div class="prompt">${escapeHtml(t?.prompt||'')}</div>
${t?.context ? `<div class="emh-ept-note">${escapeHtml(t.context)}</div>`:''}
${isBuild ? `
<div class="emh-ept-note">Tap words to build a correct sentence. Use “Clear” if needed.</div>
<div class="emh-build" data-task="${escapeHtml(tid)}">
<div class="emh-build-output" id="emh-build-output">${escapeHtml(built)}</div>
<div class="emh-build-words" id="emh-build-words">
${(words||[]).map((w,i)=>`<button type="button" class="emh-chip" data-word="${escapeHtml(w)}">${escapeHtml(w)}</button>`).join('')}
</div>
<div class="emh-ept-controls" style="margin-top:10px">
<button class="emh-btn emh-accent-outline" id="emh-build-clear" type="button">Clear</button>
</div>
</div>
`:`
<textarea class="emh-ept-textarea" id="emh-writing" placeholder="Write your answer...">${escapeHtml(current)}</textarea>
`}
</div>
<div class="emh-ept-controls">
<button class="emh-btn emh-accent-outline" id="emh-prev" ${state.qIndex===0?'disabled':''}>Previous</button>
<button class="emh-btn emh-accent" id="emh-next">${state.qIndex===total-1?'Next: Speaking':'Next'}</button>
</div>
</div>
`;
main.innerHTML=html;
}
function renderResults(results){
const tips=(results.generated_tips||[]).slice(0,6).map(t=>`<li>${escapeHtml(t)}</li>`).join('');
const sR=results.reading?.score_0_30 ?? 0;
const sL=results.listening?.score_0_30 ?? 0;
const sS=results.speaking?.overall_0_30 ?? 0;
const sW=results.writing?.overall_0_30 ?? 0;
const total=results.overall?.total_0_120 ?? (sR+sL+sS+sW);
main.innerHTML=`
<div class="emh-ept-card">
<h3>Results</h3>
${results.placement&&results.placement.cefr ? `<div class=\"emh-ept-note\" style=\"margin-top:8px\"><strong>Estimated English Level (CEFR):</strong> ${escapeHtml(results.placement.cefr)} <span style=\"opacity:.8\">(confidence ${(Math.round((results.placement.confidence||0)*100))}%)</span><br><span style=\"opacity:.9\">${escapeHtml(results.placement.summary||'')}</span></div>`:''}
<details style="margin-top:10px">
<summary>Section Feedback (AI)</summary>
<div class="emh-ept-note"><strong>Reading:</strong> ${escapeHtml(results.reading?.feedback||'')}</div>
<div class="emh-ept-note"><strong>Listening:</strong> ${escapeHtml(results.listening?.feedback||'')}</div>
<div class="emh-ept-note"><strong>Speaking:</strong> ${escapeHtml(results.speaking?.overall_feedback||results.speaking?.feedback||'')}</div>
<div class="emh-ept-note"><strong>Writing:</strong> ${escapeHtml(results.writing?.overall_feedback||results.writing?.feedback||'')}</div>
</details>
<h4 style="margin-top:12px">Advice</h4>
<ul>${tips||'<li>No advice available.</li>'}</ul>
<div class="emh-ept-controls">
<a class="emh-btn emh-primary" href="${escapeHtml(window.location.href)}">Take Another Mock (if quota allows)</a>
<a class="emh-btn" href="${escapeHtml(window.location.origin + '/free-english-placement-test/')}" >View History</a>
</div>
</div>
`;
}
function goSection(section, seconds){
state.section=section;
state.qIndex=0;
startTimer(seconds, async ()=>{
showToast('Time is up for this section. Auto-moving to next section.');
await autosave();
if(state.section==='speaking'){
await submitExam();
}else{
nextSection();
}});
render();
}
async function submitExam(){
stopTimer();
window.onbeforeunload=null;
const fd=new FormData();
fd.append('attempt_id', String(state.attemptId));
fd.append('answers', JSON.stringify({
reading: state.answers.reading,
listening: state.answers.listening,
speaking: state.answers.speaking,
writing: state.answers.writing
}));
const entries=Object.entries((rec&&rec.audioByTask) ? rec.audioByTask:{});
for(const [taskId, item] of entries){
if(!taskId||!item||!item.blob) continue;
let ext='webm';
const type=item.blob.type||item.mime||'';
if(type.includes('mp4')) ext='m4a';
if(type.includes('ogg')) ext='ogg';
if(type.includes('wav')) ext='wav';
if(type.includes('mpeg')) ext='mp3';
fd.append(`speaking_audio[${taskId}]`, item.blob, `speaking_${taskId}.${ext}`);
}
const done=showSubmitProgress('Submitting and scoring your exam. This can take a moment…');
const r=await api(EMH_EPT_2026.actions.submit, fd, true);
done();
modal.classList.remove('show');
if(!r.success){
showToast('Submit failed: ' + (r.data?.message||'error'));
return;
}
state.section='results';
renderResults(r.data.results);
}
function nextSection(){
if(state.section==='reading'){
goSection('listening', (state.settings.time_listening_min||36)*60);
setTimeout(()=>{ ensureListeningAudio(true); }, 400);
return;
}
if(state.section==='listening'){
goSection('writing', (state.settings.time_writing_min||29)*60);
return;
}
if(state.section==='writing'){
goSection('speaking', (state.settings.time_speaking_min||16)*60);
return;
}}
function render(){
if(state.section==='reading') return renderReading();
if(state.section==='listening'){
const r=renderListening();
setTimeout(()=>{ try{ ensureListeningAudio(true); }catch(e){}}, 100);
return r;
}
if(state.section==='speaking'){
const r=renderSpeaking();
setTimeout(()=>{ try{ ensureSpeakingPromptAudio(); }catch(e){}}, 100);
return r;
}
if(state.section==='writing') return renderWriting();
}
$(document).on('change', 'input[type=radio][name^="reading_"]', function(){
const qid=$(this).attr('name').replace('reading_','');
state.answers.reading[qid]=parseInt($(this).val(),10);
});
$(document).on('input', 'input[id^="reading_fill_"]', function(){
const qid=$(this).data('qid');
state.answers.reading[qid]=String($(this).val()||'');
});
$(document).on('change', 'input[type=radio][name^="listening_"]', function(){
const qid=$(this).attr('name').replace('listening_','');
state.answers.listening[qid]=parseInt($(this).val(),10);
});
$(document).on('input', '#emh-writing', function(){
const tasks=state.exam.writing?.tasks||[];
const t=tasks[state.qIndex];
if(t?.id) state.answers.writing[t.id]=$(this).val();
});
$(document).on('click', '.emh-build-words .emh-chip', function(){
const tasks=state.exam.writing?.tasks||[];
const t=tasks[state.qIndex];
if(!t?.id) return;
const word=$(this).data('word');
const prev=(typeof state.answers.writing[t.id]==='string') ? state.answers.writing[t.id]:'';
const next=(prev ? (prev + ' ' + word):String(word||''));
state.answers.writing[t.id]=next;
$('#emh-build-output').text(next);
});
$(document).on('click', '#emh-build-clear', function(){
const tasks=state.exam.writing?.tasks||[];
const t=tasks[state.qIndex];
if(!t?.id) return;
state.answers.writing[t.id]='';
$('#emh-build-output').text('');
});
$(document).on('click', '#emh-prev', async function(){
if(state.qIndex > 0){
state.qIndex--;
render();
await autosave();
}});
$(document).on('click', '#emh-next', async function(){
if(state.section==='reading'){
const qs=state.exam.reading?.questions||[];
if(state.qIndex < qs.length-1){ state.qIndex++; render(); await autosave(); return; }
nextSection();
return;
}
if(state.section==='listening'){
const qs=state.exam.listening?.questions||[];
if(state.qIndex < qs.length-1){
state.qIndex++;
render();
await autosave();
return;
}
nextSection();
return;
}
if(state.section==='writing'){
const tasks=state.exam.writing?.tasks||[];
const t=tasks[state.qIndex];
if(t?.id){
if(t?.type==='build_sentence'){
state.answers.writing[t.id]=(typeof state.answers.writing[t.id]==='string') ? state.answers.writing[t.id]:'';
}else{
state.answers.writing[t.id]=$('#emh-writing').val()||'';
}}
if(state.qIndex < tasks.length-1){ state.qIndex++; render(); await autosave(); return; }
nextSection();
return;
}
if(state.section==='speaking'){
const tasks=state.exam.speaking?.tasks||[];
const t=tasks[state.qIndex];
if(t?.id){
state.answers.speaking[t.id]=state.transcripts[t.id]||'';
}
if(state.qIndex < tasks.length-1){ state.qIndex++; render(); await autosave(); return; }
await submitExam();
return;
}});
let rec={ stream:null, recorder:null, chunks:[], blob:null, mime:'', startedAt:0, audioByTask:{}};
async function startRecording(){
const status=document.getElementById('emh-rec-status');
const startBtn=document.getElementById('emh-rec-start');
const stopBtn=document.getElementById('emh-rec-stop');
const audioEl=document.getElementById('emh-audio-preview');
const tasks=state.exam.speaking?.tasks||[];
const currentTask=tasks[state.qIndex]||null;
try{
startBtn.disabled=true;
status.textContent='Requesting mic…';
rec.stream=await getMicStream();
rec.chunks=[];
rec.blob=null;
rec.mime=pickMimeType()||'';
const opts=rec.mime ? { mimeType: rec.mime }:undefined;
rec.recorder=new MediaRecorder(rec.stream, opts);
rec.recorder.ondataavailable=(e)=>{ if(e.data&&e.data.size>0) rec.chunks.push(e.data); };
rec.recorder.onstop=()=>{
const blob=new Blob(rec.chunks, { type: rec.mime||rec.chunks[0]?.type||'audio/webm' });
rec.blob=blob;
if(currentTask?.id){
rec.audioByTask[currentTask.id]={ blob: blob, mime: blob.type||rec.mime||'' };}
if(audioEl){
audioEl.src=URL.createObjectURL(blob);
audioEl.style.display='block';
}
status.textContent='Recorded';
};
rec.startedAt=Date.now();
rec.recorder.start();
stopBtn.disabled=false;
status.textContent='Recording…';
const maxSec=(EMH_EPT_2026.limits?.maxRecordSeconds||75);
setTimeout(()=>{
if(rec.recorder&&rec.recorder.state==='recording'){
stopRecording();
}}, maxSec*1000);
} catch(e){
showToast('Microphone error: ' + (e.message||e));
startBtn.disabled=false;
if(status) status.textContent='Idle';
}}
function stopRecording(){
const status=document.getElementById('emh-rec-status');
const startBtn=document.getElementById('emh-rec-start');
const stopBtn=document.getElementById('emh-rec-stop');
try{
stopBtn.disabled=true;
if(rec.recorder&&rec.recorder.state==='recording') rec.recorder.stop();
if(rec.stream){
rec.stream.getTracks().forEach(t=>t.stop());
}
startBtn.disabled=false;
status.textContent='Recorded';
} catch(e){
startBtn.disabled=false;
if(status) status.textContent='Idle';
}}
async function whisperTranscribe(taskOverride=null){
const tasks=state.exam.speaking?.tasks||[];
const t=taskOverride||tasks[state.qIndex];
if(!t?.id) return { success:false, message:'Missing speaking task.' };
const audioItem=(rec.audioByTask&&rec.audioByTask[t.id]) ? rec.audioByTask[t.id]:(rec.blob ? { blob: rec.blob, mime: rec.blob.type||rec.mime||'' }:null);
if(!audioItem||!audioItem.blob){
if(!taskOverride) showToast('Please record audio first.');
return { success:false, message:'Please record audio first.' };}
const status=document.getElementById('emh-rec-status');
if(status) status.textContent='Processing…';
const fd=new FormData();
fd.append('attempt_id', String(state.attemptId));
fd.append('task_id', t.id);
let ext='webm';
const type=audioItem.blob.type||audioItem.mime||'';
if(type.includes('mp4')) ext='m4a';
if(type.includes('ogg')) ext='ogg';
if(type.includes('wav')) ext='wav';
if(type.includes('mpeg')) ext='mp3';
fd.append('audio', audioItem.blob, `speaking_${t.id}.${ext}`);
const r=await api(EMH_EPT_2026.actions.whisper, fd, true);
if(!r.success){
if(!taskOverride) showToast('EMH AI Agent failed: ' + (r.data?.message||'error'));
if(status) status.textContent='Recorded';
return { success:false, message:(r.data?.message||'error') };}
const transcript=r.data.transcript||'';
state.transcripts[t.id]=transcript;
state.answers.speaking[t.id]=transcript;
if(status) status.textContent='Ready';
await autosave();
return { success:true, transcript };}
$(document).on('click', '#emh-rec-start', startRecording);
$(document).on('click', '#emh-rec-stop', stopRecording);
$(document).on('click', '#emh-ept-start-btn', async function(){
const btn=this;
btn.disabled=true;
main.innerHTML=renderLoading('Checking your access and starting your attempt…', 12);
const r1=await api(EMH_EPT_2026.actions.start, {});
if(!r1.success){
main.innerHTML=renderIntroCard('', {used:0,limit:0,left:0}, '');
showToast(r1.data?.message||'Start failed');
btn.disabled=false;
return;
}
state.attemptId=r1.data.attempt_id;
state.settings=r1.data.settings;
state.user=r1.data.user||{};
state.attemptsInfo=r1.data.attempts||{};
state.subscriptionUrl=r1.data.subscription_url||'';
try{
const ub=document.getElementById('emh-ept-userbar');
if(ub){
const n=state.user?.display ? escapeHtml(state.user.display):'User';
const used=state.attemptsInfo?.used ?? 0;
const lim=state.attemptsInfo?.limit ?? 0;
const left=state.attemptsInfo?.left ?? 0;
ub.innerHTML=`<span>Welcome, ${n}</span><span> ${used}/${lim} attempts</span><span> ${left} left</span>`;
}}catch(e){}
main.innerHTML=renderLoading('Generating Reading and Listening questions…', 38);
const r2=await api(EMH_EPT_2026.actions.gen, { attempt_id: state.attemptId });
if(!r2.success){
main.innerHTML=renderIntroCard(state.user?.display||'', state.attemptsInfo, state.subscriptionUrl);
const base=(r2.data?.message||'Generation failed');
const extra=formatMissingPools(r2.data?.missing);
showToast(base + extra);
btn.disabled=false;
return;
}
state.exam=r2.data.exam;
bestEffortExamMode();
main.innerHTML=renderLoading('Preparing Listening audio…', 65);
await autosave();
main.innerHTML=renderLoading('Almost ready…', 92);
setTimeout(()=>{
goSection('reading', (state.settings.time_reading_min||35)*60);
}, 350);
setInterval(autosave, 20000);
});
if(view==='dashboard'){
loadDashboard();
window.addEventListener('resize', ()=>{
const canvas=document.getElementById('emh-ept-chart');
if(!canvas) return;
loadDashboard();
});
}
if(view==='exam'){
(async ()=>{
try{
const r=await api(EMH_EPT_2026.actions.status, {});
if(!r||!r.success) return;
const n=r.data?.user?.display ? escapeHtml(r.data.user.display):'User';
const used=r.data?.attempts?.used ?? 0;
const lim=r.data?.attempts?.limit ?? 0;
const left=r.data?.attempts?.left ?? 0;
const ub=document.getElementById('emh-ept-userbar');
if(ub){
ub.innerHTML=`<span>Welcome, ${n}</span><span> ${used}/${lim} attempts</span><span> ${left} left</span>`;
}
const ps=document.getElementById('emh-ept-prestatus');
if(ps){
ps.textContent=`Mock Exam Taking: ${left} left (${used}/${lim})`;
}}catch(e){}})();
}});