//js/lyrics.js import { getTrackTitle, getTrackArtists, buildTrackFilename } from './utils.js'; import { SVG_CLOSE, SVG_GENIUS_ACTIVE, SVG_GENIUS_INACTIVE, SVG_MINUS, SVG_PLUS, SVG_RESET, SVG_GLOBE, } from './icons.js'; import { sidePanelManager } from './side-panel.js'; const loadAmLyrics = () => { const images = Array.from(document.images).filter((img) => !img.complete); if (images.length === 0) { import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error); } else { Promise.all( images.map( (img) => new Promise((res) => { img.onload = img.onerror = res; }) ) ).then(() => import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error)); } }; if (document.readyState === 'complete') { loadAmLyrics(); } else { window.addEventListener('load', loadAmLyrics); } // Check if text contains Japanese, Chinese, or Korean characters function containsAsianText(text) { if (!text) return false; // Japanese: Hiragana (3040-309F), Katakana (30A0-30FF), Kanji (4E00-9FFF, 3400-4DBF) // Chinese: CJK Unified Ideographs (4E00-9FFF, 3400-4DBF) // Korean: Hangul (AC00-D7AF, 1100-11FF, 3130-318F) const asianRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/; return asianRegex.test(text); } // Check if track has Asian text in title or artist names function trackHasAsianText(track) { if (!track) return false; const title = track.title || ''; const artist = getTrackArtists(track) || ''; return containsAsianText(title) || containsAsianText(artist); } // Hiragana (3040-309F) or Katakana (30A0-30FF) = unambiguously Japanese function containsJapaneseKana(text) { if (!text) return false; return /[\u3040-\u309F\u30A0-\u30FF]/.test(text); } function trackIsJapanese(track) { if (!track) return false; const title = track.title || ''; const artist = getTrackArtists(track) || ''; return containsJapaneseKana(title) || containsJapaneseKana(artist); } function cleanTrackerSearch(text) { if (!text) return ''; // chud emojis will NOT be tolerated in my precious genius lyrics worker let cleaned = text.replace( /[\p{Extended_Pictographic}\p{Emoji_Component}\p{Emoji_Presentation}\p{Emoji_Modifier}\p{Emoji_Modifier_Base}\p{Symbol}]/gu, '' ); cleaned = cleaned.replace(/[\u2600-\u27BF\u2B50\u2B06\u2194\u21AA\u2934\u203C\u2049\u3030\u303D\u3297\u3299]/g, ''); cleaned = cleaned.replace(/\[v\s*\d+\s*\]/gi, ''); cleaned = cleaned.replace(/\s+/g, ' '); return cleaned.trim(); } class GeniusManager { constructor() { this.cache = new Map(); this.loading = false; } // idgaf anymore im js hardcoding this lmaooo getToken() { const hostname = window.location.hostname; if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') { return 'OpITG-h86oehKYuJJ5QVY5F-HxUWXb31EwGKarx2Tle3W9rBUVnMaUL9qo_Oh9Q7'; } return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z'; } async searchTrack(title, artist) { const cleanTitle = title.split('(')[0].split('-')[0].trim(); const query = encodeURIComponent(`${cleanTitle} ${artist}`); const token = this.getToken(); const url = `https://api.genius.com/search?q=${query}&access_token=${token}`; const response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`); if (!response.ok) throw new Error('Failed to search Genius'); const data = await response.json(); if (data.response.hits.length === 0) return null; const normalize = (str) => str.toLowerCase().replace(/[^\p{L}\p{N}]/gu, ''); const targetArtist = normalize(artist); const hit = data.response.hits.find((h) => { const hitArtist = normalize(h.result.primary_artist.name); return hitArtist.includes(targetArtist) || targetArtist.includes(hitArtist); }); return hit ? hit.result : data.response.hits[0].result; } async getReferents(songId) { const token = this.getToken(); const url = `https://api.genius.com/referents?song_id=${songId}&text_format=plain&per_page=50&access_token=${token}`; const response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`); if (!response.ok) throw new Error('Failed to fetch annotations'); const data = await response.json(); return data.response.referents; } async getDataForTrack(track) { if (this.cache.has(track.id)) return this.cache.get(track.id); try { this.loading = true; const artist = Array.isArray(track.artists) ? track.artists[0].name : track.artist.name; const song = await this.searchTrack(track.title, artist); if (!song) { this.loading = false; return null; } const referents = await this.getReferents(song.id); const result = { song, referents }; this.cache.set(track.id, result); this.loading = false; return result; } catch (error) { console.error('Genius Error:', error); this.loading = false; throw error; } } findAnnotations(lineText, referents) { if (!referents || !lineText) return []; const normalize = (str) => str .toLowerCase() .replace(/[^\p{L}\p{N}\s]/gu, '') .replace(/\s+/g, ' ') .trim(); const normLine = normalize(lineText); const getWordSet = (str) => new Set(str.split(' ').filter((w) => w.length > 0)); const lineWords = getWordSet(normLine); return referents.filter((ref) => { const normFragment = normalize(ref.fragment); if (normLine.includes(normFragment) || normFragment.includes(normLine)) return true; const fragmentWords = getWordSet(normFragment); if (fragmentWords.size === 0 || lineWords.size === 0) return false; let matchCount = 0; fragmentWords.forEach((w) => { if (lineWords.has(w)) matchCount++; }); return matchCount / Math.min(fragmentWords.size, lineWords.size) > 0.6; }); } } export class LyricsManager { static #instance = null; static get instance() { if (!LyricsManager.#instance) { throw new Error('LyricsManager is not initialized. Call LyricsManager.initialize() first.'); } return LyricsManager.#instance; } /** @private */ 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 this.geniusManager = new GeniusManager(); this.isGeniusMode = false; this.currentGeniusData = null; this.timingOffset = 0; // Offset in milliseconds (positive = delay lyrics, negative = advance lyrics) } static async initialize(api) { if (LyricsManager.#instance) { throw new Error('LyricsManager is already initialized'); } return (LyricsManager.#instance = new LyricsManager(api)); } // Get timing offset for current track getTimingOffset(trackId) { try { const key = `lyrics-offset-${trackId}`; const stored = localStorage.getItem(key); return stored ? parseInt(stored, 10) : 0; } catch { return 0; } } // Set timing offset for current track setTimingOffset(trackId, offsetMs) { try { const key = `lyrics-offset-${trackId}`; localStorage.setItem(key, offsetMs.toString()); } catch (e) { console.warn('Failed to save lyrics timing offset:', e); } } // Reset timing offset for current track resetTimingOffset(trackId) { this.setTimingOffset(trackId, 0); } // Get formatted offset display string getOffsetDisplayString(offsetMs) { const sign = offsetMs >= 0 ? '+' : ''; const seconds = Math.abs(offsetMs) / 1000; return `${sign}${seconds.toFixed(1)}s`; } // 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) { // eslint-disable-next-line @typescript-eslint/unbound-method 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 instanceof URL ? url.toString() : url.url; 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://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js'); } // Load Kuromoji analyzer from CDN if (!window.KuromojiAnalyzer) { await this.loadScript( 'https://cdn.jsdelivr.net/npm/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); } // Only process if text contains Asian characters if (!containsAsianText(text)) { return 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 { return false; } } async ensureComponentLoaded() { if (this.componentLoaded) return; if (typeof customElements !== 'undefined') { await customElements.whenDefined('am-lyrics'); this.componentLoaded = true; } } 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; } getLRC(lyricsData, track) { const lrcContent = this.generateLRCContent(lyricsData, track); if (!lrcContent) { alert('No synced lyrics available for this track'); return; } return new File([lrcContent], buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc'), { type: 'application/octet-stream', }); } downloadLRC(lyricsData, track) { const blob = this.getLRC(lyricsData, track); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = blob.name; 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 async 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) => { if (mutation.type === 'childList') { let relevant = false; if (mutation.addedNodes.length > 0) { for (const node of mutation.addedNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('genius-indicator')) continue; relevant = true; break; } } if (!relevant && mutation.removedNodes.length > 0) { for (const node of mutation.removedNodes) { if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('genius-indicator')) continue; relevant = true; break; } } return relevant; } if (mutation.type === 'characterData') return true; return false; }); if (!hasRelevantChange) { return; } // Debounce mutations if (this.observerTimeout) { clearTimeout(this.observerTimeout); } this.observerTimeout = setTimeout(async () => { if (amLyricsElement.getAttribute('lang') !== 'ja') { const text = (amLyricsElement.shadowRoot || amLyricsElement).textContent || ''; if (containsJapaneseKana(text)) amLyricsElement.setAttribute('lang', 'ja'); } if (this.isRomajiMode) { await this.convertLyricsContent(amLyricsElement); } if (this.isGeniusMode && this.currentGeniusData) { await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents); } }, 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) { await this.convertLyricsContent(amLyricsElement); } if (this.isGeniusMode && this.currentGeniusData) { await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents); } } // 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 await 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; } async applyGeniusAnnotations(amLyricsElement, referents) { if (!amLyricsElement || !referents) return; const root = amLyricsElement.shadowRoot || amLyricsElement; const lineElements = Array.from(root.querySelectorAll('p, .line, .lyric-line, .lrc-line')); if (lineElements.length === 0) return; lineElements.forEach((el) => { el.classList.remove('genius-annotated', 'genius-multi-start', 'genius-multi-end', 'genius-multi-mid'); delete el.__geniusAnnotations; }); const normalize = (str) => str .toLowerCase() .replace(/[^\p{L}\p{N}\s]/gu, '') .replace(/\s+/g, ' ') .trim(); referents.forEach((ref) => { const fragment = normalize(ref.fragment); if (!fragment) return; for (let i = 0; i < lineElements.length; i++) { let combinedText = ''; let currentLines = []; for (let j = i; j < lineElements.length; j++) { const line = lineElements[j]; const lineClone = line.cloneNode(true); lineClone .querySelectorAll('.time, .timestamp, [class*="time"], .genius-indicator') .forEach((n) => n.remove()); const text = normalize(lineClone.textContent || ''); if (!text) continue; if (currentLines.length > 0) combinedText += ' '; combinedText += text; currentLines.push(line); if (combinedText.includes(fragment)) { currentLines.forEach((el, idx) => { el.classList.add('genius-annotated'); if (!el.__geniusAnnotations) el.__geniusAnnotations = []; if (!el.__geniusAnnotations.some((a) => a.id === ref.id)) { el.__geniusAnnotations.push(ref); } if (currentLines.length > 1) { if (idx === 0) el.classList.add('genius-multi-start'); else if (idx === currentLines.length - 1) el.classList.add('genius-multi-end'); else el.classList.add('genius-multi-mid'); } if (!el.querySelector('.genius-indicator')) { const smiley = document.createElement('span'); smiley.className = 'genius-indicator'; smiley.textContent = ' ☺'; smiley.style.color = '#ffff64'; smiley.style.marginLeft = '0.5em'; el.appendChild(smiley); } }); break; } if (combinedText.length > fragment.length + 50) break; } } }); } } export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = false) { const manager = lyricsManager || new LyricsManager(); // Load Kuroshiro in background only if track has Asian text and Romaji mode is enabled const isRomajiMode = manager.getRomajiMode(); if (isRomajiMode && trackHasAsianText(track) && !manager.kuroshiroLoaded && !manager.kuroshiroLoading) { manager.loadKuroshiro().catch((err) => { console.warn('Failed to load Kuroshiro for Romaji conversion:', err); }); } // Load saved timing offset for this track manager.timingOffset = manager.getTimingOffset(track.id); const renderControls = (container) => { const isRomajiMode = manager.getRomajiMode(); manager.isRomajiMode = isRomajiMode; const isGeniusMode = manager.isGeniusMode; const offsetDisplay = manager.getOffsetDisplayString(manager.timingOffset); container.innerHTML = `