您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Synced lyrics with translation/romanization resizable/draggable panel, themed, opacity control. Translations are provided by Gemini 2.0 Flash and 1.5 Flash via the Google AI Studio API (Accessed via a remote server).
// ==UserScript== // @name [AI Translations] Spotify Web Player Floating Lyrics // @namespace http://tampermonkey.net/ // @version 2.5.4 // @description Synced lyrics with translation/romanization resizable/draggable panel, themed, opacity control. Translations are provided by Gemini 2.0 Flash and 1.5 Flash via the Google AI Studio API (Accessed via a remote server). // @author jayxdcode // @license All Rights Reserved // @match https://open.spotify.com/* // @grant GM_log // @grant unsafeWindow // @grant GM_xmlhttpRequest // @connect lrclib.net // @connect src-backend.onrender.com // @connect genius.com // @connect google.com // @copyright 2025, jayxdcode // @sandbox JavaScript // ==/UserScript== (function() { 'use strict'; // -- begin -- const mobileDebug = true; // only set to true if you have eruda. const BACKEND_URL = "https://src-backend.onrender.com/api/translate"; const POLL_INTERVAL = 1000; const STORAGE_KEY = 'tm-lyrics-panel-position'; const SIZE_KEY = 'tm-lyrics-panel-size'; const THEME_KEY = 'tm-lyrics-theme'; const OPACITY_KEY = 'tm-lyrics-opacity'; const CONFIG_KEY = 'tm-lyrics-config'; let lyricsConfig = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}'); let lastCandidates = []; let currentTrackId = null; let currentTrackDur = null; let currInf = null; let syncIntervalId = null; let lyricsData = null; let observer = null; let isDragging = false; let dragLocked = false; let isResizing = false; let currentOpacity = parseFloat(localStorage.getItem(OPACITY_KEY)) || 0.85; let currentTheme = localStorage.getItem(THEME_KEY) || 'dark'; let lastRenderedIdx = -1; let logVisible = false; // --- Utility Functions --- function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // --- Panel viewport adjustment logic --- function handleViewportChange() { const panel = document.getElementById('tm-lyrics-panel'); if (!panel) return; const rect = panel.getBoundingClientRect(); const winWidth = window.innerWidth; const winHeight = window.innerHeight; const isOutOfBounds = rect.left < 0 || rect.top < 0 || rect.right > winWidth || rect.bottom > winHeight; const isTooLarge = rect.width > winWidth || rect.height > winHeight; if (isOutOfBounds || isTooLarge) { debug('Panel is out of bounds or too large for viewport. Adjusting...'); // Clamp size to fit viewport with a small margin const newWidth = Math.min(rect.width, winWidth - 20); const newHeight = Math.min(rect.height, winHeight - 20); panel.style.width = newWidth + 'px'; panel.style.height = newHeight + 'px'; // Re-check rect after resize const newRect = panel.getBoundingClientRect(); // Clamp position to keep the panel fully inside the viewport const newLeft = Math.max(10, Math.min(newRect.left, winWidth - newRect.width - 10)); const newTop = Math.max(10, Math.min(newRect.top, winHeight - newRect.height - 10)); panel.style.left = newLeft + 'px'; panel.style.top = newTop + 'px'; localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: panel.style.left, top: panel.style.top })); localStorage.setItem(SIZE_KEY, JSON.stringify({ width: panel.style.width, height: panel.style.height })); } } // --- Manual Lyrics Menu --- function showManualLyricsMenu(trackKey) { // Ensure we have candidates if (!lastCandidates || !lastCandidates.length) { const manualQuery = prompt('No lyric candidates available. Search manually:'); if (manualQuery && manualQuery.trim() !== '') { loadLyrics('', '', '', currentTrackDur, (parsed) => { lyricsData = parsed; renderLyrics(0); setupProgressSync(currInf.bar, currInf.duration); }, { flag: true, query: manualQuery }); } return; } // Add blur overlay const existingOverlay = document.getElementById('tm-manual-overlay'); if (existingOverlay) existingOverlay.remove(); const overlay = document.createElement('div'); overlay.id = 'tm-manual-overlay'; Object.assign(overlay.style, { position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh', background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(5px)', zIndex: 9999 }); overlay.onclick = () => { overlay.remove(); menu.remove(); }; document.body.appendChild(overlay); // Remove any existing menu document.getElementById('tm-manual-menu')?.remove(); // Container const menu = document.createElement('div'); menu.id = 'tm-manual-menu'; Object.assign(menu.style, { position: 'fixed', top: '50%', left: '50%', transform: 'translate(-50%, -50%)', width: '90vw', maxWidth: '600px', maxHeight: '70vh', background: '#2a2a2a', color: '#fff', borderRadius: '12px', overflow: 'hidden', display: 'flex', flexDirection: 'column', zIndex: 10000, boxShadow: '0 4px 20px rgba(0,0,0,0.5)' }); document.body.appendChild(menu); // Header with title & close const header = document.createElement('div'); header.textContent = 'Choose Lyrics Source'; Object.assign(header.style, { padding: '12px 16px', fontWeight: 'bold', borderBottom: '1px solid #444', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }); const closeBtn = document.createElement('button'); closeBtn.textContent = '×'; Object.assign(closeBtn.style, { background: 'none', border: 'none', color: '#fff', fontSize: '20px', cursor: 'pointer' }); closeBtn.onclick = () => { overlay.remove(); menu.remove(); }; header.appendChild(closeBtn); menu.appendChild(header); // Scrollable list const list = document.createElement('div'); Object.assign(list.style, { flex: '1', overflowY: 'auto', padding: '8px' }); menu.appendChild(list); lastCandidates.forEach((c, idx) => { const panel = document.createElement('div'); Object.assign(panel.style, { background: '#333', borderRadius: '8px', marginBottom: '8px', overflow: 'hidden' }); // Summary row const summary = document.createElement('div'); Object.assign(summary.style, { padding: '10px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', cursor: 'pointer' }); summary.innerHTML = `<span>Candidate ${idx+1}</span><span style="font-size:12px; opacity:.7;">▼</span>`; panel.appendChild(summary); // 3-line preview const preview = document.createElement('pre'); const lines = c.syncedLyrics ? c.syncedLyrics.trim().split('\n').slice(0, 3) : c.plainLyrics.trim().split('\n').slice(0, 3); preview.textContent = lines.join('\n'); Object.assign(preview.style, { margin: '0 12px 8px', padding: '0', fontSize: '12px', lineHeight: '1.2', color: '#ccc' }); panel.appendChild(preview); // Body (hidden full lyrics) const body = document.createElement('pre'); body.textContent = c.syncedLyrics ? c.syncedLyrics.trim() : c.plainLyrics.trim(); Object.assign(body.style, { margin: 0, padding: '8px 12px', fontSize: '13px', lineHeight: '1.4', whiteSpace: 'pre-wrap', display: 'none', background: '#2b2b2b' }); panel.appendChild(body); // Toggle on click summary.onclick = () => { const isOpen = body.style.display === 'block'; body.style.display = isOpen ? 'none' : 'block'; summary.querySelector('span:last-child').textContent = isOpen ? '▼' : '▲'; }; list.appendChild(panel); }); // Footer with offset input + buttons const footer = document.createElement('div'); Object.assign(footer.style, { padding: '12px 16px', borderTop: '1px solid #444', display: 'flex', alignItems: 'center', gap: '8px', flexWrap: 'wrap' }); // Offset const offLabel = document.createElement('label'); offLabel.textContent = 'Offset (ms):'; Object.assign(offLabel.style, { fontSize: '14px' }); const offInput = document.createElement('input'); offInput.type = 'number'; offInput.value = lyricsConfig[trackKey]?.offset || 0; Object.assign(offInput.style, { width: '60px', padding: '4px', borderRadius: '4px', border: '1px solid #555', background: '#444', color: '#fff' }); footer.appendChild(offLabel); footer.appendChild(offInput); // Manual Search button const searchBtn = document.createElement('button'); searchBtn.textContent = 'Manual Search'; Object.assign(searchBtn.style, { padding: '6px 12px', background: 'none', color: '#fff', border: '2px solid #555', borderRadius: '4px', cursor: 'pointer' }); searchBtn.onclick = () => { const manualQuery = prompt('Enter manual search query (e.g., song title and artist):'); if (manualQuery && manualQuery.trim() !== '') { overlay.remove(); menu.remove(); loadLyrics('', '', '', currentTrackDur, (parsed) => { lyricsData = parsed; renderLyrics(0); if (currInf) { setupProgressSync(currInf.bar, currInf.duration); } }, { flag: true, query: manualQuery }); } }; footer.appendChild(searchBtn); // Reset Pick button const resetBtn = document.createElement('button'); resetBtn.textContent = 'Reset Pick'; Object.assign(resetBtn.style, { padding: '6px 12px', background: 'none', color: '#fff', border: '2px solid #555', borderRadius: '4px', cursor: 'pointer' }); resetBtn.onclick = () => { delete lyricsConfig[trackKey]; localStorage.setItem(CONFIG_KEY, JSON.stringify(lyricsConfig)); }; footer.appendChild(resetBtn); // Use Selected button const useBtn = document.createElement('button'); useBtn.textContent = 'Use Selected'; Object.assign(useBtn.style, { padding: '6px 12px', background: 'none', color: '#fff', border: '2px solid #333', borderRadius: '4px', cursor: 'pointer' }); useBtn.onclick = () => { const openBodies = Array.from(list.children) .filter(p => p.querySelector('pre:last-of-type').style.display === 'block'); let rawLrc = openBodies.length ? openBodies[0].querySelector('pre:last-of-type').textContent : lastCandidates[0].syncedLyrics; const offset = parseInt(offInput.value, 10) || 0; lyricsConfig[trackKey] = { manualLrc: rawLrc, offset }; localStorage.setItem(CONFIG_KEY, JSON.stringify(lyricsConfig)); overlay.remove(); menu.remove(); const [t, a] = trackKey.split('|'); loadLyrics(t, a, '', 0, parsed => { lyricsData = parsed; renderLyrics(0); setupProgressSync(null, 0); }); }; footer.appendChild(useBtn); menu.appendChild(footer); } // --- Panel creation and drag/resize logic --- function createPanel() { document.getElementById('tm-lyrics-overlay')?.remove(); const overlay = document.createElement('div'); overlay.id = 'tm-lyrics-overlay'; Object.assign(overlay.style, { position: 'fixed', top: '0', left: '0', width: '100vw', height: '100vh', zIndex: 9998, pointerEvents: 'none' }); const panel = document.createElement('div'); panel.id = 'tm-lyrics-panel'; Object.assign(panel.style, { position: 'fixed', width: '470px', height: '390px', minWidth: '470px', minHeight: '390px', boxShadow: '0 4px 20px rgba(0,0,0,0.4)', borderRadius: '10px', fontSize: '25px', lineHeight: '1.6', padding: '0', overflow: 'hidden', pointerEvents: 'auto', userSelect: 'none', zIndex: 9999, border: '2px solid #333', display: 'flex', flexDirection: 'column' }); const defaultPos = { left: '100px', top: '100px' }; const savedPos = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null'); panel.style.left = (savedPos && savedPos.left) ? savedPos.left : defaultPos.left; panel.style.top = (savedPos && savedPos.top) ? savedPos.top : defaultPos.top; const savedSize = JSON.parse(localStorage.getItem(SIZE_KEY) || 'null'); if (savedSize && savedSize.width && savedSize.height) { panel.style.width = savedSize.width; panel.style.height = savedSize.height; } const header = document.createElement('div'); header.id = 'tm-lyrics-header'; Object.assign(header.style, { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '7px 14px', cursor: 'move', userSelect: 'none', borderTopLeftRadius: '10px', borderTopRightRadius: '10px', flexShrink: 0 }); const title = document.createElement('span'); title.id = 'tm-header-title'; title.innerHTML = dragLocked ? '<b>Lyrics (Locked)</b>' : '<b>Lyrics</b>'; header.appendChild(title); detectLongClick(title, toggleLogVisibility, null, 1000); const controls = document.createElement('div'); Object.assign(controls.style, { display: 'flex', gap: '8px', alignItems: 'center' }); const opDown = document.createElement('button'); opDown.textContent = '- Opacity'; opDown.addEventListener('click', () => { currentOpacity = Math.max(0.2, parseFloat((currentOpacity - 0.1).toFixed(2))); localStorage.setItem(OPACITY_KEY, currentOpacity); applyTheme(panel); }); const opUp = document.createElement('button'); opUp.textContent = '+ Opacity'; opUp.addEventListener('click', () => { currentOpacity = Math.min(1, parseFloat((currentOpacity + 0.1).toFixed(2))); localStorage.setItem(OPACITY_KEY, currentOpacity); applyTheme(panel); }); const manualBtn = document.createElement('button'); manualBtn.textContent = 'Manual LRC'; manualBtn.onclick = () => { const trackKey = currentTrackId; showManualLyricsMenu(trackKey); }; const ghIcon = document.createElement('div'); Object.assign(ghIcon.style, { display: 'flex', alignItems: 'center', paddingTop: '5px', fontSize: '14px' }); ghIcon.innerHTML = `<a href="https://github.com/jayxdcode" target="_blank" title="View on GitHub" style="opacity:0.8; color:white"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27s1.36.09 2 .27c1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8"/></svg></a>`; controls.append(manualBtn, opDown, opUp, ghIcon); header.appendChild(controls); controls.querySelectorAll('button').forEach(btn => Object.assign(btn.style, { background: 'transparent', color: '#fff', border: '2px solid #333', borderRadius: '4px', padding: '6px 10px', fontSize: '14px', cursor: 'pointer', transition: 'opacity 0.2s' })); const content = document.createElement('div'); content.id = 'tm-lyrics-lines'; Object.assign(content.style, { padding: '12px', overflowY: 'auto', scrollBehavior: 'smooth', flex: '1 1 auto', minHeight: '0' }); content.innerHTML = '<em>Lyrics will appear here</em>'; const resizeHandle = document.createElement('div'); resizeHandle.id = 'tm-lyrics-resize'; Object.assign(resizeHandle.style, { position: 'absolute', right: '1px', bottom: '.5px', width: '18px', height: '18px', cursor: 'nwse-resize', background: 'linear-gradient(135deg,transparent 60%,#888 60%)', opacity: 1 }); panel.appendChild(header); panel.appendChild(content); panel.appendChild(resizeHandle); overlay.appendChild(panel); document.body.appendChild(overlay); applyTheme(panel); // Drag logic let dragX = 0, dragY = 0; header.addEventListener('mousedown', e => { if (dragLocked) return; isDragging = true; dragX = e.clientX - panel.offsetLeft; dragY = e.clientY - panel.offsetTop; document.body.style.userSelect = 'none'; }); document.addEventListener('mousemove', e => { if (!isDragging) return; let x = e.clientX - dragX; let y = e.clientY - dragY; x = Math.min(Math.max(0, x), window.innerWidth - panel.offsetWidth); y = Math.min(Math.max(0, y), window.innerHeight - panel.offsetHeight); panel.style.left = x + 'px'; panel.style.top = y + 'px'; }); document.addEventListener('mouseup', () => { if (!isDragging) return; isDragging = false; document.body.style.userSelect = ''; localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: panel.style.left, top: panel.style.top })); }); // Touch drag header.addEventListener('touchstart', e => { if (dragLocked) return; const t = e.touches[0]; isDragging = true; dragX = t.clientX - panel.offsetLeft; dragY = t.clientY - panel.offsetTop; document.body.style.userSelect = 'none'; }, { passive: false }); document.addEventListener('touchmove', e => { if (!isDragging) return; const t = e.touches[0]; let x = t.clientX - dragX; let y = t.clientY - dragY; x = Math.min(Math.max(0, x), window.innerWidth - panel.offsetWidth); y = Math.min(Math.max(0, y), window.innerHeight - panel.offsetHeight); panel.style.left = x + 'px'; panel.style.top = y + 'px'; }, { passive: false }); document.addEventListener('touchend', () => { if (!isDragging) return; isDragging = false; document.body.style.userSelect = ''; localStorage.setItem(STORAGE_KEY, JSON.stringify({ left: panel.style.left, top: panel.style.top })); }); // Resize logic let startW, startH, startX, startY; resizeHandle.addEventListener('mousedown', e => { isResizing = true; startW = panel.offsetWidth; startH = panel.offsetHeight; startX = e.clientX; startY = e.clientY; e.preventDefault(); e.stopPropagation(); }); document.addEventListener('mousemove', e => { if (!isResizing) return; let w = Math.max(200, startW + e.clientX - startX); let h = Math.max(120, startH + e.clientY - startY); w = Math.min(w, window.innerWidth - panel.offsetLeft); h = Math.min(h, window.innerHeight - panel.offsetTop); panel.style.width = w + 'px'; panel.style.height = h + 'px'; }); document.addEventListener('mouseup', () => { if (!isResizing) return; isResizing = false; localStorage.setItem(SIZE_KEY, JSON.stringify({ width: panel.style.width, height: panel.style.height })); }); resizeHandle.addEventListener('touchstart', e => { const t = e.touches[0]; isResizing = true; startW = panel.offsetWidth; startH = panel.offsetHeight; startX = t.clientX; startY = t.clientY; e.preventDefault(); e.stopPropagation(); }, { passive: false }); document.addEventListener('touchmove', e => { if (!isResizing) return; const t = e.touches[0]; let w = Math.max(200, startW + t.clientX - startX); let h = Math.max(120, startH + t.clientY - startY); w = Math.min(w, window.innerWidth - panel.offsetLeft); h = Math.min(h, window.innerHeight - panel.offsetTop); panel.style.width = w + 'px'; panel.style.height = h + 'px'; }, { passive: false }); document.addEventListener('touchend', () => { if (!isResizing) return; isResizing = false; localStorage.setItem(SIZE_KEY, JSON.stringify({ width: panel.style.width, height: panel.style.height })); }); } function applyTheme(panel) { const header = panel.querySelector('#tm-lyrics-header'); if (currentTheme === 'light') { panel.style.background = `rgba(245, 245, 245, ${currentOpacity})`; panel.style.color = '#000'; if (header) header.style.background = `rgba(220, 220, 220, ${currentOpacity})`; } else { panel.style.background = `rgba(0, 0, 0, ${currentOpacity})`; panel.style.color = '#fff'; if (header) header.style.background = `rgba(33, 33, 33, ${currentOpacity})`; } } function gmFetch(url, headers = {}) { if (typeof GM_xmlhttpRequest === 'function') { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, headers, onload: res => resolve(res), onerror: err => reject(err), ontimeout: () => reject(new Error('Request timed out')) }); }); } else { // Use native fetch if GM_xmlhttpRequest is not available return fetch(url, { headers }) .then(response => { if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } return response; }) .catch(error => { throw new Error(`Fetch failed: ${error.message}`); }); } } function toggleLogVisibility() { const logs = document.getElementById('tm-logs'); logs.style.display = logVisible ? 'block' : 'none'; logVisible = logVisible ? false : true; } /** * Attaches a long click detection to a DOM element. * * @param {HTMLElement} element The DOM element to attach the listener to. * @param {function} onLongClick Callback function to execute when a long click is detected. * @param {function} [onShortClick] Optional callback for a short click. If not provided, * only long clicks will trigger a callback. * @param {number} [longClickThreshold=500] The duration in milliseconds to consider a click "long". */ function detectLongClick(element, onLongClick, onShortClick, longClickThreshold = 500) { let pressTimer; let isLongClickTriggered = false; // Flag to prevent short click after long click if (!element || typeof onLongClick !== 'function') { console.error("detectLongClick: Invalid element or onLongClick callback provided."); return; } const startTimer = () => { isLongClickTriggered = false; // Reset flag for new press pressTimer = setTimeout(() => { isLongClickTriggered = true; onLongClick(); }, longClickThreshold); }; const clearTimer = () => { clearTimeout(pressTimer); }; // --- Mouse Events --- element.addEventListener('mousedown', (event) => { // Prevent right-click from triggering long-click for mouse events if (event.button === 2) { return; } startTimer(); }); element.addEventListener('mouseup', () => { clearTimer(); // Only trigger short click if long click wasn't triggered if (!isLongClickTriggered && typeof onShortClick === 'function') { onShortClick(); } }); // If mouse leaves the element while pressed (important to clear timer) element.addEventListener('mouseleave', () => { clearTimer(); // Reset long click flag if mouse leaves, preventing accidental short click if re-entered isLongClickTriggered = false; }); // --- Touch Events --- // Using passive: true for better scroll performance. If you need to prevent default // browser behavior (like scrolling/zooming on touch), set to false and handle `event.preventDefault()`. element.addEventListener('touchstart', (event) => { // event.preventDefault(); // Uncomment if you need to prevent default touch behaviors startTimer(); }, { passive: true }); element.addEventListener('touchend', () => { clearTimer(); if (!isLongClickTriggered && typeof onShortClick === 'function') { onShortClick(); } }, { passive: true }); element.addEventListener('touchcancel', () => { clearTimer(); isLongClickTriggered = false; // Reset if touch is interrupted (e.g., phone call) }, { passive: true }); } async function parseAl(url = null) { try { if (!url) return ''; const res = await fetch(url); const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const titleText = doc.querySelector('title')?.textContent; if (titleText) { const match = titleText.match(/^(.*?) - Album by .*? \| Spotify$/); if (match && match.length > 1) return match[1]; } } catch (e) { debug('parseAl error:', e); } return ''; } /** * [RESTORED] Fetch up to 3 Genius English Translation links via strict Google search. */ async function fetchStrictGeniusLinks(title, artist) { if (!title || !artist) return []; const query = `site:genius.com ${title} ${artist} "(english translation)"`; const googleSearchUrl = `https://www.google.com/search?q=${encodeURIComponent(query)}`; debug(`🔍 Google search for Genius link: ${googleSearchUrl}`); try { const searchRes = await gmFetch(googleSearchUrl, { 'User-Agent': 'Mozilla/5.0' }); const doc = new DOMParser().parseFromString(searchRes.responseText, 'text/html'); const anchors = Array.from(doc.querySelectorAll('a[href^="/url?q=https://genius.com/Genius-english-translations-"]')); const geniusLinks = []; for (let a of anchors) { if (geniusLinks.length >= 3) break; const href = a.getAttribute('href'); const match = href.match(/\/url\?q=(https:\/\/genius\.com\/Genius-english-translations-[^&]+)/i); if (match && match[1]) { const decoded = decodeURIComponent(match[1]); if (!geniusLinks.includes(decoded)) geniusLinks.push(decoded); } } debug('✅ Found Genius links via Google:', geniusLinks); return geniusLinks; } catch (err) { debug('[⚠️ WARNING] ❌ Failed to fetch Google search results:', err); return []; } } /** * [NEW & FIXED] Scrapes the lyrics from a given Genius URL. */ async function scrapeGeniusUrl(url) { if (!url) return null; debug(`📄 Scraping Genius URL: ${url}`); try { const pageRes = await gmFetch(url, { 'User-Agent': 'Mozilla/5.0' }); const doc = new DOMParser().parseFromString(pageRes.responseText, 'text/html'); const containers = doc.querySelectorAll('div[data-lyrics-container="true"]'); if (!containers.length) throw new Error('No lyrics containers found on page.'); const blocks = []; containers.forEach(div => { const clone = div.cloneNode(true); clone.querySelectorAll('[data-exclude-from-selection="true"]').forEach(e => e.remove()); blocks.push(clone.innerText.trim()); }); const lyrics = blocks.join('\n\n').trim(); debug('✅ Successfully scraped lyrics from Genius page.'); return lyrics; } catch (err) { debug(`[⚠️ WARNING] ❌ Failed to scrape Genius URL ${url}:`, err); return null; } } async function fetchTranslations(lrcText, geniusTr, title, artist) { try { const response = await fetch(BACKEND_URL, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ lrcText, geniusLyrics: geniusTr, title, artist }), // Changed key to match backend }); if (!response.ok) { const errorBody = await response.text(); debug('[❗ERROR] Backend server returned an error:', response.status, errorBody); return { rom: "", transl: "" }; } const data = await response.json(); debug('Received backend data:', data); return data; } catch (error) { debug('[❗ERROR] Failed to fetch from backend server:', error); return { rom: "", transl: "" }; } } function parseLRCToArray(lrc) { if (!lrc) return []; const lines = []; const regex = /\[(\d+):(\d+)(?:\.(\d+))?\](.*)/g; for (const raw of lrc.split('\n')) { let matches, l = raw; while ((matches = regex.exec(l)) !== null) { const time = parseInt(matches[1], 10) * 60000 + parseInt(matches[2], 10) * 1000 + (matches[3] ? parseInt(matches[3].padEnd(3, '0'), 10) : 0); lines.push({ time, text: l.replace(/\[\d+:\d+(?:\.\d+)?\]/g, '').trim() }); } regex.lastIndex = 0; } lines.sort((a, b) => a.time - b.time); if (lines.length && lines[0].time !== 0) lines.unshift({ time: 0, text: '' }); return lines; } function mergeLRC(origArr, romArr, transArr) { const romMap = new Map(romArr.map(r => [r.time, r.text])); const transMap = new Map(transArr.map(t => [t.time, t.text])); return origArr.map(o => ({ time: o.time, text: o.text, roman: romMap.get(o.time) || '', trans: transMap.get(o.time) || '' })); } function parseLRC(lrc, romLrc, translLrc) { return mergeLRC(parseLRCToArray(lrc), parseLRCToArray(romLrc), parseLRCToArray(translLrc)); } async function loadLyrics(title, artist, album, duration, onTransReady, manual = { flag: false, query: "" }) { if (!manual.flag) debug('Searching for lyrics:', title, artist, album, duration); else debug(`Manually searching lyrics: using user prompt "${manual.query}"...`); const trackKey = `${title}|${artist}`; let geniusLyrics = null; try { // --- 0) Attempt to get Genius lyrics first --- /* PLACEHOLDER if (!manual.flag) { const geniusLinks = await fetchStrictGeniusLinks(title, artist); if (geniusLinks.length > 0) { geniusLyrics = await scrapeGeniusUrl(geniusLinks[0]); } } */ // --- 1) Manual override check --- if (lyricsConfig[trackKey]?.manualLrc && !manual.flag) { const { manualLrc, offset = 0 } = lyricsConfig[trackKey]; onTransReady(parseLRC(manualLrc, '', '').map(l => ({ ...l, time: l.time + offset }))); const { rom, transl } = await fetchTranslations(manualLrc, geniusLyrics, title, artist); onTransReady(parseLRC(manualLrc, rom, transl).map(l => ({ ...l, time: l.time + offset }))); const searchRes = await fetch(`https://lrclib.net/api/search?q=${encodeURIComponent([title, artist, album].join(' '))}`); if (searchRes.ok) lastCandidates = await searchRes.json(); return; } // --- 2) Fetch from lrclib (with fallback) --- const primaryMetadata = manual.flag ? manual.query : [title, artist, album].filter(Boolean).join(' '); let searchRes = await fetch(`https://lrclib.net/api/search?q=${encodeURIComponent(primaryMetadata)}`); if (!searchRes.ok) throw new Error('lrclib search failed'); let searchData = await searchRes.json(); if (!Array.isArray(searchData) || !searchData.some(c => c.syncedLyrics)) { if (!manual.flag && album) { debug('Retrying lrclib search without album.'); const fallbackRes = await fetch(`https://lrclib.net/api/search?q=${encodeURIComponent([title, artist].join(' '))}`); if (fallbackRes.ok) searchData = await fallbackRes.json(); } } lastCandidates = Array.isArray(searchData) ? searchData : []; // --- 3) Pick best candidate --- let candidate = null, minDelta = Infinity; lastCandidates.filter(c => c.syncedLyrics).forEach(c => { const delta = Math.abs(Number(c.duration) - duration); if (delta < minDelta && delta < 8000) { candidate = c; minDelta = delta; } }); if (!candidate && lastCandidates.length > 0) candidate = lastCandidates[0]; if (!candidate || (!candidate.syncedLyrics && !candidate.plainLyrics)) { onTransReady([{ time: 0, text: 'Failed to find any lyrics for this track.', roman: '', trans: '' }]); return; } // --- 4) Process candidate and get translations --- const rawLrc = candidate.syncedLyrics || `[00:00.01] ${candidate.plainLyrics}`; onTransReady(parseLRC(rawLrc, '', '')); // Render original lyrics immediately const { rom, transl } = await fetchTranslations(rawLrc, geniusLyrics, title, artist); onTransReady(parseLRC(rawLrc, rom, transl)); } catch (e) { // alert(`Error while displaying lrc: ${e} \n\n\n Please report this to \n\nhttps://github.com/jayxdcode/src-backend/issues\n\nalongside with a screenshot of this alert.`); debug('[❗ERROR] [Lyrics] loadLyrics error:', `${e}`); onTransReady([{ time: 0, text: 'An error occurred while loading lyrics.', roman: '', trans: '' }]); } } function parseTimeString(str) { if (!str) return 0; const parts = str.split(':').map(Number); return parts.length === 2 ? (parts[0] * 60 + parts[1]) * 1000 : (parts.length === 3 ? (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000 : 0); } function addTimeJumpListener() { const lyricLinesWithTimestamp = document.querySelectorAll('#tm-lyrics-lines [timestamp]'); lyricLinesWithTimestamp.forEach(element => { const timestampValue = element.getAttribute('timestamp'); if (timestampValue) { element.addEventListener('click', () => timeJump(timestampValue)); } }); } async function getTrackInfo() { const bar = document.querySelector('[data-testid="now-playing-bar"], [data-testid="main-view-player-bar"], [data-testid="bottom-bar"], footer'); if (!bar) return null; const titleEl = bar.querySelector('[data-testid="context-item-info-title"] [data-testid="context-item-link"], [data-testid="nowplaying-track-link"], [data-testid="now-playing-widget-title"] a, .track-info__name a'); const artistEl = bar.querySelector('[data-testid="context-item-info-artist"], [data-testid="nowplaying-artist"], [data-testid="now-playing-widget-artist"] a, .track-info__artists a'); const title = titleEl?.textContent.trim() || ''; const artist = artistEl?.textContent.trim() || ''; const album = titleEl?.href ? await parseAl(titleEl.href) : ''; const durationEl = bar.querySelector('[data-testid="playback-duration"], [data-testid="playback_duration"]'); const duration = durationEl ? parseTimeString(durationEl.textContent.trim()) : null; currentTrackDur = duration; return { id: title + '|' + artist, title, artist, album, duration, bar }; } function getProgressBarTimeMs(bar, durationMs) { if (!bar || !durationMs) return 0; const pbar = bar.querySelector('[data-testid="progress-bar"]'); if (pbar?.style) { const match = pbar.style.cssText.match(/--progress-bar-transform:\s*([\d.]+)%/); if (match?.[1]) return durationMs * parseFloat(match[1]) / 100; } const slider = bar.querySelector('div[role="slider"][aria-valuenow]'); if (slider) return Number(slider.getAttribute('aria-valuenow')); const input = bar.querySelector('input[type="range"]'); if (input) return durationMs * Number(input.value) / Number(input.max); const posEl = bar.querySelector('[data-testid="player-position"]'); if (posEl) return parseTimeString(posEl.textContent.trim()); return 0; } function renderLyrics(currentIdx) { const linesDiv = document.getElementById('tm-lyrics-lines'); if (!linesDiv) return; let html = ''; const color = currentTheme === 'light' ? '#000' : '#fff'; const subColor = currentTheme === 'light' ? '#555' : '#ccc'; const start = Math.max(0, currentIdx - 70); const end = Math.min(lyricsData.length - 1, currentIdx + 70); for (let i = start; i <= end; i++) { const ln = lyricsData[i]; if (!ln.text && !ln.roman && !ln.trans) { html += `<div class="tm-lyric-line" style="min-height:1.6em;"> </div>`; continue; } const lineClass = i === currentIdx ? `tm-lrc-${i} tm-lyric-current` : `tm-lrc-${i} tm-lyric-line`; const lineStyle = `white-space: pre-wrap; color:${color}; ${i === currentIdx ? "font-weight:bold;" : "opacity:.7;"} margin:10px 0; min-height:1.6em; display:block;`; html += `<div class="${lineClass}" style="${lineStyle}" timestamp=${ln.time}>${ln.text || ' '}`; if (ln.roman && ln.text.trim() !== ln.roman.trim()) html += `<div style="font-size:.75em; color:${subColor}; margin-top:2px;">${ln.roman}</div>`; if (ln.trans && ln.text.trim() !== ln.trans.trim()) html += `<div style="font-size:.75em; color:${subColor}; margin-top:2px;">${ln.trans}</div>`; html += `</div>`; } linesDiv.innerHTML = html; const currElem = linesDiv.querySelector('.tm-lyric-current'); if (currElem) { linesDiv.scrollTop = currElem.offsetTop - linesDiv.clientHeight / 2 + currElem.offsetHeight / 2; } addTimeJumpListener(); } function syncLyrics(bar, durationMs) { if (!bar || !lyricsData || lyricsData.length === 0) return; let t = getProgressBarTimeMs(bar, durationMs); if (lyricsData.length === 1) { if (lastRenderedIdx !== 0) { renderLyrics(0); lastRenderedIdx = 0; } return; } let idx = lyricsData.findIndex((line, i) => i === lyricsData.length - 1 || (line.time <= t && t < lyricsData[i + 1].time)); if (idx === -1) idx = lyricsData.length - 1; if (idx !== lastRenderedIdx) { renderLyrics(idx); lastRenderedIdx = idx; } } function setupProgressSync(bar, durationMs) { if (!bar) return; if (observer) observer.disconnect(); if (syncIntervalId) clearInterval(syncIntervalId); const pbar = bar.querySelector('[data-testid="progress-bar"], div[role="slider"]'); if (pbar) { observer = new MutationObserver(() => syncLyrics(bar, durationMs)); observer.observe(pbar, { attributes: true, attributeFilter: ['style', 'aria-valuenow'] }); } syncIntervalId = setInterval(() => syncLyrics(bar, durationMs), 300); } /* LRC time jump logic */ // window.timeJump = function timeJump(timestamp) { const progressInput = document.querySelector("[data-testid='playback-progressbar'] input"); const minOffset = 100; const maxOffset = 200; const randOffset = Math.random() * (maxOffset - minOffset) + minOffset; const seekTo = Math.min(timestamp, progressInput.max); progressInput.value = seekTo - randOffset; progressInput.dispatchEvent(new Event('input', { bubbles: true })); progressInput.dispatchEvent(new Event('change', { bubbles: true })); }; async function poller() { try { const info = await getTrackInfo(); if (!info || !info.title || !info.artist) return; if (info.id !== currentTrackId) { debug('Track changed:', info.title, '-', info.artist); currentTrackId = info.id; currInf = info; lyricsData = null; lastRenderedIdx = -1; const lines = document.getElementById('tm-lyrics-lines'); if (lines) lines.innerHTML = '<em>Loading lyrics...</em>'; await loadLyrics(info.title, info.artist, info.album, info.duration, (parsed) => { lyricsData = parsed; renderLyrics(0); setupProgressSync(info.bar, info.duration); }); } } catch (e) { debug('[❗ERROR] [Poller Error]', e); } } /** * Creates and appends a hidden <div> to the page to serve as a log container. * This is called once during the script's initialization. */ function setupLogElement() { // Create the main container for logs const logs = document.createElement('div'); logs.id = 'tm-logs'; // Style it to be hidden by default but available for inspection Object.assign(logs.style, { position: 'fixed', bottom: '10px', right: '10px', width: '400px', height: '300px', background: 'rgba(0, 0, 0, 0.8)', color: '#0f0', fontFamily: 'monospace', fontSize: '12px', zIndex: '10001', overflowY: 'scroll', padding: '10px', border: '1px solid #333', borderRadius: '5px', display: 'none' // Hidden by default }); // Add it to the page document.body.appendChild(logs); console.log('[Lyrics] Log element created. To view it, run this in the console:'); console.log("document.getElementById('tm-logs').style.display = 'block';"); } // Example of how to call it when your script starts: // setupLogElement(); // function debug(...args) { console.log('[Lyrics]', ...args); } /** * Logs messages to the console and a dedicated <div> for on-page debugging. * @param {...any} args - The values to log. */ function debug(...args) { // Also log to the standard developer console console.log('[Lyrics]', ...args); // Find the log container element on the page const logs = document.body.querySelector('#tm-logs'); if (logs) { // Format arguments for HTML display, handling objects with JSON.stringify const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg, null, 2) : arg).join(' '); // Append the new log message logs.innerHTML += `<div style='margin: .75em;'>${message}</div>`; // Auto-scroll to the bottom logs.scrollTop = logs.scrollHeight; } } function init() { setupLogElement(); debug('Initializing Lyrics Panel'); createPanel(); window.addEventListener('resize', debounce(handleViewportChange, 250)); setInterval(poller, POLL_INTERVAL); } // Wait for the main UI to be available before initializing const readyObserver = new MutationObserver((mutations, obs) => { if (document.querySelector('[data-testid="now-playing-bar"], [data-testid="main-view-player-bar"]')) { obs.disconnect(); init(); } }); readyObserver.observe(document.body, { childList: true, subtree: true }); // -- end -- })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址