NTS Plus+

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

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

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         NTS Plus+
// @namespace    Violentmonkey Scripts
// @match        https://www.nts.live/*
// @match        https://nts.live/*
// @noframes
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @grant        GM_addElement
// @grant        unsafeWindow
// @version      2.5
// @author       Dan
// @license      MIT
// @description  Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, 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;
}

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;

  // Timestamps alone like "18:21"
  if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return true;

  // Station blocklist & navigation items
  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;

  // Large channel/navigation lists
  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;

  // Ignore navigation, header, and notification elements
  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;

    // Direct structured elements (Artist + Title in same container)
    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;
    }

    // Fallback: Check text content of children (e.g. <span>18:21</span><span>Artist</span><span>Title</span>)
    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,
      });
    }
  });

  // Older to newest order for natural bottom-insertion
  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(', ');
  }

  // Ensure this object represents a genuine track and not page metadata
  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);
    }
  }

  // Continue recursively traversing all children
  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>
        ${!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'),
    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);
      }
    }
  });

  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);
      }
    });
  }

  store.dom.exportBtn?.addEventListener('click', async () => {
    const text = store.tracks.map(t => {
      const timeStr = store.liveMode ? (t.seenAt || '--:--') : (t.offset != null ? formatTime(t.offset) : '--:--');
      return `${timeStr} | ${t.artist}${t.title ? ' - ' + t.title : ''}`;
    }).join('\n');
    const ok = await copyText(text || 'No tracks logged in this session.');
    if (ok) {
      store.dom.exportBtn.textContent = 'Copied ✓';
      setTimeout(() => (store.dom.exportBtn.textContent = 'Export'), 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();

  // Attach background Supporter embed frame for Channel 1, 2, or any Infinite Mixtape slug
  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();
}

init();