JeyKul / Tidal Polish Lite Fixed

// ==UserScript==
// @name         Tidal Polish Lite Fixed
// @namespace    https://openuserjs.org/users/JeyKul
// @version      2.0.0
// @description  Lightweight TIDAL polish with subtle floating fullscreen artwork, darker artwork-based blobs, fullscreen metadata via aria-hidden, and quick toggles
// @author       JeyKul
// @license      MIT
// @match        *://*.tidal.com/*
// @require      https://raw.githubusercontent.com/jariz/vibrant.js/refs/heads/master/dist/Vibrant.min.js
// @grant        none
// ==/UserScript==

(function () {
  'use strict';

  const settings = {
    blobs: true,
    floatingArt: true,
    fullscreenMeta: true,
    lyricFocus: true
  };

  const state = {
    currentColors: [[82, 26, 34], [24, 58, 62], [74, 34, 78], [30, 24, 28]],
    lastArtworkSrc: '',
    lastRenderedGradient: '',
    lastGradientBucket: -1,
    artFloatHost: null,
    metaEl: null,
    panelEl: null,
    toggleBtnEl: null,
    observer: null,
    paletteTimer: null,
    metaTimer: null,
    mediaTimer: null,
    lyricsTimer: null,
    gradientTimer: null,
    currentAnimation: null
  };

  const css = `
    span[data-test="lyrics-line"] {
      transition: filter 180ms ease, opacity 140ms ease, text-shadow 140ms ease;
      cursor: default;
    }

    html.tp-lyrics-off span[data-test="lyrics-line"] {
      filter: none !important;
      opacity: 1 !important;
      text-shadow: none !important;
    }

    span[data-test="lyrics-line"][data-current="true"] {
      text-shadow: rgba(255,255,255,0.44) 0 0 8px;
      opacity: 1;
      filter: none;
    }

    span[data-test="lyrics-line"]:not([data-current="true"]) {
      filter: blur(2px);
      opacity: 0.52;
    }

    img[data-test="now-playing-artwork"],
    ._tilt_8fda471._artworkTilt_1c6d5cc {
      border-radius: 28px !important;
    }

    ._tilt_8fda471._artworkTilt_1c6d5cc {
      overflow: hidden !important;
      position: relative !important;
    }

    img[data-test="now-playing-artwork"] {
      display: block;
      overflow: hidden !important;
      box-shadow: 0 10px 30px rgba(0,0,0,0.22);
      clip-path: inset(0 round 28px);
      filter: none !important;
      opacity: 1 !important;
    }

    .tp-art-float-wrap {
      display: block !important;
      will-change: transform;
    }

    ._artworkWrapper_bca4689._artworkWrapperHidden_7e72bc4
    > ._artworkContainer_a3f2f92._artworkShifted_92ad2d0 {
      animation: tpArtworkFloat 6s ease-in-out infinite !important;
      will-change: transform;
    }

    @keyframes tpArtworkFloat {
      0% {
        transform: translateY(0);
      }
      50% {
        transform: translateY(-20px);
      }
      100% {
        transform: translateY(0);
      }
    }

    .tp-meta {
      position: fixed;
      left: 50%;
      bottom: 24px;
      transform: translateX(-50%);
      width: min(76vw, 680px);
      text-align: center;
      z-index: 9998;
      pointer-events: none;
      opacity: 0;
      transition: opacity 180ms ease;
    }

    html.tp-fullscreen-meta-on .tp-meta.tp-visible {
      opacity: 1;
    }

    .tp-meta-inner {
      padding: 13px 18px;
      border-radius: 18px;
      background: linear-gradient(180deg, rgba(0,0,0,0.34), rgba(0,0,0,0.16));
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      box-shadow: 0 10px 30px rgba(0,0,0,0.22);
    }

    .tp-meta-title {
      color: rgba(255,255,255,0.98);
      font-size: clamp(20px, 2.3vw, 32px);
      font-weight: 700;
      line-height: 1.1;
      letter-spacing: -0.02em;
    }

    .tp-meta-artist {
      margin-top: 7px;
      color: rgba(255,255,255,0.72);
      font-size: clamp(13px, 1.2vw, 17px);
      font-weight: 500;
      line-height: 1.24;
    }

    .tp-toggle-btn {
      position: fixed;
      right: 20px;
      bottom: 120px;
      width: 42px;
      height: 42px;
      border: 0;
      border-radius: 999px;
      z-index: 2147483647;
      background: rgba(15,15,17,0.68);
      color: rgba(255,255,255,0.94);
      backdrop-filter: blur(12px);
      -webkit-backdrop-filter: blur(12px);
      box-shadow: 0 8px 24px rgba(0,0,0,0.22);
      cursor: pointer;
    }

    .tp-panel {
      position: fixed;
      right: 18px;
      bottom: 170px;
      width: min(280px, calc(100vw - 28px));
      padding: 12px;
      border-radius: 18px;
      z-index: 2147483647;
      background: rgba(12,12,14,0.76);
      color: rgba(255,255,255,0.95);
      backdrop-filter: blur(16px);
      -webkit-backdrop-filter: blur(16px);
      box-shadow: 0 18px 44px rgba(0,0,0,0.28);
      opacity: 0;
      pointer-events: none;
      transform: translateY(8px);
      transition: opacity 160ms ease, transform 160ms ease;
    }

    .tp-panel.tp-open {
      opacity: 1;
      pointer-events: auto;
      transform: translateY(0);
    }

    .tp-panel-title {
      font-size: 13px;
      font-weight: 700;
      letter-spacing: 0.08em;
      text-transform: uppercase;
      color: rgba(255,255,255,0.58);
      margin-bottom: 8px;
    }

    .tp-setting {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 10px 8px;
      border-radius: 12px;
    }

    .tp-setting-name {
      font-size: 14px;
      font-weight: 600;
      color: rgba(255,255,255,0.95);
    }

    .tp-setting-desc {
      font-size: 12px;
      color: rgba(255,255,255,0.56);
      margin-top: 2px;
    }

    .tp-switch {
      position: relative;
      width: 42px;
      height: 24px;
      border-radius: 999px;
      border: 0;
      background: rgba(255,255,255,0.14);
      cursor: pointer;
      flex: 0 0 auto;
    }

    .tp-switch::after {
      content: '';
      position: absolute;
      top: 3px;
      left: 3px;
      width: 18px;
      height: 18px;
      border-radius: 50%;
      background: rgba(255,255,255,0.96);
      transition: transform 160ms ease;
    }

    .tp-switch[aria-checked="true"] {
      background: rgba(94, 176, 146, 0.86);
    }

    .tp-switch[aria-checked="true"]::after {
      transform: translateX(18px);
    }

    @media (prefers-reduced-motion: reduce) {
      ._artworkWrapper_bca4689._artworkWrapperHidden_7e72bc4
      > ._artworkContainer_a3f2f92._artworkShifted_92ad2d0 {
        animation: none !important;
      }
    }
  `;

  function injectCSS() {
    if (document.getElementById('tidal-polish-lite-fixed-css')) return;
    const style = document.createElement('style');
    style.id = 'tidal-polish-lite-fixed-css';
    style.textContent = css;
    document.head.appendChild(style);
  }

  function applySettingClasses() {
    const root = document.documentElement;
    root.classList.toggle('tp-floating-art-on', settings.floatingArt);
    root.classList.toggle('tp-fullscreen-meta-on', settings.fullscreenMeta);
    root.classList.toggle('tp-lyrics-off', !settings.lyricFocus);
  }

  function findScrollContainer() {
    return document.querySelector('div[class*="smoothScroll"]');
  }

  function animateScroll(container, targetScrollTop) {
    if (state.currentAnimation) cancelAnimationFrame(state.currentAnimation.frame);
    const start = container.scrollTop;
    const distance = targetScrollTop - start;
    const startTime = performance.now();
    const duration = 320;

    function step(now) {
      const progress = Math.min((now - startTime) / duration, 1);
      const eased = 1 - Math.pow(1 - progress, 3);
      container.scrollTop = start + distance * eased;
      if (progress < 1) state.currentAnimation.frame = requestAnimationFrame(step);
      else state.currentAnimation = null;
    }

    state.currentAnimation = { frame: requestAnimationFrame(step) };
  }

  function centerLyric(line) {
    const container = findScrollContainer();
    if (!container || !line || !settings.lyricFocus) return;
    const lineTop = line.offsetTop;
    const lineHeight = line.offsetHeight;
    const containerHeight = container.clientHeight;
    const targetScrollTop = lineTop - containerHeight / 3 + lineHeight / 2;
    animateScroll(container, Math.max(0, Math.min(targetScrollTop, container.scrollHeight - containerHeight)));
  }

  function attachLyricObserver() {
    document.querySelectorAll('span[data-test="lyrics-line"]').forEach(el => {
      if (el.dataset.lyricObserved === '1') return;
      el.dataset.lyricObserved = '1';
      const lyricObserver = new MutationObserver(mutations => {
        for (const mutation of mutations) {
          if (mutation.type === 'attributes' && mutation.attributeName === 'data-current' && mutation.target.getAttribute('data-current') === 'true') {
            centerLyric(mutation.target);
            break;
          }
        }
      });
      lyricObserver.observe(el, { attributes: true, attributeFilter: ['data-current'] });
    });
  }

  function upgradeMedia() {
    document.querySelectorAll('img._cellImage_0ef8dd3').forEach(img => {
      const src = img.srcset?.split(',').pop()?.trim().split(' ')[0] || img.src;
      if (src && img.src !== src) img.src = src;
      if (img.srcset) img.srcset = '';
      if (img.sizes) img.sizes = '';
    });

    document.querySelectorAll('div._albumImageOverlay_2eabc2b').forEach(overlay => overlay.remove());
  }

  function getFooterAlbumImage() {
    return document.querySelector('button[data-test="player-details-toggle-now-playing"] img._cellImage_0ef8dd3, button[data-test="player-details-toggle-now-playing"] img');
  }

  function pickBestArtworkSource() {
    const candidates = [
      document.querySelector('#nowPlaying img[data-test="now-playing-artwork"]'),
      document.querySelector('img[data-test="now-playing-artwork"]'),
      getFooterAlbumImage()
    ].filter(Boolean);

    for (const img of candidates) {
      const src = img.currentSrc || img.src || '';
      if (src && !src.startsWith('data:')) return { img, src };
    }

    return { img: null, src: '' };
  }

  function getBlobTargets() {
    const targets = [];
    const section = document.querySelector('section[data-test="now-playing"], #nowPlaying');
    if (section) targets.push(section);
    document.querySelectorAll('._background_42cea4e').forEach(el => targets.push(el));
    return targets;
  }

  function getTrackInfo() {
    const titleEl = document.querySelector('._trackContent_7b758a9 ._marketText_1lyag_1._semibold20_1lyag_246');
    const artistEl = document.querySelector('._trackContent_7b758a9 ._item_39605ae._link_6a45a19');
    return {
      title: titleEl?.textContent?.trim() || pickBestArtworkSource().img?.getAttribute('alt') || '',
      artist: artistEl?.getAttribute('aria-label')?.trim() || artistEl?.textContent?.trim() || ''
    };
  }

  function isFullscreenForMeta() {
    return !!document.querySelector('._player_8c80ae6.nowPlayingVisible._controlsHidden_ec77acd[aria-hidden="true"]');
  }

  function ensureMetaOverlay() {
    if (state.metaEl && document.body.contains(state.metaEl)) return state.metaEl;
    const el = document.createElement('div');
    el.className = 'tp-meta';
    el.innerHTML = '<div class="tp-meta-inner"><div class="tp-meta-title"></div><div class="tp-meta-artist"></div></div>';
    document.body.appendChild(el);
    state.metaEl = el;
    return el;
  }

  function updateMetaOverlay() {
    const el = ensureMetaOverlay();
    const { title, artist } = getTrackInfo();
    el.querySelector('.tp-meta-title').textContent = title || '';
    el.querySelector('.tp-meta-artist').textContent = artist || '';
    el.classList.toggle('tp-visible', settings.fullscreenMeta && isFullscreenForMeta() && !!title);
  }

  function ensureFloatingArtworkWrapper() {
    const container = document.querySelector('._player_8c80ae6.nowPlayingVisible ._artworkContainer_a3f2f92');
    if (!container) return;

    state.artFloatHost = container;
  }

  function updateFloatingArtworkState() {
    ensureFloatingArtworkWrapper();
    if (!state.artFloatHost) return;
  }

  function clamp(v, min, max) {
    return Math.max(min, Math.min(max, v));
  }

  function rgba(r, g, b, a = 1) {
    return `rgba(${Math.round(clamp(r, 0, 255))}, ${Math.round(clamp(g, 0, 255))}, ${Math.round(clamp(b, 0, 255))}, ${a})`;
  }

  function darken(rgb, factor) {
    return [
      Math.round(clamp(rgb[0] * factor, 0, 255)),
      Math.round(clamp(rgb[1] * factor, 0, 255)),
      Math.round(clamp(rgb[2] * factor, 0, 255))
    ];
  }

  function saturate(rgb, factor = 1.08) {
    const [r, g, b] = rgb;
    const avg = (r + g + b) / 3;
    return [
      Math.round(clamp(avg + (r - avg) * factor, 0, 255)),
      Math.round(clamp(avg + (g - avg) * factor, 0, 255)),
      Math.round(clamp(avg + (b - avg) * factor, 0, 255))
    ];
  }

  function distance(a, b) {
    return Math.sqrt(Math.pow(a[0] - b[0], 2) + Math.pow(a[1] - b[1], 2) + Math.pow(a[2] - b[2], 2));
  }

  function uniqueColors(colors, threshold = 26) {
    const out = [];
    for (const c of colors) {
      if (!out.some(existing => distance(existing, c) < threshold)) out.push(c);
    }
    return out;
  }

  function swatchToRgb(swatch) {
    return swatch && typeof swatch.getRgb === 'function' ? swatch.getRgb() : null;
  }

  function tonePalette(colors) {
    const toned = uniqueColors(colors).slice(0, 4).map((c, i) => {
      if (i === 0) return darken(saturate(c, 1.10), 0.58);
      if (i === 1) return darken(saturate(c, 1.08), 0.54);
      if (i === 2) return darken(saturate(c, 1.06), 0.50);
      return darken(saturate(c, 1.04), 0.46);
    });

    if (toned.length >= 4) return toned;
    if (toned.length === 3) return [toned[0], toned[1], toned[2], darken(toned[1], 0.75)];
    if (toned.length === 2) return [toned[0], toned[1], darken(toned[0], 0.78), darken(toned[1], 0.74)];
    if (toned.length === 1) return [toned[0], darken(toned[0], 0.84), darken(toned[0], 0.72), darken(toned[0], 0.60)];
    return [[82, 26, 34], [24, 58, 62], [74, 34, 78], [30, 24, 28]];
  }

  async function extractColorsFromArtwork() {
    const picked = pickBestArtworkSource();
    if (!picked.img || !picked.src || typeof Vibrant === 'undefined') return null;

    try {
      const vibrant = new Vibrant(picked.img, 96, 1);
      const swatches = vibrant.swatches();
      const extracted = [
        swatchToRgb(swatches.DarkVibrant),
        swatchToRgb(swatches.Vibrant),
        swatchToRgb(swatches.DarkMuted),
        swatchToRgb(swatches.Muted),
        swatchToRgb(swatches.LightVibrant)
      ].filter(Boolean);

      if (!extracted.length) return null;
      return tonePalette(extracted);
    } catch {
      return null;
    }
  }

  function buildAnimatedGradient(colors, phase) {
    const c1 = colors[0] || [82, 26, 34];
    const c2 = colors[1] || [24, 58, 62];
    const c3 = colors[2] || [74, 34, 78];
    const c4 = colors[3] || [30, 24, 28];

    const p1x = 16 + Math.sin(phase * 0.9) * 6;
    const p1y = 18 + Math.cos(phase * 0.8) * 5;
    const p2x = 82 + Math.sin(phase * 0.7 + 1.3) * 6;
    const p2y = 16 + Math.cos(phase * 0.9 + 0.8) * 6;
    const p3x = 56 + Math.sin(phase * 0.65 + 2.2) * 7;
    const p3y = 80 + Math.cos(phase * 0.8 + 1.7) * 6;
    const p4x = 20 + Math.sin(phase * 0.75 + 2.8) * 6;
    const p4y = 76 + Math.cos(phase * 0.7 + 0.4) * 7;

    return [
      `radial-gradient(circle at ${p1x}% ${p1y}%, ${rgba(c1[0], c1[1], c1[2], 0.76)} 0%, ${rgba(c1[0], c1[1], c1[2], 0.42)} 18%, ${rgba(c1[0], c1[1], c1[2], 0.12)} 38%, ${rgba(c1[0], c1[1], c1[2], 0)} 62%)`,
      `radial-gradient(circle at ${p2x}% ${p2y}%, ${rgba(c2[0], c2[1], c2[2], 0.70)} 0%, ${rgba(c2[0], c2[1], c2[2], 0.36)} 18%, ${rgba(c2[0], c2[1], c2[2], 0.10)} 38%, ${rgba(c2[0], c2[1], c2[2], 0)} 62%)`,
      `radial-gradient(circle at ${p3x}% ${p3y}%, ${rgba(c3[0], c3[1], c3[2], 0.64)} 0%, ${rgba(c3[0], c3[1], c3[2], 0.32)} 18%, ${rgba(c3[0], c3[1], c3[2], 0.09)} 38%, ${rgba(c3[0], c3[1], c3[2], 0)} 62%)`,
      `radial-gradient(circle at ${p4x}% ${p4y}%, ${rgba(c4[0], c4[1], c4[2], 0.46)} 0%, ${rgba(c4[0], c4[1], c4[2], 0.22)} 16%, ${rgba(c4[0], c4[1], c4[2], 0.06)} 34%, ${rgba(c4[0], c4[1], c4[2], 0)} 58%)`,
      `linear-gradient(145deg, rgba(0,0,0,0.42), rgba(0,0,0,0.20) 42%, rgba(0,0,0,0.34))`,
      `radial-gradient(circle at 14% 84%, rgba(0,0,0,0.34) 0%, rgba(0,0,0,0.16) 24%, rgba(0,0,0,0) 54%)`
    ].join(', ');
  }

  function renderGradientFrame() {
    if (!settings.blobs) return;
    const now = performance.now();
    const bucket = Math.floor(now / 140);
    if (bucket === state.lastGradientBucket) return;
    state.lastGradientBucket = bucket;

    const gradient = buildAnimatedGradient(state.currentColors, now / 2600);
    if (gradient === state.lastRenderedGradient) return;
    state.lastRenderedGradient = gradient;

    getBlobTargets().forEach(el => {
      el.style.setProperty('background-image', gradient, 'important');
      el.style.setProperty('background-repeat', 'no-repeat, no-repeat, no-repeat, no-repeat, no-repeat, no-repeat', 'important');
    });
  }

  function clearBlobs() {
    getBlobTargets().forEach(el => el.style.removeProperty('background-image'));
  }

  function schedulePaletteUpdate(delay = 120) {
    if (state.paletteTimer) clearTimeout(state.paletteTimer);
    state.paletteTimer = setTimeout(async () => {
      if (!settings.blobs) return;
      const picked = pickBestArtworkSource();
      if (!picked.src || picked.src === state.lastArtworkSrc) return;
      const colors = await extractColorsFromArtwork();
      if (colors && colors.length) {
        state.currentColors = colors;
        state.lastArtworkSrc = picked.src;
        state.lastGradientBucket = -1;
        state.lastRenderedGradient = '';
      }
    }, delay);
  }

  function renderSettingsPanel() {
    if (!state.panelEl) return;
    state.panelEl.innerHTML = `
      <div class="tp-panel-title">Tidal Polish</div>
      ${[
        ['blobs', 'Animated Background', ''],
        ['floatingArt', 'Floating artwork', ''],
        ['fullscreenMeta', 'Song info', 'Shows Singer and Track name when fullscreen'],
        ['lyricFocus', 'Lyrics focus', 'Blurs not sung lines']
      ].map(([key, name, desc]) => `
        <div class="tp-setting">
          <div>
            <div class="tp-setting-name">${name}</div>
            <div class="tp-setting-desc">${desc}</div>
          </div>
          <button class="tp-switch" type="button" role="switch" aria-checked="${settings[key] ? 'true' : 'false'}" data-key="${key}"></button>
        </div>
      `).join('')}
    `;
  }

  function ensureControls() {
    if (!state.toggleBtnEl || !document.body.contains(state.toggleBtnEl)) {
      const btn = document.createElement('button');
      btn.className = 'tp-toggle-btn';
      btn.type = 'button';
      btn.textContent = '✦';
      btn.setAttribute('aria-label', 'Toggle Tidal Polish settings');
      btn.addEventListener('click', () => state.panelEl?.classList.toggle('tp-open'));
      document.body.appendChild(btn);
      state.toggleBtnEl = btn;
    }

    if (!state.panelEl || !document.body.contains(state.panelEl)) {
      const panel = document.createElement('div');
      panel.className = 'tp-panel';
      panel.addEventListener('click', e => {
        const btn = e.target.closest('.tp-switch');
        if (!btn) return;
        const key = btn.dataset.key;
        settings[key] = !settings[key];
        applySettingClasses();
        renderSettingsPanel();
        if (!settings.blobs) clearBlobs();
        if (settings.blobs) schedulePaletteUpdate(0);
        updateFloatingArtworkState();
        updateMetaOverlay();
      });
      document.body.appendChild(panel);
      state.panelEl = panel;
    }

    renderSettingsPanel();
  }

  function setupObserver() {
    if (state.observer) return;
    state.observer = new MutationObserver(mutations => {
      let needsPalette = false;
      let needsMeta = false;
      let needsFloat = false;
      let needsLyrics = false;

      for (const mutation of mutations) {
        if (mutation.type === 'attributes') {
          if (mutation.attributeName === 'src' || mutation.attributeName === 'srcset') needsPalette = true;
          if (mutation.attributeName === 'aria-hidden' || mutation.attributeName === 'class') {
            needsMeta = true;
            needsFloat = true;
          }
        }
        if (mutation.addedNodes && mutation.addedNodes.length) {
          needsMeta = true;
          needsFloat = true;
          needsLyrics = true;
        }
      }

      if (needsPalette) schedulePaletteUpdate(90);
      if (needsMeta) setTimeout(updateMetaOverlay, 0);
      if (needsFloat) setTimeout(updateFloatingArtworkState, 0);
      if (needsLyrics) setTimeout(attachLyricObserver, 0);
    });

    state.observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['src', 'srcset', 'class', 'aria-hidden']
    });
  }

  function start() {
    injectCSS();
    applySettingClasses();
    attachLyricObserver();
    upgradeMedia();
    ensureMetaOverlay();
    ensureControls();
    updateFloatingArtworkState();
    updateMetaOverlay();
    schedulePaletteUpdate(20);
    setupObserver();

    state.mediaTimer = setInterval(upgradeMedia, 3000);
    state.metaTimer = setInterval(updateMetaOverlay, 700);
    state.lyricsTimer = setInterval(attachLyricObserver, 2500);
    state.gradientTimer = setInterval(renderGradientFrame, 140);
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', start, { once: true });
  } else {
    start();
  }
})();