Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, yt-dlp command generator, and Archive Backfill.
// ==UserScript==
// @name NTS Test
// @namespace Violentmonkey Scripts
// @match https://www.nts.live/*
// @match https://nts.live/*
// @noframes
// @grant GM_addStyle
// @grant GM_setClipboard
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_addElement
// @grant unsafeWindow
// @version 2.7
// @author Dan
// @license MIT
// @description Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, yt-dlp command generator, and Archive Backfill.
// ==/UserScript==
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
const DEBUG = false;
const trackBus = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('nts-live-tracks') : null;
const DEDUPE_WINDOW_MS = 20 * 60 * 1000;
const BACKFILL_KEY = 'nts_backfill_queue';
GM_addStyle(`
.nts-tracklist-toggle-btn {
position: fixed;
bottom: 24px;
right: 24px;
background: #000;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 20px;
padding: 8px 16px;
font-size: 12px;
font-weight: 700;
cursor: pointer;
z-index: 999999;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
gap: 6px;
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
transition: transform 0.15s, background 0.15s;
}
.nts-tracklist-toggle-btn:hover {
background: #1e1e1e;
transform: scale(1.03);
}
.nts-tracklist-pane {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, monospace, sans-serif;
font-size: 12px;
position: fixed;
bottom: 24px;
right: 24px;
width: min(660px, 92vw);
height: min(650px, calc(100vh - 120px));
background: rgba(18, 18, 18, 0.95);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
color: #eee;
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
z-index: 999999;
display: flex;
flex-direction: column;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.85);
overflow: hidden;
resize: both;
min-width: 360px;
min-height: 220px;
}
.nts-tracklist-header {
padding: 10px 14px;
background: rgba(0, 0, 0, 0.7);
border-bottom: 1px solid rgba(255, 255, 255, 0.15);
display: flex;
justify-content: space-between;
align-items: center;
user-select: none;
cursor: move;
touch-action: none;
}
.nts-tracklist-title {
font-weight: 700;
font-size: 12px;
letter-spacing: 0.5px;
text-transform: uppercase;
color: #fff;
}
.nts-header-actions {
display: flex;
align-items: center;
gap: 5px;
}
.nts-btn-icon {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.18);
color: #eee;
border-radius: 4px;
padding: 3px 8px;
font-size: 11px;
cursor: pointer;
line-height: 1.2;
transition: background 0.15s;
}
.nts-btn-icon:hover {
background: rgba(255, 255, 255, 0.25);
color: #fff;
}
.nts-tracklist-search {
padding: 8px 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.3);
}
.nts-tracklist-search input {
width: 100%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.15);
border-radius: 4px;
color: #fff;
padding: 6px 10px;
font-size: 12px;
outline: none;
box-sizing: border-box;
}
.nts-tracklist-search input:focus {
border-color: rgba(255, 255, 255, 0.4);
}
.nts-tracklist-content {
flex: 1;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.25) transparent;
}
.nts-tracklist-content::-webkit-scrollbar {
width: 6px;
}
.nts-tracklist-content::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.25);
border-radius: 3px;
}
.nts-tracklist-table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.nts-tracklist-table th,
.nts-tracklist-table td {
padding: 7px 10px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
vertical-align: middle;
}
.nts-tracklist-table th {
background: rgba(0, 0, 0, 0.5);
font-size: 11px;
text-transform: uppercase;
color: #888;
position: sticky;
top: 0;
z-index: 2;
}
.nts-tracklist-table tr:hover {
background: rgba(255, 255, 255, 0.05);
}
.nts-tracklist-table tr.playing {
background: rgba(29, 185, 84, 0.22) !important;
}
.nts-tracklist-table tr.playing td {
color: #fff;
}
.nts-tracklist-table td.playing-icon {
width: 16px;
text-align: center;
color: #1DB954;
font-size: 10px;
padding-left: 6px;
padding-right: 2px;
}
.nts-tracklist-table tr.playing .playing-icon::before {
content: '▶';
}
.nts-jump-btn {
background: none;
border: none;
padding: 0;
color: #fff;
font: inherit;
cursor: pointer;
text-align: left;
}
.nts-jump-btn:hover {
color: #1DB954;
text-decoration: underline;
}
.nts-no-offset {
color: #888;
cursor: default;
}
.nts-dim-title {
color: #777;
font-style: italic;
}
.nts-links-cell {
display: flex;
gap: 4px;
align-items: center;
}
.nts-service-link {
font-size: 10px;
text-decoration: none;
font-weight: 700;
padding: 2px 4px;
border-radius: 3px;
line-height: 1;
background: rgba(255, 255, 255, 0.08);
border: none;
cursor: pointer;
}
.nts-spotify { color: #1DB954; }
.nts-bc { color: #1DA0C3; }
.nts-discogs { color: #FF9800; }
.nts-yt { color: #FF4444; }
.nts-copy { color: #aaa; }
.nts-service-link:hover { background: rgba(255, 255, 255, 0.2); }
`);
const MIXTAPE_RE = /^\/infinite-mixtapes\/([^/]+)/;
let navToken = 0;
let lastPath = null;
let fetchTapped = false;
let xhrTapped = false;
let mcWidget = null;
let mcIframe = null;
let scWidget = null;
let scIframe = null;
const store = {
liveMode: false,
tracks: [],
seen: new Map(),
lastTimedIndex: -1,
currentTrackIndex: null,
activeAudio: null,
abortController: null,
liveObserver: null,
showTitle: '',
episodeTitle: '',
dom: {},
};
// --- Page & Route Detection ---
function isLivePage() {
const p = location.pathname;
if (p.startsWith('/live-tracklist')) return false;
return p === '/' || p.startsWith('/live') || MIXTAPE_RE.test(p);
}
function detectActiveChannelOrMixtape() {
const p = location.pathname;
const m = p.match(MIXTAPE_RE);
if (m) return m[1];
const audio = document.querySelector('audio[src*="stream"]');
const src = audio?.src || '';
if (src.includes('stream2')) return '2';
if (src.includes('stream')) return '1';
const ch2 = document.querySelector('[aria-label*="Channel 2" i], button.live-channel--2');
if (ch2?.classList.contains('active') || ch2?.getAttribute('aria-pressed') === 'true') return '2';
return '1';
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
function formatTime(seconds) {
if (seconds == null || isNaN(seconds) || seconds < 0) return '--:--';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remSeconds.toString().padStart(2, '0')}`;
}
async function copyText(text) {
if (typeof GM_setClipboard === 'function') {
GM_setClipboard(text);
return true;
}
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
function generateCueSheet(tracks, showTitle, episodeTitle) {
let cue = `PERFORMER "${(showTitle || 'NTS Live').replace(/"/g, "'")}"\nTITLE "${(episodeTitle || 'Episode').replace(/"/g, "'")}"\nFILE "mix.mp3" MP3\n`;
let trackCounter = 1;
tracks.forEach((t) => {
if (t.offset == null) return;
const trackNum = String(trackCounter++).padStart(2, '0');
const mins = Math.floor(t.offset / 60);
const secs = Math.floor(t.offset % 60);
const mm = String(mins).padStart(2, '0');
const ss = String(secs).padStart(2, '0');
cue += ` TRACK ${trackNum} AUDIO\n`;
cue += ` TITLE "${(t.title || 'Untitled').replace(/"/g, "'")}"\n`;
cue += ` PERFORMER "${(t.artist || 'Unknown').replace(/"/g, "'")}"\n`;
cue += ` INDEX 01 ${mm}:${ss}:00\n`;
});
return cue;
}
// --- yt-dlp Command Generator ---
function generateYtDlpCommand(mode = 'original') {
const url = location.href;
const baseOutput = `"%(upload_date)s - %(title)s.%(ext)s"`;
switch (mode) {
case 'mp3':
return `yt-dlp "${url}" -x --audio-format mp3 --audio-quality 0 --embed-metadata --embed-thumbnail --embed-chapters --write-info-json --write-description --write-thumbnail -o ${baseOutput}`;
case 'split':
return `yt-dlp "${url}" -f "bestaudio/best" --split-chapters --embed-metadata --embed-thumbnail --write-info-json -o "chapter:%(upload_date)s - %(title)s/%(section_number)02d - %(section_title)s.%(ext)s"`;
case 'original':
default:
return `yt-dlp "${url}" -f "bestaudio/best" --embed-metadata --embed-thumbnail --embed-chapters --write-info-json --write-description --write-thumbnail -o ${baseOutput}`;
}
}
function isSeekable(a) {
return a && Number.isFinite(a.duration) && a.duration > 0 && a.seekable.length > 0;
}
function getAudioElement() {
if (store.activeAudio?.isConnected && isSeekable(store.activeAudio)) return store.activeAudio;
const candidates = [
...document.querySelectorAll('.soundcloud-player__content audio'),
...document.querySelectorAll('audio'),
];
store.activeAudio = candidates.find(isSeekable) || null;
return store.activeAudio;
}
function ensurePlaybackStarted() {
const isPlaying = getAudioElement() ||
document.querySelector('audio[src*="stream"]') ||
document.querySelector('iframe[src*="soundcloud.com"]') ||
document.querySelector('iframe[src*="mixcloud.com"]');
if (!isPlaying) {
const playBtn = document.querySelector('button[aria-label*="Play" i], button.play, .play-button, [class*="play-button"]');
if (playBtn) playBtn.click();
}
}
// --- Official Mixcloud Widget API Bridge ---
function loadMixcloudApi() {
return new Promise((resolve, reject) => {
if (win.Mixcloud?.PlayerWidget) return resolve(win.Mixcloud);
const existing = document.getElementById('nts-mc-api');
if (existing) {
if (win.Mixcloud) return resolve(win.Mixcloud);
existing.addEventListener('load', () => resolve(win.Mixcloud));
existing.addEventListener('error', reject);
return;
}
const src = 'https://widget.mixcloud.com/media/js/widgetApi.js';
let scriptEl;
if (typeof GM_addElement === 'function') {
scriptEl = GM_addElement('script', { src, id: 'nts-mc-api' });
} else {
scriptEl = document.createElement('script');
scriptEl.id = 'nts-mc-api';
scriptEl.src = src;
document.head.appendChild(scriptEl);
}
scriptEl.addEventListener('load', () => win.Mixcloud ? resolve(win.Mixcloud) : reject(new Error('No Mixcloud global')));
scriptEl.addEventListener('error', reject);
});
}
async function attachMixcloud() {
const iframe = document.querySelector('iframe[src*="mixcloud.com/widget"], iframe[src*="player-widget.mixcloud.com"], iframe[src*="widget.mixcloud.com"]');
if (!iframe || (iframe === mcIframe && mcWidget)) return;
try {
const Mixcloud = await loadMixcloudApi();
if (!iframe.dataset.ntsReady) {
await new Promise((resolve) => {
iframe.addEventListener('load', resolve, { once: true });
setTimeout(resolve, 2500);
});
iframe.dataset.ntsReady = '1';
}
const widget = Mixcloud.PlayerWidget(iframe);
await widget.ready;
widget.events.progress.on((position) => {
if (!store.liveMode && store.tracks.length && position != null) {
updateCurrentTrack(position);
}
});
mcWidget = widget;
mcIframe = iframe;
} catch (e) {
mcWidget = null;
}
}
function seekMixcloud(offset) {
if (!mcWidget) return false;
mcWidget.seek(Number(offset)).then((ok) => {
if (ok !== false) mcWidget.play();
}).catch(() => {});
return true;
}
// --- Official SoundCloud Widget API Bridge ---
function attachSoundCloud() {
const iframe = document.querySelector('iframe[src*="soundcloud.com"], .soundcloud-player__content iframe');
if (!iframe || (iframe === scIframe && scWidget)) return;
if (win.SC?.Widget) {
try {
const widget = win.SC.Widget(iframe);
const SCevents = win.SC.Widget.Events;
widget.bind(SCevents.READY, () => {
widget.bind(SCevents.PLAY_PROGRESS, (data) => {
if (!store.liveMode && store.tracks.length && data?.currentPosition != null) {
updateCurrentTrack(data.currentPosition / 1000);
}
});
});
scWidget = widget;
scIframe = iframe;
} catch (e) {
scWidget = null;
}
}
}
function seekSoundCloud(offset) {
const iframe = document.querySelector('iframe[src*="soundcloud.com"], .soundcloud-player__content iframe');
if (!iframe) return false;
if (scWidget) {
try {
scWidget.seekTo(Number(offset) * 1000);
scWidget.play();
return true;
} catch (e) {}
}
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(JSON.stringify({ method: 'seekTo', value: Number(offset) * 1000 }), '*');
iframe.contentWindow.postMessage(JSON.stringify({ method: 'play' }), '*');
return true;
}
return false;
}
// --- Universal Seek Router ---
function jumpToOffset(offset) {
if (store.liveMode || offset == null || isNaN(offset)) return;
ensurePlaybackStarted();
const audio = getAudioElement();
if (audio) {
const doSeek = () => {
const end = audio.seekable.length ? audio.seekable.end(audio.seekable.length - 1) : 0;
audio.currentTime = Math.min(Number(offset), Math.max(0, end - 1));
};
if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
doSeek();
if (audio.paused) audio.play().catch(() => {});
} else {
audio.addEventListener('loadedmetadata', doSeek, { once: true });
audio.play().catch(() => {});
}
return;
}
if (seekMixcloud(offset)) return;
if (seekSoundCloud(offset)) return;
const progressBar = document.querySelector('[class*="progressbar"], [class*="scrubber"], input[type="range"]');
if (progressBar) {
const lastTrack = store.tracks[store.tracks.length - 1];
const totalDuration = (lastTrack && lastTrack.offset ? lastTrack.offset + 300 : 7200);
const ratio = Math.min(Math.max(Number(offset) / totalDuration, 0), 1);
const rect = progressBar.getBoundingClientRect();
const clickX = rect.left + rect.width * ratio;
const clickY = rect.top + rect.height / 2;
progressBar.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: clickX, clientY: clickY }));
progressBar.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, clientX: clickX, clientY: clickY }));
}
}
function findActiveIndex(tracks, currentTime) {
if (!tracks.length || currentTime == null || store.lastTimedIndex < 0) return -1;
if (tracks[0].offset > currentTime) return -1;
let low = 0;
let high = store.lastTimedIndex;
let best = -1;
while (low <= high) {
const mid = (low + high) >> 1;
if (tracks[mid].offset <= currentTime) {
best = mid;
low = mid + 1;
} else {
high = mid - 1;
}
}
return best;
}
function updateCurrentTrack(currentTime) {
if (store.liveMode) return;
const activeIndex = findActiveIndex(store.tracks, currentTime);
if (activeIndex === store.currentTrackIndex) return;
const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
if (!tbody) {
store.currentTrackIndex = activeIndex;
return;
}
if (store.currentTrackIndex !== null) {
const prevRow = tbody.querySelector(`tr[data-index="${store.currentTrackIndex}"]`);
if (prevRow) prevRow.classList.remove('playing');
}
const row = tbody.querySelector(`tr[data-index="${activeIndex}"]`);
if (row) {
row.classList.add('playing');
const content = store.dom.content || document.querySelector('.nts-tracklist-content');
const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');
if (content && !pane?.matches(':hover')) {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
content.scrollTo({
top: row.offsetTop - content.clientHeight / 2 + row.clientHeight / 2,
behavior: prefersReducedMotion ? 'auto' : 'smooth'
});
}
}
store.currentTrackIndex = activeIndex;
const toggleBtn = document.querySelector('.nts-tracklist-toggle-btn');
if (toggleBtn && activeIndex !== -1 && store.tracks[activeIndex]) {
const t = store.tracks[activeIndex];
toggleBtn.textContent = `▶ ${t.artist}${t.title ? ' - ' + t.title : ''}`;
}
}
// --- Robust String & Stream Parser ---
function isJunkString(str) {
if (!str) return true;
const s = str.trim();
if (!s || s.length > 180) return true;
if (/^\d{1,2}:\d{2}(:\d{2})?$/.test(s)) return true;
const junkRegex = /^(no tracks found|live tracklist|tracklist|listen live|live on [12]|channel [12]|unlock live|nts supporters?|nts radio|nts live|nts|unknown|loading\.{0,3}|enable real-time notifications.*|recently played)$/i;
if (junkRegex.test(s)) return true;
if (s.includes('Live on 1') && s.includes('Live on 2')) return true;
return false;
}
function parseNowPlaying(raw) {
if (isJunkString(raw)) return null;
const clean = raw.trim();
const match = clean.split(/\s+[-–—]\s+/);
if (match.length >= 2) {
const artist = match[0].trim();
const title = match.slice(1).join(' - ').trim();
if (artist.toLowerCase() === title.toLowerCase()) {
return { artist, title: null };
}
return { artist, title };
}
return { artist: clean, title: null };
}
// --- Live Session Backfill Queue ---
function loadQueue() {
try { return JSON.parse(localStorage.getItem(BACKFILL_KEY)) || []; }
catch { return []; }
}
function saveQueue(q) {
localStorage.setItem(BACKFILL_KEY, JSON.stringify(q.slice(-50)));
}
function queueLiveShow(chData) {
const details = chData?.now?.embeds?.details;
if (!details) return;
const showAlias = details.show_alias || details.alias || null;
const startTs = chData?.now?.start_timestamp || null;
if (!showAlias) return;
const entry = {
broadcastTitle: chData?.now?.broadcast_title || details.name || 'Live Show',
showAlias,
startTs,
listenedAt: Date.now(),
resolved: false,
tracklist: [],
};
const q = loadQueue();
if (q.some(e => e.showAlias === entry.showAlias && e.startTs === entry.startTs)) return;
q.push(entry);
saveQueue(q);
}
async function processBackfillQueue(force = false) {
const q = loadQueue();
const ready = q.filter(e => !e.resolved && (force || Date.now() - e.listenedAt > 2.5 * 3600 * 1000));
if (!ready.length) return q.filter(e => e.resolved);
for (const entry of ready) {
try {
const res = await fetch(`/api/v2/shows/${entry.showAlias}/episodes?limit=6`);
if (!res.ok) continue;
const data = await res.json();
const ep = (data.results || []).find(e =>
e.broadcast && entry.startTs && Math.abs(new Date(e.broadcast) - new Date(entry.startTs)) < 3600 * 1000 * 2
) || (data.results?.[0]);
if (!ep?.episode_alias) continue;
const epRes = await fetch(`/api/v2/shows/${entry.showAlias}/episodes/${ep.episode_alias}`);
if (!epRes.ok) continue;
const epData = await epRes.json();
const tracks = (epData.tracklist || []).map(parseRawTrack);
if (tracks.length) {
entry.tracklist = tracks;
entry.episodeUrl = `https://www.nts.live/shows/${entry.showAlias}/episodes/${ep.episode_alias}`;
entry.resolved = true;
}
} catch {}
}
saveQueue(q);
return q.filter(e => e.resolved);
}
// --- Supporter Live Tracklist Embed Frame Bridge ---
const LT_URL = (slug) => `https://www.nts.live/live-tracklist/${slug}?embed=true`;
let ltFrame = null;
let ltObserver = null;
let ltTarget = null;
let ltPoll = null;
let ltWatch = null;
function ltScrapeDoc(doc) {
if (!doc || !doc.body) return;
const rows = doc.querySelectorAll('main li, ul:not([class*="nav"]) li, [class*="track-list"] > div, [class*="TrackList"] > div');
const found = [];
rows.forEach(row => {
if (row.querySelector('nav, ul, header, button')) return;
const artistEl = row.querySelector('[class*="artist"], [class*="Artist"]');
const titleEl = row.querySelector('[class*="title"], [class*="Title"], [class*="track-name"]');
const stampEl = row.querySelector('time, [datetime], [class*="time"], [class*="Time"]');
const artist = artistEl?.textContent?.trim();
const title = titleEl?.textContent?.trim();
const stamp = stampEl?.textContent?.trim() || null;
if (artist && !isJunkString(artist)) {
found.push({
artist,
title: (title && !isJunkString(title) && title.toLowerCase() !== artist.toLowerCase()) ? title : null,
seenAt: stamp,
});
return;
}
const textNodes = Array.from(row.children)
.map(c => c.textContent.trim())
.filter(t => t && !isJunkString(t));
if (textNodes.length >= 2) {
found.push({
artist: textNodes[0],
title: textNodes[1],
seenAt: stamp,
});
}
});
found.reverse().forEach(t => pushLiveTrack(t.artist, t.title, t.seenAt));
}
function ltBind(frame) {
let doc;
try { doc = frame.contentDocument; } catch { doc = null; }
if (!doc) return;
try {
const nd = doc.getElementById('__NEXT_DATA__');
if (nd?.textContent) inspectApiPayload(JSON.parse(nd.textContent));
} catch {}
ltObserver?.disconnect();
let scheduled = false;
ltObserver = new MutationObserver(() => {
if (scheduled || !store.liveMode) return;
scheduled = true;
setTimeout(() => {
scheduled = false;
ltScrapeDoc(doc);
}, 800);
});
ltObserver.observe(doc.body, { childList: true, subtree: true, characterData: true });
ltScrapeDoc(doc);
}
function attachLiveTracklistFrame(slug) {
if (ltFrame && ltTarget === slug) return;
detachLiveTracklistFrame();
ltTarget = slug;
const frame = document.createElement('iframe');
frame.id = 'nts-lt-frame';
frame.src = LT_URL(slug);
frame.setAttribute('style', [
'position:fixed', 'left:-10000px', 'top:0',
'width:480px', 'height:800px',
'opacity:0', 'pointer-events:none', 'border:0',
].join(';'));
frame.setAttribute('aria-hidden', 'true');
frame.setAttribute('tabindex', '-1');
frame.addEventListener('load', () => ltBind(frame));
document.body.appendChild(frame);
ltFrame = frame;
ltPoll = setInterval(() => {
if (!store.liveMode) return;
try { ltScrapeDoc(frame.contentDocument); } catch {}
}, 20000);
ltWatch = setInterval(() => {
if (!store.liveMode) return;
const now = detectActiveChannelOrMixtape();
if (now !== ltTarget) attachLiveTracklistFrame(now);
}, 5000);
}
function detachLiveTracklistFrame() {
ltObserver?.disconnect();
ltObserver = null;
clearInterval(ltPoll);
clearInterval(ltWatch);
ltPoll = null;
ltWatch = null;
ltFrame?.remove();
ltFrame = null;
ltTarget = null;
}
// --- Live Supporter Interception Engine ---
function installFetchTap() {
if (fetchTapped || typeof win.fetch !== 'function') return;
fetchTapped = true;
const origFetch = win.fetch;
win.fetch = function (...args) {
const p = origFetch.apply(this, args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (store.liveMode && /\/api\/.*(now-playing|tracklist|live-tracks|tracks|mixtapes)/i.test(url)) {
p.then(res => res.clone().json())
.then(inspectApiPayload)
.catch(() => {});
}
} catch {}
return p;
};
}
function installXhrTap() {
if (xhrTapped || !win.XMLHttpRequest?.prototype) return;
xhrTapped = true;
const origOpen = win.XMLHttpRequest.prototype.open;
const origSend = win.XMLHttpRequest.prototype.send;
win.XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this._ntsUrl = String(url);
return origOpen.call(this, method, url, ...rest);
};
win.XMLHttpRequest.prototype.send = function (...args) {
if (store.liveMode && this._ntsUrl && /\/api\/.*(now-playing|tracklist|live-tracks|tracks|mixtapes)/i.test(this._ntsUrl)) {
this.addEventListener('load', () => {
try {
inspectApiPayload(JSON.parse(this.responseText));
} catch {}
});
}
return origSend.apply(this, args);
};
}
function inspectApiPayload(obj, depth = 0) {
if (!store.liveMode || !obj || typeof obj !== 'object' || depth > 7) return;
const rawTitle = obj.title || obj.track_title || obj.song_name || obj.song?.title;
let rawArtist = obj.artist || obj.artist_name;
if (!rawArtist) {
if (Array.isArray(obj.artists)) rawArtist = obj.artists.map(a => a.name || a.artist_name || a).join(', ');
else if (Array.isArray(obj.mainArtists)) rawArtist = obj.mainArtists.map(a => a.name || a.artist_name || a).join(', ');
}
if ((rawArtist || rawTitle) && !obj.channel_name && !obj.embeds && !obj.mixtape_alias && !obj.broadcast_title) {
const artist = (rawArtist || rawTitle || '').trim();
const title = (rawArtist && rawTitle && rawArtist !== rawTitle) ? rawTitle.trim() : null;
if (!isJunkString(artist)) {
pushLiveTrack(artist, title);
}
}
for (const v of Object.values(obj)) {
inspectApiPayload(v, depth + 1);
}
}
// --- Live Supporter DOM Scraper ---
function watchNowPlayingDom() {
store.liveObserver?.disconnect();
const parseAndPush = (node) => {
if (!node || ['BUTTON', 'H1', 'H2', 'H3', 'H4', 'H5', 'NAV', 'HEADER'].includes(node.tagName)) return;
const artistEl = node.querySelector('.track__artist, [class*="artist"]');
const titleEl = node.querySelector('.track__title, [class*="title"], [class*="track-name"]');
if (artistEl && titleEl) {
const a = artistEl.textContent?.trim();
const t = titleEl.textContent?.trim();
if (a && !isJunkString(a)) {
pushLiveTrack(a, (t && !isJunkString(t) && a.toLowerCase() !== t.toLowerCase()) ? t : null);
return;
}
}
const parsed = parseNowPlaying(node.textContent);
if (parsed && !isJunkString(parsed.artist)) {
pushLiveTrack(parsed.artist, parsed.title);
}
};
let scheduled = false;
const observer = new MutationObserver(() => {
if (scheduled || !store.liveMode) return;
scheduled = true;
setTimeout(() => {
scheduled = false;
document.querySelectorAll('[class*="live-tracklist"] li, [class*="now-playing__track"], [class*="nowPlaying"] [class*="track"]').forEach(parseAndPush);
}, 1000);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
store.liveObserver = observer;
}
function pushLiveTrack(artist, title, seenAt = null) {
if (!store.liveMode || !artist || isJunkString(artist)) return;
if (title && isJunkString(title)) title = null;
if (store.showTitle && (artist.toLowerCase() === store.showTitle.toLowerCase() || title?.toLowerCase() === store.showTitle.toLowerCase())) {
return;
}
const norm = s => String(s || '').toLowerCase().replace(/\s+/g, ' ').replace(/[‘’“”"']/g, '').trim();
const nArtist = norm(artist);
const nTitle = norm(title);
const finalTitle = (title && nArtist !== nTitle) ? title.trim() : null;
const key = `${nArtist}|${finalTitle ? norm(finalTitle) : ''}`;
const nowMs = Date.now();
const last = store.seen.get(key);
if (last && nowMs - last < DEDUPE_WINDOW_MS) return;
store.seen.set(key, nowMs);
const newTrack = {
title: finalTitle,
artist: artist.trim(),
_lcTitle: (finalTitle || '').toLowerCase(),
_lcArtist: artist.trim().toLowerCase(),
offset: null,
duration: null,
seenAt: seenAt || new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }),
};
store.tracks.push(newTrack);
store.currentTrackIndex = store.tracks.length - 1;
const titleHeader = store.dom.title || document.querySelector('.nts-tracklist-title');
if (titleHeader) {
titleHeader.textContent = `Tracklist (Session: ${store.tracks.length})`;
}
const searchInput = store.dom.searchInput || document.querySelector('#nts-search-input');
const term = searchInput?.value.toLowerCase().trim() || '';
if (term) {
const visible = store.tracks
.map((track, idx) => ({ track, idx }))
.filter(({ track }) => track._lcTitle.includes(term) || track._lcArtist.includes(term));
renderTrackRows(visible);
} else {
appendLiveRow(newTrack, store.tracks.length - 1);
}
const toggleBtn = document.querySelector('.nts-tracklist-toggle-btn');
if (toggleBtn) {
toggleBtn.textContent = `▶ ${newTrack.artist}${newTrack.title ? ' - ' + newTrack.title : ''}`;
}
const content = store.dom.content || document.querySelector('.nts-tracklist-content');
if (content && !term) {
content.scrollTop = content.scrollHeight;
}
}
// --- Supporter Popup Bridge Window Engine ---
function initPopupScraper() {
const isPopup = /live-tracklist|live-tracks/i.test(location.pathname) ||
(window.opener !== null && window.name === 'nts-tracklist');
if (!isPopup) return false;
const scrape = () => {
document.querySelectorAll('main li, ul:not([class*="nav"]) li, [class*="track-list"] > div').forEach(node => {
if (node.querySelector('nav, ul, header, button')) return;
const artistEl = node.querySelector('[class*="artist"]');
const titleEl = node.querySelector('[class*="title"], [class*="track-name"]');
if (artistEl && titleEl) {
const a = artistEl.textContent?.trim();
const t = titleEl.textContent?.trim();
if (a && !isJunkString(a)) {
trackBus?.postMessage({ artist: a, title: (!isJunkString(t) && a.toLowerCase() !== t.toLowerCase()) ? t : null });
return;
}
}
const parsed = parseNowPlaying(node.textContent);
if (parsed && !isJunkString(parsed.artist)) {
trackBus?.postMessage(parsed);
}
});
};
new MutationObserver(scrape).observe(document.body, { childList: true, subtree: true, characterData: true });
scrape();
return true;
}
// Pointer Events Drag with Viewport Bounds Clamping
function makeDraggable(pane, header) {
let startX = 0, startY = 0, startLeft = 0, startTop = 0;
header.addEventListener('pointerdown', (e) => {
if (['BUTTON', 'INPUT', 'A'].includes(e.target.tagName)) return;
e.preventDefault();
header.setPointerCapture(e.pointerId);
const rect = pane.getBoundingClientRect();
startX = e.clientX; startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
const onPointerMove = (ev) => {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
const maxLeft = Math.max(0, window.innerWidth - pane.offsetWidth);
const maxTop = Math.max(0, window.innerHeight - pane.offsetHeight);
pane.style.left = `${Math.min(Math.max(0, startLeft + dx), maxLeft)}px`;
pane.style.top = `${Math.min(Math.max(0, startTop + dy), maxTop)}px`;
pane.style.bottom = 'auto';
pane.style.right = 'auto';
};
const onPointerUp = (ev) => {
header.releasePointerCapture(ev.pointerId);
header.removeEventListener('pointermove', onPointerMove);
header.removeEventListener('pointerup', onPointerUp);
};
header.addEventListener('pointermove', onPointerMove);
header.addEventListener('pointerup', onPointerUp);
});
}
function buildRowHtml(track, idx, isCurrent) {
const rawQuery = track.title ? `${track.artist} ${track.title}` : track.artist;
const query = encodeURIComponent(rawQuery.trim());
const spotifyUrl = `https://open.spotify.com/search/${query}`;
const bcUrl = `https://bandcamp.com/search?q=${query}`;
const discogsUrl = `https://www.discogs.com/search/?q=${query}&type=release`;
const ytUrl = `https://www.youtube.com/results?search_query=${query}`;
const copyPayload = escapeHtml(track.title ? `${track.artist} - ${track.title}` : track.artist);
let timeDisplay;
if (store.liveMode) {
timeDisplay = `<span class="nts-no-offset">${track.seenAt || '--:--'}</span>`;
} else if (track.offset != null) {
timeDisplay = `<button type="button" class="nts-jump-btn" data-offset="${track.offset}" title="Jump to ${formatTime(track.offset)}">${formatTime(track.offset)}</button>`;
} else {
timeDisplay = `<span class="nts-no-offset">--:--</span>`;
}
let titleDisplay;
if (track.title) {
titleDisplay = (!store.liveMode && track.offset != null)
? `<button type="button" class="nts-jump-btn" data-offset="${track.offset}">${escapeHtml(track.title)}</button>`
: `<span>${escapeHtml(track.title)}</span>`;
} else {
titleDisplay = `<span class="nts-dim-title">—</span>`;
}
return `
<tr data-index="${idx}" class="${isCurrent ? 'playing' : ''}">
<td class="playing-icon"></td>
<td>${timeDisplay}</td>
<td class="title-col">${titleDisplay}</td>
<td>${escapeHtml(track.artist || 'Unknown')}</td>
<td>${track.duration ? formatTime(track.duration) : '-'}</td>
<td>
<div class="nts-links-cell">
<a class="nts-service-link nts-spotify" href="${spotifyUrl}" target="_blank" rel="noopener" title="Spotify">SP</a>
<a class="nts-service-link nts-bc" href="${bcUrl}" target="_blank" rel="noopener" title="Bandcamp">BC</a>
<a class="nts-service-link nts-discogs" href="${discogsUrl}" target="_blank" rel="noopener" title="Discogs">DC</a>
<a class="nts-service-link nts-yt" href="${ytUrl}" target="_blank" rel="noopener" title="YouTube">YT</a>
<button type="button" class="nts-service-link nts-copy" data-copy="${copyPayload}" title="Copy">📋</button>
</div>
</td>
</tr>
`;
}
function renderTrackRows(visibleItems) {
const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
if (!tbody) return;
if (!visibleItems.length) {
const emptyMsg = store.liveMode
? 'Waiting for track data… (Live per-track info requires NTS Supporter, or click Backfill)'
: 'No matching tracks';
tbody.innerHTML = `<tr><td colspan="6" style="text-align:center; padding: 24px 14px; color: #888; line-height: 1.4;">${emptyMsg}</td></tr>`;
return;
}
tbody.innerHTML = visibleItems.map(({ track, idx }) => buildRowHtml(track, idx, idx === store.currentTrackIndex)).join('');
}
function appendLiveRow(track, idx) {
const tbody = store.dom.tbody || document.querySelector('.nts-tracklist-table tbody');
if (!tbody) return;
tbody.querySelector('td[colspan]')?.closest('tr')?.remove();
const prev = tbody.querySelector('tr.playing');
prev?.classList.remove('playing');
const tmp = document.createElement('tbody');
tmp.innerHTML = buildRowHtml(track, idx, true);
if (tmp.firstElementChild) {
tbody.appendChild(tmp.firstElementChild);
}
}
function createTracklistPane() {
destroyPane(false);
const pane = document.createElement('div');
pane.className = 'nts-tracklist-pane';
let countLabel;
if (store.liveMode) {
countLabel = `Session: ${store.tracks.length}`;
} else {
const timedCount = store.lastTimedIndex + 1;
countLabel = timedCount > 0 && timedCount !== store.tracks.length
? `${timedCount}/${store.tracks.length} timed`
: `${store.tracks.length}`;
}
pane.innerHTML = `
<div class="nts-tracklist-header" id="nts-drag-header">
<span class="nts-tracklist-title">Tracklist (${countLabel})</span>
<div class="nts-header-actions">
${store.liveMode ? `
<button type="button" class="nts-btn-icon" id="nts-backfill-btn" title="Backfill from NTS Archive">Backfill</button>
` : `
<button type="button" class="nts-btn-icon" id="nts-prev-btn" title="Previous Track">⏮</button>
<button type="button" class="nts-btn-icon" id="nts-next-btn" title="Next Track">⏭</button>
`}
<button type="button" class="nts-btn-icon" id="nts-export-btn" title="Export Tracklist">Export</button>
<button type="button" class="nts-btn-icon" id="nts-ytdlp-btn" title="Copy yt-dlp download commands">yt-dlp</button>
${!store.liveMode ? `
<button type="button" class="nts-btn-icon" id="nts-cue-btn" title="Export CUE Sheet">CUE</button>
` : ''}
<button type="button" class="nts-btn-icon" id="nts-close-btn" title="Minimize (T)">✕</button>
</div>
</div>
<div class="nts-tracklist-search">
<input type="text" id="nts-search-input" placeholder="Filter tracks or artists... (Esc to clear)" />
</div>
<div class="nts-tracklist-content">
<table class="nts-tracklist-table">
<thead>
<tr>
<th></th>
<th>${store.liveMode ? 'Heard' : 'Time'}</th>
<th>Title</th>
<th>Artist</th>
<th>Dur</th>
<th>Links</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
`;
store.dom = {
pane,
tbody: pane.querySelector('.nts-tracklist-table tbody'),
content: pane.querySelector('.nts-tracklist-content'),
title: pane.querySelector('.nts-tracklist-title'),
searchInput: pane.querySelector('#nts-search-input'),
exportBtn: pane.querySelector('#nts-export-btn'),
ytdlpBtn: pane.querySelector('#nts-ytdlp-btn'),
backfillBtn: pane.querySelector('#nts-backfill-btn'),
cueBtn: pane.querySelector('#nts-cue-btn'),
};
pane.addEventListener('click', async (e) => {
const jumpEl = e.target.closest('[data-offset]');
if (jumpEl && !store.liveMode) {
jumpToOffset(Number(jumpEl.dataset.offset));
return;
}
const copyEl = e.target.closest('[data-copy]');
if (copyEl) {
const val = copyEl.getAttribute('data-copy');
const ok = await copyText(val);
if (ok) {
copyEl.textContent = '✓';
setTimeout(() => (copyEl.textContent = '📋'), 1500);
}
}
});
// --- yt-dlp Dropdown Menu Handler ---
store.dom.ytdlpBtn?.addEventListener('click', (e) => {
e.stopPropagation();
pane.querySelectorAll('.nts-export-menu').forEach(m => m.remove());
const menu = document.createElement('div');
menu.className = 'nts-export-menu nts-ytdlp-menu';
menu.style.right = store.liveMode ? '34px' : '78px';
menu.innerHTML = `
<button data-mode="original" title="Direct stream audio (AAC/M4A) without re-encoding">Original (Best)</button>
<button data-mode="mp3" title="Convert to MP3 for legacy/DJ hardware">Convert to MP3</button>
<button data-mode="split" title="Split into individual track files using tracklist">Split Tracks</button>
`;
pane.appendChild(menu);
menu.addEventListener('click', async (evt) => {
const mode = evt.target.dataset.mode;
if (!mode) return;
const cmd = generateYtDlpCommand(mode);
const ok = await copyText(cmd);
evt.target.textContent = ok ? 'Copied ✓' : 'Failed';
setTimeout(() => menu.remove(), 700);
});
const closeListener = (ev) => {
if (!menu.contains(ev.target) && ev.target !== store.dom.ytdlpBtn) {
menu.remove();
document.removeEventListener('click', closeListener);
}
};
setTimeout(() => document.addEventListener('click', closeListener), 0);
});
if (store.liveMode) {
store.dom.backfillBtn?.addEventListener('click', async () => {
const btn = store.dom.backfillBtn;
btn.textContent = 'Checking...';
const resolved = await processBackfillQueue(true);
if (!resolved.length) {
btn.textContent = 'Queued (~2-3h)';
setTimeout(() => (btn.textContent = 'Backfill'), 2500);
return;
}
let mergedCount = 0;
resolved.forEach(show => {
show.tracklist.forEach(t => {
pushLiveTrack(t.artist, t.title, 'Archive');
mergedCount++;
});
});
btn.textContent = `+${mergedCount} Loaded ✓`;
setTimeout(() => (btn.textContent = 'Backfill'), 2500);
});
} else {
pane.querySelector('#nts-prev-btn')?.addEventListener('click', () => {
const idx = store.currentTrackIndex !== null ? Math.max(0, store.currentTrackIndex - 1) : 0;
if (store.tracks[idx]?.offset != null) jumpToOffset(store.tracks[idx].offset);
});
pane.querySelector('#nts-next-btn')?.addEventListener('click', () => {
const idx = store.currentTrackIndex !== null ? Math.min(store.tracks.length - 1, store.currentTrackIndex + 1) : 0;
if (store.tracks[idx]?.offset != null) jumpToOffset(store.tracks[idx].offset);
});
store.dom.cueBtn?.addEventListener('click', async () => {
const cue = generateCueSheet(store.tracks, store.showTitle, store.episodeTitle);
const ok = await copyText(cue);
if (ok) {
store.dom.cueBtn.textContent = 'Copied ✓';
setTimeout(() => (store.dom.cueBtn.textContent = 'CUE'), 1500);
}
});
}
pane.querySelector('#nts-close-btn').addEventListener('click', () => {
sessionStorage.setItem('nts_pane_open', 'false');
pane.style.display = 'none';
renderToggleButton();
});
let debounceTimer = null;
store.dom.searchInput.addEventListener('input', (e) => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
const term = e.target.value.toLowerCase().trim();
const visible = store.tracks
.map((track, idx) => ({ track, idx }))
.filter(({ track }) => !term || track._lcTitle.includes(term) || track._lcArtist.includes(term));
renderTrackRows(visible);
}, 150);
});
makeDraggable(pane, pane.querySelector('#nts-drag-header'));
document.body.appendChild(pane);
const shouldBeOpen = sessionStorage.getItem('nts_pane_open') !== 'false';
if (!shouldBeOpen) {
pane.style.display = 'none';
renderToggleButton();
}
return pane;
}
function renderToggleButton() {
if (document.querySelector('.nts-tracklist-toggle-btn')) return;
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'nts-tracklist-toggle-btn';
const cur = store.tracks[store.currentTrackIndex];
if (cur) {
btn.textContent = `▶ ${cur.artist}${cur.title ? ' - ' + cur.title : ''}`;
} else if (store.liveMode) {
btn.textContent = `▶ ${store.showTitle || 'Live Radio'}`;
} else {
btn.textContent = '🎵 Tracklist';
}
btn.onclick = () => togglePane();
document.body.appendChild(btn);
}
function togglePane() {
const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');
if (pane && pane.style.display !== 'none') {
sessionStorage.setItem('nts_pane_open', 'false');
pane.style.display = 'none';
renderToggleButton();
return;
}
if (pane) {
sessionStorage.setItem('nts_pane_open', 'true');
pane.style.display = 'flex';
document.querySelector('.nts-tracklist-toggle-btn')?.remove();
return;
}
sessionStorage.setItem('nts_pane_open', 'true');
if (location.pathname.includes('/episodes/')) {
fetchAndCreatePane();
} else if (isLivePage()) {
store.liveMode = true;
const m = location.pathname.match(MIXTAPE_RE);
initMixtapeMode(m ? m[1] : null);
}
}
function destroyPane(invalidateToken = true) {
if (invalidateToken) {
navToken++;
store.abortController?.abort();
}
detachLiveTracklistFrame();
if (store.liveObserver) {
store.liveObserver.disconnect();
store.liveObserver = null;
}
document.querySelector('.nts-tracklist-pane')?.remove();
document.querySelector('.nts-tracklist-toggle-btn')?.remove();
store.currentTrackIndex = null;
store.activeAudio = null;
store.dom = {};
mcWidget = null;
mcIframe = null;
scWidget = null;
scIframe = null;
}
function parseRawTrack(track) {
let artist = track.artist || '';
if (!artist && Array.isArray(track.mainArtists)) {
artist = track.mainArtists.map(a => a.name || a).join(', ');
} else if (!artist && Array.isArray(track.artists)) {
artist = track.artists.map(a => a.name || a).join(', ');
}
const offset = track.offset != null ? Number(track.offset) : (track.offset_estimate != null ? Number(track.offset_estimate) : null);
const title = track.title || 'Untitled';
const finalArtist = artist || 'Unknown';
return {
title,
artist: finalArtist,
_lcTitle: title.toLowerCase(),
_lcArtist: finalArtist.toLowerCase(),
offset: offset,
duration: track.duration || null,
};
}
function scrapeDOMTracks() {
const container = document.querySelector('#tracklist, .tracklist, [class*="tracklist"]');
if (!container) return [];
const domTracks = [];
const trackNodes = container.querySelectorAll('.track, [class*="track__"], li[class*="track"]');
trackNodes.forEach(node => {
const artist = node.querySelector('.track__artist, [class*="artist"]')?.textContent?.trim() || '';
const title = node.querySelector('.track__title, [class*="title"]')?.textContent?.trim() || '';
if (artist && title) {
domTracks.push({
artist,
title,
_lcArtist: artist.toLowerCase(),
_lcTitle: title.toLowerCase(),
offset: null,
duration: null
});
}
});
return domTracks;
}
// --- Unified Live (Mixtape & Channel) Mode ---
async function initMixtapeMode(alias) {
destroyPane(true);
const token = navToken;
store.liveMode = true;
store.tracks = [];
store.lastTimedIndex = -1;
store.seen.clear();
const targetSlug = alias || detectActiveChannelOrMixtape();
if (alias) {
try {
const res = await fetch('https://www.nts.live/api/v2/mixtapes');
if (res.ok) {
const data = await res.json();
const mix = (data.results || []).find(m =>
m.mixtape_alias === alias || m.alias === alias
);
store.showTitle = mix?.title ? `∞ ${mix.title}` : '∞ Infinite Mixtape';
store.episodeTitle = mix?.subtitle || mix?.description || '';
}
} catch {}
} else {
try {
const res = await fetch('https://www.nts.live/api/v2/live');
if (res.ok) {
const data = await res.json();
const activeCh = detectActiveChannelOrMixtape();
const chData = (data.results || []).find(r => String(r.channel_name) === activeCh) || data.results?.[0];
store.showTitle = chData?.now?.broadcast_title || chData?.now?.embeds?.details?.name || `NTS Channel ${activeCh}`;
store.episodeTitle = chData?.channel_name ? `Channel ${chData.channel_name}` : '';
if (chData) queueLiveShow(chData);
}
} catch {}
}
if (token !== navToken) return;
createTracklistPane();
renderTrackRows([]);
installFetchTap();
installXhrTap();
watchNowPlayingDom();
attachLiveTracklistFrame(targetSlug);
}
// --- Archived Episodes Mode ---
async function fetchAndCreatePane() {
const path = window.location.pathname;
if (!path.includes('/episodes/')) return;
const token = ++navToken;
store.abortController?.abort();
store.abortController = new AbortController();
let rawList = [];
try {
const nextDataEl = document.getElementById('__NEXT_DATA__');
if (nextDataEl?.textContent && !document.body.dataset.ntsNextUsed) {
document.body.dataset.ntsNextUsed = '1';
const nextJson = JSON.parse(nextDataEl.textContent);
const epData = nextJson.props?.pageProps?.episode || nextJson.props?.pageProps;
if (epData) {
store.showTitle = epData.show?.title || '';
store.episodeTitle = epData.title || '';
rawList = epData.tracklist || epData.results || [];
}
}
} catch {}
if (!Array.isArray(rawList) || !rawList.length) {
const match = path.match(/\/shows\/([^/]+)\/episodes\/([^/]+)/);
if (match) {
const [_, show, episode] = match;
try {
const res = await fetch(`/api/v2/shows/${show}/episodes/${episode}`, { signal: store.abortController.signal });
if (res.ok) {
const data = await res.json();
store.showTitle = data.show?.title || '';
store.episodeTitle = data.title || '';
rawList = data.tracklist || data.results || [];
}
} catch (e) {
if (e.name === 'AbortError') return;
}
}
}
if (!Array.isArray(rawList) || !rawList.length) {
try {
const res = await fetch(window.location.href, {
headers: { 'Accept': 'application/json' },
signal: store.abortController.signal
});
if (res.ok) {
const data = await res.json();
store.showTitle = data.show?.title || '';
store.episodeTitle = data.title || '';
rawList = data.tracklist || data.results || (data.episode && data.episode.tracklist) || [];
}
} catch (e) {
if (e.name === 'AbortError') return;
}
}
if (token !== navToken) return;
let parsed = Array.isArray(rawList) ? rawList.map(parseRawTrack) : [];
if (!parsed.length) {
parsed = scrapeDOMTracks();
}
parsed.sort((a, b) => {
if (a.offset != null && b.offset != null) return a.offset - b.offset;
if (a.offset != null) return -1;
if (b.offset != null) return 1;
return 0;
});
store.tracks = parsed;
let lastTimed = -1;
for (let i = parsed.length - 1; i >= 0; i--) {
if (parsed[i].offset != null) {
lastTimed = i;
break;
}
}
store.lastTimedIndex = lastTimed;
if (parsed.length > 0) {
createTracklistPane();
renderTrackRows(parsed.map((track, idx) => ({ track, idx })));
} else {
renderToggleButton();
}
}
// --- Navigation Router ---
function handleUrlChange() {
if (location.pathname === lastPath) return;
lastPath = location.pathname;
if (location.pathname.includes('/episodes/')) {
store.liveMode = false;
fetchAndCreatePane();
} else if (isLivePage()) {
store.liveMode = true;
const m = location.pathname.match(MIXTAPE_RE);
initMixtapeMode(m ? m[1] : null);
} else {
store.liveMode = false;
destroyPane(true);
}
}
function init() {
if (initPopupScraper()) return;
processBackfillQueue(false);
if (trackBus) {
trackBus.onmessage = (e) => {
if (store.liveMode && e.data?.artist && !isJunkString(e.data.artist)) {
pushLiveTrack(e.data.artist, e.data.title || null, e.data.seenAt || null);
}
};
}
installFetchTap();
installXhrTap();
document.addEventListener('timeupdate', (e) => {
if (e.target.tagName === 'AUDIO' && isSeekable(e.target) && store.tracks.length && !store.liveMode) {
updateCurrentTrack(e.target.currentTime);
}
}, true);
window.addEventListener('message', (event) => {
if (store.liveMode || !store.tracks.length || !event.data) return;
try {
const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data;
if (data.widgetEvent === 'playProgress' || data.method === 'playProgress') {
const ms = data.value?.currentPosition ?? data.currentPosition;
if (ms != null) updateCurrentTrack(ms / 1000);
}
} catch (e) {}
});
let iframeCheckScheduled = false;
const iframeWatcher = new MutationObserver(() => {
if (iframeCheckScheduled || (mcWidget && scWidget)) return;
iframeCheckScheduled = true;
setTimeout(() => {
iframeCheckScheduled = false;
if (!mcWidget) attachMixcloud();
if (!scWidget) attachSoundCloud();
}, 500);
});
iframeWatcher.observe(document.body, { childList: true, subtree: true });
attachMixcloud();
attachSoundCloud();
window.addEventListener('popstate', handleUrlChange);
const wrapHistory = (type) => {
const orig = history[type];
return function (...args) {
const res = orig.apply(this, args);
handleUrlChange();
return res;
};
};
history.pushState = wrapHistory('pushState');
history.replaceState = wrapHistory('replaceState');
window.addEventListener('keydown', (e) => {
const activeEl = document.activeElement;
const isInput = ['INPUT', 'TEXTAREA'].includes(activeEl?.tagName);
if (e.key.toLowerCase() === 't' && !isInput && !e.ctrlKey && !e.metaKey && !e.altKey) {
togglePane();
}
if (e.key === 'Escape') {
const searchInput = store.dom.searchInput || document.querySelector('#nts-search-input');
const pane = store.dom.pane || document.querySelector('.nts-tracklist-pane');
if (isInput && searchInput && searchInput.value) {
searchInput.value = '';
renderTrackRows(store.tracks.map((track, idx) => ({ track, idx })));
searchInput.blur();
} else if (pane && pane.style.display !== 'none') {
sessionStorage.setItem('nts_pane_open', 'false');
pane.style.display = 'none';
renderToggleButton();
}
}
});
handleUrlChange();
}
// ============================================================
// NTS PLUS+ — Persistent Discovery & Export Layer
// ============================================================
const NTS_PLUS_LIBRARY_KEY = 'nts_plus_library_v1';
const NTS_PLUS_UI_KEY = 'nts_plus_ui_v1';
function ntsLoad(key, fallback) {
try { const value = JSON.parse(localStorage.getItem(key)); return value ?? fallback; }
catch { return fallback; }
}
function ntsSave(key, value) {
try { localStorage.setItem(key, JSON.stringify(value)); } catch {}
}
function ntsNorm(v) {
return String(v || '').toLowerCase().replace(/\s+/g, ' ').replace(/[‘’“”"']/g, '').trim();
}
function ntsTrackKey(t) { return `${ntsNorm(t?.artist)}|${ntsNorm(t?.title)}`; }
function ntsLibrary() { return ntsLoad(NTS_PLUS_LIBRARY_KEY, {}); }
function ntsIsFavourite(t) { return !!ntsLibrary()[ntsTrackKey(t)]; }
function ntsToggleFavourite(t) {
if (!t) return false;
const lib = ntsLibrary(), key = ntsTrackKey(t);
if (lib[key]) { delete lib[key]; ntsSave(NTS_PLUS_LIBRARY_KEY, lib); return false; }
lib[key] = {
artist: t.artist || '', title: t.title || '', addedAt: Date.now(),
source: { showTitle: store.showTitle || '', episodeTitle: store.episodeTitle || '', url: location.href, offset: t.offset ?? null }
};
ntsSave(NTS_PLUS_LIBRARY_KEY, lib);
return true;
}
function ntsSavePaneState(pane) {
if (!pane) return;
const r = pane.getBoundingClientRect();
ntsSave(NTS_PLUS_UI_KEY, { left:r.left, top:r.top, width:r.width, height:r.height });
}
function ntsRestorePaneState(pane) {
const s = ntsLoad(NTS_PLUS_UI_KEY, null);
if (!s) return;
const w = Math.min(s.width || pane.offsetWidth, window.innerWidth);
const h = Math.min(s.height || pane.offsetHeight, window.innerHeight);
const l = Math.max(0, Math.min(s.left || 0, window.innerWidth - w));
const t = Math.max(0, Math.min(s.top || 0, window.innerHeight - h));
Object.assign(pane.style, { width:`${w}px`, height:`${h}px`, left:`${l}px`, top:`${t}px`, right:'auto', bottom:'auto' });
}
function ntsStats() {
const tracks = store.tracks.filter(Boolean);
const counts = new Map();
tracks.forEach(t => { const a=(t.artist||'').trim(); if(a) counts.set(a,(counts.get(a)||0)+1); });
return {
tracks: tracks.length,
uniqueArtists: new Set(tracks.map(t=>ntsNorm(t.artist)).filter(Boolean)).size,
topArtists:[...counts.entries()].sort((a,b)=>b[1]-a[1]).slice(0,5),
favourites: Object.keys(ntsLibrary()).length
};
}
function ntsStatsText() {
const s=ntsStats();
return [`Tracks: ${s.tracks}`,`Unique artists: ${s.uniqueArtists}`,`Saved favourites: ${s.favourites}`,'','Top artists:', ...(s.topArtists.length?s.topArtists.map(([a,n])=>`${a} × ${n}`):['No artist data'])].join('\n');
}
function ntsExportTracks() {
return store.tracks.map((t,i)=>({
number:i+1,
time:store.liveMode?(t.seenAt||'--:--'):(t.offset!=null?formatTime(t.offset):'--:--'),
offset:t.offset??null, artist:t.artist||'', title:t.title||'', duration:t.duration??null,
favourite:ntsIsFavourite(t)
}));
}
function ntsCsv() {
const esc=v=>`"${String(v??'').replace(/"/g,'""')}"`;
return [['Number','Time','Offset','Artist','Title','Duration','Favourite'],...ntsExportTracks().map(t=>[t.number,t.time,t.offset??'',t.artist,t.title,t.duration??'',t.favourite?'Yes':'No'])].map(r=>r.map(esc).join(',')).join('\n');
}
function ntsMarkdown() {
const esc=v=>String(v||'').replace(/\|/g,'\\|');
return [`# ${store.showTitle||'NTS Tracklist'}`,store.episodeTitle?`**Episode:** ${store.episodeTitle}`:'',`**Source:** ${location.href}`,'','| # | Time | Artist | Track | ★ |','|---|---|---|---|---|',...ntsExportTracks().map(t=>`| ${t.number} | ${t.time} | ${esc(t.artist)} | ${esc(t.title)||'—'} | ${t.favourite?'★':''} |`)].filter(Boolean).join('\n');
}
function ntsJson() { return JSON.stringify({ exportedAt:new Date().toISOString(), show:store.showTitle, episode:store.episodeTitle, url:location.href, liveMode:store.liveMode, stats:ntsStats(), tracks:ntsExportTracks() },null,2); }
function ntsTxt() { return ntsExportTracks().map(t=>`${t.time} | ${t.favourite?'★ ':''}${t.artist}${t.title?' - '+t.title:''}`).join('\n'); }
function ntsDownload(name, content, type) {
const a=document.createElement('a'), url=URL.createObjectURL(new Blob([content],{type:type+';charset=utf-8'}));
a.href=url;a.download=name;document.body.appendChild(a);a.click();a.remove();setTimeout(()=>URL.revokeObjectURL(url),1000);
}
function ntsRandom() {
if (!store.tracks.length) return;
const pool=store.tracks.filter(t=>!ntsIsFavourite(t));
const t=(pool.length?pool:store.tracks)[Math.floor(Math.random()*(pool.length||store.tracks.length))];
const idx=store.tracks.indexOf(t);
if (!store.liveMode && t.offset!=null) jumpToOffset(t.offset);
const row=store.dom.tbody?.querySelector(`tr[data-index="${idx}"]`);
row?.scrollIntoView({block:'center',behavior:'smooth'});
row?.classList.add('nts-random-highlight');setTimeout(()=>row?.classList.remove('nts-random-highlight'),1800);
}
function ntsDecorateRows() {
const tbody=store.dom.tbody; if(!tbody) return;
tbody.querySelectorAll('tr[data-index]').forEach(row=>{
if(row.querySelector('.nts-favourite-cell')) return;
const idx=Number(row.dataset.index), t=store.tracks[idx]; if(!t) return;
const td=document.createElement('td'); td.className='nts-favourite-cell';
const b=document.createElement('button'); b.type='button'; b.className='nts-favourite-btn'+(ntsIsFavourite(t)?' active':'');
b.dataset.favourite=idx;b.textContent=ntsIsFavourite(t)?'★':'☆';b.title='Toggle favourite';td.appendChild(b);
row.insertBefore(td,row.children[1]||null);
});
}
GM_addStyle(`
.nts-favourite-cell{width:22px;padding-left:2px!important;padding-right:2px!important;text-align:center}
.nts-favourite-btn{background:none;border:0;color:#777;cursor:pointer;font-size:15px;padding:1px 3px}
.nts-favourite-btn:hover{color:#fff;transform:scale(1.15)}
.nts-favourite-btn.active{color:#ffd700}
.nts-random-highlight{animation:ntsRandomPulse 1.8s ease}
@keyframes ntsRandomPulse{0%{box-shadow:inset 0 0 0 999px rgba(255,255,255,.16)}100%{box-shadow:inset 0 0 0 999px rgba(255,255,255,0)}}
.nts-export-menu{position:absolute;right:42px;top:40px;background:#171717;border:1px solid rgba(255,255,255,.2);border-radius:6px;padding:4px;min-width:145px;z-index:1000001;box-shadow:0 8px 24px rgba(0,0,0,.7)}
.nts-export-menu button{display:block;width:100%;background:none;border:0;color:#eee;text-align:left;padding:7px 9px;cursor:pointer;border-radius:4px;font-size:11px}
.nts-export-menu button:hover{background:rgba(255,255,255,.1);color:#fff}
`);
const ntsOriginalCreatePane=createTracklistPane, ntsOriginalRenderRows=renderTrackRows, ntsOriginalAppendLiveRow=appendLiveRow, ntsOriginalDrag=makeDraggable;
renderTrackRows=function(items){ ntsOriginalRenderRows(items); ntsDecorateRows(); };
appendLiveRow=function(t,i){ ntsOriginalAppendLiveRow(t,i); ntsDecorateRows(); };
makeDraggable=function(pane,header){ ntsOriginalDrag(pane,header); header.addEventListener('pointerup',()=>setTimeout(()=>ntsSavePaneState(pane),0)); };
createTracklistPane=function(){
const pane=ntsOriginalCreatePane();
if(!pane) return pane;
ntsRestorePaneState(pane);
const head=pane.querySelector('.nts-header-actions');
if(head && !pane.querySelector('#nts-random-btn')){
const random=document.createElement('button');random.type='button';random.className='nts-btn-icon';random.id='nts-random-btn';random.title='Random discovery (R)';random.textContent='🎲';
const stats=document.createElement('button');stats.type='button';stats.className='nts-btn-icon';stats.id='nts-stats-btn';stats.title='Track statistics (S)';stats.textContent='Stats';
head.insertBefore(stats,head.querySelector('#nts-export-btn'));head.insertBefore(random,stats);
random.addEventListener('click',ntsRandom);
stats.addEventListener('click',()=>alert(ntsStatsText()));
}
const exportBtn=pane.querySelector('#nts-export-btn');
if(exportBtn){
const fresh=exportBtn.cloneNode(true);exportBtn.replaceWith(fresh);store.dom.exportBtn=fresh;
fresh.addEventListener('click',(e)=>{
e.stopPropagation();
pane.querySelectorAll('.nts-export-menu').forEach(m=>m.remove());
const menu=document.createElement('div');menu.className='nts-export-menu';
menu.style.right = store.liveMode ? '78px' : '118px';
menu.innerHTML='<button data-x="txt">Copy TXT</button><button data-x="md">Copy Markdown</button><button data-x="csv">Download CSV</button><button data-x="json">Download JSON</button>';
pane.appendChild(menu);
menu.addEventListener('click',async evt=>{
const type=evt.target.dataset.x;if(!type)return;
const content=type==='csv'?ntsCsv():type==='json'?ntsJson():type==='md'?ntsMarkdown():ntsTxt();
if(type==='csv'||type==='json'){
const base=(store.showTitle||'nts-tracklist').replace(/[<>:"/\\\\|?*]+/g,'-');
ntsDownload(`${base}.${type}`,content,type==='json'?'application/json':'text/csv');
} else await copyText(content||'No tracks logged.');
evt.target.textContent=type==='csv'||type==='json'?'Downloaded ✓':'Copied ✓';
setTimeout(()=>menu.remove(),700);
});
const closeListener = (ev) => {
if (!menu.contains(ev.target) && ev.target !== fresh) {
menu.remove();
document.removeEventListener('click', closeListener);
}
};
setTimeout(() => document.addEventListener('click', closeListener), 0);
});
}
pane.addEventListener('click',e=>{
const b=e.target.closest('[data-favourite]');if(!b)return;
const t=store.tracks[Number(b.dataset.favourite)];if(!t)return;
const on=ntsToggleFavourite(t);b.textContent=on?'★':'☆';b.classList.toggle('active',on);
});
setTimeout(ntsDecorateRows,0);
return pane;
};
init();