Play music from Spotify, YouTube & Apple Music while you race on Nitro Type. Paste any playlist, album, or track URL — playback is bridged through YouTube with album art, shuffle, session resume, and more.
// ==UserScript==
// @name Nitro Type - Universal Music Player
// @namespace https://nitrotype.info
// @version 2.1.2
// @license GPL-3.0-or-later
// @author Captain.Loveridge
// @description Play music from Spotify, YouTube & Apple Music while you race on Nitro Type. Paste any playlist, album, or track URL — playback is bridged through YouTube with album art, shuffle, session resume, and more.
// @match https://www.nitrotype.com/race
// @match https://www.nitrotype.com/race?*
// @match https://www.nitrotype.com/race/*
// @match *://*.nitrotype.com/settings/mods*
// @grant GM_xmlhttpRequest
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant unsafeWindow
// @connect open.spotify.com
// @connect api.spotify.com
// @connect *.invidious.io
// @connect vid.puffyan.us
// @connect invidious.snopyta.org
// @connect invidious.nerdvpn.de
// @connect inv.nadeko.net
// @connect pipedapi.kavin.rocks
// @connect pipedapi.adminforge.de
// @connect pipedapi.syncpundit.io
// @connect www.youtube.com
// @connect music.youtube.com
// @connect youtube.com
// @connect www.google.com
// @connect noembed.com
// @connect itunes.apple.com
// @connect music.apple.com
// @run-at document-start
// ==/UserScript==
(function () {
'use strict';
if (window.top !== window.self) return;
const pageWindow = (typeof unsafeWindow !== 'undefined' && unsafeWindow) ? unsafeWindow : window;
const SCRIPT_SINGLETON_KEY = '__ntUniversalMusicSingleton';
if (pageWindow[SCRIPT_SINGLETON_KEY]) {
try { console.info('[NT Music] Duplicate instance detected; skipping.'); } catch (_) { }
return;
}
pageWindow[SCRIPT_SINGLETON_KEY] = true;
const SCRIPT_VERSION = '2.1.2';
const YT_CACHE_VERSION = 1;
const STATION_ID = 'spotify';
const OEMBED_BASE = 'https://open.spotify.com/oembed?url=';
const YT_IFRAME_API_SRC = 'https://www.youtube.com/iframe_api';
const YT_READY_EVENT = 'nt-yt-iframe-api-ready';
const YT_ERROR_EVENT = 'nt-yt-iframe-api-error';
const EMBED_API_SRC = 'https://open.spotify.com/embed/iframe-api/v1';
const EMBED_READY_EVENT = 'nt-spotify-embed-api-ready';
const EMBED_ERROR_EVENT = 'nt-spotify-embed-api-error';
const PROGRESS_TICK_MS = 1000;
const WEAK_META_RETRY_MS = 15000;
const FRAME_WATCH_TICK_MS = 250;
const AUTO_ACTIVATE_DELAY_MS = 250;
const PRE_CUE_INIT_DELAY_MS = 250;
const FRAME_UI_REFRESH_MS = 1000;
const NATIVE_AUDIO_REFRESH_MS = 3000;
const TRACE_STORAGE_KEY = 'nt_music_runtime_trace';
const TRACE_LIMIT = 120;
const INVIDIOUS_INSTANCES = [
'https://vid.puffyan.us',
'https://invidious.snopyta.org',
'https://invidious.nerdvpn.de',
'https://inv.nadeko.net',
];
const PIPED_INSTANCES = [
'https://pipedapi.kavin.rocks',
'https://pipedapi.adminforge.de',
'https://pipedapi.syncpundit.io',
];
const STORAGE_KEYS = {
sourceUri: 'nt_spotify_embed_source_uri',
sourceUrl: 'nt_spotify_embed_source_url',
sessionActive: 'nt_spotify_session_active',
queueIndex: 'nt_spotify_queue_index',
playbackPositionSec: 'nt_spotify_playback_pos_sec',
shuffleOrder: 'nt_spotify_shuffle_order',
shufflePosition: 'nt_spotify_shuffle_position',
queueMode: 'nt_spotify_queue_mode',
};
// (boundReactElements removed — unused after React approach was replaced by Howler lock)
const state = {
frame: null,
frameDoc: null,
frameWindow: null,
frameWatchTimer: null,
frameWatchStartTimer: null,
frameSuppressRetryTimers: [],
autoActivateTimer: null,
preCueTimer: null,
nextTrackPreResolveTimer: null,
active: false,
pendingSelection: false,
progressTimer: null,
ytApi: null,
ytApiLoaded: false,
ytApiLoadStarted: false,
ytApiLoadFailed: false,
ytApiWaiters: [],
ytPlayer: null,
ytPlayerPromise: null,
ytPlayerReady: false,
ytPlayerHost: null,
queue: [],
queueIndex: -1,
queueMode: 'shuffle',
shuffleOrder: [],
shufflePosition: -1,
playHistory: [],
playback: {
uri: '',
position: 0,
duration: 0,
isPaused: true,
isBuffering: false,
updatedAt: 0,
},
trackMeta: null,
outputVolume: 1,
metaCache: new Map(),
metaRetryAt: new Map(),
sourceTrackListCache: new Map(),
ytSearchCache: new Map(),
ytSearchInFlightByUri: new Map(),
spotifyAccessToken: '',
spotifyAccessTokenExpiresAt: 0,
spotifyIsAnonymous: false,
spotifyIsPremium: false,
embedApi: null,
embedWaiters: [],
embedHost: null,
embedController: null,
embedApiLoadStarted: false,
embedApiLoadFailed: false,
pendingToast: null,
isActivating: false,
suppressNativeStationEventsUntil: 0,
originalStationLabel: '',
originalIndicatorHTML: '',
preCuePromise: null,
preCueResult: null,
preCueSourceUri: '',
_audioObserverDoc: null,
_audioObserverTop: null,
_frameUiObserver: null,
_frameUiSyncQueued: false,
_lastFrameUiSync: 0,
_lastNativeMuteCheck: 0,
_stopCooldownUntil: 0,
_spotifyItemActionUntil: 0,
_pendingNativeStationHandoffTimer: 0,
_pendingNativeStationHandoffToken: 0,
_pendingSfxSyncTimer: 0,
_autoActivateAttempted: false,
_pendingSeekSec: 0,
_lastForcedNativeMusicOff: 0,
_howlerLocked: false, // true while Howler._volume/_muted are property-locked
_advancingQueue: false, // semaphore to prevent double-fire of playNextInQueue
_lastYTErrorCode: 0, // debounce repeated YT error events
_lastYTErrorTime: 0,
};
let musicRuntimeStarted = false;
function formatTraceError(error) {
if (!error) return '';
if (typeof error === 'string') return error;
const message = String(error.message || error.toString() || 'Unknown error');
const stack = typeof error.stack === 'string' ? error.stack.split('\n').slice(0, 2).join(' | ') : '';
return stack ? `${message} :: ${stack}` : message;
}
function getMusicTraceEntries() {
try {
const raw = localStorage.getItem(TRACE_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed) ? parsed : [];
} catch (_) {
return [];
}
}
function clearMusicTrace() {
try {
localStorage.removeItem(TRACE_STORAGE_KEY);
} catch (_) {
// noop
}
}
function traceMusic(event, data) {
try {
const entries = getMusicTraceEntries();
entries.push({
t: Date.now(),
iso: new Date().toISOString(),
event: String(event || ''),
data: data && typeof data === 'object' ? data : { value: data },
});
while (entries.length > TRACE_LIMIT) entries.shift();
localStorage.setItem(TRACE_STORAGE_KEY, JSON.stringify(entries));
} catch (_) {
// noop
}
}
function initNTRouteHelper(targetWindow = window) {
const hostWindow = targetWindow || window;
const existing = hostWindow.NTRouteHelper;
if (existing && existing.__ntRouteHelperReady && typeof existing.subscribe === 'function') {
return existing;
}
const helper = existing || {};
const listeners = helper.listeners instanceof Set ? helper.listeners : new Set();
let currentKey = `${hostWindow.location.pathname}${hostWindow.location.search}${hostWindow.location.hash}`;
const notify = (reason) => {
const nextKey = `${hostWindow.location.pathname}${hostWindow.location.search}${hostWindow.location.hash}`;
if (reason !== 'init' && nextKey === currentKey) return;
const previousKey = currentKey;
currentKey = nextKey;
listeners.forEach((listener) => {
try {
listener({
reason,
previous: previousKey,
current: nextKey,
pathname: hostWindow.location.pathname
});
} catch (error) {
console.error('[NTRouteHelper] listener error', error);
}
});
helper.currentKey = currentKey;
};
if (!helper.__ntRouteHelperWrapped) {
const wrapHistoryMethod = (methodName) => {
const current = hostWindow.history[methodName];
if (typeof current !== 'function' || current.__ntRouteHelperWrapped) return;
const wrapped = function () {
const result = current.apply(this, arguments);
queueMicrotask(() => notify(methodName));
return result;
};
wrapped.__ntRouteHelperWrapped = true;
wrapped.__ntRouteHelperOriginal = current;
hostWindow.history[methodName] = wrapped;
};
wrapHistoryMethod('pushState');
wrapHistoryMethod('replaceState');
hostWindow.addEventListener('popstate', () => queueMicrotask(() => notify('popstate')));
helper.__ntRouteHelperWrapped = true;
}
helper.listeners = listeners;
helper.currentKey = currentKey;
helper.version = '1.0.0';
helper.__ntRouteHelperReady = true;
helper.subscribe = function (listener, options = {}) {
if (typeof listener !== 'function') return () => { };
listeners.add(listener);
if (options.immediate !== false) {
try {
listener({
reason: 'init',
previous: currentKey,
current: currentKey,
pathname: hostWindow.location.pathname
});
} catch (error) {
console.error('[NTRouteHelper] immediate listener error', error);
}
}
return () => listeners.delete(listener);
};
helper.notify = notify;
hostWindow.NTRouteHelper = helper;
return helper;
}
function isMusicRaceRoute(pathname = window.location.pathname) {
return /^\/race(?:\/|$)/i.test(String(pathname || ''));
}
// ─── Mod Menu Manifest Bridge ───────────────────────────────────────────────
const NTCFG_MUSIC_MANIFEST_ID = 'music-player';
const NTCFG_MUSIC_MANIFEST_KEY = `ntcfg:manifest:${NTCFG_MUSIC_MANIFEST_ID}`;
const NTCFG_MUSIC_VALUE_PREFIX = `ntcfg:${NTCFG_MUSIC_MANIFEST_ID}:`;
const NTCFG_MUSIC_BRIDGE_VERSION = '1.5.0-bridge.1';
const MUSIC_STORAGE_VERSION = 1;
const MUSIC_STORAGE_VERSION_KEY = `${NTCFG_MUSIC_VALUE_PREFIX}__storage_version`;
const MUSIC_SHARED_SETTINGS = {
SOURCE_URL: {
type: 'text',
label: 'Source URL',
default: '',
group: 'Source',
description: 'Spotify, YouTube, or Apple Music playlist/album/track URL.',
placeholder: 'Paste playlist, album, or track URL'
},
DEFAULT_PLATFORM: {
type: 'select',
label: 'Default Platform',
default: 'spotify',
group: 'Source',
description: 'Controls the label styling and initial queue hint when no source is configured.',
options: [
{ value: 'spotify', label: 'Spotify' },
{ value: 'youtube', label: 'YouTube' },
{ value: 'apple-music', label: 'Apple Music' }
]
},
SESSION_RESUME: {
type: 'boolean',
label: 'Resume Last Session',
default: true,
group: 'Source',
description: 'Restores queue position and shuffle mode after reload.'
},
AUTO_ACTIVATE: {
type: 'boolean',
label: 'Auto Activate on Race Load',
default: true,
group: 'Source',
description: 'Immediately bring the player shell online on race pages.'
},
QUEUE_MODE: {
type: 'select',
label: 'Queue Mode',
default: 'shuffle',
group: 'Playback',
description: 'Controls how tracks are ordered during playback.',
options: [
{ value: 'shuffle', label: 'Shuffle' },
{ value: 'sequential', label: 'Sequential' }
]
},
SHOW_ALBUM_ART: {
type: 'boolean',
label: 'Show Album Art',
default: true,
group: 'Playback',
description: 'Display album cover artwork in the inline now-playing card.'
},
MUTE_NATIVE_STATION: {
type: 'boolean',
label: 'Mute Native Nitro Type Station',
default: true,
group: 'Playback',
description: 'Silence the built-in race station when this player is active.'
},
PROGRESS_TICK_MS: {
type: 'number',
label: 'Progress Tick (ms)',
default: 1000,
group: 'Playback',
description: 'How often the inline player UI refreshes progress text.',
min: 250,
max: 5000,
step: 50
},
DEBUG_LOGGING: {
type: 'boolean',
label: 'Debug Logging',
default: false,
group: 'Advanced',
description: 'Enable verbose console logging for troubleshooting.'
}
};
const getNtcfgMusicStorageKey = (settingKey) => `${NTCFG_MUSIC_VALUE_PREFIX}${settingKey}`;
const coerceNtcfgMusicValue = (settingKey, value) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return value;
if (meta.type === 'boolean') {
if (typeof value === 'string') {
const raw = value.trim().toLowerCase();
if (raw === 'false' || raw === '0' || raw === 'off') return false;
if (raw === 'true' || raw === '1' || raw === 'on') return true;
}
return !!value;
}
if (meta.type === 'number') {
const fallback = Number(meta.default);
const parsed = Number(value);
let normalized = Number.isFinite(parsed) ? parsed : fallback;
const min = Number(meta.min);
const max = Number(meta.max);
const step = Number(meta.step);
if (Number.isFinite(step) && step >= 1) {
normalized = Math.round(normalized);
}
if (Number.isFinite(min)) normalized = Math.max(min, normalized);
if (Number.isFinite(max)) normalized = Math.min(max, normalized);
return normalized;
}
if (meta.type === 'select') {
const raw = String(value ?? '').trim();
const options = Array.isArray(meta.options) ? meta.options : [];
return options.some((opt) => String(opt.value) === raw) ? raw : meta.default;
}
return String(value ?? meta.default);
};
const readNtcfgMusicValue = (settingKey) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return undefined;
try {
const raw = localStorage.getItem(getNtcfgMusicStorageKey(settingKey));
if (raw == null) return meta.default;
const parsed = JSON.parse(raw);
return coerceNtcfgMusicValue(settingKey, parsed);
} catch {
return meta.default;
}
};
const writeNtcfgMusicValue = (settingKey, value) => {
try {
const serialized = JSON.stringify(value);
if (localStorage.getItem(getNtcfgMusicStorageKey(settingKey)) !== serialized) {
localStorage.setItem(getNtcfgMusicStorageKey(settingKey), serialized);
}
} catch {
// ignore storage sync failures
}
};
// GM_getValue/GM_setValue mapping for music player settings
const MUSIC_GM_KEY_MAP = {
SOURCE_URL: STORAGE_KEYS.sourceUrl,
QUEUE_MODE: STORAGE_KEYS.queueMode
};
const syncNtcfgMusicSettingFromGM = (settingKey) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return;
const gmKey = MUSIC_GM_KEY_MAP[settingKey];
let currentValue;
if (gmKey) {
// Settings that map to existing GM storage keys
currentValue = GM_getValue(gmKey, meta.default);
} else {
// Settings stored purely as GM values by setting key
currentValue = GM_getValue(`ntmusic_${settingKey}`, meta.default);
}
const normalized = coerceNtcfgMusicValue(settingKey, currentValue);
writeNtcfgMusicValue(settingKey, normalized);
};
const syncAllNtcfgMusicSettingsFromGM = () => {
Object.keys(MUSIC_SHARED_SETTINGS).forEach(syncNtcfgMusicSettingFromGM);
};
const setNtcfgMusicValue = (settingKey, value) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return value;
const normalized = coerceNtcfgMusicValue(settingKey, value);
const gmKey = MUSIC_GM_KEY_MAP[settingKey];
if (gmKey) {
GM_setValue(gmKey, normalized);
} else {
GM_setValue(`ntmusic_${settingKey}`, normalized);
}
writeNtcfgMusicValue(settingKey, normalized);
return normalized;
};
const applyNtcfgMusicValueDirect = (settingKey, value) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return;
const normalized = coerceNtcfgMusicValue(settingKey, value);
setNtcfgMusicValue(settingKey, normalized);
handleMusicSettingChange(settingKey, normalized);
};
const applyNtcfgMusicValueIfChanged = (settingKey, value) => {
const meta = MUSIC_SHARED_SETTINGS[settingKey];
if (!meta) return;
const normalized = coerceNtcfgMusicValue(settingKey, value);
const gmKey = MUSIC_GM_KEY_MAP[settingKey];
const currentRaw = gmKey ? GM_getValue(gmKey, meta.default) : GM_getValue(`ntmusic_${settingKey}`, meta.default);
const currentValue = coerceNtcfgMusicValue(settingKey, currentRaw);
if (JSON.stringify(currentValue) !== JSON.stringify(normalized)) {
setNtcfgMusicValue(settingKey, normalized);
handleMusicSettingChange(settingKey, normalized);
}
};
// Live setting change handler — wires mod menu changes to actual code
function handleMusicSettingChange(settingKey, value) {
switch (settingKey) {
case 'SOURCE_URL': {
const url = String(value || '').trim();
if (!url) {
clearSavedSource();
break;
}
if (typeof parseAndSaveSourceFromInput === 'function') {
void parseAndSaveSourceFromInput(url, false);
break;
}
const parsed = typeof parseSourceInput === 'function' ? parseSourceInput(url) : null;
if (parsed) {
saveSource(parsed);
if (state.active) {
void loadSource(parsed.uri);
}
} else {
setNtcfgMusicValue('SOURCE_URL', url);
}
break;
}
case 'SHOW_ALBUM_ART':
// Live toggle — re-render to show/hide art
if (typeof renderNowPlaying === 'function' && state.active) {
renderNowPlaying();
}
break;
case 'QUEUE_MODE':
// Update in-memory queue mode if it differs
if (state.queueMode !== value) {
state.queueMode = value;
if (value === 'shuffle' && state.queue.length > 1) {
generateShuffleOrder(state.queueIndex >= 0 ? state.queueIndex : 0);
} else if (value === 'sequential') {
state.shuffleOrder = [];
state.shufflePosition = -1;
}
}
break;
case 'MUTE_NATIVE_STATION':
// If toggled off while active, restore native audio immediately
if (!value && state.active && state.frameDoc) {
restoreNativeAudio(state.frameDoc);
}
// If toggled on while active, suppress native audio immediately
if (value && state.active && state.frameDoc) {
suppressNativeAudio(state.frameDoc);
}
break;
case 'PROGRESS_TICK_MS':
// Restart the progress timer with the new interval
if (state.active) {
startProgressTimer();
}
break;
case 'DEBUG_LOGGING':
_ntMusicApi.verbose = value === true;
break;
// SESSION_RESUME and AUTO_ACTIVATE are read at decision time — no live action needed
}
}
const registerNtcfgMusicManifest = () => {
try {
const manifest = {
id: NTCFG_MUSIC_MANIFEST_ID,
name: 'Music Player',
version: NTCFG_MUSIC_BRIDGE_VERSION,
scriptVersion: SCRIPT_VERSION,
storageVersion: MUSIC_STORAGE_VERSION,
supportsGlobalReset: true,
description: 'Universal music playback shell for Spotify, YouTube, and Apple Music sources.',
sections: [
{ id: 'source', title: 'Source', subtitle: 'Where the queue comes from.', resetButton: true },
{ id: 'playback', title: 'Playback', subtitle: 'Queue behavior and in-race control feel.', resetButton: true },
{ id: 'advanced', title: 'Advanced', subtitle: 'Debug and diagnostic controls.', resetButton: true }
],
settings: MUSIC_SHARED_SETTINGS
};
const serialized = JSON.stringify(manifest);
if (localStorage.getItem(NTCFG_MUSIC_MANIFEST_KEY) !== serialized) {
localStorage.setItem(NTCFG_MUSIC_MANIFEST_KEY, serialized);
}
} catch {
// ignore manifest registration failures
}
};
// Helper functions for reading settings at decision time
function isMusicSessionResumeEnabled() {
const val = readNtcfgMusicValue('SESSION_RESUME');
return val !== false;
}
function isMusicAutoActivateEnabled() {
const val = readNtcfgMusicValue('AUTO_ACTIVATE');
return val !== false;
}
function isMusicMuteNativeEnabled() {
const val = readNtcfgMusicValue('MUTE_NATIVE_STATION');
return val !== false;
}
function getProgressTickMs() {
const val = readNtcfgMusicValue('PROGRESS_TICK_MS');
return typeof val === 'number' && val >= 250 ? val : 1000;
}
function isMusicDebugEnabled() {
return readNtcfgMusicValue('DEBUG_LOGGING') === true;
}
function isMusicAlbumArtEnabled() {
const val = readNtcfgMusicValue('SHOW_ALBUM_ART');
return val !== false;
}
function getDefaultPlatform() {
const val = readNtcfgMusicValue('DEFAULT_PLATFORM');
return val || 'spotify';
}
function dispatchMusicActionResult(requestId, status, error = '') {
if (!requestId) return;
try {
document.dispatchEvent(new CustomEvent('ntcfg:action-result', {
detail: {
requestId,
script: NTCFG_MUSIC_MANIFEST_ID,
status,
error
}
}));
} catch {
// ignore dispatch failures
}
}
function resetMusicSettingsToDefaults() {
Object.entries(MUSIC_SHARED_SETTINGS).forEach(([settingKey, meta]) => {
if (!meta || meta.type === 'note' || meta.type === 'action') return;
setNtcfgMusicValue(settingKey, meta.default);
});
clearSavedSource();
clearSessionState();
clearQueue();
state.preCueResult = null;
state.preCueSourceUri = '';
state.metaCache.clear();
state.sourceTrackListCache.clear();
state.ytSearchCache.clear();
}
// Listen for mod menu changes (same tab)
document.addEventListener('ntcfg:change', (event) => {
if (event?.detail?.script !== NTCFG_MUSIC_MANIFEST_ID) return;
applyNtcfgMusicValueDirect(event.detail.key, event.detail.value);
});
document.addEventListener('ntcfg:action', (event) => {
const detail = event?.detail || {};
if (detail.script !== '*') return;
if (detail.key !== 'clear-settings' || detail.scope !== 'prefs+caches') return;
try {
resetMusicSettingsToDefaults();
GM_setValue(MUSIC_STORAGE_VERSION_KEY, MUSIC_STORAGE_VERSION);
registerNtcfgMusicManifest();
syncAllNtcfgMusicSettingsFromGM();
document.dispatchEvent(new CustomEvent('ntcfg:manifest-updated', {
detail: { script: NTCFG_MUSIC_MANIFEST_ID }
}));
dispatchMusicActionResult(detail.requestId, 'success');
} catch (error) {
dispatchMusicActionResult(detail.requestId, 'error', error?.message || String(error));
}
});
// Listen for cross-tab changes
window.addEventListener('storage', (event) => {
const storageKey = String(event?.key || '');
if (!storageKey.startsWith(NTCFG_MUSIC_VALUE_PREFIX) || event.newValue == null) return;
const settingKey = storageKey.slice(NTCFG_MUSIC_VALUE_PREFIX.length);
if (!MUSIC_SHARED_SETTINGS[settingKey]) return;
try {
applyNtcfgMusicValueIfChanged(settingKey, JSON.parse(event.newValue));
} catch {
// ignore invalid synced payloads
}
});
// Register manifest, sync settings, and write alive key for mod menu detection
registerNtcfgMusicManifest();
syncAllNtcfgMusicSettingsFromGM();
try { GM_setValue(MUSIC_STORAGE_VERSION_KEY, MUSIC_STORAGE_VERSION); } catch { /* ignore */ }
const publishNtcfgMusicManifestHeartbeat = () => {
try { localStorage.setItem('ntcfg:alive:' + NTCFG_MUSIC_MANIFEST_ID, String(Date.now())); } catch { /* ignore */ }
try {
document.dispatchEvent(new CustomEvent('ntcfg:manifest-updated', {
detail: { script: NTCFG_MUSIC_MANIFEST_ID }
}));
} catch {
// ignore event dispatch failures
}
};
publishNtcfgMusicManifestHeartbeat();
// ─── End Mod Menu Manifest Bridge ───────────────────────────────────────────
// ─── Console API for debugging (type NTMusic in the browser console) ──
var _ntMusicApi = {
/** Print the current queue as a collapsed group */
queue: function () {
var q = state.queue;
if (!q || !q.length) {
console.log('%c[NT Music]%c No tracks in queue.', 'color:#1db954;font-weight:bold', 'color:inherit');
return;
}
var current = state.queueIndex >= 0 ? state.queueIndex : -1;
console.groupCollapsed(
'%c[NT Music]%c ' + q.length + ' track' + (q.length === 1 ? '' : 's') + ' in queue',
'color:#1db954;font-weight:bold', 'color:inherit'
);
for (var i = 0; i < q.length; i += 1) {
var t = q[i];
var prefix = i === current ? '\u25b6 ' : ' ';
var title = t.title || t.spotifyUri || '(unknown)';
var artist = t.artist || '';
var ytId = t.ytVideoId || '\u2014';
console.log(
prefix + (i + 1) + '. ' + title + (artist ? ' \u2014 ' + artist : '') + ' [YT: ' + ytId + ']'
);
}
console.groupEnd();
},
/** Print the currently playing track */
now: function () {
var meta = state.trackMeta;
var entry = (state.queue && state.queue[state.queueIndex]) || null;
if (!meta && !entry) {
console.log('%c[NT Music]%c Nothing playing.', 'color:#1db954;font-weight:bold', 'color:inherit');
return;
}
var title = (meta && meta.title) || (entry && entry.title) || '?';
var artist = (meta && meta.artist) || (entry && entry.artist) || '?';
var ytId = (entry && entry.ytVideoId) || '?';
var pb = state.playback || {};
var pos = Math.round((pb.position || 0) / 1000);
var dur = Math.round((pb.duration || 0) / 1000);
var paused = pb.isPaused ? ' (paused)' : '';
console.log(
'%c[NT Music]%c \u25b6 ' + title + ' \u2014 ' + artist +
' [' + pos + 's / ' + dur + 's]' + paused +
' [YT: ' + ytId + ']',
'color:#1db954;font-weight:bold', 'color:inherit'
);
},
/** Print version and state summary */
status: function () {
const summary = {
version: SCRIPT_VERSION,
active: state.active,
queueLength: state.queue ? state.queue.length : 0,
mode: state.queueMode || 'sequential',
debug: _ntMusicApi.verbose,
engine: state.embedController ? 'Spotify Embed' : 'YouTube',
};
console.log(
'%c[NT Music]%c v' + summary.version +
' | Active: ' + summary.active +
' | Queue: ' + summary.queueLength + ' tracks' +
' | Mode: ' + summary.mode +
' | Debug: ' + (summary.debug ? 'on' : 'off') +
' | Engine: ' + summary.engine,
'color:#1db954;font-weight:bold', 'color:inherit'
);
return summary;
},
trace: function () {
const entries = getMusicTraceEntries();
if (!entries.length) {
console.log('%c[NT Music]%c No trace entries recorded.', 'color:#1db954;font-weight:bold', 'color:inherit');
return [];
}
console.groupCollapsed('%c[NT Music]%c Trace (' + entries.length + ' entries)', 'color:#1db954;font-weight:bold', 'color:inherit');
entries.forEach((entry) => {
console.log(entry.iso + ' ' + entry.event, entry.data);
});
console.groupEnd();
return entries;
},
clearTrace: function () {
clearMusicTrace();
console.log('%c[NT Music]%c Trace cleared.', 'color:#1db954;font-weight:bold', 'color:inherit');
},
/** Toggle verbose debug logging (YouTube search, Topic matching, etc.) */
verbose: false,
debug: function (enabled) {
const nextValue = enabled === undefined ? !_ntMusicApi.verbose : Boolean(enabled);
_ntMusicApi.verbose = nextValue;
setNtcfgMusicValue('DEBUG_LOGGING', nextValue);
console.log('%c[NT Music]%c Debug ' + (nextValue ? 'enabled' : 'disabled') + '.', 'color:#1db954;font-weight:bold', 'color:inherit');
return nextValue;
}
};
_ntMusicApi.verbose = isMusicDebugEnabled();
try {
(typeof unsafeWindow !== 'undefined' ? unsafeWindow : window).NTMusic = _ntMusicApi;
} catch (e) {
window.NTMusic = _ntMusicApi;
}
console.log(
'%c[NT Music]%c v' + SCRIPT_VERSION + ' loaded \u2014 type %cNTMusic.queue()%c or %cNTMusic.now()%c in console to inspect',
'color:#1db954;font-weight:bold', 'color:inherit',
'color:#1db954', 'color:inherit',
'color:#1db954', 'color:inherit'
);
const ntMusicRouteHelper = initNTRouteHelper(window);
ntMusicRouteHelper.subscribe(() => {
syncMusicRuntimeToRoute();
}, { immediate: false });
syncMusicRuntimeToRoute();
function startMusicRuntime() {
if (musicRuntimeStarted) return;
musicRuntimeStarted = true;
init();
}
function stopMusicRuntime() {
if (!musicRuntimeStarted) return;
musicRuntimeStarted = false;
cleanupAll();
}
function syncMusicRuntimeToRoute() {
if (isMusicRaceRoute()) {
startMusicRuntime();
return;
}
stopMusicRuntime();
}
function schedulePreCueFromSavedSource(delayMs = PRE_CUE_INIT_DELAY_MS) {
if (state.preCueTimer) {
window.clearTimeout(state.preCueTimer);
state.preCueTimer = null;
}
if (!musicRuntimeStarted) return;
const saved = getSavedSourceParsed();
if (!saved) return;
if (state.preCueSourceUri === saved.uri && (state.preCueResult || state.preCuePromise)) {
return;
}
state.preCueTimer = window.setTimeout(async () => {
state.preCueTimer = null;
const currentSaved = getSavedSourceParsed();
if (!currentSaved) return;
if (state.preCueSourceUri === currentSaved.uri && (state.preCueResult || state.preCuePromise)) {
return;
}
const preCuePromise = preCueFromSavedSource();
state.preCuePromise = preCuePromise;
try {
await preCuePromise;
} catch (error) {
// Fall back to on-demand loading if pre-cue fails.
} finally {
if (state.preCuePromise === preCuePromise) {
state.preCuePromise = null;
}
}
}, Math.max(0, Number(delayMs) || 0));
}
function init() {
// Restore queue mode from saved session before pre-cue
var savedQueueMode = String(GM_getValue(STORAGE_KEYS.queueMode, '') || '').trim();
if (savedQueueMode === 'shuffle' || savedQueueMode === 'sequential') {
state.queueMode = savedQueueMode;
}
installYouTubeApiHook();
installEmbedApiHook();
installAudioContextTracker(getPageWindow());
clearFrameWatchStartTimer();
state.frameWatchStartTimer = window.setTimeout(() => {
state.frameWatchStartTimer = null;
startFrameWatcher();
}, 1500);
schedulePreCueFromSavedSource(PRE_CUE_INIT_DELAY_MS);
window.addEventListener('message', onMusicMessageFromRace);
window.addEventListener('beforeunload', cleanupAll);
}
function clearFrameWatchStartTimer() {
if (state.frameWatchStartTimer) {
window.clearTimeout(state.frameWatchStartTimer);
state.frameWatchStartTimer = null;
}
}
function stopFrameWatcher() {
if (state.frameWatchTimer) {
window.clearInterval(state.frameWatchTimer);
state.frameWatchTimer = null;
}
}
function resetFrameBindingState() {
clearFrameSuppressRetryTimers();
state._lastFrameUiSync = 0;
state._lastNativeMuteCheck = 0;
state.suppressNativeStationEventsUntil = 0;
state.frame = null;
state.frameDoc = null;
state.frameWindow = null;
}
function clearFrameSuppressRetryTimers() {
if (!Array.isArray(state.frameSuppressRetryTimers) || !state.frameSuppressRetryTimers.length) {
state.frameSuppressRetryTimers = [];
return;
}
state.frameSuppressRetryTimers.forEach((timerId) => {
try {
window.clearTimeout(timerId);
} catch (_) {
// noop
}
});
state.frameSuppressRetryTimers = [];
}
function scheduleFrameSuppressRetryWave() {
clearFrameSuppressRetryTimers();
[50, 150, 300, 600, 1200, 2500].forEach((delayMs) => {
const timerId = window.setTimeout(function () {
state.frameSuppressRetryTimers = state.frameSuppressRetryTimers.filter((id) => id !== timerId);
if (isSpotifyUiSelected() && state.frameDoc) {
suppressNativeAudio(state.frameDoc);
}
}, delayMs);
state.frameSuppressRetryTimers.push(timerId);
});
}
// registerMenuCommands removed — no external menu commands needed
function installYouTubeApiHook() {
injectYouTubeApiBridge();
window.addEventListener(YT_READY_EVENT, onYouTubeReadyEvent);
window.addEventListener(YT_ERROR_EVENT, onYouTubeErrorEvent);
const pageWindow = getPageWindow();
try {
if (pageWindow.__ntYouTubeApi && pageWindow.__ntYouTubeApi.Player) {
onYouTubeApiReady(pageWindow.__ntYouTubeApi);
} else if (pageWindow.YT && pageWindow.YT.Player) {
onYouTubeApiReady(pageWindow.YT);
}
} catch (error) {
// noop
}
}
function getPageWindow() {
if (typeof unsafeWindow !== 'undefined') return unsafeWindow;
return window;
}
function injectYouTubeApiBridge() {
const pageWindow = getPageWindow();
if (pageWindow.__ntYTBridgeInstalled) return;
pageWindow.__ntYTBridgeInstalled = true;
const previous = pageWindow.onYouTubeIframeAPIReady;
pageWindow.onYouTubeIframeAPIReady = function () {
try { pageWindow.__ntYouTubeApi = pageWindow.YT || null; } catch (err) { }
window.dispatchEvent(new CustomEvent(YT_READY_EVENT));
if (typeof previous === 'function') {
try { previous(); } catch (err) { }
}
};
}
function loadYouTubeApiScript() {
if (state.ytApiLoadStarted) return;
state.ytApiLoadStarted = true;
if (document.querySelector('script[data-nt-youtube-api="1"]')) return;
const script = document.createElement('script');
script.async = true;
script.src = YT_IFRAME_API_SRC;
script.dataset.ntYoutubeApi = '1';
script.onerror = () => {
state.ytApiLoadFailed = true;
failYouTubeApiWaiters(new Error('YouTube IFrame API failed to load'));
window.dispatchEvent(new CustomEvent(YT_ERROR_EVENT));
};
document.body.appendChild(script);
}
function waitForYouTubeApi(timeoutMs) {
if (state.ytApiLoaded && state.ytApi && state.ytApi.Player) {
return Promise.resolve(state.ytApi);
}
if (state.ytApiLoadFailed) {
return Promise.reject(new Error('YouTube IFrame API unavailable'));
}
loadYouTubeApiScript();
return new Promise((resolve, reject) => {
let settled = false;
const waiter = {
resolve: (api) => {
if (settled) return;
settled = true;
resolve(api);
},
reject: (error) => {
if (settled) return;
settled = true;
reject(error);
},
};
const timer = window.setTimeout(() => {
if (settled) return;
settled = true;
state.ytApiWaiters = state.ytApiWaiters.filter((entry) => entry !== waiter);
reject(new Error('YouTube API timeout'));
}, Number(timeoutMs) || 12000);
const wrapped = {
resolve: (api) => {
window.clearTimeout(timer);
waiter.resolve(api);
},
reject: (error) => {
window.clearTimeout(timer);
waiter.reject(error);
},
};
state.ytApiWaiters.push(wrapped);
});
}
function onYouTubeReadyEvent() {
const pageWindow = getPageWindow();
try {
const api = pageWindow.__ntYouTubeApi || pageWindow.YT;
if (api && api.Player) {
onYouTubeApiReady(api);
}
} catch (error) {
// noop
}
}
function onYouTubeErrorEvent() {
state.ytApiLoadFailed = true;
}
function onYouTubeApiReady(api) {
if (!api || !api.Player) return;
state.ytApi = api;
state.ytApiLoaded = true;
state.ytApiLoadFailed = false;
const waiters = state.ytApiWaiters.splice(0, state.ytApiWaiters.length);
waiters.forEach((waiter) => {
try {
waiter.resolve(api);
} catch (error) {
// noop
}
});
}
function failYouTubeApiWaiters(error) {
const waiters = state.ytApiWaiters.splice(0, state.ytApiWaiters.length);
waiters.forEach((waiter) => {
try {
waiter.reject(error);
} catch (err) {
// noop
}
});
}
function ensureYouTubePlayerHost() {
const existing = document.getElementById('nt-yt-player-host');
if (existing && existing.isConnected) {
if (String(existing.tagName || '').toUpperCase() === 'DIV') {
state.ytPlayerHost = existing;
return existing.id;
}
try {
existing.remove();
} catch (error) {
// noop
}
}
const host = document.createElement('div');
host.id = 'nt-yt-player-host';
host.style.cssText = [
'position: fixed',
'right: -5000px',
'bottom: -5000px',
'width: 1px',
'height: 1px',
'overflow: hidden',
'opacity: 0',
'pointer-events: none',
'z-index: -1',
].join(';');
const mountRoot = document.body || document.documentElement;
if (!mountRoot) {
throw new Error('YouTube player host mount root unavailable');
}
mountRoot.appendChild(host);
state.ytPlayerHost = host;
return host.id;
}
async function ensureYouTubePlayer(initialVideoId) {
if (state.ytPlayer) return state.ytPlayer;
if (state.ytPlayerPromise) return state.ytPlayerPromise;
traceMusic('ensure-youtube-player:start', {
initialVideoId: String(initialVideoId || ''),
existingHostTag: document.getElementById('nt-yt-player-host')
? document.getElementById('nt-yt-player-host').tagName
: null,
});
state.ytPlayerPromise = (async () => {
const yt = await waitForYouTubeApi(12000);
const hostId = ensureYouTubePlayerHost();
return new Promise((resolve, reject) => {
try {
const player = new yt.Player(hostId, {
height: 1,
width: 1,
videoId: String(initialVideoId || ''),
playerVars: {
autoplay: 1,
controls: 0,
disablekb: 1,
fs: 0,
modestbranding: 1,
rel: 0,
playsinline: 1,
origin: window.location.origin,
},
events: {
onReady: () => {
state.ytPlayer = player;
state.ytPlayerReady = true;
state.ytPlayerHost = document.getElementById(hostId) || state.ytPlayerHost;
state.ytPlayerPromise = null;
traceMusic('ensure-youtube-player:ready', {
hostId,
hostTag: state.ytPlayerHost ? state.ytPlayerHost.tagName : null,
hostSrc: state.ytPlayerHost && state.ytPlayerHost.tagName === 'IFRAME'
? state.ytPlayerHost.src
: null,
});
resolve(player);
},
onStateChange: onYTStateChange,
onError: onYTPlayerError,
},
});
} catch (error) {
state.ytPlayerPromise = null;
traceMusic('ensure-youtube-player:construct-error', {
initialVideoId: String(initialVideoId || ''),
error: formatTraceError(error),
});
reject(error);
}
});
})().catch((error) => {
state.ytPlayerPromise = null;
traceMusic('ensure-youtube-player:promise-error', {
initialVideoId: String(initialVideoId || ''),
error: formatTraceError(error),
});
throw error;
});
return state.ytPlayerPromise;
}
function onYTPlayerError(event) {
const code = event && event.data;
traceMusic('youtube-player-error', {
code,
queueIndex: state.queueIndex,
currentUri: state.queue && state.queue[state.queueIndex]
? state.queue[state.queueIndex].spotifyUri
: '',
currentVideoId: state.queue && state.queue[state.queueIndex]
? state.queue[state.queueIndex].ytVideoId
: '',
});
// Debounce — error 150 (embed-blocked) fires repeatedly for the same
// video. Only act on the first one; ignore duplicates within 3 s.
var now = Date.now();
if (state._lastYTErrorCode === code && now - state._lastYTErrorTime < 3000) return;
state._lastYTErrorCode = code;
state._lastYTErrorTime = now;
console.warn('[NT Music] YouTube player error:', code);
if (!state.active) return;
// Error 150 / 101 = embedding not allowed. Try to find an alternative
// video for the SAME track before giving up and skipping.
if ((code === 150 || code === 101) && state.queue && state.queue[state.queueIndex]) {
var failedEntry = state.queue[state.queueIndex];
var failedVideoId = failedEntry.ytVideoId;
// Clear the cached video so re-resolution doesn't hit the same one
if (failedEntry.spotifyUri) {
state.ytSearchCache.delete(failedEntry.spotifyUri);
var storageKey = 'yt_cache_v' + YT_CACHE_VERSION + '_' + failedEntry.spotifyUri;
GM_deleteValue(storageKey);
}
failedEntry.ytVideoId = '';
// Mark the failed video so the re-search can exclude it
if (!failedEntry._failedVideoIds) failedEntry._failedVideoIds = [];
if (failedVideoId) failedEntry._failedVideoIds.push(failedVideoId);
// Only retry once per track to avoid infinite loops
if (failedEntry._failedVideoIds.length <= 3) {
window.setTimeout(function () {
void resolveAndPlayTrack(state.queueIndex, true);
}, 500);
return;
}
}
// Fallback: skip to next track
window.setTimeout(function () {
void playNextInQueue();
}, 500);
}
function onYTStateChange(event) {
const status = Number(event && event.data);
if (status === 0) {
syncPlaybackFromYT();
state.playback.isPaused = true;
state.playback.position = state.playback.duration;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
if (state.active) {
void playNextInQueue();
}
return;
}
if (status === 1) {
// If we have a pending resume seek, execute it now that the video is actually playing
if (state._pendingSeekSec > 0) {
var seekTarget = state._pendingSeekSec;
state._pendingSeekSec = 0;
var p = state.ytPlayer;
if (p && typeof p.seekTo === 'function') {
p.seekTo(seekTarget, true);
state.playback.position = seekTarget * 1000;
state.playback.updatedAt = Date.now();
}
}
syncPlaybackFromYT();
state.playback.isPaused = false;
state.playback.isBuffering = false;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
schedulePreResolveNextTrack(3000);
return;
}
if (status === 2) {
syncPlaybackFromYT();
state.playback.isPaused = true;
state.playback.isBuffering = false;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
return;
}
if (status === 3) {
syncPlaybackFromYT();
state.playback.isPaused = false;
state.playback.isBuffering = true;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
}
}
function safeYTCall(methodName, ...args) {
const player = state.ytPlayer;
if (!player) return false;
const fn = player[methodName];
if (typeof fn !== 'function') return false;
try {
fn.apply(player, args);
return true;
} catch (error) {
return false;
}
}
function syncPlaybackFromYT() {
const player = state.ytPlayer;
if (!player || !state.ytPlayerReady) return;
try {
const durationMs = Math.max(0, Math.round((Number(player.getDuration()) || 0) * 1000));
const positionMs = Math.max(0, Math.round((Number(player.getCurrentTime()) || 0) * 1000));
if (durationMs > 0) {
state.playback.duration = durationMs;
}
state.playback.position = positionMs;
state.playback.updatedAt = Date.now();
const current = getCurrentQueueEntry();
if (current) {
state.playback.uri = current.spotifyUri;
}
} catch (error) {
// noop
}
}
function getCurrentQueueEntry() {
if (!Array.isArray(state.queue) || !state.queue.length) return null;
const idx = Number(state.queueIndex);
if (!Number.isInteger(idx) || idx < 0 || idx >= state.queue.length) return null;
return state.queue[idx];
}
function clearQueue() {
state.queue = [];
state.queueIndex = -1;
state.shuffleOrder = [];
state.shufflePosition = -1;
state.playHistory = [];
}
async function buildQueue(parsedSource) {
if (!parsedSource) return [];
const platform = parsedSource.platform || 'spotify';
// ── YouTube direct ──────────────────────────────────────────────
if (platform === 'youtube') return buildQueueFromYouTube(parsedSource);
// ── Apple Music ─────────────────────────────────────────────────
if (platform === 'apple-music') return buildQueueFromAppleMusic(parsedSource);
// ── Spotify (original path) ─────────────────────────────────────
if (parsedSource.type === 'track') {
const meta = await resolveTrackMetaForUri(parsedSource.uri);
if (!meta) return [];
return [
{
spotifyUri: meta.uri,
title: String(meta.title || 'Spotify'),
artist: String(meta.artist || ''),
durationMs: Number(meta.durationMs) || 0,
ytVideoId: '',
art: String(meta.art || ''),
openUrl: String(meta.openUrl || spotifyUriToOpenUrl(meta.uri)),
sourceUri: parsedSource.uri,
_resolving: false,
},
];
}
const tracks = await fetchSpotifyTrackList(parsedSource.type, parsedSource.id, parsedSource.uri);
if (!tracks.length) return [];
return tracks.map((track) => ({
spotifyUri: track.uri,
title: String(track.title || 'Spotify'),
artist: String(track.artist || ''),
durationMs: Number(track.durationMs) || 0,
ytVideoId: '',
art: String(track.art || ''),
openUrl: String(track.openUrl || spotifyUriToOpenUrl(track.uri)),
sourceUri: parsedSource.uri,
_resolving: false,
}));
}
// ─── YouTube queue builder ──────────────────────────────────────────
async function buildQueueFromYouTube(parsedSource) {
traceMusic('build-queue-youtube:start', {
type: parsedSource ? parsedSource.type : '',
id: parsedSource ? parsedSource.id : '',
uri: parsedSource ? parsedSource.uri : '',
});
if (parsedSource.type === 'track') {
// Kick off the direct YouTube Music art lookup immediately (doesn't need title/artist).
// Meanwhile, fetch noembed metadata in parallel for title + artist + thumbnail fallback.
const directArtPromise = fetchYouTubeMusicArtDirect(parsedSource.id);
const meta = await fetchYouTubeVideoMeta(parsedSource.id);
// Check if the direct lookup already found album art; if not, fall back to
// a YouTube Music search using the title+artist we just got from noembed.
var musicArt = await directArtPromise;
if (!musicArt && meta.title) {
musicArt = await fetchYouTubeMusicArtBySearch(parsedSource.id, meta.title, meta.artist);
}
return [
{
spotifyUri: parsedSource.uri, // 'youtube:video:VIDEO_ID'
title: meta.title,
artist: meta.artist,
durationMs: 0, // YT player will report real duration
ytVideoId: parsedSource.id, // KEY: already resolved → skip search
art: musicArt // 1st: YouTube Music album cover (lh3 square art)
|| meta.thumbnail // 2nd: noembed thumbnail
|| 'https://i.ytimg.com/vi/' + parsedSource.id + '/hqdefault.jpg', // 3rd: fallback
openUrl: parsedSource.openUrl,
sourceUri: parsedSource.uri,
_resolving: false,
},
];
}
if (parsedSource.type === 'playlist') {
var playlistTracks = await fetchYouTubePlaylistTracks(parsedSource.id);
traceMusic('build-queue-youtube:playlist-tracks', {
playlistId: parsedSource.id,
count: playlistTracks.length,
});
if (!playlistTracks.length) {
toast('Could not read YouTube playlist. It may be private or empty.');
return [];
}
return playlistTracks.map(function (t) {
// Prefer playlist-level album art (YouTube Music albums) over video thumbnail
var artUrl = t.playlistArt
|| 'https://i.ytimg.com/vi/' + t.videoId + '/hqdefault.jpg';
return {
spotifyUri: 'youtube:video:' + t.videoId,
title: t.title || 'YouTube Video',
artist: t.author || '',
durationMs: (t.durationSec || 0) * 1000,
ytVideoId: t.videoId, // pre-resolved → skip search pipeline
art: artUrl,
openUrl: 'https://www.youtube.com/watch?v=' + t.videoId,
sourceUri: parsedSource.uri,
_resolving: false,
};
});
}
return [];
}
// ─── Apple Music queue builder ──────────────────────────────────────
async function buildQueueFromAppleMusic(parsedSource) {
if (parsedSource.type === 'track') {
const meta = await fetchAppleMusicTrackMeta(parsedSource.id);
if (!meta) {
toast('Could not fetch Apple Music track info.');
return [];
}
return [
{
spotifyUri: parsedSource.uri, // 'apple-music:track:ID'
title: meta.title,
artist: meta.artist,
durationMs: meta.durationMs,
ytVideoId: '', // needs YouTube search
art: meta.art,
openUrl: meta.openUrl || parsedSource.openUrl,
sourceUri: parsedSource.uri,
_resolving: false,
},
];
}
if (parsedSource.type === 'album') {
const tracks = await fetchAppleMusicAlbumTracks(parsedSource.id);
if (!tracks.length) {
toast('Could not read Apple Music album tracks.');
return [];
}
return tracks.map(function (t) {
return {
spotifyUri: 'apple-music:track:' + t.appleMusicId,
title: t.title,
artist: t.artist,
durationMs: t.durationMs,
ytVideoId: '',
art: t.art,
openUrl: t.openUrl || parsedSource.openUrl,
sourceUri: parsedSource.uri,
_resolving: false,
};
});
}
if (parsedSource.type === 'playlist') {
var amTracks = await fetchAppleMusicPlaylistTracks(parsedSource.id, parsedSource.openUrl);
if (!amTracks.length) {
toast('Could not read Apple Music playlist. It may be private or the page format changed.');
return [];
}
return amTracks.map(function (t) {
var uri = t.appleMusicId
? 'apple-music:track:' + t.appleMusicId
: 'apple-music:title:' + encodeURIComponent(t.title + ':' + t.artist);
return {
spotifyUri: uri,
title: t.title,
artist: t.artist,
durationMs: t.durationMs || 0,
ytVideoId: '', // needs YouTube search
art: t.art || '',
openUrl: t.openUrl || parsedSource.openUrl,
sourceUri: parsedSource.uri,
_resolving: false,
};
});
}
return [];
}
async function preCueFromSavedSource() {
const saved = getSavedSourceParsed();
if (!saved) return;
const sourceUri = saved.uri;
// Check if we have a saved session to resume
const session = getSavedSessionState();
// Set source URI early so loadSource() knows to await our promise
// (must be set before any async work, otherwise loadSource can't match it)
state.preCueSourceUri = sourceUri;
try {
const queue = await buildQueue(saved);
if (!queue.length) {
if (state.preCueSourceUri === sourceUri) {
state.preCueSourceUri = '';
}
return;
}
let startIndex = 0;
let shuffleOrder = [];
let shufflePosition = -1;
let resumePositionSec = 0;
// Restore queue mode from session
if (session) {
state.queueMode = session.queueMode || 'shuffle';
}
if (session && session.queueIndex >= 0 && session.queueIndex < queue.length) {
// Resume from saved session position
startIndex = session.queueIndex;
resumePositionSec = session.playbackPositionSec || 0;
if (state.queueMode === 'shuffle' && session.shuffleOrder.length === queue.length) {
shuffleOrder = session.shuffleOrder;
shufflePosition = session.shufflePosition;
} else if (state.queueMode === 'shuffle' && queue.length > 1) {
// Saved shuffle doesn't match queue size — regenerate with saved index first
const si = [];
for (let ix = 0; ix < queue.length; ix += 1) si.push(ix);
for (let ix = si.length - 1; ix > 0; ix -= 1) {
const jx = Math.floor(Math.random() * (ix + 1));
const t = si[ix]; si[ix] = si[jx]; si[jx] = t;
}
const sp = si.indexOf(startIndex);
if (sp > 0) { si[sp] = si[0]; si[0] = startIndex; }
shuffleOrder = si;
shufflePosition = 0;
}
} else if (state.queueMode === 'shuffle' && queue.length > 1) {
// No session — fresh shuffle
startIndex = Math.floor(Math.random() * queue.length);
const indices = [];
for (let i = 0; i < queue.length; i += 1) indices.push(i);
for (let i = indices.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = indices[i];
indices[i] = indices[j];
indices[j] = tmp;
}
const pos = indices.indexOf(startIndex);
if (pos > 0) {
indices[pos] = indices[0];
indices[0] = startIndex;
}
shuffleOrder = indices;
shufflePosition = 0;
}
// Pre-resolve first track's YouTube video ID
const firstTrack = queue[startIndex];
if (firstTrack && !firstTrack.ytVideoId) {
await hydrateQueueEntryMetadata(firstTrack);
const result = await resolveYouTubeVideoForTrack(firstTrack);
if (result && result.videoId) firstTrack.ytVideoId = result.videoId;
}
// Pre-resolve second track too
const secondIndex = shuffleOrder.length > 1
? shuffleOrder[Math.min(shufflePosition + 1, shuffleOrder.length - 1)]
: (queue.length > 1 ? (startIndex + 1) % queue.length : -1);
if (secondIndex >= 0 && secondIndex !== startIndex && queue[secondIndex] && !queue[secondIndex].ytVideoId) {
await hydrateQueueEntryMetadata(queue[secondIndex]);
const result = await resolveYouTubeVideoForTrack(queue[secondIndex]);
if (result && result.videoId) queue[secondIndex].ytVideoId = result.videoId;
}
// Also pre-load the YouTube IFrame API
loadYouTubeApiScript();
if (state.preCueSourceUri !== sourceUri) return;
state.preCueResult = { queue, startIndex, shuffleOrder, shufflePosition, resumePositionSec };
} catch (err) {
if (state.preCueSourceUri !== sourceUri) return;
console.warn('[NT Music] Pre-cue failed:', err);
state.preCueResult = null;
}
}
async function loadSource(sourceUri) {
const parsed = parseSourceInput(sourceUri);
traceMusic('load-source:start', {
sourceUri,
parsedPlatform: parsed ? parsed.platform : '',
parsedType: parsed ? parsed.type : '',
parsedId: parsed ? parsed.id : '',
preCueSourceUri: state.preCueSourceUri,
});
if (!parsed) return false;
// Internal URIs (e.g. apple-music:playlist:xxx) may not have a usable
// openUrl. Fill it in from the stored URL so downstream functions like
// fetchAppleMusicPlaylistTracks receive the real page URL.
if (!parsed.openUrl) {
const storedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
if (storedUrl) parsed.openUrl = storedUrl;
}
// Check if pre-cue has results for this exact source
let queue;
let preStartIndex = null;
let preShuffleOrder = null;
let preShufflePosition = null;
let preResumePositionSec = 0;
if (state.preCueResult && state.preCueSourceUri === parsed.uri) {
queue = state.preCueResult.queue;
preStartIndex = state.preCueResult.startIndex;
preShuffleOrder = state.preCueResult.shuffleOrder;
preShufflePosition = state.preCueResult.shufflePosition;
preResumePositionSec = state.preCueResult.resumePositionSec || 0;
state.preCueResult = null;
state.preCueSourceUri = '';
} else {
// Wait for in-flight pre-cue to finish if it's for this source, otherwise build fresh
if (state.preCuePromise && state.preCueSourceUri === parsed.uri) {
try { await state.preCuePromise; } catch (e) { /* ignore */ }
if (state.preCueResult && state.preCueSourceUri === parsed.uri) {
queue = state.preCueResult.queue;
preStartIndex = state.preCueResult.startIndex;
preShuffleOrder = state.preCueResult.shuffleOrder;
preShufflePosition = state.preCueResult.shufflePosition;
preResumePositionSec = state.preCueResult.resumePositionSec || 0;
state.preCueResult = null;
state.preCueSourceUri = '';
}
}
if (!queue) {
queue = await buildQueue(parsed);
}
}
traceMusic('load-source:queue-ready', {
sourceUri: parsed.uri,
queueLength: Array.isArray(queue) ? queue.length : 0,
queueMode: state.queueMode,
usedPreCue: preStartIndex != null,
});
if (!queue.length) {
traceMusic('load-source:empty-queue', {
sourceUri: parsed.uri,
platform: parsed.platform,
type: parsed.type,
});
if (parsed.type === 'album' || parsed.type === 'playlist') {
toast('Could not read track list. Try a public playlist/album or a single track URL.');
}
return false;
}
clearQueue();
state.queue = queue;
state.queueIndex = 0;
const savedSession = getSavedSessionState();
let startIndex = preStartIndex != null ? preStartIndex : 0;
if (preStartIndex == null && savedSession && savedSession.queueIndex >= 0 && savedSession.queueIndex < queue.length) {
startIndex = savedSession.queueIndex;
}
state.playHistory = [];
if (state.queueMode === 'shuffle' && queue.length > 1) {
if (preShuffleOrder && preShuffleOrder.length === queue.length) {
state.shuffleOrder = preShuffleOrder;
state.shufflePosition = preShufflePosition != null ? preShufflePosition : 0;
} else if (
savedSession
&& savedSession.queueMode === 'shuffle'
&& Array.isArray(savedSession.shuffleOrder)
&& savedSession.shuffleOrder.length === queue.length
) {
const normalizedShuffleOrder = savedSession.shuffleOrder.map((value) => Number(value));
const isValidShuffleOrder = normalizedShuffleOrder.every((value, index, values) => (
Number.isInteger(value)
&& value >= 0
&& value < queue.length
&& values.indexOf(value) === index
));
if (isValidShuffleOrder) {
state.shuffleOrder = normalizedShuffleOrder;
const fallbackPos = Math.max(0, normalizedShuffleOrder.indexOf(startIndex));
state.shufflePosition = (
Number.isInteger(savedSession.shufflePosition)
&& savedSession.shufflePosition >= 0
&& savedSession.shufflePosition < normalizedShuffleOrder.length
) ? savedSession.shufflePosition : fallbackPos;
} else {
generateShuffleOrder(startIndex);
}
} else if (savedSession && savedSession.queueIndex >= 0 && savedSession.queueIndex < queue.length) {
generateShuffleOrder(startIndex);
} else {
startIndex = Math.floor(Math.random() * queue.length);
generateShuffleOrder(startIndex);
}
} else {
state.shuffleOrder = [];
state.shufflePosition = -1;
}
// If pre-cue didn't provide a resume position, check saved session directly
var resumeSec = preResumePositionSec;
if (!resumeSec) {
if (savedSession && savedSession.queueIndex === startIndex) {
resumeSec = savedSession.playbackPositionSec || 0;
}
}
const started = await resolveAndPlayTrack(startIndex, true, resumeSec);
traceMusic('load-source:resolve-result', {
sourceUri: parsed.uri,
startIndex,
resumeSec,
started,
});
if (!started) return false;
setCurrentStationLabel(state.frameDoc, getPlatformLabel(parsed), parsed.platform);
return true;
}
async function resolveAndPlayTrack(index, autoplay, seekToSec) {
if (!Array.isArray(state.queue) || !state.queue.length) return false;
if (index < 0 || index >= state.queue.length) return false;
const entry = state.queue[index];
if (!entry) return false;
traceMusic('resolve-play-track:start', {
index,
autoplay: Boolean(autoplay),
seekToSec: Number(seekToSec) || 0,
uri: entry.spotifyUri || '',
title: entry.title || '',
artist: entry.artist || '',
hasYtVideoId: Boolean(entry.ytVideoId),
});
await hydrateQueueEntryMetadata(entry);
if (!entry.ytVideoId) {
const result = await resolveYouTubeVideoForTrack(entry);
if (!result || !result.videoId) {
traceMusic('resolve-play-track:no-youtube-id', {
index,
uri: entry.spotifyUri || '',
title: entry.title || '',
artist: entry.artist || '',
});
console.warn('[NT Music] Could not resolve YouTube result for track:', entry);
return false;
}
entry.ytVideoId = result.videoId;
// Use album art thumbnail from YouTube Music search when available
if (result.thumbnail && !entry.art) entry.art = result.thumbnail;
}
let loaded = false;
// Embed controller only understands Spotify URIs — force YT player for
// YouTube direct links, Apple Music tracks, and any other non-Spotify source.
const useEmbed = getActivePlaybackEngine() === 'embed'
&& String(entry.spotifyUri || '').startsWith('spotify:');
if (useEmbed) {
void safeYTCall('pauseVideo');
loaded = await loadSourceIntoEmbedController(entry.spotifyUri, autoplay);
} else {
if (state.embedController) void safeControllerCall('pause');
await ensureYouTubePlayer(entry.ytVideoId);
loaded = safeYTCall('loadVideoById', entry.ytVideoId);
if (!loaded) {
loaded = safeYTCall('cueVideoById', entry.ytVideoId);
if (loaded && autoplay) {
safeYTCall('playVideo');
}
}
// If resuming, defer the seek until the player actually enters PLAYING state.
// Seeking during BUFFERING gets overridden when playback starts.
if (loaded && seekToSec && seekToSec > 0) {
state._pendingSeekSec = seekToSec;
}
}
traceMusic('resolve-play-track:load-result', {
index,
uri: entry.spotifyUri || '',
ytVideoId: entry.ytVideoId || '',
loaded,
engine: useEmbed ? 'embed' : 'youtube',
});
if (!loaded) return false;
state.queueIndex = index;
// Persist session state on each track change
saveSessionState();
// Record in play history for back-button support
state.playHistory.push(index);
if (state.playHistory.length > 200) {
state.playHistory = state.playHistory.slice(-100);
}
state.playback.uri = entry.spotifyUri;
state.playback.position = (seekToSec && seekToSec > 0) ? seekToSec * 1000 : 0;
state.playback.duration = Number(entry.durationMs) || 0;
state.playback.isPaused = false;
state.playback.isBuffering = false;
state.playback.updatedAt = Date.now();
state.trackMeta = {
uri: entry.spotifyUri,
title: entry.title,
artist: entry.artist,
art: entry.art,
openUrl: String(entry.openUrl || sourceUriToOpenUrl(entry.spotifyUri)),
youtubeUrl: `https://www.youtube.com/watch?v=${entry.ytVideoId}`,
sourceUri: entry.sourceUri || '',
};
if (state.active) renderNowPlaying();
schedulePreResolveNextTrack(useEmbed ? 2500 : 4000);
return true;
}
function schedulePreResolveNextTrack(delayMs) {
if (state.nextTrackPreResolveTimer) {
window.clearTimeout(state.nextTrackPreResolveTimer);
state.nextTrackPreResolveTimer = null;
}
if (!state.active) return;
if (!Array.isArray(state.queue) || state.queue.length < 2) return;
state.nextTrackPreResolveTimer = window.setTimeout(() => {
state.nextTrackPreResolveTimer = null;
void preResolveNextTrack();
}, Math.max(0, Number(delayMs) || 0));
}
async function loadSourceIntoEmbedController(uri, shouldResume) {
try {
const api = await waitForEmbedApi(12000);
ensureEmbedHost();
if (!state.embedController) {
await createEmbedController(api, uri);
} else {
await safeControllerCall('loadUri', uri);
}
if (shouldResume && state.embedController) {
await safeControllerCall('resume');
}
return true;
} catch (error) {
console.error('[NT Music] Failed to load embed source:', error);
return false;
}
}
async function preResolveNextTrack() {
if (!state.active) return;
if (!Array.isArray(state.queue) || state.queue.length < 2) return;
const maxPreResolve = Math.min(3, state.queue.length - 1);
for (let i = 1; i <= maxPreResolve; i++) {
let nextIndex;
if (state.queueMode === 'shuffle') {
nextIndex = peekNextShuffleQueueIndex(i);
if (nextIndex === -1) break; // Beyond shuffle cycle boundary
} else {
nextIndex = (state.queueIndex + i) % state.queue.length;
}
const nextEntry = state.queue[nextIndex];
if (!nextEntry) break;
if (nextEntry.ytVideoId || nextEntry._resolving) continue;
nextEntry._resolving = true;
try {
await hydrateQueueEntryMetadata(nextEntry);
const result = await resolveYouTubeVideoForTrack(nextEntry);
if (result && result.videoId) {
nextEntry.ytVideoId = result.videoId;
}
} finally {
nextEntry._resolving = false;
}
}
}
function generateShuffleOrder(startIndex) {
const len = state.queue.length;
if (len === 0) {
state.shuffleOrder = [];
state.shufflePosition = -1;
return;
}
const indices = [];
for (let i = 0; i < len; i++) indices.push(i);
// Fisher-Yates shuffle
for (let i = indices.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const tmp = indices[i];
indices[i] = indices[j];
indices[j] = tmp;
}
// Place startIndex at position 0 so "next" begins from position 1
if (typeof startIndex === 'number' && startIndex >= 0 && startIndex < len) {
const pos = indices.indexOf(startIndex);
if (pos > 0) {
indices[pos] = indices[0];
indices[0] = startIndex;
}
state.shufflePosition = 0;
} else {
state.shufflePosition = -1;
}
state.shuffleOrder = indices;
}
function getNextShuffleQueueIndex() {
if (!state.shuffleOrder.length) return 0;
const nextPos = state.shufflePosition + 1;
if (nextPos >= state.shuffleOrder.length) {
// Full cycle complete — reshuffle, put last-played at front to avoid immediate repeat
const lastPlayed = state.shuffleOrder[state.shufflePosition] || 0;
generateShuffleOrder(lastPlayed);
state.shufflePosition = 1;
return state.shuffleOrder.length > 1 ? state.shuffleOrder[1] : 0;
}
state.shufflePosition = nextPos;
return state.shuffleOrder[nextPos];
}
function peekNextShuffleQueueIndex(offset) {
if (!state.shuffleOrder.length) return 0;
const peekPos = state.shufflePosition + offset;
if (peekPos >= state.shuffleOrder.length) return -1;
return state.shuffleOrder[peekPos];
}
function getCurrentPlaybackPositionMs() {
const player = state.ytPlayer;
if (player && state.ytPlayerReady && typeof player.getCurrentTime === 'function') {
return Math.max(0, Math.round((Number(player.getCurrentTime()) || 0) * 1000));
}
return state.playback.position || 0;
}
async function playNextInQueue() {
if (!Array.isArray(state.queue) || !state.queue.length) return;
if (state._advancingQueue) return;
state._advancingQueue = true;
try {
let nextIndex;
if (state.queueMode === 'shuffle') {
nextIndex = getNextShuffleQueueIndex();
} else {
nextIndex = (state.queueIndex + 1) % state.queue.length;
}
const started = await resolveAndPlayTrack(nextIndex, true);
if (!started && state.queue.length > 1) {
let fallback;
if (state.queueMode === 'shuffle') {
fallback = getNextShuffleQueueIndex();
} else {
fallback = (nextIndex + 1) % state.queue.length;
}
if (fallback !== nextIndex) {
await resolveAndPlayTrack(fallback, true);
}
}
} finally { state._advancingQueue = false; }
}
async function playPrevInQueue() {
if (!Array.isArray(state.queue) || !state.queue.length) return;
// Standard music player: if >3s into the track, restart it first
const positionMs = getCurrentPlaybackPositionMs();
if (positionMs > 3000) {
const player = state.ytPlayer;
if (player && state.ytPlayerReady && typeof player.seekTo === 'function') {
player.seekTo(0, true);
state.playback.position = 0;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
}
return;
}
// Go to previous track from play history
if (state.playHistory.length > 1) {
// Pop current track off history
state.playHistory.pop();
// Get the previous track
const prevIndex = state.playHistory[state.playHistory.length - 1];
// In shuffle mode, rewind shufflePosition so "next" replays same sequence
if (state.queueMode === 'shuffle' && state.shuffleOrder.length > 0) {
const posInShuffle = state.shuffleOrder.indexOf(prevIndex);
if (posInShuffle !== -1) {
state.shufflePosition = posInShuffle;
}
}
// Pop this too — resolveAndPlayTrack will push it back
state.playHistory.pop();
const started = await resolveAndPlayTrack(prevIndex, true);
if (!started && state.playHistory.length > 0) {
const fallbackIndex = state.playHistory[state.playHistory.length - 1];
state.playHistory.pop();
await resolveAndPlayTrack(fallbackIndex, true);
}
} else {
// No history — fall back to sequential previous
let prevIndex = state.queueIndex - 1;
if (prevIndex < 0) prevIndex = state.queue.length - 1;
const started = await resolveAndPlayTrack(prevIndex, true);
if (!started && state.queue.length > 1) {
const fallback = (prevIndex - 1 + state.queue.length) % state.queue.length;
await resolveAndPlayTrack(fallback, true);
}
}
}
async function resolveYouTubeVideoForTrack(track) {
if (!track || !track.spotifyUri) return null;
const uri = String(track.spotifyUri || '').trim();
if (!uri) return null;
// Skip cache if the cached video was already marked as failed (e.g. embed-blocked)
var failedIds = (track._failedVideoIds && track._failedVideoIds.length) ? track._failedVideoIds : [];
if (state.ytSearchCache.has(uri)) {
var cachedResult = state.ytSearchCache.get(uri);
if (!failedIds.length || failedIds.indexOf(cachedResult.videoId) === -1) {
return cachedResult;
}
// Cached result is in the failed list — clear it and re-search
state.ytSearchCache.delete(uri);
}
const storageKey = `yt_cache_v${YT_CACHE_VERSION}_${uri}`;
const storedVideoId = String(GM_getValue(storageKey, '') || '').trim();
if (/^[A-Za-z0-9_-]{11}$/.test(storedVideoId) && failedIds.indexOf(storedVideoId) === -1) {
const cached = {
videoId: storedVideoId,
title: '',
author: '',
durationSec: 0,
};
state.ytSearchCache.set(uri, cached);
return cached;
}
if (state.ytSearchInFlightByUri.has(uri)) {
return state.ytSearchInFlightByUri.get(uri);
}
const task = (async () => {
let title = String(track.title || '').trim();
let artist = String(track.artist || '').trim();
let expectedDurationSec = Math.round((Number(track.durationMs) || 0) / 1000);
if (!hasUsableTitle(title) || !hasUsableArtist(artist)) {
const meta = await resolveTrackMetaForUri(uri);
if (meta) {
if (!hasUsableTitle(title)) title = String(meta.title || '').trim();
if (!hasUsableArtist(artist)) artist = String(meta.artist || '').trim();
if (!expectedDurationSec && meta.durationMs) {
expectedDurationSec = Math.round(Number(meta.durationMs) / 1000);
}
track.title = title || track.title;
track.artist = artist || track.artist;
if (meta.durationMs && !track.durationMs) track.durationMs = meta.durationMs;
if (meta.art && !track.art) track.art = meta.art;
if (meta.openUrl && !track.openUrl) track.openUrl = meta.openUrl;
}
}
if (!hasUsableTitle(title)) {
console.warn('[NT Music] Missing usable title; refusing to guess track.', { uri, title, artist });
return null;
}
if (!hasUsableArtist(artist)) {
console.warn('[NT Music] Missing artist metadata; using title-only search.', { uri, title });
}
const primaryArtist = extractPrimaryArtist(artist);
const baseQuery = [artist, title].filter(Boolean).join(' ').trim();
const shortQuery = [primaryArtist, title].filter(Boolean).join(' ').trim();
if (!baseQuery) return null;
const queries = dedupeStrings([
primaryArtist && title ? `${title} ${primaryArtist} - Topic` : '',
primaryArtist && title ? `"${primaryArtist} - Topic" ${title}` : '',
primaryArtist && title ? `${primaryArtist} - ${title}` : '',
primaryArtist && title ? `${primaryArtist} ${title} official audio` : '',
primaryArtist && title ? `${primaryArtist} ${title} official video` : '',
primaryArtist && title ? `${primaryArtist} ${title}` : '',
artist !== primaryArtist && artist && title ? `${artist} ${title}` : '',
`${shortQuery} audio`,
`${baseQuery}`,
]);
// Compute meta quality once — used to adjust acceptance strictness
const mq = getTrackMetaQuality(track);
// --- Two-pass search: prefer Topic channel results ---
// Pass 1: Try the first two queries (Topic-targeted) and ONLY accept
// results from Topic channels. This prevents viral non-Topic
// videos from eclipsing the exact studio recording.
// Pass 2: Normal search across all queries, accepting any passing result.
// --- Pass 1: Topic-channel hunting ---
// Search multiple sources for Topic-channel results, starting with the
// most reliable. YouTube Music is checked first because its "Songs"
// category maps directly to Topic channel videos. The others are
// fallbacks for edge cases where YT Music doesn't have the track.
if (primaryArtist && title) {
var topicCandidates = [];
// Source 1 (primary): YouTube Music search — songs on YT Music ARE
// Topic channel videos, making this the most reliable source by far.
var ytMusicResults = await searchYouTubeMusicForTopic(title, primaryArtist);
for (var r4 = 0; r4 < ytMusicResults.length; r4 += 1) topicCandidates.push(ytMusicResults[r4]);
// Source 2: Google search — sometimes finds Topic content via site:
var googleResults = await searchGoogleForTopic(title, primaryArtist);
for (var r3 = 0; r3 < googleResults.length; r3 += 1) topicCandidates.push(googleResults[r3]);
// Source 3 & 4: YouTube searches with Topic-targeted queries (least
// reliable — standard YouTube search rarely surfaces Topic content)
for (var tq = 0; tq < 2 && tq < queries.length; tq += 1) {
var itResults = await searchYouTubeInnerTube(queries[tq]);
for (var r = 0; r < itResults.length; r += 1) topicCandidates.push(itResults[r]);
var webResults = await searchYouTubeWeb(queries[tq]);
for (var r2 = 0; r2 < webResults.length; r2 += 1) topicCandidates.push(webResults[r2]);
}
// Filter to Topic-channel candidates that match the PRIMARY artist.
// Without the artist check, "8-Bit Arcade - Topic" would pass for
// an Owl City search, producing 8-bit covers instead of originals.
var primaryNorm = normalizeSearchText(primaryArtist);
var topicOnly = topicCandidates.filter(function (c) {
var auth = String(c.author || '');
if (!/\s-\sTopic$/i.test(auth)) return false;
var channelArtist = auth.replace(/\s-\sTopic$/i, '').trim();
var channelNorm = normalizeSearchText(channelArtist);
// Accept if the channel artist matches primary artist, or if
// one contains the other (handles "98°" vs "98 Degrees" etc.)
return channelNorm === primaryNorm ||
channelNorm.indexOf(primaryNorm) !== -1 ||
primaryNorm.indexOf(channelNorm) !== -1;
});
if (_ntMusicApi.verbose) console.log('[NT Music] Topic search for "' + title + '" by ' + primaryArtist + ': ' + topicCandidates.length + ' raw candidates (' + ytMusicResults.length + ' from YT Music), ' + topicOnly.length + ' Topic-channel matches');
if (topicOnly.length) {
var best = pickBestYouTubeMatch(topicOnly, title, artist, expectedDurationSec, mq, failedIds);
if (best) {
if (_ntMusicApi.verbose) console.log('[NT Music] Topic winner: ' + best.videoId + ' "' + best.title + '" by ' + best.author);
state.ytSearchCache.set(uri, best);
GM_setValue(storageKey, best.videoId);
return best;
}
if (_ntMusicApi.verbose) console.warn('[NT Music] Topic candidates found but none passed scoring for "' + title + '"');
}
}
// Pass 2: Normal search — accept the best from any source
for (let i = 0; i < queries.length; i += 1) {
const query = queries[i];
const result = await searchYouTube(query, title, artist, expectedDurationSec, mq, failedIds);
if (result && result.videoId) {
if (_ntMusicApi.verbose) console.log('[NT Music] Pass 2 picked: ' + result.videoId + ' "' + result.title + '" by ' + result.author + ' (query: ' + query + ')');
state.ytSearchCache.set(uri, result);
GM_setValue(storageKey, result.videoId);
return result;
}
}
return null;
})();
state.ytSearchInFlightByUri.set(uri, task);
try {
return await task;
} finally {
state.ytSearchInFlightByUri.delete(uri);
}
}
async function searchYouTube(query, title, artist, expectedDurationSec, metaQuality, excludeVideoIds) {
// Primary: YouTube InnerTube API (direct, most reliable)
const innerTubeResults = await searchYouTubeInnerTube(query);
if (innerTubeResults.length) {
const best = pickBestYouTubeMatch(innerTubeResults, title, artist, expectedDurationSec, metaQuality, excludeVideoIds);
if (best) return best;
}
// Fallback 1: YouTube web scrape (ytInitialData from search page)
const webResults = await searchYouTubeWeb(query);
if (webResults.length) {
const best = pickBestYouTubeMatch(webResults, title, artist, expectedDurationSec, metaQuality, excludeVideoIds);
if (best) return best;
}
// Fallback 2: Invidious (third-party, can be flaky)
const invidiousResults = await searchInvidious(query);
if (invidiousResults.length) {
const best = pickBestYouTubeMatch(invidiousResults, title, artist, expectedDurationSec, metaQuality, excludeVideoIds);
if (best) return best;
}
// Fallback 3: Piped (third-party, can be flaky)
const pipedResults = await searchPiped(query);
if (pipedResults.length) {
const best = pickBestYouTubeMatch(pipedResults, title, artist, expectedDurationSec, metaQuality, excludeVideoIds);
if (best) return best;
}
return null;
}
async function searchInvidious(query) {
const results = [];
for (let i = 0; i < INVIDIOUS_INSTANCES.length; i += 1) {
const instance = INVIDIOUS_INSTANCES[i];
const url = `${instance}/api/v1/search?q=${encodeURIComponent(query)}&type=video`;
try {
const response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
},
timeout: 9000,
});
if (response.status < 200 || response.status >= 300) continue;
const payload = safeJsonParse(response.responseText);
if (!Array.isArray(payload)) continue;
payload.forEach((entry) => {
const videoId = String(entry && entry.videoId ? entry.videoId : '').trim();
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) return;
results.push({
videoId,
title: String(entry && entry.title ? entry.title : ''),
author: String(entry && (entry.author || entry.uploader || entry.authorName) ? (entry.author || entry.uploader || entry.authorName) : ''),
durationSec: Number(entry && (entry.lengthSeconds || entry.duration) ? (entry.lengthSeconds || entry.duration) : 0) || 0,
views: Number(entry && (entry.viewCount || entry.views) ? (entry.viewCount || entry.views) : 0) || 0,
isLive: Boolean(entry && (entry.liveNow || entry.isLive)),
verified: Boolean(entry && (entry.authorVerified || entry.verified)),
});
});
if (results.length) return results;
} catch (error) {
// try next instance
}
}
return results;
}
async function searchPiped(query) {
for (let i = 0; i < PIPED_INSTANCES.length; i += 1) {
const instance = PIPED_INSTANCES[i];
const filters = ['music_songs', 'videos'];
for (let f = 0; f < filters.length; f += 1) {
const filter = filters[f];
const url = `${instance}/search?q=${encodeURIComponent(query)}&filter=${encodeURIComponent(filter)}`;
try {
const response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
},
timeout: 9000,
});
if (response.status < 200 || response.status >= 300) continue;
const payload = safeJsonParse(response.responseText) || {};
const items = Array.isArray(payload.items) ? payload.items : [];
const results = [];
items.forEach((entry) => {
const urlValue = String(entry && entry.url ? entry.url : '');
const videoId = extractVideoIdFromUrl(urlValue) || extractVideoIdFromUrl(String(entry && entry.videoUrl ? entry.videoUrl : ''));
if (!videoId) return;
const duration = Number(entry && entry.duration ? entry.duration : 0) || parseDurationText(String(entry && entry.durationText ? entry.durationText : ''));
results.push({
videoId,
title: String(entry && entry.title ? entry.title : ''),
author: String(entry && (entry.uploaderName || entry.uploader || entry.author) ? (entry.uploaderName || entry.uploader || entry.author) : ''),
durationSec: Number(duration) || 0,
views: Number(entry && (entry.views || entry.viewCount) ? (entry.views || entry.viewCount) : 0) || 0,
isLive: Boolean(entry && (entry.isLive || entry.live)),
verified: Boolean(entry && (entry.uploaderVerified || entry.verified)),
});
});
if (results.length) return results;
} catch (error) {
// try next filter/instance
}
}
}
return [];
}
async function searchYouTubeWeb(query) {
try {
const response = await gmRequest({
method: 'GET',
url: `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`,
headers: {
Accept: 'text/html',
},
timeout: 10000,
});
if (response.status < 200 || response.status >= 300) return [];
const html = String(response.responseText || '');
const candidates = extractYouTubeSearchCandidates(html);
if (candidates.length) return candidates;
const fallback = html.match(/"videoId":"([A-Za-z0-9_-]{11})"/);
if (!fallback) return [];
return [
{
videoId: String(fallback[1]),
title: '',
author: '',
durationSec: 0,
views: 0,
isLive: false,
verified: false,
},
];
} catch (error) {
return [];
}
}
/**
* Search Google for a YouTube Topic-channel video.
* Google is much better than YouTube's own search at surfacing Topic
* channel content. We query:
* site:youtube.com "{artist} - Topic" "{title}"
* and extract YouTube video IDs from the results.
*/
async function searchGoogleForTopic(title, artist) {
if (!title || !artist) return [];
try {
const q = `site:youtube.com "${artist} - Topic" "${title}"`;
const response = await gmRequest({
method: 'GET',
url: `https://www.google.com/search?q=${encodeURIComponent(q)}&num=5`,
headers: {
Accept: 'text/html',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
},
timeout: 8000,
});
if (response.status < 200 || response.status >= 300) return [];
var html = String(response.responseText || '');
// Extract YouTube video IDs from Google result links
// Google links look like: youtube.com/watch?v=XXXXXXXXXXX
var idPattern = /youtube\.com\/watch\?v=([A-Za-z0-9_-]{11})/g;
var seen = {};
var results = [];
var match;
while ((match = idPattern.exec(html)) !== null) {
var vid = match[1];
if (seen[vid]) continue;
seen[vid] = true;
results.push({
videoId: vid,
title: '', // We don't have metadata from Google results
author: '', // Will be enriched below if possible
durationSec: 0,
views: 0,
isLive: false,
verified: false,
});
}
if (!results.length) return [];
// Try to enrich the first few results with actual metadata via noembed
for (var i = 0; i < Math.min(results.length, 3); i += 1) {
try {
var oembed = await gmRequest({
method: 'GET',
url: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v=' + results[i].videoId,
headers: { Accept: 'application/json' },
timeout: 5000,
});
if (oembed.status >= 200 && oembed.status < 300) {
var data = safeJsonParse(oembed.responseText);
if (data) {
results[i].title = String(data.title || '');
// noembed strips " - Topic" from author_name, so check if the
// returned author matches our target artist — if so, restore the
// " - Topic" suffix since our Google query explicitly filtered for it.
var noembedAuthor = String(data.author_name || '');
if (noembedAuthor && artist &&
noembedAuthor.toLowerCase().trim() === artist.toLowerCase().trim()) {
results[i].author = artist + ' - Topic';
} else if (/\s-\sTopic$/i.test(noembedAuthor)) {
// noembed kept the suffix (future-proofing)
results[i].author = noembedAuthor;
} else {
results[i].author = noembedAuthor;
}
}
}
} catch (_) { }
}
return results;
} catch (error) {
return [];
}
}
async function searchYouTubeInnerTube(query) {
try {
const response = await gmRequest({
method: 'POST',
url: 'https://www.youtube.com/youtubei/v1/search?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: JSON.stringify({
query: query,
context: {
client: {
clientName: 'WEB',
clientVersion: '2.20240101.00.00',
hl: 'en',
gl: 'US',
},
},
params: 'EgIQAQ%3D%3D',
}),
timeout: 10000,
});
if (response.status < 200 || response.status >= 300) return [];
const text = String(response.responseText || '');
if (!text) return [];
const blocks = extractJsonBlocksByKey(text, '"videoRenderer":', 30);
const results = [];
for (let i = 0; i < blocks.length; i += 1) {
const renderer = safeJsonParse(blocks[i]);
if (!renderer || !renderer.videoId) continue;
const videoId = String(renderer.videoId).trim();
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) continue;
const lengthLabel = readYouTubeText(renderer.lengthText);
results.push({
videoId,
title: readYouTubeText(renderer.title),
author: readYouTubeText(renderer.ownerText),
durationSec: parseDurationText(lengthLabel),
views: parseInnerTubeViewCount(renderer),
isLive: Boolean(renderer.isLive) || /\blive\b/i.test(String(lengthLabel || '')),
verified: checkInnerTubeVerified(renderer),
});
}
return results;
} catch (error) {
return [];
}
}
// ─── YouTube Music (WEB_REMIX) search for Topic channel content ───────
// YouTube Music's "Songs" category maps directly to Topic channel videos.
// Standard YouTube search almost never surfaces Topic content, but YT Music
// is built around it — making this the most reliable source for Topic videos.
async function searchYouTubeMusicForTopic(title, artist) {
try {
var query = title + ' ' + artist;
var response = await gmRequest({
method: 'POST',
url: 'https://music.youtube.com/youtubei/v1/search?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://music.youtube.com',
Referer: 'https://music.youtube.com/',
},
data: JSON.stringify({
query: query,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.20240101.01.00',
hl: 'en',
gl: 'US',
},
},
params: 'EgWKAQIIAQ%3D%3D', // Filter: Songs only
}),
timeout: 10000,
});
if (response.status < 200 || response.status >= 300) return [];
var text = String(response.responseText || '');
if (!text) return [];
// Extract musicResponsiveListItemRenderer blocks (same renderer used
// for songs in YT Music search results and playlist items)
var blocks = extractJsonBlocksByKey(text, '"musicResponsiveListItemRenderer":', 30);
var results = [];
for (var i = 0; i < blocks.length; i += 1) {
var renderer = safeJsonParse(blocks[i]);
if (!renderer) continue;
// Extract video ID — try multiple paths
var videoId = '';
// Path 1: playlistItemData.videoId (same field as playlist items)
if (renderer.playlistItemData && renderer.playlistItemData.videoId) {
videoId = String(renderer.playlistItemData.videoId).trim();
}
// Path 2: overlay → watchEndpoint.videoId
if (!videoId && renderer.overlay) {
try {
videoId = String(
renderer.overlay.musicItemThumbnailOverlayRenderer
.content.musicPlayButtonRenderer.playNavigationEndpoint
.watchEndpoint.videoId || ''
).trim();
} catch (_) { /* structure not present */ }
}
// Path 3: flexColumns navigation endpoint
if (!videoId && Array.isArray(renderer.flexColumns)) {
try {
var navEp = renderer.flexColumns[0]
.musicResponsiveListItemFlexColumnRenderer.text.runs[0]
.navigationEndpoint;
if (navEp && navEp.watchEndpoint) {
videoId = String(navEp.watchEndpoint.videoId || '').trim();
}
} catch (_) { /* structure not present */ }
}
if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId)) continue;
// Extract title and artist from flexColumns
var songTitle = '';
var songArtist = '';
var flexColumns = renderer.flexColumns;
if (Array.isArray(flexColumns)) {
songTitle = readMusicFlexColumnText(flexColumns[0]);
// For search results, flexColumns[1] contains "Artist • Album"
// — extract just the artist (first run) to avoid album noise
songArtist = readMusicSearchArtist(flexColumns[1]);
}
// Extract thumbnail (album art) — YouTube Music provides proper cover art
var thumbnail = '';
try {
var thumbObj = renderer.thumbnail;
if (thumbObj && thumbObj.musicThumbnailRenderer) {
var thumbList = thumbObj.musicThumbnailRenderer.thumbnail;
if (thumbList && Array.isArray(thumbList.thumbnails)) {
thumbnail = pickBestMusicThumbnail(thumbList.thumbnails);
}
}
} catch (_) { /* thumbnail not available */ }
// Extract duration from fixedColumns
var durationSec = 0;
var fixedColumns = renderer.fixedColumns;
if (Array.isArray(fixedColumns) && fixedColumns[0]) {
var fixedRenderer = fixedColumns[0].musicResponsiveListItemFixedColumnRenderer;
if (fixedRenderer) {
var durText = readYouTubeText(fixedRenderer.text);
durationSec = parseDurationText(durText);
}
}
// YouTube Music "Songs" results ARE Topic channel content —
// append " - Topic" so the Topic filter in Pass 1 accepts them
results.push({
videoId: videoId,
title: songTitle || '',
author: (songArtist || artist) + ' - Topic',
durationSec: durationSec,
thumbnail: thumbnail,
views: 0,
isLive: false,
verified: false,
});
}
return results;
} catch (error) {
return [];
}
}
/**
* Extract the artist name from a YouTube Music search result flex column.
* In search results, flexColumns[1] contains multiple runs like:
* ["Rascal Flatts", " • ", "Cars (Original Motion Picture Soundtrack)"]
* We only want the first run (the artist name).
*/
function readMusicSearchArtist(column) {
if (!column) return '';
var renderer = column.musicResponsiveListItemFlexColumnRenderer;
if (!renderer || !renderer.text) return '';
var runs = renderer.text.runs;
if (!Array.isArray(runs) || !runs.length) return '';
// First run is the primary artist; subsequent runs are separators + album
return String(runs[0].text || '').trim();
}
// ─── YouTube Music Album Art Lookup ──────────────────────────────────
// For a known video ID, fetch the proper album cover art from YouTube Music.
// Two strategies:
// 1. Direct: call the "next" endpoint and regex-extract lh3 URLs
// 2. Search: search YouTube Music for title+artist, match by videoId
// Strategy 2 is the reliable fallback — it reuses the same search + thumbnail
// extraction pipeline that already works for playlists and Topic matching.
async function fetchYouTubeMusicAlbumArt(videoId, title, artist) {
// Strategy 1: Direct lookup via YouTube Music "next" endpoint.
// Scan the entire response for lh3.googleusercontent.com URLs (album covers).
var directArt = await fetchYouTubeMusicArtDirect(videoId);
if (directArt) return directArt;
// Strategy 2: Search YouTube Music for the track, match by videoId.
// This reuses searchYouTubeMusicForTopic which we know returns lh3 thumbnails.
if (title) {
var searchArt = await fetchYouTubeMusicArtBySearch(videoId, title, artist || '');
if (searchArt) return searchArt;
}
return '';
}
/** Strategy 1: Extract first lh3 album-art URL from the "next" endpoint response. */
async function fetchYouTubeMusicArtDirect(videoId) {
try {
var response = await gmRequest({
method: 'POST',
url: 'https://music.youtube.com/youtubei/v1/next?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://music.youtube.com',
Referer: 'https://music.youtube.com/',
},
data: JSON.stringify({
videoId: videoId,
isAudioOnly: true,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.20240101.01.00',
hl: 'en',
gl: 'US',
},
},
}),
timeout: 8000,
});
if (response.status < 200 || response.status >= 300) return '';
var text = String(response.responseText || '');
if (!text) return '';
// Scan the full response for lh3 album-art URLs. The first match is
// almost always the current track's album cover; later ones are related tracks.
var urlPattern = /https:\/\/lh3\.googleusercontent\.com\/[A-Za-z0-9_\-\/+=]+(?:=[^\s"',}\]]+)?/g;
var match = urlPattern.exec(text);
if (match) {
var artUrl = match[0];
// Upscale to 300×300
if (artUrl.indexOf('=w') !== -1) {
artUrl = artUrl.replace(/=w\d+-h\d+[^\s"',}\]]*/, '=w300-h300-l90-rj');
} else {
artUrl += '=w300-h300-l90-rj';
}
return artUrl;
}
return '';
} catch (_) {
return '';
}
}
/** Strategy 2: Search YouTube Music and use the thumbnail of the matching result. */
async function fetchYouTubeMusicArtBySearch(videoId, title, artist) {
try {
var query = title + ' ' + artist;
var response = await gmRequest({
method: 'POST',
url: 'https://music.youtube.com/youtubei/v1/search?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://music.youtube.com',
Referer: 'https://music.youtube.com/',
},
data: JSON.stringify({
query: query,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.20240101.01.00',
hl: 'en',
gl: 'US',
},
},
params: 'EgWKAQIIAQ%3D%3D', // Filter: Songs only
}),
timeout: 10000,
});
if (response.status < 200 || response.status >= 300) return '';
var text = String(response.responseText || '');
if (!text) return '';
var blocks = extractJsonBlocksByKey(text, '"musicResponsiveListItemRenderer":', 20);
var exactMatch = '';
var firstArt = '';
for (var i = 0; i < blocks.length; i += 1) {
var renderer = safeJsonParse(blocks[i]);
if (!renderer) continue;
// Extract thumbnail
var thumbnail = '';
try {
var thumbObj = renderer.thumbnail;
if (thumbObj && thumbObj.musicThumbnailRenderer) {
var thumbList = thumbObj.musicThumbnailRenderer.thumbnail;
if (thumbList && Array.isArray(thumbList.thumbnails)) {
thumbnail = pickBestMusicThumbnail(thumbList.thumbnails);
}
}
} catch (_) { /* skip */ }
if (!thumbnail) continue;
// Save first art as fallback
if (!firstArt) firstArt = thumbnail;
// Check for exact videoId match
var vid = '';
if (renderer.playlistItemData && renderer.playlistItemData.videoId) {
vid = String(renderer.playlistItemData.videoId).trim();
}
if (vid === videoId) {
exactMatch = thumbnail;
break;
}
}
return exactMatch || firstArt;
} catch (_) {
return '';
}
}
// ─── YouTube Playlist Fetching ────────────────────────────────────────
async function fetchYouTubePlaylistTracks(playlistId) {
const cacheKey = 'youtube:playlist:' + playlistId + '::tracks';
if (state.sourceTrackListCache.has(cacheKey)) {
const cached = state.sourceTrackListCache.get(cacheKey);
traceMusic('youtube-playlist:cache-hit', {
playlistId,
count: Array.isArray(cached) ? cached.length : 0,
});
return cached;
}
traceMusic('youtube-playlist:fetch-start', { playlistId });
// Primary: YouTube Music API (WEB_REMIX) — gives per-track album art
var tracks = await fetchYouTubePlaylistMusicApi(playlistId);
traceMusic('youtube-playlist:music-api-result', {
playlistId,
count: tracks.length,
});
// Fallback: regular InnerTube browse API (WEB client)
if (!tracks.length) {
var result = await fetchYouTubePlaylistInnerTube(playlistId);
traceMusic('youtube-playlist:innertube-result', {
playlistId,
count: result && Array.isArray(result.tracks) ? result.tracks.length : 0,
});
// Fallback: web scrape playlist page
if (!result.tracks.length) {
result = await fetchYouTubePlaylistWebScrape(playlistId);
traceMusic('youtube-playlist:web-scrape-result', {
playlistId,
count: result && Array.isArray(result.tracks) ? result.tracks.length : 0,
});
}
tracks = result.tracks;
// If we got playlist-level album art (e.g. YouTube Music OLAK albums), stamp it on each track
if (result.headerArt && tracks.length) {
for (var i = 0; i < tracks.length; i += 1) {
if (!tracks[i].playlistArt) tracks[i].playlistArt = result.headerArt;
}
}
}
if (tracks.length) {
state.sourceTrackListCache.set(cacheKey, tracks);
}
traceMusic('youtube-playlist:final-result', {
playlistId,
count: tracks.length,
});
return tracks;
}
async function fetchYouTubePlaylistInnerTube(playlistId) {
try {
var response = await gmRequest({
method: 'POST',
url: 'https://www.youtube.com/youtubei/v1/browse?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: JSON.stringify({
browseId: 'VL' + playlistId,
context: {
client: {
clientName: 'WEB',
clientVersion: '2.20240101.00.00',
hl: 'en',
gl: 'US',
},
},
}),
timeout: 15000,
});
if (response.status < 200 || response.status >= 300) return { tracks: [], headerArt: '' };
var text = String(response.responseText || '');
if (!text) return { tracks: [], headerArt: '' };
var results = extractPlaylistVideoRenderers(text);
// Extract playlist-level album art from header (YouTube Music albums)
var headerArt = extractPlaylistHeaderArt(text);
// Handle continuation for playlists >100 videos
var continuationToken = extractContinuationToken(text);
var safety = 0;
while (continuationToken && safety < 20) {
safety += 1;
var contResponse = await gmRequest({
method: 'POST',
url: 'https://www.youtube.com/youtubei/v1/browse?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
data: JSON.stringify({
continuation: continuationToken,
context: {
client: {
clientName: 'WEB',
clientVersion: '2.20240101.00.00',
hl: 'en',
gl: 'US',
},
},
}),
timeout: 15000,
});
if (contResponse.status < 200 || contResponse.status >= 300) break;
var contText = String(contResponse.responseText || '');
var contResults = extractPlaylistVideoRenderers(contText);
if (!contResults.length) break;
for (var ci = 0; ci < contResults.length; ci += 1) {
results.push(contResults[ci]);
}
continuationToken = extractContinuationToken(contText);
}
return { tracks: results, headerArt: headerArt };
} catch (error) {
return { tracks: [], headerArt: '' };
}
}
function extractPlaylistVideoRenderers(text) {
var blocks = extractJsonBlocksByKey(String(text || ''), '"playlistVideoRenderer":', 500);
var results = [];
for (var i = 0; i < blocks.length; i += 1) {
var renderer = safeJsonParse(blocks[i]);
if (!renderer || !renderer.videoId) continue;
var videoId = String(renderer.videoId).trim();
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) continue;
var lengthLabel = readYouTubeText(renderer.lengthText);
results.push({
videoId: videoId,
title: readYouTubeText(renderer.title),
author: readYouTubeText(renderer.shortBylineText) || readYouTubeText(renderer.ownerText),
durationSec: parseDurationText(lengthLabel),
});
}
return results;
}
// ─── YouTube Music (WEB_REMIX) playlist fetcher ─────────────────────────
// Uses the YouTube Music InnerTube client to get per-track album art.
// Regular WEB client only gives video thumbnails; WEB_REMIX gives actual
// album cover art from lh3.googleusercontent.com.
async function fetchYouTubePlaylistMusicApi(playlistId) {
try {
var response = await gmRequest({
method: 'POST',
url: 'https://music.youtube.com/youtubei/v1/browse?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://music.youtube.com',
Referer: 'https://music.youtube.com/',
},
data: JSON.stringify({
browseId: 'VL' + playlistId,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.20240101.01.00',
hl: 'en',
gl: 'US',
},
},
}),
timeout: 15000,
});
if (response.status < 200 || response.status >= 300) return [];
var text = String(response.responseText || '');
if (!text) return [];
var results = extractMusicResponsiveListItems(text);
// Handle continuation for playlists >100 tracks
var continuationToken = extractContinuationToken(text);
var safety = 0;
while (continuationToken && safety < 20) {
safety += 1;
var contResponse = await gmRequest({
method: 'POST',
url: 'https://music.youtube.com/youtubei/v1/browse?prettyPrint=false',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
Origin: 'https://music.youtube.com',
Referer: 'https://music.youtube.com/',
},
data: JSON.stringify({
continuation: continuationToken,
context: {
client: {
clientName: 'WEB_REMIX',
clientVersion: '1.20240101.01.00',
hl: 'en',
gl: 'US',
},
},
}),
timeout: 15000,
});
if (contResponse.status < 200 || contResponse.status >= 300) break;
var contText = String(contResponse.responseText || '');
var contResults = extractMusicResponsiveListItems(contText);
if (!contResults.length) break;
for (var ci = 0; ci < contResults.length; ci += 1) {
results.push(contResults[ci]);
}
continuationToken = extractContinuationToken(contText);
}
return results;
} catch (error) {
return [];
}
}
/**
* Parse musicResponsiveListItemRenderer blocks from YouTube Music API response.
* Each renderer contains: videoId, title, artist, album, duration, and album art.
*
* Structure:
* flexColumns[0] = song title (with videoId in watchEndpoint)
* flexColumns[1] = artist name
* flexColumns[2] = album name
* fixedColumns[0] = duration text
* thumbnail.musicThumbnailRenderer = album art
* playlistItemData.videoId = video ID
*/
function extractMusicResponsiveListItems(text) {
var blocks = extractJsonBlocksByKey(String(text || ''), '"musicResponsiveListItemRenderer":', 600);
var results = [];
for (var i = 0; i < blocks.length; i += 1) {
var renderer = safeJsonParse(blocks[i]);
if (!renderer) continue;
// Extract video ID
var videoId = '';
if (renderer.playlistItemData && renderer.playlistItemData.videoId) {
videoId = String(renderer.playlistItemData.videoId).trim();
}
if (!videoId || !/^[A-Za-z0-9_-]{11}$/.test(videoId)) continue;
// Extract title, artist, album from flexColumns
var title = '';
var author = '';
var album = '';
var flexColumns = renderer.flexColumns;
if (Array.isArray(flexColumns)) {
title = readMusicFlexColumnText(flexColumns[0]);
author = readMusicFlexColumnText(flexColumns[1]);
album = readMusicFlexColumnText(flexColumns[2]);
}
// Extract duration from fixedColumns
var durationSec = 0;
var fixedColumns = renderer.fixedColumns;
if (Array.isArray(fixedColumns) && fixedColumns[0]) {
var fixedRenderer = fixedColumns[0].musicResponsiveListItemFixedColumnRenderer;
if (fixedRenderer) {
var durText = readYouTubeText(fixedRenderer.text);
durationSec = parseDurationText(durText);
}
}
// Extract album art from musicThumbnailRenderer
var albumArt = '';
var thumbObj = renderer.thumbnail;
if (thumbObj && thumbObj.musicThumbnailRenderer) {
var thumbs = thumbObj.musicThumbnailRenderer.thumbnail;
if (thumbs && Array.isArray(thumbs.thumbnails)) {
albumArt = pickBestMusicThumbnail(thumbs.thumbnails);
}
}
results.push({
videoId: videoId,
title: title,
author: author,
album: album,
durationSec: durationSec,
playlistArt: albumArt,
});
}
return results;
}
/**
* Read the text content from a YouTube Music flex column.
* Path: column.musicResponsiveListItemFlexColumnRenderer.text.runs[0].text
*/
function readMusicFlexColumnText(column) {
if (!column) return '';
var renderer = column.musicResponsiveListItemFlexColumnRenderer;
if (!renderer || !renderer.text) return '';
return readYouTubeText(renderer.text);
}
/**
* Pick the best album art thumbnail from YouTube Music thumbnails.
* YT Music provides lh3.googleusercontent.com URLs at 60x60 and 120x120,
* but we can resize them via URL parameter to get higher resolution.
*/
function pickBestMusicThumbnail(thumbnails) {
if (!Array.isArray(thumbnails) || !thumbnails.length) return '';
// Pick the largest available thumbnail
var best = '';
var bestSize = 0;
for (var i = 0; i < thumbnails.length; i += 1) {
var t = thumbnails[i];
if (!t || !t.url) continue;
var size = Math.max(t.width || 0, t.height || 0);
if (size > bestSize) {
bestSize = size;
best = String(t.url);
}
}
if (!best) return '';
// Upscale lh3.googleusercontent.com URLs to 300x300 for display
// Format: ...=w120-h120-l90-rj → ...=w300-h300-l90-rj
if (best.indexOf('lh3.googleusercontent.com') !== -1) {
best = best.replace(/=w\d+-h\d+/, '=w300-h300');
}
return best;
}
function extractContinuationToken(text) {
// Look for continuationCommand.token inside continuationEndpoint blocks
var blocks = extractJsonBlocksByKey(String(text || ''), '"continuationCommand":', 5);
for (var i = 0; i < blocks.length; i += 1) {
var obj = safeJsonParse(blocks[i]);
if (obj && typeof obj.token === 'string' && obj.token.length > 10) {
return obj.token;
}
}
return '';
}
/**
* Extract playlist-level album art from YouTube InnerTube response.
* YouTube Music album playlists (OLAK5uy_...) store square album art
* in the playlist header, not in individual track renderers.
* Looks in heroPlaylistThumbnailRenderer and playlistCustomThumbnailRenderer.
*/
function extractPlaylistHeaderArt(text) {
// Strategy 1: heroPlaylistThumbnailRenderer (primary album art in header banner)
var heroBlocks = extractJsonBlocksByKey(String(text || ''), '"heroPlaylistThumbnailRenderer":', 3);
for (var i = 0; i < heroBlocks.length; i += 1) {
var hero = safeJsonParse(heroBlocks[i]);
if (hero && hero.thumbnail && Array.isArray(hero.thumbnail.thumbnails)) {
var best = pickBestSquareThumbnail(hero.thumbnail.thumbnails);
if (best) return best;
}
}
// Strategy 2: playlistCustomThumbnailRenderer (sidebar thumbnail)
var customBlocks = extractJsonBlocksByKey(String(text || ''), '"playlistCustomThumbnailRenderer":', 3);
for (var j = 0; j < customBlocks.length; j += 1) {
var custom = safeJsonParse(customBlocks[j]);
if (custom && custom.thumbnail && Array.isArray(custom.thumbnail.thumbnails)) {
var best2 = pickBestSquareThumbnail(custom.thumbnail.thumbnails);
if (best2) return best2;
}
}
// Strategy 3: playlistHeaderRenderer may embed thumbnails directly
var headerBlocks = extractJsonBlocksByKey(String(text || ''), '"playlistHeaderRenderer":', 2);
for (var k = 0; k < headerBlocks.length; k += 1) {
var header = safeJsonParse(headerBlocks[k]);
if (!header) continue;
// Check cinematic container
var cinematic = header.cinematicContainer;
if (cinematic) {
var cBlocks = extractJsonBlocksByKey(JSON.stringify(cinematic), '"thumbnails":', 3);
for (var ci = 0; ci < cBlocks.length; ci += 1) {
var cObj = safeJsonParse(cBlocks[ci]);
if (Array.isArray(cObj)) {
var best3 = pickBestSquareThumbnail(cObj);
if (best3) return best3;
}
}
}
}
return '';
}
/**
* Pick the best thumbnail URL from an array, preferring square (1:1) images
* around 300-640px. Falls back to the largest available.
*/
function pickBestSquareThumbnail(thumbnails) {
if (!Array.isArray(thumbnails) || !thumbnails.length) return '';
// Filter to square-ish thumbnails (aspect ratio close to 1:1)
var square = thumbnails.filter(function (t) {
if (!t || !t.url || !t.width || !t.height) return false;
var ratio = t.width / t.height;
return ratio > 0.85 && ratio < 1.18;
});
// Prefer square thumbnails; fall back to all if none are square
var pool = square.length ? square : thumbnails;
// Find the best size: prefer 300-640px range, then take largest
var best = '';
var bestScore = -1;
for (var i = 0; i < pool.length; i += 1) {
var t = pool[i];
if (!t || !t.url) continue;
var size = Math.max(t.width || 0, t.height || 0);
// Ideal range 300-640 scores highest; outside that, score by size
var score = (size >= 300 && size <= 640) ? (10000 + size) : size;
if (score > bestScore) {
bestScore = score;
best = String(t.url);
}
}
return best;
}
async function fetchYouTubePlaylistWebScrape(playlistId) {
try {
var response = await gmRequest({
method: 'GET',
url: 'https://www.youtube.com/playlist?list=' + encodeURIComponent(playlistId),
headers: {
Accept: 'text/html',
},
timeout: 15000,
});
if (response.status < 200 || response.status >= 300) return { tracks: [], headerArt: '' };
var html = String(response.responseText || '');
var results = extractPlaylistVideoRenderers(html);
var headerArt = extractPlaylistHeaderArt(html);
return { tracks: results, headerArt: headerArt };
} catch (error) {
return { tracks: [], headerArt: '' };
}
}
function parseInnerTubeViewCount(renderer) {
if (!renderer) return 0;
const viewText = readYouTubeText(renderer.viewCountText) ||
readYouTubeText(renderer.shortViewCountText) || '';
if (!viewText) return 0;
const cleaned = viewText.replace(/[^0-9.KMBkmb]/g, '').trim();
if (!cleaned) return 0;
const multiplierMatch = cleaned.match(/([0-9.]+)\s*([KMBkmb])?/);
if (!multiplierMatch) return 0;
let num = parseFloat(multiplierMatch[1]) || 0;
const suffix = (multiplierMatch[2] || '').toUpperCase();
if (suffix === 'K') num *= 1000;
else if (suffix === 'M') num *= 1000000;
else if (suffix === 'B') num *= 1000000000;
return Math.round(num);
}
function checkInnerTubeVerified(renderer) {
if (!renderer) return false;
const badges = renderer.ownerBadges;
if (!Array.isArray(badges)) return false;
for (let i = 0; i < badges.length; i += 1) {
const badge = badges[i];
const meta = badge && badge.metadataBadgeRenderer;
if (!meta) continue;
const style = String(meta.style || '').toUpperCase();
if (style.includes('VERIFIED') || style.includes('OFFICIAL')) return true;
}
return false;
}
function pickBestYouTubeMatch(candidates, trackTitle, trackArtist, expectedDurationSec, metaQuality, excludeVideoIds) {
if (!Array.isArray(candidates) || !candidates.length) return null;
var _excludeSet = (Array.isArray(excludeVideoIds) && excludeVideoIds.length) ? excludeVideoIds : null;
const titleNorm = normalizeSearchText(trackTitle);
const artistNorm = normalizeSearchText(trackArtist);
if (!titleNorm) return null;
const primaryArtist = extractPrimaryArtist(trackArtist);
const primaryArtistNorm = normalizeSearchText(primaryArtist);
const artistRequired = hasUsableArtist(trackArtist);
const wantedTokens = tokenizeSearchText(`${trackTitle} ${trackArtist}`);
const trackTitleLower = String(trackTitle || '').toLowerCase();
// Hard-reject patterns — skip these candidates entirely
const REJECT_PATTERN = /\b(?:originally performed|karaoke|backing track|in the style of|made famous by|made popular by|tribute to)\b/i;
// Modern mismatch patterns — always penalise (never wanted from Spotify source)
const MODERN_MISMATCH = /\b(?:sped up|slowed|reverb|nightcore|8d audio|8d|bass boosted|bassboosted|daycore|chipmunk)\b/i;
let best = null;
let bestScore = -Infinity;
let bestHasArtistSignal = false;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i];
if (!candidate || !candidate.videoId) continue;
if (_excludeSet && _excludeSet.indexOf(candidate.videoId) !== -1) continue;
if (candidate.isLive) continue;
const durationSec = Number(candidate.durationSec) || 0;
if (durationSec > 0 && durationSec < 45) continue;
// Max duration ceiling — reject full-album uploads etc.
const expected = Number(expectedDurationSec) || 0;
if (expected > 0 && durationSec > 0 && durationSec > expected * 3) continue;
const text = `${candidate.title || ''} ${candidate.author || ''}`.trim();
// Hard reject karaoke, "originally performed by", tribute, etc.
if (REJECT_PATTERN.test(text)) continue;
const normText = normalizeSearchText(text);
const tokens = tokenizeSearchText(text);
let score = 0;
// --- Title & artist matching ---
const titleMatch = titleNorm && normText.includes(titleNorm);
const primaryArtistMatch = primaryArtistNorm && normText.includes(primaryArtistNorm);
const artistMatch = artistNorm && artistNorm !== primaryArtistNorm && normText.includes(artistNorm);
if (titleMatch) score += 10;
else score -= 8;
if (primaryArtistMatch) score += 8;
if (artistMatch) score += 3;
if (artistRequired && !(primaryArtistMatch || artistMatch)) score -= 7;
for (let t = 0; t < wantedTokens.length; t += 1) {
if (tokens.includes(wantedTokens[t])) score += 1;
}
// --- Channel quality (tiered) ---
const authorText = String(candidate.author || '').trim();
const authorNorm = normalizeSearchText(authorText);
const isTopicChannel = /\s-\sTopic$/i.test(authorText);
const isVevoChannel = /VEVO$/i.test(authorText);
var channelMatchesPrimary = false;
var channelMatchesArtist = false;
if (isTopicChannel) {
// Tier 1a: Topic channel — exact studio recording, best possible match
score += 14;
channelMatchesPrimary = primaryArtistNorm && authorNorm.includes(primaryArtistNorm);
channelMatchesArtist = artistNorm && authorNorm.includes(artistNorm);
if (channelMatchesPrimary) score += 6;
else if (channelMatchesArtist) score += 4;
} else if (isVevoChannel) {
// Tier 1b: VEVO — official music video, reliable but may differ from album audio
score += 10;
channelMatchesPrimary = primaryArtistNorm && authorNorm.includes(primaryArtistNorm);
channelMatchesArtist = artistNorm && authorNorm.includes(artistNorm);
if (channelMatchesPrimary) score += 6;
else if (channelMatchesArtist) score += 4;
} else {
// Tier 2: Artist's own channel (not Topic/VEVO)
channelMatchesPrimary = primaryArtistNorm && authorNorm.includes(primaryArtistNorm);
channelMatchesArtist = artistNorm && authorNorm.includes(artistNorm);
if (channelMatchesPrimary) {
score += 7;
if (candidate.verified) score += 2;
} else if (channelMatchesArtist) {
score += 4;
if (candidate.verified) score += 1;
}
// Bonus for official audio/video in title
if (/official audio|official video/i.test(text)) {
score += 4;
} else if (/\baudio\b|\blyrics\b/i.test(text)) {
score += 2;
}
}
// --- Content penalties (conditional on Spotify track title) ---
// Only penalise these terms if the Spotify track title does NOT
// contain them — e.g. a remix album should match remix results.
var contentWords = ['cover', 'remix', 'live', 'instrumental', 'symphonic', 'orchestra', 'orchestral', 'acoustic'];
for (var cw = 0; cw < contentWords.length; cw += 1) {
var word = contentWords[cw];
var re = new RegExp('\\b' + word + '\\b', 'i');
if (re.test(text) && !re.test(trackTitleLower)) score -= 8;
}
// Always penalise — never legitimate from a Spotify source
if (/\b(?:reaction|trailer|commercial)\b/i.test(text)) score -= 8;
// Modern YouTube mismatch junk — always penalise
if (MODERN_MISMATCH.test(text)) score -= 10;
// Featured-artist mismatch — if the YouTube title has "ft." or "feat."
// but the Spotify title does not, this is likely a different arrangement,
// collaboration, or remix (e.g. "ONE DAY ft. Andel and Nalyn" vs "One Day").
var ytHasFeat = /\b(?:feat\.?|ft\.?|featuring)\b/i.test(String(candidate.title || ''));
var spotifyHasFeat = /\b(?:feat\.?|ft\.?|featuring)\b/i.test(trackTitleLower);
if (ytHasFeat && !spotifyHasFeat) score -= 5;
// Extra-artist mismatch — YouTube titles often list collaborators as
// comma or ampersand-separated names before the " - " delimiter, e.g.
// "Rascal Flatts, Lzzy Hale - Life Is A Highway". If the Spotify
// metadata has no knowledge of the extra artist(s), this is a different
// version (duet, collab, remix) and should be penalised.
var ytTitle = String(candidate.title || '');
var dashIdx = ytTitle.indexOf(' - ');
if (dashIdx > 0) {
var artistPart = ytTitle.substring(0, dashIdx);
// Split on comma, &, x, × — common multi-artist separators
var ytArtists = artistPart.split(/\s*[,&x\u00d7]\s*/i).map(function (s) { return s.trim().toLowerCase(); }).filter(Boolean);
if (ytArtists.length > 1) {
var spotifyArtistLower = String(trackArtist || '').toLowerCase();
var extraArtistFound = false;
for (var ea = 0; ea < ytArtists.length; ea += 1) {
var ytA = ytArtists[ea];
if (ytA.length < 2) continue; // skip single-char fragments
if (spotifyArtistLower.indexOf(ytA) === -1 && trackTitleLower.indexOf(ytA) === -1) {
extraArtistFound = true;
break;
}
}
if (extraArtistFound) score -= 6;
}
}
// --- Duration matching ---
if (expected > 0 && durationSec > 0) {
const ratio = Math.abs(durationSec - expected) / expected;
if (ratio <= 0.08) score += 8;
else if (ratio <= 0.15) score += 5;
else if (ratio <= 0.25) score += 2;
else if (ratio >= 0.5) score -= 5;
}
// --- Global bonuses ---
if (candidate.verified) score += 1.5;
score += Math.min(2, Math.log10(Math.max(1, Number(candidate.views) || 0)) / 5);
// Track whether this candidate has ANY artist/channel signal
var hasArtistSignal = primaryArtistMatch || artistMatch
|| channelMatchesPrimary || channelMatchesArtist;
if (score > bestScore) {
bestScore = score;
best = candidate;
bestHasArtistSignal = hasArtistSignal;
}
}
if (!best) return null;
// --- Acceptance threshold ---
// When artist info is known, REQUIRE at least one artist or channel
// match. This prevents "right title, wrong artist" false positives
// (e.g. "Stay" by The Kid LAROI matching "Stay" by Rihanna).
if (artistRequired && !bestHasArtistSignal) return null;
// Dynamic minimum score — higher-quality Spotify metadata means we
// can afford to be stricter; lower quality means we cast a wider net.
var mq = Number(metaQuality) || 0;
var minScore;
if (!artistRequired) {
// Title-only search — be strict
minScore = 11;
} else if (mq >= 10) {
// Full metadata (title + artist + duration + art) — be strict
minScore = 10;
} else if (mq >= 7) {
// Good metadata — moderate
minScore = 8;
} else {
// Sparse metadata — more lenient
minScore = 6;
}
if (bestScore < minScore) return null;
return {
videoId: best.videoId,
title: String(best.title || ''),
author: String(best.author || ''),
durationSec: Number(best.durationSec) || 0,
_score: bestScore,
};
}
function extractYouTubeSearchCandidates(html) {
const blocks = extractJsonBlocksByKey(String(html || ''), '"videoRenderer":', 60);
const results = [];
for (let i = 0; i < blocks.length; i += 1) {
const renderer = safeJsonParse(blocks[i]);
if (!renderer || typeof renderer !== 'object') continue;
const videoId = String(renderer.videoId || '').trim();
if (!/^[A-Za-z0-9_-]{11}$/.test(videoId)) continue;
const title = readYouTubeText(renderer.title);
const author = readYouTubeText(renderer.ownerText);
const durationLabel = readYouTubeText(renderer.lengthText);
const durationSec = parseDurationText(durationLabel);
const isLive = /live/i.test(String(durationLabel || '')) || Boolean(renderer.isLive);
results.push({
videoId,
title,
author,
durationSec,
views: 0,
isLive,
verified: false,
});
}
return results;
}
function extractJsonBlocksByKey(text, key, maxCount) {
const source = String(text || '');
const found = [];
let cursor = 0;
const limit = Number(maxCount) > 0 ? Number(maxCount) : 40;
while (cursor < source.length && found.length < limit) {
const keyIndex = source.indexOf(key, cursor);
if (keyIndex === -1) break;
const braceStart = source.indexOf('{', keyIndex + key.length);
if (braceStart === -1) break;
const block = readBalancedJsonObject(source, braceStart);
if (!block) {
cursor = keyIndex + key.length;
continue;
}
found.push(block.json);
cursor = block.endIndex;
}
return found;
}
function readBalancedJsonObject(text, startIndex) {
const source = String(text || '');
let depth = 0;
let inString = false;
let escaped = false;
for (let i = startIndex; i < source.length; i += 1) {
const ch = source[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '{') {
depth += 1;
continue;
}
if (ch === '}') {
depth -= 1;
if (depth === 0) {
return {
json: source.slice(startIndex, i + 1),
endIndex: i + 1,
};
}
}
}
return null;
}
function readYouTubeText(node) {
if (!node) return '';
if (typeof node === 'string') return node;
if (typeof node.simpleText === 'string') return node.simpleText;
if (Array.isArray(node.runs)) {
return node.runs
.map((entry) => (entry && typeof entry.text === 'string' ? entry.text : ''))
.filter(Boolean)
.join('')
.trim();
}
return '';
}
function extractVideoIdFromUrl(url) {
const value = String(url || '').trim();
if (!value) return '';
const direct = value.match(/[?&]v=([A-Za-z0-9_-]{11})/);
if (direct) return direct[1];
const short = value.match(/(?:youtu\.be\/|\/shorts\/)([A-Za-z0-9_-]{11})/);
if (short) return short[1];
const cleanPath = value.match(/\/watch\/([A-Za-z0-9_-]{11})/);
if (cleanPath) return cleanPath[1];
return '';
}
function normalizeSearchText(value) {
return String(value || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim();
}
function tokenizeSearchText(value) {
const normalized = normalizeSearchText(value);
if (!normalized) return [];
return normalized.split(/\s+/).filter((token) => token.length > 1);
}
function extractPrimaryArtist(artistString) {
const raw = String(artistString || '').trim();
if (!raw) return '';
// Always split on feat/ft/featuring (unambiguous collaboration markers)
const featSplit = /\s*[,;&]\s*|\s+(?:feat\.?|ft\.?|featuring)\s+/i;
const parts = raw.split(featSplit).map((p) => p.trim()).filter(Boolean);
const primary = parts[0] || raw;
// Only split on " and " / " with " / " x " if there are also commas/semicolons
// present — this avoids breaking "Simon and Garfunkel", "Florence and The Machine"
if (/[,;&]/.test(raw)) {
const ambiguousSplit = /\s+(?:with|and|x)\s+/i;
const subParts = primary.split(ambiguousSplit).map((p) => p.trim()).filter(Boolean);
return subParts[0] || primary;
}
return primary;
}
function dedupeStrings(values) {
if (!Array.isArray(values)) return [];
const seen = new Set();
const result = [];
for (let i = 0; i < values.length; i += 1) {
const raw = String(values[i] || '').trim();
if (!raw) continue;
const key = raw.toLowerCase();
if (seen.has(key)) continue;
seen.add(key);
result.push(raw);
}
return result;
}
function isPlaceholderTitle(value) {
const norm = normalizeSearchText(value);
if (!norm) return true;
if (norm === 'spotify') return true;
if (norm === 'unknown track') return true;
return false;
}
function isPlaceholderArtist(value) {
const norm = normalizeSearchText(value);
if (!norm) return true;
if (norm === 'spotify') return true;
if (norm === 'unknown artist') return true;
if (norm === 'various artists') return true;
return false;
}
function hasUsableTitle(value) {
return !isPlaceholderTitle(value);
}
function hasUsableArtist(value) {
return !isPlaceholderArtist(value);
}
function getTrackMetaQuality(meta) {
if (!meta || typeof meta !== 'object') return 0;
let score = 0;
if (String(meta.uri || '').trim()) score += 1;
if (hasUsableTitle(meta.title)) score += 4;
if (hasUsableArtist(meta.artist)) score += 5;
if (Number(meta.durationMs) > 0) score += 2;
if (String(meta.art || '').trim()) score += 1;
return score;
}
function isStrongTrackMeta(meta) {
if (!meta) return false;
return hasUsableTitle(meta.title) && hasUsableArtist(meta.artist);
}
function chooseMetaField(primaryValue, fallbackValue, isUsable) {
const primary = String(primaryValue || '').trim();
const fallback = String(fallbackValue || '').trim();
if (isUsable(primary)) return primary;
if (isUsable(fallback)) return fallback;
return primary || fallback;
}
function chooseBetterTrackMeta(current, candidate) {
if (!current) return candidate || null;
if (!candidate) return current;
const currentQuality = getTrackMetaQuality(current);
const candidateQuality = getTrackMetaQuality(candidate);
if (candidateQuality > currentQuality) {
return mergeTrackMeta(candidate, current);
}
return mergeTrackMeta(current, candidate);
}
async function resolveTrackMetaForUri(uri) {
const key = String(uri || '').trim();
if (!key) return null;
// Non-Spotify URIs cannot be resolved via Spotify APIs — their metadata
// is populated by platform-specific fetchers in buildQueue* functions.
if (!key.startsWith('spotify:')) return null;
const cached = state.metaCache.has(key) ? state.metaCache.get(key) : null;
if (cached && isStrongTrackMeta(cached)) {
return cached;
}
if (cached && !isStrongTrackMeta(cached)) {
const retryAt = Number(state.metaRetryAt.get(key) || 0);
if (Date.now() < retryAt) {
return cached;
}
state.metaRetryAt.set(key, Date.now() + WEAK_META_RETRY_MS);
}
const debug = [];
let resolved = cached ? mergeTrackMeta(cached, null) : null;
const fromApi = await fetchTrackMetaFromSpotifyApi(key);
if (fromApi) {
resolved = chooseBetterTrackMeta(resolved, fromApi);
}
debug.push({ source: 'spotify_api', ok: Boolean(fromApi), quality: getTrackMetaQuality(fromApi) });
if (!isStrongTrackMeta(resolved) || !Number(resolved && resolved.durationMs)) {
const fromPage = await fetchTrackMetaFromOpenPage(key);
if (fromPage) {
resolved = chooseBetterTrackMeta(resolved, fromPage);
}
debug.push({ source: 'open_page', ok: Boolean(fromPage), quality: getTrackMetaQuality(fromPage) });
}
if (!isStrongTrackMeta(resolved) || !Number(resolved && resolved.durationMs)) {
const fromEmbed = await fetchTrackMetaFromEmbedPage(key);
if (fromEmbed) {
resolved = chooseBetterTrackMeta(resolved, fromEmbed);
}
debug.push({ source: 'embed_page', ok: Boolean(fromEmbed), quality: getTrackMetaQuality(fromEmbed) });
}
if (!resolved || !hasUsableTitle(resolved.title) || !String(resolved.art || '').trim()) {
const fromOEmbed = await fetchTrackMetaFromOEmbed(key);
if (fromOEmbed) {
resolved = chooseBetterTrackMeta(resolved, fromOEmbed);
}
debug.push({ source: 'oembed', ok: Boolean(fromOEmbed), quality: getTrackMetaQuality(fromOEmbed) });
}
if (!resolved) {
resolved = {
uri: key,
title: 'Spotify',
artist: '',
art: '',
durationMs: 0,
openUrl: spotifyUriToOpenUrl(key),
};
}
resolved = mergeTrackMeta(resolved, {
uri: key,
openUrl: spotifyUriToOpenUrl(key),
});
if (!cached || getTrackMetaQuality(resolved) >= getTrackMetaQuality(cached)) {
state.metaCache.set(key, resolved);
} else {
resolved = cached;
}
if (isStrongTrackMeta(resolved)) {
state.metaRetryAt.delete(key);
}
return resolved;
}
function mergeTrackMeta(primary, fallback) {
const first = primary || {};
const second = fallback || {};
return {
uri: String(first.uri || second.uri || ''),
title: chooseMetaField(first.title, second.title, hasUsableTitle) || 'Spotify',
artist: chooseMetaField(first.artist, second.artist, hasUsableArtist),
art: chooseMetaField(first.art, second.art, (value) => Boolean(String(value || '').trim())),
durationMs: Number(first.durationMs) || Number(second.durationMs) || 0,
openUrl: String(first.openUrl || second.openUrl || ''),
};
}
async function fetchTrackMetaFromSpotifyApi(trackUri) {
const parsed = parseSpotifyInput(trackUri);
if (!parsed || parsed.type !== 'track') return null;
const payload = await spotifyApiGet(
`https://api.spotify.com/v1/tracks/${encodeURIComponent(parsed.id)}?market=from_token`
);
if (!payload) return null;
const parsedTrack = parseSpotifyApiTrack(payload);
if (!parsedTrack) return null;
return {
uri: parsedTrack.uri,
title: parsedTrack.title,
artist: parsedTrack.artist,
art: parsedTrack.art,
durationMs: parsedTrack.durationMs,
openUrl: parsedTrack.openUrl,
};
}
async function fetchTrackMetaFromOEmbed(trackUri) {
const openUrl = spotifyUriToOpenUrl(trackUri);
if (!openUrl) return null;
try {
const response = await gmRequest({
method: 'GET',
url: `${OEMBED_BASE}${encodeURIComponent(openUrl)}`,
headers: {
Accept: 'application/json',
},
});
if (response.status < 200 || response.status >= 300) return null;
const data = safeJsonParse(response.responseText) || {};
const parsedTitle = parseOembedTitle(String(data.title || ''));
const parsedArtist = chooseMetaField(parsedTitle.artist, String(data.author_name || ''), hasUsableArtist);
return {
uri: String(trackUri),
title: parsedTitle.title || 'Spotify',
artist: parsedArtist || '',
art: String(data.thumbnail_url || ''),
durationMs: 0,
openUrl,
};
} catch (error) {
return null;
}
}
async function fetchTrackMetaFromOpenPage(trackUri) {
const openUrl = spotifyUriToOpenUrl(trackUri);
if (!openUrl) return null;
try {
const response = await gmRequest({
method: 'GET',
url: openUrl,
headers: {
Accept: 'text/html',
},
});
if (response.status < 200 || response.status >= 300) return null;
const html = String(response.responseText || '');
return parseSpotifyTrackMetaFromHtml(html, trackUri, openUrl);
} catch (error) {
return null;
}
}
async function fetchTrackMetaFromEmbedPage(trackUri) {
const parsed = parseSpotifyInput(trackUri);
if (!parsed || parsed.type !== 'track') return null;
const embedUrl = `https://open.spotify.com/embed/track/${parsed.id}`;
try {
const response = await gmRequest({
method: 'GET',
url: embedUrl,
headers: {
Accept: 'text/html',
},
timeout: 12000,
});
if (response.status < 200 || response.status >= 300) return null;
const html = String(response.responseText || '');
const payloads = extractSpotifyStatePayloads(html);
let best = null;
for (let i = 0; i < payloads.length; i += 1) {
const decoded = decodeSpotifyStatePayload(payloads[i]);
if (!decoded) continue;
const parsedTracks = extractTracksFromStateObject(decoded);
for (let t = 0; t < parsedTracks.length; t += 1) {
const candidate = parsedTracks[t];
if (!candidate || String(candidate.uri || '').trim() !== trackUri) continue;
best = chooseBetterTrackMeta(best, candidate);
}
}
if (!best) {
const fallbackTracks = extractTrackLinksFromEmbedHtml(html);
const fallback = fallbackTracks.find((entry) => String(entry.uri || '').trim() === trackUri) || null;
best = chooseBetterTrackMeta(best, fallback);
}
if (!best) return null;
return mergeTrackMeta(best, {
uri: trackUri,
openUrl: spotifyUriToOpenUrl(trackUri),
});
} catch (error) {
return null;
}
}
function parseSpotifyTrackMetaFromHtml(html, trackUri, openUrl) {
if (!html) return null;
try {
const doc = new DOMParser().parseFromString(String(html), 'text/html');
const ogTitle = getMetaContent(doc, 'og:title', true);
const ogDescription = getMetaContent(doc, 'og:description', true);
const description = getMetaContent(doc, 'description', false);
const musicDuration = Number(getMetaContent(doc, 'music:duration', false));
const ogImage = getMetaContent(doc, 'og:image', true);
const titleTag = String(doc.title || '').trim();
const parsedFromDescription = parseArtistTitleFromSpotifyDescription(ogDescription || description);
const parsedFromTitle = parseSpotifyDocumentTitle(titleTag);
const title = firstNonEmptyString([
ogTitle,
parsedFromDescription && parsedFromDescription.title,
parsedFromTitle && parsedFromTitle.title,
]) || 'Spotify';
const artist = firstNonEmptyString([
parsedFromDescription && parsedFromDescription.artist,
parsedFromTitle && parsedFromTitle.artist,
]) || '';
return {
uri: String(trackUri || ''),
title,
artist,
art: String(ogImage || ''),
durationMs: Number.isFinite(musicDuration) && musicDuration > 0 ? musicDuration * 1000 : 0,
openUrl: String(openUrl || ''),
};
} catch (error) {
return null;
}
}
function parseOembedTitle(rawTitle) {
const cleaned = String(rawTitle || '').replace(/\s*\|\s*Spotify\s*$/i, '').trim();
const songMatch = cleaned.match(/^(.*?)\s*-\s*song and lyrics by\s*(.*)$/i);
if (songMatch) {
return {
title: songMatch[1].trim(),
artist: songMatch[2].trim(),
};
}
const byMatch = cleaned.match(/^(.*?)\s+by\s+(.*)$/i);
if (byMatch) {
return {
title: byMatch[1].trim(),
artist: byMatch[2].trim(),
};
}
return {
title: cleaned,
artist: '',
};
}
function parseArtistTitleFromSpotifyDescription(text) {
const raw = String(text || '').trim();
if (!raw) return null;
const parts = raw.split(/\s*·\s*/).map((part) => part.trim()).filter(Boolean);
if (parts.length >= 2) {
return {
artist: parts[0],
title: parts[1],
};
}
const listenSongMatch = raw.match(/^Listen to (.+?) on Spotify\.\s*Song\s*[·-]\s*(.+?)(?:\s*[·-]|$)/i);
if (listenSongMatch) {
return {
title: listenSongMatch[1].trim(),
artist: listenSongMatch[2].trim(),
};
}
const commaSongMatch = raw.match(/^(.+?),\s*a\s+song\s+by\s+(.+?)\s+on Spotify/i);
if (commaSongMatch) {
return {
title: commaSongMatch[1].trim(),
artist: commaSongMatch[2].trim(),
};
}
return null;
}
function parseSpotifyDocumentTitle(text) {
const raw = String(text || '').trim();
if (!raw) return null;
const stripped = raw.replace(/\s*\|\s*Spotify\s*$/i, '').trim();
const match = stripped.match(/^(.*?)\s*-\s*song and lyrics by\s*(.*?)$/i);
if (match) {
return {
title: match[1].trim(),
artist: match[2].trim(),
};
}
const genericByMatch = stripped.match(/^(.*?)\s*-\s*(?:song|single|album|playlist)\s+by\s+(.*?)$/i);
if (genericByMatch) {
return {
title: genericByMatch[1].trim(),
artist: genericByMatch[2].trim(),
};
}
return null;
}
function getMetaContent(doc, key, isProperty) {
if (!doc || !key) return '';
const selector = isProperty
? `meta[property="${cssEscapeForQuery(key)}"]`
: `meta[name="${cssEscapeForQuery(key)}"]`;
const node = doc.querySelector(selector);
if (!node) return '';
return String(node.getAttribute('content') || '').trim();
}
function cssEscapeForQuery(value) {
return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
}
async function fetchSpotifyTrackList(type, id, sourceUri) {
const cacheKey = `${sourceUri}::tracks`;
if (state.sourceTrackListCache.has(cacheKey)) {
return state.sourceTrackListCache.get(cacheKey);
}
let tracks = await fetchSpotifyTrackListFromApi(type, id, sourceUri);
// Middle fallback: scrape the full open.spotify.com page (may have more
// tracks than the embed page for some playlists)
if (!tracks.length) {
tracks = await fetchSpotifyTrackListFromOpenPage(type, id, sourceUri);
}
if (!tracks.length) {
tracks = await fetchSpotifyTrackListFromEmbedPage(type, id, sourceUri);
}
if (tracks.length) {
state.sourceTrackListCache.set(cacheKey, tracks);
}
if (tracks.length >= 100 && state.spotifyIsAnonymous && type === 'playlist') {
window.setTimeout(function () {
toast('Loaded ' + tracks.length + ' tracks. Log in to open.spotify.com for full playlist access.');
}, 2000);
}
return tracks;
}
async function fetchSpotifyTrackListFromApi(type, id, sourceUri) {
const tracks = [];
if (type === 'track') {
const meta = await resolveTrackMetaForUri(`spotify:track:${id}`);
if (!meta) return [];
return [
{
uri: meta.uri,
title: meta.title,
artist: meta.artist,
art: meta.art,
durationMs: meta.durationMs,
openUrl: meta.openUrl,
sourceUri,
},
];
}
if (type === 'playlist') {
let nextUrl = `https://api.spotify.com/v1/playlists/${encodeURIComponent(id)}/tracks?market=from_token&limit=100&offset=0`;
let safety = 0;
while (nextUrl && safety < 60) {
const payload = await spotifyApiGet(nextUrl);
if (!payload) break;
const items = Array.isArray(payload.items) ? payload.items : [];
items.forEach((item) => {
const parsedTrack = parseSpotifyApiTrack(item && item.track ? item.track : item);
if (!parsedTrack) return;
tracks.push({
uri: parsedTrack.uri,
title: parsedTrack.title,
artist: parsedTrack.artist,
art: parsedTrack.art,
durationMs: parsedTrack.durationMs,
openUrl: parsedTrack.openUrl,
sourceUri,
});
});
nextUrl = typeof payload.next === 'string' ? payload.next : '';
safety += 1;
}
return dedupeTracksByUri(tracks);
}
if (type === 'album') {
const albumMeta = await spotifyApiGet(
`https://api.spotify.com/v1/albums/${encodeURIComponent(id)}?market=from_token&fields=images,tracks.items(uri,name,duration_ms,artists(name)),tracks.next`
);
if (!albumMeta) return [];
const albumArt = pickImageUrl(albumMeta.images);
const addItems = (items) => {
if (!Array.isArray(items)) return;
items.forEach((item) => {
const parsedTrack = parseSpotifyApiTrack(item, albumArt);
if (!parsedTrack) return;
tracks.push({
uri: parsedTrack.uri,
title: parsedTrack.title,
artist: parsedTrack.artist,
art: parsedTrack.art,
durationMs: parsedTrack.durationMs,
openUrl: parsedTrack.openUrl,
sourceUri,
});
});
};
addItems(albumMeta.tracks && albumMeta.tracks.items);
let nextUrl = albumMeta.tracks && typeof albumMeta.tracks.next === 'string' ? albumMeta.tracks.next : '';
let safety = 0;
while (nextUrl && safety < 60) {
const payload = await spotifyApiGet(nextUrl);
if (!payload) break;
addItems(payload.items);
nextUrl = typeof payload.next === 'string' ? payload.next : '';
safety += 1;
}
return dedupeTracksByUri(tracks);
}
return [];
}
async function fetchSpotifyTrackListFromOpenPage(type, id, sourceUri) {
const openUrl = `https://open.spotify.com/${type}/${id}`;
try {
const response = await gmRequest({
method: 'GET',
url: openUrl,
headers: {
Accept: 'text/html',
},
timeout: 15000,
});
if (response.status < 200 || response.status >= 300) return [];
const html = String(response.responseText || '');
const payloads = extractSpotifyStatePayloads(html);
let parsedTracks = [];
for (let i = 0; i < payloads.length; i += 1) {
const decoded = decodeSpotifyStatePayload(payloads[i]);
if (!decoded) continue;
parsedTracks = extractTracksFromStateObject(decoded);
if (parsedTracks.length) break;
}
if (!parsedTracks.length) {
parsedTracks = extractTrackLinksFromEmbedHtml(html);
}
const tracks = parsedTracks.map((track) => ({
uri: track.uri,
title: String(track.title || 'Spotify'),
artist: String(track.artist || ''),
art: String(track.art || ''),
durationMs: Number(track.durationMs) || 0,
openUrl: String(track.openUrl || spotifyUriToOpenUrl(track.uri)),
sourceUri,
}));
return dedupeTracksByUri(tracks);
} catch (error) {
return [];
}
}
async function fetchSpotifyTrackListFromEmbedPage(type, id, sourceUri) {
const embedUrl = `https://open.spotify.com/embed/${type}/${id}`;
try {
const response = await gmRequest({
method: 'GET',
url: embedUrl,
headers: {
Accept: 'text/html',
},
timeout: 12000,
});
if (response.status < 200 || response.status >= 300) return [];
const html = String(response.responseText || '');
const payloads = extractSpotifyStatePayloads(html);
let parsedTracks = [];
for (let i = 0; i < payloads.length; i += 1) {
const payload = payloads[i];
const decoded = decodeSpotifyStatePayload(payload);
if (!decoded) continue;
parsedTracks = extractTracksFromStateObject(decoded);
if (parsedTracks.length) break;
}
if (!parsedTracks.length) {
parsedTracks = extractTrackLinksFromEmbedHtml(html);
}
const tracks = parsedTracks.map((track) => ({
uri: track.uri,
title: String(track.title || 'Spotify'),
artist: String(track.artist || ''),
art: String(track.art || ''),
durationMs: Number(track.durationMs) || 0,
openUrl: String(track.openUrl || spotifyUriToOpenUrl(track.uri)),
sourceUri,
}));
return dedupeTracksByUri(tracks);
} catch (error) {
return [];
}
}
function dedupeTracksByUri(tracks) {
if (!Array.isArray(tracks)) return [];
const seen = new Set();
const result = [];
tracks.forEach((track) => {
if (!track || !track.uri) return;
if (seen.has(track.uri)) return;
seen.add(track.uri);
result.push(track);
});
return result;
}
function extractSpotifyStatePayloads(html) {
const payloads = [];
try {
const doc = new DOMParser().parseFromString(String(html || ''), 'text/html');
const directSelectors = [
'script#__NEXT_DATA__',
'script#initial-state',
'script#initialState',
'script[data-testid="initial-state"]',
'script#appServerState',
'script#__APP_STATE__',
];
directSelectors.forEach((selector) => {
const node = doc.querySelector(selector);
if (!node || !node.textContent) return;
const payload = String(node.textContent || '').trim();
if (payload) payloads.push(payload);
});
const scriptNodes = doc.querySelectorAll('script[data-type="resource"], script');
scriptNodes.forEach((node) => {
const content = String((node && node.textContent) || '').trim();
if (!content) return;
if (/__NEXT_DATA__|"spotify:track:"|%22spotify%3Atrack%3A|"trackList"|"items"/i.test(content)) {
payloads.push(content);
}
});
} catch (error) {
// noop
}
return dedupeStrings(payloads);
}
function decodeSpotifyStatePayload(payload) {
const raw = String(payload || '').trim();
if (!raw) return null;
const direct = safeJsonParse(raw);
if (direct) return direct;
const extractedJson = extractJsonObjectFromText(raw);
if (extractedJson) {
const parsedExtracted = safeJsonParse(extractedJson);
if (parsedExtracted) return parsedExtracted;
}
const decodedBase64 = decodeBase64ToText(raw);
if (decodedBase64) {
const parsedBase64 = safeJsonParse(decodedBase64);
if (parsedBase64) return parsedBase64;
const utf8Decoded = decodeLatin1AsUtf8(decodedBase64);
if (utf8Decoded) {
const parsedUtf8 = safeJsonParse(utf8Decoded);
if (parsedUtf8) return parsedUtf8;
}
}
const urlDecoded = tryDecodeURIComponent(raw);
if (urlDecoded && urlDecoded !== raw) {
const parsedUrlDecoded = safeJsonParse(urlDecoded);
if (parsedUrlDecoded) return parsedUrlDecoded;
const parsedUrlExtracted = safeJsonParse(extractJsonObjectFromText(urlDecoded));
if (parsedUrlExtracted) return parsedUrlExtracted;
}
return null;
}
function tryDecodeURIComponent(value) {
const raw = String(value || '');
if (!raw) return '';
try {
return decodeURIComponent(raw);
} catch (error) {
return raw;
}
}
function extractJsonObjectFromText(text) {
const source = String(text || '');
if (!source) return '';
const firstBrace = source.indexOf('{');
if (firstBrace === -1) return '';
const block = readBalancedJsonObject(source, firstBrace);
return block ? block.json : '';
}
function decodeBase64ToText(input) {
const normalized = String(input || '')
.replace(/[\r\n\s]/g, '')
.replace(/-/g, '+')
.replace(/_/g, '/');
if (!normalized) return '';
let padded = normalized;
if (padded.length % 4) {
padded = `${padded}${'='.repeat(4 - (padded.length % 4))}`;
}
try {
return atob(padded);
} catch (error) {
return '';
}
}
function decodeLatin1AsUtf8(text) {
const raw = String(text || '');
if (!raw) return '';
try {
let encoded = '';
for (let i = 0; i < raw.length; i += 1) {
encoded += `%${raw.charCodeAt(i).toString(16).padStart(2, '0')}`;
}
return decodeURIComponent(encoded);
} catch (error) {
return '';
}
}
function extractTracksFromStateObject(root) {
if (!root || typeof root !== 'object') return [];
const seenObjects = new Set();
const stack = [root];
const tracksByUri = new Map();
let safety = 0;
while (stack.length && safety < 200000) {
const value = stack.pop();
safety += 1;
if (!value || typeof value !== 'object') continue;
if (seenObjects.has(value)) continue;
seenObjects.add(value);
const parsedTrack = parseTrackLikeObject(value);
if (parsedTrack && !tracksByUri.has(parsedTrack.uri)) {
tracksByUri.set(parsedTrack.uri, parsedTrack);
}
if (Array.isArray(value)) {
for (let i = value.length - 1; i >= 0; i -= 1) {
const child = value[i];
if (child && typeof child === 'object') stack.push(child);
}
continue;
}
const keys = Object.keys(value);
for (let i = keys.length - 1; i >= 0; i -= 1) {
const child = value[keys[i]];
if (child && typeof child === 'object') stack.push(child);
}
}
return Array.from(tracksByUri.values());
}
function parseTrackLikeObject(value) {
if (!value || typeof value !== 'object') return null;
const uri = String(value.uri || '').trim();
if (!/^spotify:track:[A-Za-z0-9]{22}$/.test(uri)) return null;
const title = firstNonEmptyString([
value.name,
value.title,
value.track_name,
value.trackName,
value.track_title,
]) || 'Spotify';
const artist = extractArtistText(value);
const art = pickImageUrl(
value.images ||
(value.coverArt && value.coverArt.sources) ||
(value.album && value.album.images) ||
(value.albumOfTrack && value.albumOfTrack.coverArt && value.albumOfTrack.coverArt.sources) ||
(value.visuals && value.visuals.coverArt && value.visuals.coverArt.sources)
);
return {
uri,
title,
artist,
art,
durationMs: readDurationMs(value),
openUrl: spotifyUriToOpenUrl(uri),
};
}
function parseSpotifyApiTrack(track, fallbackArt) {
if (!track || typeof track !== 'object') return null;
const uri = String(track.uri || '').trim();
if (!/^spotify:track:[A-Za-z0-9]{22}$/.test(uri)) return null;
return {
uri,
title: firstNonEmptyString([track.name, track.title]) || 'Spotify',
artist: extractArtistText(track),
art: pickImageUrl((track.album && track.album.images) || track.images) || String(fallbackArt || ''),
durationMs: readDurationMs(track),
openUrl: spotifyUriToOpenUrl(uri),
};
}
function extractTrackLinksFromEmbedHtml(html) {
const source = String(html || '');
if (!source) return [];
const tracks = [];
const seen = new Set();
const re = /spotify:track:([A-Za-z0-9]{22})/g;
let match;
while ((match = re.exec(source))) {
const uri = `spotify:track:${match[1]}`;
if (seen.has(uri)) continue;
seen.add(uri);
tracks.push({
uri,
title: 'Spotify',
artist: '',
art: '',
durationMs: 0,
openUrl: spotifyUriToOpenUrl(uri),
});
}
return tracks;
}
function extractArtistText(value) {
if (!value || typeof value !== 'object') return '';
const artists = [];
const list = [];
const seen = new Set();
const addArtist = (raw) => {
const text = String(raw || '').trim();
if (!text) return;
const normalized = text
.replace(/\s*[·|].*$/, '')
.replace(/\s*\([^)]*\)\s*$/, '')
.trim();
if (!normalized) return;
const key = normalized.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
artists.push(normalized);
};
if (Array.isArray(value.artists)) {
list.push(...value.artists);
} else if (value.artists && typeof value.artists === 'object') {
if (Array.isArray(value.artists.items)) list.push(...value.artists.items);
if (Array.isArray(value.artists.nodes)) list.push(...value.artists.nodes);
if (Array.isArray(value.artists.edges)) {
value.artists.edges.forEach((edge) => {
if (edge && edge.node) list.push(edge.node);
});
}
}
if (Array.isArray(value.artist)) {
list.push(...value.artist);
} else if (value.artist && typeof value.artist === 'object') {
if (Array.isArray(value.artist.items)) list.push(...value.artist.items);
if (value.artist.profile) list.push(value.artist);
}
list.forEach((entry) => {
if (!entry) return;
if (typeof entry === 'string') {
addArtist(entry);
return;
}
if (typeof entry.name === 'string' && entry.name.trim()) {
addArtist(entry.name);
return;
}
const profileName = entry.profile && typeof entry.profile.name === 'string'
? entry.profile.name.trim()
: '';
if (profileName) addArtist(profileName);
});
if (!artists.length && typeof value.artist === 'string' && value.artist.trim()) {
addArtist(value.artist);
}
const directArtistFields = [
value.artistName,
value.artist_name,
value.subtitle,
value.byline,
value.ownerName,
];
for (let i = 0; i < directArtistFields.length; i += 1) {
if (artists.length) break;
addArtist(directArtistFields[i]);
}
if (!artists.length && value.album && typeof value.album === 'object') {
const albumArtist = extractArtistText(value.album);
if (albumArtist) addArtist(albumArtist);
}
return artists.join(', ');
}
async function hydrateQueueEntryMetadata(entry) {
if (!entry || !entry.spotifyUri) return;
const needsTitle = !hasUsableTitle(entry.title);
const needsArtist = !hasUsableArtist(entry.artist);
const needsArt = !String(entry.art || '').trim();
const needsDuration = !Number(entry.durationMs);
if (!needsTitle && !needsArtist && !needsArt && !needsDuration) return;
const meta = await resolveTrackMetaForUri(entry.spotifyUri);
if (!meta) return;
if (needsTitle && hasUsableTitle(meta.title)) entry.title = meta.title;
if (needsArtist && hasUsableArtist(meta.artist)) entry.artist = meta.artist;
if (needsArt && meta.art) entry.art = meta.art;
if (needsDuration && meta.durationMs) entry.durationMs = meta.durationMs;
if (!entry.openUrl && meta.openUrl) entry.openUrl = meta.openUrl;
}
function readDurationMs(value) {
if (!value || typeof value !== 'object') return 0;
const direct = [
value.duration_ms,
value.durationMs,
value.trackDurationMs,
value.lengthMs,
];
for (let i = 0; i < direct.length; i += 1) {
const parsed = Number(direct[i]);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
const duration = value.duration;
if (typeof duration === 'number' && Number.isFinite(duration) && duration > 0) {
return duration;
}
if (duration && typeof duration === 'object') {
const nested = [
duration.totalMilliseconds,
duration.milliseconds,
duration.ms,
duration.total,
];
for (let i = 0; i < nested.length; i += 1) {
const parsed = Number(nested[i]);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
}
return 0;
}
function pickImageUrl(images) {
if (!images) return '';
if (typeof images === 'string') return images;
if (Array.isArray(images)) {
for (let i = 0; i < images.length; i += 1) {
const entry = images[i];
if (!entry) continue;
if (typeof entry === 'string' && entry) return entry;
if (typeof entry.url === 'string' && entry.url) return entry.url;
if (entry.sources) {
const nested = pickImageUrl(entry.sources);
if (nested) return nested;
}
}
return '';
}
if (typeof images === 'object') {
if (typeof images.url === 'string' && images.url) return images.url;
if (images.sources) return pickImageUrl(images.sources);
}
return '';
}
function firstNonEmptyString(values) {
if (!Array.isArray(values)) return '';
for (let i = 0; i < values.length; i += 1) {
const candidate = values[i];
if (typeof candidate !== 'string') continue;
const trimmed = candidate.trim();
if (trimmed) return trimmed;
}
return '';
}
async function getSpotifyAccessToken(forceRefresh) {
if (
!forceRefresh &&
state.spotifyAccessToken &&
Date.now() < Number(state.spotifyAccessTokenExpiresAt || 0) - 15000
) {
return state.spotifyAccessToken;
}
try {
const response = await gmRequest({
method: 'GET',
url: 'https://open.spotify.com/get_access_token?reason=transport&productType=web_player',
headers: {
Accept: 'application/json',
},
});
if (response.status < 200 || response.status >= 300) return '';
const payload = safeJsonParse(response.responseText) || {};
const token = String(payload.accessToken || '').trim();
state.spotifyIsAnonymous = Boolean(payload.isAnonymous);
state.spotifyIsPremium = Boolean(payload.isPremium);
if (!token) return '';
const expiresAt = Number(payload.accessTokenExpirationTimestampMs || 0);
state.spotifyAccessToken = token;
state.spotifyAccessTokenExpiresAt = Number.isFinite(expiresAt) && expiresAt > 0
? expiresAt
: Date.now() + 50 * 60 * 1000;
return token;
} catch (error) {
return '';
}
}
async function spotifyApiGet(url) {
let token = await getSpotifyAccessToken(false);
if (!token) return null;
let response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
timeout: 12000,
});
if (response.status === 401) {
token = await getSpotifyAccessToken(true);
if (!token) return null;
response = await gmRequest({
method: 'GET',
url,
headers: {
Accept: 'application/json',
Authorization: `Bearer ${token}`,
},
timeout: 12000,
});
}
if (response.status < 200 || response.status >= 300) return null;
return safeJsonParse(response.responseText);
}
function installEmbedApiHook() {
injectEmbedApiBridge();
window.addEventListener(EMBED_READY_EVENT, onEmbedReadyEvent);
window.addEventListener(EMBED_ERROR_EVENT, onEmbedErrorEvent);
const pageWindow = getPageWindow();
try {
if (pageWindow.__ntSpotifyIframeApi) {
onEmbedApiReady(pageWindow.__ntSpotifyIframeApi);
}
} catch (error) {
// noop
}
}
function loadEmbedApiScript() {
if (state.embedApiLoadStarted) return;
state.embedApiLoadStarted = true;
if (document.querySelector('script[data-nt-spotify-embed-api="1"]')) return;
const script = document.createElement('script');
script.async = true;
script.src = EMBED_API_SRC;
script.dataset.ntSpotifyEmbedApi = '1';
script.onerror = () => {
state.embedApiLoadFailed = true;
failEmbedApiWaiters(new Error('Spotify embed API failed to load'));
window.dispatchEvent(new CustomEvent(EMBED_ERROR_EVENT));
};
document.body.appendChild(script);
}
function waitForEmbedApi(timeoutMs) {
if (state.embedApi) return Promise.resolve(state.embedApi);
if (state.embedApiLoadFailed) return Promise.reject(new Error('Spotify embed API unavailable'));
loadEmbedApiScript();
return new Promise((resolve, reject) => {
let settled = false;
const done = (api) => {
if (settled) return;
settled = true;
resolve(api);
};
const timer = window.setTimeout(() => {
if (settled) return;
settled = true;
state.embedWaiters = state.embedWaiters.filter((fn) => fn !== wrapped);
reject(new Error('Spotify embed API timeout'));
}, timeoutMs || 12000);
const wrapped = (api) => {
window.clearTimeout(timer);
done(api);
};
state.embedWaiters.push(wrapped);
});
}
function injectEmbedApiBridge() {
const pageWindow = getPageWindow();
if (pageWindow.__ntSpotifyEmbedBridgeInstalled) return;
pageWindow.__ntSpotifyEmbedBridgeInstalled = true;
const previous = pageWindow.onSpotifyIframeApiReady;
pageWindow.onSpotifyIframeApiReady = function (IFrameAPI) {
pageWindow.__ntSpotifyIframeApi = IFrameAPI;
window.dispatchEvent(new CustomEvent(EMBED_READY_EVENT));
if (typeof previous === 'function') {
try { previous(IFrameAPI); } catch (err) { }
}
};
}
function onEmbedReadyEvent() {
const pageWindow = getPageWindow();
try {
if (pageWindow.__ntSpotifyIframeApi) {
onEmbedApiReady(pageWindow.__ntSpotifyIframeApi);
}
} catch (error) {
// noop
}
}
function onEmbedErrorEvent() {
state.embedApiLoadFailed = true;
}
function onEmbedApiReady(api) {
if (!api) return;
state.embedApi = api;
state.embedApiLoadFailed = false;
const waiters = state.embedWaiters.splice(0, state.embedWaiters.length);
waiters.forEach((resolve) => {
try {
resolve(api);
} catch (error) {
// noop
}
});
}
function failEmbedApiWaiters(error) {
const waiters = state.embedWaiters.splice(0, state.embedWaiters.length);
waiters.forEach((waiterFn) => {
try {
// Pass null to signal failure — callers check for null/falsy api
waiterFn(null);
} catch (err) {
// noop
}
});
}
function getActivePlaybackEngine() {
return state.spotifyIsPremium ? 'embed' : 'yt';
}
function createEmbedController(api, uri) {
return new Promise((resolve, reject) => {
try {
api.createController(
state.embedHost,
{
uri,
width: 320,
height: 80,
},
(controller) => {
if (!controller || typeof controller.addListener !== 'function') {
reject(new Error('Spotify embed controller unavailable'));
return;
}
state.embedController = controller;
wireControllerEvents(controller);
resolve(controller);
}
);
} catch (error) {
reject(error);
}
});
}
function ensureEmbedHost() {
if (state.embedHost && document.body.contains(state.embedHost)) return;
const host = document.createElement('div');
host.id = 'nt-spotify-embed-host';
host.style.cssText = [
'position: fixed',
'right: -5000px',
'bottom: -5000px',
'width: 320px',
'height: 80px',
'overflow: hidden',
'pointer-events: none',
'z-index: -1',
].join(';');
document.body.appendChild(host);
state.embedHost = host;
}
async function updateTrackMetaFromUri(uri) {
if (!uri) return;
try {
const meta = await resolveTrackMetaForUri(uri);
if (meta) {
state.trackMeta = chooseBetterTrackMeta(state.trackMeta, meta);
if (state.active) renderNowPlaying();
}
} catch (err) {
// noop — best-effort metadata update
}
}
function wireControllerEvents(controller) {
if (!controller || typeof controller.addListener !== 'function') return;
controller.addListener('playback_started', (event) => {
if (getActivePlaybackEngine() !== 'embed') return;
const data = event && event.data ? event.data : null;
if (!data) return;
if (data.playingURI) {
state.playback.uri = data.playingURI;
void updateTrackMetaFromUri(data.playingURI);
}
state.playback.isPaused = false;
state.playback.updatedAt = Date.now();
if (state.active) renderNowPlaying();
});
controller.addListener('playback_update', (event) => {
if (getActivePlaybackEngine() !== 'embed') return;
const data = event && event.data ? event.data : null;
if (!data) return;
state.playback.uri = data.playingURI || state.playback.uri;
state.playback.position = Number(data.position) || 0;
state.playback.duration = Number(data.duration) || 0;
state.playback.isPaused = Boolean(data.isPaused);
state.playback.isBuffering = Boolean(data.isBuffering);
state.playback.updatedAt = Date.now();
if (state.playback.uri) {
if (!state.trackMeta || state.trackMeta.uri !== state.playback.uri) {
void updateTrackMetaFromUri(state.playback.uri);
}
}
if (state.active) renderNowPlaying();
});
}
async function safeControllerCall(methodName, arg) {
const controller = state.embedController;
if (!controller) return false;
const fn = controller[methodName];
if (typeof fn !== 'function') return false;
try {
const result = arg === undefined ? fn.call(controller) : fn.call(controller, arg);
if (result && typeof result.then === 'function') {
await result.catch(() => { });
}
return true;
} catch (error) {
return false;
}
}
function startFrameWatcher() {
syncFrameBinding();
stopFrameWatcher();
state.frameWatchTimer = window.setInterval(syncFrameBinding, FRAME_WATCH_TICK_MS);
}
function ensureRootBindings(doc) {
if (!doc || doc.__ntSpotifyRootBound) return;
doc.documentElement.addEventListener('click', handleGlobalClick, true);
doc.documentElement.addEventListener('pointerdown', handleGlobalClick, true);
doc.documentElement.addEventListener('mousedown', handleGlobalClick, true);
doc.documentElement.addEventListener('touchstart', handleGlobalClick, true);
doc.__ntSpotifyRootBound = true;
}
function clampSpotifyOutputVolume(value) {
const parsed = Number(value);
if (!Number.isFinite(parsed)) return 1;
return Math.min(1, Math.max(0.2, parsed));
}
function clearAutoActivateTimer() {
if (state.autoActivateTimer) {
window.clearTimeout(state.autoActivateTimer);
state.autoActivateTimer = null;
}
}
function isSpotifyControlVisuallyActive() {
return state.active ? !state.playback.isPaused : state.pendingSelection;
}
function isInlineEditorOpen(transport) {
return Boolean(transport && transport.dataset.editorOpen === '1');
}
async function activateSpotifyFromSavedSourceOrEditor() {
if (getSavedSourceParsed()) {
await activateSpotifyMode({ preferSavedOnly: true });
return;
}
openSpotifyInputEditor(state.frameDoc, { showToast: false });
}
function getNativeMusicControls(doc) {
if (!doc) return {};
const option = doc.querySelector('.race-header-controls--option.option-music');
const sfxOption = doc.querySelector('.race-header-controls--option.option-sfx');
const volumeRoot = option ? option.querySelector('.race-header-controls--volume-control') : null;
const volumeInput = volumeRoot ? volumeRoot.querySelector('input[type="range"]') : null;
const sfxVolumeRoot = sfxOption ? sfxOption.querySelector('.race-header-controls--volume-control') : null;
const sfxVolumeInput = sfxVolumeRoot ? sfxVolumeRoot.querySelector('input[type="range"]') : null;
return {
option,
optionLabel: option ? option.querySelector('.race-header-controls--option-label span') : null,
sfxOption,
sfxLabel: sfxOption ? sfxOption.querySelector('.race-header-controls--option-label span') : null,
sfxVolumeRoot,
sfxVolumeInput,
volumeRoot,
volumeInput,
currentIndicator: doc.querySelector('.music-selector--current > .music-selector--indicator'),
currentLabel: doc.querySelector('.music-selector--current > .music-selector--indicator + div'),
selectedNative: doc.querySelector('.music-selector--list--item.selected:not(.nt-spotify-item)'),
proxyRoot: option ? option.querySelector('.nt-spotify-native-music-proxy') : null,
proxyButton: option ? option.querySelector('.nt-spotify-native-music-proxy-hitbox') : null,
proxyVolumeRoot: option ? option.querySelector('.nt-spotify-native-music-proxy-volume') : null,
proxyVolumeInput: option ? option.querySelector('.nt-spotify-native-music-proxy-volume input[type="range"]') : null,
};
}
function stopProxyOwnedEvent(event, preventDefault) {
if (!event) return;
if (preventDefault !== false && event.cancelable) {
event.preventDefault();
}
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
if (typeof event.stopPropagation === 'function') {
event.stopPropagation();
}
}
function syncNativeMusicProxyUi(doc) {
const controls = getNativeMusicControls(doc);
const proxyButton = controls.proxyButton;
const proxyVolumeRoot = controls.proxyVolumeRoot;
const proxyVolumeInput = controls.proxyVolumeInput;
if (!proxyButton || !proxyVolumeRoot || !proxyVolumeInput) return;
const volume = clampSpotifyOutputVolume(state.outputVolume);
const shouldLookActive = isSpotifyControlVisuallyActive();
proxyButton.setAttribute('aria-pressed', shouldLookActive ? 'true' : 'false');
proxyButton.setAttribute('aria-label', state.active ? 'Toggle Spotify playback' : 'Activate Spotify');
if (doc.activeElement !== proxyVolumeInput) {
proxyVolumeInput.value = String(volume);
}
proxyVolumeRoot.style.setProperty('--volume-value', `${Math.round(volume * 100)}%`);
}
function ensureNativeMusicProxy(doc) {
const controls = getNativeMusicControls(doc);
const option = controls.option;
if (!option) return null;
let proxyRoot = controls.proxyRoot;
if (!proxyRoot) {
proxyRoot = doc.createElement('div');
proxyRoot.className = 'nt-spotify-native-music-proxy';
proxyRoot.style.cssText = [
'position:absolute',
'inset:0',
'z-index:4',
'overflow:visible',
'pointer-events:none',
].join(';');
const button = doc.createElement('button');
button.type = 'button';
button.className = 'nt-spotify-native-music-proxy-hitbox';
button.style.cssText = [
'position:absolute',
'inset:0',
'margin:0',
'padding:0',
'border:0',
'background:transparent',
'cursor:pointer',
'pointer-events:auto',
].join(';');
const volumeRoot = doc.createElement('div');
volumeRoot.className = 'race-header-controls--volume-control nt-spotify-native-music-proxy-volume';
volumeRoot.style.pointerEvents = 'auto';
const volumeLabel = doc.createElement('div');
volumeLabel.className = 'race-header-controls--volume-control--label';
volumeLabel.textContent = 'Volume';
const volumeContainer = doc.createElement('div');
volumeContainer.className = 'race-header-controls--volume-control--container';
volumeContainer.style.pointerEvents = 'auto';
const volumeInput = doc.createElement('input');
volumeInput.type = 'range';
volumeInput.min = '0.2';
volumeInput.max = '1';
volumeInput.step = '0.025';
volumeInput.style.pointerEvents = 'auto';
const volumeBar = doc.createElement('div');
volumeBar.className = 'race-header-controls--volume-control--bar';
volumeContainer.appendChild(volumeInput);
volumeContainer.appendChild(volumeBar);
volumeRoot.appendChild(volumeLabel);
volumeRoot.appendChild(volumeContainer);
proxyRoot.appendChild(button);
proxyRoot.appendChild(volumeRoot);
option.appendChild(proxyRoot);
}
if (proxyRoot.dataset.ntSpotifyBound !== '1') {
const button = proxyRoot.querySelector('.nt-spotify-native-music-proxy-hitbox');
const volumeInput = proxyRoot.querySelector('.nt-spotify-native-music-proxy-volume input[type="range"]');
if (button) {
['pointerdown', 'mousedown', 'touchstart'].forEach((type) => {
button.addEventListener(type, (event) => {
stopProxyOwnedEvent(event, false);
});
});
button.addEventListener('click', (event) => {
stopProxyOwnedEvent(event, true);
if (!state.active) {
void activateSpotifyFromSavedSourceOrEditor();
return;
}
void toggleSpotifyPlaybackFromControls();
});
button.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
stopProxyOwnedEvent(event, true);
if (!state.active) {
void activateSpotifyFromSavedSourceOrEditor();
return;
}
void toggleSpotifyPlaybackFromControls();
});
}
if (volumeInput) {
['pointerdown', 'mousedown', 'touchstart', 'click'].forEach((type) => {
volumeInput.addEventListener(type, (event) => {
stopProxyOwnedEvent(event, type === 'click');
});
});
['input', 'change'].forEach((type) => {
volumeInput.addEventListener(type, (event) => {
stopProxyOwnedEvent(event, false);
const nextVolume = clampSpotifyOutputVolume(volumeInput.value);
state.outputVolume = nextVolume;
applySpotifyOutputVolume();
syncNativeMusicProxyUi(doc);
});
});
}
proxyRoot.dataset.ntSpotifyBound = '1';
}
syncNativeMusicProxyUi(doc);
return proxyRoot;
}
function ensureNativeSfxBindings(doc) {
const controls = getNativeMusicControls(doc);
const sfxOption = controls.sfxOption;
const sfxVolumeInput = controls.sfxVolumeInput;
if (sfxOption && sfxOption.dataset.ntSpotifySfxBound !== '1') {
['click', 'pointerdown', 'mousedown', 'touchstart'].forEach((type) => {
sfxOption.addEventListener(type, () => {
if (!isSpotifyUiSelected()) return;
scheduleNativeSfxSync(doc);
}, true);
});
sfxOption.dataset.ntSpotifySfxBound = '1';
}
if (sfxVolumeInput && sfxVolumeInput.dataset.ntSpotifySfxBound !== '1') {
['input', 'change', 'click', 'pointerdown', 'mousedown', 'touchstart'].forEach((type) => {
sfxVolumeInput.addEventListener(type, () => {
if (!isSpotifyUiSelected()) return;
scheduleNativeSfxSync(doc);
}, true);
});
sfxVolumeInput.dataset.ntSpotifySfxBound = '1';
}
}
function isNativeSfxEnabled(doc) {
const controls = getNativeMusicControls(doc || state.frameDoc);
const option = controls.sfxOption;
const label = String(controls.sfxLabel && controls.sfxLabel.textContent || '').trim().toLowerCase();
if (option && option.classList && option.classList.contains('is-on')) {
return true;
}
return label === 'sfx on';
}
function isNativeStationPlaying(doc) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc) return false;
const selector = targetDoc.querySelector('.music-selector');
if (selector && selector.classList.contains('playing')) {
return true;
}
const indicator = targetDoc.querySelector('.music-selector--current > .music-selector--indicator');
return Boolean(indicator && indicator.classList.contains('playing'));
}
function isNativeMusicEnabled(doc) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc) return false;
if (isNativeStationPlaying(targetDoc)) {
return true;
}
if (!isSpotifyUiSelected()) {
const controls = getNativeMusicControls(targetDoc);
const option = controls.option;
const label = String(controls.optionLabel && controls.optionLabel.textContent || '').trim().toLowerCase();
if (option && option.classList && option.classList.contains('is-on')) {
return true;
}
return label === 'music on';
}
return false;
}
function restoreNativeMusicControlVisuals(doc) {
const controls = getNativeMusicControls(doc || state.frameDoc);
if (!controls.option) return;
const nativeLooksActive = isNativeStationPlaying(doc || state.frameDoc);
controls.option.classList.toggle('is-on', nativeLooksActive);
controls.option.classList.toggle('false', !nativeLooksActive);
controls.option.removeAttribute('aria-pressed');
if (controls.optionLabel) {
controls.optionLabel.textContent = nativeLooksActive ? 'Music ON' : 'Music OFF';
}
}
function forceNativeMusicOff(doc, options) {
const targetDoc = doc || state.frameDoc;
const opts = options || {};
if (!targetDoc || !isMusicMuteNativeEnabled()) return false;
if (!isNativeMusicEnabled(targetDoc)) return false;
const now = Date.now();
if (!opts.force && now - Number(state._lastForcedNativeMusicOff || 0) < 120) {
return false;
}
const controls = getNativeMusicControls(targetDoc);
if (!controls.option) return false;
state._lastForcedNativeMusicOff = now;
try {
controls.option.click();
return true;
} catch (_) { }
try {
controls.option.dispatchEvent(new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: targetDoc.defaultView || window
}));
return true;
} catch (_) { }
return false;
}
function shouldHardMuteNativeAudio(doc) {
if (!isMusicMuteNativeEnabled()) return false;
return !isNativeSfxEnabled(doc);
}
function openSpotifyInputEditor(doc, options) {
if (!doc) return;
const showToast = !(options && options.showToast === false);
injectTopTransportControls(doc);
claimSpotifySelection(doc, { openEditor: true });
if (showToast) {
toast('Paste a Spotify, YouTube, or Apple Music URL to get started.');
}
}
function applySpotifyOutputVolume() {
const volume = clampSpotifyOutputVolume(state.outputVolume);
state.outputVolume = volume;
safeYTCall('setVolume', Math.round(volume * 100));
void safeControllerCall('setVolume', volume);
}
function isSpotifyUiSelected() {
return Boolean(state.active || state.pendingSelection);
}
function areNativeStationEventsSuppressed() {
return Boolean(
state.isActivating
|| Date.now() < Number(state.suppressNativeStationEventsUntil || 0)
);
}
function claimSpotifySelection(doc, options) {
const targetDoc = doc || state.frameDoc;
const opts = options || {};
const savedSource = getSavedSourceParsed();
const managedPlatform = (savedSource && savedSource.platform) || getDefaultPlatform() || 'spotify';
clearPendingNativeStationHandoff();
state.pendingSelection = true;
state._stopCooldownUntil = 0;
if (!targetDoc) return;
suppressNativeAudio(targetDoc);
startAudioObservers(targetDoc);
markSpotifyItemSelected(targetDoc, true);
setCurrentStationLabel(targetDoc, getPlatformLabel(savedSource), managedPlatform);
if (opts.openEditor !== false) {
setInlineEditorOpen(targetDoc, true);
}
syncTopTransportControls(targetDoc);
syncNativeMusicControls(targetDoc);
}
function releaseSpotifySelection(doc, options) {
const targetDoc = doc || state.frameDoc;
const opts = options || {};
clearPendingNativeStationHandoff();
clearAutoActivateTimer();
state._autoActivateAttempted = true;
state.pendingSelection = false;
state.suppressNativeStationEventsUntil = 0;
if (!targetDoc) return;
if (!state.active && opts.closeEditor !== false) {
setInlineEditorOpen(targetDoc, false);
}
markSpotifyItemSelected(targetDoc, false);
if (!state.active) {
if (opts.restoreLabel !== false) {
restoreNativeStationLabel(targetDoc);
}
if (opts.restoreAudio !== false) {
restoreNativeAudio(targetDoc);
}
stopAudioObservers();
syncTopTransportControls(targetDoc);
syncNativeMusicControls(targetDoc);
}
}
function shouldHandleSpotifyItemEvent(event) {
const type = String(event && event.type ? event.type : '');
const now = Date.now();
if (type === 'pointerdown') {
state._spotifyItemActionUntil = now + 400;
return true;
}
if (type === 'mousedown') {
if (state._spotifyItemActionUntil > now) return false;
state._spotifyItemActionUntil = now + 400;
return true;
}
if (type === 'click') {
if (state._spotifyItemActionUntil > now) return false;
return true;
}
return false;
}
function handleSpotifyItemActivation(event) {
state._stopCooldownUntil = 0;
if (event && event.preventDefault) event.preventDefault();
if (event && event.stopPropagation) event.stopPropagation();
if (event && typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
if (!shouldHandleSpotifyItemEvent(event)) {
return;
}
if (!state.active && !getSavedSourceParsed()) {
openSpotifyInputEditor(state.frameDoc);
return;
}
if (!state.active) {
void activateSpotifyMode({ preferSavedOnly: true });
} else if (state.active && state.playback.isPaused) {
void toggleSpotifyPlaybackFromControls();
}
}
function syncNativeMusicControls(doc) {
const controls = getNativeMusicControls(doc);
const option = controls.option;
const optionLabel = controls.optionLabel;
const volumeRoot = controls.volumeRoot;
const volumeInput = controls.volumeInput;
if (volumeInput) {
const parsedNativeVolume = Number(volumeInput.value);
if (Number.isFinite(parsedNativeVolume) && !isSpotifyUiSelected()) {
state.outputVolume = clampSpotifyOutputVolume(parsedNativeVolume);
}
}
if (!option) return;
if (!isSpotifyUiSelected()) {
if (controls.proxyRoot) controls.proxyRoot.remove();
if (volumeRoot) {
volumeRoot.style.visibility = '';
volumeRoot.removeAttribute('aria-hidden');
}
if (option.dataset.ntSpotifyManaged === '1') {
option.removeAttribute('data-ntSpotifyManaged');
}
restoreNativeMusicControlVisuals(doc);
return;
}
option.dataset.ntSpotifyManaged = '1';
ensureNativeMusicProxy(doc);
const volume = clampSpotifyOutputVolume(state.outputVolume);
if (volumeRoot) {
volumeRoot.style.visibility = 'hidden';
volumeRoot.setAttribute('aria-hidden', 'true');
}
const shouldLookActive = isSpotifyControlVisuallyActive();
option.classList.toggle('is-on', shouldLookActive);
option.classList.toggle('false', !shouldLookActive);
option.setAttribute('aria-pressed', shouldLookActive ? 'true' : 'false');
if (optionLabel) {
optionLabel.textContent = state.active
? (state.playback.isPaused ? 'Music OFF' : 'Music ON')
: 'Music ON';
}
syncNativeMusicProxyUi(doc);
}
function syncFrameUiNow(doc) {
if (!doc) return;
try { ensureNativeSfxBindings(doc); } catch (e) { }
if (isSpotifyUiSelected()) {
const savedSource = getSavedSourceParsed();
if (savedSource && !hasExplicitNativeSelectionEvidence(doc)) {
try {
setCurrentStationLabel(doc, getPlatformLabel(savedSource), savedSource.platform);
} catch (e) { }
}
}
if (!isSpotifyUiSelected()) {
const controls = getNativeMusicControls(doc);
const currentPlatform = String(controls.currentIndicator && controls.currentIndicator.getAttribute('data-platform') || '').trim().toLowerCase();
if (isManagedStationPlatform(currentPlatform)) {
try { restoreNativeStationLabel(doc); } catch (e) { }
}
}
const suppressNativeStationEvents = areNativeStationEventsSuppressed();
if (isSpotifyUiSelected() && !suppressNativeStationEvents && looksLikeNativeStationSelection(doc)) {
const controls = getNativeMusicControls(doc);
scheduleNativeStationHandoff(doc, {
stationId: extractStationIdFromMenuItem(controls.selectedNative)
});
try { injectSpotifyItem(doc); } catch (e) { }
try { injectTopTransportControls(doc); } catch (e) { }
return;
}
try { injectSpotifyItem(doc); } catch (e) { }
try { injectTopTransportControls(doc); } catch (e) { }
try { markSpotifyItemSelected(doc, isSpotifyUiSelected()); } catch (e) { }
try { syncTopTransportControls(doc); } catch (e) { }
try { syncNativeMusicControls(doc); } catch (e) { }
}
function queueImmediateFrameUiSync(doc) {
if (!doc || state.frameDoc !== doc || state._frameUiSyncQueued) return;
state._frameUiSyncQueued = true;
const flush = function () {
state._frameUiSyncQueued = false;
if (state.frameDoc !== doc) return;
syncFrameUiNow(doc);
};
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(flush);
} else {
window.setTimeout(flush, 0);
}
}
function reassertSpotifySuppression(doc) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc) return;
if (!isSpotifyUiSelected()) return;
if (hasExplicitNativeSelectionEvidence(targetDoc)) return;
try { suppressNativeAudio(targetDoc); } catch (_) { }
try { reinforceNativeMusicOff(targetDoc, [0, 35, 100, 220, 420]); } catch (_) { }
}
function nodeTouchesMusicSelector(node) {
if (!node || node.nodeType !== 1) return false;
if (typeof node.closest === 'function' && node.closest(
'.race-header-controls--music, .race-header-controls--station-selector, .music-selector, .race-header-controls--option.option-music'
)) {
return true;
}
if (typeof node.matches === 'function' && node.matches(
'.race-header-controls--music, .race-header-controls--station-selector, .music-selector, .music-selector--controls, .music-selector--current, .music-selector--indicator, .music-selector--list, .music-selector--list--item, .race-header-controls--option.option-music'
)) {
return true;
}
if (typeof node.querySelector === 'function' && node.querySelector(
'.race-header-controls--music, .race-header-controls--station-selector, .music-selector, .music-selector--controls, .music-selector--current, .music-selector--indicator, .music-selector--list, .music-selector--list--item, .race-header-controls--option.option-music'
)) {
return true;
}
return false;
}
function stopFrameUiObserver() {
if (state._frameUiObserver) {
try { state._frameUiObserver.disconnect(); } catch (_) { }
state._frameUiObserver = null;
}
state._frameUiSyncQueued = false;
}
function startFrameUiObserver(doc) {
stopFrameUiObserver();
if (!doc || !doc.body) return;
const callback = function (mutations) {
if (state.frameDoc !== doc) return;
for (let i = 0; i < mutations.length; i += 1) {
const mutation = mutations[i];
if (mutation.type === 'characterData') {
const parent = mutation.target && mutation.target.parentElement;
if (parent && nodeTouchesMusicSelector(parent)) {
reassertSpotifySuppression(doc);
queueImmediateFrameUiSync(doc);
return;
}
}
if (mutation.type === 'attributes' && nodeTouchesMusicSelector(mutation.target)) {
reassertSpotifySuppression(doc);
queueImmediateFrameUiSync(doc);
return;
}
const added = mutation.addedNodes;
if (added) {
for (let j = 0; j < added.length; j += 1) {
if (nodeTouchesMusicSelector(added[j])) {
reassertSpotifySuppression(doc);
queueImmediateFrameUiSync(doc);
return;
}
}
}
const removed = mutation.removedNodes;
if (removed) {
for (let j = 0; j < removed.length; j += 1) {
if (nodeTouchesMusicSelector(removed[j])) {
reassertSpotifySuppression(doc);
queueImmediateFrameUiSync(doc);
return;
}
}
}
}
};
try {
const obs = new MutationObserver(callback);
obs.observe(doc.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class', 'data-platform'],
characterData: true
});
state._frameUiObserver = obs;
} catch (_) { }
}
function maybeStopForNativeStationSelection(doc) {
if (!doc || !state.active) return false;
if (areNativeStationEventsSuppressed()) return false;
const controls = getNativeMusicControls(doc);
const selectedNative = controls.selectedNative;
if (selectedNative) {
scheduleNativeStationHandoff(doc, {
stationId: extractStationIdFromMenuItem(selectedNative)
});
return true;
}
if (hasExplicitNativeSelectionEvidence(doc, controls)) {
scheduleNativeStationHandoff(doc);
return true;
}
return false;
}
function syncFrameBinding() {
let frame = state.frame;
let doc = null;
if (frame) {
try {
if (document.contains(frame)) {
doc = frame.contentDocument;
}
} catch (error) {
doc = null;
}
}
if (!doc || !doc.body) {
frame = document.querySelector('main.structure-content iframe');
if (!frame) {
frame = document.querySelector('iframe[src*="/race"]');
}
// Fallback search through all same-origin iframes if specific classes changed
if (!frame) {
const frames = document.querySelectorAll('iframe');
for (const f of frames) {
try {
if (f.contentDocument && f.contentDocument.body) {
frame = f;
break;
}
} catch (e) { }
}
}
if (!frame) {
detachFrame();
return;
}
try {
doc = frame.contentDocument;
} catch (error) {
return;
}
}
if (!doc || !doc.body) return;
if (state.frameDoc === doc) {
ensureRootBindings(doc);
const now = Date.now();
if (!state._lastFrameUiSync || now - state._lastFrameUiSync >= FRAME_UI_REFRESH_MS) {
state._lastFrameUiSync = now;
syncFrameUiNow(doc);
}
if (isSpotifyUiSelected()) {
try {
if (!hasExplicitNativeSelectionEvidence(doc) && isNativeMusicEnabled(doc)) {
reassertSpotifySuppression(doc);
}
} catch (e) { }
try {
if (maybeStopForNativeStationSelection(doc)) {
return;
}
} catch (e) { }
}
if (isSpotifyUiSelected() && (!state._lastNativeMuteCheck || now - state._lastNativeMuteCheck >= NATIVE_AUDIO_REFRESH_MS)) {
state._lastNativeMuteCheck = now;
try { suppressNativeAudio(doc); } catch (e) { }
}
return;
}
detachFrame();
attachFrame(frame, doc);
}
function detachFrame() {
clearAutoActivateTimer();
clearPendingNativeStationHandoff();
clearPendingSfxSync();
resetFrameBindingState();
stopFrameUiObserver();
stopAudioObservers();
}
function attachFrame(frame, doc) {
state._lastFrameUiSync = 0;
state._lastNativeMuteCheck = 0;
state.frame = frame;
state.frameDoc = doc;
state.frameWindow = frame.contentWindow || null;
ensureFrameFocusGuard(state.frameWindow);
installAudioContextTracker(state.frameWindow);
ensureRootBindings(doc);
syncFrameUiNow(doc);
refreshSpotifyUI();
startFrameUiObserver(doc);
if (state.pendingToast) {
toast(state.pendingToast, 'success');
state.pendingToast = null;
}
if (isSpotifyUiSelected()) {
renderOverlay(doc);
renderNowPlaying();
// New race loaded while Spotify is playing — keep native audio silent.
// Howler on the new frame may not exist yet, so we retry aggressively
// in the first second to catch it the instant it initialises.
suppressNativeAudio(doc);
scheduleFrameSuppressRetryWave();
startAudioObservers(doc);
} else if (!state._autoActivateAttempted) {
// Auto-activate if the previous session was active (resume on page reload)
if (isMusicAutoActivateEnabled()) {
var session = getSavedSessionState();
if (session) {
state.suppressNativeStationEventsUntil = Date.now() + 2500;
claimSpotifySelection(doc, { openEditor: false });
// Give the race iframe time to finish settling before loading
// third-party music APIs and resuming a saved session.
clearAutoActivateTimer();
state.autoActivateTimer = window.setTimeout(function () {
state.autoActivateTimer = null;
if (!state.active && state.frameDoc === doc && state.frameWindow) {
state._autoActivateAttempted = true;
void activateSpotifyMode({ preferSavedOnly: true });
}
}, AUTO_ACTIVATE_DELAY_MS);
}
}
}
}
function handleGlobalClick(event) {
const itemNode = event.target ? event.target.closest('.nt-spotify-item') : null;
const anyStationItem = event.target ? event.target.closest('.music-selector--list--item') : null;
if (itemNode) {
handleSpotifyItemActivation(event);
return;
}
// Detect click on a non-Spotify station item — deactivate Spotify
if (anyStationItem && !anyStationItem.classList.contains('nt-spotify-item') && isSpotifyUiSelected()) {
const type = String(event && event.type || '');
if (!type || !['click', 'pointerdown', 'mousedown', 'touchstart'].includes(type)) {
return;
}
scheduleNativeStationHandoff(state.frameDoc, {
stationId: extractStationIdFromMenuItem(anyStationItem),
force: true
});
return;
}
}
function ensureFrameFocusGuard(frameWindow) {
if (!frameWindow) return;
if (frameWindow.__ntSpotifyFocusGuardInstalled) return;
if (!frameWindow.HTMLElement || !frameWindow.HTMLElement.prototype) return;
const originalFocus = frameWindow.HTMLElement.prototype.focus;
if (typeof originalFocus !== 'function') return;
frameWindow.HTMLElement.prototype.focus = function patchedSpotifyFocus(...args) {
try {
if (frameWindow.__ntSpotifyFocusLocked) {
const inEditor = this && typeof this.closest === 'function'
? this.closest('.nt-spotify-inline-editor')
: null;
if (!inEditor) return;
}
} catch (error) {
// noop
}
return originalFocus.apply(this, args);
};
frameWindow.__ntSpotifyFocusGuardInstalled = true;
frameWindow.__ntSpotifyFocusLocked = false;
}
function setFrameFocusLock(locked) {
if (!state.frameWindow) return;
try {
state.frameWindow.__ntSpotifyFocusLocked = Boolean(locked);
} catch (error) {
// noop
}
}
function onMusicMessageFromRace(event) {
if (!event || !event.data || event.data.type !== 'music-player') return;
if (state.frameWindow && event.source !== state.frameWindow) return;
if (event.data.action === 'set-station') {
const stationId = extractStationIdFromMusicMessage(event.data);
traceMusic('race-message:set-station', {
stationId,
active: state.active,
suppressed: areNativeStationEventsSuppressed(),
hasFrameDoc: Boolean(state.frameDoc),
});
if (stationId === 'spotify' || stationId === STATION_ID) {
// Only auto-activate if we're not in a cooldown window after an
// intentional stop — otherwise the postMessage from NT's code
// immediately re-activates what the user just turned off.
if (!state.active && Date.now() > (state._stopCooldownUntil || 0)) {
void activateSpotifyMode({ preferSavedOnly: true });
}
return;
}
if (state.active) {
if (areNativeStationEventsSuppressed()) {
return;
}
if (!stationId) {
return;
}
scheduleNativeStationHandoff(state.frameDoc, {
stationId,
force: true
});
}
return;
}
}
function extractStationIdFromMusicMessage(payload) {
if (!payload || typeof payload !== 'object') return '';
const raw = [
payload.stationId,
payload.id,
payload.station,
payload.selectedStation,
payload.data && payload.data.stationId,
payload.data && payload.data.id,
payload.data && payload.data.station,
payload.data && payload.data.selectedStation,
];
for (let i = 0; i < raw.length; i += 1) {
const value = String(raw[i] || '').trim().toLowerCase();
if (value) return value;
}
return '';
}
function getStationMenuItemById(doc, stationId) {
if (!doc || !stationId) return null;
const safeId = String(stationId)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
try {
return doc.querySelector(`.music-selector--list--item[data-station-id="${safeId}"]`);
} catch (error) {
return null;
}
}
function extractStationIdFromMenuItem(item) {
if (!item || typeof item.getAttribute !== 'function') return '';
return String(item.getAttribute('data-station-id') || '').trim().toLowerCase();
}
function clearPendingNativeStationHandoff() {
if (state._pendingNativeStationHandoffTimer) {
window.clearTimeout(state._pendingNativeStationHandoffTimer);
state._pendingNativeStationHandoffTimer = 0;
}
state._pendingNativeStationHandoffToken += 1;
}
function isManagedStationPlatform(platform) {
const normalized = String(platform || '').trim().toLowerCase();
return normalized === 'spotify' || normalized === 'youtube' || normalized === 'apple-music';
}
function currentStationLooksSpotify(doc, controls) {
const nextControls = controls || getNativeMusicControls(doc);
const currentIndicator = nextControls.currentIndicator;
const currentLabel = String(nextControls.currentLabel && nextControls.currentLabel.textContent || '').trim().toLowerCase();
const currentPlatform = String(currentIndicator && currentIndicator.getAttribute('data-platform') || '').trim().toLowerCase();
const expectedSpotifyLabel = String(getPlatformLabel(getSavedSourceParsed()) || 'Spotify').trim().toLowerCase();
return isManagedStationPlatform(currentPlatform)
|| currentLabel === 'spotify'
|| currentLabel === expectedSpotifyLabel;
}
function hasExplicitNativeSelectionEvidence(doc, controls) {
if (!doc) return false;
const nextControls = controls || getNativeMusicControls(doc);
const selectedNative = nextControls.selectedNative;
if (selectedNative) {
return true;
}
const currentIndicator = nextControls.currentIndicator;
const currentPlatform = String(currentIndicator && currentIndicator.getAttribute('data-platform') || '').trim().toLowerCase();
return Boolean(currentPlatform && !isManagedStationPlatform(currentPlatform));
}
function looksLikeNativeStationSelection(doc, stationId) {
if (!doc) return false;
const controls = getNativeMusicControls(doc);
const selectedNative = controls.selectedNative;
if (selectedNative) {
const selectedStationId = extractStationIdFromMenuItem(selectedNative);
if (!stationId || !selectedStationId || selectedStationId === stationId) {
return true;
}
}
if (hasExplicitNativeSelectionEvidence(doc, controls)) {
return true;
}
return false;
}
function scheduleNativeStationHandoff(doc, options) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc || !isSpotifyUiSelected()) return;
const opts = options || {};
const stationId = String(opts.stationId || '').trim().toLowerCase();
const force = Boolean(opts.force);
const token = state._pendingNativeStationHandoffToken + 1;
clearPendingNativeStationHandoff();
state._pendingNativeStationHandoffToken = token;
traceMusic('native-handoff:scheduled', {
stationId,
force,
delayMs: Number.isFinite(Number(opts.delayMs)) ? Number(opts.delayMs) : 0,
});
if (force) {
state.suppressNativeStationEventsUntil = 0;
}
state._pendingNativeStationHandoffTimer = window.setTimeout(() => {
state._pendingNativeStationHandoffTimer = 0;
const finalize = () => {
if (state._pendingNativeStationHandoffToken !== token) return;
const liveDoc = state.frameDoc || targetDoc;
if (!liveDoc || !isSpotifyUiSelected()) return;
if (!force && !looksLikeNativeStationSelection(liveDoc, stationId)) return;
clearPendingNativeStationHandoff();
traceMusic('native-handoff:finalize', {
stationId,
force,
active: state.active,
});
if (state.active) {
stopSpotifyMode();
} else {
releaseSpotifySelection(liveDoc, {
closeEditor: true,
restoreLabel: true,
restoreAudio: true
});
}
};
if (typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(() => {
window.requestAnimationFrame(finalize);
});
} else {
finalize();
}
}, Number.isFinite(Number(opts.delayMs)) ? Number(opts.delayMs) : 0);
}
async function activateSpotifyMode(options) {
if (state.isActivating) return;
state.isActivating = true;
traceMusic('activate-mode:start', {
preferSavedOnly: Boolean(options && options.preferSavedOnly),
currentSourceUri: state.playback.uri || '',
});
try {
void getSpotifyAccessToken(false);
const preferSavedOnly = Boolean(options && options.preferSavedOnly);
const source = preferSavedOnly ? getSavedSourceParsed() : await ensureSourceConfigured();
traceMusic('activate-mode:source-selected', {
preferSavedOnly,
hasSource: Boolean(source),
sourceUri: source ? source.uri : '',
sourceType: source ? source.type : '',
sourcePlatform: source ? source.platform : '',
});
if (!source) {
if (preferSavedOnly) {
openSpotifyInputEditor(state.frameDoc, { showToast: false });
focusSpotifyInput();
toast('Paste a Spotify, YouTube, or Apple Music URL to get started.');
}
return;
}
state._autoActivateAttempted = true;
// Show loading state immediately so the user sees feedback
state.active = true;
state.pendingSelection = false;
state.trackMeta = null;
resetPlaybackState();
const nativeControls = getNativeMusicControls(state.frameDoc);
if (nativeControls.volumeInput) {
state.outputVolume = clampSpotifyOutputVolume(nativeControls.volumeInput.value);
}
// Restore volume in case it was zeroed during stopSpotifyMode
applySpotifyOutputVolume();
state.playback.uri = source.uri;
// *** IMMEDIATELY silence NT's native audio ***
// This must happen BEFORE any async work (loadSource) so there's
// no gap where NT's music plays over ours.
suppressNativeAudio(state.frameDoc);
startAudioObservers(state.frameDoc);
if (state.frameDoc) {
markSpotifyItemSelected(state.frameDoc, true);
setCurrentStationLabel(state.frameDoc, getPlatformLabel(source), source ? source.platform : undefined);
renderOverlay(state.frameDoc);
}
renderNowPlaying();
const loaded = await loadSource(source.uri);
// Guard: user may have stopped Spotify mode during the async load
if (!state.active) return;
if (!loaded) {
traceMusic('activate-mode:load-failed', {
sourceUri: source.uri,
sourceType: source.type,
sourcePlatform: source.platform,
});
// Rollback — loading failed
state.active = false;
state.pendingSelection = true;
state.playback.uri = '';
if (state.frameDoc) {
markSpotifyItemSelected(state.frameDoc, true); // keep selected so UI stays visible
removeOverlay(state.frameDoc);
// Show transport bar with editor open so user can fix the URL
const transport = state.frameDoc.querySelector('.nt-spotify-transport');
if (transport) transport.style.display = 'flex';
setInlineEditorOpen(state.frameDoc, true);
suppressNativeAudio(state.frameDoc);
}
toast('Unable to load source — paste a different URL.');
return;
}
state.suppressNativeStationEventsUntil = Date.now() + 4000;
traceMusic('activate-mode:load-succeeded', {
sourceUri: source.uri,
queueLength: state.queue.length,
queueIndex: state.queueIndex,
});
startProgressTimer();
applySpotifyOutputVolume();
renderNowPlaying();
} catch (error) {
traceMusic('activate-mode:error', {
error: formatTraceError(error),
});
console.error('[NT Music] Activation failed:', error);
state.active = false;
state.pendingSelection = false;
state.playback.uri = '';
toast('Playback activation failed.');
} finally {
state.isActivating = false;
}
}
function stopSpotifyMode() {
traceMusic('stop-mode', {
currentUri: state.playback.uri || '',
queueLength: state.queue.length,
queueIndex: state.queueIndex,
});
clearPendingNativeStationHandoff();
clearAutoActivateTimer();
state._autoActivateAttempted = true;
state.active = false;
state.pendingSelection = false;
state.suppressNativeStationEventsUntil = 0;
state._stopCooldownUntil = Date.now() + 3000; // prevent re-activation for 3 s
// User explicitly deactivated — clear session so we don't auto-resume
clearSessionState();
stopProgressTimer();
stopAudioObservers();
// Zero volume instantly — faster than waiting for stopVideo cross-iframe round-trip
void safeYTCall('setVolume', 0);
// Then stop playback entirely
void safeYTCall('stopVideo');
void safeYTCall('pauseVideo');
if (state.embedController) void safeControllerCall('pause');
clearQueue();
resetPlaybackState();
if (state.frameDoc) {
markSpotifyItemSelected(state.frameDoc, false);
removeOverlay(state.frameDoc);
syncTopTransportControls(state.frameDoc);
restoreNativeStationLabel(state.frameDoc);
restoreNativeAudio(state.frameDoc);
syncNativeMusicControls(state.frameDoc);
}
}
// ---------------------------------------------------------------------------
// Native audio suppression — keep NT's own radio silent while Spotify is on
// ---------------------------------------------------------------------------
function suppressNativeAudio(doc) {
// Respect the mute native station toggle from mod menu
if (!isMusicMuteNativeEnabled()) return;
forceNativeMusicOff(doc, { force: true });
const hardMute = shouldHardMuteNativeAudio(doc);
// 1) Hard mute only when SFX are disabled. If SFX are enabled, use
// a softer stop strategy so Nitro's effect sounds can still play.
if (hardMute) {
lockHowlerVolume(true);
} else {
lockHowlerVolume(false);
}
syncTrackedAudioContextsSuspended(hardMute);
// 2) PostMessage tells NT's music controller to stop
try { window.top.postMessage({ type: 'music-player', action: 'stop' }, '*'); } catch (_) { }
// 3) Hard-stop Howler only when SFX are disabled. When SFX are enabled,
// rely on the native Music OFF toggle instead so sound effects can keep
// using Nitro's shared audio stack.
if (hardMute) {
stopHowlerPlayback(doc, { hardMute });
}
// 4) Belt-and-suspenders muting for tag-based audio/video, but only in
// hard-mute mode so sound effects can continue when SFX are enabled.
if (hardMute) {
muteAudioElements(doc);
muteAudioElements(document);
}
}
function restoreNativeAudio(doc) {
lockHowlerVolume(false);
syncTrackedAudioContextsSuspended(false);
unmuteAudioElements(doc);
unmuteAudioElements(document);
}
function stopHowlerPlayback(doc, options) {
const opts = options || {};
const hardMute = Boolean(opts.hardMute);
var wins = [];
try { wins.push(getPageWindow()); } catch (_) { }
try { if (state.frameWindow) wins.push(state.frameWindow); } catch (_) { }
for (var i = 0; i < wins.length; i += 1) {
var win = wins[i];
if (!win) continue;
try {
if (!win.Howler) continue;
try { win.Howler.stop(); } catch (_) { }
var howls = win.Howler._howls;
if (howls && howls.length) {
for (var h = 0; h < howls.length; h += 1) {
try {
if (hardMute) {
try { howls[h].mute(true); } catch (_) { }
try { howls[h].volume(0); } catch (_) { }
}
try { howls[h].stop(); } catch (_) { }
} catch (_) { }
}
}
if (hardMute) {
try {
var hCtx = win.Howler.ctx || win.Howler._ctx;
if (hCtx && hCtx.state === 'running') hCtx.suspend();
} catch (_) { }
}
} catch (_) { }
}
}
// ---------------------------------------------------------------------------
// Howler.js property lock
// ---------------------------------------------------------------------------
// NT uses Howler.js for its music. Simply calling Howler.mute(true) or
// Howler.stop() is not enough because NT's own code immediately calls
// Howler.mute(false) or plays new sounds on each race load.
//
// Instead we use Object.defineProperty to replace Howler._volume and
// Howler._muted with locked getters that always return 0 / true.
// This makes it physically impossible for Howler to produce audio until
// we remove the lock — no amount of React re-renders or NT JS can
// override it.
// ---------------------------------------------------------------------------
function lockHowlerVolume(lock) {
var wins = [];
try { wins.push(getPageWindow()); } catch (_) { }
try { if (state.frameWindow) wins.push(state.frameWindow); } catch (_) { }
for (var i = 0; i < wins.length; i += 1) {
var win = wins[i];
if (!win) continue;
try {
if (!win.Howler) continue;
if (lock) {
if (!win._ntHowlerLocked) {
// Save originals
win._ntHowlerOrigVol = win.Howler._volume;
win._ntHowlerOrigMuted = win.Howler._muted;
// Lock _volume → always 0
Object.defineProperty(win.Howler, '_volume', {
get: function () { return 0; },
set: function () { /* no-op — NT can't change it */ },
configurable: true,
});
// Lock _muted → always true
Object.defineProperty(win.Howler, '_muted', {
get: function () { return true; },
set: function () { /* no-op */ },
configurable: true,
});
win._ntHowlerLocked = true;
}
// Stop all currently-playing sounds (safe to call repeatedly)
try { win.Howler.stop(); } catch (_) { }
// Also mute + stop each individual Howl instance
try {
var howls = win.Howler._howls;
if (howls && howls.length) {
for (var h = 0; h < howls.length; h += 1) {
try { howls[h].mute(true); } catch (_) { }
try { howls[h].stop(); } catch (_) { }
try { howls[h].volume(0); } catch (_) { }
}
}
} catch (_) { }
// Also suspend Howler's AudioContext so decode/playback truly halts
try {
var hCtx = win.Howler.ctx || win.Howler._ctx;
if (hCtx && hCtx.state === 'running') hCtx.suspend();
} catch (_) { }
} else {
// --- Unlock ---
if (win._ntHowlerLocked) {
// Remove our property overrides — reveals the original instance value
delete win.Howler._volume;
delete win.Howler._muted;
// Restore saved values
win.Howler._volume = (win._ntHowlerOrigVol !== undefined)
? win._ntHowlerOrigVol : 1;
win.Howler._muted = !!win._ntHowlerOrigMuted;
// Resume AudioContext
try {
var hCtx2 = win.Howler.ctx || win.Howler._ctx;
if (hCtx2 && hCtx2.state === 'suspended') hCtx2.resume();
} catch (_) { }
win._ntHowlerLocked = false;
delete win._ntHowlerOrigVol;
delete win._ntHowlerOrigMuted;
}
}
} catch (e) {
console.warn('[NT Music] lockHowlerVolume error:', e);
}
}
}
function syncTrackedAudioContextsSuspended(shouldSuspend) {
var wins = [];
try { wins.push(getPageWindow()); } catch (_) { }
try { if (state.frameWindow) wins.push(state.frameWindow); } catch (_) { }
for (var i = 0; i < wins.length; i += 1) {
var win = wins[i];
if (!win || !win._ntAudioContexts || !win._ntAudioContexts.length) continue;
for (var j = 0; j < win._ntAudioContexts.length; j += 1) {
var ctx = win._ntAudioContexts[j];
if (!ctx || !ctx.state) continue;
try {
if (shouldSuspend) {
if (ctx.state === 'running') ctx.suspend();
} else if (ctx.state === 'suspended') {
ctx.resume();
}
} catch (_) { }
}
}
}
function clearPendingSfxSync() {
if (state._pendingSfxSyncTimer) {
window.clearTimeout(state._pendingSfxSyncTimer);
state._pendingSfxSyncTimer = 0;
}
}
function reinforceNativeMusicOff(doc, delays) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc || !isSpotifyUiSelected()) return;
const steps = Array.isArray(delays) ? delays : [0, 60, 160, 320];
steps.forEach((delay) => {
window.setTimeout(() => {
if (!isSpotifyUiSelected() || !state.frameDoc) return;
try { forceNativeMusicOff(state.frameDoc, { force: true }); } catch (_) { }
try { window.top.postMessage({ type: 'music-player', action: 'stop' }, '*'); } catch (_) { }
}, Math.max(0, Number(delay) || 0));
});
}
function scheduleNativeSfxSync(doc) {
const targetDoc = doc || state.frameDoc;
if (!targetDoc || !isSpotifyUiSelected()) return;
clearPendingSfxSync();
state._pendingSfxSyncTimer = window.setTimeout(() => {
state._pendingSfxSyncTimer = 0;
const liveDoc = state.frameDoc || targetDoc;
if (!liveDoc || !isSpotifyUiSelected()) return;
if (isNativeSfxEnabled(liveDoc)) {
// Let Nitro's SFX stack run again, but keep native music forced off.
restoreNativeAudio(liveDoc);
reinforceNativeMusicOff(liveDoc, [0, 40, 120, 260, 500]);
} else {
suppressNativeAudio(liveDoc);
}
}, 0);
}
/**
* Intercept AudioContext creation on the given window so we can
* suspend/resume contexts created AFTER our script loads.
*/
function installAudioContextTracker(win) {
if (!win || win._ntAudioCtxTracked) return;
try {
win._ntAudioContexts = win._ntAudioContexts || [];
const OrigAC = win.AudioContext || win.webkitAudioContext;
if (!OrigAC) return;
const WrappedAC = function () {
const ctx = arguments.length ? new OrigAC(arguments[0]) : new OrigAC();
win._ntAudioContexts.push(ctx);
if (isSpotifyUiSelected() && shouldHardMuteNativeAudio(state.frameDoc)) {
try { ctx.suspend(); } catch (_) { }
}
return ctx;
};
WrappedAC.prototype = OrigAC.prototype;
if (win.AudioContext) win.AudioContext = WrappedAC;
if (win.webkitAudioContext) win.webkitAudioContext = WrappedAC;
win._ntAudioCtxTracked = true;
} catch (_) { }
}
function muteAudioElements(root) {
if (!root) return;
try {
const els = root.querySelectorAll('audio, video');
for (let i = 0; i < els.length; i += 1) {
const el = els[i];
// Don't mute our own YouTube player
if (el.closest && el.closest('#nt-yt-player-host')) continue;
try { el.muted = true; el.pause(); } catch (_) { }
}
} catch (_) { }
}
function unmuteAudioElements(root) {
if (!root) return;
try {
const els = root.querySelectorAll('audio, video');
for (let i = 0; i < els.length; i += 1) {
const el = els[i];
if (el.closest && el.closest('#nt-yt-player-host')) continue;
try { el.muted = false; } catch (_) { }
}
} catch (_) { }
}
// ---------------------------------------------------------------------------
// MutationObserver — catch <audio>/<video> elements the instant they appear
// ---------------------------------------------------------------------------
function startAudioObservers(doc) {
stopAudioObservers();
const callback = function (mutations) {
if (!isSpotifyUiSelected()) return;
if (!shouldHardMuteNativeAudio(state.frameDoc)) return;
for (let i = 0; i < mutations.length; i += 1) {
const added = mutations[i].addedNodes;
if (!added) continue;
for (let j = 0; j < added.length; j += 1) {
const node = added[j];
if (node.nodeType !== 1) continue;
const tag = node.tagName;
if (tag === 'AUDIO' || tag === 'VIDEO') {
if (node.closest && node.closest('#nt-yt-player-host')) continue;
try { node.muted = true; node.pause(); } catch (_) { }
}
if (typeof node.querySelectorAll === 'function') {
try {
const els = node.querySelectorAll('audio, video');
for (let k = 0; k < els.length; k += 1) {
const el = els[k];
if (el.closest && el.closest('#nt-yt-player-host')) continue;
try { el.muted = true; el.pause(); } catch (_) { }
}
} catch (_) { }
}
}
}
};
const opts = { childList: true, subtree: true };
if (doc && doc.body) {
try {
const obs = new MutationObserver(callback);
obs.observe(doc.body, opts);
state._audioObserverDoc = obs;
} catch (_) { }
}
if (document.body && document !== doc) {
try {
const obs = new MutationObserver(callback);
obs.observe(document.body, opts);
state._audioObserverTop = obs;
} catch (_) { }
}
}
function stopAudioObservers() {
if (state._audioObserverDoc) {
try { state._audioObserverDoc.disconnect(); } catch (_) { }
state._audioObserverDoc = null;
}
if (state._audioObserverTop) {
try { state._audioObserverTop.disconnect(); } catch (_) { }
state._audioObserverTop = null;
}
}
function resetPlaybackState() {
state.playback.uri = '';
state.playback.position = 0;
state.playback.duration = 0;
state.playback.isPaused = true;
state.playback.isBuffering = false;
state.playback.updatedAt = 0;
}
var _lastSessionSaveTime = 0;
var SESSION_SAVE_INTERVAL_MS = 15000; // save session state every 15s
function startProgressTimer() {
stopProgressTimer();
state.progressTimer = window.setInterval(() => {
if (!state.active) return;
syncPlaybackFromYT();
renderNowPlaying();
// Periodically persist session state for resume-on-reload
var now = Date.now();
if (now - _lastSessionSaveTime > SESSION_SAVE_INTERVAL_MS) {
_lastSessionSaveTime = now;
saveSessionState();
}
}, getProgressTickMs());
}
function stopProgressTimer() {
if (state.progressTimer) {
window.clearInterval(state.progressTimer);
state.progressTimer = null;
}
}
function getDisplayPlayback() {
const meta = state.trackMeta;
const hasUri = Boolean(state.playback.uri || (meta && meta.uri));
if (!hasUri) {
return {
title: 'Nothing playing',
artist: '',
art: '',
status: 'SPOTIFY',
progressPct: 0,
};
}
let position = state.playback.position;
const duration = state.playback.duration;
if (!state.playback.isPaused && state.playback.updatedAt) {
if (getActivePlaybackEngine() === 'embed') {
position += Date.now() - state.playback.updatedAt;
} else {
const player = state.ytPlayer;
if (player && typeof player.getCurrentTime === 'function') {
position = Math.max(0, Math.round((Number(player.getCurrentTime()) || 0) * 1000));
} else {
position += Date.now() - state.playback.updatedAt;
}
}
}
if (duration > 0) {
position = Math.max(0, Math.min(duration, position));
} else {
position = Math.max(0, position);
}
const progressPct = duration > 0 ? (position / duration) * 100 : 0;
return {
title: meta && meta.title ? meta.title : 'Loading track...',
artist: meta && meta.artist ? meta.artist : '',
art: meta && meta.art ? meta.art : '',
status: state.playback.isPaused ? 'PAUSED' : 'NOW PLAYING',
progressPct: Math.max(0, Math.min(100, progressPct)),
};
}
function renderNowPlaying() {
const doc = state.frameDoc;
if (!doc || !state.active) return;
let nodes = getOverlayNodes(doc);
if (!nodes) {
renderOverlay(doc);
nodes = getOverlayNodes(doc);
}
if (!nodes) {
syncTopTransportControls(doc);
return;
}
const display = getDisplayPlayback();
nodes.overlay.style.display = 'flex';
const showArt = display.art && isMusicAlbumArtEnabled();
nodes.coverWrap.style.display = showArt ? 'block' : 'none';
if (showArt) {
nodes.cover.src = display.art;
nodes.cover.alt = `${display.title} cover`;
} else {
nodes.cover.removeAttribute('src');
nodes.cover.alt = '';
}
nodes.title.textContent = display.title;
nodes.title.title = display.title;
nodes.artist.textContent = display.artist;
nodes.artist.title = display.artist;
syncTopTransportControls(doc);
}
function renderOverlay(doc) {
injectTopTransportControls(doc);
const transport = doc.querySelector('.nt-spotify-transport');
if (!transport) return;
if (transport.querySelector('.nt-spotify-nowplaying-inline')) return;
const overlay = doc.createElement('div');
overlay.className = 'nt-spotify-nowplaying-inline';
overlay.title = 'Click to edit Spotify URL';
overlay.style.cssText = [
'margin-left:4px',
'display: flex',
'align-items: center',
'gap: 6px',
'width: 180px',
'padding: 1px 6px',
'border-radius: 8px',
'background: transparent',
'border: none',
'color: #fff',
'font-size: 10px',
'line-height: 1.1',
'flex-shrink: 0',
'overflow: hidden',
'pointer-events: auto',
'cursor: pointer',
].join(';');
const coverWrap = doc.createElement('div');
coverWrap.className = 'nt-spotify-cover-wrap';
coverWrap.style.cssText = 'width:18px;height:18px;flex-shrink:0;display:none;';
const cover = doc.createElement('img');
cover.className = 'nt-spotify-cover';
cover.style.cssText = 'width:18px;height:18px;border-radius:4px;display:block;';
coverWrap.appendChild(cover);
const meta = doc.createElement('div');
meta.style.cssText = 'flex:1;min-width:0;';
const title = doc.createElement('div');
title.className = 'nt-spotify-title';
title.style.cssText = 'font-weight:700;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%;';
const artist = doc.createElement('div');
artist.className = 'nt-spotify-artist';
artist.style.cssText = 'color:#c9c9c9;font-size:8px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;width:100%;';
meta.appendChild(title);
meta.appendChild(artist);
overlay.appendChild(coverWrap);
overlay.appendChild(meta);
transport.insertBefore(overlay, transport.firstChild || null);
}
function removeOverlay(doc) {
const overlay = doc.querySelector('.nt-spotify-nowplaying-inline');
if (overlay) overlay.remove();
}
function getOverlayNodes(doc) {
const overlay = doc.querySelector('.nt-spotify-nowplaying-inline');
if (!overlay) return null;
return {
overlay,
coverWrap: overlay.querySelector('.nt-spotify-cover-wrap'),
cover: overlay.querySelector('.nt-spotify-cover'),
title: overlay.querySelector('.nt-spotify-title'),
artist: overlay.querySelector('.nt-spotify-artist'),
};
}
function injectSpotifyItem(doc) {
const list = doc.querySelector('.music-selector--list');
if (!list) return;
let item = list.querySelector('.nt-spotify-item');
if (!item) {
item = doc.createElement('div');
item.className = 'music-selector--list--item nt-spotify-item';
item.setAttribute('data-station-id', STATION_ID);
const name = doc.createElement('div');
name.className = 'music-selector--list--name';
name.style.cssText = 'display:flex;align-items:center;gap:6px;';
const iconWrap = doc.createElement('span');
iconWrap.className = 'nt-spotify-item-icon';
iconWrap.style.cssText = 'display:inline-flex;align-items:center;justify-content:center;width:14px;height:14px;flex-shrink:0;';
iconWrap.appendChild(createSpotifyIcon(doc, 14));
name.appendChild(iconWrap);
const label = doc.createElement('span');
label.className = 'nt-spotify-item-label';
name.appendChild(label);
const note = doc.createElement('div');
note.className = 'music-selector--list--broadcasting nt-spotify-item-note';
note.style.display = 'none';
note.style.alignItems = 'center';
const broadcastIcon = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
broadcastIcon.setAttribute('class', 'icon icon-broadcast');
const use = doc.createElementNS('http://www.w3.org/2000/svg', 'use');
use.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', '/dist/site/images/icons/icons.css.svg#icon-broadcast');
broadcastIcon.appendChild(use);
note.appendChild(broadcastIcon);
item.appendChild(name);
item.appendChild(note);
list.appendChild(item);
}
const legacyControls = item.querySelector('.nt-spotify-controls');
if (legacyControls) legacyControls.remove();
if (item.dataset.ntSpotifyBound !== '1') {
['pointerdown', 'mousedown', 'touchstart', 'click'].forEach((type) => {
item.addEventListener(type, handleSpotifyItemActivation, true);
});
item.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
handleSpotifyItemActivation(event);
}, true);
item.dataset.ntSpotifyBound = '1';
}
// Update label and icon to reflect current platform
const currentLabel = getSpotifyMenuLabel();
const saved = getSavedSourceParsed();
const platform = saved ? saved.platform : 'spotify';
const labelNode = item.querySelector('.nt-spotify-item-label');
if (labelNode) {
labelNode.textContent = currentLabel;
}
const iconWrap = item.querySelector('.nt-spotify-item-icon');
if (iconWrap) {
const currentPlatform = iconWrap.getAttribute('data-platform') || 'spotify';
if (currentPlatform !== platform) {
iconWrap.replaceChildren(createPlatformIcon(doc, 14, platform));
iconWrap.setAttribute('data-platform', platform);
}
}
syncSpotifyControlValues(doc, item);
item.style.cursor = 'pointer';
markSpotifyItemSelected(doc, isSpotifyUiSelected());
}
// bindCurrentStationStart removed — handled by handleGlobalClick event delegation
function injectTopTransportControls(doc) {
if (!doc) return;
const musicRoot = doc.querySelector('.race-header-controls--music');
if (!musicRoot) return;
let transport = musicRoot.querySelector('.nt-spotify-transport');
if (!transport) {
transport = doc.createElement('div');
transport.className = 'nt-spotify-transport';
transport.style.cssText = [
'display:none',
'align-items:center',
'flex-wrap:nowrap',
'overflow:hidden',
'gap:2px',
'margin-left:6px',
'padding:3px 6px',
'border-radius:10px',
'background: rgba(0, 0, 0, 0.6)',
'border: none',
'pointer-events:auto',
'position:relative',
'z-index:5',
].join(';');
const prevBtn = createTransportIconButton(doc, 'nt-spotify-top-prev-btn', 'prev', false, 'Previous');
const playBtn = createTransportIconButton(doc, 'nt-spotify-top-play-btn', 'play', true, 'Play / Pause');
const nextBtn = createTransportIconButton(doc, 'nt-spotify-top-next-btn', 'next', false, 'Next');
const shuffleBtn = createTransportIconButton(doc, 'nt-spotify-top-shuffle-btn', 'shuffle', false, 'Shuffle On/Off');
const linkBtn = createTransportIconButton(doc, 'nt-spotify-top-link-btn', 'link', false, 'Set Spotify URL');
transport.appendChild(prevBtn);
transport.appendChild(playBtn);
transport.appendChild(nextBtn);
transport.appendChild(shuffleBtn);
transport.appendChild(linkBtn);
const editor = doc.createElement('div');
editor.className = 'nt-spotify-inline-editor';
editor.style.cssText = [
'display:none',
'position:absolute',
'top:100%',
'left:0',
'min-width:340px',
'width:max-content',
'max-width:min(520px, calc(100vw - 32px))',
'margin-top:4px',
'align-items:center',
'gap:6px',
'background:rgba(0,0,0,0.85)',
'border-radius:8px',
'padding:4px 6px',
'border:1px solid rgba(255,255,255,0.18)',
'box-shadow:0 8px 20px rgba(0,0,0,0.32)',
'box-sizing:border-box',
'z-index:10',
].join(';');
const input = doc.createElement('input');
input.className = 'nt-spotify-top-url-input';
input.type = 'text';
input.placeholder = 'Paste Spotify, YouTube, or Apple Music URL';
input.autocomplete = 'off';
input.spellcheck = false;
input.style.cssText = [
'width:100%',
'flex:1 1 auto',
'min-width:170px',
'box-sizing:border-box',
'height:26px',
'padding:4px 8px',
'border-radius:6px',
'border:1px solid rgba(255,255,255,0.22)',
'background:rgba(0,0,0,0.45)',
'color:#fff',
'font-size:11px',
'outline:none',
].join(';');
const saveBtn = createSpotifyControlButton(doc, 'Save', 'nt-spotify-top-save-btn', true);
const clearBtn = createSpotifyControlButton(doc, 'Clear', 'nt-spotify-top-clear-btn', false);
editor.appendChild(input);
editor.appendChild(saveBtn);
editor.appendChild(clearBtn);
// Position transport + editor wrapper so the editor can drop below
// without being clipped by the transport's overflow:hidden.
const wrapper = doc.createElement('div');
wrapper.className = 'nt-spotify-transport-wrapper';
wrapper.style.cssText = 'position:relative;display:inline-flex;align-items:center;z-index:5;';
wrapper.appendChild(transport);
wrapper.appendChild(editor);
transport.dataset.editorOpen = '0';
const stationSelector = musicRoot.querySelector('.race-header-controls--station-selector');
if (stationSelector && stationSelector.parentElement === musicRoot) {
stationSelector.insertAdjacentElement('afterend', wrapper);
} else {
musicRoot.appendChild(wrapper);
}
}
const oldStopBtn = transport.querySelector('.nt-spotify-top-stop-btn');
if (oldStopBtn) oldStopBtn.remove();
if (!transport.dataset.ntSpotifyBound) {
const wrapper = transport.closest('.nt-spotify-transport-wrapper') || transport.parentElement;
const prevBtn = transport.querySelector('.nt-spotify-top-prev-btn');
const playBtn = transport.querySelector('.nt-spotify-top-play-btn');
const nextBtn = transport.querySelector('.nt-spotify-top-next-btn');
const shuffleBtn = transport.querySelector('.nt-spotify-top-shuffle-btn');
const linkBtn = transport.querySelector('.nt-spotify-top-link-btn');
const input = wrapper.querySelector('.nt-spotify-top-url-input');
const saveBtn = wrapper.querySelector('.nt-spotify-top-save-btn');
const clearBtn = wrapper.querySelector('.nt-spotify-top-clear-btn');
if (prevBtn) {
prevBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
prevBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
void playPrevInQueue();
});
}
if (playBtn) {
playBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
playBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
void toggleSpotifyPlaybackFromControls();
});
}
if (nextBtn) {
nextBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
nextBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
void playNextInQueue();
});
}
if (shuffleBtn) {
shuffleBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
shuffleBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
toggleShuffleMode();
syncTopTransportControls(doc);
});
}
if (linkBtn) {
linkBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
linkBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
const isOpen = isInlineEditorOpen(transport);
setInlineEditorOpen(doc, !isOpen);
});
}
if (input) {
input.addEventListener('pointerdown', (event) => {
event.stopPropagation();
setFrameFocusLock(true);
});
input.addEventListener('mousedown', (event) => {
event.stopPropagation();
setFrameFocusLock(true);
});
input.addEventListener('click', (event) => {
event.stopPropagation();
});
const stopKeyEvent = (event) => {
if (!event) return;
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
};
input.addEventListener('keydown', (event) => {
stopKeyEvent(event);
if (event.key === 'Enter') {
event.preventDefault();
void parseAndSaveSourceFromInput(input.value, true);
}
}, true);
input.addEventListener('keyup', stopKeyEvent, true);
input.addEventListener('keypress', stopKeyEvent, true);
input.addEventListener('focus', () => {
setFrameFocusLock(true);
input.classList.add('protected');
});
input.addEventListener('blur', () => {
input.classList.remove('protected');
window.setTimeout(() => {
const active = doc.activeElement;
const stillInEditor = active && typeof active.closest === 'function'
? active.closest('.nt-spotify-inline-editor')
: null;
if (!stillInEditor) {
setFrameFocusLock(false);
}
}, 75);
});
}
if (saveBtn && input) {
saveBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
saveBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
void parseAndSaveSourceFromInput(input.value, true);
});
}
if (clearBtn) {
clearBtn.addEventListener('pointerdown', (event) => {
event.stopPropagation();
});
clearBtn.addEventListener('click', (event) => {
event.preventDefault();
event.stopPropagation();
clearSavedSource();
setInlineEditorOpen(doc, true);
});
}
transport.addEventListener('click', (event) => {
const nowPlaying = event.target && event.target.closest
? event.target.closest('.nt-spotify-nowplaying-inline')
: null;
if (!nowPlaying) return;
event.preventDefault();
event.stopPropagation();
const isOpen = isInlineEditorOpen(transport);
setInlineEditorOpen(doc, !isOpen);
});
transport.addEventListener('pointerdown', (event) => {
const nowPlaying = event.target && event.target.closest
? event.target.closest('.nt-spotify-nowplaying-inline')
: null;
if (!nowPlaying) return;
event.stopPropagation();
});
const stopTransportEvent = (event) => {
if (!event) return;
event.stopPropagation();
if (typeof event.stopImmediatePropagation === 'function') {
event.stopImmediatePropagation();
}
};
transport.addEventListener('mousedown', stopTransportEvent, false);
transport.addEventListener('click', stopTransportEvent, false);
transport.addEventListener('keydown', (event) => {
const insideEditor = event.target && event.target.closest
? event.target.closest('.nt-spotify-inline-editor')
: null;
if (!insideEditor) return;
stopTransportEvent(event);
}, true);
transport.addEventListener('keyup', (event) => {
const insideEditor = event.target && event.target.closest
? event.target.closest('.nt-spotify-inline-editor')
: null;
if (!insideEditor) return;
stopTransportEvent(event);
}, true);
transport.addEventListener('keypress', (event) => {
const insideEditor = event.target && event.target.closest
? event.target.closest('.nt-spotify-inline-editor')
: null;
if (!insideEditor) return;
stopTransportEvent(event);
}, true);
if (input) {
const savedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
input.value = savedUrl;
}
wrapper.addEventListener('mouseleave', () => {
if (!isInlineEditorOpen(transport)) return;
const active = doc.activeElement;
const inputEl = wrapper.querySelector('.nt-spotify-top-url-input');
if (active && inputEl && active === inputEl) return;
setInlineEditorOpen(doc, false);
});
const nowPlayingMeta = transport.querySelector('.nt-spotify-nowplaying-inline');
if (nowPlayingMeta) {
nowPlayingMeta.addEventListener('keydown', (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
const isOpen = isInlineEditorOpen(transport);
setInlineEditorOpen(doc, !isOpen);
});
}
transport.dataset.ntSpotifyBound = '1';
}
}
function setInlineEditorOpen(doc, open) {
if (!doc) return;
const transport = doc.querySelector('.nt-spotify-transport');
if (!transport) return;
// Editor is a sibling of transport inside the wrapper, not a child
const wrapper = transport.closest('.nt-spotify-transport-wrapper') || transport.parentElement;
const editor = wrapper
? wrapper.querySelector('.nt-spotify-inline-editor')
: transport.querySelector('.nt-spotify-inline-editor');
if (!editor) return;
const isOpen = Boolean(open);
editor.style.display = isOpen ? 'flex' : 'none';
transport.dataset.editorOpen = isOpen ? '1' : '0';
setFrameFocusLock(isOpen);
if (isOpen) {
const input = editor.querySelector('.nt-spotify-top-url-input');
const savedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
if (input && doc.activeElement !== input) {
input.value = savedUrl;
window.setTimeout(() => {
try {
input.focus();
input.select();
} catch (error) {
// noop
}
}, 0);
}
} else if (!state.active && !state.pendingSelection) {
// Editor closed and Spotify isn't active — hide the transport bar
transport.style.display = 'none';
markSpotifyItemSelected(doc, false);
restoreNativeStationLabel(doc);
}
}
function toggleShuffleMode() {
state.queueMode = state.queueMode === 'shuffle' ? 'sequential' : 'shuffle';
setNtcfgMusicValue('QUEUE_MODE', state.queueMode);
if (state.queueMode === 'shuffle') {
if (state.queue.length > 1) {
generateShuffleOrder(state.queueIndex >= 0 ? state.queueIndex : 0);
}
toast('Shuffle ON');
} else {
state.shuffleOrder = [];
state.shufflePosition = -1;
toast('Shuffle OFF');
}
schedulePreResolveNextTrack(250);
}
function createSpotifyControlButton(doc, label, className, isPrimary) {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = className;
btn.textContent = label;
btn.style.cssText = [
'appearance:none',
`border:1px solid ${isPrimary ? 'rgba(29,185,84,0.6)' : 'rgba(255,255,255,0.22)'}`,
`background:${isPrimary ? 'linear-gradient(90deg, rgba(29,185,84,0.25), rgba(29,185,84,0.1))' : 'rgba(0,0,0,0.32)'}`,
'color:#fff',
'border-radius:6px',
'padding:4px 10px',
'font-size:11px',
'font-weight:600',
'cursor:pointer',
'line-height:1.2',
'white-space:nowrap',
'transition:opacity .15s ease',
].join(';');
return btn;
}
function createTransportIconButton(doc, className, iconType, isPrimary, ariaLabel) {
const btn = doc.createElement('button');
btn.type = 'button';
btn.className = className;
btn.setAttribute('aria-label', ariaLabel || iconType || 'control');
btn.style.cssText = [
'appearance:none',
'border:none',
'background:rgba(255,255,255,0.04)',
'color:#fff',
'border-radius:4px',
'width:28px',
'height:28px',
'display:inline-flex',
'align-items:center',
'justify-content:center',
'cursor:pointer',
'transition:opacity .15s ease, background-color .15s ease',
'padding:0',
'line-height:1',
'flex:0 0 auto',
].join(';');
btn.dataset.iconType = iconType;
const icon = createTransportIcon(doc, iconType);
if (icon) btn.appendChild(icon);
return btn;
}
function createTransportIcon(doc, type) {
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', '15');
svg.setAttribute('height', '15');
svg.setAttribute('aria-hidden', 'true');
svg.style.display = 'block';
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', '#ffffff');
if (type === 'play') {
path.setAttribute('d', 'M8 5v14l11-7z');
} else if (type === 'pause') {
path.setAttribute('d', 'M7 5h4v14H7zm6 0h4v14h-4z');
} else if (type === 'prev') {
path.setAttribute('d', 'M6 6h3v12H6zm4.5 6L18 6v12z');
} else if (type === 'next') {
path.setAttribute('d', 'M15 6h3v12h-3zM6 6l7.5 6L6 18z');
} else if (type === 'shuffle') {
path.setAttribute('d', 'M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zM14.83 13.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z');
} else if (type === 'link') {
path.setAttribute('d', 'M3.9 12c0-1.71 1.39-3.1 3.1-3.1h4V7H7c-2.76 0-5 2.24-5 5s2.24 5 5 5h4v-1.9H7c-1.71 0-3.1-1.39-3.1-3.1zM8 13h8v-2H8v2zm9-6h-4v1.9h4c1.71 0 3.1 1.39 3.1 3.1s-1.39 3.1-3.1 3.1h-4V17h4c2.76 0 5-2.24 5-5s-2.24-5-5-5z');
} else {
path.setAttribute('d', 'M8 5v14l11-7z');
}
svg.appendChild(path);
return svg;
}
function syncSpotifyControlValues(doc, item) {
if (!doc) return;
const transport = doc.querySelector('.nt-spotify-transport');
const wrapper = transport ? (transport.closest('.nt-spotify-transport-wrapper') || transport.parentElement) : null;
const input = wrapper
? wrapper.querySelector('.nt-spotify-top-url-input')
: (item ? item.querySelector('.nt-spotify-top-url-input') : null);
const savedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
if (input && doc.activeElement !== input) {
input.value = savedUrl;
}
}
async function toggleSpotifyPlaybackFromControls() {
if (!state.active) {
await activateSpotifyMode({ preferSavedOnly: true });
return;
}
if (getActivePlaybackEngine() === 'embed') {
void safeControllerCall('togglePlay');
} else {
if (state.playback.isPaused) {
safeYTCall('playVideo');
state.playback.isPaused = false;
state.playback.updatedAt = Date.now();
} else {
safeYTCall('pauseVideo');
syncPlaybackFromYT();
state.playback.isPaused = true;
state.playback.updatedAt = Date.now();
}
}
renderNowPlaying();
refreshSpotifyUI();
}
function syncTopTransportControls(doc) {
if (!doc) return;
const transport = doc.querySelector('.nt-spotify-transport');
if (!transport) return;
// Hide the entire transport bar when Spotify is not active
// (but keep it visible if the editor is open for URL entry)
const editorOpen = isInlineEditorOpen(transport);
transport.style.display = (isSpotifyUiSelected() || editorOpen) ? 'flex' : 'none';
if (!state.active) return;
if (isSpotifyUiSelected() && !areNativeStationEventsSuppressed() && looksLikeNativeStationSelection(doc)) return;
const playBtn = transport.querySelector('.nt-spotify-top-play-btn');
const prevBtn = transport.querySelector('.nt-spotify-top-prev-btn');
const nextBtn = transport.querySelector('.nt-spotify-top-next-btn');
const shuffleBtn = transport.querySelector('.nt-spotify-top-shuffle-btn');
if (playBtn) {
const iconType = state.playback.isPaused ? 'play' : 'pause';
const currentIcon = String(playBtn.dataset.iconType || '');
if (currentIcon !== iconType || !playBtn.querySelector('svg')) {
playBtn.dataset.iconType = iconType;
playBtn.replaceChildren(createTransportIcon(doc, iconType));
}
}
[prevBtn, nextBtn].forEach((btn) => {
if (!btn) return;
btn.disabled = false;
btn.style.opacity = '1';
btn.style.pointerEvents = 'auto';
});
if (shuffleBtn) {
const isShuffle = state.queueMode === 'shuffle';
shuffleBtn.style.opacity = isShuffle ? '1' : '0.65';
shuffleBtn.style.background = isShuffle
? 'rgba(29,185,84,0.28)'
: 'rgba(255,255,255,0.04)';
shuffleBtn.style.pointerEvents = 'auto';
shuffleBtn.disabled = false;
}
const savedSource = getSavedSourceParsed();
setCurrentStationLabel(doc, getPlatformLabel(savedSource), savedSource ? savedSource.platform : undefined);
}
async function parseAndSaveSourceFromInput(rawInput, startAfterSave) {
const parsed = parseSourceInput(rawInput);
if (!parsed) {
toast('Invalid URL — paste a Spotify, YouTube, or Apple Music link.');
focusSpotifyInput();
return null;
}
saveSource(parsed);
toast(getPlatformLabel(parsed) + ' source saved.');
refreshSpotifyUI();
if (startAfterSave) {
await activateSpotifyMode({ preferSavedOnly: true });
return parsed;
}
if (state.active) {
await loadSource(parsed.uri);
}
return parsed;
}
function clearSavedSource() {
const shouldKeepSpotifySelected = isSpotifyUiSelected();
if (state.active) {
state.active = false;
state.pendingSelection = shouldKeepSpotifySelected;
state.suppressNativeStationEventsUntil = 0;
clearSessionState();
stopProgressTimer();
void safeYTCall('setVolume', 0);
void safeYTCall('stopVideo');
void safeYTCall('pauseVideo');
if (state.embedController) void safeControllerCall('pause');
if (state.frameDoc) {
removeOverlay(state.frameDoc);
}
}
if (state.preCueTimer) {
window.clearTimeout(state.preCueTimer);
state.preCueTimer = null;
}
GM_deleteValue(STORAGE_KEYS.sourceUri);
setNtcfgMusicValue('SOURCE_URL', '');
state.trackMeta = null;
resetPlaybackState();
clearQueue();
state.preCuePromise = null;
state.preCueResult = null;
state.preCueSourceUri = '';
state.sourceTrackListCache.clear();
if (shouldKeepSpotifySelected && state.frameDoc) {
claimSpotifySelection(state.frameDoc, { openEditor: false });
}
toast('Saved source cleared.');
refreshSpotifyUI();
}
function focusSpotifyInput() {
const doc = state.frameDoc;
if (!doc) return;
const input = doc.querySelector('.nt-spotify-top-url-input') || doc.querySelector('.nt-spotify-top-url-input');
if (!input) return;
setInlineEditorOpen(doc, true);
input.focus();
input.select();
}
function getSpotifyMenuLabel() {
const saved = getSavedSourceParsed();
return getPlatformLabel(saved);
}
function markSpotifyItemSelected(doc, selected) {
if (!doc) return;
const item = doc.querySelector('.nt-spotify-item');
if (!item) return;
if (selected && isSpotifyUiSelected()) {
const controls = getNativeMusicControls(doc);
if (controls.selectedNative && !currentStationLooksSpotify(doc, controls) && !areNativeStationEventsSuppressed()) {
return;
}
}
if (selected) {
const rows = doc.querySelectorAll('.music-selector--list--item');
rows.forEach((row) => {
if (!row || row === item) return;
row.classList.remove('selected');
const rowNote = row.querySelector('.music-selector--list--broadcasting');
if (rowNote) rowNote.style.display = 'none';
});
}
item.classList.toggle('selected', Boolean(selected));
const note = item.querySelector('.nt-spotify-item-note');
if (note) {
note.style.display = selected ? 'flex' : 'none';
}
}
function refreshSpotifyUI() {
if (!state.frameDoc) return;
injectSpotifyItem(state.frameDoc);
injectTopTransportControls(state.frameDoc);
markSpotifyItemSelected(state.frameDoc, isSpotifyUiSelected());
const item = state.frameDoc.querySelector('.nt-spotify-item');
if (item) {
syncSpotifyControlValues(state.frameDoc, item);
}
syncTopTransportControls(state.frameDoc);
syncNativeMusicControls(state.frameDoc);
}
function setCurrentStationLabel(doc, text, platform) {
if (!doc) return;
const node = doc.querySelector('.music-selector--current > .music-selector--indicator + div');
if (!node) return;
// Save original label before first overwrite
if (!state.originalStationLabel && node.textContent !== text) {
state.originalStationLabel = node.textContent;
}
node.textContent = text;
// Replace the 3-dot indicator with the platform icon
var indicator = doc.querySelector('.music-selector--current > .music-selector--indicator');
if (indicator) {
// Determine platform from arg or from saved source
var savedSource = getSavedSourceParsed();
var plat = platform || (savedSource && savedSource.platform) || getDefaultPlatform() || 'spotify';
var currentPlat = indicator.getAttribute('data-platform') || '';
if (currentPlat !== plat) {
// Save original indicator HTML before first replacement
if (!state.originalIndicatorHTML) {
state.originalIndicatorHTML = indicator.innerHTML;
}
indicator.innerHTML = '';
indicator.style.display = 'inline-flex';
indicator.style.alignItems = 'center';
indicator.style.justifyContent = 'center';
var icon = createPlatformIcon(doc, 14, plat);
indicator.appendChild(icon);
indicator.setAttribute('data-platform', plat);
}
}
}
function restoreNativeStationLabel(doc) {
if (!doc) return;
const indicator = doc.querySelector('.music-selector--current > .music-selector--indicator');
const node = doc.querySelector('.music-selector--current > .music-selector--indicator + div');
const currentPlatform = String(indicator && indicator.getAttribute('data-platform') || '').trim().toLowerCase();
const currentLabel = String(node && node.textContent || '').trim();
const expectedSpotifyLabel = String(getPlatformLabel(getSavedSourceParsed()) || 'Spotify').trim().toLowerCase();
// Try to read the currently-selected native station's name
const selectedNative = doc.querySelector('.music-selector--list--item.selected:not(.nt-spotify-item)');
if (selectedNative) {
const nameEl = selectedNative.querySelector('.music-selector--list--name');
const nativeName = nameEl ? String(nameEl.textContent || '').trim() : '';
if (nativeName) {
if (node) node.textContent = nativeName;
}
} else if (
node &&
currentLabel &&
!isManagedStationPlatform(currentPlatform) &&
currentLabel.toLowerCase() !== 'spotify' &&
currentLabel.toLowerCase() !== expectedSpotifyLabel
) {
// Nitro has already rendered the new native label — keep it as-is.
} else if (state.originalStationLabel) {
// Fallback: restore saved original
if (node) node.textContent = state.originalStationLabel;
}
// Restore original 3-dot indicator
if (indicator && state.originalIndicatorHTML) {
indicator.innerHTML = state.originalIndicatorHTML;
indicator.style.display = '';
indicator.style.alignItems = '';
indicator.style.justifyContent = '';
indicator.removeAttribute('data-platform');
}
}
function createSpotifyIcon(doc, size) {
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('aria-hidden', 'true');
svg.style.display = 'block';
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', '#1DB954');
path.setAttribute(
'd',
'M12 0C5.4 0 0 5.4 0 12s5.4 12 12 12 12-5.4 12-12S18.66 0 12 0zm5.52 17.34a.72.72 0 0 1-1.02.24c-2.82-1.74-6.36-2.1-10.56-1.14a.72.72 0 0 1-.36-1.4c4.56-1.02 8.52-.6 11.64 1.32.36.24.48.72.3 1.02zm1.44-3.3a.9.9 0 0 1-1.26.3c-3.24-1.98-8.16-2.58-11.94-1.38a.9.9 0 1 1-.54-1.72c4.32-1.32 9.72-.66 13.44 1.62.42.24.6.78.3 1.2zm.12-3.36c-3.9-2.34-10.32-2.52-13.98-1.38a1.08 1.08 0 1 1-.66-2.04c4.26-1.26 11.28-1.02 15.72 1.62.48.3.66.96.42 1.44-.3.42-.96.6-1.5.36z'
);
svg.appendChild(path);
return svg;
}
function createYouTubeIcon(doc, size) {
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('aria-hidden', 'true');
svg.style.display = 'block';
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', '#FF0000');
path.setAttribute(
'd',
'M23.5 6.2a3 3 0 0 0-2.1-2.1C19.5 3.6 12 3.6 12 3.6s-7.5 0-9.4.5A3 3 0 0 0 .5 6.2C0 8.1 0 12 0 12s0 3.9.5 5.8a3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1c.5-1.9.5-5.8.5-5.8s0-3.9-.5-5.8zM9.6 15.6V8.4l6.3 3.6-6.3 3.6z'
);
svg.appendChild(path);
return svg;
}
function createAppleMusicIcon(doc, size) {
const svg = doc.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('width', String(size));
svg.setAttribute('height', String(size));
svg.setAttribute('aria-hidden', 'true');
svg.style.display = 'block';
const path = doc.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('fill', '#FA2D48');
path.setAttribute(
'd',
'M23.99 6.07a6.16 6.16 0 0 0-.14-1.43 3.77 3.77 0 0 0-.6-1.22 3.65 3.65 0 0 0-1.22-.91 3.7 3.7 0 0 0-1.27-.42A13.3 13.3 0 0 0 18.7 2H5.3a12.6 12.6 0 0 0-2.06.1 3.7 3.7 0 0 0-1.27.41 3.52 3.52 0 0 0-1.22.92 3.72 3.72 0 0 0-.6 1.22A6 6 0 0 0 .01 6.07C0 6.68 0 7.29 0 7.91v8.18c0 .62 0 1.23.01 1.84a6.16 6.16 0 0 0 .14 1.43 3.77 3.77 0 0 0 .6 1.22 3.52 3.52 0 0 0 1.22.92 3.7 3.7 0 0 0 1.27.41c.68.08 1.37.1 2.06.1H18.7c.69 0 1.38-.02 2.06-.1a3.7 3.7 0 0 0 1.27-.42 3.65 3.65 0 0 0 1.22-.91 3.72 3.72 0 0 0 .6-1.22 6 6 0 0 0 .14-1.44c.01-.6.01-1.21.01-1.83V7.9c0-.61 0-1.22-.01-1.83zM16.94 13.7v2.23a2 2 0 0 1-.04.4 1.39 1.39 0 0 1-.55.89 1.81 1.81 0 0 1-.86.38 2.29 2.29 0 0 1-.92-.02 1.43 1.43 0 0 1-.74-.44 1.18 1.18 0 0 1-.3-.73 1.26 1.26 0 0 1 .2-.82 1.58 1.58 0 0 1 .73-.55c.37-.14.75-.23 1.13-.32.35-.08.52-.25.55-.61V10.7l-.01-.19a.63.63 0 0 0-.3-.46.8.8 0 0 0-.44-.13l-.13.02-4.23.97a.72.72 0 0 0-.56.57l-.01.14v5.63a2.06 2.06 0 0 1-.04.42 1.4 1.4 0 0 1-.55.87 1.79 1.79 0 0 1-.86.39 2.32 2.32 0 0 1-.92-.02 1.41 1.41 0 0 1-.74-.44 1.2 1.2 0 0 1-.3-.73 1.28 1.28 0 0 1 .2-.83 1.59 1.59 0 0 1 .73-.54c.37-.14.76-.23 1.13-.33.34-.08.51-.25.54-.6V8.94a1.6 1.6 0 0 1 .1-.56 1.1 1.1 0 0 1 .7-.7l4.84-1.27a2.1 2.1 0 0 1 .43-.08.68.68 0 0 1 .59.26.83.83 0 0 1 .15.4c.01.08.02.16.02.24v6.48z'
);
svg.appendChild(path);
return svg;
}
function createPlatformIcon(doc, size, platform) {
if (platform === 'youtube') return createYouTubeIcon(doc, size);
if (platform === 'apple-music') return createAppleMusicIcon(doc, size);
return createSpotifyIcon(doc, size);
}
function clearSourceTrackListCache(sourceUri) {
const key = String(sourceUri || '').trim();
if (!key) return;
state.sourceTrackListCache.delete(`${key}::tracks`);
}
function invalidatePreCueState() {
if (state.preCueTimer) {
window.clearTimeout(state.preCueTimer);
state.preCueTimer = null;
}
state.preCuePromise = null;
state.preCueResult = null;
state.preCueSourceUri = '';
}
function getSavedSourceParsed() {
const savedUri = String(GM_getValue(STORAGE_KEYS.sourceUri, '') || '').trim();
const savedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
if (!savedUri && !savedUrl) return null;
const parsedFromSaved = (savedUri ? parseSourceInput(savedUri) : null)
|| (savedUrl ? parseSourceInput(savedUrl) : null);
if (parsedFromSaved) {
// Merge stored open URL if the parsed result has none (internal URIs
// like apple-music:playlist:xxx can't reconstruct the full URL).
if (!parsedFromSaved.openUrl && savedUrl) {
parsedFromSaved.openUrl = savedUrl;
}
return parsedFromSaved;
}
return null;
}
async function ensureSourceConfigured() {
const savedUrl = String(GM_getValue(STORAGE_KEYS.sourceUrl, '') || '').trim();
const fromSaved = getSavedSourceParsed();
if (fromSaved) return fromSaved;
const input = window.prompt('Paste a Spotify, YouTube, or Apple Music URL:', savedUrl || '');
if (!input) return null;
const parsed = parseSourceInput(input);
if (!parsed) {
toast('Invalid URL — paste a Spotify, YouTube, or Apple Music link.');
return null;
}
saveSource(parsed);
refreshSpotifyUI();
return parsed;
}
function saveSource(parsed, options = {}) {
const previousSavedUri = String(GM_getValue(STORAGE_KEYS.sourceUri, '') || '').trim();
traceMusic('save-source', {
previousSavedUri,
nextSourceUri: parsed ? parsed.uri : '',
nextPlatform: parsed ? parsed.platform : '',
nextType: parsed ? parsed.type : '',
});
GM_setValue(STORAGE_KEYS.sourceUri, parsed.uri);
// Only overwrite the stored URL if we have a real one — internal URIs
// (e.g. apple-music:playlist:xxx) can't reconstruct a usable open URL,
// so we preserve whatever was stored from the original paste.
if (parsed.openUrl) {
setNtcfgMusicValue('SOURCE_URL', parsed.openUrl);
}
clearSourceTrackListCache(parsed.uri);
if (previousSavedUri && previousSavedUri !== parsed.uri) {
clearSourceTrackListCache(previousSavedUri);
}
invalidatePreCueState();
if (options.schedulePreCue !== false && musicRuntimeStarted) {
schedulePreCueFromSavedSource(150);
}
}
// ─── Session persistence ─────────────────────────────────────────────
// Saves enough state so that after a page reload the player can resume
// at the same track and approximate position without user interaction.
function saveSessionState() {
if (!state.active || !state.queue.length) return;
GM_setValue(STORAGE_KEYS.sessionActive, true);
GM_setValue(STORAGE_KEYS.queueIndex, state.queueIndex);
GM_setValue(STORAGE_KEYS.queueMode, state.queueMode || 'shuffle');
// Save current playback position in seconds
var posSec = Math.max(0, Math.floor(Number(state.playback.position || 0) / 1000) || 0);
var player = state.ytPlayer;
if (player && state.ytPlayerReady && typeof player.getCurrentTime === 'function') {
const ytPosSec = Math.max(0, Math.floor(Number(player.getCurrentTime()) || 0));
if (ytPosSec > 0 || posSec === 0) {
posSec = ytPosSec;
}
}
GM_setValue(STORAGE_KEYS.playbackPositionSec, posSec);
// Save shuffle state
if (state.queueMode === 'shuffle' && state.shuffleOrder.length) {
GM_setValue(STORAGE_KEYS.shuffleOrder, JSON.stringify(state.shuffleOrder));
GM_setValue(STORAGE_KEYS.shufflePosition, state.shufflePosition);
}
}
function clearSessionState() {
GM_setValue(STORAGE_KEYS.sessionActive, false);
GM_setValue(STORAGE_KEYS.queueIndex, 0);
GM_setValue(STORAGE_KEYS.playbackPositionSec, 0);
GM_setValue(STORAGE_KEYS.shuffleOrder, '');
GM_setValue(STORAGE_KEYS.shufflePosition, -1);
}
function getSavedSessionState() {
// Respect the session resume toggle from mod menu
if (!isMusicSessionResumeEnabled()) return null;
var active = Boolean(GM_getValue(STORAGE_KEYS.sessionActive, false));
if (!active) return null;
var queueIndex = parseInt(GM_getValue(STORAGE_KEYS.queueIndex, 0), 10) || 0;
var playbackPositionSec = parseInt(GM_getValue(STORAGE_KEYS.playbackPositionSec, 0), 10) || 0;
var queueMode = String(GM_getValue(STORAGE_KEYS.queueMode, 'shuffle') || 'shuffle');
var shuffleOrderRaw = String(GM_getValue(STORAGE_KEYS.shuffleOrder, '') || '');
var shufflePosition = parseInt(GM_getValue(STORAGE_KEYS.shufflePosition, -1), 10);
var shuffleOrder = [];
if (shuffleOrderRaw) {
try { shuffleOrder = JSON.parse(shuffleOrderRaw); } catch (_) { }
if (!Array.isArray(shuffleOrder)) shuffleOrder = [];
}
return {
queueIndex: queueIndex,
playbackPositionSec: playbackPositionSec,
queueMode: queueMode,
shuffleOrder: shuffleOrder,
shufflePosition: isNaN(shufflePosition) ? -1 : shufflePosition,
};
}
function parseSpotifyInput(input) {
const raw = String(input || '').trim();
if (!raw) return null;
const uriMatch = raw.match(/^spotify:(track|album|playlist):([A-Za-z0-9]{22})$/i);
if (uriMatch) {
const type = uriMatch[1].toLowerCase();
const id = uriMatch[2];
return {
type,
id,
uri: `spotify:${type}:${id}`,
openUrl: `https://open.spotify.com/${type}/${id}`,
};
}
try {
const url = new URL(raw);
const host = url.hostname.toLowerCase();
if (host !== 'open.spotify.com' && host !== 'play.spotify.com') {
return null;
}
const parts = url.pathname.split('/').filter(Boolean);
if (!parts.length) return null;
let offset = 0;
if (parts[0] === 'embed') offset = 1;
const type = (parts[offset] || '').toLowerCase();
const id = (parts[offset + 1] || '').split('?')[0];
if (!id) return null;
if (type !== 'track' && type !== 'album' && type !== 'playlist') return null;
return {
type,
id,
uri: `spotify:${type}:${id}`,
openUrl: `https://open.spotify.com/${type}/${id}`,
};
} catch (error) {
return null;
}
}
function spotifyUriToOpenUrl(uri) {
const parsed = parseSpotifyInput(uri);
return parsed ? parsed.openUrl : '';
}
// ─── YouTube URL Parser ─────────────────────────────────────────────
function parseYouTubeInput(input) {
const raw = String(input || '').trim();
if (!raw) return null;
// Handle internal URIs: youtube:video:VIDEO_ID or youtube:playlist:PLAYLIST_ID
const internalMatch = raw.match(/^youtube:(video|playlist):(.+)$/);
if (internalMatch) {
const type = internalMatch[1] === 'video' ? 'track' : 'playlist';
const id = internalMatch[2];
if (type === 'track' && /^[A-Za-z0-9_-]{11}$/.test(id)) {
return {
platform: 'youtube',
type: 'track',
id: id,
uri: `youtube:video:${id}`,
openUrl: `https://www.youtube.com/watch?v=${id}`,
};
}
if (type === 'playlist' && /^[A-Za-z0-9_-]{10,}$/.test(id)) {
return {
platform: 'youtube',
type: 'playlist',
id: id,
uri: `youtube:playlist:${id}`,
openUrl: `https://www.youtube.com/playlist?list=${id}`,
};
}
}
try {
const url = new URL(raw);
const host = url.hostname.toLowerCase().replace(/^www\./, '');
// youtube.com / music.youtube.com / m.youtube.com
if (host === 'youtube.com' || host === 'music.youtube.com' || host === 'm.youtube.com') {
// Standard watch URL: ?v=VIDEO_ID
const v = url.searchParams.get('v');
if (v && /^[A-Za-z0-9_-]{11}$/.test(v)) {
return {
platform: 'youtube',
type: 'track',
id: v,
uri: `youtube:video:${v}`,
openUrl: `https://www.youtube.com/watch?v=${v}`,
};
}
// Shorts: /shorts/VIDEO_ID
const shortsMatch = url.pathname.match(/^\/shorts\/([A-Za-z0-9_-]{11})/);
if (shortsMatch) {
return {
platform: 'youtube',
type: 'track',
id: shortsMatch[1],
uri: `youtube:video:${shortsMatch[1]}`,
openUrl: `https://www.youtube.com/watch?v=${shortsMatch[1]}`,
};
}
// Embed: /embed/VIDEO_ID
const embedMatch = url.pathname.match(/^\/embed\/([A-Za-z0-9_-]{11})/);
if (embedMatch) {
return {
platform: 'youtube',
type: 'track',
id: embedMatch[1],
uri: `youtube:video:${embedMatch[1]}`,
openUrl: `https://www.youtube.com/watch?v=${embedMatch[1]}`,
};
}
// Playlist: ?list=PLAYLIST_ID
const list = url.searchParams.get('list');
if (list && /^[A-Za-z0-9_-]{10,}$/.test(list)) {
return {
platform: 'youtube',
type: 'playlist',
id: list,
uri: `youtube:playlist:${list}`,
openUrl: `https://www.youtube.com/playlist?list=${list}`,
};
}
}
// youtu.be short URLs
if (host === 'youtu.be') {
const id = url.pathname.slice(1).split('/')[0].split('?')[0];
if (id && /^[A-Za-z0-9_-]{11}$/.test(id)) {
return {
platform: 'youtube',
type: 'track',
id: id,
uri: `youtube:video:${id}`,
openUrl: `https://www.youtube.com/watch?v=${id}`,
};
}
}
} catch (_) {
// Not a URL
}
return null;
}
// ─── Apple Music URL Parser ─────────────────────────────────────────
function parseAppleMusicInput(input) {
const raw = String(input || '').trim();
if (!raw) return null;
// Handle internal URIs: apple-music:track:ID, apple-music:album:ID, apple-music:playlist:ID
const internalMatch = raw.match(/^apple-music:(track|album|playlist):(.+)$/);
if (internalMatch) {
const type = internalMatch[1];
const id = internalMatch[2];
return {
platform: 'apple-music',
type: type,
id: id,
uri: `apple-music:${type}:${id}`,
openUrl: '', // No URL to reconstruct — the saved URL will be used as fallback
};
}
try {
const url = new URL(raw);
const host = url.hostname.toLowerCase();
if (host !== 'music.apple.com') return null;
// Paths: /{country}/album/{slug}/{albumId}?i={trackId}
// /{country}/album/{slug}/{albumId}
// /{country}/playlist/{slug}/{playlistId}
// /{country}/song/{slug}/{songId}
const parts = url.pathname.split('/').filter(Boolean);
if (parts.length < 3) return null;
const resourceType = parts[1];
if (resourceType === 'album') {
// albumId is the last numeric segment
const albumId = parts.length >= 4 ? parts[3] : parts[2];
if (!albumId || !/^\d+$/.test(albumId)) return null;
// ?i=trackId → single track from album
const trackId = url.searchParams.get('i');
if (trackId && /^\d+$/.test(trackId)) {
return {
platform: 'apple-music',
type: 'track',
id: trackId,
albumId: albumId,
uri: `apple-music:track:${trackId}`,
openUrl: raw,
};
}
// Whole album
return {
platform: 'apple-music',
type: 'album',
id: albumId,
uri: `apple-music:album:${albumId}`,
openUrl: raw,
};
}
if (resourceType === 'song') {
const songId = parts.length >= 4 ? parts[3] : parts[2];
if (!songId || !/^\d+$/.test(songId)) return null;
return {
platform: 'apple-music',
type: 'track',
id: songId,
uri: `apple-music:track:${songId}`,
openUrl: raw,
};
}
if (resourceType === 'playlist') {
const playlistId = parts.length >= 4 ? parts[3] : parts[2];
if (!playlistId) return null;
return {
platform: 'apple-music',
type: 'playlist',
id: playlistId,
uri: `apple-music:playlist:${playlistId}`,
openUrl: raw,
};
}
return null;
} catch (_) {
return null;
}
}
// ─── Unified Source Parser ──────────────────────────────────────────
// Handles Spotify, YouTube, and Apple Music inputs.
// Returns { platform, type, id, uri, openUrl, ... } or null.
function parseSourceInput(input) {
// Spotify first (backward compatible — most common case)
const spotify = parseSpotifyInput(input);
if (spotify) {
spotify.platform = 'spotify';
return spotify;
}
// YouTube
const youtube = parseYouTubeInput(input);
if (youtube) return youtube;
// Apple Music
const apple = parseAppleMusicInput(input);
if (apple) return apple;
return null;
}
// ─── Platform-agnostic URI → open URL ───────────────────────────────
function sourceUriToOpenUrl(uri) {
const parsed = parseSourceInput(uri);
return parsed ? parsed.openUrl : '';
}
// ─── Platform display name from parsed source ──────────────────────
function getPlatformLabel(parsedOrUri) {
const p = typeof parsedOrUri === 'string'
? parseSourceInput(parsedOrUri)
: parsedOrUri;
const fallbackPlatform = getDefaultPlatform();
const fallbackLabel = fallbackPlatform === 'youtube' ? 'YouTube'
: fallbackPlatform === 'apple-music' ? 'Apple Music'
: 'Spotify';
if (!p) return fallbackLabel;
if (p.platform === 'youtube') return 'YouTube';
if (p.platform === 'apple-music') return 'Apple Music';
if (p.platform === 'spotify') return 'Spotify';
return fallbackLabel;
}
// ─── YouTube Video Metadata (via noembed) ───────────────────────────
function fetchYouTubeVideoMeta(videoId) {
return new Promise(function (resolve) {
var fallback = { title: 'YouTube Video', artist: '', thumbnail: '' };
GM_xmlhttpRequest({
method: 'GET',
url: 'https://noembed.com/embed?url=https://www.youtube.com/watch?v=' + videoId,
timeout: 8000,
onload: function (resp) {
const data = safeJsonParse(resp.responseText);
if (data && data.title) {
resolve({
title: String(data.title || ''),
artist: String(data.author_name || ''),
thumbnail: String(data.thumbnail_url || ''),
});
} else {
resolve(fallback);
}
},
onerror: function () { resolve(fallback); },
ontimeout: function () { resolve(fallback); },
});
});
}
// ─── Apple Music Metadata (via iTunes Lookup API) ───────────────────
function fetchAppleMusicTrackMeta(trackId) {
return new Promise(function (resolve) {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://itunes.apple.com/lookup?id=' + encodeURIComponent(trackId),
timeout: 10000,
onload: function (resp) {
const data = safeJsonParse(resp.responseText);
if (data && data.results && data.results.length) {
const t = data.results[0];
resolve({
title: String(t.trackName || t.collectionName || ''),
artist: String(t.artistName || ''),
durationMs: Number(t.trackTimeMillis) || 0,
art: String(t.artworkUrl100 || '').replace('100x100', '300x300'),
openUrl: String(t.trackViewUrl || ''),
});
} else {
resolve(null);
}
},
onerror: function () { resolve(null); },
ontimeout: function () { resolve(null); },
});
});
}
function fetchAppleMusicAlbumTracks(albumId) {
return new Promise(function (resolve) {
GM_xmlhttpRequest({
method: 'GET',
url: 'https://itunes.apple.com/lookup?id=' + encodeURIComponent(albumId) + '&entity=song',
timeout: 15000,
onload: function (resp) {
const data = safeJsonParse(resp.responseText);
if (data && data.results && data.results.length > 1) {
const tracks = data.results.filter(function (r) { return r.wrapperType === 'track'; });
resolve(tracks.map(function (t) {
return {
title: String(t.trackName || ''),
artist: String(t.artistName || ''),
durationMs: Number(t.trackTimeMillis) || 0,
art: String(t.artworkUrl100 || '').replace('100x100', '300x300'),
openUrl: String(t.trackViewUrl || ''),
appleMusicId: String(t.trackId || ''),
};
}));
} else {
resolve([]);
}
},
onerror: function () { resolve([]); },
ontimeout: function () { resolve([]); },
});
});
}
// ─── Apple Music Playlist Scraping ───────────────────────────────────
async function fetchAppleMusicPlaylistTracks(playlistId, playlistUrl) {
var cacheKey = 'apple-music:playlist:' + playlistId + '::tracks';
if (state.sourceTrackListCache.has(cacheKey)) {
return state.sourceTrackListCache.get(cacheKey);
}
try {
var url = playlistUrl || ('https://music.apple.com/us/playlist/' + playlistId);
var response = await gmRequest({
method: 'GET',
url: url,
headers: {
Accept: 'text/html',
},
timeout: 20000,
});
if (response.status < 200 || response.status >= 300) return [];
var html = String(response.responseText || '');
if (!html) return [];
// Strategy 1: Extract from embedded JSON in <script> tags
var tracks = extractAppleMusicTracksFromServerData(html);
// Strategy 2: Extract from track-lockup regex patterns
if (!tracks.length) {
tracks = extractAppleMusicTracksFromLockups(html);
}
// Strategy 3: Extract from JSON-LD structured data
if (!tracks.length) {
tracks = extractAppleMusicTracksFromMetaTags(html);
}
if (!tracks.length) return [];
// Enrich tracks that have Apple Music IDs via iTunes Lookup
await enrichAppleMusicTracks(tracks);
if (tracks.length) {
state.sourceTrackListCache.set(cacheKey, tracks);
}
return tracks;
} catch (error) {
console.warn('[NT Music] Apple Music fetch failed:', error.message || error);
return [];
}
}
function extractAppleMusicTracksFromServerData(html) {
var tracks = [];
var seen = new Set();
try {
var doc = new DOMParser().parseFromString(html, 'text/html');
var scriptNodes = doc.querySelectorAll('script');
for (var i = 0; i < scriptNodes.length; i += 1) {
var content = String(scriptNodes[i].textContent || '').trim();
if (!content) continue;
// Look for script content that contains song/track data
if (!/"songs"|"type":"songs"|trackName|songName|"name"/.test(content)) continue;
var decoded = decodeAppleMusicPayload(content);
if (!decoded) continue;
var extracted = traverseForAppleMusicTracks(decoded, seen);
if (extracted.length) {
for (var j = 0; j < extracted.length; j += 1) {
tracks.push(extracted[j]);
}
}
}
// Also check meta[name="serialized-server-data"] attributes
var metaNodes = doc.querySelectorAll('meta[name="serialized-server-data"]');
for (var m = 0; m < metaNodes.length; m += 1) {
var metaContent = String(metaNodes[m].getAttribute('content') || '');
var metaDecoded = decodeAppleMusicPayload(metaContent);
if (!metaDecoded) continue;
var metaExtracted = traverseForAppleMusicTracks(metaDecoded, seen);
if (metaExtracted.length) {
for (var k = 0; k < metaExtracted.length; k += 1) {
tracks.push(metaExtracted[k]);
}
}
}
} catch (error) {
// noop
}
return tracks;
}
function decodeAppleMusicPayload(content) {
var raw = String(content || '').trim();
if (!raw) return null;
// Direct JSON
var direct = safeJsonParse(raw);
if (direct) return direct;
// Base64 decode
var b64 = decodeBase64ToText(raw);
if (b64) {
var parsed64 = safeJsonParse(b64);
if (parsed64) return parsed64;
}
// URL decode
try {
var urlDecoded = decodeURIComponent(raw);
if (urlDecoded && urlDecoded !== raw) {
var parsedUrl = safeJsonParse(urlDecoded);
if (parsedUrl) return parsedUrl;
}
} catch (_) { }
// Extract JSON object from text
var extracted = extractJsonObjectFromText(raw);
if (extracted) return safeJsonParse(extracted);
return null;
}
function traverseForAppleMusicTracks(root, seen) {
if (!root || typeof root !== 'object') return [];
var seenObjects = new Set();
var stack = [root];
var tracks = [];
var safety = 0;
while (stack.length && safety < 200000) {
var value = stack.pop();
safety += 1;
if (!value || typeof value !== 'object') continue;
if (seenObjects.has(value)) continue;
seenObjects.add(value);
var track = parseAppleMusicTrackLikeObject(value);
if (track && !seen.has(track.dedupeKey)) {
seen.add(track.dedupeKey);
tracks.push(track);
}
if (Array.isArray(value)) {
for (var i = value.length - 1; i >= 0; i -= 1) {
if (value[i] && typeof value[i] === 'object') stack.push(value[i]);
}
continue;
}
var keys = Object.keys(value);
for (var k = keys.length - 1; k >= 0; k -= 1) {
var child = value[keys[k]];
if (child && typeof child === 'object') stack.push(child);
}
}
return tracks;
}
function parseAppleMusicTrackLikeObject(value) {
if (!value || typeof value !== 'object') return null;
var title = '';
var artist = '';
var appleMusicId = '';
var durationMs = 0;
var art = '';
// Format 1: Apple Music API-style — { type: "songs", attributes: { name, artistName } }
if (value.type === 'songs' && value.attributes && typeof value.attributes === 'object') {
var attrs = value.attributes;
title = String(attrs.name || '').trim();
artist = String(attrs.artistName || '').trim();
durationMs = Number(attrs.durationInMillis) || 0;
appleMusicId = String(value.id || '').trim();
if (attrs.artwork && attrs.artwork.url) {
art = String(attrs.artwork.url || '')
.replace('{w}', '300').replace('{h}', '300')
.replace('{f}', 'jpg').replace('{c}', 'bb');
}
}
// Format 2: songName / trackName / title / name with artistName
if (!title) {
title = String(value.songName || value.trackName || '').trim();
// Apple Music playlist items use flat 'title' key
if (!title && value.title && typeof value.title === 'string') {
title = String(value.title).trim();
}
if (!title && value.name && typeof value.name === 'string' && value.artistName) {
title = String(value.name).trim();
}
artist = String(value.artistName || value.artist_name || value.byline || '').trim();
// Fallback: subtitleLinks[0].title (Apple Music playlist format)
if (!artist && Array.isArray(value.subtitleLinks) && value.subtitleLinks.length) {
artist = String((value.subtitleLinks[0] && value.subtitleLinks[0].title) || '').trim();
}
appleMusicId = String(value.trackId || value.songId || value.adamId || '').trim();
// Apple Music playlist items store ID in contentDescriptor.identifiers.storeAdamID
if (!appleMusicId && value.contentDescriptor && value.contentDescriptor.identifiers) {
appleMusicId = String(value.contentDescriptor.identifiers.storeAdamID || '').trim();
}
// Fallback: extract numeric ID from track-lockup id format ("track-lockup - pl.xxx - 1234567890")
if (!appleMusicId && typeof value.id === 'string') {
var idParts = value.id.match(/(\d{6,})$/);
if (idParts) appleMusicId = idParts[1];
}
durationMs = Number(value.durationInMillis || value.trackTimeMillis || value.duration) || 0;
// Apple Music playlist artwork: artwork.dictionary.url
if (!art && value.artwork && value.artwork.dictionary && value.artwork.dictionary.url) {
art = String(value.artwork.dictionary.url || '')
.replace('{w}', '300').replace('{h}', '300')
.replace('{f}', 'jpg').replace('{c}', 'bb');
}
}
if (!title) return null;
// Reject if the "title" is too short or looks like a non-track field
if (title.length < 2) return null;
// Require artist to be present for confidence
if (!artist) return null;
// Only accept numeric Apple Music IDs
if (appleMusicId && !/^\d+$/.test(appleMusicId)) appleMusicId = '';
var dedupeKey = appleMusicId
? 'apple-music:track:' + appleMusicId
: 'apple-music-title:' + title.toLowerCase() + ':' + artist.toLowerCase();
return { title: title, artist: artist, appleMusicId: appleMusicId, durationMs: durationMs, art: art, openUrl: '', dedupeKey: dedupeKey };
}
function extractAppleMusicTracksFromLockups(html) {
var tracks = [];
var seen = new Set();
// Use extractJsonBlocksByKey to find track-lockup blocks — handles nested JSON
var blocks = extractJsonBlocksByKey(html, '"track-lockup', 200);
for (var i = 0; i < blocks.length; i += 1) {
var obj = safeJsonParse(blocks[i]);
if (!obj) continue;
var title = String(obj.title || '').trim();
if (!title) continue;
var artistName = String(obj.artistName || '').trim();
if (!artistName && Array.isArray(obj.subtitleLinks)) {
artistName = obj.subtitleLinks
.map(function (link) { return String((link && link.title) || '').trim(); })
.filter(Boolean)
.join(', ');
} else if (!artistName && typeof obj.subtitle === 'string') {
artistName = String(obj.subtitle).trim();
}
var amId = '';
if (obj.contentDescriptor && obj.contentDescriptor.identifiers) {
amId = String(obj.contentDescriptor.identifiers.storeAdamID || '').trim();
}
if (!amId && typeof obj.id === 'string') {
var idMatch = obj.id.match(/(\d{6,})$/);
if (idMatch) amId = idMatch[1];
}
var durationMs = Number(obj.duration) || 0;
var art = '';
if (obj.artwork && obj.artwork.dictionary && obj.artwork.dictionary.url) {
art = String(obj.artwork.dictionary.url || '')
.replace('{w}', '300').replace('{h}', '300')
.replace('{f}', 'jpg').replace('{c}', 'bb');
}
var dedupeKey = amId
? 'apple-music:track:' + amId
: 'apple-music-title:' + title.toLowerCase() + ':' + artistName.toLowerCase();
if (seen.has(dedupeKey)) continue;
seen.add(dedupeKey);
tracks.push({
title: title,
artist: artistName,
appleMusicId: amId,
durationMs: durationMs,
art: art,
openUrl: '',
dedupeKey: dedupeKey,
});
}
return tracks;
}
function extractAppleMusicTracksFromMetaTags(html) {
var tracks = [];
try {
var doc = new DOMParser().parseFromString(html, 'text/html');
// Look for JSON-LD structured data
var ldScripts = doc.querySelectorAll('script[type="application/ld+json"]');
for (var i = 0; i < ldScripts.length; i += 1) {
var data = safeJsonParse(ldScripts[i].textContent);
if (!data) continue;
// MusicPlaylist or MusicAlbum schema
if ((data['@type'] === 'MusicPlaylist' || data['@type'] === 'MusicAlbum') && Array.isArray(data.track)) {
data.track.forEach(function (t) {
if (!t || !t.name) return;
var title = String(t.name || '').trim();
var artist = '';
if (t.byArtist) {
artist = String(
typeof t.byArtist === 'string' ? t.byArtist
: (t.byArtist.name || '')
).trim();
}
// Extract Apple Music ID from URL: https://music.apple.com/us/song/track-name/1234567890
var trackUrl = String(t.url || '').trim();
var appleMusicId = '';
var idFromUrl = trackUrl.match(/\/(\d{6,})(?:\?|$)/);
if (idFromUrl) appleMusicId = idFromUrl[1];
// Extract duration from ISO 8601 (e.g. "PT3M45S")
var durationMs = 0;
if (t.duration) {
var durMatch = String(t.duration).match(/PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?/i);
if (durMatch) {
durationMs = ((parseInt(durMatch[1] || '0', 10) * 3600)
+ (parseInt(durMatch[2] || '0', 10) * 60)
+ (parseInt(durMatch[3] || '0', 10))) * 1000;
}
}
tracks.push({
title: title,
artist: artist,
appleMusicId: appleMusicId,
durationMs: durationMs,
art: '',
openUrl: trackUrl,
dedupeKey: appleMusicId
? 'apple-music:track:' + appleMusicId
: 'apple-music-title:' + title.toLowerCase() + ':' + artist.toLowerCase(),
});
});
}
}
} catch (_) { }
return tracks;
}
async function enrichAppleMusicTracks(tracks) {
// Collect tracks with numeric Apple Music IDs for batch lookup
var withIds = tracks.filter(function (t) {
return t.appleMusicId && /^\d+$/.test(t.appleMusicId);
});
if (!withIds.length) return;
// iTunes Lookup API supports multiple IDs per request
var batchSize = 150;
for (var offset = 0; offset < withIds.length; offset += batchSize) {
var batch = withIds.slice(offset, offset + batchSize);
var ids = batch.map(function (t) { return t.appleMusicId; }).join(',');
try {
var response = await gmRequest({
method: 'GET',
url: 'https://itunes.apple.com/lookup?id=' + encodeURIComponent(ids),
headers: { Accept: 'application/json' },
timeout: 15000,
});
if (response.status < 200 || response.status >= 300) continue;
var data = safeJsonParse(response.responseText);
if (!data || !Array.isArray(data.results)) continue;
// Index results by trackId
var lookup = {};
data.results.forEach(function (r) {
if (r.wrapperType === 'track' && r.trackId) {
lookup[String(r.trackId)] = r;
}
});
// Enrich original tracks
batch.forEach(function (t) {
var r = lookup[t.appleMusicId];
if (!r) return;
if (!t.title || t.title === 'Untitled') t.title = String(r.trackName || t.title);
if (!t.artist) t.artist = String(r.artistName || '');
if (!t.durationMs) t.durationMs = Number(r.trackTimeMillis) || 0;
if (!t.art) t.art = String(r.artworkUrl100 || '').replace('100x100', '300x300');
t.openUrl = String(r.trackViewUrl || '');
});
} catch (_) {
// continue with un-enriched data
}
}
}
function toast(message, type) {
const doc = state.frameDoc;
if (!doc || !doc.body) {
state.pendingToast = message;
return;
}
removeToast(doc);
const toastEl = doc.createElement('div');
toastEl.className = 'nt-spotify-toast';
toastEl.textContent = message;
const bg = type === 'success' ? 'rgba(18, 128, 67, 0.95)' : 'rgba(0, 0, 0, 0.88)';
toastEl.style.cssText = [
'position: fixed',
'left: 50%',
'bottom: 24px',
'transform: translateX(-50%)',
'z-index: 10000',
'padding: 8px 12px',
'border-radius: 999px',
`background: ${bg}`,
'color: #fff',
'font-size: 12px',
'font-weight: 600',
'box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3)',
'pointer-events: none',
].join(';');
doc.body.appendChild(toastEl);
window.setTimeout(() => {
toastEl.remove();
}, 2800);
}
function removeToast(doc) {
const currentToast = doc.querySelector('.nt-spotify-toast');
if (currentToast) currentToast.remove();
}
function parseDurationText(label) {
const raw = String(label || '').trim();
if (!raw) return 0;
if (/^\d+$/.test(raw)) {
return Number(raw) || 0;
}
if (!/^\d{1,2}:\d{2}(?::\d{2})?$/.test(raw)) return 0;
const parts = raw.split(':').map((value) => Number(value));
if (parts.some((value) => !Number.isFinite(value))) return 0;
if (parts.length === 2) {
return parts[0] * 60 + parts[1];
}
return parts[0] * 3600 + parts[1] * 60 + parts[2];
}
function safeJsonParse(text) {
try {
return JSON.parse(text);
} catch (error) {
return null;
}
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method,
url: options.url,
headers: options.headers,
data: options.data,
timeout: options.timeout || 20000,
onload: resolve,
onerror: reject,
ontimeout: () => reject(new Error(`Request timed out: ${options.url}`)),
});
});
}
function cleanupAll() {
// Persist session state before unload so we can resume on next page load
if (state.active && state.queue.length) {
try { saveSessionState(); } catch (_) { }
}
stopProgressTimer();
// Unmute native audio that we may have muted
if (state.active && state.frameDoc) {
try { restoreNativeAudio(state.frameDoc); } catch (_) { }
}
stopAudioObservers();
clearFrameWatchStartTimer();
clearAutoActivateTimer();
if (state.preCueTimer) {
window.clearTimeout(state.preCueTimer);
state.preCueTimer = null;
}
if (state.nextTrackPreResolveTimer) {
window.clearTimeout(state.nextTrackPreResolveTimer);
state.nextTrackPreResolveTimer = null;
}
state._autoActivateAttempted = false;
stopFrameWatcher();
void safeYTCall('pauseVideo');
if (state.embedController) void safeControllerCall('pause');
if (state.embedHost) {
state.embedHost.remove();
state.embedHost = null;
}
state.embedController = null;
if (state.ytPlayer && typeof state.ytPlayer.destroy === 'function') {
try {
state.ytPlayer.destroy();
} catch (error) {
// noop
}
}
state.ytPlayer = null;
state.ytPlayerReady = false;
state.ytPlayerPromise = null;
const liveYtPlayerHost = document.getElementById('nt-yt-player-host');
if (liveYtPlayerHost) {
try {
liveYtPlayerHost.remove();
} catch (error) {
// noop
}
}
if (state.ytPlayerHost) {
try {
state.ytPlayerHost.remove();
} catch (error) {
// noop
}
state.ytPlayerHost = null;
}
detachFrame();
state.active = false;
state.pendingSelection = false;
state.isActivating = false;
state.trackMeta = null;
state._pendingSeekSec = 0;
state._stopCooldownUntil = 0;
state.originalStationLabel = '';
state.originalIndicatorHTML = '';
clearQueue();
resetPlaybackState();
state.preCuePromise = null;
state.preCueResult = null;
state.preCueSourceUri = '';
window.removeEventListener('message', onMusicMessageFromRace);
window.removeEventListener('beforeunload', cleanupAll);
window.removeEventListener(YT_READY_EVENT, onYouTubeReadyEvent);
window.removeEventListener(YT_ERROR_EVENT, onYouTubeErrorEvent);
window.removeEventListener(EMBED_READY_EVENT, onEmbedReadyEvent);
window.removeEventListener(EMBED_ERROR_EVENT, onEmbedErrorEvent);
}
})();