feat: fix performance on convert to romaji

This commit is contained in:
Aji Priyo Wibowo 2026-01-08 16:31:39 +07:00
parent df2b77eb7d
commit daac9d9e60
4 changed files with 135 additions and 66 deletions

View file

@ -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();

View file

@ -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);

56
package-lock.json generated
View file

@ -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": "*"
}
}
}
}

View file

@ -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"
}
}