//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 = '
Loading lyrics...
'; try { await lyricsManager.ensureComponentLoaded(); // Set initial Romaji mode lyricsManager.isRomajiMode = lyricsManager.getRomajiMode(); lyricsManager.currentTrackId = track.id; const title = track.title; const artist = getTrackArtists(track); const album = track.album?.title; const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined; const isrc = track.isrc || ""; container.innerHTML = ""; const amLyrics = document.createElement("am-lyrics"); amLyrics.setAttribute("song-title", title); amLyrics.setAttribute("song-artist", artist); if (album) amLyrics.setAttribute("song-album", album); if (durationMs) amLyrics.setAttribute("song-duration", durationMs); amLyrics.setAttribute("query", `${title} ${artist}`.trim()); if (isrc) amLyrics.setAttribute("isrc", isrc); amLyrics.setAttribute("highlight-color", "#93c5fd"); amLyrics.setAttribute("hover-background-color", "rgba(59, 130, 246, 0.14)"); amLyrics.setAttribute("autoscroll", ""); amLyrics.setAttribute("interpolate", ""); amLyrics.style.height = "100%"; amLyrics.style.width = "100%"; container.appendChild(amLyrics); // Setup observer IMMEDIATELY to catch lyrics as they load (not after waiting) // This is critical - observer must be running before lyrics arrive from LRCLIB lyricsManager.setupLyricsObserver(amLyrics); // If Romaji mode is enabled, ensure Kuroshiro is ready if (lyricsManager.isRomajiMode && !lyricsManager.kuroshiroLoaded) { await lyricsManager.loadKuroshiro(); } // Wait for lyrics to appear, then do an immediate conversion const waitForLyrics = () => { return new Promise((resolve) => { // Check if lyrics are already loaded const checkForLyrics = () => { const hasLyrics = amLyrics.querySelector(".lyric-line, [class*='lyric']") || (amLyrics.shadowRoot && amLyrics.shadowRoot.querySelector("[class*='lyric']")) || (amLyrics.textContent && amLyrics.textContent.length > 50); return hasLyrics; }; if (checkForLyrics()) { resolve(); return; } // Check more frequently (200ms) for faster response let attempts = 0; const maxAttempts = 25; // 5 seconds max const interval = setInterval(() => { attempts++; if (checkForLyrics() || attempts >= maxAttempts) { clearInterval(interval); resolve(); } }, 200); }); }; await waitForLyrics(); // Convert immediately after lyrics detected if (lyricsManager.isRomajiMode) { await lyricsManager.convertLyricsContent(amLyrics); // One retry after 500ms in case more lyrics load setTimeout(() => lyricsManager.convertLyricsContent(amLyrics), 500); } const cleanup = setupSync(track, audioPlayer, amLyrics); // Attach cleanup to container for easy access container.lyricsCleanup = cleanup; container.lyricsManager = lyricsManager; return amLyrics; } catch (error) { console.error("Failed to load lyrics:", error); container.innerHTML = '
Failed to load lyrics
'; return null; } } function setupSync(track, audioPlayer, amLyrics) { let baseTimeMs = 0; let lastTimestamp = performance.now(); let animationFrameId = null; const updateTime = () => { const currentMs = audioPlayer.currentTime * 1000; baseTimeMs = currentMs; lastTimestamp = performance.now(); amLyrics.currentTime = currentMs; }; const tick = () => { if (!audioPlayer.paused) { const now = performance.now(); const elapsed = now - lastTimestamp; const nextMs = baseTimeMs + elapsed; amLyrics.currentTime = nextMs; animationFrameId = requestAnimationFrame(tick); } }; const onPlay = () => { baseTimeMs = audioPlayer.currentTime * 1000; lastTimestamp = performance.now(); tick(); }; const onPause = () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); animationFrameId = null; } }; const onLineClick = (e) => { if (e.detail && e.detail.timestamp) { audioPlayer.currentTime = e.detail.timestamp / 1000; audioPlayer.play(); } }; audioPlayer.addEventListener("timeupdate", updateTime); audioPlayer.addEventListener("play", onPlay); audioPlayer.addEventListener("pause", onPause); audioPlayer.addEventListener("seeked", updateTime); amLyrics.addEventListener("line-click", onLineClick); if (!audioPlayer.paused) { tick(); } return () => { if (animationFrameId) { cancelAnimationFrame(animationFrameId); } audioPlayer.removeEventListener("timeupdate", updateTime); audioPlayer.removeEventListener("play", onPlay); audioPlayer.removeEventListener("pause", onPause); audioPlayer.removeEventListener("seeked", updateTime); amLyrics.removeEventListener("line-click", onLineClick); }; } export async function renderLyricsInFullscreen( track, audioPlayer, lyricsManager, container, ) { return renderLyricsComponent(container, track, audioPlayer, lyricsManager); } export function clearFullscreenLyricsSync(container) { if (container && container.lyricsCleanup) { container.lyricsCleanup(); container.lyricsCleanup = null; } if (container && container.lyricsManager) { container.lyricsManager.stopLyricsObserver(); } } export function clearLyricsPanelSync(audioPlayer, panel) { if (panel && panel.lyricsCleanup) { panel.lyricsCleanup(); panel.lyricsCleanup = null; } if (panel && panel.lyricsManager) { panel.lyricsManager.stopLyricsObserver(); } }