// ==UserScript==
// @name YouTube Music / Spotify 网易云歌词显示
// @version 0.22
// @description 为 YTM 和 Spotify 添加网易云歌词 (带有翻译或者罗马音显示)
// @author Hyun
// @license MIT
// @match *://music.youtube.com/*
// @match *://open.spotify.com/*
// @icon https://music.163.com/favicon.ico
// @connect interface.music.163.com
// @grant GM.xmlHttpRequest
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.addElement
// @grant unsafeWindow
// @require https://fastly.jsdelivr.net/npm/[email protected]/crypto-js.min.js
// @run-at document-end
// @inject-into content
// @namespace https://greasyfork.org/users/718868
// ==/UserScript==
const EAPI_AES_KEY = 'e82ckenh8dichen8';
const EAPI_ENCODE_KEY = '3go8&$8*3*3h0k(2)2';
const EAPI_CHECK_TOKEN = '9ca17ae2e6ffcda170e2e6ee8ad85dba908ca4d74da9ac8ea2d44e938f9eadc66da5a8979af572a5a9b68ac12af0feaec3b92aa69af9b1d372f6b8adccb35e968b9bb6c14f908d0099fb6ff48efdacd361f5b6ee9e';
const EAPI_BASE_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) NeteaseMusicDesktop/3.0.14.2534',
};
const EAPI_BASE_COOKIES = {
"os": "osx",
"appver": "3.0.14",
"requestId": 0,
"osver": "15.6.1",
};
// Setup Trusted Types policy
window.trustedTypes && window.trustedTypes.createPolicy('default', {
createScriptURL: s => s,
createScript: s => s,
createHTML: s => s
});
// A simple cookie jar using GM storage
class CookieJar {
constructor(storageKey) {
this.storageKey = storageKey;
}
async get(url) {
const allCookies = await GM.getValue(this.storageKey, {});
const now = Date.now();
const { hostname, pathname } = new URL(url);
const matchingCookies = [];
let needsSave = false;
for (const domain in allCookies) {
if (!hostname.endsWith(domain)) continue;
const domainJar = allCookies[domain];
for (const path in domainJar) {
if (!pathname.startsWith(path)) continue;
const pathJar = domainJar[path];
for (const name in pathJar) {
const cookie = pathJar[name];
if (cookie.expires && cookie.expires < now) {
delete pathJar[name];
needsSave = true;
} else {
matchingCookies.push(`${name}=${cookie.value}`);
}
}
if (Object.keys(pathJar).length === 0) delete domainJar[path];
}
if (Object.keys(domainJar).length === 0) delete allCookies[domain];
}
if (needsSave) await GM.setValue(this.storageKey, allCookies);
return matchingCookies.join('; ');
}
async set(url, setCookieHeader) {
if (!setCookieHeader) return;
const allCookies = await GM.getValue(this.storageKey, {});
const { hostname } = new URL(url);
const parts = setCookieHeader.split(';').map(p => p.trim());
const [name, value] = parts[0].split('=').map(s => s.trim());
if (!name) return;
const cookie = { value };
let domain = hostname;
let path = '/';
parts.slice(1).forEach(part => {
let [key, val] = part.split('=').map(s => s.trim());
switch (key.toLowerCase()) {
case 'expires': cookie.expires = new Date(val).getTime(); break;
case 'max-age': cookie.expires = Date.now() + (parseInt(val, 10) * 1000); break;
case 'path': path = val; break;
case 'domain': domain = val.startsWith('.') ? val.substring(1) : val; break;
}
});
allCookies[domain] = allCookies[domain] || {};
allCookies[domain][path] = allCookies[domain][path] || {};
allCookies[domain][path][name] = cookie;
await GM.setValue(this.storageKey, allCookies);
}
}
const cookieJar = new CookieJar('lyrics.eapi.cookies');
// Netease Cloud Music API (EAPI)
const eapi = (path, options={}) => new Promise(async (resolve, reject) => {
const { data = {}, headers = {}, header = {}, cookies = {}, params = {} } = options;
Object.assign(header, EAPI_BASE_COOKIES);
Object.assign(headers, EAPI_BASE_HEADERS);
Object.assign(cookies, EAPI_BASE_COOKIES);
const queryStr = new URLSearchParams(params).toString();
const url = `https://interface.music.163.com/eapi${path}${queryStr ? `?${queryStr}` : ''}`;
const storedCookies = await cookieJar.get(url);
if (storedCookies) {
storedCookies.split('; ').forEach(c => {
const [k, v] = c.split('=', 2);
if (k) cookies[k] = v;
});
}
data.header = JSON.stringify(header);
const body = JSON.stringify(data);
const sign = CryptoJS.MD5(`nobody/api${path}use${body}md5forencrypt`).toString();
GM.xmlHttpRequest({
url,
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/x-www-form-urlencoded',
'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; ')
},
data: `params=${encodeURIComponent(
CryptoJS.AES.encrypt(
`/api${path}-36cd479b6b5-${body}-36cd479b6b5-${sign}`,
CryptoJS.enc.Utf8.parse(EAPI_AES_KEY),
{ mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }
).toString(CryptoJS.format.Hex).toUpperCase())
}`,
responseType: 'json',
anonymous: true,
onerror: reject,
ontimeout: reject,
onload: async (response) => {
if (response.responseHeaders) {
const setCookieHeaders = response.responseHeaders.split('\r\n')
.filter(h => h.toLowerCase().startsWith('set-cookie:'))
.map(h => h.substring(h.indexOf(':') + 1).trim());
for (const header of setCookieHeaders) {
const cookieStrings = header.split(/,(?=\s*[^=;\s]+=)/);
for (const cookieStr of cookieStrings) {
if (cookieStr) await cookieJar.set(url, cookieStr.trim());
}
}
}
if (response.status >= 200 && response.status < 300) {
const res = response.response;
if (res.code !== 200) {
reject(new Error(res.message));
return;
}
resolve(res);
} else {
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
}
}
})
});
const cloudmusic = {
register: (deviceId) => {
const encode = (some_id) => {
let xoredString = '';
for (let i = 0; i < some_id.length; i++) {
const charCode = some_id.charCodeAt(i) ^ EAPI_ENCODE_KEY.charCodeAt(i % EAPI_ENCODE_KEY.length);
xoredString += String.fromCharCode(charCode);
}
const wordArray = CryptoJS.enc.Utf8.parse(xoredString);
return btoa(`${some_id} ${CryptoJS.MD5(wordArray).toString(CryptoJS.enc.Base64)}`);
}
return eapi('/register/anonimous', {
data: {
username: encode(deviceId),
},
params: {
'_nmclfl': '1'
}
});
},
search: (keyword, limit = 10) => eapi('/search/song/list/page', {
data: {
offset: '0',
scene: 'NORMAL',
needCorrect: 'true',
checkToken: EAPI_CHECK_TOKEN,
keyword,
limit: limit.toString(),
verifyId: 1
},
headers: {
'X-Anticheattoken': EAPI_CHECK_TOKEN
},
params: {
'_nmclfl': '1'
}
}),
lyric: (id) => eapi('/song/lyric/v1', {
data: {
id, tv: "-1", yv: "-1", rv: "-1", lv: "-1",
verifyId: 1
},
params: {
'_nmclfl': '1'
}
})
}
// Floating Lyrics UI
const lyricsUI = {
update(lyricsData) {},
show() {},
hide() {},
tick(currentTimeMs) {},
updateSources(sources) {},
onSourceSelect(id) {},
};
async function injectUI() {
const panelHTML = `
<div id="lyric-float" aria-label="Floating lyrics panel" style="display: none;">
<div class="drag-handle" id="lyric-dragHandle" title="Drag to move • Right-click or long-press for options">
<span class="handle-dots" aria-hidden="true"></span>
<span class="brand">Floating Lyrics</span>
</div>
<div class="lyrics-scroll">
<div class="fade-top"></div>
<div class="list" id="lyric-list"></div>
<div class="fade-bottom"></div>
</div>
<div class="resize-handle" id="lyric-resizeHandle"></div>
</div>
`;
const menuHTML = `
<div class="ctx-menu" id="lyric-menu" role="menu" aria-hidden="true">
<div class="menu-title">Settings</div>
<div class="menu-group">
<div class="menu-row">
<div class="menu-label">Display</div>
<div class="mode-wrap" id="lyric-modeWrap">
<label class="radio"><input type="radio" name="mode" value="orig">Original only</label>
<label class="radio"><input type="radio" name="mode" value="orig+trans">Original + Translation</label>
<label class="radio"><input type="radio" name="mode" value="orig+roma">Original + Romanized</label>
</div>
</div>
</div>
<div class="menu-group">
<div class="menu-row">
<div class="menu-label">Source</div>
<select id="lyric-sourceSelect"></select>
</div>
</div>
<div class="menu-group">
<div class="menu-row">
<div class="menu-label">Font size</div>
<button class="btn small" id="lyric-fontMinus">–</button>
<input class="slider" id="lyric-fontSlider" type="range" min="16" max="64" step="1">
<button class="btn small" id="lyric-fontPlus">+</button>
<div class="value" id="lyric-fontValue">28px</div>
</div>
</div>
<div class="menu-group">
<div class="menu-row">
<div class="menu-label">Offset</div>
<div class="offset-buttons" id="lyric-offsetButtons">
<button class="btn" data-delta="-500">−0.50s</button>
<button class="btn" data-delta="-100">−0.10s</button>
<button class="btn" data-reset="1">Reset</button>
<button class="btn" data-delta="100">+0.10s</button>
<button class="btn" data-delta="500">+0.50s</button>
</div>
<div class="value" id="lyric-offsetValue">+0.00s</div>
</div>
<div class="hint">Tip: Offsets shift when each line appears.</div>
</div>
</div>
`;
const styles = `
:host {
--font-size: 28px; --accent-1: #8be9fd; --accent-2: #ff79c6;
--panel-bg: rgba(0, 0, 0, 0.8); --panel-br: rgba(255, 255, 255, 0.18);
--shadow-1: rgba(0, 0, 0, 0.45); --shadow-2: rgba(0, 0, 0, 0.65);
--handle-h: 22px;
}
#lyric-float {
position: fixed; z-index: 99999; left: calc(50% - 300px); top: 18%;
width: 600px; height: 280px; min-width: 260px; min-height: 140px;
max-width: calc(100vw - 12px); max-height: calc(100vh - 12px);
/* resize: both; */ overflow: hidden; border-radius: 16px; background: var(--panel-bg);
border: 1px solid var(--panel-br); backdrop-filter: blur(14px) saturate(140%);
-webkit-backdrop-filter: blur(14px) saturate(140%);
box-shadow: 0 10px 30px var(--shadow-1), 0 30px 60px var(--shadow-2), inset 0 1px 0 rgba(255,255,255,0.08);
user-select: none; transition: box-shadow 160ms ease;
}
#lyric-float.dragging {
box-shadow: 0 6px 16px rgba(0,0,0,0.55), 0 20px 40px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.06);
}
.drag-handle {
position: absolute; inset: 0 0 auto 0; height: var(--handle-h); display: flex;
align-items: center; gap: 6px; padding: 0 10px; cursor: move; color: rgba(255,255,255,0.7);
font-size: 11px; letter-spacing: .3px;
background: linear-gradient(to bottom, rgba(255,255,255,0.2), rgba(255,255,255,0));
border-bottom: 1px solid rgba(255,255,255,0.06); touch-action: none;
}
.handle-dots {
display: inline-block; width: 14px; height: 4px;
background: radial-gradient(circle at 2px 2px, rgba(255,255,255,0.6) 2px, transparent 3px), radial-gradient(circle at 7px 2px, rgba(255,255,255,0.35) 2px, transparent 3px), radial-gradient(circle at 12px 2px, rgba(255,255,255,0.2) 2px, transparent 3px);
filter: drop-shadow(0 1px 1px rgba(0,0,0,0.4));
}
.brand { display: none; }
.lyrics-scroll { position: absolute; inset: var(--handle-h) 0 0 0; overflow: hidden; }
.resize-handle {
position: absolute; right: 0; bottom: 0;
width: 24px; height: 24px; cursor: se-resize;
z-index: 10; touch-action: none;
}
.resize-handle::after {
content: ''; position: absolute; right: 4px; bottom: 4px;
width: 8px; height: 8px; border-right: 2px solid rgba(255,255,255,0.4);
border-bottom: 2px solid rgba(255,255,255,0.4);
border-bottom-right-radius: 3px; pointer-events: none;
}
.fade-top, .fade-bottom { position: absolute; left: 0; right: 0; pointer-events: none; }
.fade-top { top: 0; height: 46px; background: linear-gradient(to bottom, rgba(5,7,15,0.9), rgba(5,7,15,0)); }
.fade-bottom { bottom: 0; height: 46px; background: linear-gradient(to top, rgba(5,7,15,0.9), rgba(5,7,15,0)); }
.list { position: absolute; inset: 0; overflow: auto; scroll-behavior: smooth; padding: 10px 20px 16px; }
.group { padding: 4px 6px; }
.line {
font-size: var(--font-size); line-height: 1.35; color: rgba(255,255,255,0.55);
text-align: center; padding: 2px 8px; transition: color 140ms ease, transform 200ms ease, opacity 200ms ease;
text-shadow: 0 1px 0 rgba(0,0,0,0.5); white-space: pre-wrap; word-break: break-word;
}
.sub { font-size: calc(var(--font-size) * .72); opacity: .9; color: rgba(255,255,255,0.65); }
.line.active {
color: #fff; transform: scale(1.02);
text-shadow: 0 2px 10px rgba(0,0,0,0.5), 0 0 22px rgba(139,233,253,0.25);
background: linear-gradient(90deg, var(--accent-1), var(--accent-2));
-webkit-background-clip: text; background-clip: text; color: transparent;
filter: drop-shadow(0 0 10px rgba(139,233,253,0.08));
}
.ctx-menu {
position: fixed; z-index: 100000; min-width: 180px; padding: 8px 6px; border-radius: 10px;
background: rgba(20, 24, 35, 0.96); border: 1px solid rgba(255,255,255,0.08);
box-shadow: 0 16px 40px rgba(0,0,0,0.55); color: #eaeef7;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); display: none;
max-width: 92vw; max-height: calc(100vh - 16px); overflow: auto; box-sizing: border-box;
}
@media (pointer: coarse) {
.ctx-menu.open {
left: 0 !important; right: 0 !important; top: auto !important;
bottom: calc(env(safe-area-inset-bottom) + 8px) !important;
width: 100vw !important; max-width: 100vw !important; max-height: 60vh !important;
border-radius: 14px 14px 0 0 !important;
}
}
.ctx-menu.open { display: block; }
.menu-title { margin: 2px 8px 6px; font-size: 12px; opacity: .7; letter-spacing: .3px; }
.menu-group { padding: 4px 6px; }
.menu-row { display: flex; align-items: center; gap: 8px; padding: 4px 6px; flex-wrap: wrap; }
.menu-row + .menu-row { border-top: 1px dashed rgba(255,255,255,0.08); }
.menu-label { width: 56px; font-size: 13px; opacity: .7; }
.mode-wrap, .offset-buttons { display: flex; gap: 8px; flex-wrap: wrap; }
.btn {
appearance: none; border: 1px solid rgba(255,255,255,0.15);
background: linear-gradient(to bottom, rgba(255,255,255,0.08), rgba(255,255,255,0.02));
color: #eaf1ff; padding: 4px 7px; border-radius: 7px; font-size: 12px; cursor: pointer;
transition: transform 80ms ease, background 120ms ease, border-color 120ms ease;
}
.btn:hover { transform: translateY(-1px); border-color: rgba(255,255,255,0.25); }
.btn:active { transform: translateY(0); }
.btn.small { padding: 3px 6px; }
.radio {
display: inline-flex; align-items: center; gap: 8px; padding: 4px 8px;
border-radius: 8px; border: 1px solid transparent; cursor: pointer; font-size: 12px;
}
.radio input { accent-color: #6ee7ff; }
.radio.active { border-color: rgba(255,255,255,0.16); background: rgba(255,255,255,0.04); }
.slider { flex: 1; min-width: 0; }
.value { font-variant-numeric: tabular-nums; opacity: .8; min-width: 40px; text-align: right; }
.menu-row select, .menu-row input[type="range"] { flex: 1; min-width: 0; }
.hint { opacity: .55; font-size: 12px; margin: 6px 8px 0; text-align: right; }
.no-select, .no-select * { user-select: none !important; }
`;
const shadowHost = document.createElement('div');
shadowHost.id = 'lyrics-shadow-host';
const shadowRoot = shadowHost.attachShadow({ mode: 'open' });
document.body.appendChild(shadowHost);
const styleEl = document.createElement('style');
styleEl.textContent = styles;
shadowRoot.appendChild(styleEl);
shadowRoot.appendChild((new DOMParser().parseFromString(panelHTML, 'text/html')).body.firstChild);
shadowRoot.appendChild((new DOMParser().parseFromString(menuHTML, 'text/html')).body.firstChild);
await initUIController(shadowRoot, shadowHost);
}
async function initUIController(shadowRoot, shadowHost) {
const $ = (sel, root = shadowRoot) => root.querySelector(sel);
const $$ = (sel, root = shadowRoot) => Array.from(root.querySelectorAll(sel));
let timeline = [];
let mode = await GM.getValue('lyrics.mode', 'orig');
let offsetMs = +(await GM.getValue('lyrics.offsetMs', 0));
let fontSize = +(await GM.getValue('lyrics.fontSize', 28));
shadowHost.style.setProperty('--font-size', fontSize + 'px');
const panel = $('#lyric-float');
const saveBounds = async () => {
const r = panel.getBoundingClientRect();
await GM.setValue('lyrics.bounds', JSON.stringify({
x: r.left, y: r.top, w: r.width, h: r.height
}));
};
const clampPanelToViewport = () => {
const r = panel.getBoundingClientRect();
const L = Math.max(0, Math.min(r.left, innerWidth - r.width));
const T = Math.max(0, Math.min(r.top, innerHeight - r.height));
if (r.left !== L) panel.style.left = L + 'px';
if (r.top !== T) panel.style.top = T + 'px';
};
try {
const savedBoundsStr = await GM.getValue('lyrics.bounds');
const savedBounds = savedBoundsStr ? JSON.parse(savedBoundsStr) : null;
if (savedBounds && savedBounds.w > 50 && savedBounds.h > 50) {
panel.style.width = `${savedBounds.w}px`;
panel.style.height = `${savedBounds.h}px`;
panel.style.left = `${savedBounds.x}px`;
panel.style.top = `${savedBounds.y}px`;
requestAnimationFrame(clampPanelToViewport);
}
} catch (e) { console.error('[Lyrics] Error loading bounds', e); }
const list = $('#lyric-list');
const menu = $('#lyric-menu');
const handle = $('#lyric-dragHandle');
const renderList = () => {
list.innerHTML = '';
if (!timeline.length) return;
timeline.forEach((row, i) => {
const g = document.createElement('div'); g.className = 'group'; g.dataset.idx = i;
const main = document.createElement('div'); main.className = 'line orig'; main.textContent = row.orig;
g.appendChild(main);
if (mode === 'orig+trans' && row.trans) {
const sub = document.createElement('div'); sub.className = 'line sub'; sub.textContent = row.trans; g.appendChild(sub);
} else if (mode === 'orig+roma' && row.roma) {
const sub = document.createElement('div'); sub.className = 'line sub'; sub.textContent = row.roma; g.appendChild(sub);
}
list.appendChild(g);
});
};
let lastActive = -1;
const setActiveIndex = (idx) => {
if (idx === lastActive) return;
const prev = $(`.group[data-idx="${lastActive}"] .orig`, list);
const next = $(`.group[data-idx="${idx}"] .orig`, list);
prev?.classList.remove('active');
next?.classList.add('active');
lastActive = idx;
if (next) {
const listRect = list.getBoundingClientRect();
const nextRect = next.parentElement.getBoundingClientRect();
const isInView = nextRect.top >= listRect.top && nextRect.bottom <= listRect.bottom;
if (!isInView) {
list.scrollTo({ top: next.parentElement.offsetTop - list.clientHeight / 2 + next.clientHeight, behavior: 'smooth' });
}
}
};
const idxFor = (tEff) => {
if (!timeline.length) return 0;
let lo = 0, hi = timeline.length - 1, ans = 0;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (timeline[mid].t <= tEff) { ans = mid; lo = mid + 1; } else hi = mid - 1;
}
return ans;
};
const tick = (currentTimeMs) => {
const eff = currentTimeMs + offsetMs;
let i = idxFor(eff);
setActiveIndex(i);
};
// --- Menu & Dragging Logic ---
function openMenu(x, y, opts = {}) {
const isCoarse = opts.pointerType ? (opts.pointerType === 'touch' || opts.pointerType === 'pen') : window.matchMedia('(pointer: coarse)').matches;
menu.style.width = ''; menu.style.left = ''; menu.style.top = '';
if (!isCoarse) { menu.style.left = x + 'px'; menu.style.top = y + 'px'; }
refreshMenuUI();
menu.classList.add('open');
menu.setAttribute('aria-hidden', 'false');
requestAnimationFrame(() => {
if (!isCoarse) {
const r = menu.getBoundingClientRect(); const vw = innerWidth, vh = innerHeight;
const L = Math.min(r.left, vw - r.width - 8), T = Math.min(r.top, vh - r.height - 8);
menu.style.left = Math.max(8, L) + 'px'; menu.style.top = Math.max(8, T) + 'px';
}
});
}
function closeMenu() { menu.classList.remove('open'); menu.setAttribute('aria-hidden', 'true'); }
;(() => { // Dragging
let dragging = false, startX = 0, startY = 0, startLeft = 0, startTop = 0, pointerId = null;
const onPointerMove = (e) => {
if (!dragging) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
let L = startLeft + dx, T = startTop + dy;
const maxL = window.innerWidth - panel.offsetWidth, maxT = window.innerHeight - panel.offsetHeight;
L = Math.max(0, Math.min(maxL, L)); T = Math.max(0, Math.min(maxT, T));
panel.style.left = L + 'px'; panel.style.top = T + 'px';
};
const onPointerUp = async () => {
if (!dragging) return;
dragging = false;
panel.classList.remove('dragging', 'no-select');
if (pointerId !== null) { try { handle.releasePointerCapture(pointerId); } catch {} pointerId = null; }
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
await saveBounds();
};
handle.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault(); closeMenu(); dragging = true;
panel.classList.add('dragging', 'no-select');
startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect();
startLeft = rect.left; startTop = rect.top;
pointerId = e.pointerId;
try { handle.setPointerCapture(pointerId); } catch {}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
});
})();
;(() => { // Resize logic
const resizeHandle = $('#lyric-resizeHandle');
if (!resizeHandle) return;
let resizing = false, startX = 0, startY = 0, startW = 0, startH = 0, pointerId = null;
const onPointerMove = (e) => {
if (!resizing) return;
const dx = e.clientX - startX, dy = e.clientY - startY;
panel.style.width = startW + dx + 'px';
panel.style.height = startH + dy + 'px';
};
const onPointerUp = () => {
if (!resizing) return;
resizing = false;
panel.classList.remove('no-select');
if (pointerId !== null) { try { resizeHandle.releasePointerCapture(pointerId); } catch {} pointerId = null; }
window.removeEventListener('pointermove', onPointerMove);
window.removeEventListener('pointerup', onPointerUp);
};
resizeHandle.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
e.preventDefault(); e.stopPropagation(); closeMenu(); resizing = true;
panel.classList.add('no-select');
startX = e.clientX; startY = e.clientY;
const rect = panel.getBoundingClientRect();
startW = rect.width; startH = rect.height;
pointerId = e.pointerId;
try { resizeHandle.setPointerCapture(pointerId); } catch {}
window.addEventListener('pointermove', onPointerMove);
window.addEventListener('pointerup', onPointerUp);
});
})();
panel.addEventListener('contextmenu', (e) => { e.preventDefault(); openMenu(e.clientX, e.clientY, { pointerType: 'mouse' }); });
;(() => { // Long-press
let pressTimer = 0, startX = 0, startY = 0, lastPointerType = 'mouse';
const clearTimer = () => { if (pressTimer) { clearTimeout(pressTimer); pressTimer = 0; } };
const onDown = (e) => {
if (e.button === 2 || (e.target && e.target.closest && e.target.closest('#lyric-dragHandle'))) return;
lastPointerType = e.pointerType || (e.touches ? 'touch' : 'mouse');
startX = e.clientX; startY = e.clientY;
clearTimer();
pressTimer = setTimeout(() => { openMenu(startX, startY, { pointerType: lastPointerType }); pressTimer = 0; }, 500);
};
const onMove = (e) => { if (pressTimer && Math.hypot(e.clientX - startX, e.clientY - startY) > 8) clearTimer(); };
panel.addEventListener('pointerdown', onDown);
panel.addEventListener('pointermove', onMove);
['pointerup', 'pointercancel', 'wheel'].forEach(evt => panel.addEventListener(evt, clearTimer, { passive: true }));
})();
window.addEventListener('click', (e) => {
if (!menu.classList.contains('open')) return;
if ((e.composedPath && e.composedPath() || []).includes(menu)) return;
closeMenu();
});
window.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeMenu(); });
let resizeDebounceTimer;
const onPanelResize = () => {
clampPanelToViewport();
clearTimeout(resizeDebounceTimer);
resizeDebounceTimer = setTimeout(saveBounds, 200);
};
new ResizeObserver(onPanelResize).observe(panel);
window.addEventListener('resize', clampPanelToViewport);
// --- Settings Wiring ---
const modeWrap = $('#lyric-modeWrap'), modeRadios = $$('input[name="mode"]', modeWrap);
const sourceSelect = $('#lyric-sourceSelect'), fontMinus = $('#lyric-fontMinus'), fontPlus = $('#lyric-fontPlus');
const fontSlider = $('#lyric-fontSlider'), fontValue = $('#lyric-fontValue');
const offsetButtons = $('#lyric-offsetButtons'), offsetValue = $('#lyric-offsetValue');
sourceSelect.addEventListener('change', () => {
const selectedId = sourceSelect.value;
if (selectedId) {
lyricsUI.onSourceSelect(selectedId);
}
});
const fmtOff = (ms) => (ms >= 0 ? '+' : '') + (ms / 1000).toFixed(2) + 's';
const refreshOff = () => { if (offsetValue) offsetValue.textContent = fmtOff(offsetMs); };
const refreshModeRadios = () => {
$$('.radio', modeWrap).forEach(lab => {
const inp = lab.querySelector('input');
lab.classList.toggle('active', inp && inp.value === mode);
if (inp) inp.checked = inp.value === mode;
});
};
const applySize = async (v) => {
fontSize = Math.max(12, Math.min(96, v | 0));
shadowHost.style.setProperty('--font-size', fontSize + 'px');
await GM.setValue('lyrics.fontSize', String(fontSize));
if (fontSlider) fontSlider.value = String(fontSize);
if (fontValue) fontValue.textContent = fontSize + 'px';
};
const refreshMenuUI = () => { refreshModeRadios(); refreshOff(); applySize(fontSize); };
modeRadios.forEach(inp => inp.addEventListener('change', async () => {
mode = inp.value; await GM.setValue('lyrics.mode', mode);
refreshModeRadios(); renderList(); lastActive = -1;
}));
fontMinus?.addEventListener('click', () => applySize(fontSize - 2));
fontPlus?.addEventListener('click', () => applySize(fontSize + 2));
fontSlider?.addEventListener('input', () => applySize(+fontSlider.value));
offsetButtons?.addEventListener('click', async (e) => {
const btn = e.target.closest('button');
if (!btn) return;
if (btn.dataset.reset) offsetMs = 0;
else if (btn.dataset.delta) offsetMs = offsetMs + (+btn.dataset.delta);
await GM.setValue('lyrics.offsetMs', String(offsetMs));
refreshOff(); lastActive = -1;
});
refreshMenuUI();
// --- Public API ---
const parseLrc = (txt = '') => txt.split(/\r?\n/).flatMap(line => {
line = line.trim(); if (!line) return [];
const m = line.match(/^\[(\d{2}):(\d{2})(?:[.:](\d{2,3}))?]\s*(.*)$/);
if (m) {
const [, mm, ss, ff = '0', text] = m;
const sub = ff.length === 3 ? +ff : +ff * 10;
const t = (+mm) * 60000 + (+ss) * 1000 + sub;
return [{ t, text }];
}
try {
const o = JSON.parse(line);
if (Number.isFinite(o?.t) && Array.isArray(o?.c)) {
return [{ t: +o.t, text: o.c.map(x => x?.tx || '').join('') }];
}
} catch {}
return [];
}).sort((a, b) => a.t - b.t);
lyricsUI.updateSources = (sources) => {
sourceSelect.innerHTML = '';
if (!sources || sources.length === 0) {
const opt = document.createElement('option');
opt.textContent = 'No results';
opt.disabled = true;
sourceSelect.appendChild(opt);
return;
}
sources.forEach((song) => {
const opt = document.createElement('option');
opt.value = song.resourceId;
const artists = song.baseInfo.simpleSongData.ar?.map(a => a.name).join('/') || '';
opt.textContent = `${song.baseInfo.simpleSongData.al.name}${artists ? ` - ${artists}` : ''}`;
sourceSelect.appendChild(opt);
});
};
lyricsUI.update = (lyricsData) => {
const O = parseLrc(lyricsData.lrc?.lyric);
const T = parseLrc(lyricsData.tlyric?.lyric);
const R = parseLrc(lyricsData.romalrc?.lyric);
const findNear = (arr, t, tol = 500) => {
let lo = 0, hi = arr.length - 1, best = null;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
const dt = arr[mid].t - t;
if (!best || Math.abs(dt) < Math.abs(best.t - t)) best = arr[mid];
dt < 0 ? (lo = mid + 1) : (hi = mid - 1);
}
return best && Math.abs(best.t - t) <= tol ? best.text : '';
};
timeline = O.map(o => ({ t: o.t, orig: o.text, trans: findNear(T, o.t), roma: findNear(R, o.t) }));
renderList();
lastActive = -1;
console.log('[Lyrics] Lyrics updated,', timeline.length, 'lines loaded');
};
lyricsUI.show = () => { panel.style.display = ''; };
lyricsUI.hide = () => { panel.style.display = 'none'; };
lyricsUI.tick = tick;
}
window.onunhandledrejection = console.error; // for debugging
if (window.unsafeWindow) { // for debugging
unsafeWindow._cloudmusic = cloudmusic;
unsafeWindow._lyricsUI = lyricsUI;
}
(async () => {
'use strict';
// Register anonymous device every 7 days
const registerTime = await GM.getValue('lyrics.register.time', 0);
const now = Date.now();
if (now - registerTime > 7 * 24 * 60 * 60 * 1000) {
try {
const deviceId = '7B79802670C7A45DB9091976D71E0AE829E28926C6C34A1B8644'; // TODO: device ID generation
console.log('[Lyrics] Registering new device ID:', deviceId);
console.log('[Lyrics] Registration successful.', await cloudmusic.register(deviceId));
await GM.setValue('lyrics.register.time', now);
} catch (error) {
console.error('[Lyrics] Registration failed:', error);
}
}
// This script is injected into the page context to communicate with the userscript.
const inlineScriptYTM = () => {
setInterval(() => {
const playerApi = document.querySelector('ytmusic-app')?.playerApi;
if (!playerApi || !playerApi.getVideoData || !playerApi.getProgressState) return;
const meta = playerApi.getVideoData();
const progress = playerApi.getProgressState();
const state = playerApi.getPlayerState();
// Only send updates when a song is active (playing or paused)
if (state !== 1 && state !== 2) {
return;
}
const currentPlayerState = {
videoId: meta.video_id,
title: meta.title,
author: meta.author,
currentTime: progress.current
};
window.dispatchEvent(new CustomEvent('lyrics-player-update', { detail: currentPlayerState }));
}, 500);
};
const inlineScriptSpotify = () => {
console.log('[Lyrics] Spotify script injected');
const parseTimeToSeconds = (timeStr) => {
if (!timeStr) return 0;
return timeStr.split(':').map(Number).reduce((acc, time) => (acc * 60) + time, 0);
};
setInterval(() => {
const titleEl = document.querySelector('div[data-testid="context-item-info-title"]');
const authorEl = document.querySelector('div[data-testid="context-item-info-subtitles"]');
const timeEl = document.querySelector('div[data-testid="playback-position"]');
const durationEl = document.querySelector('div[data-testid="playback-duration"]');
if (!titleEl || !authorEl || !timeEl || !durationEl) return;
const title = titleEl.textContent;
const author = authorEl.textContent;
const currentTime = parseTimeToSeconds(timeEl.textContent) + 1.5; // slight offset to compensate for delay
const duration = parseTimeToSeconds(durationEl.textContent);
if (!title || !author || duration === 0) return;
const currentPlayerState = { videoId: `${title} - ${author}`, title, author, currentTime };
window.dispatchEvent(new CustomEvent('lyrics-player-update', { detail: currentPlayerState }));
}, 500);
};
// Inject the page script
const injectPageScript = async (scriptFunc) => {
let injected = false;
window.addEventListener('lyrics-player-inject', () => { injected = true; }, { once: true });
const textContent = `window.dispatchEvent(new CustomEvent('lyrics-player-inject'));(${scriptFunc})();`;
if (typeof GM !== 'undefined' && typeof GM.addElement === 'function') {
GM.addElement('script', { textContent })
} else {
try {
const script = document.createElement("script");
script.textContent = textContent;
(document.body ?? document.head ?? document.documentElement).append(script);
} catch (error) {
console.warn("[Lyrics] Failed to inject page script:", error);
}
}
await new Promise((resolve) => setTimeout(resolve, 100));
if (injected) {
console.log('[Lyrics] Page script injected successfully');
return;
}
console.warn("[Lyrics] Page script injection failed, falling back to eval");
eval(textContent);
};
await injectUI();
if (window.location.hostname.indexOf('youtube') >= 0) {
await injectPageScript(inlineScriptYTM);
} else if (window.location.hostname.indexOf('spotify') >= 0) {
await injectPageScript(inlineScriptSpotify);
}
const fetchLyricsForId = async (id) => {
try {
console.log(`[Lyrics] Fetching lyrics for ID: ${id}`);
const lyricData = await cloudmusic.lyric(id);
if (!lyricData.lrc?.lyric) throw new Error('No lyrics found for this ID');
lyricsUI.update(lyricData);
lyricsUI.show();
} catch (error) {
console.error('[Lyrics] Error fetching selected lyrics:', error);
lyricsUI.hide();
}
};
lyricsUI.onSourceSelect = fetchLyricsForId;
// Main logic driven by events from the page script
let lastVideoId = null;
window.addEventListener('lyrics-player-update', async (event) => {
const { videoId, title, author, currentTime } = event.detail;
// Update lyrics if song has changed
if (videoId && videoId !== lastVideoId) {
lastVideoId = videoId;
console.log(`[Lyrics] New song detected: ${title} by ${author}`);
try {
const keyword = `${title} ${author}`;
const results = await cloudmusic.search(keyword);
const searchResults = results?.data?.resources || [];
lyricsUI.updateSources(searchResults);
if (searchResults.length > 0) {
console.log(`[Lyrics] Found ${searchResults.length} results for "${keyword}"`, searchResults);
const firstSongId = searchResults[0].resourceId;
await fetchLyricsForId(firstSongId);
} else {
console.log(`[Lyrics] No results found for "${keyword}"`);
lyricsUI.hide();
}
} catch (error) {
console.error('[Lyrics] Error fetching lyrics:', error);
lyricsUI.updateSources([]);
lyricsUI.hide();
}
}
lyricsUI.tick(currentTime * 1000);
});
console.log('[Lyrics] initialized');
})();