NTS Live Enhanced Tracklist

Adds an interactive tracklist panel to NTS Live shows with time-synced highlighting, clickable tracks for navigation, and Spotify search integration. Features include auto-scrolling to current track, custom dark theme, and direct playback control.

// ==UserScript==
// @name        NTS Live Enhanced Tracklist
// @namespace   Violentmonkey Scripts
// @match       https://www.nts.live/shows/*
// @grant       GM_addStyle
// @version     1.2
// @author      Reorx
// @license MIT
// @description Adds an interactive tracklist panel to NTS Live shows with time-synced highlighting, clickable tracks for navigation, and Spotify search integration. Features include auto-scrolling to current track, custom dark theme, and direct playback control.
// ==/UserScript==

GM_addStyle(`
  .nts-tracklist-pane {
    font-size: 13px;
    position: fixed;
    bottom: 12px;
    right: 12px;
    width: min(600px, 80vw);
    height: calc(100vh - 96px);
    background: rgba(0, 0, 0, 0.9);
    color: white;
    border-radius: 8px;
    z-index: 10003;
    display: flex;
    flex-direction: column;
  }
  .nts-tracklist-header {
    padding: 12px 15px;
    border-bottom: 1px solid rgba(255, 255, 255, 0.2);
    display: flex;
    justify-content: space-between;
    align-items: center;
    font-weight: bold;
    font-size: 14px;
  }
  .nts-tracklist-content {
    padding: 0;
    overflow-y: auto;
    flex: 1;
    scrollbar-width: thin;
    scrollbar-color: rgba(255, 255, 255, 0.3) transparent;
  }
  .nts-tracklist-content::-webkit-scrollbar {
    width: 8px;
  }
  .nts-tracklist-content::-webkit-scrollbar-track {
    background: transparent;
  }
  .nts-tracklist-content::-webkit-scrollbar-thumb {
    background-color: rgba(255, 255, 255, 0.3);
    border-radius: 4px;
  }
  .nts-tracklist-content::-webkit-scrollbar-thumb:hover {
    background-color: rgba(255, 255, 255, 0.5);
  }
  .nts-tracklist-table {
    width: 100%;
    border-collapse: collapse;
  }
  .nts-tracklist-table th,
  .nts-tracklist-table td {
    padding: 8px;
    text-align: left;
    border-bottom: 1px solid rgba(255, 255, 255, 0.1);
  }
  .nts-tracklist-table th {
    font-weight: bold;
  }
  .nts-tracklist-table tr.playing {
    background: rgba(255, 255, 255, 0.2);
    font-weight: bold;
  }
  .nts-tracklist-table td.playing-icon {
    color: #1DB954;
    text-align: center;
    width: 20px;
    padding-left: 4px;
    padding-right: 4px;
  }
  .nts-tracklist-close {
    cursor: pointer;
    color: white;
    font-size: 20px;
    line-height: 1;
  }
  .nts-tracklist-table .title {
    cursor: pointer;
  }
  .nts-tracklist-table .title:hover {
    text-decoration: underline;
  }
`);

function formatTime(seconds) {
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const remainingSeconds = Math.floor(seconds % 60);

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

function createTracklistPane() {
  // Remove any existing pane first
  destroyPane();

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

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

  const title = document.createElement('span');
  title.textContent = 'Enhanced Tracklist';

  const closeBtn = document.createElement('span');
  closeBtn.className = 'nts-tracklist-close';
  closeBtn.innerHTML = '×';
  closeBtn.onclick = () => pane.remove();

  header.appendChild(title);
  header.appendChild(closeBtn);

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

  const table = document.createElement('table');
  table.className = 'nts-tracklist-table';
  table.innerHTML = `
    <thead>
      <tr>
        <th></th>
        <th>Time</th>
        <th>Title</th>
        <th>Artist</th>
        <th>Dur</th>
        <th>Open</th>
      </tr>
    </thead>
    <tbody></tbody>
  `;

  content.appendChild(table);
  pane.appendChild(header);
  pane.appendChild(content);
  document.body.appendChild(pane);
  return table.querySelector('tbody');
}

const store = {
  currentTrackIndex: null,
}

function destroyPane() {
  const existingPane = document.querySelector('.nts-tracklist-pane');
  if (existingPane) {
    existingPane.remove();
  }
  store.currentTrackIndex = null;
}

function updateCurrentTrack(tracklist, currentTime) {
  const rows = document.querySelectorAll('.nts-tracklist-table tbody tr');
  rows.forEach(row => {
    row.classList.remove('playing');
    row.querySelector('.playing-icon').textContent = '';
  });

  // Find the current track by comparing offsets
  const currentTrack = tracklist.find((track, index) => {
    const nextTrack = tracklist[index + 1];
    return track.offset <= currentTime &&
           (!nextTrack || currentTime < nextTrack.offset);
  });

  if (currentTrack) {
    const trackIndex = tracklist.indexOf(currentTrack);
    const currentRow = rows[trackIndex];
    if (currentRow) {
      currentRow.classList.add('playing');
      currentRow.querySelector('.playing-icon').textContent = '▶';

      // If the track has changed, scroll it into view
      if (store.currentTrackIndex !== trackIndex) {
        console.log('currentTrackIndex', store.currentTrackIndex, 'trackIndex', trackIndex);
        currentRow.scrollIntoView({ behavior: 'smooth', block: 'center' });
        store.currentTrackIndex = trackIndex;
      }
    }
  }
}

function jumpToOffset(offset) {
  const audio = document.querySelector('.soundcloud-player__content audio');
  if (audio) {
    audio.currentTime = offset;
  }
}

function renderTracklist(tracklist) {
  const tbody = document.querySelector('.nts-tracklist-table tbody');
  if (!tbody) return;

  tbody.innerHTML = tracklist.map(track => `
    <tr>
      <td class="playing-icon"></td>
      <td>${formatTime(track.offset)}</td>
      <td><span class="title" onclick="(${jumpToOffset.toString()})(${track.offset})">${track.title}</span></td>
      <td>${track.artist}</td>
      <td>${track.duration ? formatTime(track.duration) : '-'}</td>
      <td><a href="spotify:search:${encodeURIComponent(track.title)}" style="color: #1DB954; text-decoration: none;">S</a></td>
    </tr>
  `).join('');
}

async function fetchAndCreatePane() {
  const path = window.location.pathname;
  const match = path.match(/\/shows\/([^/]+)\/episodes\/([^/]+)/);
  if (!match) return;

  const [_, show, episode] = match;
  const apiUrl = `https://www.nts.live/api/v2/shows/${show}/episodes/${episode}/tracklist`;

  try {
    const response = await fetch(apiUrl);
    const data = await response.json();
    const validTracks = data.results
      .map(track => ({
        ...track,
        offset: track.offset ?? track.offset_estimate
      }))
      .sort((a, b) => a.offset - b.offset);

    const tbody = createTracklistPane();
    renderTracklist(validTracks);

    const audio = document.querySelector('.soundcloud-player__content audio');
    if (!audio) return;

    audio.addEventListener('timeupdate', () => {
      updateCurrentTrack(validTracks, audio.currentTime);
    });

    // Initial update
    updateCurrentTrack(validTracks, audio.currentTime);

  } catch (error) {
    console.error('Failed to fetch tracklist:', error);
  }
}


function handleUrlChange() {
  const path = window.location.pathname;
  const match = path.match(/\/shows\/([^/]+)\/episodes\/([^/]+)/);
  if (!match) {
    destroyPane();
  } else {
    fetchAndCreatePane();
  }
}

async function init() {
  // Setup history change listeners
  window.addEventListener('popstate', handleUrlChange);
  const originalPushState = history.pushState;


  history.pushState = function() {
    originalPushState.apply(this, arguments);
    handleUrlChange();
  };
  const originalReplaceState = history.replaceState;
  history.replaceState = function() {
    originalReplaceState.apply(this, arguments);
    handleUrlChange();
  };

  // handle initial page load
  handleUrlChange()
}

// Start the script
init();