Greasy Fork is available in English.
Interactive tracklist for NTS Live with seeking, time-sync, CUE export, multi-service links, Supporter embed bridge, and Archive Backfill.
// ==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, '<')
.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;
}
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();