// === STORY READER GAME === // Prototype for reading long stories with sentence chunking and word-by-word translation class StoryReader { constructor(options) { this.container = options.container; this.content = options.content; this.onScoreUpdate = options.onScoreUpdate || (() => {}); this.onGameEnd = options.onGameEnd || (() => {}); // Reading state this.currentChapter = 0; this.currentSentence = 0; this.totalSentences = 0; this.readingSessions = 0; this.wordsRead = 0; this.comprehensionScore = 0; // Story data this.story = null; this.availableStories = []; this.currentStoryIndex = 0; this.vocabulary = {}; // UI state this.showTranslations = false; this.showPronunciations = false; this.readingMode = 'sentence'; // 'sentence' or 'paragraph' this.fontSize = 'medium'; // Reading time tracking this.startTime = Date.now(); this.totalReadingTime = 0; this.readingTimer = null; // TTS settings this.autoPlayTTS = true; this.ttsEnabled = true; // Expose content globally for SettingsManager TTS language detection window.currentGameContent = this.content; this.init(); } init() { logSh(`🔍 Story Reader content received:`, this.content, 'DEBUG'); logSh(`🔍 Story field exists: ${!!this.content.story}`, 'DEBUG'); logSh(`🔍 RawContent exists: ${!!this.content.rawContent}`, 'DEBUG'); // Discover all available stories this.discoverAvailableStories(); if (this.availableStories.length === 0) { logSh('No story content found in content or rawContent', 'ERROR'); this.showError('This content does not contain any stories for reading.'); return; } // Get URL params to check if specific story is requested const urlParams = new URLSearchParams(window.location.search); const requestedStory = urlParams.get('story'); if (requestedStory) { const storyIndex = this.availableStories.findIndex(story => story.id === requestedStory || story.title.toLowerCase().includes(requestedStory.toLowerCase()) ); if (storyIndex !== -1) { this.currentStoryIndex = storyIndex; } } this.selectStory(this.currentStoryIndex); this.vocabulary = this.content.rawContent?.vocabulary || this.content.vocabulary || {}; logSh(`📖 Story Reader initialized: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO'); this.createInterface(); this.loadProgress(); this.renderCurrentSentence(); } discoverAvailableStories() { this.availableStories = []; // Check main story field const mainStory = this.content.rawContent?.story || this.content.story; if (mainStory && mainStory.title) { this.availableStories.push({ id: 'main', title: mainStory.title, data: mainStory, source: 'main' }); } // Check additionalStories field (like in WTA1B1) const additionalStories = this.content.rawContent?.additionalStories || this.content.additionalStories; if (additionalStories && Array.isArray(additionalStories)) { additionalStories.forEach((story, index) => { if (story && story.title) { this.availableStories.push({ id: `additional_${index}`, title: story.title, data: story, source: 'additional' }); } }); } // NEW: Check for simple texts and convert them to stories const texts = this.content.rawContent?.texts || this.content.texts; if (texts && Array.isArray(texts)) { texts.forEach((text, index) => { if (text && (text.title || text.original_language)) { const convertedStory = this.convertTextToStory(text, index); this.availableStories.push({ id: `text_${index}`, title: text.title || `Text ${index + 1}`, data: convertedStory, source: 'text' }); } }); } // NEW: Check for sentences and create a story from them const sentences = this.content.rawContent?.sentences || this.content.sentences; if (sentences && Array.isArray(sentences) && sentences.length > 0 && this.availableStories.length === 0) { const sentencesStory = this.convertSentencesToStory(sentences); this.availableStories.push({ id: 'sentences', title: 'Reading Practice', data: sentencesStory, source: 'sentences' }); } logSh(`📚 Discovered ${this.availableStories.length} stories:`, this.availableStories.map(s => s.title), 'INFO'); } selectStory(storyIndex) { if (storyIndex >= 0 && storyIndex < this.availableStories.length) { this.currentStoryIndex = storyIndex; this.story = this.availableStories[storyIndex].data; this.calculateTotalSentences(); // Reset reading position for new story this.currentSentence = 0; this.wordsRead = 0; // Update URL to include story parameter this.updateUrlForStory(); logSh(`📖 Selected story: "${this.story.title}" (${this.totalSentences} sentences)`, 'INFO'); } } updateUrlForStory() { const urlParams = new URLSearchParams(window.location.search); urlParams.set('story', this.availableStories[this.currentStoryIndex].id); const newUrl = `${window.location.pathname}?${urlParams.toString()}`; window.history.replaceState({}, '', newUrl); } showError(message) { this.container.innerHTML = `

❌ Error

${message}

`; } calculateTotalSentences() { this.totalSentences = 0; this.story.chapters.forEach(chapter => { this.totalSentences += chapter.sentences.length; }); } createInterface() { // Create story selector dropdown if multiple stories available const storySelector = this.availableStories.length > 1 ? `
` : ''; this.container.innerHTML = `
${storySelector}

${this.story.title}

Sentence 1 of ${this.totalSentences}
Chapter 1: Loading...
Loading story...
Words Read: 0
Reading Time: 00:00
Progress: 0%
`; this.addStyles(); this.setupEventListeners(); } addStyles() { const style = document.createElement('style'); style.textContent = ` .story-reader-wrapper { max-width: 800px; margin: 0 auto; padding: 20px; font-family: 'Georgia', serif; line-height: 1.6; } .story-selector { background: #f8fafc; border: 2px solid #e2e8f0; border-radius: 10px; padding: 15px 20px; margin-bottom: 25px; display: flex; align-items: center; gap: 15px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); } .story-selector label { font-weight: 600; color: #2d3748; font-size: 1.1em; min-width: 120px; } .story-selector select { flex: 1; padding: 8px 12px; border: 2px solid #cbd5e0; border-radius: 6px; background: white; font-size: 1em; color: #2d3748; cursor: pointer; transition: border-color 0.2s; } .story-selector select:focus { outline: none; border-color: #3b82f6; box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1); } .story-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; padding-bottom: 15px; border-bottom: 2px solid #e2e8f0; } .story-title h2 { margin: 0 0 10px 0; color: #2d3748; font-size: 1.8em; } .reading-progress { display: flex; align-items: center; gap: 10px; } .progress-bar { width: 200px; height: 8px; background: #e2e8f0; border-radius: 4px; overflow: hidden; } .progress-fill { height: 100%; background: linear-gradient(90deg, #3b82f6, #10b981); width: 0%; transition: width 0.3s ease; } .story-controls { display: flex; gap: 10px; flex-wrap: wrap; } .control-btn { padding: 8px 12px; border: 2px solid #e2e8f0; background: white; border-radius: 6px; cursor: pointer; font-size: 0.9em; transition: all 0.2s; white-space: nowrap; } .control-btn:hover { background: #f7fafc; border-color: #cbd5e0; } .control-btn.secondary { background: #f8fafc; color: #4a5568; } .control-btn.secondary:hover { background: #e2e8f0; color: #2d3748; } .settings-panel { background: #f7fafc; border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; margin-bottom: 20px; } .setting-group { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; } .setting-group label { font-weight: 600; min-width: 100px; } .chapter-info { background: #edf2f7; padding: 10px 15px; border-radius: 6px; margin-bottom: 20px; font-style: italic; color: #4a5568; } .reading-area { position: relative; background: white; border: 2px solid #e2e8f0; border-radius: 12px; padding: 30px; margin-bottom: 20px; min-height: 200px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); } .sentence-display { text-align: center; } .original-text { font-size: 1.2em; color: #2d3748; margin-bottom: 15px; cursor: pointer; padding: 15px; border-radius: 8px; transition: background-color 0.2s; } .original-text:hover { background-color: #f7fafc; } .original-text.small { font-size: 1em; } .original-text.medium { font-size: 1.2em; } .original-text.large { font-size: 1.4em; } .original-text.extra-large { font-size: 1.6em; } .translation-text { font-style: italic; color: #718096; font-size: 1em; padding: 10px; background: #f0fff4; border-radius: 6px; border-left: 4px solid #10b981; } .clickable-word { cursor: pointer; padding: 2px 4px; border-radius: 3px; transition: background-color 0.2s; position: relative; display: inline-block; } .clickable-word:hover { background-color: #fef5e7; color: #d69e2e; } .punctuation { color: #2d3748; font-weight: normal; cursor: default; user-select: none; } .word-with-pronunciation { position: relative; display: inline-block; margin: 0 2px; vertical-align: top; line-height: 1.8; } .pronunciation-text { position: absolute; top: -16px; left: 50%; transform: translateX(-50%); font-size: 0.7em; color: #718096; font-style: italic; white-space: nowrap; pointer-events: none; z-index: 10; display: none; } .pronunciation-text.show { display: block; } .reading-area { position: relative; background: white; border: 2px solid #e2e8f0; border-radius: 12px; padding: 40px 30px 30px 30px; margin-bottom: 20px; min-height: 200px; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05); line-height: 2.2; } .word-popup { position: fixed; background: white; border: 2px solid #3b82f6; border-radius: 6px; padding: 8px 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); z-index: 9999; max-width: 200px; min-width: 120px; font-size: 0.9em; line-height: 1.3; } .word-original { font-weight: bold; color: #2d3748; font-size: 1em; margin-bottom: 3px; } .word-translation { color: #10b981; font-size: 0.9em; margin-bottom: 2px; } .word-type { font-size: 0.75em; color: #718096; font-style: italic; } .word-tts-btn { position: absolute; top: 5px; right: 5px; background: #3b82f6; color: white; border: none; border-radius: 50%; width: 24px; height: 24px; cursor: pointer; font-size: 12px; display: flex; align-items: center; justify-content: center; transition: background 0.2s; } .word-tts-btn:hover { background: #2563eb; } .story-navigation { display: flex; justify-content: center; gap: 15px; margin-bottom: 20px; } .nav-btn { padding: 12px 24px; border: 2px solid #e2e8f0; background: white; border-radius: 8px; cursor: pointer; font-size: 1em; transition: all 0.2s; } .nav-btn:hover:not(:disabled) { background: #f7fafc; border-color: #cbd5e0; } .nav-btn:disabled { opacity: 0.5; cursor: not-allowed; } .nav-btn.primary { background: #3b82f6; color: white; border-color: #3b82f6; } .nav-btn.primary:hover { background: #2563eb; } .reading-stats { display: flex; justify-content: space-around; background: #f7fafc; padding: 15px; border-radius: 8px; border: 1px solid #e2e8f0; } .stat { text-align: center; } .stat-label { display: block; font-size: 0.9em; color: #718096; margin-bottom: 5px; } .stat-value { display: block; font-weight: bold; font-size: 1.1em; color: #2d3748; } @media (max-width: 768px) { .story-reader-wrapper { padding: 10px; } .story-header { flex-direction: column; gap: 15px; } .reading-stats { flex-direction: column; gap: 10px; } } `; document.head.appendChild(style); } setupEventListeners() { // Story selector (if multiple stories) const storySelect = document.getElementById('story-select'); if (storySelect) { storySelect.addEventListener('change', (e) => this.changeStory(parseInt(e.target.value))); } // Navigation document.getElementById('prev-btn').addEventListener('click', () => this.previousSentence()); document.getElementById('next-btn').addEventListener('click', () => this.nextSentence()); document.getElementById('bookmark-btn').addEventListener('click', () => this.saveBookmark()); // Controls document.getElementById('play-sentence-btn').addEventListener('click', () => this.playSentenceTTS()); document.getElementById('settings-btn').addEventListener('click', () => this.toggleSettings()); document.getElementById('toggle-translation-btn').addEventListener('click', () => this.toggleTranslations()); document.getElementById('pronunciation-toggle-btn').addEventListener('click', () => this.togglePronunciations()); // Settings document.getElementById('font-size-select').addEventListener('change', (e) => this.changeFontSize(e.target.value)); document.getElementById('reading-mode-select').addEventListener('change', (e) => this.changeReadingMode(e.target.value)); document.getElementById('auto-play-tts').addEventListener('change', (e) => this.toggleAutoPlayTTS(e.target.checked)); document.getElementById('tts-speed-select').addEventListener('change', (e) => this.changeTTSSpeed(e.target.value)); // Keyboard shortcuts document.addEventListener('keydown', (e) => { if (e.key === 'ArrowLeft') this.previousSentence(); if (e.key === 'ArrowRight') this.nextSentence(); if (e.key === 'Space') { e.preventDefault(); this.nextSentence(); } if (e.key === 't' || e.key === 'T') this.toggleTranslations(); if (e.key === 's' || e.key === 'S') this.playSentenceTTS(); }); // Click outside to close word popup document.addEventListener('click', (e) => { if (!e.target.closest('.word-popup') && !e.target.closest('.clickable-word')) { this.hideWordPopup(); } }); } getCurrentSentenceData() { let sentenceCount = 0; for (let chapterIndex = 0; chapterIndex < this.story.chapters.length; chapterIndex++) { const chapter = this.story.chapters[chapterIndex]; if (sentenceCount + chapter.sentences.length > this.currentSentence) { const sentenceInChapter = this.currentSentence - sentenceCount; return { chapter: chapterIndex, sentence: sentenceInChapter, data: chapter.sentences[sentenceInChapter], chapterTitle: chapter.title }; } sentenceCount += chapter.sentences.length; } return null; } // Match words from sentence with centralized vocabulary matchWordsWithVocabulary(sentence) { const words = sentence.split(/(\s+|[.,!?;:"'()[\]{}\-–—])/); const matchedWords = []; words.forEach(token => { // Handle whitespace tokens if (/^\s+$/.test(token)) { matchedWords.push({ original: token, hasVocab: false, isWhitespace: true }); return; } // Handle pure punctuation tokens (preserve them as non-clickable) if (/^[.,!?;:"'()[\]{}\-–—]+$/.test(token)) { matchedWords.push({ original: token, hasVocab: false, isPunctuation: true }); return; } // Clean word (remove punctuation for matching) const cleanWord = token.toLowerCase().replace(/[.,!?;:"'()[\]{}\-–—]/g, ''); // Skip empty tokens if (!cleanWord) return; // Check if word exists in vocabulary (try exact match first, then stems) let vocabEntry = this.content.vocabulary[cleanWord]; // Try common variations if exact match not found if (!vocabEntry) { // Try without 's' for plurals if (cleanWord.endsWith('s')) { vocabEntry = this.content.vocabulary[cleanWord.slice(0, -1)]; } // Try without 'ed' for past tense if (!vocabEntry && cleanWord.endsWith('ed')) { vocabEntry = this.content.vocabulary[cleanWord.slice(0, -2)]; } // Try without 'ing' for present participle if (!vocabEntry && cleanWord.endsWith('ing')) { vocabEntry = this.content.vocabulary[cleanWord.slice(0, -3)]; } } if (vocabEntry) { // Word found in vocabulary matchedWords.push({ original: token, hasVocab: true, word: cleanWord, translation: vocabEntry.translation || vocabEntry.user_language, pronunciation: vocabEntry.pronunciation, type: vocabEntry.type || 'unknown' }); } else { // Word not in vocabulary - render as plain text matchedWords.push({ original: token, hasVocab: false }); } }); return matchedWords; } renderCurrentSentence() { const sentenceData = this.getCurrentSentenceData(); if (!sentenceData) return; const { data, chapterTitle } = sentenceData; // Update chapter info document.getElementById('chapter-info').innerHTML = ` ${chapterTitle} `; // Update progress const progress = ((this.currentSentence + 1) / this.totalSentences) * 100; document.getElementById('progress-fill').style.width = `${progress}%`; document.getElementById('progress-text').textContent = `Sentence ${this.currentSentence + 1} of ${this.totalSentences}`; document.getElementById('reading-percentage').textContent = `${Math.round(progress)}%`; // Check if sentence has word-by-word data (old format) or needs automatic matching let wordsHtml; console.log('🔍 DEBUG: sentence data:', data); console.log('🔍 DEBUG: data.words exists?', !!data.words); console.log('🔍 DEBUG: data.words length:', data.words ? data.words.length : 'N/A'); if (data.words && data.words.length > 0) { // Old format with word-by-word data wordsHtml = data.words.map(wordData => { const pronunciation = wordData.pronunciation || ''; const pronunciationHtml = pronunciation ? `${pronunciation}` : ''; return ` ${pronunciationHtml} ${wordData.word} `; }).join(' '); } else { // New format with centralized vocabulary - use automatic matching const matchedWords = this.matchWordsWithVocabulary(data.original); wordsHtml = matchedWords.map(wordInfo => { if (wordInfo.isWhitespace) { return wordInfo.original; } else if (wordInfo.isPunctuation) { // Render punctuation as non-clickable text return `${wordInfo.original}`; } else if (wordInfo.hasVocab) { const pronunciation = this.showPronunciation && wordInfo.pronunciation ? wordInfo.pronunciation : ''; const pronunciationHtml = pronunciation ? `${pronunciation}` : ''; return ` ${pronunciationHtml} ${wordInfo.original} `; } else { // No vocabulary entry - render as plain text return wordInfo.original; } }).join(''); } document.getElementById('original-text').innerHTML = wordsHtml; document.getElementById('translation-text').textContent = data.translation; // Add word click listeners document.querySelectorAll('.clickable-word').forEach(word => { word.addEventListener('click', (e) => this.showWordPopup(e)); }); // Update navigation buttons document.getElementById('prev-btn').disabled = this.currentSentence === 0; document.getElementById('next-btn').disabled = this.currentSentence >= this.totalSentences - 1; // Update stats this.updateStats(); // Auto-play TTS if enabled if (this.autoPlayTTS && this.ttsEnabled) { // Small delay to let the sentence render setTimeout(() => this.playSentenceTTS(), 300); } } showWordPopup(event) { const word = event.target.dataset.word; const translation = event.target.dataset.translation; const type = event.target.dataset.type; const pronunciation = event.target.dataset.pronunciation; logSh(`🔍 Word clicked: ${word}, translation: ${translation}`, 'DEBUG'); const popup = document.getElementById('word-popup'); if (!popup) { logSh('❌ Word popup element not found!', 'ERROR'); return; } // Store reference to story reader for TTS button popup.storyReader = this; popup.currentWord = word; document.getElementById('popup-word').textContent = word; document.getElementById('popup-translation').textContent = translation; // Show pronunciation in popup if available const typeText = pronunciation ? `${pronunciation} (${type})` : `(${type})`; document.getElementById('popup-type').textContent = typeText; // Position popup ABOVE the clicked word const rect = event.target.getBoundingClientRect(); popup.style.display = 'block'; // Center horizontally on the word, show above it const popupLeft = rect.left + (rect.width / 2) - 100; // Center popup (200px wide / 2) const popupTop = rect.top - 10; // Above the word with small gap popup.style.left = `${popupLeft}px`; popup.style.top = `${popupTop}px`; popup.style.transform = 'translateY(-100%)'; // Move up by its own height // Ensure popup stays within viewport if (popupLeft < 10) { popup.style.left = '10px'; } if (popupLeft + 200 > window.innerWidth) { popup.style.left = `${window.innerWidth - 210}px`; } if (popupTop - 80 < 10) { // If no room above, show below instead popup.style.top = `${rect.bottom + 10}px`; popup.style.transform = 'translateY(0)'; } logSh(`📍 Popup positioned at: ${rect.left}px, ${rect.bottom + 10}px`, 'DEBUG'); } hideWordPopup() { document.getElementById('word-popup').style.display = 'none'; } previousSentence() { if (this.currentSentence > 0) { this.currentSentence--; this.renderCurrentSentence(); this.saveProgress(); } } nextSentence() { if (this.currentSentence < this.totalSentences - 1) { this.currentSentence++; this.renderCurrentSentence(); this.saveProgress(); } else { this.completeReading(); } } toggleSettings() { const panel = document.getElementById('settings-panel'); panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; } toggleTranslations() { this.showTranslations = !this.showTranslations; const translationText = document.getElementById('translation-text'); translationText.style.display = this.showTranslations ? 'block' : 'none'; const btn = document.getElementById('toggle-translation-btn'); btn.textContent = this.showTranslations ? '🌐 Hide Translations' : '🌐 Show Translations'; } togglePronunciations() { this.showPronunciations = !this.showPronunciations; const pronunciations = document.querySelectorAll('.pronunciation-text'); pronunciations.forEach(pronunciation => { if (this.showPronunciations) { pronunciation.classList.add('show'); } else { pronunciation.classList.remove('show'); } }); const btn = document.getElementById('pronunciation-toggle-btn'); btn.textContent = this.showPronunciations ? '🔇 Hide Pronunciations' : '🔊 Show Pronunciations'; } changeFontSize(size) { this.fontSize = size; document.getElementById('original-text').className = `original-text ${size}`; } changeReadingMode(mode) { this.readingMode = mode; // Mode implementation can be extended later } changeStory(storyIndex) { if (storyIndex !== this.currentStoryIndex) { // Save progress for current story before switching this.saveProgress(); // Select new story this.selectStory(storyIndex); // Load progress for new story this.loadProgress(); // Update the interface title and progress this.updateStoryTitle(); this.renderCurrentSentence(); logSh(`📖 Switched to story: "${this.story.title}"`, 'INFO'); } } updateStoryTitle() { const titleElement = document.querySelector('.story-title h2'); if (titleElement) { titleElement.textContent = this.story.title; } } // NEW: Convert simple text to story format convertTextToStory(text, index) { // Split text into sentences for easier reading const sentences = this.splitTextIntoSentences(text.original_language, text.user_language); return { title: text.title || `Text ${index + 1}`, totalSentences: sentences.length, chapters: [{ title: "Reading Text", sentences: sentences }] }; } // NEW: Convert array of sentences to story format convertSentencesToStory(sentences) { const storyTitle = this.content.name || "Reading Practice"; const convertedSentences = sentences.map((sentence, index) => ({ id: index + 1, original: sentence.original_language || sentence.english || sentence.original || '', translation: sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '', words: this.breakSentenceIntoWords( sentence.original_language || sentence.english || sentence.original || '', sentence.user_language || sentence.chinese || sentence.french || sentence.translation || '' ) })); return { title: storyTitle, totalSentences: convertedSentences.length, chapters: [{ title: "Reading Sentences", sentences: convertedSentences }] }; } // NEW: Split long text into manageable sentences splitTextIntoSentences(originalText, translationText) { // Split by sentence endings const originalSentences = originalText.split(/[.!?]+/).filter(s => s.trim().length > 0); const translationSentences = translationText.split(/[.!?]+/).filter(s => s.trim().length > 0); const sentences = []; const maxSentences = Math.max(originalSentences.length, translationSentences.length); for (let i = 0; i < maxSentences; i++) { const original = (originalSentences[i] || '').trim(); const translation = (translationSentences[i] || '').trim(); if (original || translation) { sentences.push({ id: i + 1, original: original + (original && !original.match(/[.!?]$/) ? '.' : ''), translation: translation + (translation && !translation.match(/[.!?]$/) ? '.' : ''), words: this.breakSentenceIntoWords(original, translation) }); } } return sentences; } // NEW: Break sentence into word-by-word format for Story Reader breakSentenceIntoWords(original, translation) { if (!original) return []; // First, separate punctuation from words while preserving spaces const preprocessed = original.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 '); const words = preprocessed.split(/\s+/).filter(word => word.trim().length > 0); // Do the same for translation const translationPreprocessed = translation ? translation.replace(/([.,!?;:"'()[\]{}\-–—])/g, ' $1 ') : ''; const translationWords = translationPreprocessed ? translationPreprocessed.split(/\s+/).filter(word => word.trim().length > 0) : []; return words.map((word, index) => { // Clean punctuation for word lookup, but preserve punctuation in display const cleanWord = word.replace(/[.,!?;:"'()[\]{}\-–—]/g, '').toLowerCase(); // Try to find in vocabulary let wordTranslation = translationWords[index] || ''; let wordType = 'word'; let pronunciation = ''; // Special handling for letter pairs (like "Aa", "Bb", etc.) if (/^[A-Za-z]{1,2}$/.test(cleanWord)) { wordType = 'letter'; wordTranslation = word; // Keep the letter as is } // Special handling for punctuation marks if (/^[.,!?;:"'()[\]{}]$/.test(word)) { wordType = 'punctuation'; wordTranslation = word; // Keep punctuation as is } // Look up in content vocabulary if available if (this.vocabulary && this.vocabulary[cleanWord]) { const vocabEntry = this.vocabulary[cleanWord]; wordTranslation = vocabEntry.user_language || vocabEntry.translation || wordTranslation; wordType = vocabEntry.type || wordType; pronunciation = vocabEntry.pronunciation || ''; } return { word: word, translation: wordTranslation, type: wordType, pronunciation: pronunciation }; }); } // TTS Methods playSentenceTTS() { const sentenceData = this.getCurrentSentenceData(); if (!sentenceData || !this.ttsEnabled) return; const text = sentenceData.data.original; this.speakText(text); } speakText(text, options = {}) { if (!text || !this.ttsEnabled) return; // Use SettingsManager if available for better language support if (window.SettingsManager && window.SettingsManager.speak) { const ttsOptions = { lang: this.getContentLanguage(), rate: parseFloat(document.getElementById('tts-speed-select')?.value || '0.8'), ...options }; window.SettingsManager.speak(text, ttsOptions) .catch(error => { console.warn('🔊 SettingsManager TTS failed:', error); this.fallbackTTS(text, ttsOptions); }); } else { this.fallbackTTS(text, options); } } fallbackTTS(text, options = {}) { if ('speechSynthesis' in window && text) { // Cancel any ongoing speech speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); utterance.lang = this.getContentLanguage(); utterance.rate = options.rate || 0.8; utterance.volume = 1.0; speechSynthesis.speak(utterance); } } getContentLanguage() { // Get language from content or use sensible defaults if (this.content.language) { const langMap = { 'chinese': 'zh-CN', 'english': 'en-US', 'french': 'fr-FR', 'spanish': 'es-ES' }; return langMap[this.content.language] || this.content.language; } return 'en-US'; // Default fallback } toggleAutoPlayTTS(enabled) { this.autoPlayTTS = enabled; logSh(`🔊 Auto-play TTS ${enabled ? 'enabled' : 'disabled'}`, 'INFO'); } changeTTSSpeed(speed) { logSh(`🔊 TTS speed changed to ${speed}x`, 'INFO'); } speakWordFromPopup() { const popup = document.getElementById('word-popup'); if (popup && popup.currentWord) { this.speakText(popup.currentWord, { rate: 0.7 }); // Slower for individual words } } updateStats() { const sentenceData = this.getCurrentSentenceData(); if (sentenceData) { this.wordsRead += sentenceData.data.words.length; document.getElementById('words-read').textContent = this.wordsRead; } } saveProgress() { const progressData = { currentSentence: this.currentSentence, wordsRead: this.wordsRead, timestamp: Date.now() }; const progressKey = this.getProgressKey(); localStorage.setItem(progressKey, JSON.stringify(progressData)); } loadProgress() { const progressKey = this.getProgressKey(); const saved = localStorage.getItem(progressKey); if (saved) { try { const data = JSON.parse(saved); this.currentSentence = data.currentSentence || 0; this.wordsRead = data.wordsRead || 0; } catch (error) { logSh('Error loading progress:', error, 'WARN'); this.currentSentence = 0; this.wordsRead = 0; } } else { // No saved progress - start fresh this.currentSentence = 0; this.wordsRead = 0; } } getProgressKey() { const storyId = this.availableStories[this.currentStoryIndex]?.id || 'main'; return `story_progress_${this.content.name}_${storyId}`; } saveBookmark() { this.saveProgress(); const toast = document.createElement('div'); toast.textContent = '🔖 Bookmark saved!'; toast.style.cssText = ` position: fixed; top: 20px; right: 20px; background: #10b981; color: white; padding: 10px 20px; border-radius: 6px; z-index: 1000; `; document.body.appendChild(toast); setTimeout(() => toast.remove(), 2000); } completeReading() { this.onGameEnd(this.wordsRead); const completionMessage = `

🎉 Story Complete!

You've finished reading "${this.story.title}"

Words read: ${this.wordsRead}

Total sentences: ${this.totalSentences}

`; document.getElementById('reading-area').innerHTML = completionMessage; } start() { logSh('📖 Story Reader: Starting', 'INFO'); this.startReadingTimer(); } startReadingTimer() { this.startTime = Date.now(); this.readingTimer = setInterval(() => { this.updateReadingTime(); }, 1000); } updateReadingTime() { const currentTime = Date.now(); this.totalReadingTime = Math.floor((currentTime - this.startTime) / 1000); const minutes = Math.floor(this.totalReadingTime / 60); const seconds = this.totalReadingTime % 60; const timeString = `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; document.getElementById('reading-time').textContent = timeString; } restart() { this.currentSentence = 0; this.wordsRead = 0; // Restart reading timer if (this.readingTimer) { clearInterval(this.readingTimer); } this.startReadingTimer(); this.renderCurrentSentence(); this.saveProgress(); } destroy() { // Clean up timer if (this.readingTimer) { clearInterval(this.readingTimer); } this.container.innerHTML = ''; } // === COMPATIBILITY SYSTEM === static getCompatibilityRequirements() { return { minimum: { texts: 1 }, optimal: { texts: 1, stories: 1 }, name: "Story Reader", description: "Needs text content or stories for guided reading experience" }; } static checkContentCompatibility(content) { const requirements = StoryReader.getCompatibilityRequirements(); // Extract texts and stories using same method as instance const texts = StoryReader.extractTextsStatic(content); const stories = StoryReader.extractStoriesStatic(content); const textCount = texts.length; const storyCount = stories.length; const totalContent = textCount + storyCount; // Dynamic percentage based on content quality (1 text min → 1 story optimal) // Stories are much better than texts for reading experience let score = 0; if (storyCount >= 1) { score = 100; // Perfect: has stories } else if (textCount >= 3) { score = 75; // Good: multiple texts } else if (textCount >= 1) { score = 50; // Basic: at least one text } else { score = 0; // No content } const recommendations = []; if (totalContent === 0) { recommendations.push("Add text content or stories for reading"); } else if (storyCount === 0) { recommendations.push("Add story content for optimal reading experience"); } return { score: Math.round(score), details: { texts: { found: textCount, minimum: requirements.minimum.texts, status: textCount >= requirements.minimum.texts ? 'sufficient' : 'insufficient' }, stories: { found: storyCount, optimal: requirements.optimal.stories, status: storyCount >= requirements.optimal.stories ? 'available' : 'missing' } }, recommendations: recommendations }; } static extractTextsStatic(content) { let texts = []; // Priority 1: Use raw module content if (content.rawContent) { // Extract from texts array if (content.rawContent.texts && Array.isArray(content.rawContent.texts)) { texts.push(...content.rawContent.texts); } } // Priority 2: Direct content properties if (content.texts && Array.isArray(content.texts)) { texts.push(...content.texts); } // Filter valid texts texts = texts.filter(text => text && typeof text === 'object' && text.content && typeof text.content === 'string' && text.content.trim().length > 10 ); return texts; } static extractStoriesStatic(content) { let stories = []; // Priority 1: Use raw module content if (content.rawContent) { // Extract from story object if (content.rawContent.story && content.rawContent.story.chapters) { stories.push(content.rawContent.story); } // Extract from additionalStories array if (content.rawContent.additionalStories && Array.isArray(content.rawContent.additionalStories)) { stories.push(...content.rawContent.additionalStories); } } // Priority 2: Direct content properties if (content.story && content.story.chapters) { stories.push(content.story); } if (content.additionalStories && Array.isArray(content.additionalStories)) { stories.push(...content.additionalStories); } // Filter valid stories stories = stories.filter(story => story && typeof story === 'object' && story.chapters && Array.isArray(story.chapters) && story.chapters.length > 0 ); return stories; } } // Module registration window.GameModules = window.GameModules || {}; window.GameModules.StoryReader = StoryReader;