您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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();