Merge branch 'main' of github.com:SamidyFR/monochrome

This commit is contained in:
Samidy 2026-01-08 14:52:06 +03:00
commit c9f639ba6c
6 changed files with 1249 additions and 669 deletions

View file

@ -1,7 +1,11 @@
[<img src="https://github.com/SamidyFR/monochrome/blob/main/assets/512.png?raw=true" alt="Monochrome Logo">](https://monochrome.samidy.com)
<p align="center">
<a href="https://monochrome.samidy.com">
<img src="https://github.com/SamidyFR/monochrome/blob/main/public/assets/512.png?raw=true" alt="Monochrome Logo">
</a>
</p>
# Monochrome
<h1 align="center">Monochrome</h1>
**Monochrome** is an open-source, privacy-respecting, ad-free [TIDAL](https://tidal.com) web UI, built on top of [Hi-Fi](https://github.com/sachinsenal0x64/hifi).

View file

@ -609,6 +609,16 @@
<span class="slider"></span>
</label>
</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="info">
<span class="label">Filename Template</span>

View file

@ -118,6 +118,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

@ -1,6 +1,16 @@
//js/lyrics.js
import { getTrackTitle, getTrackArtists, buildTrackFilename, SVG_DOWNLOAD, SVG_CLOSE } from './utils.js';
import { sidePanelManager } from './side-panel.js';
import {
getTrackTitle,
getTrackArtists,
buildTrackFilename,
SVG_DOWNLOAD,
SVG_CLOSE,
} from "./utils.js";
import { sidePanelManager } from "./side-panel.js";
// Dictionary path for kuromoji
// Using CDN - the kuroshiro-analyzer loaded from unpkg will use this as base for fetching dict files
const KUROMOJI_DICT_PATH = "https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/";
export class LyricsManager {
constructor(api) {
@ -11,24 +21,214 @@ export class LyricsManager {
this.componentLoaded = false;
this.amLyricsElement = null;
this.animationFrameId = null;
this.currentTrackId = null;
this.mutationObserver = null;
this.romajiObserver = null;
this.isRomajiMode = false;
this.originalLyricsData = null;
this.kuroshiroLoaded = false;
this.kuroshiroLoading = false;
this.romajiTextCache = new Map(); // Cache: originalText -> convertedRomaji
this.convertedTracksCache = new Set(); // Track IDs that have been fully converted
}
// Load Kuroshiro from CDN (npm package uses Node.js path which doesn't work in browser)
async loadKuroshiro() {
if (this.kuroshiroLoaded) return true;
if (this.kuroshiroLoading) {
// Wait for existing load to complete
return new Promise((resolve) => {
const checkLoad = setInterval(() => {
if (!this.kuroshiroLoading) {
clearInterval(checkLoad);
resolve(this.kuroshiroLoaded);
}
}, 100);
});
}
this.kuroshiroLoading = true;
try {
// Bug on kuromoji@0.1.2 where it mangles absolute URLs
// Using self-hosted dict files is failed, so we use CDN with monkey-patch
// Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN
// Kuromoji uses XHR, not fetch, for loading dictionary files
if (!window._originalXHROpen) {
window._originalXHROpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url, ...rest) {
const urlStr = url.toString();
if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
// Extract just the filename
const filename = urlStr.split('/').pop();
// Redirect to CDN
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`;
return window._originalXHROpen.call(this, method, cdnUrl, ...rest);
}
return window._originalXHROpen.call(this, method, url, ...rest);
};
}
// Also patch fetch just in case
if (!window._originalFetch) {
window._originalFetch = window.fetch;
window.fetch = async (url, options) => {
const urlStr = url.toString();
if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
const filename = urlStr.split('/').pop();
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`;
console.log(`Redirecting dict fetch: ${filename} -> CDN`);
return window._originalFetch(cdnUrl, options);
}
return window._originalFetch(url, options);
};
}
// Load Kuroshiro from CDN
if (!window.Kuroshiro) {
await this.loadScript(
"https://unpkg.com/kuroshiro@1.2.0/dist/kuroshiro.min.js",
);
}
// Load Kuromoji analyzer from CDN
if (!window.KuromojiAnalyzer) {
await this.loadScript(
"https://unpkg.com/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js",
);
}
// Initialize Kuroshiro (CDN version exports as .default)
const Kuroshiro = window.Kuroshiro.default || window.Kuroshiro;
const KuromojiAnalyzer =
window.KuromojiAnalyzer.default || window.KuromojiAnalyzer;
this.kuroshiro = new Kuroshiro();
// Initialize with a dummy path - our fetch interceptor will redirect to CDN
await this.kuroshiro.init(
new KuromojiAnalyzer({
dictPath: "/dict/", // This gets mangled but our interceptor fixes it
}),
);
this.kuroshiroLoaded = true;
this.kuroshiroLoading = false;
console.log("✓ Kuroshiro loaded and initialized successfully");
return true;
} catch (error) {
console.error("✗ Failed to load Kuroshiro:", error);
this.kuroshiroLoaded = false;
this.kuroshiroLoading = false;
return false;
}
}
// Helper to load external scripts
loadScript(src) {
return new Promise((resolve, reject) => {
// Check if script already exists
if (document.querySelector(`script[src="${src}"]`)) {
resolve();
return;
}
const script = document.createElement("script");
script.src = src;
script.onload = resolve;
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
document.head.appendChild(script);
});
}
// Check if text contains Japanese characters
containsJapanese(text) {
if (!text) return false;
// Match any Japanese character (Hiragana, Katakana, Kanji)
return /[\u3040-\u30FF\u31F0-\u9FFF]/.test(text);
}
// Convert Japanese text to Romaji (including Kanji) with caching
async convertToRomaji(text) {
if (!text) return text;
// Check cache first
if (this.romajiTextCache.has(text)) {
return this.romajiTextCache.get(text);
}
// Make sure Kuroshiro is loaded
if (!this.kuroshiroLoaded) {
const success = await this.loadKuroshiro();
if (!success) {
console.warn("Kuroshiro not available, skipping conversion");
return text;
}
}
if (!this.kuroshiro) {
console.warn("Kuroshiro not available, skipping conversion");
return text;
}
try {
// Convert to Romaji using Kuroshiro (handles Kanji, Hiragana, Katakana)
const result = await this.kuroshiro.convert(text, {
to: "romaji",
mode: "spaced",
romajiSystem: "hepburn",
});
// Cache the result
this.romajiTextCache.set(text, result);
return result;
} catch (error) {
console.warn(
"Romaji conversion failed for text:",
text.substring(0, 30),
error,
);
return text;
}
}
// Set Romaji mode and save preference
setRomajiMode(enabled) {
this.isRomajiMode = enabled;
try {
localStorage.setItem("lyricsRomajiMode", enabled ? "true" : "false");
} catch (e) {
console.warn("Failed to save Romaji mode preference:", e);
}
}
// Get saved Romaji mode preference
getRomajiMode() {
try {
return localStorage.getItem("lyricsRomajiMode") === "true";
} catch (e) {
return false;
}
}
async ensureComponentLoaded() {
if (this.componentLoaded) return;
if (typeof customElements !== 'undefined' && customElements.get('am-lyrics')) {
if (
typeof customElements !== "undefined" &&
customElements.get("am-lyrics")
) {
this.componentLoaded = true;
return;
}
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.type = 'module';
script.src = 'https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js';
const script = document.createElement("script");
script.type = "module";
script.src =
"https://cdn.jsdelivr.net/npm/@uimaxbai/am-lyrics@0.6.2/dist/src/am-lyrics.min.js";
script.onload = () => {
if (typeof customElements !== 'undefined') {
customElements.whenDefined('am-lyrics')
if (typeof customElements !== "undefined") {
customElements
.whenDefined("am-lyrics")
.then(() => {
this.componentLoaded = true;
resolve();
@ -39,13 +239,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);
});
}
async fetchLyrics(trackId, track = null) {
// LRCLIB
if (track) {
if (this.lyricsCache.has(trackId)) {
return this.lyricsCache.get(trackId);
@ -53,26 +253,28 @@ export class LyricsManager {
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 || '';
? 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');
console.warn("Missing required fields for LRCLIB");
return null;
}
const params = new URLSearchParams({
track_name: title,
artist_name: artist
artist_name: artist,
});
if (album) params.append('album_name', album);
if (duration) params.append('duration', duration.toString());
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()}`);
const response = await fetch(
`https://lrclib.net/api/get?${params.toString()}`,
);
if (response.ok) {
const data = await response.json();
@ -80,7 +282,7 @@ export class LyricsManager {
if (data.syncedLyrics) {
const lyricsData = {
subtitles: data.syncedLyrics,
lyricsProvider: 'LRCLIB'
lyricsProvider: "LRCLIB",
};
this.lyricsCache.set(trackId, lyricsData);
@ -88,7 +290,7 @@ export class LyricsManager {
}
}
} catch (error) {
console.warn('LRCLIB fetch failed:', error);
console.warn("LRCLIB fetch failed:", error);
}
}
@ -97,16 +299,21 @@ export class LyricsManager {
parseSyncedLyrics(subtitles) {
if (!subtitles) return [];
const lines = subtitles.split('\n').filter(line => line.trim());
return lines.map(line => {
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;
const timeInSeconds =
parseInt(minutes) * 60 +
parseInt(seconds) +
parseInt(centiseconds) / 100;
return { time: timeInSeconds, text: text.trim() };
}
return null;
}).filter(Boolean);
})
.filter(Boolean);
}
generateLRCContent(lyricsData, track) {
@ -117,9 +324,9 @@ export class LyricsManager {
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 += `[al:${track.album?.title || "Unknown Album"}]\n`;
lrc += `[by:${lyricsData.lyricsProvider || "Unknown"}]\n`;
lrc += "\n";
lrc += lyricsData.subtitles;
return lrc;
@ -128,15 +335,18 @@ export class LyricsManager {
downloadLRC(lyricsData, track) {
const lrcContent = this.generateLRCContent(lyricsData, track);
if (!lrcContent) {
alert('No synced lyrics available for this track');
alert("No synced lyrics available for this track");
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 a = document.createElement('a');
const a = document.createElement("a");
a.href = url;
a.download = buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc');
a.download = buildTrackFilename(track, "LOSSLESS").replace(
/\.flac$/,
".lrc",
);
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
@ -155,74 +365,340 @@ export class LyricsManager {
}
return currentIndex;
}
// Setup MutationObserver to convert lyrics in am-lyrics component
setupLyricsObserver(amLyricsElement) {
this.stopLyricsObserver();
if (!amLyricsElement) return;
// Check for shadow DOM
const observeRoot = amLyricsElement.shadowRoot || amLyricsElement;
this.romajiObserver = new MutationObserver((mutations) => {
// Check if any relevant mutation occurred
const hasRelevantChange = mutations.some((mutation) => {
// New nodes added
if (mutation.type === "childList" && mutation.addedNodes.length > 0) {
return true;
}
// Text content changed
if (mutation.type === "characterData" && mutation.target.textContent) {
// Only trigger if the text contains Japanese
return this.containsJapanese(mutation.target.textContent);
}
return false;
});
if (!hasRelevantChange) {
return;
}
// Debounce mutations
if (this.observerTimeout) {
clearTimeout(this.observerTimeout);
}
this.observerTimeout = setTimeout(async () => {
await this.convertLyricsContent(amLyricsElement);
}, 100);
});
// Observe all child nodes for changes (in shadow DOM if it exists)
// Watch for new nodes AND text content changes to catch when lyrics refresh
this.romajiObserver.observe(observeRoot, {
childList: true,
subtree: true,
characterData: true, // Watch text changes to catch lyric refreshes
attributes: false, // Don't watch attribute changes (highlight, etc)
});
// Initial conversion if Romaji mode is enabled - single attempt, no periodic polling
if (this.isRomajiMode) {
this.convertLyricsContent(amLyricsElement);
}
}
// Convert lyrics content to Romaji
async convertLyricsContent(amLyricsElement) {
if (!amLyricsElement || !this.isRomajiMode) {
return;
}
// Find the root to traverse - check for shadow DOM first
const rootToTraverse = amLyricsElement.shadowRoot || amLyricsElement;
// Make sure Kuroshiro is ready
if (!this.kuroshiroLoaded) {
const success = await this.loadKuroshiro();
if (!success) {
console.warn("Cannot convert lyrics - Kuroshiro load failed");
return;
}
}
// Find all text nodes in the component
const textNodes = [];
const walker = document.createTreeWalker(
rootToTraverse,
NodeFilter.SHOW_TEXT,
null,
false,
);
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
// Convert Japanese text to Romaji (using async/await for Kuroshiro)
for (const textNode of textNodes) {
if (!textNode.parentElement) {
continue;
}
const parentTag = textNode.parentElement.tagName?.toLowerCase();
const parentClass = String(textNode.parentElement.className || "");
// Skip elements that shouldn't be converted
const skipTags = ["script", "style", "code", "input", "textarea", "time"];
if (skipTags.includes(parentTag)) {
continue;
}
const originalText = textNode.textContent;
// Skip progress indicators and timestamps (but NOT progress-text which is the actual lyrics!)
if (
(parentClass.includes("progress") &&
!parentClass.includes("progress-text")) ||
(parentClass.includes("time") &&
!parentClass.includes("progress-text")) ||
parentClass.includes("timestamp")
) {
continue;
}
if (!originalText || originalText.trim().length === 0) {
continue;
}
// Check if contains Japanese - convert if we find Japanese
if (this.containsJapanese(originalText)) {
const romajiText = await this.convertToRomaji(originalText);
// Only update if conversion produced different text
if (romajiText && romajiText !== originalText) {
textNode.textContent = romajiText;
}
}
}
// Mark this track as converted
if (this.currentTrackId) {
this.convertedTracksCache.add(this.currentTrackId);
}
}
// Stop the observer
stopLyricsObserver() {
if (this.romajiObserver) {
this.romajiObserver.disconnect();
this.romajiObserver = null;
}
if (this.observerTimeout) {
clearTimeout(this.observerTimeout);
this.observerTimeout = null;
}
}
// Toggle Romaji mode
async toggleRomajiMode(amLyricsElement) {
this.isRomajiMode = !this.isRomajiMode;
this.setRomajiMode(this.isRomajiMode);
if (amLyricsElement) {
if (this.isRomajiMode) {
// Turning ON: Setup observer and convert immediately
this.setupLyricsObserver(amLyricsElement);
await this.convertLyricsContent(amLyricsElement);
} else {
// Turning OFF: Stop observer
// Note: To restore original Japanese, we'd need to reload the component
this.stopLyricsObserver();
}
}
return this.isRomajiMode;
}
}
export async function openLyricsPanel(track, audioPlayer, lyricsManager) {
// If no manager provided, create a temp one
const manager = lyricsManager || new LyricsManager();
// Load Kuroshiro early for Kanji conversion (blocking if Romaji mode is enabled)
if (!manager.kuroshiroLoaded && !manager.kuroshiroLoading) {
if (manager.getRomajiMode()) {
// If Romaji mode is enabled, wait for Kuroshiro to load before continuing
await manager.loadKuroshiro();
} else {
// Otherwise, load in background
manager.loadKuroshiro().catch((err) => {
console.warn("Failed to load Kuroshiro for Romaji conversion:", err);
});
}
}
const renderControls = (container) => {
const isRomajiMode = manager.getRomajiMode();
manager.isRomajiMode = isRomajiMode;
container.innerHTML = `
<button id="close-side-panel-btn" class="btn-icon" title="Close">
${SVG_CLOSE}
</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();
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) => {
// Clean up any previous sync (though sidePanelManager might handle cleanup, we ensure it here)
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
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>';
try {
await lyricsManager.ensureComponentLoaded();
// Set initial Romaji mode
lyricsManager.isRomajiMode = lyricsManager.getRomajiMode();
lyricsManager.currentTrackId = track.id;
const title = track.title;
const artist = getTrackArtists(track);
const album = track.album?.title;
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
const isrc = track.isrc || '';
const durationMs = track.duration
? Math.round(track.duration * 1000)
: undefined;
const isrc = track.isrc || "";
container.innerHTML = '';
const amLyrics = document.createElement('am-lyrics');
amLyrics.setAttribute('song-title', title);
amLyrics.setAttribute('song-artist', artist);
if (album) amLyrics.setAttribute('song-album', album);
if (durationMs) amLyrics.setAttribute('song-duration', durationMs);
amLyrics.setAttribute('query', `${title} ${artist}`.trim());
if (isrc) amLyrics.setAttribute('isrc', isrc);
container.innerHTML = "";
const amLyrics = document.createElement("am-lyrics");
amLyrics.setAttribute("song-title", title);
amLyrics.setAttribute("song-artist", artist);
if (album) amLyrics.setAttribute("song-album", album);
if (durationMs) amLyrics.setAttribute("song-duration", durationMs);
amLyrics.setAttribute("query", `${title} ${artist}`.trim());
if (isrc) amLyrics.setAttribute("isrc", isrc);
amLyrics.setAttribute('highlight-color', '#93c5fd');
amLyrics.setAttribute('hover-background-color', 'rgba(59, 130, 246, 0.14)');
amLyrics.setAttribute('autoscroll', '');
amLyrics.setAttribute('interpolate', '');
amLyrics.style.height = '100%';
amLyrics.style.width = '100%';
amLyrics.setAttribute("highlight-color", "#93c5fd");
amLyrics.setAttribute("hover-background-color", "rgba(59, 130, 246, 0.14)");
amLyrics.setAttribute("autoscroll", "");
amLyrics.setAttribute("interpolate", "");
amLyrics.style.height = "100%";
amLyrics.style.width = "100%";
container.appendChild(amLyrics);
// Setup observer IMMEDIATELY to catch lyrics as they load (not after waiting)
// This is critical - observer must be running before lyrics arrive from LRCLIB
lyricsManager.setupLyricsObserver(amLyrics);
// If Romaji mode is enabled, ensure Kuroshiro is ready
if (lyricsManager.isRomajiMode && !lyricsManager.kuroshiroLoaded) {
await lyricsManager.loadKuroshiro();
}
// Wait for lyrics to appear, then do an immediate conversion
const waitForLyrics = () => {
return new Promise((resolve) => {
// Check if lyrics are already loaded
const checkForLyrics = () => {
const hasLyrics =
amLyrics.querySelector(".lyric-line, [class*='lyric']") ||
(amLyrics.shadowRoot && amLyrics.shadowRoot.querySelector("[class*='lyric']")) ||
(amLyrics.textContent && amLyrics.textContent.length > 50);
return hasLyrics;
};
if (checkForLyrics()) {
resolve();
return;
}
// Check more frequently (200ms) for faster response
let attempts = 0;
const maxAttempts = 25; // 5 seconds max
const interval = setInterval(() => {
attempts++;
if (checkForLyrics() || attempts >= maxAttempts) {
clearInterval(interval);
resolve();
}
}, 200);
});
};
await waitForLyrics();
// Convert immediately after lyrics detected
if (lyricsManager.isRomajiMode) {
await lyricsManager.convertLyricsContent(amLyrics);
// One retry after 500ms in case more lyrics load
setTimeout(() => lyricsManager.convertLyricsContent(amLyrics), 500);
}
const cleanup = setupSync(track, audioPlayer, amLyrics);
// Attach cleanup to container for easy access
container.lyricsCleanup = cleanup;
container.lyricsManager = lyricsManager;
return amLyrics;
} catch (error) {
console.error('Failed to load lyrics:', error);
container.innerHTML = '<div class="lyrics-error">Failed to load lyrics</div>';
console.error("Failed to load lyrics:", error);
container.innerHTML =
'<div class="lyrics-error">Failed to load lyrics</div>';
return null;
}
}
@ -269,11 +745,11 @@ function setupSync(track, audioPlayer, amLyrics) {
}
};
audioPlayer.addEventListener('timeupdate', updateTime);
audioPlayer.addEventListener('play', onPlay);
audioPlayer.addEventListener('pause', onPause);
audioPlayer.addEventListener('seeked', updateTime);
amLyrics.addEventListener('line-click', onLineClick);
audioPlayer.addEventListener("timeupdate", updateTime);
audioPlayer.addEventListener("play", onPlay);
audioPlayer.addEventListener("pause", onPause);
audioPlayer.addEventListener("seeked", updateTime);
amLyrics.addEventListener("line-click", onLineClick);
if (!audioPlayer.paused) {
tick();
@ -283,15 +759,20 @@ function setupSync(track, audioPlayer, amLyrics) {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
}
audioPlayer.removeEventListener('timeupdate', updateTime);
audioPlayer.removeEventListener('play', onPlay);
audioPlayer.removeEventListener('pause', onPause);
audioPlayer.removeEventListener('seeked', updateTime);
amLyrics.removeEventListener('line-click', onLineClick);
audioPlayer.removeEventListener("timeupdate", updateTime);
audioPlayer.removeEventListener("play", onPlay);
audioPlayer.removeEventListener("pause", onPause);
audioPlayer.removeEventListener("seeked", updateTime);
amLyrics.removeEventListener("line-click", onLineClick);
};
}
export async function renderLyricsInFullscreen(track, audioPlayer, lyricsManager, container) {
export async function renderLyricsInFullscreen(
track,
audioPlayer,
lyricsManager,
container,
) {
return renderLyricsComponent(container, track, audioPlayer, lyricsManager);
}
@ -300,6 +781,9 @@ export function clearFullscreenLyricsSync(container) {
container.lyricsCleanup();
container.lyricsCleanup = null;
}
if (container && container.lyricsManager) {
container.lyricsManager.stopLyricsObserver();
}
}
export function clearLyricsPanelSync(audioPlayer, panel) {
@ -307,4 +791,7 @@ export function clearLyricsPanelSync(audioPlayer, panel) {
panel.lyricsCleanup();
panel.lyricsCleanup = null;
}
if (panel && panel.lyricsManager) {
panel.lyricsManager.stopLyricsObserver();
}
}

View file

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

View file

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