"use strict"; const DATA = window.JPLEARN_DATA; const appEl = document.getElementById("app"); const toastEl = document.getElementById("toast"); const DEVICE_KEY = "jplearn.deviceId.v1"; const STATE_KEY = "jplearn.state.v1"; const LESSON_MAP = new Map(DATA.lessons.map((lesson) => [lesson.id, lesson])); const KANJI_MAP = new Map(DATA.kanji.map((entry, index) => [index, entry])); let route = "dashboard"; let routeParam = null; let kanaScript = "hiragana"; let kanaMode = "glyph-to-romaji"; let kanaQuestion = null; let kanaAnswered = false; let selectedKanjiIndex = 0; let reviewQueue = []; let reviewIndex = 0; let reviewRevealed = false; let speakingIndex = 0; let mediaRecorder = null; let mediaChunks = []; let recordedAudioUrl = null; let recognition = null; let saveTimer = null; function createDeviceId() { if (window.crypto && typeof window.crypto.randomUUID === "function") return window.crypto.randomUUID(); const bytes = new Uint8Array(24); window.crypto.getRandomValues(bytes); return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join(""); } function getDeviceId() { let id = localStorage.getItem(DEVICE_KEY); if (!id || !/^[A-Za-z0-9_-]{12,128}$/.test(id)) { id = createDeviceId(); localStorage.setItem(DEVICE_KEY, id); } return id; } const DEVICE_ID = getDeviceId(); function defaultState() { return { version: 1, xp: 0, completedLessons: {}, exerciseResults: {}, srs: {}, speakingLog: [], speakingBest: {}, badges: [], studyDays: [], activityLog: [], kanaCorrect: 0, reviewCount: 0, updatedAt: 0 }; } function loadLocalState() { try { const parsed = JSON.parse(localStorage.getItem(STATE_KEY) || "null"); if (!parsed || typeof parsed !== "object") return defaultState(); return { ...defaultState(), ...parsed }; } catch { return defaultState(); } } let state = loadLocalState(); function persistLocal() { localStorage.setItem(STATE_KEY, JSON.stringify(state)); } function saveState() { state.updatedAt = Date.now(); persistLocal(); window.clearTimeout(saveTimer); saveTimer = window.setTimeout(async () => { try { await fetch("/api/state", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ device: DEVICE_ID, state }) }); } catch { // The local mirror remains available when the network is offline. } }, 350); } async function loadRemoteState() { try { const response = await fetch(`/api/state?device=${encodeURIComponent(DEVICE_ID)}`, { headers: { Accept: "application/json" } }); if (!response.ok) return; const payload = await response.json(); if (payload?.state && Number(payload.state.updatedAt || 0) >= Number(state.updatedAt || 0)) { state = { ...defaultState(), ...payload.state }; persistLocal(); render(); } else if (!payload?.state && state.updatedAt) { saveState(); } } catch { // Offline start uses the local mirror. } } function esc(value) { return String(value ?? "") .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); } function icon(name) { return ``; } function showToast(message) { toastEl.textContent = message; toastEl.classList.add("show"); window.clearTimeout(showToast.timer); showToast.timer = window.setTimeout(() => toastEl.classList.remove("show"), 2600); } function localDateKey(date = new Date()) { const year = date.getFullYear(); const month = String(date.getMonth() + 1).padStart(2, "0"); const day = String(date.getDate()).padStart(2, "0"); return `${year}-${month}-${day}`; } function addDays(date, days) { const next = new Date(date); next.setDate(next.getDate() + days); return next; } function recordActivity(type, amount = 1) { const date = localDateKey(); state.activityLog.push({ date, type, amount, at: Date.now() }); if (state.activityLog.length > 3000) state.activityLog = state.activityLog.slice(-3000); if (!state.studyDays.includes(date)) state.studyDays.push(date); state.studyDays.sort(); } function awardXP(amount) { state.xp += amount; } function dateFromKey(key) { const [year, month, day] = key.split("-").map(Number); return new Date(year, month - 1, day); } function calculateStreak() { if (!state.studyDays.length) return 0; const days = new Set(state.studyDays); let cursor = new Date(); if (!days.has(localDateKey(cursor))) cursor = addDays(cursor, -1); let streak = 0; while (days.has(localDateKey(cursor))) { streak += 1; cursor = addDays(cursor, -1); } return streak; } function weekRange() { const now = new Date(); const day = now.getDay(); const mondayOffset = day === 0 ? -6 : 1 - day; const monday = addDays(now, mondayOffset); monday.setHours(0, 0, 0, 0); const sunday = addDays(monday, 6); sunday.setHours(23, 59, 59, 999); return { monday, sunday }; } function weeklySummary() { const { monday, sunday } = weekRange(); const start = localDateKey(monday); const end = localDateKey(sunday); const inWeek = state.activityLog.filter((event) => event.date >= start && event.date <= end); return { days: new Set(inWeek.map((event) => event.date)).size, lessons: inWeek.filter((event) => event.type === "lesson").reduce((sum, event) => sum + event.amount, 0), reviews: inWeek.filter((event) => event.type === "review").reduce((sum, event) => sum + event.amount, 0), speaking: inWeek.filter((event) => event.type === "speaking").reduce((sum, event) => sum + event.amount, 0) }; } function dailyKanji() { const now = new Date(); const start = new Date(now.getFullYear(), 0, 0); const day = Math.floor((now - start) / 86_400_000); const index = day % DATA.kanji.length; return { index, entry: DATA.kanji[index] }; } function audioButtons(text, labelPrefix = "") { const safeText = esc(text); return `
`; } const audioObjectUrls = new Map(); let currentAudio = null; async function playJapanese(text, speed = "normal") { const cleanText = String(text || "").trim(); if (!cleanText) return; const rate = speed === "slow" ? 0.55 : 0.95; try { const cacheKey = `${cleanText}:${speed}`; if (!audioObjectUrls.has(cacheKey)) { const response = await fetch("/api/tts", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ text: cleanText }) }); if (!response.ok) throw new Error("TTS unavailable"); const blob = await response.blob(); if (!blob || blob.type.includes("json")) throw new Error("TTS unavailable"); audioObjectUrls.set(cacheKey, URL.createObjectURL(blob)); } if (currentAudio) currentAudio.pause(); currentAudio = new Audio(audioObjectUrls.get(cacheKey)); currentAudio.playbackRate = speed === "slow" ? 0.72 : 1; await currentAudio.play(); return; } catch { // Fall through to the device voice. } if (!("speechSynthesis" in window)) { showToast("此裝置目前沒有可用的日語語音。"); return; } window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(cleanText); utterance.lang = "ja-JP"; utterance.rate = rate; const japaneseVoice = window.speechSynthesis.getVoices().find((voice) => voice.lang && voice.lang.toLowerCase().startsWith("ja")); if (japaneseVoice) utterance.voice = japaneseVoice; window.speechSynthesis.speak(utterance); } function rubyWord(word, reading) { if (!reading || word === reading) return esc(word); return `${esc(word)}${esc(reading)}`; } function setRoute(nextRoute, param = null) { const target = param ? `${nextRoute}/${param}` : nextRoute; if (location.hash.replace(/^#/, "") === target) render(); else location.hash = target; } function parseHash() { const raw = location.hash.replace(/^#/, "") || "dashboard"; const [name, param] = raw.split("/"); const valid = ["dashboard", "kana", "lessons", "lesson", "kanji", "review", "speaking"]; route = valid.includes(name) ? name : "dashboard"; routeParam = param || null; } function updateNav() { const active = route === "lesson" ? "lessons" : route; document.querySelectorAll("[data-route]").forEach((button) => { button.classList.toggle("active", button.dataset.route === active); }); } function badgeHTML() { return `
${DATA.badges.map((badge) => { const earned = state.badges.includes(badge.id); return `
${esc(badge.label)}${esc(badge.description)}
`; }).join("")}
`; } function awardBadge(id) { if (state.badges.includes(id)) return; const badge = DATA.badges.find((item) => item.id === id); state.badges.push(id); if (badge) showToast(`獲得徽章:${badge.label}`); } function checkBadges() { const completed = Object.keys(state.completedLessons); const n5Done = DATA.lessons.filter((lesson) => lesson.level === "N5").every((lesson) => completed.includes(lesson.id)); const n4Lessons = DATA.lessons.filter((lesson) => lesson.level === "N4"); const n4Done = n4Lessons.every((lesson) => completed.includes(lesson.id)); if (completed.length >= 1) awardBadge("first-step"); if (state.kanaCorrect >= 20) awardBadge("kana-reader"); if (state.speakingLog.length >= 1) awardBadge("first-speak"); if (state.speakingLog.length >= 20) awardBadge("speaking-master"); if (n5Done) awardBadge("n5-complete"); if (n4Lessons.some((lesson) => completed.includes(lesson.id))) awardBadge("n4-first"); if (n4Done) awardBadge("n4-complete"); if (completed.length === DATA.lessons.length) awardBadge("all-lessons"); if (state.reviewCount >= 100) awardBadge("review-100"); } function renderDashboard() { const streak = calculateStreak(); const weekly = weeklySummary(); const level = Math.floor(state.xp / 300) + 1; const levelProgress = Math.round(((state.xp % 300) / 300) * 100); const completedCount = Object.keys(state.completedLessons).length; const dueCount = Object.values(state.srs).filter((card) => new Date(card.due) <= new Date()).length; const { entry, index } = dailyKanji(); return `

Weekly plan · 今週

にほんご手帳

一天一點,讓日文成為穩定累積的能力。今天先完成一件小事,就很好。

${state.xp}XP 經驗值
${streak}連續學習天
${completedCount}/20已完成課程
Level ${level}${state.xp % 300}/300 XP

今日一字 · ${index + 1}/50

${esc(entry[0])}
${esc(entry[1])}|${esc(entry[2])}

${esc(entry[5])}

${audioButtons(entry[4], "今日一字例句")}

本週小結

星期一至星期日

${weekly.days}學習天數
${weekly.lessons}完成課數
${weekly.reviews}複習張數
${weekly.speaking}開口說句數

今天的練習

${dueCount ? `有 ${dueCount} 張卡片到期` : "從想練的地方開始"}

學習徽章

簡約印章式紀錄

${badgeHTML()}
`; } function kanaDisplayEntries(script) { const nEntry = DATA[script][45]; const entries = DATA[script].slice(0, 45); // Align the y-row and w-row to the five-column chart. const result = []; entries.forEach((entry, index) => { result.push(entry); if (index === 35 || index === 36) result.push(null); if (index === 43) result.push(null, null, null); }); result.push(null, null, nEntry, null, null); return result; } function renderKana() { const entries = kanaDisplayEntries(kanaScript); return `

Kana · 五十音

五十音圖

點任何一個假名即可聆聽發音。先熟悉形狀與聲音,再用巨字測驗確認自己真的記得。

${entries.map((entry) => entry ? `` : ``).join("")}
${renderKanaQuiz()} `; } function pickRandom(items, count, exclude) { const pool = items.filter((item) => item !== exclude); const shuffled = [...pool].sort(() => Math.random() - 0.5); return shuffled.slice(0, count); } function ensureKanaQuestion() { if (kanaQuestion) return; const source = DATA[kanaScript]; const answer = source[Math.floor(Math.random() * source.length)]; const options = [answer, ...pickRandom(source, 3, answer)].sort(() => Math.random() - 0.5); kanaQuestion = { answer, options }; kanaAnswered = false; } function renderKanaQuiz() { ensureKanaQuestion(); const { answer, options } = kanaQuestion; const isGlyphMode = kanaMode === "glyph-to-romaji"; const focus = isGlyphMode ? answer[0] : answer[1]; return `

Kana quiz · 巨字焦點

${isGlyphMode ? "這個假名怎麼讀?" : "哪一個是這個音?"}

${esc(focus)}
${options.map((option) => ``).join("")}

累積答對:${state.kanaCorrect} 題

`; } function renderKanji() { const entry = DATA.kanji[selectedKanjiIndex]; return `

Kanji focus · 漢字

漢字焦點卡

一次專心看一個字:讀音、意思、筆順提示與例句。共 50 字。

${DATA.kanji.map((item, index) => ``).join("")}

${selectedKanjiIndex + 1} / 50

${esc(entry[0])}

${esc(entry[1])}|${esc(entry[2])}

筆順提示(簡化)

${esc(entry[6])} 畫。${esc(entry[7])}

這裡提供的是幫助記憶的簡化提示,不是完整標準筆順教材。

${esc(entry[4])}
${esc(entry[5])}
${selectedKanjiIndex + 1} / 50
`; } const exerciseAttempts = {}; const matchSelections = {}; function lessonResult(lessonId) { return state.exerciseResults[lessonId] || {}; } function exerciseDoneCount(lessonId) { return ["mc", "fill", "listen", "match"].filter((type) => lessonResult(lessonId)[type]?.done).length; } function renderLessons() { const groups = ["N5", "N4"].map((level) => { const lessons = DATA.lessons.filter((lesson) => lesson.level === level); return `

${level} ${level === "N5" ? "基礎" : "進階"}

${lessons.length} 課
${lessons.map((lesson) => { const completed = state.completedLessons[lesson.id]; const done = exerciseDoneCount(lesson.id); return `
LESSON ${String(lesson.no).padStart(2, "0")}${esc(lesson.level)}

${esc(lesson.title)}

約 ${lesson.minutes} 分鐘 練習 ${done}/4 ${completed ? `成績 ${completed.score} 分` : `未完成`}
`; }).join("")}
`; }).join(""); return `

Lessons · 課程

二十課的路徑

從自我介紹到條件句,每課都包含單字、例句、文法與四種練習。完成後,句子會自動進入複習與開口說。

${groups}`; } function sentenceCard(example) { return `
${esc(example[1])}
${esc(example[2])}
`; } function optionExercise(lesson, type, title, promptHTML, options, answerIndex) { const result = lessonResult(lesson.id)[type]; return `

${icon(type === "listen" ? "sound" : "book")}${esc(title)}

${promptHTML}
${options.map((option, index) => { const cls = result?.done && index === answerIndex ? "correct" : ""; return ``; }).join("")}
${result?.done ? "已完成,做得好。" : ""}
`; } function seededShuffle(items, seedText) { let seed = [...seedText].reduce((sum, char) => sum + char.charCodeAt(0), 0); const result = [...items]; for (let i = result.length - 1; i > 0; i -= 1) { seed = (seed * 9301 + 49297) % 233280; const j = Math.floor((seed / 233280) * (i + 1)); [result[i], result[j]] = [result[j], result[i]]; } return result; } function matchingExercise(lesson) { const pairs = lesson.vocab.slice(0, 4); const result = lessonResult(lesson.id).match; const right = seededShuffle(pairs, lesson.id); return `

${icon("cards")}配對練習

先點日文,再點正確的中文意思。完成四組配對即可。

${pairs.map((pair, index) => ``).join("")}
${right.map((pair) => { const originalIndex = pairs.indexOf(pair); return ``; }).join("")}
${result?.done ? "配對完成,做得好。" : ""}
`; } function renderLesson(lesson) { const completed = state.completedLessons[lesson.id]; const mc = lesson.quiz.mc; const fill = lesson.quiz.fill; const listen = lesson.quiz.listen; return `

${lesson.level} · Lesson ${lesson.no} · 約 ${lesson.minutes} 分鐘

${esc(lesson.title)}

${esc(lesson.jpTitle)}

${completed ? `
本課已完成,成績 ${completed.score} 分。可以繼續複習單字與例句。
` : ""}

單字翻卡

點卡片翻面

${lesson.vocab.map((vocab) => `
${rubyWord(vocab[0], vocab[1])}點一下看意思
${esc(vocab[2])}${esc(vocab[1])} 點卡片翻回去
`).join("")}

例句

日文在上,中文在下

${lesson.examples.map(sentenceCard).join("")}

Grammar · 文法

${esc(lesson.grammar.title)}

${esc(lesson.grammar.body)}

練習

完成四種練習後,本課即完成

${optionExercise(lesson, "mc", "選擇題", `

${esc(mc[0])}

`, mc[1], mc[2])} ${optionExercise(lesson, "fill", "填空題", `

${esc(fill[0])}

`, fill[1], fill[2])} ${optionExercise(lesson, "listen", "聽力題", `

${esc(listen[1])}

`, listen[2], listen[3])} ${matchingExercise(lesson)}
`; } function createSrsCards(lesson) { const now = new Date().toISOString(); lesson.vocab.forEach((vocab, index) => { const id = `${lesson.id}:v:${index}`; if (!state.srs[id]) { state.srs[id] = { id, lessonId: lesson.id, kind: "vocab", front: vocab[0], kana: vocab[1], back: vocab[2], due: now, interval: 0, ease: 2.5, reps: 0, lapses: 0, mastered: false, lastReviewed: null }; } }); lesson.examples.forEach((example, index) => { const id = `${lesson.id}:s:${index}`; if (!state.srs[id]) { state.srs[id] = { id, lessonId: lesson.id, kind: "sentence", front: example[0], kana: example[1], back: example[2], due: now, interval: 0, ease: 2.5, reps: 0, lapses: 0, mastered: false, lastReviewed: null }; } }); } function completeLessonIfReady(lesson) { const results = lessonResult(lesson.id); const types = ["mc", "fill", "listen", "match"]; if (!types.every((type) => results[type]?.done) || state.completedLessons[lesson.id]) return; const score = Math.round((types.filter((type) => results[type].correctFirstTry).length / types.length) * 100); state.completedLessons[lesson.id] = { score, completedAt: Date.now() }; awardXP(25); recordActivity("lesson", 1); createSrsCards(lesson); checkBadges(); saveState(); showToast(`第 ${lesson.no} 課完成,成績 ${score} 分,獲得 25 XP。`); render(); } function markExerciseDone(lesson, type, correctFirstTry) { if (!state.exerciseResults[lesson.id]) state.exerciseResults[lesson.id] = {}; if (state.exerciseResults[lesson.id][type]?.done) return; state.exerciseResults[lesson.id][type] = { done: true, correctFirstTry, completedAt: Date.now() }; awardXP(10); recordActivity("exercise", 1); saveState(); completeLessonIfReady(lesson); } function prepareReviewQueue() { const now = new Date(); const due = Object.values(state.srs) .filter((card) => new Date(card.due) <= now) .sort((a, b) => new Date(a.due) - new Date(b.due)); reviewQueue = due.map((card) => card.id); reviewIndex = 0; reviewRevealed = false; } function renderReview() { const cards = Object.values(state.srs); const now = new Date(); const dueCount = cards.filter((card) => new Date(card.due) <= now).length; const mastered = cards.filter((card) => card.mastered).length; if (!reviewQueue.length || reviewIndex >= reviewQueue.length) prepareReviewQueue(); const card = state.srs[reviewQueue[reviewIndex]]; return `

Review · 間隔複習

把記得變成真的記得

完成課程後,單字與例句會進入這裡。依你的熟悉程度安排下一次出現,不需要一次背完。

${dueCount}今日到期
${mastered}已掌握
${cards.length}全部卡片
${card ? `

${card.kind === "sentence" ? "例句" : "單字"} · ${esc(LESSON_MAP.get(card.lessonId)?.title || "")}

${esc(card.front)}
${reviewRevealed ? `

${esc(card.back)}

${audioButtons(card.kana, "複習卡片")}
` : ``} ${reviewRevealed ? `
` : ""}

本輪進度 ${Math.min(reviewIndex + 1, reviewQueue.length)} / ${reviewQueue.length}

` : `

目前沒有到期卡片

先完成一課,或等已排程的卡片到期。

`} `; } function scheduleReview(card, rating) { const previousInterval = Number(card.interval || 0); const previousReps = Number(card.reps || 0); let interval; let ease = Number(card.ease || 2.5); if (rating === 0) { interval = 1; card.reps = 0; card.lapses = Number(card.lapses || 0) + 1; ease = Math.max(1.3, ease - 0.2); } else { card.reps = previousReps + 1; if (rating === 1) { interval = previousReps === 0 ? 1 : Math.max(1, Math.round(previousInterval * 0.6)); ease = Math.max(1.3, ease - 0.05); } else if (rating === 2) { if (previousReps === 0) interval = 1; else if (previousReps === 1) interval = 4; else if (previousReps === 2) interval = 9; else interval = Math.round(previousInterval * ease); } else { if (previousReps === 0) interval = 4; else if (previousReps === 1) interval = 9; else if (previousReps === 2) interval = 30; else interval = Math.round(previousInterval * ease * 1.35); ease = Math.min(3, ease + 0.1); } } card.interval = Math.min(180, Math.max(1, interval)); card.ease = Math.round(ease * 100) / 100; card.due = addDays(new Date(), card.interval).toISOString(); card.lastReviewed = new Date().toISOString(); card.mastered = card.interval >= 30 || card.reps >= 6; } function gradeReview(rating) { const card = state.srs[reviewQueue[reviewIndex]]; if (!card) return; scheduleReview(card, rating); state.reviewCount += 1; awardXP(2); recordActivity("review", 1); checkBadges(); saveState(); reviewIndex += 1; reviewRevealed = false; render(); } function speakingPool() { return Object.keys(state.completedLessons).flatMap((lessonId) => { const lesson = LESSON_MAP.get(lessonId); if (!lesson) return []; return lesson.examples.map((example, index) => ({ id: `${lesson.id}:s:${index}`, jp: example[0], kana: example[1], zh: example[2], lessonTitle: lesson.title })); }); } let lastTranscript = ""; let lastSpeakingScore = null; let lastSpeakingSentenceId = null; let isRecording = false; let completedRecognitionKey = ""; function renderSpeaking() { const pool = speakingPool(); if (!pool.length) { return `

Speaking · 開口說

先完成一課,再開口

開口說的句子會從你已完成的課程例句中選取,確保每句都學過、看得懂。

句子池還是空的

完成第一課後,例句會自動加入。

`; } const sentence = pool[speakingIndex % pool.length]; const recognitionSupported = Boolean(window.SpeechRecognition || window.webkitSpeechRecognition); const best = state.speakingBest[sentence.id]; return `

Speaking · 開口說

聽,跟著說

句子來自已完成課程:${esc(sentence.lessonTitle)}。先聽慢速,再用原速挑戰。

${(speakingIndex % pool.length) + 1} / ${pool.length}

${esc(sentence.jp)}

${esc(sentence.kana)}

${esc(sentence.zh)}

${audioButtons(sentence.kana, "開口說例句")} ${best ? `

本句最佳成績:${best} 分

` : ""}
${recognitionSupported ? `` : ""}
${recordedAudioUrl ? `

` : ""} ${lastSpeakingSentenceId === sentence.id && lastTranscript ? `
辨識結果

${esc(lastTranscript)}

` : ""} ${lastSpeakingSentenceId === sentence.id && lastSpeakingScore !== null ? `
${lastSpeakingScore}

分數採寬鬆比對,重點是願意開口與持續練習。

` : ""} ${!recognitionSupported ? `
錄音自評模式

此瀏覽器不支援日語語音辨識。請先錄下自己的聲音、播放比對,再誠實自評。

` : `
辨識不可用時

也可以錄音後自評,不會影響練習紀錄。

`}

練習紀錄

已完成 ${state.speakingLog.length} 次開口說練習。

`; } function normalizeJapanese(text) { return String(text || "") .normalize("NFKC") .replace(/[\s。、!?,.・「」『』()()]/g, "") .replace(/[ァ-ヶ]/g, (char) => String.fromCharCode(char.charCodeAt(0) - 0x60)); } function editDistance(a, b) { const matrix = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0)); for (let i = 0; i <= a.length; i += 1) matrix[i][0] = i; for (let j = 0; j <= b.length; j += 1) matrix[0][j] = j; for (let i = 1; i <= a.length; i += 1) { for (let j = 1; j <= b.length; j += 1) { matrix[i][j] = Math.min( matrix[i - 1][j] + 1, matrix[i][j - 1] + 1, matrix[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1) ); } } return matrix[a.length][b.length]; } function similarity(a, b) { const left = normalizeJapanese(a); const right = normalizeJapanese(b); if (!left || !right) return 0; const distance = editDistance(left, right); return Math.max(0, 1 - distance / Math.max(left.length, right.length)); } function scoreTranscript(sentence, transcript) { const scores = [similarity(transcript, sentence.jp), similarity(transcript, sentence.kana)]; return Math.round(Math.max(...scores) * 100); } function completeSpeaking(sentence, score, mode, transcript = "") { const firstForSentence = !state.speakingBest[sentence.id]; state.speakingBest[sentence.id] = Math.max(Number(state.speakingBest[sentence.id] || 0), score); state.speakingLog.push({ sentenceId: sentence.id, score, mode, transcript, at: Date.now(), date: localDateKey() }); awardXP(firstForSentence ? 15 : 5); recordActivity("speaking", 1); lastSpeakingScore = score; lastSpeakingSentenceId = sentence.id; lastTranscript = transcript; checkBadges(); saveState(); showToast(`跟讀完成:${score} 分,獲得 ${firstForSentence ? 15 : 5} XP。`); render(); } async function startRecorderOnly() { if (!navigator.mediaDevices?.getUserMedia || !window.MediaRecorder) { showToast("此瀏覽器不支援錄音,請改用自評。"); return false; } try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); mediaChunks = []; mediaRecorder = new MediaRecorder(stream); mediaRecorder.ondataavailable = (event) => { if (event.data.size) mediaChunks.push(event.data); }; mediaRecorder.onstop = () => { stream.getTracks().forEach((track) => track.stop()); if (recordedAudioUrl) URL.revokeObjectURL(recordedAudioUrl); recordedAudioUrl = URL.createObjectURL(new Blob(mediaChunks, { type: mediaRecorder.mimeType || "audio/webm" })); isRecording = false; render(); }; mediaRecorder.start(); isRecording = true; render(); return true; } catch { showToast("麥克風未授權,可以使用自評模式繼續練習。"); return false; } } function stopRecorderOnly() { if (mediaRecorder && mediaRecorder.state !== "inactive") mediaRecorder.stop(); } function startSpeaking(sentence) { const Recognition = window.SpeechRecognition || window.webkitSpeechRecognition; if (!Recognition) { startRecorderOnly(); return; } lastTranscript = ""; lastSpeakingScore = null; lastSpeakingSentenceId = sentence.id; try { recognition = new Recognition(); } catch { startRecorderOnly(); return; } recognition.lang = "ja-JP"; recognition.interimResults = true; recognition.continuous = false; recognition.maxAlternatives = 3; recognition.onresult = (event) => { let interim = ""; for (let i = event.resultIndex; i < event.results.length; i += 1) { const result = event.results[i]; if (!result.isFinal) { interim = result[0]?.transcript || interim; continue; } const alternatives = [...result].map((alt) => alt.transcript).filter(Boolean); let bestTranscript = alternatives[0] || ""; let bestScore = -1; alternatives.forEach((candidate) => { const candidateScore = scoreTranscript(sentence, candidate); if (candidateScore > bestScore) { bestScore = candidateScore; bestTranscript = candidate; } }); const completionKey = `${sentence.id}:${bestTranscript}:${bestScore}`; if (completionKey !== completedRecognitionKey) { completedRecognitionKey = completionKey; completeSpeaking(sentence, bestScore, "recognition", bestTranscript); } } if (interim) { lastTranscript = interim; const node = document.querySelector(".transcript p"); if (node) node.textContent = interim; } }; recognition.onerror = () => showToast("辨識未成功,可以播放錄音並使用自評。"); recognition.onend = () => { recognition = null; }; startRecorderOnly(); try { recognition.start(); } catch { /* The recorder/self-assessment path remains available. */ } } function render() { parseHash(); let html = ""; if (route === "dashboard") html = renderDashboard(); else if (route === "kana") html = renderKana(); else if (route === "lessons") html = renderLessons(); else if (route === "lesson") { const lesson = LESSON_MAP.get(routeParam); html = lesson ? renderLesson(lesson) : renderLessons(); } else if (route === "kanji") html = renderKanji(); else if (route === "review") html = renderReview(); else if (route === "speaking") html = renderSpeaking(); appEl.innerHTML = html; updateNav(); } document.querySelectorAll("[data-route]").forEach((button) => { button.addEventListener("click", () => setRoute(button.dataset.route)); }); window.addEventListener("hashchange", render); document.addEventListener("click", (event) => { const playButton = event.target.closest("[data-play]"); if (playButton) { event.preventDefault(); event.stopPropagation(); playJapanese(playButton.dataset.play, playButton.dataset.speed || "normal"); return; } const navButton = event.target.closest("[data-nav]"); if (navButton) { setRoute(navButton.dataset.nav); return; } const lessonButton = event.target.closest("[data-open-lesson]"); if (lessonButton) { setRoute("lesson", lessonButton.dataset.openLesson); return; } const scriptButton = event.target.closest("[data-kana-script]"); if (scriptButton) { kanaScript = scriptButton.dataset.kanaScript; kanaQuestion = null; render(); return; } const modeButton = event.target.closest("[data-kana-mode]"); if (modeButton) { kanaMode = modeButton.dataset.kanaMode; kanaQuestion = null; render(); return; } const kanaAnswer = event.target.closest("[data-kana-answer]"); if (kanaAnswer && kanaQuestion && !kanaAnswered) { kanaAnswered = true; const correct = kanaAnswer.dataset.kanaAnswer === kanaQuestion.answer[0]; kanaAnswer.classList.add(correct ? "correct" : "wrong"); document.querySelectorAll("[data-kana-answer]").forEach((button) => { button.disabled = true; }); const feedback = document.getElementById("kana-feedback"); if (feedback) { feedback.textContent = correct ? `答對了:${kanaQuestion.answer[0]} 讀作 ${kanaQuestion.answer[1]}。` : `這題是 ${kanaQuestion.answer[0]},讀作 ${kanaQuestion.answer[1]}。`; feedback.classList.add(correct ? "good" : "bad"); } const next = document.querySelector("[data-next-kana]"); if (next) next.disabled = false; if (correct) { state.kanaCorrect += 1; awardXP(2); recordActivity("kana", 1); checkBadges(); saveState(); } return; } if (event.target.closest("[data-next-kana]")) { kanaQuestion = null; render(); return; } const kanjiChip = event.target.closest("[data-kanji-index]"); if (kanjiChip) { selectedKanjiIndex = Number(kanjiChip.dataset.kanjiIndex); render(); return; } const kanjiStep = event.target.closest("[data-kanji-step]"); if (kanjiStep) { selectedKanjiIndex = (selectedKanjiIndex + Number(kanjiStep.dataset.kanjiStep) + DATA.kanji.length) % DATA.kanji.length; render(); return; } const flip = event.target.closest("[data-flip]"); if (flip) { flip.classList.toggle("flipped"); return; } const option = event.target.closest("[data-exercise-option]"); if (option) { const lesson = LESSON_MAP.get(option.dataset.lesson); if (!lesson) return; const type = option.dataset.type; const answer = Number(option.dataset.answer); const index = Number(option.dataset.index); const key = `${lesson.id}:${type}`; if (exerciseAttempts[key] === undefined) exerciseAttempts[key] = true; const card = option.closest("[data-exercise-card]"); const feedback = card?.querySelector(`[data-feedback="${type}"]`); if (index !== answer) { exerciseAttempts[key] = false; option.classList.add("wrong"); option.disabled = true; if (feedback) { feedback.textContent = "再想想看,你可以的。"; feedback.classList.add("bad"); } return; } option.classList.add("correct"); card?.querySelectorAll("[data-exercise-option]").forEach((button) => { button.disabled = true; }); if (feedback) { feedback.textContent = "答對了,練習完成。"; feedback.classList.remove("bad"); feedback.classList.add("good"); } markExerciseDone(lesson, type, exerciseAttempts[key]); return; } const matchButton = event.target.closest("[data-match-side]"); if (matchButton) { const lesson = LESSON_MAP.get(matchButton.dataset.lesson); if (!lesson || lessonResult(lesson.id).match?.done) return; const selection = matchSelections[lesson.id] || (matchSelections[lesson.id] = { left: null, matched: new Set(), correctFirstTry: true }); const pair = Number(matchButton.dataset.pair); const card = matchButton.closest("[data-exercise-card]"); const feedback = card?.querySelector('[data-feedback="match"]'); if (matchButton.dataset.matchSide === "left") { card.querySelectorAll('[data-match-side="left"]').forEach((button) => button.classList.remove("selected")); selection.left = pair; matchButton.classList.add("selected"); if (feedback) feedback.textContent = "再選右邊的中文意思。"; return; } if (selection.left === null) { if (feedback) feedback.textContent = "請先選左邊的日文。"; return; } if (selection.left === pair) { card.querySelectorAll(".match-btn").forEach((button) => { if (Number(button.dataset.pair) === pair) { button.classList.add("matched"); button.classList.remove("selected"); button.disabled = true; } }); selection.matched.add(pair); selection.left = null; if (feedback) feedback.textContent = `已完成 ${selection.matched.size}/4 組配對。`; if (selection.matched.size === 4) { if (feedback) { feedback.textContent = "配對完成,做得好。"; feedback.classList.add("good"); } markExerciseDone(lesson, "match", selection.correctFirstTry); } } else { selection.correctFirstTry = false; matchButton.classList.add("wrong"); window.setTimeout(() => matchButton.classList.remove("wrong"), 500); if (feedback) feedback.textContent = "這兩個不是一組,再試一次。"; } return; } if (event.target.closest("[data-reveal-review]")) { reviewRevealed = true; render(); return; } const gradeButton = event.target.closest("[data-grade]"); if (gradeButton) { gradeReview(Number(gradeButton.dataset.grade)); return; } if (event.target.closest("[data-start-speaking]")) { const pool = speakingPool(); if (pool.length) startSpeaking(pool[speakingIndex % pool.length]); return; } if (event.target.closest("[data-toggle-record]")) { if (isRecording) stopRecorderOnly(); else startRecorderOnly(); return; } if (event.target.closest("[data-next-speaking]")) { speakingIndex += 1; if (recordedAudioUrl) { URL.revokeObjectURL(recordedAudioUrl); recordedAudioUrl = null; } lastTranscript = ""; lastSpeakingScore = null; render(); return; } const selfScore = event.target.closest("[data-self-score]"); if (selfScore) { const pool = speakingPool(); if (pool.length) completeSpeaking(pool[speakingIndex % pool.length], Number(selfScore.dataset.selfScore), "self", ""); return; } }); document.addEventListener("keydown", (event) => { const flip = event.target.closest?.("[data-flip]"); if (flip && (event.key === "Enter" || event.key === " ") && event.target === flip) { event.preventDefault(); flip.classList.toggle("flipped"); } }); render(); loadRemoteState(); if ("serviceWorker" in navigator && (location.protocol === "https:" || location.hostname === "localhost")) { window.addEventListener("load", () => { navigator.serviceWorker.register("/sw.js").catch(() => {}); }); }