From daac9d9e60fccaa19484ce9a825aa4f5a81246b0 Mon Sep 17 00:00:00 2001 From: Aji Priyo Wibowo Date: Thu, 8 Jan 2026 16:31:39 +0700 Subject: [PATCH] feat: fix performance on convert to romaji --- js/app.js | 5 ++ js/lyrics.js | 136 ++++++++++++++++++++++++---------------------- package-lock.json | 56 ++++++++++++++++++- package.json | 4 ++ 4 files changed, 135 insertions(+), 66 deletions(-) diff --git a/js/app.js b/js/app.js index 69ae0da..b2e2411 100644 --- a/js/app.js +++ b/js/app.js @@ -189,6 +189,11 @@ document.addEventListener('DOMContentLoaded', async () => { const scrobbler = new LastFMScrobbler(); const lyricsManager = new LyricsManager(api); + // Pre-load Kuroshiro for romaji conversion in background (always load so it's ready instantly) + lyricsManager.loadKuroshiro().catch(err => { + console.warn('Failed to pre-load Kuroshiro:', err); + }); + const currentTheme = themeManager.getTheme(); themeManager.setTheme(currentTheme); trackListSettings.getMode(); diff --git a/js/lyrics.js b/js/lyrics.js index 0a30a45..d9d8b6e 100644 --- a/js/lyrics.js +++ b/js/lyrics.js @@ -8,6 +8,9 @@ import { } from "./utils.js"; import { sidePanelManager } from "./side-panel.js"; +// Dictionary path for kuromoji (loaded from CDN) +const KUROMOJI_DICT_PATH = "https://unpkg.com/kuromoji@0.1.2/dict/"; + export class LyricsManager { constructor(api) { this.api = api; @@ -24,10 +27,11 @@ export class LyricsManager { this.originalLyricsData = null; this.kuroshiroLoaded = false; this.kuroshiroLoading = false; - this.convertedNodes = new WeakSet(); // Track already converted nodes + this.romajiTextCache = new Map(); // Cache: originalText -> convertedRomaji + this.convertedTracksCache = new Set(); // Track IDs that have been fully converted } - // Load Kuroshiro from CDN + // 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) { @@ -53,7 +57,7 @@ export class LyricsManager { ); } - // Load Kuromoji analyzer from CDN with proper dictionary path + // 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", @@ -67,10 +71,10 @@ export class LyricsManager { this.kuroshiro = new Kuroshiro(); - // Initialize with custom dictionary path from unpkg + // Initialize with dictionary path from CDN await this.kuroshiro.init( new KuromojiAnalyzer({ - dictPath: "https://unpkg.com/kuromoji@0.1.2/dict/", + dictPath: KUROMOJI_DICT_PATH, }), ); @@ -89,6 +93,11 @@ export class LyricsManager { // 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; @@ -104,10 +113,15 @@ export class LyricsManager { return /[\u3040-\u30FF\u31F0-\u9FFF]/.test(text); } - // Convert Japanese text to Romaji (including Kanji) + // 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(); @@ -129,6 +143,8 @@ export class LyricsManager { mode: "spaced", romajiSystem: "hepburn", }); + // Cache the result + this.romajiTextCache.set(text, result); return result; } catch (error) { console.warn( @@ -327,14 +343,21 @@ export class LyricsManager { const observeRoot = amLyricsElement.shadowRoot || amLyricsElement; this.romajiObserver = new MutationObserver((mutations) => { - // Only process if new text content was added (ignore attribute changes like highlight) - const hasNewContent = mutations.some( - (mutation) => - mutation.type === "childList" && mutation.addedNodes.length > 0, - ); + // 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 (!hasNewContent) { - // Ignore highlight changes and other attribute mutations + if (!hasRelevantChange) { return; } @@ -348,27 +371,17 @@ export class LyricsManager { }); // Observe all child nodes for changes (in shadow DOM if it exists) - // Only watch for new nodes, not attribute changes (to avoid highlight spam) + // Watch for new nodes AND text content changes to catch when lyrics refresh this.romajiObserver.observe(observeRoot, { childList: true, subtree: true, - characterData: false, // Don't watch text changes, only new nodes + 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 + // Initial conversion if Romaji mode is enabled - single attempt, no periodic polling if (this.isRomajiMode) { - // Try immediately and after delays to catch lyrics when they load this.convertLyricsContent(amLyricsElement); - setTimeout(async () => { - await this.convertLyricsContent(amLyricsElement); - }, 500); - setTimeout(async () => { - await this.convertLyricsContent(amLyricsElement); - }, 1500); - setTimeout(async () => { - await this.convertLyricsContent(amLyricsElement); - }, 3000); } } @@ -405,14 +418,7 @@ export class LyricsManager { } // Convert Japanese text to Romaji (using async/await for Kuroshiro) - let convertedCount = 0; - for (const textNode of textNodes) { - // Skip if already converted - if (this.convertedNodes.has(textNode)) { - continue; - } - if (!textNode.parentElement) { continue; } @@ -443,18 +449,21 @@ export class LyricsManager { continue; } - // Check if contains Japanese + // 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 as converted - this.convertedNodes.add(textNode); - convertedCount++; } } } + + // Mark this track as converted + if (this.currentTrackId) { + this.convertedTracksCache.add(this.currentTrackId); + } } // Stop the observer @@ -467,8 +476,6 @@ export class LyricsManager { clearTimeout(this.observerTimeout); this.observerTimeout = null; } - // Clear converted nodes tracking when stopping - this.convertedNodes = new WeakSet(); } // Toggle Romaji mode @@ -480,12 +487,10 @@ export class LyricsManager { if (this.isRomajiMode) { // Turning ON: Setup observer and convert immediately this.setupLyricsObserver(amLyricsElement); - // Also try immediate conversion (don't wait for timeout) await this.convertLyricsContent(amLyricsElement); } else { - // Turning OFF: Stop observer (original lyrics should remain) + // Turning OFF: Stop observer // Note: To restore original Japanese, we'd need to reload the component - // For now, the converted text stays until lyrics are reloaded this.stopLyricsObserver(); } } @@ -602,49 +607,52 @@ async function renderLyricsComponent( container.appendChild(amLyrics); - // Wait for lyrics to load in the component, then setup observer + // 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 hasLyrics = - amLyrics.querySelector(".lyric-line, [class*='lyric']") || - (amLyrics.textContent && amLyrics.textContent.length > 50); + 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 (hasLyrics) { + if (checkForLyrics()) { resolve(); return; } - // Wait up to 10 seconds for lyrics to load + // Check more frequently (200ms) for faster response let attempts = 0; - const maxAttempts = 20; + const maxAttempts = 25; // 5 seconds max const interval = setInterval(() => { attempts++; - const hasContent = - amLyrics.querySelector(".lyric-line, [class*='lyric']") || - (amLyrics.textContent && amLyrics.textContent.length > 50); - if (hasContent || attempts >= maxAttempts) { + if (checkForLyrics() || attempts >= maxAttempts) { clearInterval(interval); resolve(); } - }, 500); + }, 200); }); }; await waitForLyrics(); - // Setup observer to convert lyrics to Romaji - lyricsManager.setupLyricsObserver(amLyrics); - - // If Romaji mode is already enabled, convert after shadow DOM is ready + // Convert immediately after lyrics detected if (lyricsManager.isRomajiMode) { - // Ensure Kuroshiro is loaded before converting - if (!lyricsManager.kuroshiroLoaded) { - await lyricsManager.loadKuroshiro(); - } - // Add small delay to ensure shadow DOM is fully populated - await new Promise((resolve) => setTimeout(resolve, 200)); await lyricsManager.convertLyricsContent(amLyrics); + // One retry after 500ms in case more lyrics load + setTimeout(() => lyricsManager.convertLyricsContent(amLyrics), 500); } const cleanup = setupSync(track, audioPlayer, amLyrics); diff --git a/package-lock.json b/package-lock.json index 1d4e47b..9285d36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,10 @@ "name": "monochrome", "version": "1.0.0", "license": "ISC", + "dependencies": { + "kuroshiro": "^1.2.0", + "kuroshiro-analyzer-kuromoji": "^1.1.0" + }, "devDependencies": { "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0", @@ -1502,7 +1506,6 @@ "version": "7.28.4", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3044,6 +3047,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/doublearray": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/doublearray/-/doublearray-0.0.2.tgz", + "integrity": "sha512-aw55FtZzT6AmiamEj2kvmR6BuFqvYgKZUkfQ7teqVRNqD5UE0rw8IeW/3gieHNKQ5sPuDKlljWEn4bzv5+1bHw==" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -4273,6 +4281,43 @@ "node": ">=0.10.0" } }, + "node_modules/kuromoji": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/kuromoji/-/kuromoji-0.1.2.tgz", + "integrity": "sha512-V0dUf+C2LpcPEXhoHLMAop/bOht16Dyr+mDiIE39yX3vqau7p80De/koFqpiTcL1zzdZlc3xuHZ8u5gjYRfFaQ==", + "dependencies": { + "async": "^2.0.1", + "doublearray": "0.0.2", + "zlibjs": "^0.3.1" + } + }, + "node_modules/kuromoji/node_modules/async": { + "version": "2.6.4", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.4.tgz", + "integrity": "sha512-mzo5dfJYwAn29PeiJ0zvwTo04zj8HDJj0Mn8TD7sno7q12prdbnasKJHhkm2c1LgrhlJ0teaea8860oxi51mGA==", + "dependencies": { + "lodash": "^4.17.14" + } + }, + "node_modules/kuroshiro": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/kuroshiro/-/kuroshiro-1.2.0.tgz", + "integrity": "sha512-yBGCK9oDOY3LGZ/KXaN9m7ADcAuSczOR2FoMRYwHLUlis3/o/uxdMVROAjENFO0NQJgALhIdWxI/vIBVrMCk9w==", + "dependencies": { + "@babel/runtime": "^7.14.0" + }, + "engines": { + "node": ">=6.5.0" + } + }, + "node_modules/kuroshiro-analyzer-kuromoji": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kuroshiro-analyzer-kuromoji/-/kuroshiro-analyzer-kuromoji-1.1.0.tgz", + "integrity": "sha512-BSJFhpsQdPwfFLfjKxfLA9iL+/PC6LCR9vgwgb5Jc7jZwk9ilX8SAV6CwhAQZY611tiuhbB52ONYKDO8hgY1bA==", + "dependencies": { + "kuromoji": "^0.1.1" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -4287,7 +4332,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.debounce": { @@ -6306,6 +6350,14 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "engines": { + "node": "*" + } } } } diff --git a/package.json b/package.json index f01543f..b2120f9 100644 --- a/package.json +++ b/package.json @@ -25,5 +25,9 @@ "vite": "^7.3.0", "vite-plugin-pwa": "^1.2.0", "wanakana": "^5.3.1" + }, + "dependencies": { + "kuroshiro": "^1.2.0", + "kuroshiro-analyzer-kuromoji": "^1.1.0" } }