Feat: adding romaji convert feature on lyric

This commit is contained in:
Aji Priyo Wibowo 2026-01-08 15:49:54 +07:00
parent b3437dc99a
commit df2b77eb7d
6 changed files with 1209 additions and 668 deletions

View file

@ -609,6 +609,16 @@
<span class="slider"></span> <span class="slider"></span>
</label> </label>
</div> </div>
<div class="setting-item">
<div class="info">
<span class="label">Romaji Lyrics</span>
<span class="description">Convert Japanese lyrics to Romaji (Latin characters)</span>
</div>
<label class="toggle-switch">
<input type="checkbox" id="romaji-lyrics-toggle">
<span class="slider"></span>
</label>
</div>
<div class="setting-item"> <div class="setting-item">
<div class="info"> <div class="info">
<span class="label">Filename Template</span> <span class="label">Filename Template</span>

View file

@ -1,6 +1,12 @@
//js/lyrics.js //js/lyrics.js
import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_DOWNLOAD, SVG_CLOSE } from './utils.js'; import {
import { sidePanelManager } from './side-panel.js'; getTrackTitle,
getTrackArtists,
buildTrackFilename,
SVG_DOWNLOAD,
SVG_CLOSE,
} from "./utils.js";
import { sidePanelManager } from "./side-panel.js";
export class LyricsManager { export class LyricsManager {
constructor(api) { constructor(api) {
@ -11,24 +17,169 @@ export class LyricsManager {
this.componentLoaded = false; this.componentLoaded = false;
this.amLyricsElement = null; this.amLyricsElement = null;
this.animationFrameId = 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.convertedNodes = new WeakSet(); // Track already converted nodes
}
// Load Kuroshiro from CDN
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 {
console.log("Loading Kuroshiro for Kanji to Romaji conversion...");
// 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 with proper dictionary path
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 custom dictionary path from unpkg
await this.kuroshiro.init(
new KuromojiAnalyzer({
dictPath: "https://unpkg.com/kuromoji@0.1.2/dict/",
}),
);
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) => {
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)
async convertToRomaji(text) {
if (!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",
});
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() { async ensureComponentLoaded() {
if (this.componentLoaded) return; if (this.componentLoaded) return;
if (typeof customElements !== 'undefined' && customElements.get('am-lyrics')) { if (
typeof customElements !== "undefined" &&
customElements.get("am-lyrics")
) {
this.componentLoaded = true; this.componentLoaded = true;
return; return;
} }
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const script = document.createElement('script'); const script = document.createElement("script");
script.type = 'module'; script.type = "module";
script.src = 'https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js'; script.src =
"https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js";
script.onload = () => { script.onload = () => {
if (typeof customElements !== 'undefined') { if (typeof customElements !== "undefined") {
customElements.whenDefined('am-lyrics') customElements
.whenDefined("am-lyrics")
.then(() => { .then(() => {
this.componentLoaded = true; this.componentLoaded = true;
resolve(); resolve();
@ -39,13 +190,13 @@ export class LyricsManager {
} }
}; };
script.onerror = () => reject(new Error('Failed to load lyrics component')); script.onerror = () =>
reject(new Error("Failed to load lyrics component"));
document.head.appendChild(script); document.head.appendChild(script);
}); });
} }
async fetchLyrics(trackId, track = null) { async fetchLyrics(trackId, track = null) {
// LRCLIB
if (track) { if (track) {
if (this.lyricsCache.has(trackId)) { if (this.lyricsCache.has(trackId)) {
return this.lyricsCache.get(trackId); return this.lyricsCache.get(trackId);
@ -53,26 +204,28 @@ export class LyricsManager {
try { try {
const artist = Array.isArray(track.artists) const artist = Array.isArray(track.artists)
? track.artists.map(a => a.name || a).join(', ') ? track.artists.map((a) => a.name || a).join(", ")
: track.artist?.name || ''; : track.artist?.name || "";
const title = track.title || ''; const title = track.title || "";
const album = track.album?.title || ''; const album = track.album?.title || "";
const duration = track.duration ? Math.round(track.duration) : null; const duration = track.duration ? Math.round(track.duration) : null;
if (!title || !artist) { if (!title || !artist) {
console.warn('Missing required fields for LRCLIB'); console.warn("Missing required fields for LRCLIB");
return null; return null;
} }
const params = new URLSearchParams({ const params = new URLSearchParams({
track_name: title, track_name: title,
artist_name: artist artist_name: artist,
}); });
if (album) params.append('album_name', album); if (album) params.append("album_name", album);
if (duration) params.append('duration', duration.toString()); if (duration) params.append("duration", duration.toString());
const response = await fetch(`https://lrclib.net/api/get?${params.toString()}`); const response = await fetch(
`https://lrclib.net/api/get?${params.toString()}`,
);
if (response.ok) { if (response.ok) {
const data = await response.json(); const data = await response.json();
@ -80,7 +233,7 @@ export class LyricsManager {
if (data.syncedLyrics) { if (data.syncedLyrics) {
const lyricsData = { const lyricsData = {
subtitles: data.syncedLyrics, subtitles: data.syncedLyrics,
lyricsProvider: 'LRCLIB' lyricsProvider: "LRCLIB",
}; };
this.lyricsCache.set(trackId, lyricsData); this.lyricsCache.set(trackId, lyricsData);
@ -88,7 +241,7 @@ export class LyricsManager {
} }
} }
} catch (error) { } catch (error) {
console.warn('LRCLIB fetch failed:', error); console.warn("LRCLIB fetch failed:", error);
} }
} }
@ -97,16 +250,21 @@ export class LyricsManager {
parseSyncedLyrics(subtitles) { parseSyncedLyrics(subtitles) {
if (!subtitles) return []; if (!subtitles) return [];
const lines = subtitles.split('\n').filter(line => line.trim()); const lines = subtitles.split("\n").filter((line) => line.trim());
return lines.map(line => { return lines
.map((line) => {
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/); const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
if (match) { if (match) {
const [, minutes, seconds, centiseconds, text] = match; const [, minutes, seconds, centiseconds, text] = match;
const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100; const timeInSeconds =
parseInt(minutes) * 60 +
parseInt(seconds) +
parseInt(centiseconds) / 100;
return { time: timeInSeconds, text: text.trim() }; return { time: timeInSeconds, text: text.trim() };
} }
return null; return null;
}).filter(Boolean); })
.filter(Boolean);
} }
generateLRCContent(lyricsData, track) { generateLRCContent(lyricsData, track) {
@ -117,9 +275,9 @@ export class LyricsManager {
let lrc = `[ti:${trackTitle}]\n`; let lrc = `[ti:${trackTitle}]\n`;
lrc += `[ar:${trackArtist}]\n`; lrc += `[ar:${trackArtist}]\n`;
lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`; lrc += `[al:${track.album?.title || "Unknown Album"}]\n`;
lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`; lrc += `[by:${lyricsData.lyricsProvider || "Unknown"}]\n`;
lrc += '\n'; lrc += "\n";
lrc += lyricsData.subtitles; lrc += lyricsData.subtitles;
return lrc; return lrc;
@ -128,15 +286,18 @@ export class LyricsManager {
downloadLRC(lyricsData, track) { downloadLRC(lyricsData, track) {
const lrcContent = this.generateLRCContent(lyricsData, track); const lrcContent = this.generateLRCContent(lyricsData, track);
if (!lrcContent) { if (!lrcContent) {
alert('No synced lyrics available for this track'); alert("No synced lyrics available for this track");
return; return;
} }
const blob = new Blob([lrcContent], { type: 'application/octet-stream' }); const blob = new Blob([lrcContent], { type: "application/octet-stream" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc'); a.download = buildTrackFilename(track, "LOSSLESS").replace(
/\.flac$/,
".lrc",
);
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
@ -155,74 +316,348 @@ export class LyricsManager {
} }
return currentIndex; 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) => {
// 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,
);
if (!hasNewContent) {
// Ignore highlight changes and other attribute mutations
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)
// Only watch for new nodes, not attribute changes (to avoid highlight spam)
this.romajiObserver.observe(observeRoot, {
childList: true,
subtree: true,
characterData: false, // Don't watch text changes, only new nodes
attributes: false, // Don't watch attribute changes (highlight, etc)
});
// Initial conversion if Romaji mode is enabled
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);
}
}
// 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)
let convertedCount = 0;
for (const textNode of textNodes) {
// Skip if already converted
if (this.convertedNodes.has(textNode)) {
continue;
}
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
if (this.containsJapanese(originalText)) {
const romajiText = await this.convertToRomaji(originalText);
if (romajiText && romajiText !== originalText) {
textNode.textContent = romajiText;
// Mark as converted
this.convertedNodes.add(textNode);
convertedCount++;
}
}
}
}
// Stop the observer
stopLyricsObserver() {
if (this.romajiObserver) {
this.romajiObserver.disconnect();
this.romajiObserver = null;
}
if (this.observerTimeout) {
clearTimeout(this.observerTimeout);
this.observerTimeout = null;
}
// Clear converted nodes tracking when stopping
this.convertedNodes = new WeakSet();
}
// 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);
// Also try immediate conversion (don't wait for timeout)
await this.convertLyricsContent(amLyricsElement);
} else {
// Turning OFF: Stop observer (original lyrics should remain)
// Note: To restore original Japanese, we'd need to reload the component
// For now, the converted text stays until lyrics are reloaded
this.stopLyricsObserver();
}
}
return this.isRomajiMode;
}
} }
export async function openLyricsPanel(track, audioPlayer, lyricsManager) { export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
// If no manager provided, create a temp one
const manager = lyricsManager || new 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 renderControls = (container) => {
const isRomajiMode = manager.getRomajiMode();
manager.isRomajiMode = isRomajiMode;
container.innerHTML = ` container.innerHTML = `
<button id="close-side-panel-btn" class="btn-icon" title="Close"> <button id="close-side-panel-btn" class="btn-icon" title="Close">
${SVG_CLOSE} ${SVG_CLOSE}
</button> </button>
<button id="romaji-toggle-btn" class="btn-icon" title="Toggle Romaji (Japanese to Latin)" data-enabled="${isRomajiMode}" style="color: ${isRomajiMode ? "var(--primary)" : ""}">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="10"></circle>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"></path>
</svg>
</button>
`; `;
container.querySelector('#close-side-panel-btn').addEventListener('click', () => { container
.querySelector("#close-side-panel-btn")
.addEventListener("click", () => {
sidePanelManager.close(); sidePanelManager.close();
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); 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) => { const renderContent = async (container) => {
// Clean up any previous sync (though sidePanelManager might handle cleanup, we ensure it here)
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel); clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
await renderLyricsComponent(container, track, audioPlayer, manager); await renderLyricsComponent(container, track, audioPlayer, manager);
}; };
sidePanelManager.open('lyrics', 'Lyrics', renderControls, renderContent); sidePanelManager.open("lyrics", "Lyrics", renderControls, renderContent);
} }
async function renderLyricsComponent(container, track, audioPlayer, lyricsManager) { async function renderLyricsComponent(
container,
track,
audioPlayer,
lyricsManager,
) {
container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>'; container.innerHTML = '<div class="lyrics-loading">Loading lyrics...</div>';
try { try {
await lyricsManager.ensureComponentLoaded(); await lyricsManager.ensureComponentLoaded();
// Set initial Romaji mode
lyricsManager.isRomajiMode = lyricsManager.getRomajiMode();
lyricsManager.currentTrackId = track.id;
const title = track.title; const title = track.title;
const artist = getTrackArtists(track); const artist = getTrackArtists(track);
const album = track.album?.title; const album = track.album?.title;
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined; const durationMs = track.duration
const isrc = track.isrc || ''; ? Math.round(track.duration * 1000)
: undefined;
const isrc = track.isrc || "";
container.innerHTML = ''; container.innerHTML = "";
const amLyrics = document.createElement('am-lyrics'); const amLyrics = document.createElement("am-lyrics");
amLyrics.setAttribute('song-title', title); amLyrics.setAttribute("song-title", title);
amLyrics.setAttribute('song-artist', artist); amLyrics.setAttribute("song-artist", artist);
if (album) amLyrics.setAttribute('song-album', album); if (album) amLyrics.setAttribute("song-album", album);
if (durationMs) amLyrics.setAttribute('song-duration', durationMs); if (durationMs) amLyrics.setAttribute("song-duration", durationMs);
amLyrics.setAttribute('query', `${title} ${artist}`.trim()); amLyrics.setAttribute("query", `${title} ${artist}`.trim());
if (isrc) amLyrics.setAttribute('isrc', isrc); if (isrc) amLyrics.setAttribute("isrc", isrc);
amLyrics.setAttribute('highlight-color', '#93c5fd'); amLyrics.setAttribute("highlight-color", "#93c5fd");
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)'); amLyrics.setAttribute("hover-background-color", "rgba(59, 130, 246, 0.14)");
amLyrics.setAttribute('autoscroll', ''); amLyrics.setAttribute("autoscroll", "");
amLyrics.setAttribute('interpolate', ''); amLyrics.setAttribute("interpolate", "");
amLyrics.style.height = '100%'; amLyrics.style.height = "100%";
amLyrics.style.width = '100%'; amLyrics.style.width = "100%";
container.appendChild(amLyrics); container.appendChild(amLyrics);
// Wait for lyrics to load in the component, then setup observer
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);
if (hasLyrics) {
resolve();
return;
}
// Wait up to 10 seconds for lyrics to load
let attempts = 0;
const maxAttempts = 20;
const interval = setInterval(() => {
attempts++;
const hasContent =
amLyrics.querySelector(".lyric-line, [class*='lyric']") ||
(amLyrics.textContent && amLyrics.textContent.length > 50);
if (hasContent || attempts >= maxAttempts) {
clearInterval(interval);
resolve();
}
}, 500);
});
};
await waitForLyrics();
// Setup observer to convert lyrics to Romaji
lyricsManager.setupLyricsObserver(amLyrics);
// If Romaji mode is already enabled, convert after shadow DOM is ready
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);
}
const cleanup = setupSync(track, audioPlayer, amLyrics); const cleanup = setupSync(track, audioPlayer, amLyrics);
// Attach cleanup to container for easy access // Attach cleanup to container for easy access
container.lyricsCleanup = cleanup; container.lyricsCleanup = cleanup;
container.lyricsManager = lyricsManager;
return amLyrics; return amLyrics;
} catch (error) { } catch (error) {
console.error('Failed to load lyrics:', error); console.error("Failed to load lyrics:", error);
container.innerHTML = '<div class="lyrics-error">Failed to load lyrics</div>'; container.innerHTML =
'<div class="lyrics-error">Failed to load lyrics</div>';
return null; return null;
} }
} }
@ -269,11 +704,11 @@ function setupSync(track, audioPlayer, amLyrics) {
} }
}; };
audioPlayer.addEventListener('timeupdate', updateTime); audioPlayer.addEventListener("timeupdate", updateTime);
audioPlayer.addEventListener('play', onPlay); audioPlayer.addEventListener("play", onPlay);
audioPlayer.addEventListener('pause', onPause); audioPlayer.addEventListener("pause", onPause);
audioPlayer.addEventListener('seeked', updateTime); audioPlayer.addEventListener("seeked", updateTime);
amLyrics.addEventListener('line-click', onLineClick); amLyrics.addEventListener("line-click", onLineClick);
if (!audioPlayer.paused) { if (!audioPlayer.paused) {
tick(); tick();
@ -283,15 +718,20 @@ function setupSync(track, audioPlayer, amLyrics) {
if (animationFrameId) { if (animationFrameId) {
cancelAnimationFrame(animationFrameId); cancelAnimationFrame(animationFrameId);
} }
audioPlayer.removeEventListener('timeupdate', updateTime); audioPlayer.removeEventListener("timeupdate", updateTime);
audioPlayer.removeEventListener('play', onPlay); audioPlayer.removeEventListener("play", onPlay);
audioPlayer.removeEventListener('pause', onPause); audioPlayer.removeEventListener("pause", onPause);
audioPlayer.removeEventListener('seeked', updateTime); audioPlayer.removeEventListener("seeked", updateTime);
amLyrics.removeEventListener('line-click', onLineClick); amLyrics.removeEventListener("line-click", onLineClick);
}; };
} }
export async function renderLyricsInFullscreen(track, audioPlayer, lyricsManager, container) { export async function renderLyricsInFullscreen(
track,
audioPlayer,
lyricsManager,
container,
) {
return renderLyricsComponent(container, track, audioPlayer, lyricsManager); return renderLyricsComponent(container, track, audioPlayer, lyricsManager);
} }
@ -300,6 +740,9 @@ export function clearFullscreenLyricsSync(container) {
container.lyricsCleanup(); container.lyricsCleanup();
container.lyricsCleanup = null; container.lyricsCleanup = null;
} }
if (container && container.lyricsManager) {
container.lyricsManager.stopLyricsObserver();
}
} }
export function clearLyricsPanelSync(audioPlayer, panel) { export function clearLyricsPanelSync(audioPlayer, panel) {
@ -307,4 +750,7 @@ export function clearLyricsPanelSync(audioPlayer, panel) {
panel.lyricsCleanup(); panel.lyricsCleanup();
panel.lyricsCleanup = null; panel.lyricsCleanup = null;
} }
if (panel && panel.lyricsManager) {
panel.lyricsManager.stopLyricsObserver();
}
} }

View file

@ -1,9 +1,17 @@
//js/settings //js/settings
import { themeManager, lastFMStorage, nowPlayingSettings, lyricsSettings, backgroundSettings, trackListSettings, cardSettings } from './storage.js'; import {
import { db } from './db.js'; themeManager,
import { authManager } from './firebase/auth.js'; lastFMStorage,
import { syncManager } from './firebase/sync.js'; nowPlayingSettings,
import { initializeFirebaseSettingsUI } from './firebase/config.js'; lyricsSettings,
backgroundSettings,
trackListSettings,
cardSettings,
} from "./storage.js";
import { db } from "./db.js";
import { authManager } from "./firebase/auth.js";
import { syncManager } from "./firebase/sync.js";
import { initializeFirebaseSettingsUI } from "./firebase/config.js";
export function initializeSettings(scrobbler, player, api, ui) { export function initializeSettings(scrobbler, player, api, ui) {
// Initialize Firebase UI & Settings // Initialize Firebase UI & Settings
@ -11,43 +19,45 @@ export function initializeSettings(scrobbler, player, api, ui) {
initializeFirebaseSettingsUI(); initializeFirebaseSettingsUI();
// Email Auth UI Logic // Email Auth UI Logic
const toggleEmailBtn = document.getElementById('toggle-email-auth-btn'); const toggleEmailBtn = document.getElementById("toggle-email-auth-btn");
const cancelEmailBtn = document.getElementById('cancel-email-auth-btn'); const cancelEmailBtn = document.getElementById("cancel-email-auth-btn");
const authContainer = document.getElementById('email-auth-container'); const authContainer = document.getElementById("email-auth-container");
const authButtonsContainer = document.getElementById('auth-buttons-container'); const authButtonsContainer = document.getElementById(
const emailInput = document.getElementById('auth-email'); "auth-buttons-container",
const passwordInput = document.getElementById('auth-password'); );
const signInBtn = document.getElementById('email-signin-btn'); const emailInput = document.getElementById("auth-email");
const signUpBtn = document.getElementById('email-signup-btn'); const passwordInput = document.getElementById("auth-password");
const signInBtn = document.getElementById("email-signin-btn");
const signUpBtn = document.getElementById("email-signup-btn");
if (toggleEmailBtn && authContainer && authButtonsContainer) { if (toggleEmailBtn && authContainer && authButtonsContainer) {
toggleEmailBtn.addEventListener('click', () => { toggleEmailBtn.addEventListener("click", () => {
authContainer.style.display = 'flex'; authContainer.style.display = "flex";
authButtonsContainer.style.display = 'none'; authButtonsContainer.style.display = "none";
}); });
} }
if (cancelEmailBtn && authContainer && authButtonsContainer) { if (cancelEmailBtn && authContainer && authButtonsContainer) {
cancelEmailBtn.addEventListener('click', () => { cancelEmailBtn.addEventListener("click", () => {
authContainer.style.display = 'none'; authContainer.style.display = "none";
authButtonsContainer.style.display = 'flex'; authButtonsContainer.style.display = "flex";
}); });
} }
if (signInBtn) { if (signInBtn) {
signInBtn.addEventListener('click', async () => { signInBtn.addEventListener("click", async () => {
const email = emailInput.value; const email = emailInput.value;
const password = passwordInput.value; const password = passwordInput.value;
if (!email || !password) { if (!email || !password) {
alert('Please enter both email and password.'); alert("Please enter both email and password.");
return; return;
} }
try { try {
await authManager.signInWithEmail(email, password); await authManager.signInWithEmail(email, password);
authContainer.style.display = 'none'; authContainer.style.display = "none";
authButtonsContainer.style.display = 'flex'; authButtonsContainer.style.display = "flex";
emailInput.value = ''; emailInput.value = "";
passwordInput.value = ''; passwordInput.value = "";
} catch (e) { } catch (e) {
// Error handled in authManager // Error handled in authManager
} }
@ -55,64 +65,65 @@ export function initializeSettings(scrobbler, player, api, ui) {
} }
if (signUpBtn) { if (signUpBtn) {
signUpBtn.addEventListener('click', async () => { signUpBtn.addEventListener("click", async () => {
const email = emailInput.value; const email = emailInput.value;
const password = passwordInput.value; const password = passwordInput.value;
if (!email || !password) { if (!email || !password) {
alert('Please enter both email and password.'); alert("Please enter both email and password.");
return; return;
} }
try { try {
await authManager.signUpWithEmail(email, password); await authManager.signUpWithEmail(email, password);
authContainer.style.display = 'none'; authContainer.style.display = "none";
authButtonsContainer.style.display = 'flex'; authButtonsContainer.style.display = "flex";
emailInput.value = ''; emailInput.value = "";
passwordInput.value = ''; passwordInput.value = "";
} catch (e) { } catch (e) {
// Error handled in authManager // Error handled in authManager
} }
}); });
} }
const lastfmConnectBtn = document.getElementById('lastfm-connect-btn'); const lastfmConnectBtn = document.getElementById("lastfm-connect-btn");
const lastfmStatus = document.getElementById('lastfm-status'); const lastfmStatus = document.getElementById("lastfm-status");
const lastfmToggle = document.getElementById('lastfm-toggle'); const lastfmToggle = document.getElementById("lastfm-toggle");
const lastfmToggleSetting = document.getElementById('lastfm-toggle-setting'); const lastfmToggleSetting = document.getElementById("lastfm-toggle-setting");
const lastfmLoveToggle = document.getElementById('lastfm-love-toggle'); const lastfmLoveToggle = document.getElementById("lastfm-love-toggle");
const lastfmLoveSetting = document.getElementById('lastfm-love-setting'); const lastfmLoveSetting = document.getElementById("lastfm-love-setting");
function updateLastFMUI() { function updateLastFMUI() {
if (scrobbler.isAuthenticated()) { if (scrobbler.isAuthenticated()) {
lastfmStatus.textContent = `Connected as ${scrobbler.username}`; lastfmStatus.textContent = `Connected as ${scrobbler.username}`;
lastfmConnectBtn.textContent = 'Disconnect'; lastfmConnectBtn.textContent = "Disconnect";
lastfmConnectBtn.classList.add('danger'); lastfmConnectBtn.classList.add("danger");
lastfmToggleSetting.style.display = 'flex'; lastfmToggleSetting.style.display = "flex";
lastfmLoveSetting.style.display = 'flex'; lastfmLoveSetting.style.display = "flex";
lastfmToggle.checked = lastFMStorage.isEnabled(); lastfmToggle.checked = lastFMStorage.isEnabled();
lastfmLoveToggle.checked = lastFMStorage.shouldLoveOnLike(); lastfmLoveToggle.checked = lastFMStorage.shouldLoveOnLike();
} else { } else {
lastfmStatus.textContent = 'Connect your Last.fm account to scrobble tracks'; lastfmStatus.textContent =
lastfmConnectBtn.textContent = 'Connect Last.fm'; "Connect your Last.fm account to scrobble tracks";
lastfmConnectBtn.classList.remove('danger'); lastfmConnectBtn.textContent = "Connect Last.fm";
lastfmToggleSetting.style.display = 'none'; lastfmConnectBtn.classList.remove("danger");
lastfmLoveSetting.style.display = 'none'; lastfmToggleSetting.style.display = "none";
lastfmLoveSetting.style.display = "none";
} }
} }
updateLastFMUI(); updateLastFMUI();
lastfmConnectBtn?.addEventListener('click', async () => { lastfmConnectBtn?.addEventListener("click", async () => {
if (scrobbler.isAuthenticated()) { if (scrobbler.isAuthenticated()) {
if (confirm('Disconnect from Last.fm?')) { if (confirm("Disconnect from Last.fm?")) {
scrobbler.disconnect(); scrobbler.disconnect();
updateLastFMUI(); updateLastFMUI();
} }
return; return;
} }
const authWindow = window.open('', '_blank'); const authWindow = window.open("", "_blank");
lastfmConnectBtn.disabled = true; lastfmConnectBtn.disabled = true;
lastfmConnectBtn.textContent = 'Opening Last.fm...'; lastfmConnectBtn.textContent = "Opening Last.fm...";
try { try {
const { token, url } = await scrobbler.getAuthUrl(); const { token, url } = await scrobbler.getAuthUrl();
@ -120,13 +131,13 @@ export function initializeSettings(scrobbler, player, api, ui) {
if (authWindow) { if (authWindow) {
authWindow.location.href = url; authWindow.location.href = url;
} else { } else {
alert('Popup blocked! Please allow popups.'); alert("Popup blocked! Please allow popups.");
lastfmConnectBtn.textContent = 'Connect Last.fm'; lastfmConnectBtn.textContent = "Connect Last.fm";
lastfmConnectBtn.disabled = false; lastfmConnectBtn.disabled = false;
return; return;
} }
lastfmConnectBtn.textContent = 'Waiting for authorization...'; lastfmConnectBtn.textContent = "Waiting for authorization...";
let attempts = 0; let attempts = 0;
const maxAttempts = 30; const maxAttempts = 30;
@ -136,10 +147,10 @@ export function initializeSettings(scrobbler, player, api, ui) {
if (attempts > maxAttempts) { if (attempts > maxAttempts) {
clearInterval(checkAuth); clearInterval(checkAuth);
lastfmConnectBtn.textContent = 'Connect Last.fm'; lastfmConnectBtn.textContent = "Connect Last.fm";
lastfmConnectBtn.disabled = false; lastfmConnectBtn.disabled = false;
if (authWindow && !authWindow.closed) authWindow.close(); if (authWindow && !authWindow.closed) authWindow.close();
alert('Authorization timed out. Please try again.'); alert("Authorization timed out. Please try again.");
return; return;
} }
@ -159,185 +170,221 @@ export function initializeSettings(scrobbler, player, api, ui) {
// Still waiting // Still waiting
} }
}, 2000); }, 2000);
} catch (error) { } catch (error) {
console.error('Last.fm connection failed:', error); console.error("Last.fm connection failed:", error);
alert('Failed to connect to Last.fm: ' + error.message); alert("Failed to connect to Last.fm: " + error.message);
lastfmConnectBtn.textContent = 'Connect Last.fm'; lastfmConnectBtn.textContent = "Connect Last.fm";
lastfmConnectBtn.disabled = false; lastfmConnectBtn.disabled = false;
if (authWindow && !authWindow.closed) authWindow.close(); if (authWindow && !authWindow.closed) authWindow.close();
} }
}); });
lastfmToggle?.addEventListener('change', (e) => { lastfmToggle?.addEventListener("change", (e) => {
lastFMStorage.setEnabled(e.target.checked); lastFMStorage.setEnabled(e.target.checked);
}); });
lastfmLoveToggle?.addEventListener('change', (e) => { lastfmLoveToggle?.addEventListener("change", (e) => {
lastFMStorage.setLoveOnLike(e.target.checked); lastFMStorage.setLoveOnLike(e.target.checked);
}); });
// Theme picker // Theme picker
const themePicker = document.getElementById('theme-picker'); const themePicker = document.getElementById("theme-picker");
const currentTheme = themeManager.getTheme(); const currentTheme = themeManager.getTheme();
themePicker.querySelectorAll('.theme-option').forEach(option => { themePicker.querySelectorAll(".theme-option").forEach((option) => {
if (option.dataset.theme === currentTheme) { if (option.dataset.theme === currentTheme) {
option.classList.add('active'); option.classList.add("active");
} }
option.addEventListener('click', () => { option.addEventListener("click", () => {
const theme = option.dataset.theme; const theme = option.dataset.theme;
themePicker.querySelectorAll('.theme-option').forEach(opt => opt.classList.remove('active')); themePicker
option.classList.add('active'); .querySelectorAll(".theme-option")
.forEach((opt) => opt.classList.remove("active"));
option.classList.add("active");
if (theme === 'custom') { if (theme === "custom") {
document.getElementById('custom-theme-editor').classList.add('show'); document.getElementById("custom-theme-editor").classList.add("show");
renderCustomThemeEditor(); renderCustomThemeEditor();
} else { } else {
document.getElementById('custom-theme-editor').classList.remove('show'); document.getElementById("custom-theme-editor").classList.remove("show");
themeManager.setTheme(theme); themeManager.setTheme(theme);
} }
}); });
}); });
function renderCustomThemeEditor() { function renderCustomThemeEditor() {
const grid = document.getElementById('theme-color-grid'); const grid = document.getElementById("theme-color-grid");
const customTheme = themeManager.getCustomTheme() || { const customTheme = themeManager.getCustomTheme() || {
background: '#000000', background: "#000000",
foreground: '#fafafa', foreground: "#fafafa",
primary: '#ffffff', primary: "#ffffff",
secondary: '#27272a', secondary: "#27272a",
muted: '#27272a', muted: "#27272a",
border: '#27272a', border: "#27272a",
highlight: '#ffffff' highlight: "#ffffff",
}; };
grid.innerHTML = Object.entries(customTheme).map(([key, value]) => ` grid.innerHTML = Object.entries(customTheme)
.map(
([key, value]) => `
<div class="theme-color-input"> <div class="theme-color-input">
<label>${key}</label> <label>${key}</label>
<input type="color" data-color="${key}" value="${value}"> <input type="color" data-color="${key}" value="${value}">
</div> </div>
`).join(''); `,
)
.join("");
} }
document.getElementById('apply-custom-theme')?.addEventListener('click', () => { document
.getElementById("apply-custom-theme")
?.addEventListener("click", () => {
const colors = {}; const colors = {};
document.querySelectorAll('#theme-color-grid input[type="color"]').forEach(input => { document
.querySelectorAll('#theme-color-grid input[type="color"]')
.forEach((input) => {
colors[input.dataset.color] = input.value; colors[input.dataset.color] = input.value;
}); });
themeManager.setCustomTheme(colors); themeManager.setCustomTheme(colors);
}); });
document.getElementById('reset-custom-theme')?.addEventListener('click', () => { document
.getElementById("reset-custom-theme")
?.addEventListener("click", () => {
renderCustomThemeEditor(); renderCustomThemeEditor();
}); });
// Quality setting // Quality setting
const qualitySetting = document.getElementById('quality-setting'); const qualitySetting = document.getElementById("quality-setting");
if (qualitySetting) { if (qualitySetting) {
const savedQuality = localStorage.getItem('playback-quality') || 'LOSSLESS'; const savedQuality = localStorage.getItem("playback-quality") || "LOSSLESS";
qualitySetting.value = savedQuality; qualitySetting.value = savedQuality;
player.setQuality(savedQuality); player.setQuality(savedQuality);
qualitySetting.addEventListener('change', (e) => { qualitySetting.addEventListener("change", (e) => {
const newQuality = e.target.value; const newQuality = e.target.value;
player.setQuality(newQuality); player.setQuality(newQuality);
localStorage.setItem('playback-quality', newQuality); localStorage.setItem("playback-quality", newQuality);
}); });
} }
// Now Playing Mode // Now Playing Mode
const nowPlayingMode = document.getElementById('now-playing-mode'); const nowPlayingMode = document.getElementById("now-playing-mode");
if (nowPlayingMode) { if (nowPlayingMode) {
nowPlayingMode.value = nowPlayingSettings.getMode(); nowPlayingMode.value = nowPlayingSettings.getMode();
nowPlayingMode.addEventListener('change', (e) => { nowPlayingMode.addEventListener("change", (e) => {
nowPlayingSettings.setMode(e.target.value); nowPlayingSettings.setMode(e.target.value);
}); });
} }
// Track List Actions Mode // Track List Actions Mode
const trackListActionsMode = document.getElementById('track-list-actions-mode'); const trackListActionsMode = document.getElementById(
"track-list-actions-mode",
);
if (trackListActionsMode) { if (trackListActionsMode) {
trackListActionsMode.value = trackListSettings.getMode(); trackListActionsMode.value = trackListSettings.getMode();
trackListActionsMode.addEventListener('change', (e) => { trackListActionsMode.addEventListener("change", (e) => {
trackListSettings.setMode(e.target.value); trackListSettings.setMode(e.target.value);
}); });
} }
// Compact Artist Toggle // Compact Artist Toggle
const compactArtistToggle = document.getElementById('compact-artist-toggle'); const compactArtistToggle = document.getElementById("compact-artist-toggle");
if (compactArtistToggle) { if (compactArtistToggle) {
compactArtistToggle.checked = cardSettings.isCompactArtist(); compactArtistToggle.checked = cardSettings.isCompactArtist();
compactArtistToggle.addEventListener('change', (e) => { compactArtistToggle.addEventListener("change", (e) => {
cardSettings.setCompactArtist(e.target.checked); cardSettings.setCompactArtist(e.target.checked);
}); });
} }
// Compact Album Toggle // Compact Album Toggle
const compactAlbumToggle = document.getElementById('compact-album-toggle'); const compactAlbumToggle = document.getElementById("compact-album-toggle");
if (compactAlbumToggle) { if (compactAlbumToggle) {
compactAlbumToggle.checked = cardSettings.isCompactAlbum(); compactAlbumToggle.checked = cardSettings.isCompactAlbum();
compactAlbumToggle.addEventListener('change', (e) => { compactAlbumToggle.addEventListener("change", (e) => {
cardSettings.setCompactAlbum(e.target.checked); cardSettings.setCompactAlbum(e.target.checked);
}); });
} }
// Download Lyrics Toggle // Download Lyrics Toggle
const downloadLyricsToggle = document.getElementById('download-lyrics-toggle'); const downloadLyricsToggle = document.getElementById(
"download-lyrics-toggle",
);
if (downloadLyricsToggle) { if (downloadLyricsToggle) {
downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics(); downloadLyricsToggle.checked = lyricsSettings.shouldDownloadLyrics();
downloadLyricsToggle.addEventListener('change', (e) => { downloadLyricsToggle.addEventListener("change", (e) => {
lyricsSettings.setDownloadLyrics(e.target.checked); lyricsSettings.setDownloadLyrics(e.target.checked);
}); });
} }
// Romaji Lyrics Toggle
const romajiLyricsToggle = document.getElementById("romaji-lyrics-toggle");
if (romajiLyricsToggle) {
romajiLyricsToggle.checked =
localStorage.getItem("lyricsRomajiMode") === "true";
romajiLyricsToggle.addEventListener("change", (e) => {
localStorage.setItem(
"lyricsRomajiMode",
e.target.checked ? "true" : "false",
);
});
}
// Album Background Toggle // Album Background Toggle
const albumBackgroundToggle = document.getElementById('album-background-toggle'); const albumBackgroundToggle = document.getElementById(
"album-background-toggle",
);
if (albumBackgroundToggle) { if (albumBackgroundToggle) {
albumBackgroundToggle.checked = backgroundSettings.isEnabled(); albumBackgroundToggle.checked = backgroundSettings.isEnabled();
albumBackgroundToggle.addEventListener('change', (e) => { albumBackgroundToggle.addEventListener("change", (e) => {
backgroundSettings.setEnabled(e.target.checked); backgroundSettings.setEnabled(e.target.checked);
}); });
} }
// Filename template setting // Filename template setting
const filenameTemplate = document.getElementById('filename-template'); const filenameTemplate = document.getElementById("filename-template");
if (filenameTemplate) { if (filenameTemplate) {
filenameTemplate.value = localStorage.getItem('filename-template') || '{trackNumber} - {artist} - {title}'; filenameTemplate.value =
filenameTemplate.addEventListener('change', (e) => { localStorage.getItem("filename-template") ||
localStorage.setItem('filename-template', e.target.value); "{trackNumber} - {artist} - {title}";
filenameTemplate.addEventListener("change", (e) => {
localStorage.setItem("filename-template", e.target.value);
}); });
} }
// ZIP folder template // ZIP folder template
const zipFolderTemplate = document.getElementById('zip-folder-template'); const zipFolderTemplate = document.getElementById("zip-folder-template");
if (zipFolderTemplate) { if (zipFolderTemplate) {
zipFolderTemplate.value = localStorage.getItem('zip-folder-template') || '{albumTitle} - {albumArtist}'; zipFolderTemplate.value =
zipFolderTemplate.addEventListener('change', (e) => { localStorage.getItem("zip-folder-template") ||
localStorage.setItem('zip-folder-template', e.target.value); "{albumTitle} - {albumArtist}";
zipFolderTemplate.addEventListener("change", (e) => {
localStorage.setItem("zip-folder-template", e.target.value);
}); });
} }
// API settings // API settings
document.getElementById('refresh-speed-test-btn')?.addEventListener('click', async () => { document
const btn = document.getElementById('refresh-speed-test-btn'); .getElementById("refresh-speed-test-btn")
?.addEventListener("click", async () => {
const btn = document.getElementById("refresh-speed-test-btn");
const originalText = btn.textContent; const originalText = btn.textContent;
btn.textContent = 'Testing...'; btn.textContent = "Testing...";
btn.disabled = true; btn.disabled = true;
try { try {
await api.settings.refreshSpeedTests(); await api.settings.refreshSpeedTests();
ui.renderApiSettings(); ui.renderApiSettings();
btn.textContent = 'Done!'; btn.textContent = "Done!";
setTimeout(() => { setTimeout(() => {
btn.textContent = originalText; btn.textContent = originalText;
btn.disabled = false; btn.disabled = false;
}, 1500); }, 1500);
} catch (error) { } catch (error) {
console.error('Failed to refresh speed tests:', error); console.error("Failed to refresh speed tests:", error);
btn.textContent = 'Error'; btn.textContent = "Error";
setTimeout(() => { setTimeout(() => {
btn.textContent = originalText; btn.textContent = originalText;
btn.disabled = false; btn.disabled = false;
@ -345,45 +392,58 @@ export function initializeSettings(scrobbler, player, api, ui) {
} }
}); });
document.getElementById('api-instance-list')?.addEventListener('click', async (e) => { document
const button = e.target.closest('button'); .getElementById("api-instance-list")
?.addEventListener("click", async (e) => {
const button = e.target.closest("button");
if (!button) return; if (!button) return;
const li = button.closest('li'); const li = button.closest("li");
const index = parseInt(li.dataset.index, 10); const index = parseInt(li.dataset.index, 10);
const type = li.dataset.type || 'api'; // Default to api if not present const type = li.dataset.type || "api"; // Default to api if not present
const instances = await api.settings.getInstances(type); const instances = await api.settings.getInstances(type);
if (button.classList.contains('move-up') && index > 0) { if (button.classList.contains("move-up") && index > 0) {
[instances[index], instances[index - 1]] = [instances[index - 1], instances[index]]; [instances[index], instances[index - 1]] = [
} else if (button.classList.contains('move-down') && index < instances.length - 1) { instances[index - 1],
[instances[index], instances[index + 1]] = [instances[index + 1], instances[index]]; instances[index],
];
} else if (
button.classList.contains("move-down") &&
index < instances.length - 1
) {
[instances[index], instances[index + 1]] = [
instances[index + 1],
instances[index],
];
} }
api.settings.saveInstances(instances, type); api.settings.saveInstances(instances, type);
ui.renderApiSettings(); ui.renderApiSettings();
}); });
document.getElementById('clear-cache-btn')?.addEventListener('click', async () => { document
const btn = document.getElementById('clear-cache-btn'); .getElementById("clear-cache-btn")
?.addEventListener("click", async () => {
const btn = document.getElementById("clear-cache-btn");
const originalText = btn.textContent; const originalText = btn.textContent;
btn.textContent = 'Clearing...'; btn.textContent = "Clearing...";
btn.disabled = true; btn.disabled = true;
try { try {
await api.clearCache(); await api.clearCache();
btn.textContent = 'Cleared!'; btn.textContent = "Cleared!";
setTimeout(() => { setTimeout(() => {
btn.textContent = originalText; btn.textContent = originalText;
btn.disabled = false; btn.disabled = false;
if (window.location.hash.includes('settings')) { if (window.location.hash.includes("settings")) {
ui.renderApiSettings(); ui.renderApiSettings();
} }
}, 1500); }, 1500);
} catch (error) { } catch (error) {
console.error('Failed to clear cache:', error); console.error("Failed to clear cache:", error);
btn.textContent = 'Error'; btn.textContent = "Error";
setTimeout(() => { setTimeout(() => {
btn.textContent = originalText; btn.textContent = originalText;
btn.disabled = false; btn.disabled = false;
@ -391,37 +451,49 @@ export function initializeSettings(scrobbler, player, api, ui) {
} }
}); });
document.getElementById('firebase-clear-cloud-btn')?.addEventListener('click', async () => { document
if (confirm('Are you sure you want to delete ALL your data from the cloud? This cannot be undone.')) { .getElementById("firebase-clear-cloud-btn")
?.addEventListener("click", async () => {
if (
confirm(
"Are you sure you want to delete ALL your data from the cloud? This cannot be undone.",
)
) {
try { try {
await syncManager.clearCloudData(); await syncManager.clearCloudData();
alert('Cloud data cleared successfully.'); alert("Cloud data cleared successfully.");
authManager.signOut(); authManager.signOut();
} catch (error) { } catch (error) {
console.error('Failed to clear cloud data:', error); console.error("Failed to clear cloud data:", error);
alert('Failed to clear cloud data: ' + error.message); alert("Failed to clear cloud data: " + error.message);
} }
} }
}); });
// Backup & Restore // Backup & Restore
document.getElementById('export-library-btn')?.addEventListener('click', async () => { document
.getElementById("export-library-btn")
?.addEventListener("click", async () => {
const data = await db.exportData(); const data = await db.exportData();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }); const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement("a");
a.href = url; a.href = url;
a.download = `monochrome-library-${new Date().toISOString().split('T')[0]}.json`; a.download = `monochrome-library-${new Date().toISOString().split("T")[0]}.json`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}); });
const importInput = document.getElementById('import-library-input'); const importInput = document.getElementById("import-library-input");
document.getElementById('import-library-btn')?.addEventListener('click', () => { document
.getElementById("import-library-btn")
?.addEventListener("click", () => {
importInput.click(); importInput.click();
}); });
importInput?.addEventListener('change', async (e) => { importInput?.addEventListener("change", async (e) => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
@ -430,11 +502,11 @@ export function initializeSettings(scrobbler, player, api, ui) {
try { try {
const data = JSON.parse(event.target.result); const data = JSON.parse(event.target.result);
await db.importData(data); await db.importData(data);
alert('Library imported successfully!'); alert("Library imported successfully!");
window.location.reload(); // Simple way to refresh all state window.location.reload(); // Simple way to refresh all state
} catch (err) { } catch (err) {
console.error('Import failed:', err); console.error("Import failed:", err);
alert('Failed to import library. Please check the file format.'); alert("Failed to import library. Please check the file format.");
} }
}; };
reader.readAsText(file); reader.readAsText(file);

12
package-lock.json generated
View file

@ -10,7 +10,8 @@
"license": "ISC", "license": "ISC",
"devDependencies": { "devDependencies": {
"vite": "^7.3.0", "vite": "^7.3.0",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0",
"wanakana": "^5.3.1"
} }
}, },
"node_modules/@apideck/better-ajv-errors": { "node_modules/@apideck/better-ajv-errors": {
@ -5749,6 +5750,15 @@
} }
} }
}, },
"node_modules/wanakana": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/wanakana/-/wanakana-5.3.1.tgz",
"integrity": "sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==",
"dev": true,
"engines": {
"node": ">=12"
}
},
"node_modules/webidl-conversions": { "node_modules/webidl-conversions": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",

View file

@ -23,6 +23,7 @@
"homepage": "https://github.com/SamidyFR/monochrome#readme", "homepage": "https://github.com/SamidyFR/monochrome#readme",
"devDependencies": { "devDependencies": {
"vite": "^7.3.0", "vite": "^7.3.0",
"vite-plugin-pwa": "^1.2.0" "vite-plugin-pwa": "^1.2.0",
"wanakana": "^5.3.1"
} }
} }

View file

@ -1,25 +1,25 @@
import { defineConfig } from 'vite'; import { defineConfig } from "vite";
import { VitePWA } from 'vite-plugin-pwa'; import { VitePWA } from "vite-plugin-pwa";
export default defineConfig({ export default defineConfig({
base: './', base: "./",
build: { build: {
outDir: 'dist', outDir: "dist",
emptyOutDir: true, emptyOutDir: true,
}, },
plugins: [ plugins: [
VitePWA({ VitePWA({
registerType: 'prompt', registerType: "prompt",
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,json}'], globPatterns: ["**/*.{js,css,html,ico,png,svg,json}"],
cleanupOutdatedCaches: true, cleanupOutdatedCaches: true,
// Define runtime caching strategies // Define runtime caching strategies
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: ({ request }) => request.destination === 'image', urlPattern: ({ request }) => request.destination === "image",
handler: 'CacheFirst', handler: "CacheFirst",
options: { options: {
cacheName: 'images', cacheName: "images",
expiration: { expiration: {
maxEntries: 100, maxEntries: 100,
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
@ -27,21 +27,23 @@ export default defineConfig({
}, },
}, },
{ {
urlPattern: ({ request }) => request.destination === 'audio' || request.destination === 'video', urlPattern: ({ request }) =>
handler: 'CacheFirst', request.destination === "audio" ||
request.destination === "video",
handler: "CacheFirst",
options: { options: {
cacheName: 'media', cacheName: "media",
expiration: { expiration: {
maxEntries: 50, maxEntries: 50,
maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days maxAgeSeconds: 60 * 24 * 60 * 60, // 60 Days
}, },
rangeRequests: true, // Support scrubbing rangeRequests: true, // Support scrubbing
}, },
}
]
}, },
includeAssets: ['instances.json', 'discord.html'], ],
manifest: false // Use existing public/manifest.json },
}) includeAssets: ["instances.json", "discord.html"],
] manifest: false, // Use existing public/manifest.json
}),
],
}); });