// ==UserScript==
// @name sc-gate-dl
// @namespace https://github.com/D3SOX/sc-gate-dl
// @version 1.11.17
// @description Add sc-gate-dl download controls and remember your position in the SoundCloud feed
// @author D3SOX
// @match https://soundcloud.com/*
// @match https://www.soundcloud.com/*
// @icon https://soundcloud.com/favicon.ico
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @connect *
// @connect localhost
// @connect 127.0.0.1
// @run-at document-idle
// @homepageURL https://github.com/D3SOX/sc-gate-dl
// @supportURL https://github.com/D3SOX/sc-gate-dl/issues
// @downloadURL https://raw.githubusercontent.com/D3SOX/sc-gate-dl/master/userscript/sc-gate-dl.user.js
// @updateURL https://raw.githubusercontent.com/D3SOX/sc-gate-dl/master/userscript/sc-gate-dl.user.js
// ==/UserScript==
(() => {
'use strict';
const WEBUI_BASE_KEY = 'sc-gate-dl-webui-base';
const API_BASE_KEY = 'sc-gate-dl-api-base';
const PANEL_GEOM_KEY = 'sc-gate-dl-panel-geom';
const QUEUE_GEOM_KEY = 'sc-gate-dl-queue-geom';
const OUTPUT_FORMAT_KEY = 'sc-gate-dl-output-format';
const BROWSER_MODE_KEY = 'sc-gate-dl-browser-mode';
const AUTO_CLOSE_KEY = 'sc-gate-dl-auto-close';
const ALWAYS_OPEN_TAB_KEY = 'sc-gate-dl-always-open-tab';
const FEED_CHECKPOINT_KEY = 'sc-gate-dl-feed-checkpoint';
const DEFAULT_WEBUI_BASE = 'http://localhost:4321';
const BUTTON_ATTR = 'data-sc-gate-dl-btn';
const WRAP_ATTR = 'data-sc-gate-dl-wrap';
const PANEL_ID = 'sc-gate-dl-panel';
const QUEUE_ID = 'sc-gate-dl-queue';
const STYLE_ID = 'sc-gate-dl-styles';
const TOOLTIP_ID = 'sc-gate-dl-tooltip';
const FEED_NAV_ID = 'sc-gate-dl-feed-nav';
const TOOLTIP_LABEL = 'Download with sc-gate-dl';
const MIN_PANEL_W = 280;
const MIN_PANEL_H = 240;
const FEED_CARD_SELECTOR = '.sound, .soundList__item, .userStreamItem';
const FEED_LOAD_TIMEOUT_MS = 15_000;
const DEFAULT_GEOM = {
width: 400,
height: 0, // filled from viewport
right: 12,
top: 72,
};
const DEFAULT_QUEUE_GEOM = {
width: 280,
left: 12,
top: 12,
};
/** @type {{ url: string, label: string }[]} */
const downloadQueue = [];
const feedTrackUrlCache = new WeakMap();
const RESERVED_FIRST = new Set([
'you',
'discover',
'stream',
'library',
'messages',
'notifications',
'settings',
'pages',
'charts',
'jobs',
'pro',
'upload',
'search',
'login',
'signup',
'about',
'stations',
'feed',
'tags',
]);
const TRAILING_SEGMENTS = new Set([
'likes',
'reposts',
'comments',
'recommended',
'sets',
'popular-tracks',
'albums',
'tracks',
'followers',
'following',
]);
const DOWNLOAD_ICON_16 = `
`;
const DOWNLOAD_ICON_24 = `
`;
const STORE_SERVICE_ATTR = 'data-sc-gate-dl-store-service';
const STORE_SERVICES = {
hypeddit: {
icon: 'https://hypeddit.com/images/favicon.ico',
},
droploud: {
icon: 'https://droploud.com/favicon.ico',
},
gaterush: {
icon: 'https://gaterush.me/icons/logo-icon-large.png',
},
downloadgater: {
icon: 'https://downloadgater.com/favicon.png',
},
stillhype: {
icon: 'https://www.stillhype.io/favicon-32x32.png',
},
pumpyoursound: {
icon: 'https://pumpyoursound.com/favicon.ico',
},
mypresskit: {
icon: 'https://www.mypresskit.info/favicon.ico',
},
bandcamp: {
icon: 'https://s4.bcbits.com/client-bundle/1/PageLayout_1/favicon-78ff127104384a042453aca8d73be7dc.static/favicon/favicon-32x32.png',
},
};
function getAutoClose() {
try {
if (typeof GM_getValue === 'function') {
const gm = GM_getValue(AUTO_CLOSE_KEY, null);
if (typeof gm === 'boolean') return gm;
}
} catch {
// ignore
}
try {
const v = localStorage.getItem(AUTO_CLOSE_KEY);
if (v === '0' || v === 'false') return false;
if (v === '1' || v === 'true') return true;
} catch {
// ignore
}
return true; // default on
}
function setAutoClose(value) {
const on = Boolean(value);
try {
if (typeof GM_setValue === 'function') GM_setValue(AUTO_CLOSE_KEY, on);
} catch {
// ignore
}
try {
localStorage.setItem(AUTO_CLOSE_KEY, on ? '1' : '0');
} catch {
// ignore
}
}
function getAlwaysOpenTab() {
try {
if (typeof GM_getValue === 'function') {
const stored = GM_getValue(ALWAYS_OPEN_TAB_KEY, null);
if (typeof stored === 'boolean') return stored;
}
} catch {
// ignore
}
try {
const stored = localStorage.getItem(ALWAYS_OPEN_TAB_KEY);
if (stored === '1' || stored === 'true') return true;
if (stored === '0' || stored === 'false') return false;
} catch {
// ignore
}
return false;
}
function setAlwaysOpenTab(value) {
const enabled = Boolean(value);
try {
if (typeof GM_setValue === 'function') {
GM_setValue(ALWAYS_OPEN_TAB_KEY, enabled);
}
} catch {
// ignore
}
try {
localStorage.setItem(ALWAYS_OPEN_TAB_KEY, enabled ? '1' : '0');
} catch {
// ignore
}
}
function getOutputFormat() {
try {
if (typeof GM_getValue === 'function') {
const gm = GM_getValue(OUTPUT_FORMAT_KEY, null);
if (gm === 'original' || gm === 'mp3-320' || gm === 'flac') return gm;
}
} catch {
// ignore
}
try {
const v = localStorage.getItem(OUTPUT_FORMAT_KEY);
if (v === 'original' || v === 'mp3-320' || v === 'flac') return v;
} catch {
// ignore
}
return 'mp3-320';
}
function syncFormatSelect(value) {
const select = document.querySelector(`#${PANEL_ID} .sc-gate-dl-format`);
if (select instanceof HTMLSelectElement) select.value = value;
}
function setOutputFormat(value) {
if (value !== 'original' && value !== 'mp3-320' && value !== 'flac') return;
try {
if (typeof GM_setValue === 'function')
GM_setValue(OUTPUT_FORMAT_KEY, value);
} catch {
// ignore
}
try {
localStorage.setItem(OUTPUT_FORMAT_KEY, value);
} catch {
// ignore
}
syncFormatSelect(value);
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.hidden && panel.dataset.trackUrl) {
loadTrackIntoPanel(panel, panel.dataset.trackUrl);
}
}
function getBrowserMode() {
try {
if (typeof GM_getValue === 'function') {
const gm = GM_getValue(BROWSER_MODE_KEY, null);
if (gm === 'headless' || gm === 'xvfb' || gm === 'headed') return gm;
}
} catch {
// ignore
}
try {
const value = localStorage.getItem(BROWSER_MODE_KEY);
if (value === 'headless' || value === 'xvfb' || value === 'headed') {
return value;
}
} catch {
// ignore
}
return 'headless';
}
function syncBrowserModeSelect(value) {
const select = document.querySelector(
`#${PANEL_ID} .sc-gate-dl-browser-mode`,
);
if (select instanceof HTMLSelectElement) select.value = value;
}
function setBrowserMode(value) {
if (value !== 'headless' && value !== 'xvfb' && value !== 'headed') return;
try {
if (typeof GM_setValue === 'function')
GM_setValue(BROWSER_MODE_KEY, value);
} catch {
// ignore
}
try {
localStorage.setItem(BROWSER_MODE_KEY, value);
} catch {
// ignore
}
syncBrowserModeSelect(value);
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.hidden && panel.dataset.trackUrl) {
loadTrackIntoPanel(panel, panel.dataset.trackUrl);
}
}
let autoCloseMenuId;
let alwaysOpenTabMenuId;
function refreshAutoCloseMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
if (
typeof GM_unregisterMenuCommand === 'function' &&
autoCloseMenuId != null
) {
try {
GM_unregisterMenuCommand(autoCloseMenuId);
} catch {
// ignore
}
}
const on = getAutoClose();
autoCloseMenuId = GM_registerMenuCommand(
`Auto-close after browser download: ${on ? 'ON' : 'OFF'}`,
() => {
setAutoClose(!getAutoClose());
refreshAutoCloseMenu();
},
);
}
function refreshAlwaysOpenTabMenu() {
if (typeof GM_registerMenuCommand !== 'function') return;
if (
typeof GM_unregisterMenuCommand === 'function' &&
alwaysOpenTabMenuId != null
) {
try {
GM_unregisterMenuCommand(alwaysOpenTabMenuId);
} catch {
// ignore
}
}
const enabled = getAlwaysOpenTab();
alwaysOpenTabMenuId = GM_registerMenuCommand(
`Always open downloads in new tab: ${enabled ? 'ON' : 'OFF'}`,
() => {
setAlwaysOpenTab(!getAlwaysOpenTab());
refreshAlwaysOpenTabMenu();
},
);
}
function registerMenuCommands() {
if (typeof GM_registerMenuCommand !== 'function') return;
GM_registerMenuCommand('Configure server…', () => {
configureWebuiBase();
});
GM_registerMenuCommand('Feed position controls…', () => {
toggleFeedNavigator();
});
GM_registerMenuCommand('Choose output format…', () => {
openFormatDialog();
});
GM_registerMenuCommand('Choose browser mode…', () => {
openBrowserModeDialog();
});
refreshAlwaysOpenTabMenu();
refreshAutoCloseMenu();
}
function currentHref() {
try {
// document.location is the browsing document (reliable with GM sandbox)
return document.location.href;
} catch {
try {
return window.location.href;
} catch {
return document.URL || '';
}
}
}
function unwrapStoreUrl(href) {
try {
const url = new URL(href, currentHref() || 'https://soundcloud.com/');
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (url.hostname.toLowerCase().replace(/^www\./, '') !== 'gate.sc') {
return url.href;
}
const wrapped = url.searchParams.get('url');
if (!wrapped) return url.href;
const destination = new URL(wrapped);
if (
destination.protocol !== 'https:' &&
destination.protocol !== 'http:'
) {
return url.href;
}
return destination.href;
} catch {
return null;
}
}
function storeServiceForUrl(href) {
try {
const url = new URL(href);
const host = url.hostname.toLowerCase().replace(/^www\./, '');
const path = url.pathname;
if (host === 'hypeddit.com' && path.startsWith('/')) return 'hypeddit';
if (
host === 'droploud.com' &&
/^\/(?:gate|track)\/[0-9a-f-]+\/?$/i.test(path)
) {
return 'droploud';
}
if (host === 'gaterush.me' && /^\/[A-Za-z0-9_-]+\/?$/.test(path)) {
return 'gaterush';
}
if (
host === 'downloadgater.com' &&
/^\/g\/[A-Za-z0-9_-]+\/?$/.test(path)
) {
return 'downloadgater';
}
if (host === 'stillhype.io' && /^\/g\/[A-Za-z0-9_-]+\/?$/.test(path)) {
return 'stillhype';
}
if (
host === 'pumpyoursound.com' &&
/^\/f\/[A-Za-z0-9_-]+\/[A-Za-z0-9_-]+\/\d+\/?$/i.test(path)
) {
return 'pumpyoursound';
}
if (
host === 'mypresskit.info' &&
/^\/gate\/[A-Za-z0-9_-]+\/?$/i.test(path)
) {
return 'mypresskit';
}
if (
(host === 'bandcamp.com' || host.endsWith('.bandcamp.com')) &&
(path === '/' || /^\/(?:track|album)\/[^/]+\/?$/i.test(path))
) {
return 'bandcamp';
}
} catch {
// Ignore malformed store URLs.
}
return null;
}
function decorateStoreLink(anchor) {
if (!(anchor instanceof HTMLAnchorElement) || isOurNode(anchor)) return;
const destination = unwrapStoreUrl(anchor.href);
if (!destination) return;
const serviceKey = storeServiceForUrl(destination);
if (!serviceKey) {
anchor.removeAttribute(STORE_SERVICE_ATTR);
anchor.style.removeProperty('--sc-gate-dl-store-icon');
return;
}
if (destination !== anchor.href) anchor.href = destination;
const service = STORE_SERVICES[serviceKey];
if (!service) return;
anchor.setAttribute(STORE_SERVICE_ATTR, serviceKey);
anchor.style.setProperty(
'--sc-gate-dl-store-icon',
`url("${service.icon}")`,
);
}
function normalizeTrackUrl(href) {
try {
const url = new URL(href, currentHref() || 'https://soundcloud.com/');
const host = url.hostname.replace(/^www\./i, '').toLowerCase();
if (
host !== 'soundcloud.com' &&
host !== 'm.soundcloud.com' &&
host !== 'on.soundcloud.com'
) {
return null;
}
let parts = url.pathname.split('/').filter(Boolean);
// New logged-in listen layout uses /n/artist/track
if (parts[0]?.toLowerCase() === 'n' && parts.length >= 3) {
parts = parts.slice(1);
}
while (
parts.length > 2 &&
TRAILING_SEGMENTS.has(parts[parts.length - 1].toLowerCase())
) {
parts = parts.slice(0, -1);
}
if (parts.length === 3 && parts[2].toLowerCase().startsWith('s-')) {
parts = parts.slice(0, 2);
}
if (parts.length < 2) return null;
if (RESERVED_FIRST.has(parts[0].toLowerCase())) return null;
// Playlists / albums: /artist/sets/slug — not a single track
if (parts[1].toLowerCase() === 'sets') return null;
if (TRAILING_SEGMENTS.has(parts[1].toLowerCase())) return null;
return `https://soundcloud.com/${parts[0]}/${parts[1]}`;
} catch {
return null;
}
}
function pageTrackUrl() {
// Only the real page location — meta tags / embeds can be /n/... or wrong
const href = currentHref();
if (href) {
const normalized = normalizeTrackUrl(href);
if (normalized) return normalized;
}
try {
const pathUrl = `https://soundcloud.com${document.location.pathname}`;
return normalizeTrackUrl(pathUrl);
} catch {
return null;
}
}
/** Feed/search cards only — track pages use the current URL. */
function trackUrlFromCard(el) {
const trackItem = el?.closest?.('.trackItem');
const trackItemTitle = trackItem?.querySelector(
'a.trackItem__trackTitle[href]',
);
if (trackItemTitle instanceof HTMLAnchorElement) {
const trackItemUrl = normalizeTrackUrl(trackItemTitle.href);
if (trackItemUrl) return trackItemUrl;
}
const closest = el?.closest?.(
'.sound, .soundList__item, .userStreamItem, .searchItem__trackItem, .listenContext',
);
if (!closest) return null;
const sound = closest.matches('.sound')
? closest
: closest.querySelector('.sound') || closest;
if (feedTrackUrlCache.has(sound)) return feedTrackUrlCache.get(sound);
let trackUrl = null;
const titleLink = sound.querySelector(
'a.soundTitle__title, a[href][class*="soundTitle"]',
);
if (titleLink?.href) {
const fromTitle = normalizeTrackUrl(titleLink.href);
if (fromTitle) trackUrl = fromTitle;
}
if (!trackUrl) {
for (const a of sound.querySelectorAll('a[href]')) {
const fromA = normalizeTrackUrl(a.href);
if (!fromA) continue;
trackUrl = fromA;
break;
}
}
if (trackUrl) feedTrackUrlCache.set(sound, trackUrl);
return trackUrl;
}
function isFeedPage() {
return /^\/(?:you\/stream|feed)\/?$/i.test(document.location.pathname);
}
function feedCards() {
const sounds = Array.from(
document.querySelectorAll(
'.userStream .sound, .stream__list .sound, .soundList__item .sound',
),
);
return sounds.length > 0
? sounds
: Array.from(
document.querySelectorAll('.soundList__item, .userStreamItem'),
);
}
function findFeedCard(trackUrl) {
const normalized = normalizeTrackUrl(trackUrl);
if (!normalized) return null;
return (
feedCards().find(
(card) =>
trackUrlFromCard(card) === normalized ||
Array.from(
card.querySelectorAll('a.trackItem__trackTitle[href]'),
).some((link) => normalizeTrackUrl(link.href) === normalized),
) || null
);
}
function feedTimestamp(card) {
const time = card.querySelector(
'.sound__header time[datetime], .soundTitle time[datetime], time[datetime]',
);
if (!(time instanceof HTMLTimeElement)) return null;
const timestamp = Date.parse(time.dateTime);
return Number.isFinite(timestamp) ? timestamp : null;
}
function parseFeedCheckpoint(value) {
const url = normalizeTrackUrl(value?.url || '');
if (!url) return null;
return {
url,
label:
typeof value.label === 'string' ? value.label : trackLabelFromUrl(url),
feedTimestamp:
typeof value.feedTimestamp === 'number' ? value.feedTimestamp : null,
savedAt: typeof value.savedAt === 'number' ? value.savedAt : 0,
};
}
function loadFeedCheckpoints() {
let raw = null;
try {
if (typeof GM_getValue === 'function') {
raw = GM_getValue(FEED_CHECKPOINT_KEY, null);
}
} catch {
// ignore
}
try {
raw ??= localStorage.getItem(FEED_CHECKPOINT_KEY);
const parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
const legacy = parseFeedCheckpoint(parsed);
if (legacy) return { newer: legacy, older: legacy };
const checkpoints = {
newer: parseFeedCheckpoint(parsed?.newer),
older: parseFeedCheckpoint(parsed?.older),
};
return checkpoints.newer || checkpoints.older ? checkpoints : null;
} catch {
return null;
}
}
function persistFeedCheckpoints(checkpoints) {
const raw = JSON.stringify(checkpoints);
try {
if (typeof GM_setValue === 'function') {
GM_setValue(FEED_CHECKPOINT_KEY, raw);
}
} catch {
// ignore
}
try {
localStorage.setItem(FEED_CHECKPOINT_KEY, raw);
} catch {
// ignore
}
}
function resetFeedCheckpoint(direction) {
if (direction !== 'newer' && direction !== 'older') return;
const checkpoints = loadFeedCheckpoints() ?? { newer: null, older: null };
checkpoints[direction] = null;
persistFeedCheckpoints(checkpoints);
setFeedStatus(
`${direction === 'newer' ? 'Up' : 'Down'} position reset. The next eligible played track will seed it.`,
);
updateFeedNavigator();
}
function checkpointLabelFromCard(card, url) {
const normalizedUrl = normalizeTrackUrl(url);
const title = Array.from(card.querySelectorAll('a[href]'))
.find(
(link) =>
!link.matches('.soundTitle__username, [class*="username"]') &&
normalizeTrackUrl(link.href) === normalizedUrl &&
link.textContent?.trim(),
)
?.textContent?.trim();
const artist = card
.querySelector('.soundTitle__username, a.soundTitle__username')
?.textContent?.trim();
return title && title !== artist
? artist
? `${artist} — ${title}`
: title
: trackLabelFromUrl(url);
}
function shouldAdvanceCheckpoint(
saved,
card,
candidateTimestamp,
direction,
candidateUrl,
) {
if (!saved || saved.url === candidateUrl) return true;
if (saved.feedTimestamp != null && candidateTimestamp != null) {
return direction === 'newer'
? candidateTimestamp >= saved.feedTimestamp
: candidateTimestamp <= saved.feedTimestamp;
}
const savedCard = findFeedCard(saved.url);
if (!savedCard) return false;
const cards = feedCards();
const candidateIndex = cards.indexOf(card);
const savedIndex = cards.indexOf(savedCard);
if (candidateIndex < 0 || savedIndex < 0) return false;
return direction === 'newer'
? candidateIndex <= savedIndex
: candidateIndex >= savedIndex;
}
function isFeedNavigatorOpen() {
const nav = document.getElementById(FEED_NAV_ID);
return Boolean(nav && !nav.hidden);
}
function saveFeedCheckpointFromCard(card, playedUrl = null) {
if (!isFeedNavigatorOpen() || !isFeedPage() || !(card instanceof Element))
return;
const feedCard = card.matches('.sound')
? card
: card.querySelector('.sound') || card;
const url =
(playedUrl ? normalizeTrackUrl(playedUrl) : null) ||
trackUrlFromCard(feedCard);
if (!url) return;
const timestamp = feedTimestamp(feedCard);
const label = checkpointLabelFromCard(feedCard, url);
const checkpoints = loadFeedCheckpoints() ?? { newer: null, older: null };
const candidate = {
url,
label,
feedTimestamp: timestamp,
savedAt: Date.now(),
};
let changed = false;
for (const direction of ['newer', 'older']) {
const saved = checkpoints[direction];
if (
!shouldAdvanceCheckpoint(saved, feedCard, timestamp, direction, url)
) {
continue;
}
if (saved?.url === url) {
if (saved.label !== label) {
checkpoints[direction] = { ...saved, label };
changed = true;
}
} else {
checkpoints[direction] = candidate;
changed = true;
}
}
if (changed) {
persistFeedCheckpoints(checkpoints);
updateFeedNavigator();
}
}
function updateFeedPlaybackOrigin(
currentOrigin,
feedCardUrl,
outsidePlaybackSelection,
) {
if (feedCardUrl) return feedCardUrl;
return outsidePlaybackSelection ? null : currentOrigin;
}
let lastRecordedPlayingUrl = null;
let feedPlaybackOriginUrl = null;
function recordPlayingFeedTrack() {
if (!isFeedPage()) {
feedPlaybackOriginUrl = null;
lastRecordedPlayingUrl = null;
return;
}
if (!isFeedNavigatorOpen() || !feedPlaybackOriginUrl) return;
const playing = document.querySelector(
'.playControls .playControl.playing, .playControls__play.playing, .playControls button[title^="Pause"], .playControls button[aria-label^="Pause"]',
);
if (!playing) {
lastRecordedPlayingUrl = null;
return;
}
const link = document.querySelector(
'.playbackSoundBadge__titleLink[href], .playbackSoundBadge a[href][class*="title"]',
);
const url =
link instanceof HTMLAnchorElement ? normalizeTrackUrl(link.href) : null;
if (!url || url === lastRecordedPlayingUrl) return;
const card = url ? findFeedCard(url) : null;
if (card) {
saveFeedCheckpointFromCard(card, url);
lastRecordedPlayingUrl = url;
feedPlaybackOriginUrl = url;
}
}
let feedSearchToken = 0;
let feedSearchActive = false;
function setFeedStatus(message) {
const status = document.querySelector(
`#${FEED_NAV_ID} .sc-gate-dl-feed-status`,
);
if (status) status.textContent = message;
}
function updateFeedNavigator() {
const nav = document.getElementById(FEED_NAV_ID);
if (!nav) return;
const checkpoints = loadFeedCheckpoints();
const resumeNewer = nav.querySelector('.sc-gate-dl-feed-resume-newer');
const resumeOlder = nav.querySelector('.sc-gate-dl-feed-resume-older');
const cancel = nav.querySelector('.sc-gate-dl-feed-cancel');
const find = nav.querySelector('.sc-gate-dl-feed-find');
const resetNewer = nav.querySelector('.sc-gate-dl-feed-reset-newer');
const resetOlder = nav.querySelector('.sc-gate-dl-feed-reset-older');
const updateResumeLink = (link, checkpoint, label) => {
if (!(link instanceof HTMLAnchorElement)) return;
link.hidden = feedSearchActive;
link.textContent = checkpoint
? `Resume ${label}: ${checkpoint.label}`
: 'No listened track saved yet';
link.title = checkpoint?.url || '';
if (checkpoint) {
link.removeAttribute('aria-disabled');
link.href = checkpoint.url;
} else {
link.setAttribute('aria-disabled', 'true');
link.removeAttribute('href');
}
};
updateResumeLink(resumeNewer, checkpoints?.newer, 'farthest up');
updateResumeLink(resumeOlder, checkpoints?.older, 'farthest down');
if (resetNewer instanceof HTMLButtonElement) {
resetNewer.disabled = !checkpoints?.newer;
}
if (resetOlder instanceof HTMLButtonElement) {
resetOlder.disabled = !checkpoints?.older;
}
if (cancel instanceof HTMLButtonElement) cancel.hidden = !feedSearchActive;
if (find instanceof HTMLButtonElement) find.disabled = feedSearchActive;
}
function waitForMoreFeed(snapshot, token, targetUrl) {
return new Promise((resolve) => {
let finished = false;
let mutationTimer = 0;
const finish = (grew) => {
if (finished) return;
finished = true;
observer.disconnect();
window.clearInterval(bottomTimer);
window.clearTimeout(timeoutTimer);
window.clearTimeout(mutationTimer);
resolve(grew);
};
const hasChanged = () => {
if (token !== feedSearchToken) return finish(false);
if (findFeedCard(targetUrl)) return finish(true);
const cards = feedCards();
const lastUrl = trackUrlFromCard(cards.at(-1));
if (
cards.length > snapshot.count ||
(lastUrl && lastUrl !== snapshot.lastUrl) ||
document.documentElement.scrollHeight > snapshot.height + 20
) {
finish(true);
}
};
const observer = new MutationObserver(() => {
if (mutationTimer) return;
mutationTimer = window.setTimeout(() => {
mutationTimer = 0;
hasChanged();
}, 100);
});
observer.observe(document.body, { childList: true, subtree: true });
const bottomTimer = window.setInterval(() => {
window.scrollTo(0, document.documentElement.scrollHeight);
hasChanged();
}, 750);
const timeoutTimer = window.setTimeout(
() => finish(false),
FEED_LOAD_TIMEOUT_MS,
);
});
}
async function scrollToFeedTrack(trackUrl) {
const url = normalizeTrackUrl(trackUrl);
if (!url) {
setFeedStatus('Enter a valid SoundCloud track URL.');
return;
}
if (!isFeedPage()) {
setFeedStatus('Open your SoundCloud Stream first.');
return;
}
const token = ++feedSearchToken;
feedSearchActive = true;
updateFeedNavigator();
let stalledLoads = 0;
while (token === feedSearchToken && isFeedPage()) {
const card = findFeedCard(url);
if (card instanceof HTMLElement) {
card.scrollIntoView({ behavior: 'smooth', block: 'center' });
window.setTimeout(() => {
if (card.isConnected) card.scrollIntoView({ block: 'center' });
}, 450);
card.classList.add('sc-gate-dl-feed-target');
window.setTimeout(
() => card.classList.remove('sc-gate-dl-feed-target'),
2400,
);
setFeedStatus('Track found and centered.');
break;
}
const cards = feedCards();
setFeedStatus(
`Loading more tracks… ${cards.length.toLocaleString()} checked`,
);
const snapshot = {
count: cards.length,
lastUrl: trackUrlFromCard(cards.at(-1)),
height: document.documentElement.scrollHeight,
};
window.scrollTo(0, document.documentElement.scrollHeight);
const grew = await waitForMoreFeed(snapshot, token, url);
stalledLoads = grew ? 0 : stalledLoads + 1;
if (stalledLoads >= 2) {
setFeedStatus('Track was not found in the available feed.');
break;
}
}
if (token === feedSearchToken) {
feedSearchActive = false;
updateFeedNavigator();
}
}
function cancelFeedSearch() {
if (!feedSearchActive) return;
feedSearchToken++;
feedSearchActive = false;
setFeedStatus('Scrolling cancelled.');
updateFeedNavigator();
}
function toggleFeedNavigator() {
const nav = document.getElementById(FEED_NAV_ID);
if (nav && !nav.hidden) {
if (feedSearchActive) cancelFeedSearch();
nav.hidden = true;
return;
}
ensureFeedNavigator(true);
}
function ensureFeedNavigator(open = false) {
let nav = document.getElementById(FEED_NAV_ID);
if (!isFeedPage()) {
if (feedSearchActive) cancelFeedSearch();
if (nav) nav.hidden = true;
if (open) {
alert('sc-gate-dl: open your SoundCloud Stream first.');
}
return;
}
if (!open) return;
if (!nav) {
nav = document.createElement('aside');
nav.id = FEED_NAV_ID;
nav.setAttribute('aria-label', 'SoundCloud feed position');
nav.innerHTML = `
Feed position
`;
nav
.querySelector('.sc-gate-dl-feed-close')
?.addEventListener('click', () => toggleFeedNavigator());
for (const direction of ['newer', 'older']) {
nav
.querySelector(`.sc-gate-dl-feed-resume-${direction}`)
?.addEventListener('click', (event) => {
event.preventDefault();
if (feedSearchActive) {
cancelFeedSearch();
return;
}
const checkpoint = loadFeedCheckpoints()?.[direction];
if (checkpoint) void scrollToFeedTrack(checkpoint.url);
});
}
nav
.querySelector('.sc-gate-dl-feed-cancel')
?.addEventListener('click', cancelFeedSearch);
nav
.querySelector('.sc-gate-dl-feed-find')
?.addEventListener('click', () => {
const input = window.prompt(
'SoundCloud track URL to find in your feed:',
);
if (input?.trim()) void scrollToFeedTrack(input.trim());
});
for (const direction of ['newer', 'older']) {
nav
.querySelector(`.sc-gate-dl-feed-reset-${direction}`)
?.addEventListener('click', () => resetFeedCheckpoint(direction));
}
document.documentElement.appendChild(nav);
}
nav.hidden = false;
updateFeedNavigator();
recordPlayingFeedTrack();
}
function resolveTrackUrl(el) {
// Listen page (classic or MUI): always prefer the address bar URL
const fromPage = pageTrackUrl();
if (fromPage) return fromPage;
return trackUrlFromCard(el);
}
/** Playlists/albums aren't single-track downloads — skip injection there. */
function isPlaylistOrAlbumContext(el) {
if (!(el instanceof Element)) return false;
if (el.closest('.sound.playlist')) return true;
if (
el.closest(
'.l-listen-hero.playlist, .fullHero.playlist, .listenContext.playlist',
)
) {
return true;
}
const sound = el.closest(
'.sound, .soundList__item, .activity, [role="group"]',
);
if (sound?.classList?.contains('playlist')) return true;
if (
sound?.querySelector(
'a.soundTitle__title[href*="/sets/"], a.sound__coverArt[href*="/sets/"]',
)
) {
return true;
}
const buy =
(el.closest('.purchaseLink__container') || el).querySelector?.(
'a[aria-label], a[title]',
) || el;
const label = (
buy.getAttribute?.('aria-label') ||
buy.getAttribute?.('title') ||
''
).toLowerCase();
if (/\bbuy all\b/.test(label)) return true;
return false;
}
const WEBUI_REACHABILITY_TIMEOUT_MS = 1_500;
const WEBUI_RESOLVE_CACHE_MS = 30_000;
/** Last server proven reachable — used for iframe origin / API until re-resolved. */
let activeWebuiBase = null;
let activeWebuiBaseAt = 0;
function readStoredWebuiRaw() {
try {
const stored = localStorage.getItem(WEBUI_BASE_KEY);
if (stored?.trim()) return stored;
} catch {
// ignore
}
try {
if (typeof GM_getValue === 'function') {
const stored = GM_getValue(WEBUI_BASE_KEY, null);
if (typeof stored === 'string' && stored.trim()) return stored;
}
} catch {
// ignore
}
return null;
}
function normalizeWebuiBase(value) {
const url = new URL(value.trim());
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
throw new TypeError('The Web UI address must use HTTP or HTTPS.');
}
url.search = '';
url.hash = '';
return url.href.replace(/\/$/, '');
}
function parseWebuiBases(
raw,
fallback = DEFAULT_WEBUI_BASE,
{ strict = true } = {},
) {
if (typeof raw !== 'string' || !raw.trim()) {
return [normalizeWebuiBase(fallback)];
}
const parts = raw
.split(/[\n,]+/)
.map((part) => part.trim())
.filter(Boolean);
const bases = [];
const seen = new Set();
for (const part of parts) {
try {
const normalized = normalizeWebuiBase(part);
if (seen.has(normalized)) continue;
seen.add(normalized);
bases.push(normalized);
} catch (error) {
if (strict) throw error;
}
}
return bases.length > 0 ? bases : [normalizeWebuiBase(fallback)];
}
function getWebuiBases() {
return parseWebuiBases(readStoredWebuiRaw(), DEFAULT_WEBUI_BASE, {
strict: false,
});
}
function getWebuiBase() {
return activeWebuiBase || getWebuiBases()[0] || DEFAULT_WEBUI_BASE;
}
function persistWebuiBases(bases) {
const serialized = bases.join('\n');
try {
if (typeof GM_setValue === 'function') {
GM_setValue(WEBUI_BASE_KEY, serialized);
}
} catch {
// ignore
}
try {
localStorage.setItem(WEBUI_BASE_KEY, serialized);
} catch {
// ignore
}
return serialized;
}
function setWebuiBases(value) {
const bases = value.trim()
? parseWebuiBases(value)
: [DEFAULT_WEBUI_BASE];
activeWebuiBase = null;
activeWebuiBaseAt = 0;
persistWebuiBases(bases);
return bases;
}
function apiOriginFromWebui(webuiBase) {
const url = new URL(webuiBase);
if (url.port === '4321') url.port = '3000';
return url.origin;
}
function abortSignalTimeout(ms) {
if (typeof AbortSignal?.timeout === 'function') {
return AbortSignal.timeout(ms);
}
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), ms);
controller.signal.addEventListener(
'abort',
() => window.clearTimeout(timer),
{ once: true },
);
return controller.signal;
}
const gmXmlHttpRequest =
typeof GM_xmlhttpRequest === 'function'
? GM_xmlhttpRequest
: typeof GM !== 'undefined' && typeof GM.xmlHttpRequest === 'function'
? GM.xmlHttpRequest.bind(GM)
: null;
/** Probe via the userscript bridge — page fetch is blocked by Private Network Access. */
function probeUrl(url) {
if (gmXmlHttpRequest) {
return new Promise((resolve) => {
gmXmlHttpRequest({
method: 'GET',
url,
timeout: WEBUI_REACHABILITY_TIMEOUT_MS,
anonymous: true,
onload: (response) => resolve(response.status > 0),
onerror: () => resolve(false),
ontimeout: () => resolve(false),
onabort: () => resolve(false),
});
});
}
return fetch(url, {
method: 'GET',
mode: 'cors',
cache: 'no-store',
signal: abortSignalTimeout(WEBUI_REACHABILITY_TIMEOUT_MS),
})
.then((response) => response.ok)
.catch(async () => {
try {
await fetch(url, {
method: 'GET',
mode: 'no-cors',
cache: 'no-store',
signal: abortSignalTimeout(WEBUI_REACHABILITY_TIMEOUT_MS),
});
return true;
} catch {
return false;
}
});
}
/** Cancel via GM bridge — page fetch to localhost is blocked by PNA. */
function requestJobCancel(jobId) {
const url = `${getApiBase()}/api/job/${encodeURIComponent(jobId)}/cancel`;
if (gmXmlHttpRequest) {
try {
// Dispatch immediately; do not wait for teardown (browser close can take seconds).
gmXmlHttpRequest({
method: 'POST',
url,
anonymous: true,
onload: () => {},
onerror: () => {},
ontimeout: () => {},
onabort: () => {},
});
} catch {
// Bridge may throw synchronously; panel cleanup must still continue.
}
return Promise.resolve();
}
return fetch(url, { method: 'POST', keepalive: true }).catch(() => {});
}
async function isWebuiReachable(base) {
const urls = [`${base}/`, `${apiOriginFromWebui(base)}/api/capabilities`];
const seen = new Set();
for (const url of urls) {
if (seen.has(url)) continue;
seen.add(url);
if (await probeUrl(url)) return true;
}
return false;
}
async function resolveWebuiBase() {
const bases = getWebuiBases();
const now = Date.now();
const cached =
activeWebuiBase &&
now - activeWebuiBaseAt < WEBUI_RESOLVE_CACHE_MS &&
bases.includes(activeWebuiBase)
? activeWebuiBase
: null;
const ordered = cached
? [cached, ...bases.filter((base) => base !== cached)]
: bases;
for (const base of ordered) {
if (await isWebuiReachable(base)) {
activeWebuiBase = base;
activeWebuiBaseAt = Date.now();
return base;
}
}
activeWebuiBase = null;
activeWebuiBaseAt = 0;
throw new Error(
`None of the configured sc-gate-dl servers are reachable:\n${bases.join('\n')}`,
);
}
function bindBackdropDismiss(dialog, close) {
let pointerDownOnBackdrop = false;
dialog.addEventListener('pointerdown', (event) => {
pointerDownOnBackdrop = event.target === dialog;
});
dialog.addEventListener('click', (event) => {
if (pointerDownOnBackdrop && event.target === dialog) close();
pointerDownOnBackdrop = false;
});
}
function configureWebuiBase() {
ensureStyles();
document.getElementById('sc-gate-dl-server-dialog')?.remove();
const dialog = document.createElement('div');
dialog.id = 'sc-gate-dl-server-dialog';
dialog.innerHTML = `
`;
const input = dialog.querySelector('.sc-gate-dl-server-input');
if (input instanceof HTMLTextAreaElement) {
input.value = getWebuiBases().join('\n');
}
const close = () => dialog.remove();
bindBackdropDismiss(dialog, close);
dialog
.querySelector('.sc-gate-dl-format-cancel')
?.addEventListener('click', close);
dialog
.querySelector('.sc-gate-dl-format-save')
?.addEventListener('click', () => {
const value =
input instanceof HTMLTextAreaElement ? input.value : '';
try {
const bases = setWebuiBases(value);
close();
void (async () => {
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.hidden && panel.dataset.trackUrl) {
try {
await resolveWebuiBase();
loadTrackIntoPanel(panel, panel.dataset.trackUrl);
} catch (error) {
window.alert(
error instanceof Error
? error.message
: 'No configured sc-gate-dl server is reachable.',
);
return;
}
}
window.alert(
bases.length === 1
? `sc-gate-dl server set to ${bases[0]}`
: `sc-gate-dl servers (tried in order):\n${bases.join('\n')}`,
);
})();
} catch {
window.alert(
'Enter complete HTTP(S) addresses, one per line.\nExample:\nhttp://192.168.178.57:4321\nhttp://100.x.y.z:8123',
);
}
});
document.documentElement.appendChild(dialog);
if (input instanceof HTMLTextAreaElement) input.focus();
}
function loadGeom() {
try {
const raw = localStorage.getItem(PANEL_GEOM_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.width === 'number' &&
typeof parsed?.height === 'number' &&
typeof parsed?.left === 'number' &&
typeof parsed?.top === 'number'
) {
return parsed;
}
} catch {
// ignore
}
return null;
}
function saveGeom(panel) {
try {
const rect = panel.getBoundingClientRect();
localStorage.setItem(
PANEL_GEOM_KEY,
JSON.stringify({
width: Math.round(rect.width),
height: Math.round(rect.height),
left: Math.round(rect.left),
top: Math.round(rect.top),
}),
);
} catch {
// ignore
}
}
function loadQueueGeom() {
try {
const raw = localStorage.getItem(QUEUE_GEOM_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw);
if (
typeof parsed?.left === 'number' &&
typeof parsed?.top === 'number' &&
typeof parsed?.width === 'number'
) {
return parsed;
}
} catch {
// ignore
}
return null;
}
function saveQueueGeom(el) {
try {
const rect = el.getBoundingClientRect();
localStorage.setItem(
QUEUE_GEOM_KEY,
JSON.stringify({
width: Math.round(rect.width),
left: Math.round(rect.left),
top: Math.round(rect.top),
}),
);
} catch {
// ignore
}
}
function trackLabelFromUrl(url) {
try {
const parts = new URL(url).pathname.split('/').filter(Boolean);
if (parts.length >= 2) {
const artist = decodeURIComponent(parts[0]).replace(/-/g, ' ');
const track = decodeURIComponent(parts[1]).replace(/-/g, ' ');
return `${artist} — ${track}`;
}
} catch {
// ignore
}
return url;
}
function isPanelBusy() {
const panel = document.getElementById(PANEL_ID);
return Boolean(panel && !panel.hidden && panel.dataset.jobId);
}
/** @type {Element | null} */
let tooltipAnchor = null;
let tooltipShowTimer = 0;
let tooltipHideTimer = 0;
let tooltipTracking = false;
const TOOLTIP_ENTER_MS_CLASSIC = 60;
const TOOLTIP_ENTER_MS_MUI = 100;
const TOOLTIP_EXIT_MS_CLASSIC = 150;
/** Faster Grow (still scale + fade; was 200ms). */
const TOOLTIP_EXIT_MS_MUI = 100;
const TOOLTIP_ENTER_ANIM_MS_MUI = 100;
function tooltipVariantFor(anchor) {
if (
anchor instanceof Element &&
(anchor.getAttribute(WRAP_ATTR) === 'mui' ||
anchor.closest?.(`[${WRAP_ATTR}="mui"]`))
) {
return 'mui';
}
return 'classic';
}
function isTooltipAnchorActive(anchor) {
if (!(anchor instanceof Element) || !anchor.isConnected) return false;
if (anchor.matches(':hover')) return true;
const active = document.activeElement;
return active instanceof Element && anchor.contains(active);
}
function onTooltipPointerMove() {
if (tooltipAnchor && !isTooltipAnchorActive(tooltipAnchor)) {
hideTooltip();
}
}
function onTooltipPointerDown(e) {
if (
tooltipAnchor &&
e.target instanceof Node &&
!tooltipAnchor.contains(e.target)
) {
hideTooltip();
}
}
function stopTooltipTracking() {
if (!tooltipTracking) return;
tooltipTracking = false;
document.removeEventListener('scroll', hideTooltip, true);
document.removeEventListener('pointermove', onTooltipPointerMove, true);
document.removeEventListener('pointerdown', onTooltipPointerDown, true);
window.removeEventListener('blur', hideTooltip);
}
function startTooltipTracking() {
if (tooltipTracking) return;
tooltipTracking = true;
// Scroll/move can leave the cursor off the control without pointerleave.
document.addEventListener('scroll', hideTooltip, true);
document.addEventListener('pointermove', onTooltipPointerMove, true);
document.addEventListener('pointerdown', onTooltipPointerDown, true);
window.addEventListener('blur', hideTooltip);
}
function removeTooltipEl() {
window.clearTimeout(tooltipHideTimer);
tooltipHideTimer = 0;
document.getElementById(TOOLTIP_ID)?.remove();
}
function hideTooltip() {
window.clearTimeout(tooltipShowTimer);
tooltipShowTimer = 0;
tooltipAnchor = null;
stopTooltipTracking();
const tip = document.getElementById(TOOLTIP_ID);
if (!tip) {
window.clearTimeout(tooltipHideTimer);
tooltipHideTimer = 0;
return;
}
if (tip.classList.contains('sc-gate-dl-tip-hiding')) return;
const exitMs =
tip.dataset.variant === 'mui'
? TOOLTIP_EXIT_MS_MUI
: TOOLTIP_EXIT_MS_CLASSIC;
tip.classList.remove('sc-gate-dl-tip-visible');
tip.classList.add('sc-gate-dl-tip-hiding');
window.clearTimeout(tooltipHideTimer);
tooltipHideTimer = window.setTimeout(() => {
tooltipHideTimer = 0;
tip.remove();
}, exitMs);
}
function showTooltip(anchor) {
removeTooltipEl();
window.clearTimeout(tooltipShowTimer);
tooltipShowTimer = 0;
if (!isTooltipAnchorActive(anchor)) return;
const variant = tooltipVariantFor(anchor);
tooltipAnchor = anchor;
startTooltipTracking();
const tip = document.createElement('div');
tip.id = TOOLTIP_ID;
tip.dataset.variant = variant;
tip.setAttribute('role', 'tooltip');
tip.className = `sc-gate-dl-tip-${variant}`;
// Classic SC tooltips use a caret; Webi/MUI track actions do not.
tip.innerHTML =
variant === 'mui'
? ``
: ``;
const content = tip.querySelector('.sc-gate-dl-tip-content');
if (content) content.textContent = TOOLTIP_LABEL;
document.documentElement.appendChild(tip);
// Anchor to the visible icon button, not a wide wrapper
const icon =
anchor.querySelector?.(`[${BUTTON_ATTR}]`) ||
(anchor.hasAttribute?.(BUTTON_ATTR) ? anchor : null) ||
anchor;
const ar = icon.getBoundingClientRect();
// Measure untransformed so width/height match the final painted box.
tip.classList.add('sc-gate-dl-tip-measure');
const tr = tip.getBoundingClientRect();
tip.classList.remove('sc-gate-dl-tip-measure');
const gap = variant === 'mui' ? 4 : 8;
let below = false;
let top;
if (variant === 'classic') {
// Classic feed tooltips (Buy Link, etc.) open below the control.
below = true;
top = ar.bottom + gap;
if (top + tr.height > window.innerHeight - 4) {
const above = ar.top - tr.height - gap;
if (above >= 4) {
below = false;
top = above;
}
}
} else {
// MUI track-header actions open above.
top = ar.top - tr.height - gap;
if (top < 4) {
top = ar.bottom + gap;
below = true;
}
}
if (below) tip.classList.add('sc-gate-dl-tip-below');
let left = ar.left + ar.width / 2 - tr.width / 2;
left = Math.min(Math.max(8, left), window.innerWidth - tr.width - 8);
tip.style.top = `${Math.round(top)}px`;
tip.style.left = `${Math.round(left)}px`;
const arrow = tip.querySelector('.sc-gate-dl-tip-arrow');
if (arrow instanceof HTMLElement) {
arrow.style.left = '50%';
arrow.style.marginLeft = '-5px';
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (document.getElementById(TOOLTIP_ID) !== tip) return;
if (!isTooltipAnchorActive(anchor)) {
removeTooltipEl();
tooltipAnchor = null;
stopTooltipTracking();
return;
}
tip.classList.add('sc-gate-dl-tip-visible');
});
});
}
function openFormatDialog() {
ensureStyles();
document.getElementById('sc-gate-dl-format-dialog')?.remove();
const current = getOutputFormat();
const dialog = document.createElement('div');
dialog.id = 'sc-gate-dl-format-dialog';
dialog.innerHTML = `
`;
const close = () => dialog.remove();
bindBackdropDismiss(dialog, close);
dialog
.querySelector('.sc-gate-dl-format-cancel')
?.addEventListener('click', close);
dialog
.querySelector('.sc-gate-dl-format-save')
?.addEventListener('click', () => {
const selected = dialog.querySelector(
'input[name="sc-gate-dl-fmt"]:checked',
);
if (selected instanceof HTMLInputElement)
setOutputFormat(selected.value);
close();
});
document.documentElement.appendChild(dialog);
}
function openBrowserModeDialog() {
ensureStyles();
document.getElementById('sc-gate-dl-browser-mode-dialog')?.remove();
const current = getBrowserMode();
const dialog = document.createElement('div');
dialog.id = 'sc-gate-dl-browser-mode-dialog';
dialog.innerHTML = `
`;
const close = () => dialog.remove();
bindBackdropDismiss(dialog, close);
dialog
.querySelector('.sc-gate-dl-format-cancel')
?.addEventListener('click', close);
dialog
.querySelector('.sc-gate-dl-format-save')
?.addEventListener('click', () => {
const selected = dialog.querySelector(
'input[name="sc-gate-dl-browser-mode"]:checked',
);
if (selected instanceof HTMLInputElement)
setBrowserMode(selected.value);
close();
});
document.documentElement.appendChild(dialog);
}
function bindTooltip(anchor) {
const enterMs =
tooltipVariantFor(anchor) === 'mui'
? TOOLTIP_ENTER_MS_MUI
: TOOLTIP_ENTER_MS_CLASSIC;
const onEnter = () => {
window.clearTimeout(tooltipShowTimer);
tooltipShowTimer = window.setTimeout(() => showTooltip(anchor), enterMs);
};
const onLeave = () => {
window.clearTimeout(tooltipShowTimer);
tooltipShowTimer = 0;
hideTooltip();
};
anchor.addEventListener('pointerenter', onEnter);
anchor.addEventListener('pointerleave', onLeave);
anchor.addEventListener('focusin', onEnter);
anchor.addEventListener('focusout', (e) => {
if (
e.relatedTarget instanceof Node &&
anchor.contains(e.relatedTarget)
) {
return;
}
onLeave();
});
}
function isOurNode(el) {
return (
el instanceof Element &&
(el.hasAttribute(BUTTON_ATTR) ||
el.hasAttribute(WRAP_ATTR) ||
!!el.closest(`[${BUTTON_ATTR}], [${WRAP_ATTR}], #${PANEL_ID}`))
);
}
function alreadyInjectedNear(anchorPoint) {
if (!anchorPoint) return true;
const scope =
anchorPoint.closest('.soundActions, .sound__soundActions') ||
anchorPoint.parentElement;
if (scope?.querySelector(`[${WRAP_ATTR}]`)) return true;
const next = anchorPoint.nextElementSibling;
const prev = anchorPoint.previousElementSibling;
return !!(
next?.hasAttribute?.(WRAP_ATTR) ||
next?.hasAttribute?.(BUTTON_ATTR) ||
prev?.hasAttribute?.(WRAP_ATTR) ||
prev?.hasAttribute?.(BUTTON_ATTR)
);
}
function ensureStyles() {
let style = document.getElementById(STYLE_ID);
if (!style) {
style = document.createElement('style');
style.id = STYLE_ID;
document.documentElement.appendChild(style);
}
// Always rewrite so userscript updates pick up new tooltip CSS without a hard cache.
style.textContent = `
#${PANEL_ID} {
position: fixed;
z-index: 2147483646;
display: flex;
flex-direction: column;
box-sizing: border-box;
min-width: ${MIN_PANEL_W}px;
min-height: ${MIN_PANEL_H}px;
max-width: calc(100vw - 8px);
max-height: calc(100vh - 8px);
background: #0f0f12;
border-radius: 10px;
overflow: hidden;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
border: 1px solid rgba(255, 255, 255, 0.12);
}
#${PANEL_ID}[hidden] { display: none !important; }
#${PANEL_ID} .sc-gate-dl-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 8px 6px 10px;
background: #16161c;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #eee;
font: 600 12px/1.2 Interstate, "Lucida Grande", Arial, sans-serif;
cursor: move;
user-select: none;
flex-shrink: 0;
touch-action: none;
}
#${PANEL_ID} .sc-gate-dl-toolbar a {
color: #f50;
text-decoration: none;
cursor: pointer;
}
#${PANEL_ID} .sc-gate-dl-toolbar .sc-gate-dl-actions {
display: flex;
align-items: center;
gap: 6px;
flex-shrink: 0;
margin-left: auto;
}
#${PANEL_ID} .sc-gate-dl-format,
#${PANEL_ID} .sc-gate-dl-browser-mode {
appearance: none;
background: #0f0f12;
color: #ddd;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
font: 500 11px/1.2 Interstate, "Lucida Grande", Arial, sans-serif;
padding: 4px 6px;
cursor: pointer;
max-width: 118px;
}
#${PANEL_ID} .sc-gate-dl-browser-mode { max-width: 92px; }
#${PANEL_ID} .sc-gate-dl-server {
display: inline-grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
border: 1px solid rgba(255,255,255,0.14);
border-radius: 6px;
background: #0f0f12;
color: #ccc;
cursor: pointer;
}
#${PANEL_ID} .sc-gate-dl-server:hover { color: #fff; }
#${PANEL_ID} .sc-gate-dl-server svg {
width: 15px;
height: 15px;
fill: currentColor;
}
#${PANEL_ID} .sc-gate-dl-toolbar button.sc-gate-dl-close {
appearance: none;
border: 0;
background: transparent;
color: #ccc;
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
}
#${PANEL_ID} .sc-gate-dl-toolbar button.sc-gate-dl-close:hover { color: #fff; }
#${PANEL_ID} iframe {
flex: 1;
width: 100%;
border: 0;
background: #0f0f12;
min-height: 0;
}
/* Download queue — smaller floating window (default top-left) */
#${QUEUE_ID} {
position: fixed;
z-index: 2147483645;
display: flex;
flex-direction: column;
box-sizing: border-box;
width: ${DEFAULT_QUEUE_GEOM.width}px;
max-width: calc(100vw - 16px);
max-height: min(360px, calc(100vh - 24px));
background: #0f0f12;
border-radius: 8px;
overflow: hidden;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 255, 255, 0.12);
}
#${QUEUE_ID}[hidden] { display: none !important; }
#${QUEUE_ID} .sc-gate-dl-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 5px 6px 5px 10px;
background: #16161c;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
color: #eee;
font: 600 11px/1.2 Interstate, "Lucida Grande", Arial, sans-serif;
cursor: move;
user-select: none;
flex-shrink: 0;
touch-action: none;
}
#${QUEUE_ID} .sc-gate-dl-toolbar button.sc-gate-dl-close {
appearance: none;
border: 0;
background: transparent;
color: #ccc;
font-size: 16px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
}
#${QUEUE_ID} .sc-gate-dl-toolbar button.sc-gate-dl-close:hover { color: #fff; }
#${QUEUE_ID} .sc-gate-dl-queue-list {
list-style: none;
margin: 0;
padding: 4px 0;
overflow-y: auto;
flex: 1;
min-height: 0;
}
#${QUEUE_ID} .sc-gate-dl-queue-item {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px 6px 10px;
color: #ddd;
font: 500 11px/1.3 Interstate, "Lucida Grande", Arial, sans-serif;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
#${QUEUE_ID} .sc-gate-dl-queue-item:last-child { border-bottom: 0; }
#${QUEUE_ID} .sc-gate-dl-queue-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
appearance: none;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: left;
cursor: pointer;
padding: 0;
}
#${QUEUE_ID} .sc-gate-dl-queue-label:hover { color: #f50; }
#${QUEUE_ID} .sc-gate-dl-queue-remove {
appearance: none;
border: 0;
background: transparent;
color: #888;
font-size: 14px;
line-height: 1;
cursor: pointer;
padding: 2px 4px;
flex-shrink: 0;
}
#${QUEUE_ID} .sc-gate-dl-queue-remove:hover { color: #fff; }
/* Resize handles — all edges + corners */
#${PANEL_ID} .sc-gate-dl-rh {
position: absolute;
z-index: 3;
background: transparent;
}
#${PANEL_ID} .sc-gate-dl-rh[data-dir="n"] { top: 0; left: 8px; right: 8px; height: 6px; cursor: n-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="s"] { bottom: 0; left: 8px; right: 8px; height: 6px; cursor: s-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="e"] { top: 8px; right: 0; bottom: 8px; width: 6px; cursor: e-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="w"] { top: 8px; left: 0; bottom: 8px; width: 6px; cursor: w-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="ne"] { top: 0; right: 0; width: 10px; height: 10px; cursor: ne-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="nw"] { top: 0; left: 0; width: 10px; height: 10px; cursor: nw-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="se"] { bottom: 0; right: 0; width: 10px; height: 10px; cursor: se-resize; }
#${PANEL_ID} .sc-gate-dl-rh[data-dir="sw"] { bottom: 0; left: 0; width: 10px; height: 10px; cursor: sw-resize; }
/* Feed checkpoint and deep-scroll controls */
#${FEED_NAV_ID} {
position: fixed;
right: 16px;
bottom: 72px;
z-index: 2147483644;
display: flex;
flex-direction: column;
gap: 6px;
box-sizing: border-box;
width: min(300px, calc(100vw - 32px));
padding: 8px;
background: #16161c;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 8px;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
font: 500 12px/1.3 Interstate, "Lucida Grande", Arial, sans-serif;
}
#${FEED_NAV_ID}[hidden] { display: none !important; }
#${FEED_NAV_ID} .sc-gate-dl-feed-heading {
display: flex;
align-items: center;
justify-content: space-between;
color: #eee;
font-weight: 700;
}
#${FEED_NAV_ID} button,
#${FEED_NAV_ID} .sc-gate-dl-feed-resume {
appearance: none;
display: block;
box-sizing: border-box;
width: 100%;
min-height: 30px;
padding: 6px 9px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 6px;
background: #0f0f12;
color: #eee;
font: inherit;
text-align: left;
text-decoration: none;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
#${FEED_NAV_ID} button.sc-gate-dl-feed-close {
width: auto;
min-height: 0;
padding: 0 5px;
border: 0;
background: transparent;
font-size: 18px;
line-height: 1;
}
#${FEED_NAV_ID} button[hidden] { display: none !important; }
#${FEED_NAV_ID} .sc-gate-dl-feed-resume[hidden] { display: none !important; }
#${FEED_NAV_ID} button:hover:not(:disabled),
#${FEED_NAV_ID} .sc-gate-dl-feed-resume:hover:not([aria-disabled]) {
border-color: #f50;
color: #fff;
}
#${FEED_NAV_ID} button:disabled { color: #777; cursor: default; }
#${FEED_NAV_ID} .sc-gate-dl-feed-resume[aria-disabled] {
color: #777;
cursor: default;
}
#${FEED_NAV_ID} .sc-gate-dl-feed-find { text-align: center; }
#${FEED_NAV_ID} .sc-gate-dl-feed-reset-row {
display: flex;
gap: 6px;
}
#${FEED_NAV_ID} .sc-gate-dl-feed-reset-row button {
flex: 1 1 50%;
width: 50%;
color: #aaa;
text-align: center;
}
#${FEED_NAV_ID} .sc-gate-dl-feed-status:empty { display: none; }
#${FEED_NAV_ID} .sc-gate-dl-feed-status {
padding: 1px 2px;
color: #aaa;
font-size: 11px;
}
.sc-gate-dl-feed-target {
outline: 3px solid #f50 !important;
outline-offset: 5px;
transition: outline-color 300ms ease;
}
/* Classic: SoundCloud .tooltip (#303030 + caret + opacity fade)
MUI/Webi: sampled native tip — #191919 fill, #3a3a3a border, ~#f8f8f8
11px regular text, Grow enter (scale 0.75→1 + fade, 200ms) */
#${TOOLTIP_ID} {
position: fixed !important;
z-index: 2147483647 !important;
pointer-events: none;
border: 0;
box-shadow: none;
white-space: nowrap;
max-width: min(280px, calc(100vw - 16px));
opacity: 0;
transform: none;
will-change: opacity, transform;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-measure {
opacity: 0 !important;
transform: none !important;
transition: none !important;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-classic {
background: #303030;
color: #fff;
border-radius: 4px;
padding: 4px 8px;
font: 500 12px/1.3 Söhne, Interstate, "Lucida Grande", Arial, sans-serif;
transition: opacity ${TOOLTIP_EXIT_MS_CLASSIC}ms ease;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-mui {
background: #191919;
color: #f8f8f8;
border: 1px solid #3a3a3a;
border-radius: 4px;
padding: 4px 8px;
font: 400 11px/1.4 Söhne, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
transform: scale(0.75);
transform-origin: center bottom;
transition:
opacity ${TOOLTIP_ENTER_ANIM_MS_MUI}ms cubic-bezier(0, 0, 0.2, 1),
transform ${TOOLTIP_ENTER_ANIM_MS_MUI}ms cubic-bezier(0, 0, 0.2, 1);
}
#${TOOLTIP_ID}.sc-gate-dl-tip-mui.sc-gate-dl-tip-below {
transform-origin: center top;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-mui.sc-gate-dl-tip-hiding {
transition:
opacity ${TOOLTIP_EXIT_MS_MUI}ms cubic-bezier(0.4, 0, 1, 1),
transform ${TOOLTIP_EXIT_MS_MUI}ms cubic-bezier(0.4, 0, 1, 1);
}
#${TOOLTIP_ID}.sc-gate-dl-tip-visible {
opacity: 1;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-mui.sc-gate-dl-tip-visible {
transform: scale(1);
}
#${TOOLTIP_ID} .sc-gate-dl-tip-content { position: relative; z-index: 1; }
#${TOOLTIP_ID} .sc-gate-dl-tip-arrow {
position: absolute;
bottom: -5px;
left: 50%;
width: 10px;
height: 10px;
margin-left: -5px;
border: 0;
transform: rotate(45deg);
background: #303030;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-below .sc-gate-dl-tip-arrow {
bottom: auto;
top: -5px;
}
@media (prefers-reduced-motion: reduce) {
#${TOOLTIP_ID}.sc-gate-dl-tip-classic,
#${TOOLTIP_ID}.sc-gate-dl-tip-mui {
transition: none;
transform: none;
}
#${TOOLTIP_ID}.sc-gate-dl-tip-mui.sc-gate-dl-tip-visible {
transform: none;
}
}
/* Format / server chooser (Violentmonkey menu) */
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) {
position: fixed;
inset: 0;
z-index: 2147483647;
display: flex;
align-items: flex-start;
justify-content: flex-end;
padding: 72px 16px 16px;
pointer-events: auto;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-format-card {
pointer-events: auto;
width: min(280px, calc(100vw - 32px));
background: #16161c;
color: #eee;
border: 1px solid rgba(255,255,255,0.12);
border-radius: 10px;
box-shadow: 0 12px 40px rgba(0,0,0,0.45);
padding: 14px;
font: 500 13px/1.35 Interstate, "Lucida Grande", Arial, sans-serif;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-server-card {
width: min(360px, calc(100vw - 32px));
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-format-title {
font-weight: 700;
margin-bottom: 10px;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-server-hint {
margin: 0 0 10px;
color: #aaa;
font-size: 12px;
font-weight: 400;
line-height: 1.4;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-server-input {
display: block;
width: 100%;
box-sizing: border-box;
resize: vertical;
min-height: 88px;
margin: 0;
padding: 8px;
border-radius: 6px;
border: 1px solid rgba(255,255,255,0.16);
background: #0f0f14;
color: #eee;
font: 500 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-format-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
cursor: pointer;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-format-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) button {
appearance: none;
border: 1px solid rgba(255,255,255,0.14);
background: #0f0f12;
color: #ddd;
border-radius: 6px;
padding: 6px 10px;
cursor: pointer;
font: inherit;
}
:is(#sc-gate-dl-format-dialog, #sc-gate-dl-browser-mode-dialog, #sc-gate-dl-server-dialog) .sc-gate-dl-format-save {
background: #f50;
border-color: #f50;
color: #fff;
}
/* Classic SC — sibling of cart in .soundActions (cart wrapper is ~16px and clips) */
div[${WRAP_ATTR}="classic"] {
display: inline-flex !important;
align-items: center;
justify-content: center;
vertical-align: middle;
margin-left: 2px !important;
width: 32px;
height: 32px;
overflow: visible !important;
line-height: 0;
flex-shrink: 0;
}
a[${BUTTON_ATTR}="classic"] {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 32px !important;
height: 32px !important;
min-width: 32px !important;
min-height: 32px !important;
padding: 0 !important;
box-sizing: border-box;
vertical-align: middle;
overflow: visible !important;
}
a[${BUTTON_ATTR}="classic"] .sc-button-label {
display: flex !important;
align-items: center;
justify-content: center;
padding: 0 !important;
width: 100%;
}
a[${BUTTON_ATTR}="classic"] svg {
display: block;
width: 16px;
height: 16px;
}
a[${STORE_SERVICE_ATTR}] svg,
a[${STORE_SERVICE_ATTR}] .sc-button-icon {
opacity: 0 !important;
}
a.soundActions__purchaseLink[${STORE_SERVICE_ATTR}],
a.sc-button-buy[${STORE_SERVICE_ATTR}] {
position: relative !important;
background-image: none !important;
}
a.soundActions__purchaseLink[${STORE_SERVICE_ATTR}]::before,
a.sc-button-buy[${STORE_SERVICE_ATTR}]::before {
opacity: 0 !important;
}
a.soundActions__purchaseLink[${STORE_SERVICE_ATTR}]::after,
a.sc-button-buy[${STORE_SERVICE_ATTR}]::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 16px;
height: 16px;
transform: translate(-50%, -50%);
background: var(--sc-gate-dl-store-icon) center / contain no-repeat;
pointer-events: none;
}
/* MUI listen page */
div[${WRAP_ATTR}="mui"] {
display: inline-flex;
vertical-align: middle;
}
button[${BUTTON_ATTR}="mui"] svg {
display: block;
width: 24px;
height: 24px;
}
a[${STORE_SERVICE_ATTR}] > button {
position: relative;
}
a[${STORE_SERVICE_ATTR}] > button::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 24px;
height: 24px;
transform: translate(-50%, -50%);
background: var(--sc-gate-dl-store-icon) center / contain no-repeat;
pointer-events: none;
}
`;
}
function applyDefaultGeom(panel) {
const saved = loadGeom();
if (saved) {
panel.style.width = `${saved.width}px`;
panel.style.height = `${saved.height}px`;
panel.style.left = `${saved.left}px`;
panel.style.top = `${saved.top}px`;
panel.style.right = 'auto';
return;
}
const height = Math.min(
Math.max(window.innerHeight - DEFAULT_GEOM.top - 12, 360),
window.innerHeight - 24,
);
panel.style.width = `${DEFAULT_GEOM.width}px`;
panel.style.height = `${height}px`;
panel.style.top = `${DEFAULT_GEOM.top}px`;
panel.style.right = `${DEFAULT_GEOM.right}px`;
panel.style.left = 'auto';
}
function clampPanel(panel) {
// display:none → zero rect; clamping then would snap to (0,0) and wipe position
if (panel.hidden) return;
const rect = panel.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const maxLeft = window.innerWidth - Math.min(rect.width, window.innerWidth);
const maxTop =
window.innerHeight - Math.min(rect.height, window.innerHeight);
const left = Math.min(Math.max(0, rect.left), Math.max(0, maxLeft));
const top = Math.min(Math.max(0, rect.top), Math.max(0, maxTop));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.right = 'auto';
}
function enableDrag(panel) {
const toolbar = panel.querySelector('.sc-gate-dl-toolbar');
if (!toolbar || toolbar.dataset.dragBound) return;
toolbar.dataset.dragBound = '1';
let dragging = false;
let startX = 0;
let startY = 0;
let origLeft = 0;
let origTop = 0;
toolbar.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('a, button, select, label')) return;
dragging = true;
const rect = panel.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
origLeft = rect.left;
origTop = rect.top;
panel.style.left = `${origLeft}px`;
panel.style.top = `${origTop}px`;
panel.style.right = 'auto';
toolbar.setPointerCapture(e.pointerId);
e.preventDefault();
});
toolbar.addEventListener('pointermove', (e) => {
if (!dragging) return;
panel.style.left = `${origLeft + (e.clientX - startX)}px`;
panel.style.top = `${origTop + (e.clientY - startY)}px`;
});
const endDrag = (e) => {
if (!dragging) return;
dragging = false;
try {
toolbar.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
clampPanel(panel);
saveGeom(panel);
};
toolbar.addEventListener('pointerup', endDrag);
toolbar.addEventListener('pointercancel', endDrag);
}
function enableEdgeResize(panel) {
if (panel.dataset.resizeBound) return;
panel.dataset.resizeBound = '1';
for (const dir of ['n', 's', 'e', 'w', 'ne', 'nw', 'se', 'sw']) {
const handle = document.createElement('div');
handle.className = 'sc-gate-dl-rh';
handle.dataset.dir = dir;
panel.appendChild(handle);
}
let resizing = false;
let dir = '';
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let startW = 0;
let startH = 0;
panel.addEventListener('pointerdown', (e) => {
const handle = e.target.closest?.('.sc-gate-dl-rh');
if (!(handle instanceof HTMLElement) || e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
resizing = true;
dir = handle.dataset.dir || '';
const rect = panel.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
startLeft = rect.left;
startTop = rect.top;
startW = rect.width;
startH = rect.height;
panel.style.left = `${startLeft}px`;
panel.style.top = `${startTop}px`;
panel.style.right = 'auto';
handle.setPointerCapture(e.pointerId);
});
panel.addEventListener('pointermove', (e) => {
if (!resizing) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
let left = startLeft;
let top = startTop;
let width = startW;
let height = startH;
if (dir.includes('e')) width = startW + dx;
if (dir.includes('s')) height = startH + dy;
if (dir.includes('w')) {
width = startW - dx;
left = startLeft + dx;
}
if (dir.includes('n')) {
height = startH - dy;
top = startTop + dy;
}
if (width < MIN_PANEL_W) {
if (dir.includes('w')) left = startLeft + startW - MIN_PANEL_W;
width = MIN_PANEL_W;
}
if (height < MIN_PANEL_H) {
if (dir.includes('n')) top = startTop + startH - MIN_PANEL_H;
height = MIN_PANEL_H;
}
width = Math.min(width, window.innerWidth - 8);
height = Math.min(height, window.innerHeight - 8);
left = Math.min(Math.max(0, left), window.innerWidth - width);
top = Math.min(Math.max(0, top), window.innerHeight - height);
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
panel.style.width = `${width}px`;
panel.style.height = `${height}px`;
});
const endResize = (e) => {
if (!resizing) return;
resizing = false;
try {
e.target.releasePointerCapture?.(e.pointerId);
} catch {
// ignore
}
clampPanel(panel);
saveGeom(panel);
};
panel.addEventListener('pointerup', endResize);
panel.addEventListener('pointercancel', endResize);
}
function buildWebuiSrc(trackUrl, embedded = false) {
const params = new URLSearchParams({
url: trackUrl,
outputFormat: getOutputFormat(),
browserMode: getBrowserMode(),
});
if (embedded) params.set('embedded', '1');
return `${getWebuiBase()}/?${params.toString()}`;
}
function loadTrackIntoPanel(panel, trackUrl) {
// Switching tracks cancels any in-flight job for the previous one
if (panel.dataset.jobId) {
void cancelActiveJob(panel);
}
panel.dataset.trackUrl = trackUrl;
const iframeSrc = buildWebuiSrc(trackUrl, true);
const tabSrc = buildWebuiSrc(trackUrl);
const iframe = panel.querySelector('iframe');
const openTab = panel.querySelector('.sc-gate-dl-open-tab');
if (iframe) iframe.src = iframeSrc;
if (openTab instanceof HTMLAnchorElement) openTab.href = tabSrc;
}
function getApiBase() {
try {
if (typeof GM_getValue === 'function') {
const gm = GM_getValue(API_BASE_KEY, null);
if (typeof gm === 'string' && gm.trim()) {
return gm.replace(/\/$/, '');
}
}
} catch {
// ignore
}
try {
const stored = localStorage.getItem(API_BASE_KEY)?.replace(/\/$/, '');
if (stored) return stored;
} catch {
// ignore
}
try {
return apiOriginFromWebui(getWebuiBase());
} catch {
return 'http://localhost:3000';
}
}
async function cancelActiveJob(panel) {
if (!panel) return;
const jobId = panel.dataset.jobId;
if (!jobId) return;
const iframe = panel.querySelector('iframe');
if (iframe?.contentWindow) {
try {
iframe.contentWindow.postMessage(
{ source: 'sc-gate-dl-host', type: 'cancel' },
new URL(getWebuiBase()).origin,
);
} catch {
// ignore
}
}
await requestJobCancel(jobId);
delete panel.dataset.jobId;
}
function cancelJobOnHostUnload() {
const panel = document.getElementById(PANEL_ID);
const jobId = panel?.dataset.jobId;
if (!jobId) return;
void requestJobCancel(jobId);
delete panel.dataset.jobId;
}
window.addEventListener('pagehide', cancelJobOnHostUnload);
window.addEventListener('beforeunload', cancelJobOnHostUnload);
async function closePanel() {
window.clearTimeout(autoCloseTimer);
const panel = document.getElementById(PANEL_ID);
if (!panel) return;
// Cancel via the GM bridge before tearing down the iframe — page fetch to
// localhost is blocked by Private Network Access, and blanking the iframe
// races the embedded WebUI's own unload cancel.
const cancellation = cancelActiveJob(panel);
await cancellation;
const iframe = panel.querySelector('iframe');
if (iframe) iframe.src = 'about:blank';
delete panel.dataset.trackUrl;
delete panel.dataset.jobId;
panel.hidden = true;
}
function applyQueueGeom(el) {
const saved = loadQueueGeom();
if (saved) {
el.style.width = `${saved.width}px`;
el.style.left = `${saved.left}px`;
el.style.top = `${saved.top}px`;
return;
}
el.style.width = `${DEFAULT_QUEUE_GEOM.width}px`;
el.style.left = `${DEFAULT_QUEUE_GEOM.left}px`;
el.style.top = `${DEFAULT_QUEUE_GEOM.top}px`;
}
function clampQueue(el) {
if (el.hidden) return;
const rect = el.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return;
const maxLeft = window.innerWidth - Math.min(rect.width, window.innerWidth);
const maxTop =
window.innerHeight - Math.min(rect.height, window.innerHeight);
const left = Math.min(Math.max(0, rect.left), Math.max(0, maxLeft));
const top = Math.min(Math.max(0, rect.top), Math.max(0, maxTop));
el.style.left = `${left}px`;
el.style.top = `${top}px`;
}
function enableQueueDrag(el) {
const toolbar = el.querySelector('.sc-gate-dl-toolbar');
if (!toolbar || toolbar.dataset.dragBound) return;
toolbar.dataset.dragBound = '1';
let dragging = false;
let startX = 0;
let startY = 0;
let origLeft = 0;
let origTop = 0;
toolbar.addEventListener('pointerdown', (e) => {
if (e.button !== 0) return;
if (e.target.closest('button')) return;
dragging = true;
const rect = el.getBoundingClientRect();
startX = e.clientX;
startY = e.clientY;
origLeft = rect.left;
origTop = rect.top;
toolbar.setPointerCapture(e.pointerId);
e.preventDefault();
});
toolbar.addEventListener('pointermove', (e) => {
if (!dragging) return;
el.style.left = `${origLeft + (e.clientX - startX)}px`;
el.style.top = `${origTop + (e.clientY - startY)}px`;
});
const endDrag = (e) => {
if (!dragging) return;
dragging = false;
try {
toolbar.releasePointerCapture(e.pointerId);
} catch {
// ignore
}
clampQueue(el);
saveQueueGeom(el);
};
toolbar.addEventListener('pointerup', endDrag);
toolbar.addEventListener('pointercancel', endDrag);
}
function clearQueue() {
downloadQueue.length = 0;
renderQueue();
}
function removeFromQueue(index) {
if (index < 0 || index >= downloadQueue.length) return;
downloadQueue.splice(index, 1);
renderQueue();
}
function enqueueDownload(trackUrl) {
if (downloadQueue.some((item) => item.url === trackUrl)) return;
const panel = document.getElementById(PANEL_ID);
if (panel && !panel.hidden && panel.dataset.trackUrl === trackUrl) return;
downloadQueue.push({
url: trackUrl,
label: trackLabelFromUrl(trackUrl),
});
renderQueue();
}
async function startNextFromQueue() {
window.clearTimeout(autoCloseTimer);
const next = downloadQueue.shift();
renderQueue();
if (!next) return false;
const opened = await openDownload(next.url);
if (!opened) {
downloadQueue.unshift(next);
renderQueue();
return false;
}
return true;
}
async function startQueuedAt(index) {
if (index < 0 || index >= downloadQueue.length) return;
const [item] = downloadQueue.splice(index, 1);
renderQueue();
if (!item) return;
const panel = document.getElementById(PANEL_ID);
// Active job in progress — only promote to next in line
if (panel && !panel.hidden && panel.dataset.jobId) {
downloadQueue.unshift(item);
renderQueue();
return;
}
const opened = await openDownload(item.url);
if (!opened) {
downloadQueue.splice(Math.min(index, downloadQueue.length), 0, item);
renderQueue();
}
}
function renderQueue() {
ensureStyles();
let el = document.getElementById(QUEUE_ID);
if (downloadQueue.length === 0) {
if (el) el.hidden = true;
return;
}
if (!el) {
el = document.createElement('div');
el.id = QUEUE_ID;
el.setAttribute('role', 'dialog');
el.setAttribute('aria-modal', 'false');
el.setAttribute('aria-label', 'sc-gate-dl download queue');
el.innerHTML = `
Queue
`;
el.querySelector('.sc-gate-dl-close')?.addEventListener('click', () => {
clearQueue();
});
document.documentElement.appendChild(el);
applyQueueGeom(el);
enableQueueDrag(el);
}
const title = el.querySelector('.sc-gate-dl-queue-title');
if (title) title.textContent = `Queue (${downloadQueue.length})`;
const list = el.querySelector('.sc-gate-dl-queue-list');
if (!(list instanceof HTMLElement)) return;
list.replaceChildren();
downloadQueue.forEach((item, index) => {
const li = document.createElement('li');
li.className = 'sc-gate-dl-queue-item';
const labelBtn = document.createElement('button');
labelBtn.type = 'button';
labelBtn.className = 'sc-gate-dl-queue-label';
labelBtn.title = item.url;
labelBtn.textContent = item.label;
labelBtn.addEventListener('click', () => startQueuedAt(index));
const removeBtn = document.createElement('button');
removeBtn.type = 'button';
removeBtn.className = 'sc-gate-dl-queue-remove';
removeBtn.setAttribute('aria-label', 'Remove from queue');
removeBtn.title = 'Remove';
removeBtn.textContent = '×';
removeBtn.addEventListener('click', () => removeFromQueue(index));
li.append(labelBtn, removeBtn);
list.appendChild(li);
});
el.hidden = false;
clampQueue(el);
}
async function openDownload(trackUrl) {
if (getAlwaysOpenTab()) {
const bases = getWebuiBases();
const cacheHit =
activeWebuiBase &&
Date.now() - activeWebuiBaseAt < WEBUI_RESOLVE_CACHE_MS &&
bases.includes(activeWebuiBase);
if (cacheHit) {
window.open(buildWebuiSrc(trackUrl), '_blank', 'noopener,noreferrer');
return true;
}
// Keep the window handle (no noopener) so we can navigate after probing.
const tab = window.open('about:blank', '_blank');
if (!tab) {
window.alert(
'sc-gate-dl: popup blocked. Allow popups for SoundCloud or disable always-open-in-tab.',
);
return false;
}
try {
await resolveWebuiBase();
tab.location.href = buildWebuiSrc(trackUrl);
return true;
} catch (error) {
tab.close();
window.alert(
error instanceof Error
? error.message
: 'No configured sc-gate-dl server is reachable.',
);
return false;
}
}
try {
await resolveWebuiBase();
} catch (error) {
window.alert(
error instanceof Error
? error.message
: 'No configured sc-gate-dl server is reachable.',
);
return false;
}
if (isPanelBusy()) {
enqueueDownload(trackUrl);
return true;
}
openPanel(trackUrl);
return true;
}
function requestDownload(trackUrl) {
void openDownload(trackUrl);
}
function finishCurrentAndAdvance() {
if (downloadQueue.length > 0) {
void startNextFromQueue();
return;
}
void closePanel();
}
let autoCloseTimer = 0;
function releaseRemotePointer() {
const iframe = document.getElementById(PANEL_ID)?.querySelector('iframe');
if (!iframe?.contentWindow) return;
try {
iframe.contentWindow.postMessage(
{ source: 'sc-gate-dl-host', type: 'release-remote-pointer' },
new URL(getWebuiBase()).origin,
);
} catch {
// The iframe may be navigating or already closed.
}
}
window.addEventListener('pointerup', releaseRemotePointer, true);
window.addEventListener('blur', releaseRemotePointer);
window.addEventListener('message', (event) => {
const data = event.data;
if (!data || data.source !== 'sc-gate-dl') return;
const panel = document.getElementById(PANEL_ID);
let webuiOrigin;
try {
webuiOrigin = new URL(getWebuiBase()).origin;
} catch {
return;
}
if (event.origin !== webuiOrigin) return;
if (event.source !== panel?.querySelector('iframe')?.contentWindow) return;
if (data.type === 'job' && data.jobId && panel) {
panel.dataset.jobId = String(data.jobId);
return;
}
if ((data.type === 'cancelled' || data.type === 'ready') && panel) {
delete panel.dataset.jobId;
return;
}
if (data.type === 'file-download') {
if (panel) delete panel.dataset.jobId;
if (!getAutoClose()) return;
window.clearTimeout(autoCloseTimer);
autoCloseTimer = window.setTimeout(() => {
finishCurrentAndAdvance();
}, 600);
} else if (data.type === 'new-download') {
if (panel) delete panel.dataset.jobId;
if (downloadQueue.length > 0) {
startNextFromQueue();
return;
}
if (getAutoClose()) void closePanel();
}
});
function openPanel(trackUrl) {
window.clearTimeout(autoCloseTimer);
ensureStyles();
let panel = document.getElementById(PANEL_ID);
if (!panel) {
panel = document.createElement('div');
panel.id = PANEL_ID;
panel.hidden = true;
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'false');
panel.setAttribute('aria-label', 'sc-gate-dl');
const format = getOutputFormat();
const browserMode = getBrowserMode();
panel.innerHTML = `
`;
panel
.querySelector('.sc-gate-dl-close')
?.addEventListener('click', () => {
void closePanel();
});
panel
.querySelector('.sc-gate-dl-open-tab')
?.addEventListener('click', () => {
// Let the target=_blank navigation happen, then tear down this job.
void closePanel();
});
panel
.querySelector('.sc-gate-dl-server')
?.addEventListener('click', configureWebuiBase);
panel
.querySelector('.sc-gate-dl-browser-mode')
?.addEventListener('change', (e) => {
const select = e.target;
if (!(select instanceof HTMLSelectElement)) return;
setBrowserMode(select.value);
});
panel
.querySelector('.sc-gate-dl-format')
?.addEventListener('change', (e) => {
const select = e.target;
if (!(select instanceof HTMLSelectElement)) return;
setOutputFormat(select.value);
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !panel.hidden) void closePanel();
});
document.documentElement.appendChild(panel);
applyDefaultGeom(panel);
enableDrag(panel);
enableEdgeResize(panel);
}
loadTrackIntoPanel(panel, trackUrl);
panel.hidden = false;
clampPanel(panel);
}
function bindClick(el, trackUrl) {
el.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
hideTooltip();
const url = resolveTrackUrl(el) || trackUrl;
if (!url) {
alert('sc-gate-dl: could not resolve a SoundCloud track URL here.');
return;
}
requestDownload(url);
});
}
function makeClassicControl(trackUrl) {
// Mirror purchaseLink__container: SC tooltips key off aria-describedby + hover
const wrap = document.createElement('div');
wrap.setAttribute(WRAP_ATTR, 'classic');
wrap.className = 'purchaseLink__container';
wrap.setAttribute('aria-describedby', TOOLTIP_ID);
wrap.setAttribute('aria-label', TOOLTIP_LABEL);
const btn = document.createElement('a');
btn.href = '#';
btn.setAttribute(BUTTON_ATTR, 'classic');
btn.className =
'soundActions__purchaseLink sc-button sc-button-tertiary sc-button-responsive';
btn.setAttribute('aria-label', TOOLTIP_LABEL);
btn.setAttribute('aria-describedby', TOOLTIP_ID);
btn.innerHTML = `${DOWNLOAD_ICON_16}
`;
bindClick(btn, trackUrl);
wrap.appendChild(btn);
bindTooltip(wrap);
return wrap;
}
function makeMuiControl(trackUrl, templateBtn) {
const wrap = document.createElement('div');
wrap.setAttribute(WRAP_ATTR, 'mui');
wrap.className = 'MuiBox-root mui-0';
wrap.setAttribute('aria-label', TOOLTIP_LABEL);
const btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute(BUTTON_ATTR, 'mui');
btn.setAttribute('aria-label', TOOLTIP_LABEL);
btn.className =
templateBtn?.className ||
'MuiButtonBase-root MuiIconButton-root MuiIconButton-colorContrast MuiIconButton-sizeMedium';
btn.innerHTML = DOWNLOAD_ICON_24;
bindClick(btn, trackUrl);
wrap.appendChild(btn);
bindTooltip(wrap);
return wrap;
}
/**
* Classic: cart sits in a 40px `.purchaseLink__container` inside an anonymous
* wrapper under `.soundActions` (flex row). Insert AFTER that wrapper.
*/
function classicAnchorPoint(el) {
const container =
el.closest?.('.purchaseLink__container') ||
(el.classList?.contains('purchaseLink__container') ? el : null);
if (!container) {
return (
el.closest?.('a.soundActions__purchaseLink, a.sc-button-buy') || el
);
}
const actions = container.closest('.soundActions');
const wrap = container.parentElement;
if (actions && wrap && wrap.parentElement === actions && wrap !== actions) {
return wrap;
}
// Container is already a direct child of .soundActions
if (actions && container.parentElement === actions) return container;
return container;
}
function injectClassic(el) {
if (isOurNode(el)) return;
if (isPlaylistOrAlbumContext(el)) return;
const trackUrl = resolveTrackUrl(el);
// Insert as sibling AFTER the cart cluster inside .soundActions.
// Do not append inside the cart wrapper — it is ~16px tall and clips the icon.
const anchorPoint = classicAnchorPoint(el);
if (isOurNode(anchorPoint) || alreadyInjectedNear(anchorPoint)) return;
const actions =
anchorPoint.closest?.('.soundActions') || anchorPoint.parentElement;
if (actions instanceof HTMLElement) {
actions.style.overflow = 'visible';
actions.style.alignItems = 'center';
}
anchorPoint.insertAdjacentElement('afterend', makeClassicControl(trackUrl));
}
function isMuiBuyAnchor(el) {
if (!(el instanceof HTMLAnchorElement)) return false;
if (isOurNode(el)) return false;
// Never treat classic SC purchase links as MUI (caused duplicate under-cart inject)
if (
el.closest(
'.purchaseLink__container, .soundActions, .sound__soundActions',
)
) {
return false;
}
if (el.classList.contains('soundActions__purchaseLink')) return false;
const label = el.getAttribute('aria-label') || '';
if (!/buy|store|purchase|free\s*download/i.test(label)) return false;
return !!el.querySelector('button.MuiIconButton-root, svg');
}
function injectMui(buyAnchor) {
if (!isMuiBuyAnchor(buyAnchor)) return;
if (isPlaylistOrAlbumContext(buyAnchor)) return;
if (alreadyInjectedNear(buyAnchor)) return;
const templateBtn = buyAnchor.querySelector('button.MuiIconButton-root');
const trackUrl = resolveTrackUrl(buyAnchor);
buyAnchor.insertAdjacentElement(
'afterend',
makeMuiControl(trackUrl, templateBtn),
);
}
function injectMuiWithoutBuy(moreButton) {
if (!(moreButton instanceof HTMLButtonElement)) return;
if (isOurNode(moreButton)) return;
if (!isMuiTrackMoreButton(moreButton)) return;
if (isPlaylistOrAlbumContext(moreButton)) return;
if (alreadyInjectedNear(moreButton)) return;
const trackUrl = pageTrackUrl();
if (!trackUrl) return;
moreButton.insertAdjacentElement(
'beforebegin',
makeMuiControl(trackUrl, moreButton),
);
}
/**
* MUI listen-page overflow control. Prefer the stable id prefix — aria-labels
* are localized ("More actions" / "Mehr Aktionen", etc.).
* Comment ellipsis and related-track menus reuse the same id prefix and can
* sit inside the Track header section, so id-only matching is not enough.
*/
function isMuiTrackMoreButton(el) {
if (!(el instanceof HTMLButtonElement)) return false;
if (!el.classList.contains('MuiIconButton-root')) return false;
// Comments: variant=ghost + colorPrimary, inside ul[aria-label="Comments"].
// Related tracks: sizeSmall, inside the track sidebar aside.
if (el.getAttribute('variant') === 'ghost') return false;
if (el.classList.contains('MuiIconButton-colorPrimary')) return false;
if (el.classList.contains('MuiIconButton-sizeSmall')) return false;
if (el.closest('ul[aria-label], aside')) return false;
const id = el.id || '';
if (id.startsWith('desktop-menu-button-')) return true;
// Fallback if SC drops the id: last action with a menu popup.
return (
el.getAttribute('aria-haspopup') === 'true' &&
!!el.closest('section[aria-label]') &&
/track[- ]?header/i.test(
el.closest('section[aria-label]')?.getAttribute('aria-label') || '',
)
);
}
function isClassicPurchase(el) {
if (!(el instanceof Element) || isOurNode(el)) return false;
if (el.classList.contains('purchaseLink__container')) return true;
if (el.classList.contains('soundActions__purchaseLink')) return true;
if (el.classList.contains('sc-button-buy')) return true;
return false;
}
function scan(root = document) {
const scope =
root instanceof Element || root === document ? root : document;
// Drop buttons that landed on playlist/album cards (e.g. before class hydrated)
for (const wrap of scope.querySelectorAll?.(`[${WRAP_ATTR}]`) || []) {
if (isPlaylistOrAlbumContext(wrap)) wrap.remove();
}
// Brand supported purchase links without loading third-party icon assets.
for (const anchor of scope.querySelectorAll?.(
'a.soundActions__purchaseLink, a.sc-button-buy, a[aria-label="Buy This Track"], a[target="_blank"][aria-label]',
) || []) {
const label = anchor.getAttribute('aria-label') || '';
if (
!anchor.classList.contains('soundActions__purchaseLink') &&
!anchor.classList.contains('sc-button-buy') &&
!/buy|store|purchase|free\s*download/i.test(label)
) {
continue;
}
decorateStoreLink(anchor);
}
// Classic layout only — one control per purchaseLink__container
const containers =
scope.querySelectorAll?.('.purchaseLink__container') || [];
for (const el of containers) {
if (isOurNode(el)) continue;
injectClassic(el);
}
// Orphan classic purchase anchors without container (rare)
for (const el of scope.querySelectorAll?.(
'a.soundActions__purchaseLink, a.sc-button-buy',
) || []) {
if (isOurNode(el)) continue;
if (el.closest('.purchaseLink__container')) continue;
if (isClassicPurchase(el)) injectClassic(el);
}
// MUI logged-in listen page only
for (const el of scope.querySelectorAll?.(
'a[aria-label="Buy This Track"], a[target="_blank"][aria-label]',
) || []) {
injectMui(el);
}
// Tracks without a purchase link still have the action row's More button.
// id prefix is language-stable; aria-labels are not.
for (const el of scope.querySelectorAll?.(
'button[id^="desktop-menu-button-"].MuiIconButton-root, section[aria-label] button.MuiIconButton-root[aria-haspopup="true"]',
) || []) {
if (!isMuiTrackMoreButton(el)) continue;
injectMuiWithoutBuy(el);
}
}
ensureStyles();
scan();
ensureFeedNavigator();
document.addEventListener(
'click',
(event) => {
if (!isFeedPage() || !(event.target instanceof Element)) return;
const playControl = event.target.closest(
'button.playControl, button.sc-button-play, button.sc-button-pause, .soundTitle__playButton, .sound__coverArt .playButton',
);
const card = playControl?.closest(FEED_CARD_SELECTOR);
const cardUrl = card ? trackUrlFromCard(playControl) : null;
const outsidePlaybackSelection = Boolean(
!card &&
event.target.closest('.playControls, .playbackSoundBadge, .queue'),
);
feedPlaybackOriginUrl = updateFeedPlaybackOrigin(
feedPlaybackOriginUrl,
cardUrl,
outsidePlaybackSelection,
);
if (card) {
saveFeedCheckpointFromCard(card, cardUrl);
}
},
true,
);
let scanTimer = 0;
function scheduleScan() {
if (scanTimer) return;
scanTimer = window.setTimeout(() => {
scanTimer = 0;
scan();
}, 0);
}
const observer = new MutationObserver((mutations) => {
let shouldScan = false;
for (const m of mutations) {
if (m.type === 'attributes') {
shouldScan = true;
break;
}
for (const node of m.addedNodes) {
if (!(node instanceof Element)) continue;
if (isOurNode(node)) continue;
shouldScan = true;
break;
}
if (shouldScan) break;
}
if (shouldScan) scheduleScan();
});
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['href'],
});
let lastHref = location.href;
setInterval(() => {
if (location.href !== lastHref) {
lastHref = location.href;
scan();
ensureFeedNavigator();
}
recordPlayingFeedTrack();
}, 1000);
registerMenuCommands();
console.info(
`[sc-gate-dl] ready — Web UI: ${getWebuiBases().join(' → ')} (run \`bun webui\`). Override with localStorage key "${WEBUI_BASE_KEY}" (one address per line).`,
);
})();