YouTube Music / Spotify 网易云歌词显示

为 YTM 和 Spotify 添加网易云歌词 (带有翻译或者罗马音显示)

// ==UserScript==
// @name                YouTube Music / Spotify 网易云歌词显示
// @name:zh-TW          YouTube Music / Spotify 歌詞外掛
// @version             0.27
// @description         为 YTM 和 Spotify 添加网易云歌词 (带有翻译或者罗马音显示)
// @description:zh-TW   為 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://gf.qytechs.cn/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) {},
  updateFeatures(features) {},
  onSourceSelect(id) {},
  onOffsetChange(newOffsetMs) {},
};

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">
        <svg class="handle-dots-svg" width="14" height="4" viewBox="0 0 14 4" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
          <circle cx="2" cy="2" r="2" fill="white"/>
          <circle cx="7" cy="2" r="2" fill="white"/>
          <circle cx="12" cy="2" r="2" fill="white"/>
        </svg>
        <span class="brand">Floating Lyrics</span>
        <button id="lyric-closeBtn" class="close-btn" aria-label="Close lyrics panel" title="Close"></button>
      </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="line-menu" id="lyric-lineMenu" role="menu" aria-hidden="true">
        <button type="button" class="line-menu-item" data-action="copy" aria-label="Copy line">
          <span>Copy line</span>
        </button>
        <button type="button" class="line-menu-item" data-action="jump" aria-label="Jump to line">
          <span>Jump to line</span>
        </button>
      </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-svg {
      width: 14px; height: 4px;
      filter: drop-shadow(0 1px 1px rgba(0,0,0,0.4));
    }
    .handle-dots-svg circle {
      animation: dot-opacity 1.8s infinite;
    }
    .handle-dots-svg circle:nth-child(1) { animation-delay: 0s; }
    .handle-dots-svg circle:nth-child(2) { animation-delay: 0.2s; }
    .handle-dots-svg circle:nth-child(3) { animation-delay: 0.4s; }
    .brand { display: none; }
    .close-btn {
      position: absolute; right: 6px; top: 50%; transform: translateY(-50%);
      width: 18px; height: 18px; border-radius: 50%; border: none;
      background: transparent; cursor: pointer;
      display: flex; align-items: center; justify-content: center;
      transition: background-color 120ms ease;
    }
    .close-btn:hover {
      background: rgba(255,255,255,0.2);
    }
    .close-btn::before {
      content: '×'; color: rgba(255,255,255,0.5); font-size: 18px; line-height: 1;
      font-weight: bold; text-align: center;
    }
    .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;
      transition: filter 160ms ease;
    }
    .list.masking .group:not(.ctx-active) {
      filter: blur(4px);
      opacity: 0.35;
      transform: scale(0.98);
      pointer-events: none;
    }
    .list.masking .group.ctx-active { z-index: 2; }
    .group {
      padding: 4px 6px;
      border-radius: 12px;
      position: relative;
      transition: box-shadow 160ms ease, transform 160ms ease;
    }
    .empty-message {
      color: rgba(255,255,255,0.4);
      font-size: var(--font-size);
      text-align: center;
    }
    .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;
      cursor: pointer;
    }
    .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));
    }
    .group.ctx-active {
      background: linear-gradient(120deg, rgba(139,233,253,0.22), rgba(255,121,198,0.16));
      box-shadow: 0 16px 38px rgba(9,15,32,0.55);
      transform: translateY(-1px);
    }
    .group.ctx-active::after {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: 12px;
      border: 1px solid rgba(139,233,253,0.32);
      opacity: 0.7;
      pointer-events: none;
    }
    .group.ctx-active .line.orig {
      text-shadow: 0 3px 16px rgba(139,233,253,0.4);
    }
    .group.ctx-active .line.orig:not(.active) {
      color: rgba(255,255,255,0.96);
    }
    .group.ctx-active .line:not(.active) {
      color: rgba(255,255,255,0.88);
    }
    @media (hover: hover) and (pointer: fine) {
      .list:not(.masking) .group:hover {
        background: linear-gradient(120deg, rgba(139,233,253,0.14), rgba(255,121,198,0.08));
        transform: translateY(-1px);
      }
    }
    .line-menu {
      position: absolute;
      z-index: 100001;
      min-width: 160px;
      padding: 6px;
      border-radius: 12px;
      background: rgba(19, 23, 36, 0.95);
      border: 1px solid rgba(255,255,255,0.08);
      box-shadow: 0 20px 40px rgba(0,0,0,0.55);
      color: #f4f6ff;
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      display: none;
      transition: opacity 140ms ease, transform 140ms ease;
      transform: translateY(4px);
      opacity: 0;
      pointer-events: none;
    }
    .line-menu.open {
      display: flex;
      flex-direction: column;
      gap: 4px;
      opacity: 1;
      transform: translateY(0);
      pointer-events: auto;
    }
    .line-menu button {
      appearance: none;
      border: none;
      background: rgba(255,255,255,0.04);
      color: inherit;
      font-size: 13px;
      padding: 8px 12px;
      border-radius: 8px;
      text-align: left;
      display: flex;
      align-items: center;
      gap: 8px;
      cursor: pointer;
      transition: background 120ms ease, color 120ms ease;
    }
    .line-menu button:hover,
    .line-menu button:focus-visible {
      background: rgba(139,233,253,0.18);
      color: #ffffff;
      outline: none;
    }
    .line-menu button:active {
      background: rgba(139,233,253,0.28);
    }
    .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;
    }
    @keyframes dot-opacity {
      0%, 100% { fill-opacity: 0.2; }
      40% { fill-opacity: 0.5; }
    }
    @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, 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 = 0; // Will be set per-song
  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 lineMenu = $('#lyric-lineMenu');
  const handle = $('#lyric-dragHandle');
  const closeBtn = $('#lyric-closeBtn');

  closeBtn?.addEventListener('click', () => lyricsUI.hide());

  const renderList = () => {
    list.innerHTML = '';
    if (!timeline.length) {
      const emptyMsg = document.createElement('div');
      emptyMsg.className = 'empty-message';
      emptyMsg.textContent = '(No lyrics available)';
      list.appendChild(emptyMsg);
      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 && !list.classList.contains('masking')) {
      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 ---
  let highlightedCtxGroup = null;

  const setCtxHighlight = (groupEl) => {
    if (highlightedCtxGroup && highlightedCtxGroup !== groupEl) {
      highlightedCtxGroup.classList.remove('ctx-active');
    }
    highlightedCtxGroup = groupEl || null;
    highlightedCtxGroup?.classList.add('ctx-active');
  };

  const closeLineMenu = () => {
    if (!lineMenu || !lineMenu.classList.contains('open')) return;
    lineMenu.classList.remove('open');
    lineMenu.setAttribute('aria-hidden', 'true');
    lineMenu.style.left = '';
    lineMenu.style.top = '';
    setCtxHighlight(null);
    list?.classList.remove('masking');
  };

  const openLineMenuFor = (lineEl, text, idx) => {
    if (!lineMenu || !text) return;

    closeMenu();
    closeLineMenu();

    const group = lineEl.closest('.group');
    if (!group) return;

    setCtxHighlight(group);

    const groupRect = group.getBoundingClientRect();
    const panelRect = panel.getBoundingClientRect();

    // First position the menu to measure its size
    lineMenu.style.left = '0px';
    lineMenu.style.top = '0px';
    lineMenu.classList.add('open');
    lineMenu.setAttribute('aria-hidden', 'false');

    const menuRect = lineMenu.getBoundingClientRect();
    const menuWidth = menuRect.width;
    const menuHeight = menuRect.height;
    const padding = 6;

    // Calculate available space
    const spaceBelow = panelRect.bottom - groupRect.bottom;
    const spaceAbove = groupRect.top - panelRect.top;

    let left, top;

    // Vertical position (prefer below, use above if not enough space)
    if (spaceBelow >= menuHeight + padding) {
      top = groupRect.bottom - panelRect.top + padding;
    } else if (spaceAbove >= menuHeight + padding) {
      top = groupRect.top - panelRect.top - menuHeight - padding;
    } else {
      top = groupRect.bottom - panelRect.top + padding;
    }

    // Align horizontally with the group
    left = groupRect.left - panelRect.left;

    // Clamp to panel bounds
    const minLeft = padding;
    const maxLeft = panelRect.width - menuWidth - padding;
    const minTop = padding;
    const maxTop = panelRect.height - menuHeight - padding;

    left = Math.max(minLeft, Math.min(maxLeft, left));
    top = Math.max(minTop, Math.min(maxTop, top));

    lineMenu.style.left = `${left}px`;
    lineMenu.style.top = `${top}px`;

    lineMenu.dataset.lineIdx = idx;
    lineMenu.dataset.lineText = text;

    list?.classList.add('masking');
  };

  function openMenu(x, y, opts = {}) {
    closeLineMenu();
    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');
  }

  // --- Line Menu Event Handlers ---
  list?.addEventListener('click', (e) => {
    const lineEl = e.target.closest('.line');
    if (!lineEl) {
      closeLineMenu();
      return;
    }

    if (e.button !== undefined && e.button !== 0) return;

    const group = lineEl.closest('.group');
    if (!group) return;

    const idx = Number(group.dataset.idx || '-1');
    const text = (lineEl.textContent || '').trim();
    if (!text) return;

    e.preventDefault();
    e.stopPropagation();

    // If clicking the same line again while menu is open, close it
    const currentIdx = lineMenu?.dataset.lineIdx;
    if (currentIdx && Number(currentIdx) === idx && lineMenu?.classList.contains('open')) {
      closeLineMenu();
      return;
    }

    openLineMenuFor(lineEl, text, idx);
  });

  list?.addEventListener('scroll', () => closeLineMenu(), { passive: true });

  lineMenu?.addEventListener('click', (e) => {
    const btn = e.target.closest('button');
    if (!btn) return;

    e.preventDefault();
    e.stopPropagation();

    const action = btn.dataset.action;
    const text = lineMenu.dataset.lineText;
    const idx = Number(lineMenu.dataset.lineIdx || '-1');

    if (!text || idx < 0) {
      closeLineMenu();
      return;
    }

    if (action === 'copy') {
      navigator.clipboard?.writeText(text).then(() => {
        console.log('[Lyrics] Copied to clipboard:', text);
      }).catch(err => {
        console.error('[Lyrics] Failed to copy:', err);
      });
    } else if (action === 'jump') {
      if (idx >= 0 && idx < timeline.length) {
        const targetTime = timeline[idx].t - offsetMs;
        console.log('[Lyrics] Jumping to line:', idx, 'time:', targetTime);
        window.dispatchEvent(new CustomEvent('lyrics-control', {
          detail: { action: 'seek', value: targetTime / 1000 }
        }));
      }
    }

    closeLineMenu();
  });

  ;(() => { // 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 || e.target.closest('.close-btn')) 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();
      closeLineMenu();
      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') && !lineMenu?.classList.contains('open')) return;
    const path = e.composedPath && e.composedPath() || [];
    if (!path.includes(menu) && menu.classList.contains('open')) closeMenu();
    if (!path.includes(lineMenu) && lineMenu?.classList.contains('open')) closeLineMenu();
  });

  window.addEventListener('keydown', (e) => {
    if (e.key === 'Escape') {
      closeMenu();
      closeLineMenu();
    }
  });

  let resizeDebounceTimer;
  const onPanelResize = () => {
    clampPanelToViewport();
    closeLineMenu();
    clearTimeout(resizeDebounceTimer);
    resizeDebounceTimer = setTimeout(saveBounds, 200);
  };
  new ResizeObserver(onPanelResize).observe(panel);

  window.addEventListener('resize', () => {
    clampPanelToViewport();
    closeLineMenu();
  });

  // --- 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 setOffset = (ms) => {
    offsetMs = ms;
    refreshOff();
    lastActive = -1;
  };
  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();
    closeLineMenu();
    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;

    let newOffset = offsetMs;
    if (btn.dataset.reset) newOffset = 0;
    else if (btn.dataset.delta) newOffset = newOffset + (+btn.dataset.delta);

    setOffset(newOffset);
    lyricsUI.onOffsetChange(newOffset);
  });

  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.updateFeatures = (features) => {
    const jumpBtn = lineMenu?.querySelector('[data-action="jump"]');
    if (jumpBtn) {
      jumpBtn.style.display = features.includes('seek') ? '' : 'none';
    }
  };

  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.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;
  lyricsUI.setOffset = setOffset;
}

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 = () => {
    window.addEventListener('lyrics-control', (e) => {
      const { action, value } = e.detail;
      const playerApi = document.querySelector('ytmusic-app')?.playerApi;
      if (!playerApi) return;

      switch (action) {
        case 'play':
          playerApi.playVideo?.();
          break;
        case 'pause':
          playerApi.pauseVideo?.();
          break;
        case 'seek':
          if (typeof value === 'number') {
            playerApi.seekTo?.(value);
          }
          break;
      }
    });

    setInterval(() => {
      const playerApi = document.querySelector('ytmusic-app')?.playerApi;
      const playerBar = document.querySelector('ytmusic-player-bar');

      if (!playerApi || !playerApi.getVideoData || !playerApi.getProgressState) return;

      const meta = playerApi.getVideoData();
      const progress = playerApi.getProgressState();
      const state = playerApi.getPlayerState();

      if (state !== 1 && state !== 2) return;

      window.dispatchEvent(new CustomEvent('lyrics-player-update', {
        detail: {
          videoId: meta.video_id,
          title: playerBar?.currentItem?.title?.runs?.[0]?.text || meta.title,
          author: meta.author,
          currentTime: progress.current
        }
      }));
    }, 500);

    return {
      features: [ 'play', 'pause', 'seek' ]
    };
  };

  const inlineScriptSpotify = () => {
    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;
      const duration = parseTimeToSeconds(durationEl.textContent);

      if (!title || !author || duration === 0) return;

      window.dispatchEvent(new CustomEvent('lyrics-player-update', {
        detail: { videoId: `${title} - ${author}`, title, author, currentTime }
      }));
    }, 500);

    return { features: [] };
  };

  // Inject the page script
  const injectPageScript = async (scriptFunc) => {
    let scriptData = null;
    window.addEventListener('lyrics-player-inject', (e) => { scriptData = e.detail; }, { once: true });
    const textContent = `window.dispatchEvent(new CustomEvent('lyrics-player-inject',{detail:(${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 (scriptData !== null) {
      console.log('[Lyrics] Page script injected:', scriptData);
      return scriptData;
    }

    console.warn("[Lyrics] Page script injection failed, falling back to eval");
    eval(textContent);
    return scriptData || {};
  };

  await injectUI();

  let scriptData = [];

  if (window.location.hostname.indexOf('youtube') >= 0) {
    scriptData = await injectPageScript(inlineScriptYTM);
  } else if (window.location.hostname.indexOf('spotify') >= 0) {
    scriptData = await injectPageScript(inlineScriptSpotify);
  }

  lyricsUI.updateFeatures(scriptData.features || []);

  const fetchLyricsForId = async (id) => {
    if (!id) return false;
    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();
      return true;
    } catch (error) {
      console.error('[Lyrics] Error fetching selected lyrics:', error);
      lyricsUI.update({});
      return false;
    }
  };

  lyricsUI.onSourceSelect = async (selectedId) => {
    if (await fetchLyricsForId(selectedId) && lastVideoId && selectedId) {
      const selections = await GM.getValue('lyrics.selections', {});
      selections[lastVideoId] = selectedId;
      await GM.setValue('lyrics.selections', selections);
      console.log(`[Lyrics] Saved selection for ${lastVideoId}: ${selectedId}`);
    }
  };

  const splitTitle = (title) => {
    const masterPattern = /(?:[「『](?<content>.+?)[」』])|(?:【.*?】|〖.*?〗|\(.*?\)|(.*?))|(?<delimiter>\s+-\s+|\s*[\//|:|│]\s*)/i;
    const noiseWords = /\b(MV|PV)\b|\b(?:covered by|feat?|ft?)\b.+/gi;

    const parse = (str) => {
      if (!str?.trim()) return [];

      const match = str.match(masterPattern);
      if (!match) return [str];

      const before = str.substring(0, match.index);
      const after = str.substring(match.index + match[0].length);
      const { delimiter, content } = match.groups;

      if (delimiter && (before.trim().length < 2 || after.trim().length < 2)) {
        const remaining = parse(after);
        return [before + match[0] + (remaining[0] || ''), ...remaining.slice(1)];
      }

      return [...parse(before), ...(content ? [content] : []), ...parse(after)];
    };
    return [...new Set(parse(title)
      .map(p => p.replace(noiseWords, '').trim())
      .filter(p => p.length > 0)
    )];
  };

  const levenshtein = (a, b) => {
    const an = a ? a.length : 0;
    const bn = b ? b.length : 0;
    if (an === 0) return bn;
    if (bn === 0) return an;
    const matrix = Array(bn + 1).fill(null).map(() => Array(an + 1).fill(null));
    for (let i = 0; i <= an; i += 1) {
      matrix[0][i] = i;
    }
    for (let j = 0; j <= bn; j += 1) {
      matrix[j][0] = j;
    }
    for (let j = 1; j <= bn; j += 1) {
      for (let i = 1; i <= an; i += 1) {
        const cost = a[i - 1] === b[j - 1] ? 0 : 1;
        matrix[j][i] = Math.min(
          matrix[j][i - 1] + 1,
          matrix[j - 1][i] + 1,
          matrix[j - 1][i - 1] + cost,
        );
      }
    }
    return matrix[bn][an];
  };

  const nLevenshtein = (a, b) => {
    const dist = levenshtein(a, b);
    const maxLen = Math.max(a.length, b.length);
    return maxLen === 0 ? 0 : (1 - (dist / maxLen));
  };

  // Main logic driven by events from the page script
  let lastVideoId = null;

  lyricsUI.onOffsetChange = async (newOffset) => {
    if (!lastVideoId) return;
    const offsets = await GM.getValue('lyrics.offsets', {});
    offsets[lastVideoId] = newOffset;
    await GM.setValue('lyrics.offsets', offsets);
    console.log(`[Lyrics] Saved offset for ${lastVideoId}: ${newOffset}ms`);
  };

  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] Now playing:', title, 'by', author);
      try {
        const offsets = await GM.getValue('lyrics.offsets', {});
        lyricsUI.setOffset(offsets[lastVideoId] || 0);

        const parts = splitTitle(title);
        if (parts.length === 0) throw new Error('Failed to parse title for search');

        const keywords = [...parts];
        if (parts[0] !== author) keywords.push(`${parts[0]} ${author}`);
        console.log(`[Lyrics] Searching for keywords: ${keywords.join(', ')}`);

        const results = await Promise.all(keywords.map(kw => cloudmusic.search(kw)));
        const seenIds = new Set();

        const calcTitleScore = (searchTitle) => {
          let avgScore = 0;
          parts.forEach((part, idx) => {
            let weight = 1 / (idx * 2 + 1); // Earlier parts have higher weight
            if (searchTitle.startsWith(part)) weight *= 2; // Bonus for prefix match
            else if (searchTitle.includes(part)) weight *= 1.5; // Bonus for substring match
            avgScore += nLevenshtein(part, searchTitle) * weight / parts.length;
          });
          const score = Math.max(nLevenshtein(title, searchTitle), avgScore)
          return score;
        };

        let searchResults = results
          .flatMap(r => r?.data?.resources || []) // Flatten results
          .filter(song => { // Remove duplicates and invalid entries
            if (!song?.resourceId || seenIds.has(song.resourceId)) {
              return false;
            }
            seenIds.add(song.resourceId);
            return true;
          }).sort((a, b) => { // Sort by relevance
            const nameA = a.baseInfo.simpleSongData.name; const authorA = a.baseInfo.simpleSongData.ar?.map(ar => ar.name).join('') || '';
            const nameB = b.baseInfo.simpleSongData.name; const authorB = b.baseInfo.simpleSongData.ar?.map(ar => ar.name).join('') || '';
            const distA = 10 * calcTitleScore(nameA) + nLevenshtein(author, authorA);
            const distB = 10 * calcTitleScore(nameB) + nLevenshtein(author, authorB);
            return distB - distA;
          });

        if (searchResults.length > 0) {
          console.log(`[Lyrics] Found ${searchResults.length} unique results:`, searchResults);

          const selections = await GM.getValue('lyrics.selections', {});
          const savedLyricId = selections[videoId];

          if (savedLyricId) {
            const savedResult = searchResults.find(r => String(r.resourceId) === String(savedLyricId));
            if (savedResult) {
              console.log(`[Lyrics] Applying saved lyric ID: ${savedLyricId}`);
              const savedIndex = searchResults.indexOf(savedResult);
              if (savedIndex > 0) {
                searchResults.splice(savedIndex, 1);
                searchResults.unshift(savedResult);
              }
            }
          }
          lyricsUI.updateSources(searchResults);
          for (const song of searchResults) {
            if (await fetchLyricsForId(song.resourceId))
              break;
          }
        } else {
          console.log('[Lyrics] No results found');
          lyricsUI.updateSources([]);
          lyricsUI.hide();
        }
      } catch (error) {
        console.error('[Lyrics] Error fetching lyrics:', error);
        lyricsUI.updateSources([]);
        lyricsUI.hide();
      }
    }

    lyricsUI.tick(currentTime * 1000);
  });

  console.log('[Lyrics] initialized');
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址