Skip to content

Instantly share code, notes, and snippets.

@vdeemann
Last active May 19, 2026 09:33
Show Gist options
  • Select an option

  • Save vdeemann/784fcffa75e0b50fab6ac29ab19bb271 to your computer and use it in GitHub Desktop.

Select an option

Save vdeemann/784fcffa75e0b50fab6ac29ab19bb271 to your computer and use it in GitHub Desktop.
Auto-mutes songs from DJs not on your favorites list on deepcut.fm
// ==UserScript==
// @name Deepcut DJ Auto-Muter
// @namespace https://github.com/vdeemann
// @version 2.1.0
// @description Auto-mutes songs from DJs not on your favorites list on deepcut.fm
// @author vdeemann
// @match https://deepcut.live/*
// @match https://deepcut.fm/*
// @match https://deep-cut.fm/*
// @match https://www.deepcut.live/*
// @match https://www.deepcut.fm/*
// @match https://www.deep-cut.fm/*
// @match https://70s.neader.com/*
// @match https://*.neader.com/*
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-idle
// ==/UserScript==
(function () {
'use strict';
// ═══════════════════════════════════════════════════════════════════
// PERSISTENCE
// ═══════════════════════════════════════════════════════════════════
const store = {
get(key, fallback) {
try { if (typeof GM_getValue === 'function') return GM_getValue(key, fallback); } catch (_) {}
try { const v = localStorage.getItem('djm_' + key); return v !== null ? JSON.parse(v) : fallback; } catch (_) { return fallback; }
},
set(key, val) {
try { if (typeof GM_setValue === 'function') return GM_setValue(key, val); } catch (_) {}
try { localStorage.setItem('djm_' + key, JSON.stringify(val)); } catch (_) {}
}
};
// ═══════════════════════════════════════════════════════════════════
// STATE
// ═══════════════════════════════════════════════════════════════════
const state = {
enabled: store.get('enabled', true),
favorites: new Set((store.get('favorites', [])).map(n => n.toLowerCase())),
currentDJ: null,
isMuted: false,
myUsername: store.get('myUsername', ''),
prevVolume: null,
log: [],
// Lifecycle
uiMounted: false,
djInterval: null,
enforceInterval: null,
djObserver: null,
iframeObserver: null,
};
function saveFavorites() {
store.set('favorites', [...state.favorites]);
}
// ═══════════════════════════════════════════════════════════════════
// ROOM DETECTION
// ═══════════════════════════════════════════════════════════════════
//
// The UI should ONLY appear inside a room, not on the homepage / room-list /
// settings / profile pages. We detect room context by:
// 1. Presence of room-only DOM (current-DJ element, songboard, room player)
// 2. URL pattern — deepcut room URLs typically look like
// /room/<slug> or /r/<slug> (not "/" or "/favorites" or "/settings")
//
// Either signal is sufficient; DOM is more reliable, URL is the fast-path
// before the room DOM has rendered.
function urlLooksLikeRoom() {
const path = (location.pathname || '').toLowerCase();
// Homepage and known non-room top-level pages
const nonRoomPaths = [
'/', '/favorites', '/settings', '/profile', '/login',
'/signup', '/about', '/contact', '/terms', '/privacy',
'/djs-needed', '/all-rooms'
];
if (nonRoomPaths.includes(path)) return false;
// Common room URL shapes
if (/^\/(room|r|rooms)\/[^/]+/.test(path)) return true;
// Fall through — let DOM be the deciding factor
return null; // unknown
}
function domLooksLikeRoom() {
// Most reliable: the mini-room current-DJ name element exists
if (document.querySelector('.mini-room-current-dj-name')) return true;
if (document.querySelector('.mini-room-current-dj')) return true;
// Songboard appears in rooms only
if (document.querySelector('.songboard-artist.songboard-main')) return true;
if (document.querySelector('.songboard-song.songboard-main')) return true;
// A YouTube embed in the page is also a strong room signal
if (document.querySelector('iframe[src*="youtube.com/embed"]')) return true;
return false;
}
function isInsideRoom() {
const urlSignal = urlLooksLikeRoom();
if (urlSignal === false) return false; // explicit non-room path
if (urlSignal === true && domLooksLikeRoom()) return true;
// URL unknown — defer to DOM
return domLooksLikeRoom();
}
// ═══════════════════════════════════════════════════════════════════
// DJ DETECTION — uses .mini-room-current-dj-name element
// ═══════════════════════════════════════════════════════════════════
function getCurrentDJName() {
// Primary: the mini-room current DJ name element
const el = document.querySelector('.mini-room-current-dj-name');
if (el) {
const name = (el.textContent || '').trim();
if (name.length > 0) return name;
}
// Fallback: scan chat for most recent "started playing" message
const subjects = document.querySelectorAll('.subject');
const texts = document.querySelectorAll('.text');
for (let i = texts.length - 1; i >= 0; i--) {
const t = (texts[i].textContent || '').trim();
if (t.startsWith('started playing')) {
// Find the matching subject (previous sibling or same-index)
const parent = texts[i].parentElement;
if (parent) {
const subj = parent.querySelector('.subject');
if (subj) return (subj.textContent || '').trim();
}
// Try same index
if (subjects[i]) return (subjects[i].textContent || '').trim();
}
}
return null;
}
// ═══════════════════════════════════════════════════════════════════
// MUTE / UNMUTE — YouTube iframe API + any audio elements
// ═══════════════════════════════════════════════════════════════════
function getYouTubeIframe() {
return document.querySelector('iframe[src*="youtube.com/embed"]');
}
function postToYT(iframe, func, args) {
if (!iframe || !iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage(JSON.stringify({
event: 'command',
func: func,
args: args || []
}), '*');
} catch (_) {}
}
function setMuted(shouldMute) {
if (state.isMuted === shouldMute) return;
state.isMuted = shouldMute;
const ytIframe = getYouTubeIframe();
if (shouldMute) {
postToYT(ytIframe, 'mute');
postToYT(ytIframe, 'setVolume', [0]);
document.querySelectorAll('audio, video').forEach(el => { el.muted = true; });
const volSlider = document.querySelector('input[type="range"][class*="vol"], .volume input[type="range"], [class*="volume"] input');
if (volSlider) {
state.prevVolume = volSlider.value;
setNativeValue(volSlider, 0);
}
} else {
postToYT(ytIframe, 'unMute');
postToYT(ytIframe, 'setVolume', [100]);
document.querySelectorAll('audio, video').forEach(el => { el.muted = false; });
const volSlider = document.querySelector('input[type="range"][class*="vol"], .volume input[type="range"], [class*="volume"] input');
if (volSlider && state.prevVolume !== null) {
setNativeValue(volSlider, state.prevVolume);
}
}
addLog(shouldMute
? '\u{1F507} Muted: ' + state.currentDJ
: '\u{1F50A} Unmuted: ' + (state.currentDJ || ''));
updateUI();
}
function setNativeValue(el, value) {
const setter = Object.getOwnPropertyDescriptor(
Object.getPrototypeOf(el), 'value'
)?.set || Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype, 'value'
)?.set;
if (setter) setter.call(el, value);
else el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
// ═══════════════════════════════════════════════════════════════════
// CORE LOOP — poll for DJ changes
// ═══════════════════════════════════════════════════════════════════
function checkDJ() {
if (!state.enabled) return;
const djName = getCurrentDJName();
if (!djName) return;
if (djName === state.currentDJ) return;
state.currentDJ = djName;
addLog('\u{1F3A7} Now spinning: ' + djName);
const djLower = djName.toLowerCase();
const isMe = state.myUsername && djLower === state.myUsername.toLowerCase();
const isFav = state.favorites.has(djLower);
if (isMe || isFav) {
setMuted(false);
} else {
setMuted(true);
}
updateUI();
}
function enforceMute() {
if (!state.enabled || !state.currentDJ) return;
const djLower = state.currentDJ.toLowerCase();
const isMe = state.myUsername && djLower === state.myUsername.toLowerCase();
const isFav = state.favorites.has(djLower);
const shouldBeMuted = !(isMe || isFav);
if (shouldBeMuted) {
const ytIframe = getYouTubeIframe();
postToYT(ytIframe, 'mute');
postToYT(ytIframe, 'setVolume', [0]);
document.querySelectorAll('audio, video').forEach(el => { el.muted = true; });
if (!state.isMuted) {
state.isMuted = true;
addLog('\u{1F507} Re-muted: ' + state.currentDJ + ' (new embed)');
updateUI();
}
}
}
function startWatching() {
if (state.djInterval) return; // already running
state.djInterval = setInterval(checkDJ, 1500);
state.enforceInterval = setInterval(enforceMute, 2000);
const djTarget = document.querySelector('.mini-room-current-dj') ||
document.querySelector('.mini-room-current-dj-name') ||
document.body;
state.djObserver = new MutationObserver(() => {
clearTimeout(state.djObserver._t);
state.djObserver._t = setTimeout(checkDJ, 200);
});
state.djObserver.observe(djTarget, {
childList: true, subtree: true, characterData: true
});
state.iframeObserver = new MutationObserver((mutations) => {
for (const m of mutations) {
for (const node of m.addedNodes) {
if (node.nodeType !== 1) continue;
const isYT = (node.tagName === 'IFRAME' && (node.src || '').includes('youtube.com')) ||
(node.querySelector && node.querySelector('iframe[src*="youtube.com"]'));
if (isYT) {
setTimeout(enforceMute, 500);
setTimeout(enforceMute, 1500);
setTimeout(enforceMute, 3000);
}
}
}
});
state.iframeObserver.observe(document.body, { childList: true, subtree: true });
}
function stopWatching() {
if (state.djInterval) { clearInterval(state.djInterval); state.djInterval = null; }
if (state.enforceInterval) { clearInterval(state.enforceInterval); state.enforceInterval = null; }
if (state.djObserver) { state.djObserver.disconnect(); state.djObserver = null; }
if (state.iframeObserver) { state.iframeObserver.disconnect(); state.iframeObserver = null; }
}
// ═══════════════════════════════════════════════════════════════════
// LOGGING
// ═══════════════════════════════════════════════════════════════════
function addLog(msg) {
const t = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
state.log.unshift('[' + t + '] ' + msg);
if (state.log.length > 50) state.log.length = 50;
const el = document.getElementById('djm-log');
if (el) el.textContent = state.log.slice(0, 15).join('\n');
}
// ═══════════════════════════════════════════════════════════════════
// UI
// ═══════════════════════════════════════════════════════════════════
function esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function createUI() {
if (document.getElementById('djm-root')) return; // already mounted
const root = document.createElement('div');
root.id = 'djm-root';
root.innerHTML = `
<style>
#djm-root {
position: fixed; bottom: 8px; right: 280px; z-index: 999999;
font-family: 'SF Mono','Fira Code','Cascadia Code','Consolas',monospace;
font-size: 12px; user-select: none;
}
#djm-pill {
display: flex; align-items: center; gap: 8px;
padding: 8px 14px; background: rgba(10,10,10,0.93);
backdrop-filter: blur(14px); border: 1px solid rgba(255,255,255,0.07);
border-radius: 40px; color: #d0d0d0; cursor: pointer;
transition: all 0.2s; box-shadow: 0 4px 24px rgba(0,0,0,0.55);
}
#djm-pill:hover { border-color: rgba(255,255,255,0.16); }
#djm-dot {
width: 8px; height: 8px; border-radius: 50%;
transition: background 0.3s, box-shadow 0.3s;
}
.djm-listening { background: #4ade80; box-shadow: 0 0 6px rgba(74,222,128,0.5); }
.djm-muted { background: #f87171; box-shadow: 0 0 6px rgba(248,113,113,0.5); }
.djm-off { background: #555; box-shadow: none; }
#djm-label { font-size: 11px; letter-spacing: 0.5px; text-transform: uppercase; }
#djm-panel {
display: none; position: absolute; bottom: 48px; right: 0;
width: 300px; max-height: 500px; overflow-y: auto;
background: rgba(12,12,12,0.96); backdrop-filter: blur(16px);
border: 1px solid rgba(255,255,255,0.08); border-radius: 14px;
padding: 16px; color: #bbb;
box-shadow: 0 8px 40px rgba(0,0,0,0.65);
}
#djm-panel.open { display: block; }
#djm-panel::-webkit-scrollbar { width: 4px; }
#djm-panel::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
.djm-sec { margin-bottom: 14px; }
.djm-sec-t {
font-size: 10px; text-transform: uppercase; letter-spacing: 1.2px;
color: #666; margin-bottom: 8px;
}
.djm-row {
display: flex; align-items: center; justify-content: space-between;
margin-bottom: 6px;
}
.djm-row label { font-size: 11px; color: #999; }
.djm-sw {
position: relative; width: 34px; height: 18px;
background: #333; border-radius: 9px; cursor: pointer; transition: background 0.2s;
}
.djm-sw.on { background: #4ade80; }
.djm-sw::after {
content: ''; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; background: #fff;
border-radius: 50%; transition: transform 0.2s;
}
.djm-sw.on::after { transform: translateX(16px); }
.djm-input {
width: 100%; box-sizing: border-box; padding: 6px 10px;
background: #1a1a1a; border: 1px solid #333; border-radius: 6px;
color: #ddd; font-size: 11px; font-family: inherit; outline: none;
}
.djm-input:focus { border-color: #4ade80; }
.djm-input::placeholder { color: #555; }
.djm-fav-item {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 8px; margin-top: 3px;
background: #1a1a1a; border-radius: 5px; font-size: 11px; color: #ccc;
}
.djm-fav-rm {
cursor: pointer; color: #666; font-size: 14px; padding: 0 4px;
transition: color 0.15s;
}
.djm-fav-rm:hover { color: #f87171; }
.djm-fav-empty { font-size: 10px; color: #555; font-style: italic; padding: 4px 0; }
#djm-now {
padding: 8px 10px; background: #1a1a1a; border-radius: 6px;
font-size: 11px; text-align: center; color: #888; margin-bottom: 4px;
}
#djm-now .dn { color: #e0e0e0; font-weight: 600; }
#djm-now.is-muted { border-left: 3px solid #f87171; }
#djm-now.is-fav { border-left: 3px solid #4ade80; }
#djm-qadd {
display: none; width: 100%; padding: 5px;
background: transparent; border: 1px dashed #4ade80;
border-radius: 5px; color: #4ade80; font-size: 10px;
cursor: pointer; text-transform: uppercase; letter-spacing: 0.5px;
margin-top: 4px; font-family: inherit;
}
#djm-qadd:hover { background: rgba(74,222,128,0.08); }
#djm-log {
font-size: 10px; color: #555; line-height: 1.5;
white-space: pre-wrap; max-height: 140px; overflow-y: auto;
}
#djm-song {
font-size: 10px; color: #666; text-align: center;
padding: 2px 0 4px;
}
</style>
<div id="djm-panel">
<!-- Now Playing -->
<div class="djm-sec">
<div class="djm-sec-t">Current DJ</div>
<div id="djm-now">Waiting for DJ...</div>
<div id="djm-song"></div>
<button id="djm-qadd">+ Add to Favorites</button>
</div>
<!-- Toggle -->
<div class="djm-sec">
<div class="djm-row">
<label>Auto-mute enabled</label>
<div id="djm-sw-en" class="djm-sw ${state.enabled ? 'on' : ''}"></div>
</div>
</div>
<!-- My Username -->
<div class="djm-sec">
<div class="djm-sec-t">My Username (never muted)</div>
<input class="djm-input" id="djm-me" placeholder="Your deepcut username"
value="${esc(state.myUsername)}">
</div>
<!-- Favorites -->
<div class="djm-sec">
<div class="djm-sec-t">Favorite DJs (never muted)</div>
<input class="djm-input" id="djm-addfav" placeholder="Type a DJ name, press Enter">
<div id="djm-favs"></div>
</div>
<!-- Log -->
<div class="djm-sec">
<div class="djm-sec-t">Activity Log</div>
<div id="djm-log"></div>
</div>
</div>
<div id="djm-pill">
<div id="djm-dot" class="djm-off"></div>
<div id="djm-label">DJ MUTER</div>
</div>
`;
document.body.appendChild(root);
state.uiMounted = true;
bindEvents();
renderFavorites();
updateUI();
}
function destroyUI() {
const root = document.getElementById('djm-root');
if (root) root.remove();
state.uiMounted = false;
// Reset playback state so re-entry starts clean
state.currentDJ = null;
state.isMuted = false;
}
function bindEvents() {
document.getElementById('djm-pill').addEventListener('click', () =>
document.getElementById('djm-panel').classList.toggle('open'));
document.getElementById('djm-sw-en').addEventListener('click', function () {
state.enabled = !state.enabled;
this.classList.toggle('on', state.enabled);
store.set('enabled', state.enabled);
if (!state.enabled) setMuted(false);
updateUI();
});
document.getElementById('djm-me').addEventListener('change', function () {
state.myUsername = this.value.trim();
store.set('myUsername', state.myUsername);
addLog('\u{1F464} Username set: ' + (state.myUsername || '(none)'));
state.currentDJ = null;
checkDJ();
});
document.getElementById('djm-addfav').addEventListener('keydown', function (e) {
if (e.key === 'Enter') {
const name = this.value.trim();
if (name) {
state.favorites.add(name.toLowerCase());
saveFavorites();
this.value = '';
renderFavorites();
addLog('\u2B50 Added favorite: ' + name);
if (state.currentDJ && state.currentDJ.toLowerCase() === name.toLowerCase()) {
setMuted(false);
}
updateUI();
}
}
});
document.getElementById('djm-qadd').addEventListener('click', () => {
if (state.currentDJ) {
state.favorites.add(state.currentDJ.toLowerCase());
saveFavorites();
renderFavorites();
addLog('\u2B50 Added favorite: ' + state.currentDJ);
setMuted(false);
updateUI();
}
});
}
function renderFavorites() {
const el = document.getElementById('djm-favs');
if (!el) return;
if (state.favorites.size === 0) {
el.innerHTML = '<div class="djm-fav-empty">No favorites yet</div>';
return;
}
el.innerHTML = [...state.favorites].map(n =>
'<div class="djm-fav-item"><span>' + esc(n) + '</span>' +
'<span class="djm-fav-rm" data-n="' + esc(n) + '">\u00D7</span></div>'
).join('');
el.querySelectorAll('.djm-fav-rm').forEach(btn => {
btn.addEventListener('click', function () {
const name = this.dataset.n;
state.favorites.delete(name);
saveFavorites();
renderFavorites();
addLog('\u2716 Removed: ' + name);
state.currentDJ = null;
checkDJ();
});
});
}
function updateUI() {
const dot = document.getElementById('djm-dot');
const label = document.getElementById('djm-label');
const now = document.getElementById('djm-now');
const qadd = document.getElementById('djm-qadd');
const songEl = document.getElementById('djm-song');
if (!dot) return;
if (!state.enabled) {
dot.className = 'djm-off';
label.textContent = 'DISABLED';
} else if (state.isMuted) {
dot.className = 'djm-muted';
label.textContent = 'MUTED';
} else if (state.currentDJ) {
dot.className = 'djm-listening';
label.textContent = 'PLAYING';
} else {
dot.className = 'djm-off';
label.textContent = 'DJ MUTER';
}
if (now && state.currentDJ) {
const djLower = state.currentDJ.toLowerCase();
const isFav = state.favorites.has(djLower) ||
(state.myUsername && djLower === state.myUsername.toLowerCase());
now.innerHTML = '<span class="dn">' + esc(state.currentDJ) + '</span>' +
(state.isMuted ? ' \u2014 muted' : ' \u2014 playing');
now.className = state.isMuted ? 'is-muted' : (isFav ? 'is-fav' : '');
qadd.style.display = isFav ? 'none' : 'block';
} else if (now) {
now.innerHTML = 'Waiting for DJ...';
now.className = '';
qadd.style.display = 'none';
}
if (songEl) {
const artist = document.querySelector('.songboard-artist.songboard-main');
const song = document.querySelector('.songboard-song.songboard-main');
const a = artist ? artist.textContent.trim() : '';
const s = song ? song.textContent.trim() : '';
songEl.textContent = (a && s) ? a + ' \u2014 ' + s : '';
}
}
// ═══════════════════════════════════════════════════════════════════
// LIFECYCLE — mount/unmount based on room context
// ═══════════════════════════════════════════════════════════════════
function syncToRoomContext() {
const inRoom = isInsideRoom();
if (inRoom && !state.uiMounted) {
createUI();
addLog('\u{1F680} DJ Muter v2.1 — entered room');
if (state.favorites.size === 0 && !state.myUsername) {
addLog('\u{1F4A1} Set your username & add favorites');
} else {
if (state.myUsername) addLog('\u{1F464} You: ' + state.myUsername);
if (state.favorites.size > 0) addLog('\u2B50 ' + state.favorites.size + ' favorite(s) loaded');
}
// Give the room a beat to render YT iframe / DJ name
setTimeout(() => { startWatching(); checkDJ(); }, 800);
} else if (!inRoom && state.uiMounted) {
stopWatching();
destroyUI();
}
}
function watchRouteChanges() {
// 1. History API hooks for SPA navigation
const fire = () => setTimeout(syncToRoomContext, 50);
['pushState', 'replaceState'].forEach(m => {
const orig = history[m];
history[m] = function () {
const r = orig.apply(this, arguments);
fire();
return r;
};
});
window.addEventListener('popstate', fire);
window.addEventListener('hashchange', fire);
// 2. Periodic re-check — catches client-side renders where the URL
// doesn't change but the room DOM mounts/unmounts (e.g. modal-style
// room joins), and is a cheap safety net.
setInterval(syncToRoomContext, 2000);
}
// ═══════════════════════════════════════════════════════════════════
// INIT
// ═══════════════════════════════════════════════════════════════════
function init() {
watchRouteChanges();
// Small delay so the initial page has a chance to render room DOM
setTimeout(syncToRoomContext, 1000);
}
if (document.readyState === 'complete' || document.readyState === 'interactive')
setTimeout(init, 500);
else
window.addEventListener('load', () => setTimeout(init, 500));
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment