//js/lyrics.js import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_DOWNLOAD, SVG_CLOSE } from './utils.js'; import { sidePanelManager } from './side-panel.js'; // Dictionary path for kuromoji // Using CDN - the kuroshiro-analyzer loaded from unpkg will use this as base for fetching dict files const KUROMOJI_DICT_PATH = 'https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/'; export class LyricsManager { constructor(api) { this.api = api; this.currentLyrics = null; this.syncedLyrics = []; this.lyricsCache = new Map(); this.componentLoaded = false; this.amLyricsElement = null; this.animationFrameId = null; this.currentTrackId = null; this.mutationObserver = null; this.romajiObserver = null; this.isRomajiMode = false; this.originalLyricsData = null; this.kuroshiroLoaded = false; this.kuroshiroLoading = false; this.romajiTextCache = new Map(); // Cache: originalText -> convertedRomaji this.convertedTracksCache = new Set(); // Track IDs that have been fully converted } // Load Kuroshiro from CDN (npm package uses Node.js path which doesn't work in browser) async loadKuroshiro() { if (this.kuroshiroLoaded) return true; if (this.kuroshiroLoading) { // Wait for existing load to complete return new Promise((resolve) => { const checkLoad = setInterval(() => { if (!this.kuroshiroLoading) { clearInterval(checkLoad); resolve(this.kuroshiroLoaded); } }, 100); }); } this.kuroshiroLoading = true; try { // Bug on kuromoji@0.1.2 where it mangles absolute URLs // Using self-hosted dict files is failed, so we use CDN with monkey-patch // Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN // Kuromoji uses XHR, not fetch, for loading dictionary files if (!window._originalXHROpen) { window._originalXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (method, url, ...rest) { const urlStr = url.toString(); if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) { // Extract just the filename const filename = urlStr.split('/').pop(); // Redirect to CDN const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`; return window._originalXHROpen.call(this, method, cdnUrl, ...rest); } return window._originalXHROpen.call(this, method, url, ...rest); }; } // Also patch fetch just in case if (!window._originalFetch) { window._originalFetch = window.fetch; window.fetch = async (url, options) => { const urlStr = url.toString(); if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) { const filename = urlStr.split('/').pop(); const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`; console.log(`Redirecting dict fetch: ${filename} -> CDN`); return window._originalFetch(cdnUrl, options); } return window._originalFetch(url, options); }; } // Load Kuroshiro from CDN if (!window.Kuroshiro) { await this.loadScript('https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js'); } // Load Kuromoji analyzer from CDN if (!window.KuromojiAnalyzer) { await this.loadScript( 'https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js' ); } // Initialize Kuroshiro (CDN version exports as .default) const Kuroshiro = window.Kuroshiro.default || window.Kuroshiro; const KuromojiAnalyzer = window.KuromojiAnalyzer.default || window.KuromojiAnalyzer; this.kuroshiro = new Kuroshiro(); // Initialize with a dummy path - our fetch interceptor will redirect to CDN await this.kuroshiro.init( new KuromojiAnalyzer({ dictPath: '/dict/', // This gets mangled but our interceptor fixes it }) ); this.kuroshiroLoaded = true; this.kuroshiroLoading = false; console.log('✓ Kuroshiro loaded and initialized successfully'); return true; } catch (error) { console.error('✗ Failed to load Kuroshiro:', error); this.kuroshiroLoaded = false; this.kuroshiroLoading = false; return false; } } // Helper to load external scripts loadScript(src) { return new Promise((resolve, reject) => { // Check if script already exists if (document.querySelector(`script[src="${src}"]`)) { resolve(); return; } const script = document.createElement('script'); script.src = src; script.onload = resolve; script.onerror = () => reject(new Error(`Failed to load script: ${src}`)); document.head.appendChild(script); }); } // Check if text contains Japanese characters containsJapanese(text) { if (!text) return false; // Match any Japanese character (Hiragana, Katakana, Kanji) return /[\u3040-\u30FF\u31F0-\u9FFF]/.test(text); } // Convert Japanese text to Romaji (including Kanji) with caching async convertToRomaji(text) { if (!text) return text; // Check cache first if (this.romajiTextCache.has(text)) { return this.romajiTextCache.get(text); } // Make sure Kuroshiro is loaded if (!this.kuroshiroLoaded) { const success = await this.loadKuroshiro(); if (!success) { console.warn('Kuroshiro not available, skipping conversion'); return text; } } if (!this.kuroshiro) { console.warn('Kuroshiro not available, skipping conversion'); return text; } try { // Convert to Romaji using Kuroshiro (handles Kanji, Hiragana, Katakana) const result = await this.kuroshiro.convert(text, { to: 'romaji', mode: 'spaced', romajiSystem: 'hepburn', }); // Cache the result this.romajiTextCache.set(text, result); return result; } catch (error) { console.warn('Romaji conversion failed for text:', text.substring(0, 30), error); return text; } } // Set Romaji mode and save preference setRomajiMode(enabled) { this.isRomajiMode = enabled; try { localStorage.setItem('lyricsRomajiMode', enabled ? 'true' : 'false'); } catch (e) { console.warn('Failed to save Romaji mode preference:', e); } } // Get saved Romaji mode preference getRomajiMode() { try { return localStorage.getItem('lyricsRomajiMode') === 'true'; } catch (e) { return false; } } async ensureComponentLoaded() { if (this.componentLoaded) return; if (typeof customElements !== 'undefined' && customElements.get('am-lyrics')) { this.componentLoaded = true; return; } return new Promise((resolve, reject) => { const script = document.createElement('script'); script.type = 'module'; script.src = 'https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js'; script.onload = () => { if (typeof customElements !== 'undefined') { customElements .whenDefined('am-lyrics') .then(() => { this.componentLoaded = true; resolve(); }) .catch(reject); } else { resolve(); } }; script.onerror = () => reject(new Error('Failed to load lyrics component')); document.head.appendChild(script); }); } async fetchLyrics(trackId, track = null) { if (track) { if (this.lyricsCache.has(trackId)) { return this.lyricsCache.get(trackId); } try { const artist = Array.isArray(track.artists) ? track.artists.map((a) => a.name || a).join(', ') : track.artist?.name || ''; const title = track.title || ''; const album = track.album?.title || ''; const duration = track.duration ? Math.round(track.duration) : null; if (!title || !artist) { console.warn('Missing required fields for LRCLIB'); return null; } const params = new URLSearchParams({ track_name: title, artist_name: artist, }); if (album) params.append('album_name', album); if (duration) params.append('duration', duration.toString()); const response = await fetch(`https://lrclib.net/api/get?${params.toString()}`); if (response.ok) { const data = await response.json(); if (data.syncedLyrics) { const lyricsData = { subtitles: data.syncedLyrics, lyricsProvider: 'LRCLIB', }; this.lyricsCache.set(trackId, lyricsData); return lyricsData; } } } catch (error) { console.warn('LRCLIB fetch failed:', error); } } return null; } parseSyncedLyrics(subtitles) { if (!subtitles) return []; const lines = subtitles.split('\n').filter((line) => line.trim()); return lines .map((line) => { const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/); if (match) { const [, minutes, seconds, centiseconds, text] = match; const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100; return { time: timeInSeconds, text: text.trim() }; } return null; }) .filter(Boolean); } generateLRCContent(lyricsData, track) { if (!lyricsData || !lyricsData.subtitles) return null; const trackTitle = getTrackTitle(track); const trackArtist = getTrackArtists(track); let lrc = `[ti:${trackTitle}]\n`; lrc += `[ar:${trackArtist}]\n`; lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`; lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`; lrc += '\n'; lrc += lyricsData.subtitles; return lrc; } downloadLRC(lyricsData, track) { const lrcContent = this.generateLRCContent(lyricsData, track); if (!lrcContent) { alert('No synced lyrics available for this track'); return; } const blob = new Blob([lrcContent], { type: 'application/octet-stream' }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc'); document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } getCurrentLine(currentTime) { if (!this.syncedLyrics || this.syncedLyrics.length === 0) return -1; let currentIndex = -1; for (let i = 0; i < this.syncedLyrics.length; i++) { if (currentTime >= this.syncedLyrics[i].time) { currentIndex = i; } else { break; } } return currentIndex; } // Setup MutationObserver to convert lyrics in am-lyrics component setupLyricsObserver(amLyricsElement) { this.stopLyricsObserver(); if (!amLyricsElement) return; // Check for shadow DOM const observeRoot = amLyricsElement.shadowRoot || amLyricsElement; this.romajiObserver = new MutationObserver((mutations) => { // Check if any relevant mutation occurred const hasRelevantChange = mutations.some((mutation) => { // New nodes added if (mutation.type === 'childList' && mutation.addedNodes.length > 0) { return true; } // Text content changed if (mutation.type === 'characterData' && mutation.target.textContent) { // Only trigger if the text contains Japanese return this.containsJapanese(mutation.target.textContent); } return false; }); if (!hasRelevantChange) { return; } // Debounce mutations if (this.observerTimeout) { clearTimeout(this.observerTimeout); } this.observerTimeout = setTimeout(async () => { await this.convertLyricsContent(amLyricsElement); }, 100); }); // Observe all child nodes for changes (in shadow DOM if it exists) // Watch for new nodes AND text content changes to catch when lyrics refresh this.romajiObserver.observe(observeRoot, { childList: true, subtree: true, characterData: true, // Watch text changes to catch lyric refreshes attributes: false, // Don't watch attribute changes (highlight, etc) }); // Initial conversion if Romaji mode is enabled - single attempt, no periodic polling if (this.isRomajiMode) { this.convertLyricsContent(amLyricsElement); } } // Convert lyrics content to Romaji async convertLyricsContent(amLyricsElement) { if (!amLyricsElement || !this.isRomajiMode) { return; } // Find the root to traverse - check for shadow DOM first const rootToTraverse = amLyricsElement.shadowRoot || amLyricsElement; // Make sure Kuroshiro is ready if (!this.kuroshiroLoaded) { const success = await this.loadKuroshiro(); if (!success) { console.warn('Cannot convert lyrics - Kuroshiro load failed'); return; } } // Find all text nodes in the component const textNodes = []; const walker = document.createTreeWalker(rootToTraverse, NodeFilter.SHOW_TEXT, null, false); let node; while ((node = walker.nextNode())) { textNodes.push(node); } // Convert Japanese text to Romaji (using async/await for Kuroshiro) for (const textNode of textNodes) { if (!textNode.parentElement) { continue; } const parentTag = textNode.parentElement.tagName?.toLowerCase(); const parentClass = String(textNode.parentElement.className || ''); // Skip elements that shouldn't be converted const skipTags = ['script', 'style', 'code', 'input', 'textarea', 'time']; if (skipTags.includes(parentTag)) { continue; } const originalText = textNode.textContent; // Skip progress indicators and timestamps (but NOT progress-text which is the actual lyrics!) if ( (parentClass.includes('progress') && !parentClass.includes('progress-text')) || (parentClass.includes('time') && !parentClass.includes('progress-text')) || parentClass.includes('timestamp') ) { continue; } if (!originalText || originalText.trim().length === 0) { continue; } // Check if contains Japanese - convert if we find Japanese if (this.containsJapanese(originalText)) { const romajiText = await this.convertToRomaji(originalText); // Only update if conversion produced different text if (romajiText && romajiText !== originalText) { textNode.textContent = romajiText; } } } // Mark this track as converted if (this.currentTrackId) { this.convertedTracksCache.add(this.currentTrackId); } } // Stop the observer stopLyricsObserver() { if (this.romajiObserver) { this.romajiObserver.disconnect(); this.romajiObserver = null; } if (this.observerTimeout) { clearTimeout(this.observerTimeout); this.observerTimeout = null; } } // Toggle Romaji mode async toggleRomajiMode(amLyricsElement) { this.isRomajiMode = !this.isRomajiMode; this.setRomajiMode(this.isRomajiMode); if (amLyricsElement) { if (this.isRomajiMode) { // Turning ON: Setup observer and convert immediately this.setupLyricsObserver(amLyricsElement); await this.convertLyricsContent(amLyricsElement); } else { // Turning OFF: Stop observer // Note: To restore original Japanese, we'd need to reload the component this.stopLyricsObserver(); } } return this.isRomajiMode; } } export async function openLyricsPanel(track, audioPlayer, lyricsManager) { const manager = lyricsManager || new LyricsManager(); // Load Kuroshiro early for Kanji conversion (blocking if Romaji mode is enabled) if (!manager.kuroshiroLoaded && !manager.kuroshiroLoading) { if (manager.getRomajiMode()) { // If Romaji mode is enabled, wait for Kuroshiro to load before continuing await manager.loadKuroshiro(); } else { // Otherwise, load in background manager.loadKuroshiro().catch((err) => { console.warn('Failed to load Kuroshiro for Romaji conversion:', err); }); } } const renderControls = (container) => { const isRomajiMode = manager.getRomajiMode(); manager.isRomajiMode = isRomajiMode; container.innerHTML = ` `; container.querySelector('#close-side-panel-btn').addEventListener('click', () => { sidePanelManager.close(); clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); }); // Romaji toggle button handler const romajiBtn = container.querySelector('#romaji-toggle-btn'); if (romajiBtn) { const updateRomajiBtn = () => { const enabled = manager.isRomajiMode; romajiBtn.setAttribute('data-enabled', enabled); romajiBtn.style.color = enabled ? 'var(--primary)' : ''; }; updateRomajiBtn(); romajiBtn.addEventListener('click', async () => { const amLyrics = sidePanelManager.panel.querySelector('am-lyrics'); if (amLyrics) { const newMode = await manager.toggleRomajiMode(amLyrics); updateRomajiBtn(); } }); } }; const renderContent = async (container) => { clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); await renderLyricsComponent(container, track, audioPlayer, manager); }; sidePanelManager.open('lyrics', 'Lyrics', renderControls, renderContent); } async function renderLyricsComponent(container, track, audioPlayer, lyricsManager) { container.innerHTML = '