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 เพื่อติดตั้งสคริปต์นี้

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

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.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();