NTS Test

Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, yt-dlp command generator, and Archive Backfill.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

// ==UserScript==
// @name         NTS Test
// @namespace    Violentmonkey Scripts
// @match        https://www.nts.live/*
// @match        https://nts.live/*
// @noframes
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addElement
// @grant        unsafeWindow
// @version      2.7
// @author       Dan
// @license      MIT
// @description  Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, yt-dlp command generator, and Archive Backfill.
// ==/UserScript==

const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const DEBUG = false;

const trackBus = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('nts-live-tracks') : null;
const DEDUPE_WINDOW_MS = 20 * 60 * 1000;
const BACKFILL_KEY = 'nts_backfill_queue';

GM_addStyle(`
.nts-tracklist-toggle-btn {
    position: fixed;
    bottom: 24px;
    right: 24px;
    background: #000;
    color: #fff;
    border: 1px solid rgba(255, 255, 255, 0.3);
    border-radius: 20px;
    padding: 8px 16px;
    font-size: 12px;
    font-weight: 700;
    cursor: pointer;
    z-index: 999999;
    box-shadow: 0 4px 14px rgba(0, 0, 0, 0.7);
    display: flex;
    align-items: center;
    gap: 6px;
    max-width: 320px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    transition: transform 0.15s, background 0.15s;
}
.nts-tracklist-toggle-btn:hover {
    background: #1e1e1e;
    transform: scale(1.03);
}

.nts-tracklist-pane {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace, sans-serif;
    font-size: 12px;
    position: fixed;
    bottom: 24px;
    right: 24px;
    width: min(660px, 92vw);
    height: min(650px, calc(100vh - 120px));
    background: rgba(18, 18, 18, 0.95);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    color: #eee;
    border: 1px solid rgba(255, 255, 255, 0.16);
    border-radius: 8px;
    z-index: 999999;
    display: flex;
    flex-direction: column;
    box-shadow: 0 12px 36px rgba(0, 0, 0, 0.85);
    overflow: hidden;
    resize: both;
    min-width: 360px;
    min-height: 220px;
}

.nts-tracklist-header {
    padding: 10px 14px;
    background: rgba(0, 0, 0, 0.7);
    border-bottom: 1px solid rgba(255, 255, 255, 0.15);
    display: flex;
    justify-content: space-between;
    align-items: center;
    user-select: none;
    cursor: move;
    touch-action: none;
}

.nts-tracklist-title {
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.5px;
    text-transform: uppercase;
    color: #fff;
}

.nts-header-actions {
    display: flex;
    align-items: center;
    gap: 5px;
}

.nts-btn-icon {
    background: rgba(255, 255, 255, 0.1);
    border: 1px solid rgba(255, 255, 255, 0.18);
    color: #eee;
    border-radius: 4px;
    padding: 3px 8px;
    font-size: 11px;
    cursor: pointer;
    line-height: 1.2;
    transition: background 0.15s;
}
.nts-btn-icon:hover {
    background: rgba(255, 255, 255, 0.25);
    color: #fff;
}

.nts-tracklist-search {
    padding: 8px 12px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
    background: rgba(0, 0, 0, 0.3);
}
.nts-tracklist-search input {
    width: 100%;
    background: rgba(255, 255, 255, 0.08);
    border: 1px solid rgba(255, 255, 255, 0.15);
    border-radius: 4px;
    color: #fff;
    padding: 6px 10px;
    font-size: 12px;
    outline: none;
    box-sizing: border-box;
}
.nts-tracklist-search input:focus {
    border-color: rgba(255, 255, 255, 0.4);
}

.nts-tracklist-content {
    flex: 1;
    overflow-y: auto;
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.nts-tracklist-content::-webkit-scrollbar {
    width: 6px;
}
.nts-tracklist-content::-webkit-scrollbar-thumb {
    background-color: rgba(255, 255, 255, 0.25);
    border-radius: 3px;
}

.nts-tracklist-table {
    width: 100%;
    border-collapse: collapse;
    text-align: left;
}
.nts-tracklist-table th,
.nts-tracklist-table td {
    padding: 7px 10px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.06);
    vertical-align: middle;
}
.nts-tracklist-table th {
    background: rgba(0, 0, 0, 0.5);
    font-size: 11px;
    text-transform: uppercase;
    color: #888;
    position: sticky;
    top: 0;
    z-index: 2;
}

.nts-tracklist-table tr:hover {
    background: rgba(255, 255, 255, 0.05);
}

.nts-tracklist-table tr.playing {
    background: rgba(29, 185, 84, 0.22) !important;
}
.nts-tracklist-table tr.playing td {
    color: #fff;
}

.nts-tracklist-table td.playing-icon {
    width: 16px;
    text-align: center;
    color: #1DB954;
    font-size: 10px;
    padding-left: 6px;
    padding-right: 2px;
}
.nts-tracklist-table tr.playing .playing-icon::before {
    content: '▶';
}

.nts-jump-btn {
    background: none;
    border: none;
    padding: 0;
    color: #fff;
    font: inherit;
    cursor: pointer;
    text-align: left;
}
.nts-jump-btn:hover {
    color: #1DB954;
    text-decoration: underline;
}

.nts-no-offset {
    color: #888;
    cursor: default;
}

.nts-dim-title {
    color: #777;
    font-style: italic;
}

.nts-links-cell {
    display: flex;
    gap: 4px;
    align-items: center;
}
.nts-service-link {
    font-size: 10px;
    text-decoration: none;
    font-weight: 700;
    padding: 2px 4px;
    border-radius: 3px;
    line-height: 1;
    background: rgba(255, 255, 255, 0.08);
    border: none;
    cursor: pointer;
}
.nts-spotify { color: #1DB954; }
.nts-bc { color: #1DA0C3; }
.nts-discogs { color: #FF9800; }
.nts-yt { color: #FF4444; }
.nts-copy { color: #aaa; }
.nts-service-link:hover { background: rgba(255, 255, 255, 0.2); }
`);

const MIXTAPE_RE = /^\/infinite-mixtapes\/([^/]+)/;

let navToken = 0;
let lastPath = null;
let fetchTapped = false;
let xhrTapped = false;

let mcWidget = null;
let mcIframe = null;
let scWidget = null;
let scIframe = null;

const store = {
  liveMode: false,
  tracks: [],
  seen: new Map(),
  lastTimedIndex: -1,
  currentTrackIndex: null,
  activeAudio: null,
  abortController: null,
  liveObserver: null,
  showTitle: '',
  episodeTitle: '',
  dom: {},
};

// --- Page & Route Detection ---
function isLivePage() {
  const p = location.pathname;
  if (p.startsWith('/live-tracklist')) return false;
  return p === '/' || p.startsWith('/live') || MIXTAPE_RE.test(p);
}

function detectActiveChannelOrMixtape() {
  const p = location.pathname;
  const m = p.match(MIXTAPE_RE);
  if (m) return m[1];

  const audio = document.querySelector('audio[src*="stream"]');
  const src = audio?.src || '';
  if (src.includes('stream2')) return '2';
  if (src.includes('stream')) return '1';

  const ch2 = document.querySelector('[aria-label*="Channel 2" i], button.live-channel--2');
  if (ch2?.classList.contains('active') || ch2?.getAttribute('aria-pressed') === 'true') return '2';
  return '1';
}

function escapeHtml(str) {
  if (!str) return '';
  return String(str)
    .replace(/&/g, '&')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#039;');
}

function formatTime(seconds) {
  if (seconds == null || isNaN(seconds) || seconds < 0) return '--:--';
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const remSeconds = Math.floor(seconds % 60);

  if (hours > 0) {
    return `${hours}:${minutes.toString().padStart(2, '0')}:${remSeconds.toString().padStart(2, '0')}`;
  }
  return `${minutes}:${remSeconds.toString().padStart(2, '0')}`;
}

async function copyText(text) {
  if (typeof GM_setClipboard === 'function') {
    GM_setClipboard(text);
    return true;
  }
  try {
    await navigator.clipboard.writeText(text);
    return true;
  } catch {
    return false;
  }
}

function generateCueSheet(tracks, showTitle, episodeTitle) {
  let cue = `PERFORMER "${(showTitle || 'NTS Live').replace(/"/g, "'")}"\nTITLE "${(episodeTitle || 'Episode').replace(/"/g, "'")}"\nFILE "mix.mp3" MP3\n`;
  let trackCounter = 1;

  tracks.forEach((t) => {
    if (t.offset == null) return;
    const trackNum = String(trackCounter++).padStart(2, '0');
    const mins = Math.floor(t.offset / 60);
    const secs = Math.floor(t.offset % 60);
    const mm = String(mins).padStart(2, '0');
    const ss = String(secs).padStart(2, '0');
    cue += `  TRACK ${trackNum} AUDIO\n`;
    cue += `    TITLE "${(t.title || 'Untitled').replace(/"/g, "'")}"\n`;
    cue += `    PERFORMER "${(t.artist || 'Unknown').replace(/"/g, "'")}"\n`;
    cue += `    INDEX 01 ${mm}:${ss}:00\n`;
  });
  return cue;
}

// --- yt-dlp Command Generator ---
function generateYtDlpCommand(mode = 'original') {
  const url = location.href;
  const baseOutput = `"%(upload_date)s - %(title)s.%(ext)s"`;

  switch (mode) {
    case 'mp3':
      return `yt-dlp "${url}" -x --audio-format mp3 --audio-quality 0 --embed-metadata --embed-thumbnail --embed-chapters --write-info-json --write-description --write-thumbnail -o ${baseOutput}`;
    case 'split':
      return `yt-dlp "${url}" -f "bestaudio/best" --split-chapters --embed-metadata --embed-thumbnail --write-info-json -o "chapter:%(upload_date)s - %(title)s/%(section_number)02d - %(section_title)s.%(ext)s"`;
    case 'original':
    default:
      return `yt-dlp "${url}" -f "bestaudio/best" --embed-metadata --embed-thumbnail --embed-chapters --write-info-json --write-description --write-thumbnail -o ${baseOutput}`;
  }
}

function isSeekable(a) {
  return a && Number.isFinite(a.duration) && a.duration > 0 && a.seekable.length > 0;
}

function getAudioElement() {
  if (store.activeAudio?.isConnected && isSeekable(store.activeAudio)) return store.activeAudio;

  const candidates = [
    ...document.querySelectorAll('.soundcloud-player__content audio'),
    ...document.querySelectorAll('audio'),
  ];
  store.activeAudio = candidates.find(isSeekable) || null;
  return store.activeAudio;
}

function ensurePlaybackStarted() {
  const isPlaying = getAudioElement() ||
                    document.querySelector('audio[src*="stream"]') ||
                    document.querySelector('iframe[src*="soundcloud.com"]') ||
                    document.querySelector('iframe[src*="mixcloud.com"]');
  if (!isPlaying) {
    const playBtn = document.querySelector('button[aria-label*="Play" i], button.play, .play-button, [class*="play-button"]');
    if (playBtn) playBtn.click();
  }
}

// --- Official Mixcloud Widget API Bridge ---
function loadMixcloudApi() {
  return new Promise((resolve, reject) => {
    if (win.Mixcloud?.PlayerWidget) return resolve(win.Mixcloud);
    const existing = document.getElementById('nts-mc-api');
    if (existing) {
      if (win.Mixcloud) return resolve(win.Mixcloud);
      existing.addEventListener('load', () => resolve(win.Mixcloud));
      existing.addEventListener('error', reject);
      return;
    }

    const src = 'https://widget.mixcloud.com/media/js/widgetApi.js';
    let scriptEl;
    if (typeof GM_addElement === 'function') {
      scriptEl = GM_addElement('script', { src, id: 'nts-mc-api' });
    } else {
      scriptEl = document.createElement('script');
      scriptEl.id = 'nts-mc-api';
      scriptEl.src = src;
      document.head.appendChild(scriptEl);
    }
    scriptEl.addEventListener('load', () => win.Mixcloud ? resolve(win.Mixcloud) : reject(new Error('No Mixcloud global')));
    scriptEl.addEventListener('error', reject);
  });
}

async function attachMixcloud() {
  const iframe = document.querySelector('iframe[src*="mixcloud.com/widget"], iframe[src*="player-widget.mixcloud.com"], iframe[src*="widget.mixcloud.com"]');
  if (!iframe || (iframe === mcIframe && mcWidget)) return;

  try {
    const Mixcloud = await loadMixcloudApi();

    if (!iframe.dataset.ntsReady) {
      await new Promise((resolve) => {
        iframe.addEventListener('load', resolve, { once: true });
        setTimeout(resolve, 2500);
      });
      iframe.dataset.ntsReady = '1';
    }

    const widget = Mixcloud.PlayerWidget(iframe);
    await widget.ready;

    widget.events.progress.on((position) => {
      if (!store.liveMode && store.tracks.length && position != null) {
        updateCurrentTrack(position);
      }
    });

    mcWidget = widget;
    mcIframe = iframe;
  } catch (e) {
    mcWidget = null;
  }
}

function seekMixcloud(offset) {
  if (!mcWidget) return false;
  mcWidget.seek(Number(offset)).then((ok) => {
    if (ok !== false) mcWidget.play();
  }).catch(() => {});
  return true;
}

// --- Official SoundCloud Widget API Bridge ---
function attachSoundCloud() {
  const iframe = document.querySelector('iframe[src*="soundcloud.com"], .soundcloud-player__content iframe');
  if (!iframe || (iframe === scIframe && scWidget)) return;

  if (win.SC?.Widget) {
    try {
      const widget = win.SC.Widget(iframe);
      const SCevents = win.SC.Widget.Events;
      widget.bind(SCevents.READY, () => {
        widget.bind(SCevents.PLAY_PROGRESS, (data) => {
          if (!store.liveMode && store.tracks.length && data?.currentPosition != null) {
            updateCurrentTrack(data.currentPosition / 1000);
          }
        });
      });
      scWidget = widget;
      scIframe = iframe;
    } catch (e) {
      scWidget = null;
    }
  }
}

function seekSoundCloud(offset) {
  const iframe = document.querySelector('iframe[src*="soundcloud.com"], .soundcloud-player__content iframe');
  if (!iframe) return false;

  if (scWidget) {
    try {
      scWidget.seekTo(Number(offset) * 1000);
      scWidget.play();
      return true;
    } catch (e) {}
  }

  if (iframe.contentWindow) {
    iframe.contentWindow.postMessage(JSON.stringify({ method: 'seekTo', value: Number(offset) * 1000 }), '*');
    iframe.contentWindow.postMessage(JSON.stringify({ method: 'play' }), '*');
    return true;
  }
  return false;
}

// --- Universal Seek Router ---
function jumpToOffset(offset) {
  if (store.liveMode || offset == null || isNaN(offset)) return;
  ensurePlaybackStarted();

  const audio = getAudioElement();
  if (audio) {
    const doSeek = () => {
      const end = audio.seekable.length ? audio.seekable.end(audio.seekable.length - 1) : 0;
      audio.currentTime = Math.min(Number(offset), Math.max(0, end - 1));
    };

    if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
      doSeek();
      if (audio.paused) audio.play().catch(() => {});
    } else {
      audio.addEventListener('loadedmetadata', doSeek, { once: true });
      audio.play().catch(() => {});
    }
    return;
  }

  if (seekMixcloud(offset)) return;
  if (seekSoundCloud(offset)) return;

  const progressBar = document.querySelector('[class*="progressbar"], [class*="scrubber"], input[type="range"]');
  if (progressBar) {
    const lastTrack = store.tracks[store.tracks.length - 1];
    const totalDuration = (lastTrack && lastTrack.offset ? lastTrack.offset + 300 : 7200);
    const ratio = Math.min(Math.max(Number(offset) / totalDuration, 0), 1);
    const rect = progressBar.getBoundingClientRect();
    const clickX = rect.left + rect.width * ratio;
    const clickY = rect.top + rect.height / 2;

    progressBar.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: clickX, clientY: clickY }));
    progressBar.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: clickX, clientY: clickY }));
  }
}

function findActiveIndex(tracks, currentTime) {
  if (!tracks.length || currentTime == null || store.lastTimedIndex < 0) return -1;
  if (tracks[0].offset > currentTime) return -1;

  let low = 0;
  let high = store.lastTimedIndex;
  let best = -1;

  while (low <= high) {
    const mid = (low + high) >> 1;
    if (tracks[mid].offset <= currentTime) {
      best = mid;
      low = mid + 1;
    } else {
      high = mid - 1;
    }
  }
  return best;
}

function updateCurrentTrack(currentTime) {
  if (store.liveMode) return;

  const activeIndex = findActiveIndex(store.tracks, currentTime);
  if (activeIndex === store.currentTrackIndex) return;

  const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
  if (!tbody) {
    store.currentTrackIndex = activeIndex;
    return;
  }

  if (store.currentTrackIndex !== null) {
    const prevRow = tbody.querySelector(`tr[data-index="${store.currentTrackIndex}"]`);
    if (prevRow) prevRow.classList.remove('playing');
  }

  const row = tbody.querySelector(`tr[data-index="${activeIndex}"]`);
  if (row) {
    row.classList.add('playing');

    const content = store.dom.content || document.querySelector('.nts-tracklist-content');
    const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');
    if (content && !pane?.matches(':hover')) {
      const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
      content.scrollTo({
        top: row.offsetTop - content.clientHeight / 2 + row.clientHeight / 2,
        behavior: prefersReducedMotion ? 'auto' : 'smooth'
      });
    }
  }

  store.currentTrackIndex = activeIndex;

  const toggleBtn = document.querySelector('.nts-tracklist-toggle-btn');
  if (toggleBtn && activeIndex !== -1 && store.tracks[activeIndex]) {
    const t = store.tracks[activeIndex];
    toggleBtn.textContent = `▶ ${t.artist}${t.title ? ' - ' + t.title : ''}`;
  }
}

// --- Robust String & Stream Parser ---
function isJunkString(str) {
  if (!str) return true;
  const s = str.trim();
  if (!s || s.length > 180) return true;

  if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return true;

  const junkRegex = /^(no tracks found|live tracklist|tracklist|listen live|live on [12]|channel [12]|unlock live|nts supporters?|nts radio|nts live|nts|unknown|loading\.{0,3}|enable real-time notifications.*|recently played)$/i;
  if (junkRegex.test(s)) return true;

  if (s.includes('Live on 1') && s.includes('Live on 2')) return true;

  return false;
}

function parseNowPlaying(raw) {
  if (isJunkString(raw)) return null;
  const clean = raw.trim();

  const match = clean.split(/\s+[-–—]\s+/);
  if (match.length >= 2) {
    const artist = match[0].trim();
    const title = match.slice(1).join(' - ').trim();
    if (artist.toLowerCase() === title.toLowerCase()) {
      return { artist, title: null };
    }
    return { artist, title };
  }

  return { artist: clean, title: null };
}

// --- Live Session Backfill Queue ---
function loadQueue() {
  try { return JSON.parse(localStorage.getItem(BACKFILL_KEY)) || []; }
  catch { return []; }
}

function saveQueue(q) {
  localStorage.setItem(BACKFILL_KEY, JSON.stringify(q.slice(-50)));
}

function queueLiveShow(chData) {
  const details = chData?.now?.embeds?.details;
  if (!details) return;

  const showAlias = details.show_alias || details.alias || null;
  const startTs = chData?.now?.start_timestamp || null;
  if (!showAlias) return;

  const entry = {
    broadcastTitle: chData?.now?.broadcast_title || details.name || 'Live Show',
    showAlias,
    startTs,
    listenedAt: Date.now(),
    resolved: false,
    tracklist: [],
  };

  const q = loadQueue();
  if (q.some(e => e.showAlias === entry.showAlias && e.startTs === entry.startTs)) return;
  q.push(entry);
  saveQueue(q);
}

async function processBackfillQueue(force = false) {
  const q = loadQueue();
  const ready = q.filter(e => !e.resolved && (force || Date.now() - e.listenedAt > 2.5 * 3600 * 1000));
  if (!ready.length) return q.filter(e => e.resolved);

  for (const entry of ready) {
    try {
      const res = await fetch(`/api/v2/shows/${entry.showAlias}/episodes?limit=6`);
      if (!res.ok) continue;
      const data = await res.json();

      const ep = (data.results || []).find(e =>
        e.broadcast && entry.startTs && Math.abs(new Date(e.broadcast) - new Date(entry.startTs)) < 3600 * 1000 * 2
      ) || (data.results?.[0]);

      if (!ep?.episode_alias) continue;

      const epRes = await fetch(`/api/v2/shows/${entry.showAlias}/episodes/${ep.episode_alias}`);
      if (!epRes.ok) continue;
      const epData = await epRes.json();

      const tracks = (epData.tracklist || []).map(parseRawTrack);
      if (tracks.length) {
        entry.tracklist = tracks;
        entry.episodeUrl = `https://www.nts.live/shows/${entry.showAlias}/episodes/${ep.episode_alias}`;
        entry.resolved = true;
      }
    } catch {}
  }
  saveQueue(q);
  return q.filter(e => e.resolved);
}

// --- Supporter Live Tracklist Embed Frame Bridge ---
const LT_URL = (slug) => `https://www.nts.live/live-tracklist/${slug}?embed=true`;

let ltFrame = null;
let ltObserver = null;
let ltTarget = null;
let ltPoll = null;
let ltWatch = null;

function ltScrapeDoc(doc) {
  if (!doc || !doc.body) return;

  const rows = doc.querySelectorAll('main li, ul:not([class*="nav"]) li, [class*="track-list"] > div, [class*="TrackList"] > div');
  const found = [];
  rows.forEach(row => {
    if (row.querySelector('nav, ul, header, button')) return;

    const artistEl = row.querySelector('[class*="artist"], [class*="Artist"]');
    const titleEl = row.querySelector('[class*="title"], [class*="Title"], [class*="track-name"]');
    const stampEl = row.querySelector('time, [datetime], [class*="time"], [class*="Time"]');

    const artist = artistEl?.textContent?.trim();
    const title = titleEl?.textContent?.trim();
    const stamp = stampEl?.textContent?.trim() || null;

    if (artist && !isJunkString(artist)) {
      found.push({
        artist,
        title: (title && !isJunkString(title) && title.toLowerCase() !== artist.toLowerCase()) ? title : null,
        seenAt: stamp,
      });
      return;
    }

    const textNodes = Array.from(row.children)
      .map(c => c.textContent.trim())
      .filter(t => t && !isJunkString(t));

    if (textNodes.length >= 2) {
      found.push({
        artist: textNodes[0],
        title: textNodes[1],
        seenAt: stamp,
      });
    }
  });

  found.reverse().forEach(t => pushLiveTrack(t.artist, t.title, t.seenAt));
}

function ltBind(frame) {
  let doc;
  try { doc = frame.contentDocument; } catch { doc = null; }
  if (!doc) return;

  try {
    const nd = doc.getElementById('__NEXT_DATA__');
    if (nd?.textContent) inspectApiPayload(JSON.parse(nd.textContent));
  } catch {}

  ltObserver?.disconnect();
  let scheduled = false;
  ltObserver = new MutationObserver(() => {
    if (scheduled || !store.liveMode) return;
    scheduled = true;
    setTimeout(() => {
      scheduled = false;
      ltScrapeDoc(doc);
    }, 800);
  });
  ltObserver.observe(doc.body, { childList: true, subtree: true, characterData: true });

  ltScrapeDoc(doc);
}

function attachLiveTracklistFrame(slug) {
  if (ltFrame && ltTarget === slug) return;
  detachLiveTracklistFrame();
  ltTarget = slug;

  const frame = document.createElement('iframe');
  frame.id = 'nts-lt-frame';
  frame.src = LT_URL(slug);
  frame.setAttribute('style', [
    'position:fixed', 'left:-10000px', 'top:0',
    'width:480px', 'height:800px',
    'opacity:0', 'pointer-events:none', 'border:0',
  ].join(';'));
  frame.setAttribute('aria-hidden', 'true');
  frame.setAttribute('tabindex', '-1');

  frame.addEventListener('load', () => ltBind(frame));
  document.body.appendChild(frame);
  ltFrame = frame;

  ltPoll = setInterval(() => {
    if (!store.liveMode) return;
    try { ltScrapeDoc(frame.contentDocument); } catch {}
  }, 20000);

  ltWatch = setInterval(() => {
    if (!store.liveMode) return;
    const now = detectActiveChannelOrMixtape();
    if (now !== ltTarget) attachLiveTracklistFrame(now);
  }, 5000);
}

function detachLiveTracklistFrame() {
  ltObserver?.disconnect();
  ltObserver = null;
  clearInterval(ltPoll);
  clearInterval(ltWatch);
  ltPoll = null;
  ltWatch = null;
  ltFrame?.remove();
  ltFrame = null;
  ltTarget = null;
}

// --- Live Supporter Interception Engine ---
function installFetchTap() {
  if (fetchTapped || typeof win.fetch !== 'function') return;
  fetchTapped = true;

  const origFetch = win.fetch;
  win.fetch = function (...args) {
    const p = origFetch.apply(this, args);
    try {
      const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
      if (store.liveMode && /\/api\/.*(now-playing|tracklist|live-tracks|tracks|mixtapes)/i.test(url)) {
        p.then(res => res.clone().json())
          .then(inspectApiPayload)
          .catch(() => {});
      }
    } catch {}
    return p;
  };
}

function installXhrTap() {
  if (xhrTapped || !win.XMLHttpRequest?.prototype) return;
  xhrTapped = true;

  const origOpen = win.XMLHttpRequest.prototype.open;
  const origSend = win.XMLHttpRequest.prototype.send;

  win.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
    this._ntsUrl = String(url);
    return origOpen.call(this, method, url, ...rest);
  };

  win.XMLHttpRequest.prototype.send = function (...args) {
    if (store.liveMode && this._ntsUrl && /\/api\/.*(now-playing|tracklist|live-tracks|tracks|mixtapes)/i.test(this._ntsUrl)) {
      this.addEventListener('load', () => {
        try {
          inspectApiPayload(JSON.parse(this.responseText));
        } catch {}
      });
    }
    return origSend.apply(this, args);
  };
}

function inspectApiPayload(obj, depth = 0) {
  if (!store.liveMode || !obj || typeof obj !== 'object' || depth > 7) return;

  const rawTitle = obj.title || obj.track_title || obj.song_name || obj.song?.title;
  let rawArtist = obj.artist || obj.artist_name;

  if (!rawArtist) {
    if (Array.isArray(obj.artists)) rawArtist = obj.artists.map(a => a.name || a.artist_name || a).join(', ');
    else if (Array.isArray(obj.mainArtists)) rawArtist = obj.mainArtists.map(a => a.name || a.artist_name || a).join(', ');
  }

  if ((rawArtist || rawTitle) && !obj.channel_name && !obj.embeds && !obj.mixtape_alias && !obj.broadcast_title) {
    const artist = (rawArtist || rawTitle || '').trim();
    const title = (rawArtist && rawTitle && rawArtist !== rawTitle) ? rawTitle.trim() : null;
    if (!isJunkString(artist)) {
      pushLiveTrack(artist, title);
    }
  }

  for (const v of Object.values(obj)) {
    inspectApiPayload(v, depth + 1);
  }
}

// --- Live Supporter DOM Scraper ---
function watchNowPlayingDom() {
  store.liveObserver?.disconnect();

  const parseAndPush = (node) => {
    if (!node || ['BUTTON', 'H1', 'H2', 'H3', 'H4', 'H5', 'NAV', 'HEADER'].includes(node.tagName)) return;

    const artistEl = node.querySelector('.track__artist, [class*="artist"]');
    const titleEl = node.querySelector('.track__title, [class*="title"], [class*="track-name"]');

    if (artistEl && titleEl) {
      const a = artistEl.textContent?.trim();
      const t = titleEl.textContent?.trim();
      if (a && !isJunkString(a)) {
        pushLiveTrack(a, (t && !isJunkString(t) && a.toLowerCase() !== t.toLowerCase()) ? t : null);
        return;
      }
    }

    const parsed = parseNowPlaying(node.textContent);
    if (parsed && !isJunkString(parsed.artist)) {
      pushLiveTrack(parsed.artist, parsed.title);
    }
  };

  let scheduled = false;
  const observer = new MutationObserver(() => {
    if (scheduled || !store.liveMode) return;
    scheduled = true;

    setTimeout(() => {
      scheduled = false;
      document.querySelectorAll('[class*="live-tracklist"] li, [class*="now-playing__track"], [class*="nowPlaying"] [class*="track"]').forEach(parseAndPush);
    }, 1000);
  });

  observer.observe(document.body, { childList: true, subtree: true, characterData: true });
  store.liveObserver = observer;
}

function pushLiveTrack(artist, title, seenAt = null) {
  if (!store.liveMode || !artist || isJunkString(artist)) return;
  if (title && isJunkString(title)) title = null;

  if (store.showTitle && (artist.toLowerCase() === store.showTitle.toLowerCase() || title?.toLowerCase() === store.showTitle.toLowerCase())) {
    return;
  }

  const norm = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[‘’“”"']/g, '').trim();
  const nArtist = norm(artist);
  const nTitle = norm(title);

  const finalTitle = (title && nArtist !== nTitle) ? title.trim() : null;

  const key = `${nArtist}|${finalTitle ? norm(finalTitle) : ''}`;
  const nowMs = Date.now();
  const last = store.seen.get(key);
  if (last && nowMs - last < DEDUPE_WINDOW_MS) return;
  store.seen.set(key, nowMs);

  const newTrack = {
    title: finalTitle,
    artist: artist.trim(),
    _lcTitle: (finalTitle || '').toLowerCase(),
    _lcArtist: artist.trim().toLowerCase(),
    offset: null,
    duration: null,
    seenAt: seenAt || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
  };

  store.tracks.push(newTrack);
  store.currentTrackIndex = store.tracks.length - 1;

  const titleHeader = store.dom.title || document.querySelector('.nts-tracklist-title');
  if (titleHeader) {
    titleHeader.textContent = `Tracklist (Session: ${store.tracks.length})`;
  }

  const searchInput = store.dom.searchInput || document.querySelector('#nts-search-input');
  const term = searchInput?.value.toLowerCase().trim() || '';

  if (term) {
    const visible = store.tracks
      .map((track, idx) => ({ track, idx }))
      .filter(({ track }) => track._lcTitle.includes(term) || track._lcArtist.includes(term));
    renderTrackRows(visible);
  } else {
    appendLiveRow(newTrack, store.tracks.length - 1);
  }

  const toggleBtn = document.querySelector('.nts-tracklist-toggle-btn');
  if (toggleBtn) {
    toggleBtn.textContent = `▶ ${newTrack.artist}${newTrack.title ? ' - ' + newTrack.title : ''}`;
  }

  const content = store.dom.content || document.querySelector('.nts-tracklist-content');
  if (content && !term) {
    content.scrollTop = content.scrollHeight;
  }
}

// --- Supporter Popup Bridge Window Engine ---
function initPopupScraper() {
  const isPopup = /live-tracklist|live-tracks/i.test(location.pathname) ||
    (window.opener !== null && window.name === 'nts-tracklist');
  if (!isPopup) return false;

  const scrape = () => {
    document.querySelectorAll('main li, ul:not([class*="nav"]) li, [class*="track-list"] > div').forEach(node => {
      if (node.querySelector('nav, ul, header, button')) return;

      const artistEl = node.querySelector('[class*="artist"]');
      const titleEl = node.querySelector('[class*="title"], [class*="track-name"]');

      if (artistEl && titleEl) {
        const a = artistEl.textContent?.trim();
        const t = titleEl.textContent?.trim();
        if (a && !isJunkString(a)) {
          trackBus?.postMessage({ artist: a, title: (!isJunkString(t) && a.toLowerCase() !== t.toLowerCase()) ? t : null });
          return;
        }
      }

      const parsed = parseNowPlaying(node.textContent);
      if (parsed && !isJunkString(parsed.artist)) {
        trackBus?.postMessage(parsed);
      }
    });
  };

  new MutationObserver(scrape).observe(document.body, { childList: true, subtree: true, characterData: true });
  scrape();
  return true;
}

// Pointer Events Drag with Viewport Bounds Clamping
function makeDraggable(pane, header) {
  let startX = 0, startY = 0, startLeft = 0, startTop = 0;

  header.addEventListener('pointerdown', (e) => {
    if (['BUTTON', 'INPUT', 'A'].includes(e.target.tagName)) return;
    e.preventDefault();
    header.setPointerCapture(e.pointerId);

    const rect = pane.getBoundingClientRect();
    startX = e.clientX;    startY = e.clientY;
    startLeft = rect.left;
    startTop = rect.top;

    const onPointerMove = (ev) => {
      const dx = ev.clientX - startX;
      const dy = ev.clientY - startY;
      const maxLeft = Math.max(0, window.innerWidth - pane.offsetWidth);
      const maxTop = Math.max(0, window.innerHeight - pane.offsetHeight);

      pane.style.left = `${Math.min(Math.max(0, startLeft + dx), maxLeft)}px`;
      pane.style.top = `${Math.min(Math.max(0, startTop + dy), maxTop)}px`;
      pane.style.bottom = 'auto';
      pane.style.right = 'auto';
    };

    const onPointerUp = (ev) => {
      header.releasePointerCapture(ev.pointerId);
      header.removeEventListener('pointermove', onPointerMove);
      header.removeEventListener('pointerup', onPointerUp);
    };

    header.addEventListener('pointermove', onPointerMove);
    header.addEventListener('pointerup', onPointerUp);
  });
}

function buildRowHtml(track, idx, isCurrent) {
  const rawQuery = track.title ? `${track.artist} ${track.title}` : track.artist;
  const query = encodeURIComponent(rawQuery.trim());
  const spotifyUrl = `https://open.spotify.com/search/${query}`;
  const bcUrl = `https://bandcamp.com/search?q=${query}`;
  const discogsUrl = `https://www.discogs.com/search/?q=${query}&type=release`;
  const ytUrl = `https://www.youtube.com/results?search_query=${query}`;
  const copyPayload = escapeHtml(track.title ? `${track.artist} - ${track.title}` : track.artist);

  let timeDisplay;
  if (store.liveMode) {
    timeDisplay = `<span class="nts-no-offset">${track.seenAt || '--:--'}</span>`;
  } else if (track.offset != null) {
    timeDisplay = `<button type="button" class="nts-jump-btn" data-offset="${track.offset}" title="Jump to ${formatTime(track.offset)}">${formatTime(track.offset)}</button>`;
  } else {
    timeDisplay = `<span class="nts-no-offset">--:--</span>`;
  }

  let titleDisplay;
  if (track.title) {
    titleDisplay = (!store.liveMode && track.offset != null)
      ? `<button type="button" class="nts-jump-btn" data-offset="${track.offset}">${escapeHtml(track.title)}</button>`
      : `<span>${escapeHtml(track.title)}</span>`;
  } else {
    titleDisplay = `<span class="nts-dim-title">—</span>`;
  }

  return `
    <tr data-index="${idx}" class="${isCurrent ? 'playing' : ''}">
      <td class="playing-icon"></td>
      <td>${timeDisplay}</td>
      <td class="title-col">${titleDisplay}</td>
      <td>${escapeHtml(track.artist || 'Unknown')}</td>
      <td>${track.duration ? formatTime(track.duration) : '-'}</td>
      <td>
        <div class="nts-links-cell">
          <a class="nts-service-link nts-spotify" href="${spotifyUrl}" target="_blank" rel="noopener" title="Spotify">SP</a>
          <a class="nts-service-link nts-bc" href="${bcUrl}" target="_blank" rel="noopener" title="Bandcamp">BC</a>
          <a class="nts-service-link nts-discogs" href="${discogsUrl}" target="_blank" rel="noopener" title="Discogs">DC</a>
          <a class="nts-service-link nts-yt" href="${ytUrl}" target="_blank" rel="noopener" title="YouTube">YT</a>
          <button type="button" class="nts-service-link nts-copy" data-copy="${copyPayload}" title="Copy">📋</button>
        </div>
      </td>
    </tr>
  `;
}

function renderTrackRows(visibleItems) {
  const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
  if (!tbody) return;

  if (!visibleItems.length) {
    const emptyMsg = store.liveMode
      ? 'Waiting for track data… (Live per-track info requires NTS Supporter, or click Backfill)'
      : 'No matching tracks';
    tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 24px 14px; color: #888; line-height: 1.4;">${emptyMsg}</td></tr>`;
    return;
  }

  tbody.innerHTML = visibleItems.map(({ track, idx }) => buildRowHtml(track, idx, idx === store.currentTrackIndex)).join('');
}

function appendLiveRow(track, idx) {
  const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
  if (!tbody) return;

  tbody.querySelector('td[colspan]')?.closest('tr')?.remove();

  const prev = tbody.querySelector('tr.playing');
  prev?.classList.remove('playing');

  const tmp = document.createElement('tbody');
  tmp.innerHTML = buildRowHtml(track, idx, true);
  if (tmp.firstElementChild) {
    tbody.appendChild(tmp.firstElementChild);
  }
}

function createTracklistPane() {
  destroyPane(false);

  const pane = document.createElement('div');
  pane.className = 'nts-tracklist-pane';

  let countLabel;
  if (store.liveMode) {
    countLabel = `Session: ${store.tracks.length}`;
  } else {
    const timedCount = store.lastTimedIndex + 1;
    countLabel = timedCount > 0 && timedCount !== store.tracks.length
      ? `${timedCount}/${store.tracks.length} timed`
      : `${store.tracks.length}`;
  }

  pane.innerHTML = `
    <div class="nts-tracklist-header" id="nts-drag-header">
      <span class="nts-tracklist-title">Tracklist (${countLabel})</span>
      <div class="nts-header-actions">
        ${store.liveMode ? `
          <button type="button" class="nts-btn-icon" id="nts-backfill-btn" title="Backfill from NTS Archive">Backfill</button>
        ` : `
          <button type="button" class="nts-btn-icon" id="nts-prev-btn" title="Previous Track">⏮</button>
          <button type="button" class="nts-btn-icon" id="nts-next-btn" title="Next Track">⏭</button>
        `}
        <button type="button" class="nts-btn-icon" id="nts-export-btn" title="Export Tracklist">Export</button>
        <button type="button" class="nts-btn-icon" id="nts-ytdlp-btn" title="Copy yt-dlp download commands">yt-dlp</button>
        ${!store.liveMode ? `
          <button type="button" class="nts-btn-icon" id="nts-cue-btn" title="Export CUE Sheet">CUE</button>
        ` : ''}
        <button type="button" class="nts-btn-icon" id="nts-close-btn" title="Minimize (T)">✕</button>
      </div>
    </div>
    <div class="nts-tracklist-search">
      <input type="text" id="nts-search-input" placeholder="Filter tracks or artists... (Esc to clear)" />
    </div>
    <div class="nts-tracklist-content">
      <table class="nts-tracklist-table">
        <thead>
          <tr>
            <th></th>
            <th>${store.liveMode ? 'Heard' : 'Time'}</th>
            <th>Title</th>
            <th>Artist</th>
            <th>Dur</th>
            <th>Links</th>
          </tr>
        </thead>
        <tbody></tbody>
      </table>
    </div>
  `;

  store.dom = {
    pane,
    tbody: pane.querySelector('.nts-tracklist-table tbody'),
    content: pane.querySelector('.nts-tracklist-content'),
    title: pane.querySelector('.nts-tracklist-title'),
    searchInput: pane.querySelector('#nts-search-input'),
    exportBtn: pane.querySelector('#nts-export-btn'),
    ytdlpBtn: pane.querySelector('#nts-ytdlp-btn'),
    backfillBtn: pane.querySelector('#nts-backfill-btn'),
    cueBtn: pane.querySelector('#nts-cue-btn'),
  };

  pane.addEventListener('click', async (e) => {
    const jumpEl = e.target.closest('[data-offset]');
    if (jumpEl && !store.liveMode) {
      jumpToOffset(Number(jumpEl.dataset.offset));
      return;
    }

    const copyEl = e.target.closest('[data-copy]');
    if (copyEl) {
      const val = copyEl.getAttribute('data-copy');
      const ok = await copyText(val);
      if (ok) {
        copyEl.textContent = '✓';
        setTimeout(() => (copyEl.textContent = '📋'), 1500);
      }
    }
  });

  // --- yt-dlp Dropdown Menu Handler ---
  store.dom.ytdlpBtn?.addEventListener('click', (e) => {
    e.stopPropagation();
    pane.querySelectorAll('.nts-export-menu').forEach(m => m.remove());

    const menu = document.createElement('div');
    menu.className = 'nts-export-menu nts-ytdlp-menu';
    menu.style.right = store.liveMode ? '34px' : '78px';
    menu.innerHTML = `
      <button data-mode="original" title="Direct stream audio (AAC/M4A) without re-encoding">Original (Best)</button>
      <button data-mode="mp3" title="Convert to MP3 for legacy/DJ hardware">Convert to MP3</button>
      <button data-mode="split" title="Split into individual track files using tracklist">Split Tracks</button>
    `;
    pane.appendChild(menu);

    menu.addEventListener('click', async (evt) => {
      const mode = evt.target.dataset.mode;
      if (!mode) return;
      const cmd = generateYtDlpCommand(mode);
      const ok = await copyText(cmd);
      evt.target.textContent = ok ? 'Copied ✓' : 'Failed';
      setTimeout(() => menu.remove(), 700);
    });

    const closeListener = (ev) => {
      if (!menu.contains(ev.target) && ev.target !== store.dom.ytdlpBtn) {
        menu.remove();
        document.removeEventListener('click', closeListener);
      }
    };
    setTimeout(() => document.addEventListener('click', closeListener), 0);
  });

  if (store.liveMode) {
    store.dom.backfillBtn?.addEventListener('click', async () => {
      const btn = store.dom.backfillBtn;
      btn.textContent = 'Checking...';
      const resolved = await processBackfillQueue(true);

      if (!resolved.length) {
        btn.textContent = 'Queued (~2-3h)';
        setTimeout(() => (btn.textContent = 'Backfill'), 2500);
        return;
      }

      let mergedCount = 0;
      resolved.forEach(show => {
        show.tracklist.forEach(t => {
          pushLiveTrack(t.artist, t.title, 'Archive');
          mergedCount++;
        });
      });

      btn.textContent = `+${mergedCount} Loaded ✓`;
      setTimeout(() => (btn.textContent = 'Backfill'), 2500);
    });
  } else {
    pane.querySelector('#nts-prev-btn')?.addEventListener('click', () => {
      const idx = store.currentTrackIndex !== null ? Math.max(0, store.currentTrackIndex - 1) : 0;
      if (store.tracks[idx]?.offset != null) jumpToOffset(store.tracks[idx].offset);
    });

    pane.querySelector('#nts-next-btn')?.addEventListener('click', () => {
      const idx = store.currentTrackIndex !== null ? Math.min(store.tracks.length - 1, store.currentTrackIndex + 1) : 0;
      if (store.tracks[idx]?.offset != null) jumpToOffset(store.tracks[idx].offset);
    });

    store.dom.cueBtn?.addEventListener('click', async () => {
      const cue = generateCueSheet(store.tracks, store.showTitle, store.episodeTitle);
      const ok = await copyText(cue);
      if (ok) {
        store.dom.cueBtn.textContent = 'Copied ✓';
        setTimeout(() => (store.dom.cueBtn.textContent = 'CUE'), 1500);
      }
    });
  }

  pane.querySelector('#nts-close-btn').addEventListener('click', () => {
    sessionStorage.setItem('nts_pane_open', 'false');
    pane.style.display = 'none';
    renderToggleButton();
  });

  let debounceTimer = null;
  store.dom.searchInput.addEventListener('input', (e) => {
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      const term = e.target.value.toLowerCase().trim();
      const visible = store.tracks
        .map((track, idx) => ({ track, idx }))
        .filter(({ track }) => !term || track._lcTitle.includes(term) || track._lcArtist.includes(term));
      renderTrackRows(visible);
    }, 150);
  });

  makeDraggable(pane, pane.querySelector('#nts-drag-header'));
  document.body.appendChild(pane);

  const shouldBeOpen = sessionStorage.getItem('nts_pane_open') !== 'false';
  if (!shouldBeOpen) {
    pane.style.display = 'none';
    renderToggleButton();
  }

  return pane;
}

function renderToggleButton() {
  if (document.querySelector('.nts-tracklist-toggle-btn')) return;
  const btn = document.createElement('button');
  btn.type = 'button';
  btn.className = 'nts-tracklist-toggle-btn';

  const cur = store.tracks[store.currentTrackIndex];
  if (cur) {
    btn.textContent = `▶ ${cur.artist}${cur.title ? ' - ' + cur.title : ''}`;
  } else if (store.liveMode) {
    btn.textContent = `▶ ${store.showTitle || 'Live Radio'}`;
  } else {
    btn.textContent = '🎵 Tracklist';
  }

  btn.onclick = () => togglePane();
  document.body.appendChild(btn);
}

function togglePane() {
  const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');

  if (pane && pane.style.display !== 'none') {
    sessionStorage.setItem('nts_pane_open', 'false');
    pane.style.display = 'none';
    renderToggleButton();
    return;
  }

  if (pane) {
    sessionStorage.setItem('nts_pane_open', 'true');
    pane.style.display = 'flex';
    document.querySelector('.nts-tracklist-toggle-btn')?.remove();
    return;
  }

  sessionStorage.setItem('nts_pane_open', 'true');
  if (location.pathname.includes('/episodes/')) {
    fetchAndCreatePane();
  } else if (isLivePage()) {
    store.liveMode = true;
    const m = location.pathname.match(MIXTAPE_RE);
    initMixtapeMode(m ? m[1] : null);
  }
}

function destroyPane(invalidateToken = true) {
  if (invalidateToken) {
    navToken++;
    store.abortController?.abort();
  }
  detachLiveTracklistFrame();
  if (store.liveObserver) {
    store.liveObserver.disconnect();
    store.liveObserver = null;
  }
  document.querySelector('.nts-tracklist-pane')?.remove();
  document.querySelector('.nts-tracklist-toggle-btn')?.remove();
  store.currentTrackIndex = null;
  store.activeAudio = null;
  store.dom = {};
  mcWidget = null;
  mcIframe = null;
  scWidget = null;
  scIframe = null;
}

function parseRawTrack(track) {
  let artist = track.artist || '';
  if (!artist && Array.isArray(track.mainArtists)) {
    artist = track.mainArtists.map(a => a.name || a).join(', ');
  } else if (!artist && Array.isArray(track.artists)) {
    artist = track.artists.map(a => a.name || a).join(', ');
  }

  const offset = track.offset != null ? Number(track.offset) : (track.offset_estimate != null ? Number(track.offset_estimate) : null);
  const title = track.title || 'Untitled';
  const finalArtist = artist || 'Unknown';

  return {
    title,
    artist: finalArtist,
    _lcTitle: title.toLowerCase(),
    _lcArtist: finalArtist.toLowerCase(),
    offset: offset,
    duration: track.duration || null,
  };
}

function scrapeDOMTracks() {
  const container = document.querySelector('#tracklist, .tracklist, [class*="tracklist"]');
  if (!container) return [];

  const domTracks = [];
  const trackNodes = container.querySelectorAll('.track, [class*="track__"], li[class*="track"]');
  trackNodes.forEach(node => {
    const artist = node.querySelector('.track__artist, [class*="artist"]')?.textContent?.trim() || '';
    const title = node.querySelector('.track__title, [class*="title"]')?.textContent?.trim() || '';
    if (artist && title) {
      domTracks.push({
        artist,
        title,
        _lcArtist: artist.toLowerCase(),
        _lcTitle: title.toLowerCase(),
        offset: null,
        duration: null
      });
    }
  });
  return domTracks;
}

// --- Unified Live (Mixtape & Channel) Mode ---
async function initMixtapeMode(alias) {
  destroyPane(true);
  const token = navToken;
  store.liveMode = true;
  store.tracks = [];
  store.lastTimedIndex = -1;
  store.seen.clear();

  const targetSlug = alias || detectActiveChannelOrMixtape();

  if (alias) {
    try {
      const res = await fetch('https://www.nts.live/api/v2/mixtapes');
      if (res.ok) {
        const data = await res.json();
        const mix = (data.results || []).find(m =>
          m.mixtape_alias === alias || m.alias === alias
        );
        store.showTitle = mix?.title ? `∞ ${mix.title}` : '∞ Infinite Mixtape';
        store.episodeTitle = mix?.subtitle || mix?.description || '';
      }
    } catch {}
  } else {
    try {
      const res = await fetch('https://www.nts.live/api/v2/live');
      if (res.ok) {
        const data = await res.json();
        const activeCh = detectActiveChannelOrMixtape();
        const chData = (data.results || []).find(r => String(r.channel_name) === activeCh) || data.results?.[0];
        store.showTitle = chData?.now?.broadcast_title || chData?.now?.embeds?.details?.name || `NTS Channel ${activeCh}`;
        store.episodeTitle = chData?.channel_name ? `Channel ${chData.channel_name}` : '';

        if (chData) queueLiveShow(chData);
      }
    } catch {}
  }

  if (token !== navToken) return;

  createTracklistPane();
  renderTrackRows([]);
  installFetchTap();
  installXhrTap();
  watchNowPlayingDom();

  attachLiveTracklistFrame(targetSlug);
}

// --- Archived Episodes Mode ---
async function fetchAndCreatePane() {
  const path = window.location.pathname;
  if (!path.includes('/episodes/')) return;

  const token = ++navToken;
  store.abortController?.abort();
  store.abortController = new AbortController();

  let rawList = [];

  try {
    const nextDataEl = document.getElementById('__NEXT_DATA__');
    if (nextDataEl?.textContent && !document.body.dataset.ntsNextUsed) {
      document.body.dataset.ntsNextUsed = '1';
      const nextJson = JSON.parse(nextDataEl.textContent);
      const epData = nextJson.props?.pageProps?.episode || nextJson.props?.pageProps;
      if (epData) {
        store.showTitle = epData.show?.title || '';
        store.episodeTitle = epData.title || '';
        rawList = epData.tracklist || epData.results || [];
      }
    }
  } catch {}

  if (!Array.isArray(rawList) || !rawList.length) {
    const match = path.match(/\/shows\/([^/]+)\/episodes\/([^/]+)/);
    if (match) {
      const [_, show, episode] = match;
      try {
        const res = await fetch(`/api/v2/shows/${show}/episodes/${episode}`, { signal: store.abortController.signal });
        if (res.ok) {
          const data = await res.json();
          store.showTitle = data.show?.title || '';
          store.episodeTitle = data.title || '';
          rawList = data.tracklist || data.results || [];
        }
      } catch (e) {
        if (e.name === 'AbortError') return;
      }
    }
  }

  if (!Array.isArray(rawList) || !rawList.length) {
    try {
      const res = await fetch(window.location.href, {
        headers: { 'Accept': 'application/json' },
        signal: store.abortController.signal
      });
      if (res.ok) {
        const data = await res.json();
        store.showTitle = data.show?.title || '';
        store.episodeTitle = data.title || '';
        rawList = data.tracklist || data.results || (data.episode && data.episode.tracklist) || [];
      }
    } catch (e) {
      if (e.name === 'AbortError') return;
    }
  }

  if (token !== navToken) return;

  let parsed = Array.isArray(rawList) ? rawList.map(parseRawTrack) : [];
  if (!parsed.length) {
    parsed = scrapeDOMTracks();
  }

  parsed.sort((a, b) => {
    if (a.offset != null && b.offset != null) return a.offset - b.offset;
    if (a.offset != null) return -1;
    if (b.offset != null) return 1;
    return 0;
  });

  store.tracks = parsed;

  let lastTimed = -1;
  for (let i = parsed.length - 1; i >= 0; i--) {
    if (parsed[i].offset != null) {
      lastTimed = i;
      break;
    }
  }
  store.lastTimedIndex = lastTimed;

  if (parsed.length > 0) {
    createTracklistPane();
    renderTrackRows(parsed.map((track, idx) => ({ track, idx })));
  } else {
    renderToggleButton();
  }
}

// --- Navigation Router ---
function handleUrlChange() {
  if (location.pathname === lastPath) return;
  lastPath = location.pathname;

  if (location.pathname.includes('/episodes/')) {
    store.liveMode = false;
    fetchAndCreatePane();
  } else if (isLivePage()) {
    store.liveMode = true;
    const m = location.pathname.match(MIXTAPE_RE);
    initMixtapeMode(m ? m[1] : null);
  } else {
    store.liveMode = false;
    destroyPane(true);
  }
}

function init() {
  if (initPopupScraper()) return;

  processBackfillQueue(false);

  if (trackBus) {
    trackBus.onmessage = (e) => {
      if (store.liveMode && e.data?.artist && !isJunkString(e.data.artist)) {
        pushLiveTrack(e.data.artist, e.data.title || null, e.data.seenAt || null);
      }
    };
  }

  installFetchTap();
  installXhrTap();

  document.addEventListener('timeupdate', (e) => {
    if (e.target.tagName === 'AUDIO' && isSeekable(e.target) && store.tracks.length && !store.liveMode) {
      updateCurrentTrack(e.target.currentTime);
    }
  }, true);

  window.addEventListener('message', (event) => {
    if (store.liveMode || !store.tracks.length || !event.data) return;
    try {
      const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
      if (data.widgetEvent === 'playProgress' || data.method === 'playProgress') {
        const ms = data.value?.currentPosition ?? data.currentPosition;
        if (ms != null) updateCurrentTrack(ms / 1000);
      }
    } catch (e) {}
  });

  let iframeCheckScheduled = false;
  const iframeWatcher = new MutationObserver(() => {
    if (iframeCheckScheduled || (mcWidget && scWidget)) return;
    iframeCheckScheduled = true;
    setTimeout(() => {
      iframeCheckScheduled = false;
      if (!mcWidget) attachMixcloud();
      if (!scWidget) attachSoundCloud();
    }, 500);
  });
  iframeWatcher.observe(document.body, { childList: true, subtree: true });
  attachMixcloud();
  attachSoundCloud();

  window.addEventListener('popstate', handleUrlChange);
  const wrapHistory = (type) => {
    const orig = history[type];
    return function (...args) {
      const res = orig.apply(this, args);
      handleUrlChange();
      return res;
    };
  };
  history.pushState = wrapHistory('pushState');
  history.replaceState = wrapHistory('replaceState');

  window.addEventListener('keydown', (e) => {
    const activeEl = document.activeElement;
    const isInput = ['INPUT', 'TEXTAREA'].includes(activeEl?.tagName);

    if (e.key.toLowerCase() === 't' && !isInput && !e.ctrlKey && !e.metaKey && !e.altKey) {
      togglePane();
    }

    if (e.key === 'Escape') {
      const searchInput = store.dom.searchInput || document.querySelector('#nts-search-input');
      const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');

      if (isInput && searchInput && searchInput.value) {
        searchInput.value = '';
        renderTrackRows(store.tracks.map((track, idx) => ({ track, idx })));
        searchInput.blur();
      } else if (pane && pane.style.display !== 'none') {
        sessionStorage.setItem('nts_pane_open', 'false');
        pane.style.display = 'none';
        renderToggleButton();
      }
    }
  });

  handleUrlChange();
}


// ============================================================
// NTS PLUS+ — Persistent Discovery & Export Layer
// ============================================================
const NTS_PLUS_LIBRARY_KEY = 'nts_plus_library_v1';
const NTS_PLUS_UI_KEY = 'nts_plus_ui_v1';

function ntsLoad(key, fallback) {
    try { const value = JSON.parse(localStorage.getItem(key)); return value ?? fallback; }
    catch { return fallback; }
}
function ntsSave(key, value) {
    try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
function ntsNorm(v) {
    return String(v || '').toLowerCase().replace(/\s+/g, ' ').replace(/[‘’“”"']/g, '').trim();
}
function ntsTrackKey(t) { return `${ntsNorm(t?.artist)}|${ntsNorm(t?.title)}`; }
function ntsLibrary() { return ntsLoad(NTS_PLUS_LIBRARY_KEY, {}); }
function ntsIsFavourite(t) { return !!ntsLibrary()[ntsTrackKey(t)]; }
function ntsToggleFavourite(t) {
    if (!t) return false;
    const lib = ntsLibrary(), key = ntsTrackKey(t);
    if (lib[key]) { delete lib[key]; ntsSave(NTS_PLUS_LIBRARY_KEY, lib); return false; }
    lib[key] = {
        artist: t.artist || '', title: t.title || '', addedAt: Date.now(),
        source: { showTitle: store.showTitle || '', episodeTitle: store.episodeTitle || '', url: location.href, offset: t.offset ?? null }
    };
    ntsSave(NTS_PLUS_LIBRARY_KEY, lib);
    return true;
}
function ntsSavePaneState(pane) {
    if (!pane) return;
    const r = pane.getBoundingClientRect();
    ntsSave(NTS_PLUS_UI_KEY, { left:r.left, top:r.top, width:r.width, height:r.height });
}
function ntsRestorePaneState(pane) {
    const s = ntsLoad(NTS_PLUS_UI_KEY, null);
    if (!s) return;
    const w = Math.min(s.width || pane.offsetWidth, window.innerWidth);
    const h = Math.min(s.height || pane.offsetHeight, window.innerHeight);
    const l = Math.max(0, Math.min(s.left || 0, window.innerWidth - w));
    const t = Math.max(0, Math.min(s.top || 0, window.innerHeight - h));
    Object.assign(pane.style, { width:`${w}px`, height:`${h}px`, left:`${l}px`, top:`${t}px`, right:'auto', bottom:'auto' });
}
function ntsStats() {
    const tracks = store.tracks.filter(Boolean);
    const counts = new Map();
    tracks.forEach(t => { const a=(t.artist||'').trim(); if(a) counts.set(a,(counts.get(a)||0)+1); });
    return {
        tracks: tracks.length,
        uniqueArtists: new Set(tracks.map(t=>ntsNorm(t.artist)).filter(Boolean)).size,
        topArtists:[...counts.entries()].sort((a,b)=>b[1]-a[1]).slice(0,5),
        favourites: Object.keys(ntsLibrary()).length
    };
}
function ntsStatsText() {
    const s=ntsStats();
    return [`Tracks: ${s.tracks}`,`Unique artists: ${s.uniqueArtists}`,`Saved favourites: ${s.favourites}`,'','Top artists:', ...(s.topArtists.length?s.topArtists.map(([a,n])=>`${a} × ${n}`):['No artist data'])].join('\n');
}
function ntsExportTracks() {
    return store.tracks.map((t,i)=>({
        number:i+1,
        time:store.liveMode?(t.seenAt||'--:--'):(t.offset!=null?formatTime(t.offset):'--:--'),
        offset:t.offset??null, artist:t.artist||'', title:t.title||'', duration:t.duration??null,
        favourite:ntsIsFavourite(t)
    }));
}
function ntsCsv() {
    const esc=v=>`"${String(v??'').replace(/"/g,'""')}"`;
    return [['Number','Time','Offset','Artist','Title','Duration','Favourite'],...ntsExportTracks().map(t=>[t.number,t.time,t.offset??'',t.artist,t.title,t.duration??'',t.favourite?'Yes':'No'])].map(r=>r.map(esc).join(',')).join('\n');
}
function ntsMarkdown() {
    const esc=v=>String(v||'').replace(/\|/g,'\\|');
    return [`# ${store.showTitle||'NTS Tracklist'}`,store.episodeTitle?`**Episode:** ${store.episodeTitle}`:'',`**Source:** ${location.href}`,'','| # | Time | Artist | Track | ★ |','|---|---|---|---|---|',...ntsExportTracks().map(t=>`| ${t.number} | ${t.time} | ${esc(t.artist)} | ${esc(t.title)||'—'} | ${t.favourite?'★':''} |`)].filter(Boolean).join('\n');
}
function ntsJson() { return JSON.stringify({ exportedAt:new Date().toISOString(), show:store.showTitle, episode:store.episodeTitle, url:location.href, liveMode:store.liveMode, stats:ntsStats(), tracks:ntsExportTracks() },null,2); }
function ntsTxt() { return ntsExportTracks().map(t=>`${t.time} | ${t.favourite?'★ ':''}${t.artist}${t.title?' - '+t.title:''}`).join('\n'); }
function ntsDownload(name, content, type) {
    const a=document.createElement('a'), url=URL.createObjectURL(new Blob([content],{type:type+';charset=utf-8'}));
    a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function ntsRandom() {
    if (!store.tracks.length) return;
    const pool=store.tracks.filter(t=>!ntsIsFavourite(t));
    const t=(pool.length?pool:store.tracks)[Math.floor(Math.random()*(pool.length||store.tracks.length))];
    const idx=store.tracks.indexOf(t);
    if (!store.liveMode && t.offset!=null) jumpToOffset(t.offset);
    const row=store.dom.tbody?.querySelector(`tr[data-index="${idx}"]`);
    row?.scrollIntoView({block:'center',behavior:'smooth'});
    row?.classList.add('nts-random-highlight');setTimeout(()=>row?.classList.remove('nts-random-highlight'),1800);
}
function ntsDecorateRows() {
    const tbody=store.dom.tbody; if(!tbody) return;
    tbody.querySelectorAll('tr[data-index]').forEach(row=>{
        if(row.querySelector('.nts-favourite-cell')) return;
        const idx=Number(row.dataset.index), t=store.tracks[idx]; if(!t) return;
        const td=document.createElement('td'); td.className='nts-favourite-cell';
        const b=document.createElement('button'); b.type='button'; b.className='nts-favourite-btn'+(ntsIsFavourite(t)?' active':'');
        b.dataset.favourite=idx;b.textContent=ntsIsFavourite(t)?'★':'☆';b.title='Toggle favourite';td.appendChild(b);
        row.insertBefore(td,row.children[1]||null);
    });
}

GM_addStyle(`
.nts-favourite-cell{width:22px;padding-left:2px!important;padding-right:2px!important;text-align:center}
.nts-favourite-btn{background:none;border:0;color:#777;cursor:pointer;font-size:15px;padding:1px 3px}
.nts-favourite-btn:hover{color:#fff;transform:scale(1.15)}
.nts-favourite-btn.active{color:#ffd700}
.nts-random-highlight{animation:ntsRandomPulse 1.8s ease}
@keyframes ntsRandomPulse{0%{box-shadow:inset 0 0 0 999px rgba(255,255,255,.16)}100%{box-shadow:inset 0 0 0 999px rgba(255,255,255,0)}}
.nts-export-menu{position:absolute;right:42px;top:40px;background:#171717;border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px;min-width:145px;z-index:1000001;box-shadow:0 8px 24px rgba(0,0,0,.7)}
.nts-export-menu button{display:block;width:100%;background:none;border:0;color:#eee;text-align:left;padding:7px 9px;cursor:pointer;border-radius:4px;font-size:11px}
.nts-export-menu button:hover{background:rgba(255,255,255,.1);color:#fff}
`);

const ntsOriginalCreatePane=createTracklistPane, ntsOriginalRenderRows=renderTrackRows, ntsOriginalAppendLiveRow=appendLiveRow, ntsOriginalDrag=makeDraggable;

renderTrackRows=function(items){ ntsOriginalRenderRows(items); ntsDecorateRows(); };
appendLiveRow=function(t,i){ ntsOriginalAppendLiveRow(t,i); ntsDecorateRows(); };
makeDraggable=function(pane,header){ ntsOriginalDrag(pane,header); header.addEventListener('pointerup',()=>setTimeout(()=>ntsSavePaneState(pane),0)); };

createTracklistPane=function(){
    const pane=ntsOriginalCreatePane();
    if(!pane) return pane;
    ntsRestorePaneState(pane);

    const head=pane.querySelector('.nts-header-actions');
    if(head && !pane.querySelector('#nts-random-btn')){
        const random=document.createElement('button');random.type='button';random.className='nts-btn-icon';random.id='nts-random-btn';random.title='Random discovery (R)';random.textContent='🎲';
        const stats=document.createElement('button');stats.type='button';stats.className='nts-btn-icon';stats.id='nts-stats-btn';stats.title='Track statistics (S)';stats.textContent='Stats';
        head.insertBefore(stats,head.querySelector('#nts-export-btn'));head.insertBefore(random,stats);
        random.addEventListener('click',ntsRandom);
        stats.addEventListener('click',()=>alert(ntsStatsText()));
    }

    const exportBtn=pane.querySelector('#nts-export-btn');
    if(exportBtn){
        const fresh=exportBtn.cloneNode(true);exportBtn.replaceWith(fresh);store.dom.exportBtn=fresh;
        fresh.addEventListener('click',(e)=>{
            e.stopPropagation();
            pane.querySelectorAll('.nts-export-menu').forEach(m=>m.remove());

            const menu=document.createElement('div');menu.className='nts-export-menu';
            menu.style.right = store.liveMode ? '78px' : '118px';
            menu.innerHTML='<button data-x="txt">Copy TXT</button><button data-x="md">Copy Markdown</button><button data-x="csv">Download CSV</button><button data-x="json">Download JSON</button>';
            pane.appendChild(menu);

            menu.addEventListener('click',async evt=>{
                const type=evt.target.dataset.x;if(!type)return;
                const content=type==='csv'?ntsCsv():type==='json'?ntsJson():type==='md'?ntsMarkdown():ntsTxt();
                if(type==='csv'||type==='json'){
                    const base=(store.showTitle||'nts-tracklist').replace(/[<>:"/\\\\|?*]+/g,'-');
                    ntsDownload(`${base}.${type}`,content,type==='json'?'application/json':'text/csv');
                } else await copyText(content||'No tracks logged.');
                evt.target.textContent=type==='csv'||type==='json'?'Downloaded ✓':'Copied ✓';
                setTimeout(()=>menu.remove(),700);
            });

            const closeListener = (ev) => {
                if (!menu.contains(ev.target) && ev.target !== fresh) {
                    menu.remove();
                    document.removeEventListener('click', closeListener);
                }
            };
            setTimeout(() => document.addEventListener('click', closeListener), 0);
        });
    }

    pane.addEventListener('click',e=>{
        const b=e.target.closest('[data-favourite]');if(!b)return;
        const t=store.tracks[Number(b.dataset.favourite)];if(!t)return;
        const on=ntsToggleFavourite(t);b.textContent=on?'★':'☆';b.classList.toggle('active',on);
    });

    setTimeout(ntsDecorateRows,0);
    return pane;
};

init();