1353 lines
50 KiB
JavaScript
1353 lines
50 KiB
JavaScript
//js/lyrics.js
|
||
import { getTrackTitle, getTrackArtists, buildTrackFilename } from './utils.js';
|
||
import {
|
||
SVG_CLOSE,
|
||
SVG_GENIUS_ACTIVE,
|
||
SVG_GENIUS_INACTIVE,
|
||
SVG_MINUS,
|
||
SVG_PLUS,
|
||
SVG_RESET,
|
||
SVG_GLOBE,
|
||
} from './icons.js';
|
||
import { sidePanelManager } from './side-panel.js';
|
||
|
||
const loadAmLyrics = () => {
|
||
const images = Array.from(document.images).filter((img) => !img.complete);
|
||
if (images.length === 0) {
|
||
import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error);
|
||
} else {
|
||
Promise.all(
|
||
images.map(
|
||
(img) =>
|
||
new Promise((res) => {
|
||
img.onload = img.onerror = res;
|
||
})
|
||
)
|
||
).then(() => import('@uimaxbai/am-lyrics/am-lyrics.js').catch(console.error));
|
||
}
|
||
};
|
||
|
||
if (document.readyState === 'complete') {
|
||
loadAmLyrics();
|
||
} else {
|
||
window.addEventListener('load', loadAmLyrics);
|
||
}
|
||
|
||
// Check if text contains Japanese, Chinese, or Korean characters
|
||
function containsAsianText(text) {
|
||
if (!text) return false;
|
||
// Japanese: Hiragana (3040-309F), Katakana (30A0-30FF), Kanji (4E00-9FFF, 3400-4DBF)
|
||
// Chinese: CJK Unified Ideographs (4E00-9FFF, 3400-4DBF)
|
||
// Korean: Hangul (AC00-D7AF, 1100-11FF, 3130-318F)
|
||
const asianRegex = /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\u3400-\u4DBF\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/;
|
||
return asianRegex.test(text);
|
||
}
|
||
|
||
// Check if track has Asian text in title or artist names
|
||
function trackHasAsianText(track) {
|
||
if (!track) return false;
|
||
const title = track.title || '';
|
||
const artist = getTrackArtists(track) || '';
|
||
return containsAsianText(title) || containsAsianText(artist);
|
||
}
|
||
|
||
function cleanTrackerSearch(text) {
|
||
if (!text) return '';
|
||
// chud emojis will NOT be tolerated in my precious genius lyrics worker
|
||
let cleaned = text.replace(
|
||
/[\p{Extended_Pictographic}\p{Emoji_Component}\p{Emoji_Presentation}\p{Emoji_Modifier}\p{Emoji_Modifier_Base}\p{Symbol}]/gu,
|
||
''
|
||
);
|
||
|
||
cleaned = cleaned.replace(/[\u2600-\u27BF\u2B50\u2B06\u2194\u21AA\u2934\u203C\u2049\u3030\u303D\u3297\u3299]/g, '');
|
||
|
||
cleaned = cleaned.replace(/\[v\s*\d+\s*\]/gi, '');
|
||
|
||
cleaned = cleaned.replace(/\s+/g, ' ');
|
||
|
||
return cleaned.trim();
|
||
}
|
||
|
||
class GeniusManager {
|
||
constructor() {
|
||
this.cache = new Map();
|
||
this.loading = false;
|
||
}
|
||
|
||
// idgaf anymore im js hardcoding this lmaooo
|
||
getToken() {
|
||
const hostname = window.location.hostname;
|
||
if (hostname.endsWith('monochrome.tf') || hostname === 'monochrome.tf') {
|
||
return 'OpITG-h86oehKYuJJ5QVY5F-HxUWXb31EwGKarx2Tle3W9rBUVnMaUL9qo_Oh9Q7';
|
||
}
|
||
return 'QmS9OvsS-7ifRBKx_ochIPQU7oejIS9Eo_z5iWHmCPyhwLVQID3pYTHJmJTa6z8z';
|
||
}
|
||
|
||
async searchTrack(title, artist) {
|
||
const cleanTitle = title.split('(')[0].split('-')[0].trim();
|
||
const query = encodeURIComponent(`${cleanTitle} ${artist}`);
|
||
const token = this.getToken();
|
||
|
||
const url = `https://api.genius.com/search?q=${query}&access_token=${token}`;
|
||
const response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`);
|
||
|
||
if (!response.ok) throw new Error('Failed to search Genius');
|
||
|
||
const data = await response.json();
|
||
if (data.response.hits.length === 0) return null;
|
||
|
||
const normalize = (str) => str.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
|
||
const targetArtist = normalize(artist);
|
||
|
||
const hit = data.response.hits.find((h) => {
|
||
const hitArtist = normalize(h.result.primary_artist.name);
|
||
return hitArtist.includes(targetArtist) || targetArtist.includes(hitArtist);
|
||
});
|
||
|
||
return hit ? hit.result : data.response.hits[0].result;
|
||
}
|
||
|
||
async getReferents(songId) {
|
||
const token = this.getToken();
|
||
const url = `https://api.genius.com/referents?song_id=${songId}&text_format=plain&per_page=50&access_token=${token}`;
|
||
const response = await fetch(`https://api.allorigins.win/raw?url=${encodeURIComponent(url)}`);
|
||
|
||
if (!response.ok) throw new Error('Failed to fetch annotations');
|
||
|
||
const data = await response.json();
|
||
return data.response.referents;
|
||
}
|
||
|
||
async getDataForTrack(track) {
|
||
if (this.cache.has(track.id)) return this.cache.get(track.id);
|
||
|
||
try {
|
||
this.loading = true;
|
||
const artist = Array.isArray(track.artists) ? track.artists[0].name : track.artist.name;
|
||
const song = await this.searchTrack(track.title, artist);
|
||
|
||
if (!song) {
|
||
this.loading = false;
|
||
return null;
|
||
}
|
||
|
||
const referents = await this.getReferents(song.id);
|
||
const result = { song, referents };
|
||
|
||
this.cache.set(track.id, result);
|
||
this.loading = false;
|
||
return result;
|
||
} catch (error) {
|
||
console.error('Genius Error:', error);
|
||
this.loading = false;
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
findAnnotations(lineText, referents) {
|
||
if (!referents || !lineText) return [];
|
||
|
||
const normalize = (str) =>
|
||
str
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{N}\s]/gu, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
const normLine = normalize(lineText);
|
||
|
||
const getWordSet = (str) => new Set(str.split(' ').filter((w) => w.length > 0));
|
||
const lineWords = getWordSet(normLine);
|
||
|
||
return referents.filter((ref) => {
|
||
const normFragment = normalize(ref.fragment);
|
||
|
||
if (normLine.includes(normFragment) || normFragment.includes(normLine)) return true;
|
||
|
||
const fragmentWords = getWordSet(normFragment);
|
||
if (fragmentWords.size === 0 || lineWords.size === 0) return false;
|
||
|
||
let matchCount = 0;
|
||
fragmentWords.forEach((w) => {
|
||
if (lineWords.has(w)) matchCount++;
|
||
});
|
||
|
||
return matchCount / Math.min(fragmentWords.size, lineWords.size) > 0.6;
|
||
});
|
||
}
|
||
}
|
||
|
||
export class LyricsManager {
|
||
static #instance = null;
|
||
|
||
static get instance() {
|
||
if (!LyricsManager.#instance) {
|
||
throw new Error('LyricsManager is not initialized. Call LyricsManager.initialize() first.');
|
||
}
|
||
return LyricsManager.#instance;
|
||
}
|
||
|
||
/** @private */
|
||
constructor(api) {
|
||
this.api = api;
|
||
this.currentLyrics = null;
|
||
this.syncedLyrics = [];
|
||
this.lyricsCache = new Map();
|
||
this.componentLoaded = false;
|
||
this.amLyricsElement = null;
|
||
this.animationFrameId = null;
|
||
this.currentTrackId = null;
|
||
this.mutationObserver = null;
|
||
this.romajiObserver = null;
|
||
this.isRomajiMode = false;
|
||
this.originalLyricsData = null;
|
||
this.kuroshiroLoaded = false;
|
||
this.kuroshiroLoading = false;
|
||
this.romajiTextCache = new Map(); // Cache: originalText -> convertedRomaji
|
||
this.convertedTracksCache = new Set(); // Track IDs that have been fully converted
|
||
this.geniusManager = new GeniusManager();
|
||
this.isGeniusMode = false;
|
||
this.currentGeniusData = null;
|
||
this.timingOffset = 0; // Offset in milliseconds (positive = delay lyrics, negative = advance lyrics)
|
||
}
|
||
|
||
static async initialize(api) {
|
||
if (LyricsManager.#instance) {
|
||
throw new Error('LyricsManager is already initialized');
|
||
}
|
||
return (LyricsManager.#instance = new LyricsManager(api));
|
||
}
|
||
|
||
// Get timing offset for current track
|
||
getTimingOffset(trackId) {
|
||
try {
|
||
const key = `lyrics-offset-${trackId}`;
|
||
const stored = localStorage.getItem(key);
|
||
return stored ? parseInt(stored, 10) : 0;
|
||
} catch {
|
||
return 0;
|
||
}
|
||
}
|
||
|
||
// Set timing offset for current track
|
||
setTimingOffset(trackId, offsetMs) {
|
||
try {
|
||
const key = `lyrics-offset-${trackId}`;
|
||
localStorage.setItem(key, offsetMs.toString());
|
||
} catch (e) {
|
||
console.warn('Failed to save lyrics timing offset:', e);
|
||
}
|
||
}
|
||
|
||
// Reset timing offset for current track
|
||
resetTimingOffset(trackId) {
|
||
this.setTimingOffset(trackId, 0);
|
||
}
|
||
|
||
// Get formatted offset display string
|
||
getOffsetDisplayString(offsetMs) {
|
||
const sign = offsetMs >= 0 ? '+' : '';
|
||
const seconds = Math.abs(offsetMs) / 1000;
|
||
return `${sign}${seconds.toFixed(1)}s`;
|
||
}
|
||
|
||
// Load Kuroshiro from CDN (npm package uses Node.js path which doesn't work in browser)
|
||
async loadKuroshiro() {
|
||
if (this.kuroshiroLoaded) return true;
|
||
if (this.kuroshiroLoading) {
|
||
// Wait for existing load to complete
|
||
return new Promise((resolve) => {
|
||
const checkLoad = setInterval(() => {
|
||
if (!this.kuroshiroLoading) {
|
||
clearInterval(checkLoad);
|
||
resolve(this.kuroshiroLoaded);
|
||
}
|
||
}, 100);
|
||
});
|
||
}
|
||
|
||
this.kuroshiroLoading = true;
|
||
try {
|
||
// Bug on kuromoji@0.1.2 where it mangles absolute URLs
|
||
// Using self-hosted dict files is failed, so we use CDN with monkey-patch
|
||
// Monkey-patch XMLHttpRequest to redirect dictionary requests to CDN
|
||
// Kuromoji uses XHR, not fetch, for loading dictionary files
|
||
if (!window._originalXHROpen) {
|
||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||
window._originalXHROpen = XMLHttpRequest.prototype.open;
|
||
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
|
||
const urlStr = url.toString();
|
||
if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
|
||
// Extract just the filename
|
||
const filename = urlStr.split('/').pop();
|
||
// Redirect to CDN
|
||
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`;
|
||
return window._originalXHROpen.call(this, method, cdnUrl, ...rest);
|
||
}
|
||
return window._originalXHROpen.call(this, method, url, ...rest);
|
||
};
|
||
}
|
||
|
||
// Also patch fetch just in case
|
||
if (!window._originalFetch) {
|
||
window._originalFetch = window.fetch;
|
||
window.fetch = async (url, options) => {
|
||
const urlStr = url instanceof URL ? url.toString() : url.url;
|
||
if (urlStr.includes('/dict/') && urlStr.includes('.dat.gz')) {
|
||
const filename = urlStr.split('/').pop();
|
||
const cdnUrl = `https://cdn.jsdelivr.net/npm/kuromoji@0.1.2/dict/${filename}`;
|
||
console.log(`Redirecting dict fetch: ${filename} -> CDN`);
|
||
return window._originalFetch(cdnUrl, options);
|
||
}
|
||
return window._originalFetch(url, options);
|
||
};
|
||
}
|
||
|
||
// Load Kuroshiro from CDN
|
||
if (!window.Kuroshiro) {
|
||
await this.loadScript('https://cdn.jsdelivr.net/npm/kuroshiro@1.2.0/dist/kuroshiro.min.js');
|
||
}
|
||
|
||
// Load Kuromoji analyzer from CDN
|
||
if (!window.KuromojiAnalyzer) {
|
||
await this.loadScript(
|
||
'https://cdn.jsdelivr.net/npm/kuroshiro-analyzer-kuromoji@1.1.0/dist/kuroshiro-analyzer-kuromoji.min.js'
|
||
);
|
||
}
|
||
|
||
// Initialize Kuroshiro (CDN version exports as .default)
|
||
const Kuroshiro = window.Kuroshiro.default || window.Kuroshiro;
|
||
const KuromojiAnalyzer = window.KuromojiAnalyzer.default || window.KuromojiAnalyzer;
|
||
|
||
this.kuroshiro = new Kuroshiro();
|
||
|
||
// Initialize with a dummy path - our fetch interceptor will redirect to CDN
|
||
await this.kuroshiro.init(
|
||
new KuromojiAnalyzer({
|
||
dictPath: '/dict/', // This gets mangled but our interceptor fixes it
|
||
})
|
||
);
|
||
|
||
this.kuroshiroLoaded = true;
|
||
this.kuroshiroLoading = false;
|
||
console.log('✓ Kuroshiro loaded and initialized successfully');
|
||
return true;
|
||
} catch (error) {
|
||
console.error('✗ Failed to load Kuroshiro:', error);
|
||
this.kuroshiroLoaded = false;
|
||
this.kuroshiroLoading = false;
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// Helper to load external scripts
|
||
loadScript(src) {
|
||
return new Promise((resolve, reject) => {
|
||
// Check if script already exists
|
||
if (document.querySelector(`script[src="${src}"]`)) {
|
||
resolve();
|
||
return;
|
||
}
|
||
const script = document.createElement('script');
|
||
script.src = src;
|
||
script.onload = resolve;
|
||
script.onerror = () => reject(new Error(`Failed to load script: ${src}`));
|
||
document.head.appendChild(script);
|
||
});
|
||
}
|
||
|
||
// Check if text contains Japanese characters
|
||
containsJapanese(text) {
|
||
if (!text) return false;
|
||
// Match any Japanese character (Hiragana, Katakana, Kanji)
|
||
return /[\u3040-\u30FF\u31F0-\u9FFF]/.test(text);
|
||
}
|
||
|
||
// Convert Japanese text to Romaji (including Kanji) with caching
|
||
async convertToRomaji(text) {
|
||
if (!text) return text;
|
||
|
||
// Check cache first
|
||
if (this.romajiTextCache.has(text)) {
|
||
return this.romajiTextCache.get(text);
|
||
}
|
||
|
||
// Only process if text contains Asian characters
|
||
if (!containsAsianText(text)) {
|
||
return text;
|
||
}
|
||
|
||
// Make sure Kuroshiro is loaded
|
||
if (!this.kuroshiroLoaded) {
|
||
const success = await this.loadKuroshiro();
|
||
if (!success) {
|
||
console.warn('Kuroshiro not available, skipping conversion');
|
||
return text;
|
||
}
|
||
}
|
||
|
||
if (!this.kuroshiro) {
|
||
console.warn('Kuroshiro not available, skipping conversion');
|
||
return text;
|
||
}
|
||
|
||
try {
|
||
// Convert to Romaji using Kuroshiro (handles Kanji, Hiragana, Katakana)
|
||
const result = await this.kuroshiro.convert(text, {
|
||
to: 'romaji',
|
||
mode: 'spaced',
|
||
romajiSystem: 'hepburn',
|
||
});
|
||
// Cache the result
|
||
this.romajiTextCache.set(text, result);
|
||
return result;
|
||
} catch (error) {
|
||
console.warn('Romaji conversion failed for text:', text.substring(0, 30), error);
|
||
return text;
|
||
}
|
||
}
|
||
|
||
// Set Romaji mode and save preference
|
||
setRomajiMode(enabled) {
|
||
this.isRomajiMode = enabled;
|
||
try {
|
||
localStorage.setItem('lyricsRomajiMode', enabled ? 'true' : 'false');
|
||
} catch (e) {
|
||
console.warn('Failed to save Romaji mode preference:', e);
|
||
}
|
||
}
|
||
|
||
// Get saved Romaji mode preference
|
||
getRomajiMode() {
|
||
try {
|
||
return localStorage.getItem('lyricsRomajiMode') === 'true';
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
async ensureComponentLoaded() {
|
||
if (this.componentLoaded) return;
|
||
|
||
if (typeof customElements !== 'undefined') {
|
||
await customElements.whenDefined('am-lyrics');
|
||
this.componentLoaded = true;
|
||
}
|
||
}
|
||
|
||
async fetchLyrics(trackId, track = null) {
|
||
if (track) {
|
||
if (this.lyricsCache.has(trackId)) {
|
||
return this.lyricsCache.get(trackId);
|
||
}
|
||
|
||
try {
|
||
const artist = Array.isArray(track.artists)
|
||
? track.artists.map((a) => a.name || a).join(', ')
|
||
: track.artist?.name || '';
|
||
const title = track.title || '';
|
||
const album = track.album?.title || '';
|
||
const duration = track.duration ? Math.round(track.duration) : null;
|
||
|
||
if (!title || !artist) {
|
||
console.warn('Missing required fields for LRCLIB');
|
||
return null;
|
||
}
|
||
|
||
const params = new URLSearchParams({
|
||
track_name: title,
|
||
artist_name: artist,
|
||
});
|
||
|
||
if (album) params.append('album_name', album);
|
||
if (duration) params.append('duration', duration.toString());
|
||
|
||
const response = await fetch(`https://lrclib.net/api/get?${params.toString()}`);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
|
||
if (data.syncedLyrics) {
|
||
const lyricsData = {
|
||
subtitles: data.syncedLyrics,
|
||
lyricsProvider: 'LRCLIB',
|
||
};
|
||
|
||
this.lyricsCache.set(trackId, lyricsData);
|
||
return lyricsData;
|
||
}
|
||
}
|
||
} catch (error) {
|
||
console.warn('LRCLIB fetch failed:', error);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
parseSyncedLyrics(subtitles) {
|
||
if (!subtitles) return [];
|
||
const lines = subtitles.split('\n').filter((line) => line.trim());
|
||
return lines
|
||
.map((line) => {
|
||
const match = line.match(/\[(\d+):(\d+)\.(\d+)\]\s*(.+)/);
|
||
if (match) {
|
||
const [, minutes, seconds, centiseconds, text] = match;
|
||
const timeInSeconds = parseInt(minutes) * 60 + parseInt(seconds) + parseInt(centiseconds) / 100;
|
||
return { time: timeInSeconds, text: text.trim() };
|
||
}
|
||
return null;
|
||
})
|
||
.filter(Boolean);
|
||
}
|
||
|
||
generateLRCContent(lyricsData, track) {
|
||
if (!lyricsData || !lyricsData.subtitles) return null;
|
||
|
||
const trackTitle = getTrackTitle(track);
|
||
const trackArtist = getTrackArtists(track);
|
||
|
||
let lrc = `[ti:${trackTitle}]\n`;
|
||
lrc += `[ar:${trackArtist}]\n`;
|
||
lrc += `[al:${track.album?.title || 'Unknown Album'}]\n`;
|
||
lrc += `[by:${lyricsData.lyricsProvider || 'Unknown'}]\n`;
|
||
lrc += '\n';
|
||
lrc += lyricsData.subtitles;
|
||
|
||
return lrc;
|
||
}
|
||
|
||
getLRC(lyricsData, track) {
|
||
const lrcContent = this.generateLRCContent(lyricsData, track);
|
||
if (!lrcContent) {
|
||
alert('No synced lyrics available for this track');
|
||
return;
|
||
}
|
||
|
||
return new File([lrcContent], buildTrackFilename(track, 'LOSSLESS').replace(/\.flac$/, '.lrc'), {
|
||
type: 'application/octet-stream',
|
||
});
|
||
}
|
||
|
||
downloadLRC(lyricsData, track) {
|
||
const blob = this.getLRC(lyricsData, track);
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = blob.name;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
getCurrentLine(currentTime) {
|
||
if (!this.syncedLyrics || this.syncedLyrics.length === 0) return -1;
|
||
let currentIndex = -1;
|
||
for (let i = 0; i < this.syncedLyrics.length; i++) {
|
||
if (currentTime >= this.syncedLyrics[i].time) {
|
||
currentIndex = i;
|
||
} else {
|
||
break;
|
||
}
|
||
}
|
||
return currentIndex;
|
||
}
|
||
|
||
// Setup MutationObserver to convert lyrics in am-lyrics component
|
||
async setupLyricsObserver(amLyricsElement) {
|
||
this.stopLyricsObserver();
|
||
|
||
if (!amLyricsElement) return;
|
||
|
||
// Check for shadow DOM
|
||
const observeRoot = amLyricsElement.shadowRoot || amLyricsElement;
|
||
|
||
this.romajiObserver = new MutationObserver((mutations) => {
|
||
// Check if any relevant mutation occurred
|
||
const hasRelevantChange = mutations.some((mutation) => {
|
||
if (mutation.type === 'childList') {
|
||
let relevant = false;
|
||
if (mutation.addedNodes.length > 0) {
|
||
for (const node of mutation.addedNodes) {
|
||
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('genius-indicator'))
|
||
continue;
|
||
relevant = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!relevant && mutation.removedNodes.length > 0) {
|
||
for (const node of mutation.removedNodes) {
|
||
if (node.nodeType === Node.ELEMENT_NODE && node.classList.contains('genius-indicator'))
|
||
continue;
|
||
relevant = true;
|
||
break;
|
||
}
|
||
}
|
||
return relevant;
|
||
}
|
||
if (mutation.type === 'characterData') return true;
|
||
return false;
|
||
});
|
||
|
||
if (!hasRelevantChange) {
|
||
return;
|
||
}
|
||
|
||
// Debounce mutations
|
||
if (this.observerTimeout) {
|
||
clearTimeout(this.observerTimeout);
|
||
}
|
||
this.observerTimeout = setTimeout(async () => {
|
||
if (this.isRomajiMode) {
|
||
await this.convertLyricsContent(amLyricsElement);
|
||
}
|
||
if (this.isGeniusMode && this.currentGeniusData) {
|
||
await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||
}
|
||
}, 100);
|
||
});
|
||
|
||
// Observe all child nodes for changes (in shadow DOM if it exists)
|
||
// Watch for new nodes AND text content changes to catch when lyrics refresh
|
||
this.romajiObserver.observe(observeRoot, {
|
||
childList: true,
|
||
subtree: true,
|
||
characterData: true, // Watch text changes to catch lyric refreshes
|
||
attributes: false, // Don't watch attribute changes (highlight, etc)
|
||
});
|
||
|
||
// Initial conversion if Romaji mode is enabled - single attempt, no periodic polling
|
||
if (this.isRomajiMode) {
|
||
await this.convertLyricsContent(amLyricsElement);
|
||
}
|
||
if (this.isGeniusMode && this.currentGeniusData) {
|
||
await this.applyGeniusAnnotations(amLyricsElement, this.currentGeniusData.referents);
|
||
}
|
||
}
|
||
|
||
// Convert lyrics content to Romaji
|
||
async convertLyricsContent(amLyricsElement) {
|
||
if (!amLyricsElement || !this.isRomajiMode) {
|
||
return;
|
||
}
|
||
|
||
// Find the root to traverse - check for shadow DOM first
|
||
const rootToTraverse = amLyricsElement.shadowRoot || amLyricsElement;
|
||
|
||
// Make sure Kuroshiro is ready
|
||
if (!this.kuroshiroLoaded) {
|
||
const success = await this.loadKuroshiro();
|
||
if (!success) {
|
||
console.warn('Cannot convert lyrics - Kuroshiro load failed');
|
||
return;
|
||
}
|
||
}
|
||
|
||
// Find all text nodes in the component
|
||
const textNodes = [];
|
||
const walker = document.createTreeWalker(rootToTraverse, NodeFilter.SHOW_TEXT, null, false);
|
||
|
||
let node;
|
||
while ((node = walker.nextNode())) {
|
||
textNodes.push(node);
|
||
}
|
||
|
||
// Convert Japanese text to Romaji (using async/await for Kuroshiro)
|
||
for (const textNode of textNodes) {
|
||
if (!textNode.parentElement) {
|
||
continue;
|
||
}
|
||
|
||
const parentTag = textNode.parentElement.tagName?.toLowerCase();
|
||
const parentClass = String(textNode.parentElement.className || '');
|
||
|
||
// Skip elements that shouldn't be converted
|
||
const skipTags = ['script', 'style', 'code', 'input', 'textarea', 'time'];
|
||
if (skipTags.includes(parentTag)) {
|
||
continue;
|
||
}
|
||
|
||
const originalText = textNode.textContent;
|
||
|
||
// Skip progress indicators and timestamps (but NOT progress-text which is the actual lyrics!)
|
||
if (
|
||
(parentClass.includes('progress') && !parentClass.includes('progress-text')) ||
|
||
(parentClass.includes('time') && !parentClass.includes('progress-text')) ||
|
||
parentClass.includes('timestamp')
|
||
) {
|
||
continue;
|
||
}
|
||
|
||
if (!originalText || originalText.trim().length === 0) {
|
||
continue;
|
||
}
|
||
|
||
// Check if contains Japanese - convert if we find Japanese
|
||
if (this.containsJapanese(originalText)) {
|
||
const romajiText = await this.convertToRomaji(originalText);
|
||
|
||
// Only update if conversion produced different text
|
||
if (romajiText && romajiText !== originalText) {
|
||
textNode.textContent = romajiText;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Mark this track as converted
|
||
if (this.currentTrackId) {
|
||
this.convertedTracksCache.add(this.currentTrackId);
|
||
}
|
||
}
|
||
|
||
// Stop the observer
|
||
stopLyricsObserver() {
|
||
if (this.romajiObserver) {
|
||
this.romajiObserver.disconnect();
|
||
this.romajiObserver = null;
|
||
}
|
||
if (this.observerTimeout) {
|
||
clearTimeout(this.observerTimeout);
|
||
this.observerTimeout = null;
|
||
}
|
||
}
|
||
|
||
// Toggle Romaji mode
|
||
async toggleRomajiMode(amLyricsElement) {
|
||
this.isRomajiMode = !this.isRomajiMode;
|
||
this.setRomajiMode(this.isRomajiMode);
|
||
|
||
if (amLyricsElement) {
|
||
if (this.isRomajiMode) {
|
||
// Turning ON: Setup observer and convert immediately
|
||
await this.setupLyricsObserver(amLyricsElement);
|
||
await this.convertLyricsContent(amLyricsElement);
|
||
} else {
|
||
// Turning OFF: Stop observer
|
||
// Note: To restore original Japanese, we'd need to reload the component
|
||
this.stopLyricsObserver();
|
||
}
|
||
}
|
||
|
||
return this.isRomajiMode;
|
||
}
|
||
|
||
async applyGeniusAnnotations(amLyricsElement, referents) {
|
||
if (!amLyricsElement || !referents) return;
|
||
|
||
const root = amLyricsElement.shadowRoot || amLyricsElement;
|
||
|
||
const lineElements = Array.from(root.querySelectorAll('p, .line, .lyric-line, .lrc-line'));
|
||
|
||
if (lineElements.length === 0) return;
|
||
|
||
lineElements.forEach((el) => {
|
||
el.classList.remove('genius-annotated', 'genius-multi-start', 'genius-multi-end', 'genius-multi-mid');
|
||
delete el.__geniusAnnotations;
|
||
});
|
||
|
||
const normalize = (str) =>
|
||
str
|
||
.toLowerCase()
|
||
.replace(/[^\p{L}\p{N}\s]/gu, '')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
|
||
referents.forEach((ref) => {
|
||
const fragment = normalize(ref.fragment);
|
||
if (!fragment) return;
|
||
|
||
for (let i = 0; i < lineElements.length; i++) {
|
||
let combinedText = '';
|
||
let currentLines = [];
|
||
|
||
for (let j = i; j < lineElements.length; j++) {
|
||
const line = lineElements[j];
|
||
|
||
const lineClone = line.cloneNode(true);
|
||
lineClone
|
||
.querySelectorAll('.time, .timestamp, [class*="time"], .genius-indicator')
|
||
.forEach((n) => n.remove());
|
||
const text = normalize(lineClone.textContent || '');
|
||
|
||
if (!text) continue;
|
||
|
||
if (currentLines.length > 0) combinedText += ' ';
|
||
combinedText += text;
|
||
currentLines.push(line);
|
||
|
||
if (combinedText.includes(fragment)) {
|
||
currentLines.forEach((el, idx) => {
|
||
el.classList.add('genius-annotated');
|
||
if (!el.__geniusAnnotations) el.__geniusAnnotations = [];
|
||
|
||
if (!el.__geniusAnnotations.some((a) => a.id === ref.id)) {
|
||
el.__geniusAnnotations.push(ref);
|
||
}
|
||
|
||
if (currentLines.length > 1) {
|
||
if (idx === 0) el.classList.add('genius-multi-start');
|
||
else if (idx === currentLines.length - 1) el.classList.add('genius-multi-end');
|
||
else el.classList.add('genius-multi-mid');
|
||
}
|
||
|
||
if (!el.querySelector('.genius-indicator')) {
|
||
const smiley = document.createElement('span');
|
||
smiley.className = 'genius-indicator';
|
||
smiley.textContent = ' ☺';
|
||
smiley.style.color = '#ffff64';
|
||
smiley.style.marginLeft = '0.5em';
|
||
el.appendChild(smiley);
|
||
}
|
||
});
|
||
break;
|
||
}
|
||
|
||
if (combinedText.length > fragment.length + 50) break;
|
||
}
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
export function openLyricsPanel(track, audioPlayer, lyricsManager, forceOpen = false) {
|
||
const manager = lyricsManager || new LyricsManager();
|
||
|
||
// Load Kuroshiro in background only if track has Asian text and Romaji mode is enabled
|
||
const isRomajiMode = manager.getRomajiMode();
|
||
if (isRomajiMode && trackHasAsianText(track) && !manager.kuroshiroLoaded && !manager.kuroshiroLoading) {
|
||
manager.loadKuroshiro().catch((err) => {
|
||
console.warn('Failed to load Kuroshiro for Romaji conversion:', err);
|
||
});
|
||
}
|
||
|
||
// Load saved timing offset for this track
|
||
manager.timingOffset = manager.getTimingOffset(track.id);
|
||
|
||
const renderControls = (container) => {
|
||
const isRomajiMode = manager.getRomajiMode();
|
||
manager.isRomajiMode = isRomajiMode;
|
||
const isGeniusMode = manager.isGeniusMode;
|
||
const offsetDisplay = manager.getOffsetDisplayString(manager.timingOffset);
|
||
|
||
container.innerHTML = `
|
||
<div class="lyrics-timing-controls">
|
||
<button id="lyrics-timing-minus-btn" class="btn-icon" title="Decrease delay (lyrics earlier) -0.5s">
|
||
${SVG_MINUS(18)}
|
||
</button>
|
||
<span id="lyrics-timing-display" class="lyrics-timing-display" title="Current timing offset">${offsetDisplay}</span>
|
||
<button id="lyrics-timing-plus-btn" class="btn-icon" title="Increase delay (lyrics later) +0.5s">
|
||
${SVG_PLUS(18)}
|
||
</button>
|
||
<button id="lyrics-timing-reset-btn" class="btn-icon" title="Reset timing offset">
|
||
${SVG_RESET(16)}
|
||
</button>
|
||
</div>
|
||
<button id="romaji-toggle-btn" class="btn-icon" title="Toggle Romaji (Japanese to Latin)" data-enabled="${isRomajiMode}" style="color: ${isRomajiMode ? 'var(--primary)' : ''}">
|
||
${SVG_GLOBE(20)}
|
||
</button>
|
||
<button id="genius-toggle-btn" class="btn-icon ${isGeniusMode ? 'active-genius' : ''}" title="Genius Mode" style="${isGeniusMode ? 'color: #ffff64;' : ''}">
|
||
${isGeniusMode ? SVG_GENIUS_ACTIVE(20) : SVG_GENIUS_INACTIVE(20)}
|
||
</button>
|
||
<button id="close-side-panel-btn" class="btn-icon" title="Close">
|
||
${SVG_CLOSE(20)}
|
||
</button>
|
||
`;
|
||
|
||
container.querySelector('#close-side-panel-btn').addEventListener('click', () => {
|
||
sidePanelManager.close();
|
||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||
});
|
||
|
||
// Timing adjustment controls
|
||
const updateTimingDisplay = () => {
|
||
const display = container.querySelector('#lyrics-timing-display');
|
||
if (display) {
|
||
display.textContent = manager.getOffsetDisplayString(manager.timingOffset);
|
||
}
|
||
};
|
||
|
||
container.querySelector('#lyrics-timing-minus-btn')?.addEventListener('click', () => {
|
||
manager.timingOffset -= 500; // Decrease by 0.5 seconds
|
||
manager.setTimingOffset(track.id, manager.timingOffset);
|
||
updateTimingDisplay();
|
||
});
|
||
|
||
container.querySelector('#lyrics-timing-plus-btn')?.addEventListener('click', () => {
|
||
manager.timingOffset += 500; // Increase by 0.5 seconds
|
||
manager.setTimingOffset(track.id, manager.timingOffset);
|
||
updateTimingDisplay();
|
||
});
|
||
|
||
container.querySelector('#lyrics-timing-reset-btn')?.addEventListener('click', () => {
|
||
manager.timingOffset = 0;
|
||
manager.resetTimingOffset(track.id);
|
||
updateTimingDisplay();
|
||
});
|
||
|
||
// 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) {
|
||
await manager.toggleRomajiMode(amLyrics);
|
||
updateRomajiBtn();
|
||
}
|
||
});
|
||
}
|
||
|
||
// Genius toggle
|
||
const geniusBtn = container.querySelector('#genius-toggle-btn');
|
||
if (geniusBtn) {
|
||
geniusBtn.addEventListener('click', async () => {
|
||
manager.isGeniusMode = !manager.isGeniusMode;
|
||
const enabled = manager.isGeniusMode;
|
||
|
||
geniusBtn.classList.toggle('active-genius', enabled);
|
||
geniusBtn.style.color = enabled ? '#ffff64' : '';
|
||
geniusBtn.innerHTML = enabled ? SVG_GENIUS_ACTIVE(20) : SVG_GENIUS_INACTIVE(20);
|
||
|
||
if (enabled) {
|
||
try {
|
||
geniusBtn.style.opacity = '0.5';
|
||
await manager.geniusManager.getDataForTrack(track);
|
||
manager.currentGeniusData = manager.geniusManager.cache.get(track.id);
|
||
const amLyrics = sidePanelManager.panel.querySelector('am-lyrics');
|
||
if (amLyrics)
|
||
manager.applyGeniusAnnotations(
|
||
amLyrics,
|
||
manager.geniusManager.cache.get(track.id)?.referents
|
||
);
|
||
} catch (e) {
|
||
alert(e.message);
|
||
manager.isGeniusMode = false;
|
||
geniusBtn.classList.remove('active-genius');
|
||
geniusBtn.style.color = '';
|
||
} finally {
|
||
geniusBtn.style.opacity = '1';
|
||
}
|
||
} else {
|
||
const amLyrics = sidePanelManager.panel.querySelector('am-lyrics');
|
||
if (amLyrics) {
|
||
const root = amLyrics.shadowRoot || amLyrics;
|
||
const lineElements = Array.from(root.querySelectorAll('.genius-annotated'));
|
||
lineElements.forEach((el) => {
|
||
el.classList.remove(
|
||
'genius-annotated',
|
||
'genius-multi-start',
|
||
'genius-multi-end',
|
||
'genius-multi-mid'
|
||
);
|
||
delete el.__geniusAnnotations;
|
||
});
|
||
}
|
||
const modal = document.querySelector('.genius-annotation-modal');
|
||
if (modal) modal.remove();
|
||
}
|
||
});
|
||
}
|
||
};
|
||
|
||
const renderContent = async (container) => {
|
||
clearLyricsPanelSync(audioPlayer, sidePanelManager.panel);
|
||
await renderLyricsComponent(container, track, audioPlayer, manager);
|
||
if (container.lyricsCleanup) {
|
||
sidePanelManager.panel.lyricsCleanup = container.lyricsCleanup;
|
||
sidePanelManager.panel.lyricsManager = container.lyricsManager;
|
||
}
|
||
};
|
||
|
||
sidePanelManager.open('lyrics', 'Lyrics', renderControls, renderContent, forceOpen);
|
||
}
|
||
|
||
function getLyricsHighlightColor() {
|
||
// Check if the current theme is light
|
||
const isLight = getComputedStyle(document.documentElement).colorScheme === 'light';
|
||
return isLight ? '#000' : '#fff';
|
||
}
|
||
|
||
function updateLyricsTheme() {
|
||
const highlightColor = getLyricsHighlightColor();
|
||
document.querySelectorAll('am-lyrics').forEach((el) => {
|
||
el.setAttribute('highlight-color', highlightColor);
|
||
});
|
||
}
|
||
|
||
// watch for theme changes
|
||
const themeObserver = new MutationObserver(() => {
|
||
updateLyricsTheme();
|
||
});
|
||
|
||
themeObserver.observe(document.documentElement, {
|
||
attributes: true,
|
||
attributeFilter: ['data-theme', 'style'],
|
||
});
|
||
|
||
function applyFullscreenLyricsShadowTweaks(amLyrics, container) {
|
||
if (!amLyrics || container?.id !== 'fullscreen-lyrics-content') return;
|
||
|
||
const injectStyle = () => {
|
||
const root = amLyrics.shadowRoot;
|
||
if (!root) return false;
|
||
|
||
let styleEl = root.getElementById('monochrome-fullscreen-lyrics-tweaks');
|
||
if (!styleEl) {
|
||
styleEl = document.createElement('style');
|
||
styleEl.id = 'monochrome-fullscreen-lyrics-tweaks';
|
||
root.appendChild(styleEl);
|
||
}
|
||
|
||
styleEl.textContent = `
|
||
.lyrics-container {
|
||
scrollbar-width: none !important;
|
||
-ms-overflow-style: none !important;
|
||
}
|
||
|
||
.lyrics-container::-webkit-scrollbar {
|
||
width: 0 !important;
|
||
height: 0 !important;
|
||
display: none !important;
|
||
background: transparent !important;
|
||
}
|
||
|
||
.lyrics-line {
|
||
transform-origin: left center;
|
||
transition:
|
||
opacity 0.42s ease,
|
||
transform 0.55s cubic-bezier(0.22, 1, 0.36, 1) var(--lyrics-line-delay, 0ms),
|
||
filter 0.48s cubic-bezier(0.22, 1, 0.36, 1) !important;
|
||
}
|
||
|
||
.lyrics-line:not(.active):not(.pre-active) {
|
||
opacity: 0.44;
|
||
}
|
||
.lyrics-line-container {
|
||
transition:
|
||
transform 0.72s cubic-bezier(0.22, 1, 0.36, 1),
|
||
background-color 0.3s ease,
|
||
color 0.3s ease !important;
|
||
}
|
||
|
||
.lyrics-line.active .lyrics-line-container,
|
||
.lyrics-line.pre-active .lyrics-line-container {
|
||
transition:
|
||
transform 0.56s cubic-bezier(0.22, 1, 0.36, 1),
|
||
background-color 0.22s ease,
|
||
color 0.22s ease !important;
|
||
}
|
||
|
||
.lyrics-line.active .lyrics-line-container {
|
||
transform: scale(1.015);
|
||
}
|
||
`;
|
||
|
||
return true;
|
||
};
|
||
|
||
if (injectStyle()) return;
|
||
|
||
let attempts = 0;
|
||
const maxAttempts = 24;
|
||
const tryInject = () => {
|
||
if (injectStyle()) return;
|
||
attempts += 1;
|
||
if (attempts < maxAttempts) {
|
||
requestAnimationFrame(tryInject);
|
||
}
|
||
};
|
||
|
||
requestAnimationFrame(tryInject);
|
||
}
|
||
|
||
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 = getTrackTitle(track);
|
||
const artist = getTrackArtists(track);
|
||
const album = track.album?.title;
|
||
const durationMs = track.duration ? Math.round(track.duration * 1000) : undefined;
|
||
const isrc = track.isrc || '';
|
||
|
||
const isTracker = track.isTracker || (track.id && String(track.id).startsWith('tracker-'));
|
||
let queryTitle = title;
|
||
let queryArtist = artist;
|
||
|
||
if (isTracker) {
|
||
queryTitle = cleanTrackerSearch(title);
|
||
queryArtist = cleanTrackerSearch(artist);
|
||
}
|
||
|
||
container.innerHTML = '';
|
||
const amLyrics = document.createElement('am-lyrics');
|
||
amLyrics.setAttribute('song-title', queryTitle);
|
||
amLyrics.setAttribute('song-artist', queryArtist);
|
||
if (album) amLyrics.setAttribute('song-album', album);
|
||
if (durationMs) amLyrics.setAttribute('song-duration', durationMs);
|
||
amLyrics.setAttribute('query', `${queryTitle} ${queryArtist}`.trim());
|
||
if (isrc) amLyrics.setAttribute('isrc', isrc);
|
||
|
||
amLyrics.setAttribute('highlight-color', getLyricsHighlightColor());
|
||
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);
|
||
applyFullscreenLyricsShadowTweaks(amLyrics, container);
|
||
|
||
lyricsManager.setupLyricsObserver(amLyrics);
|
||
|
||
// If Romaji mode is enabled and track has Asian text, ensure Kuroshiro is ready
|
||
if (lyricsManager.isRomajiMode && trackHasAsianText(track) && !lyricsManager.kuroshiroLoaded) {
|
||
await lyricsManager.loadKuroshiro();
|
||
}
|
||
|
||
lyricsManager
|
||
.fetchLyrics(track.id, track)
|
||
.then(async () => {
|
||
if (lyricsManager.isGeniusMode) {
|
||
try {
|
||
const data = await lyricsManager.geniusManager.getDataForTrack(track);
|
||
if (data) {
|
||
lyricsManager.currentGeniusData = data;
|
||
lyricsManager.applyGeniusAnnotations(amLyrics, data.referents);
|
||
}
|
||
} catch (e) {
|
||
console.warn('Genius auto-load failed', e);
|
||
}
|
||
}
|
||
})
|
||
.catch((e) => console.warn('Background lyrics fetch failed', e));
|
||
|
||
// 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);
|
||
}
|
||
|
||
if (lyricsManager.isGeniusMode && lyricsManager.currentGeniusData) {
|
||
lyricsManager.applyGeniusAnnotations(amLyrics, lyricsManager.currentGeniusData.referents);
|
||
}
|
||
|
||
const cleanup = setupSync(track, audioPlayer, amLyrics, lyricsManager);
|
||
|
||
// 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>';
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setupSync(track, audioPlayer, amLyrics, lyricsManager) {
|
||
let baseTimeMs = 0;
|
||
let lastTimestamp = performance.now();
|
||
let animationFrameId = null;
|
||
|
||
// Get timing offset from lyrics manager (in milliseconds)
|
||
const getTimingOffset = () => {
|
||
return lyricsManager?.timingOffset || 0;
|
||
};
|
||
|
||
const updateTime = () => {
|
||
const currentMs = audioPlayer.currentTime * 1000;
|
||
baseTimeMs = currentMs;
|
||
lastTimestamp = performance.now();
|
||
// Apply timing offset: positive offset delays lyrics, negative advances them
|
||
amLyrics.currentTime = currentMs - getTimingOffset();
|
||
};
|
||
|
||
const tick = () => {
|
||
if (!audioPlayer.paused) {
|
||
const now = performance.now();
|
||
const elapsed = now - lastTimestamp;
|
||
const nextMs = baseTimeMs + elapsed;
|
||
// Apply timing offset: positive offset delays lyrics, negative advances them
|
||
amLyrics.currentTime = nextMs - getTimingOffset();
|
||
animationFrameId = requestAnimationFrame(tick);
|
||
}
|
||
};
|
||
|
||
const onPlay = () => {
|
||
baseTimeMs = audioPlayer.currentTime * 1000;
|
||
lastTimestamp = performance.now();
|
||
tick();
|
||
};
|
||
|
||
const onPause = () => {
|
||
if (animationFrameId) {
|
||
cancelAnimationFrame(animationFrameId);
|
||
animationFrameId = null;
|
||
}
|
||
};
|
||
|
||
const onLineClick = (e) => {
|
||
if (e.detail && e.detail.timestamp !== undefined) {
|
||
const manager = lyricsManager || sidePanelManager.panel.lyricsManager;
|
||
if (manager && manager.isGeniusMode) {
|
||
const timestampSeconds = e.detail.timestamp / 1000;
|
||
|
||
const lyricsData = manager.lyricsCache.get(track.id);
|
||
if (lyricsData && lyricsData.subtitles) {
|
||
const parsed = manager.parseSyncedLyrics(lyricsData.subtitles);
|
||
|
||
const line = parsed.find((l) => Math.abs(l.time - timestampSeconds) < 1.0);
|
||
|
||
if (line && line.text && manager.currentGeniusData) {
|
||
const annotations = manager.geniusManager.findAnnotations(
|
||
line.text,
|
||
manager.currentGeniusData.referents
|
||
);
|
||
showGeniusAnnotations(annotations, line.text);
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
audioPlayer.currentTime = e.detail.timestamp / 1000;
|
||
audioPlayer.play();
|
||
}
|
||
};
|
||
|
||
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();
|
||
}
|
||
|
||
return () => {
|
||
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);
|
||
};
|
||
}
|
||
|
||
function showGeniusAnnotations(annotations, lineText) {
|
||
const existing = document.querySelector('.genius-annotation-modal');
|
||
if (existing) existing.remove();
|
||
|
||
const modal = document.createElement('div');
|
||
modal.className = 'genius-annotation-modal';
|
||
|
||
let contentHtml = `
|
||
<div class="genius-modal-content">
|
||
<div class="genius-header">
|
||
<span class="genius-line">"${lineText}"</span>
|
||
<button class="close-genius">×</button>
|
||
</div>
|
||
<div class="genius-body">
|
||
`;
|
||
|
||
if (annotations.length === 0) {
|
||
contentHtml += `
|
||
<div class="annotation-item">
|
||
<div class="annotation-text" style="color: var(--muted-foreground); font-style: italic;">No Genius annotation found for this line.</div>
|
||
</div>
|
||
`;
|
||
} else {
|
||
annotations.forEach((ann) => {
|
||
const body = ann.annotations[0].body.plain;
|
||
contentHtml += `
|
||
<div class="annotation-item">
|
||
<div class="annotation-text">${body.replace(/\n/g, '<br>')}</div>
|
||
</div>
|
||
`;
|
||
});
|
||
}
|
||
|
||
contentHtml += `</div></div>`;
|
||
modal.innerHTML = contentHtml;
|
||
|
||
document.body.appendChild(modal);
|
||
|
||
modal.querySelector('.close-genius').addEventListener('click', () => modal.remove());
|
||
|
||
modal.addEventListener('click', (e) => {
|
||
if (e.target === modal) modal.remove();
|
||
});
|
||
}
|
||
|
||
export async function renderLyricsInFullscreen(track, audioPlayer, lyricsManager, container) {
|
||
return renderLyricsComponent(container, track, audioPlayer, lyricsManager);
|
||
}
|
||
|
||
export function clearFullscreenLyricsSync(container) {
|
||
if (container && container.lyricsCleanup) {
|
||
container.lyricsCleanup();
|
||
container.lyricsCleanup = null;
|
||
}
|
||
if (container && container.lyricsManager) {
|
||
container.lyricsManager.stopLyricsObserver();
|
||
}
|
||
}
|
||
|
||
export function clearLyricsPanelSync(_audioPlayer, panel) {
|
||
if (panel && panel.lyricsCleanup) {
|
||
panel.lyricsCleanup();
|
||
panel.lyricsCleanup = null;
|
||
}
|
||
if (panel && panel.lyricsManager) {
|
||
panel.lyricsManager.stopLyricsObserver();
|
||
}
|
||
}
|