Greasy Fork is available in English.
AccuRadio power-user mode: media keys, synced lyrics, ban list, search shortcuts, sleep fade-out, history export, and background anti-timeout.
// ==UserScript==
// @name AccuRadio Plus
// @namespace http://tampermonkey.net/
// @version 1.7.0
// @description AccuRadio power-user mode: media keys, synced lyrics, ban list, search shortcuts, sleep fade-out, history export, and background anti-timeout.
// @author Dan, Myuui
// @license MIT
// @match *://*.accuradio.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_setClipboard
// @grant GM_xmlhttpRequest
// @grant GM_notification
// @grant GM_openInTab
// @grant unsafeWindow
// @connect lrclib.net
// ==/UserScript==
(function () {
'use strict';
const SCRIPT_VERSION = typeof GM_info !== 'undefined' ? GM_info.script.version : '1.7.0';
console.log(`AccuRadio Plus v${SCRIPT_VERSION} loaded.`);
const SETTINGS_KEY = 'accuradio_plus_settings';
const HISTORY_KEY = 'accuradio_history';
const VOLUME_KEY = 'accuradio_plus_volume';
const BAN_KEY = 'accuradio_plus_banned_artists';
const LYRICS_POS_KEY = 'accuradio_plus_lyrics_pos';
let currentTrack = '';
let cachedAlbumArt = null;
let lastArtTrack = '';
let trackCheckTimeout = null;
let isFetchingLyrics = false;
let syncedLyricsData = [];
let timeUpdateListenerBound = false;
// Sleep Timer & Fadeout State
let sleepDeadline = null;
let isFadingOut = false;
let preFadeVolume = null;
// Lyrics Cache with LRU size limit (100)
const lyricsCache = new Map();
function setLyricsCache(key, val) {
if (lyricsCache.size >= 100) {
lyricsCache.delete(lyricsCache.keys().next().value);
}
lyricsCache.set(key, val);
}
// Settings & State Init
const defaultSettings = {
enableMediaSession: true,
enableNotifications: false,
enableAutoSkipBanned: true,
volumeStep: 5,
historyLimit: 100
};
const settings = Object.assign({}, defaultSettings, GM_getValue(SETTINGS_KEY, {}));
function saveSettings() { GM_setValue(SETTINGS_KEY, settings); }
let rawVol = GM_getValue(VOLUME_KEY, 0.8);
let currentVolume = (typeof rawVol === 'number' && !isNaN(rawVol) && rawVol >= 0 && rawVol <= 1) ? rawVol : 0.8;
let bannedArtists = GM_getValue(BAN_KEY, []);
// ----------------------------------------------------
// UI Styles
// ----------------------------------------------------
const style = document.createElement('style');
style.textContent = `
#accuradio-plus-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 2147483647;
max-width: 360px;
padding: 10px 14px;
border-radius: 10px;
background: rgba(18, 18, 18, 0.94);
color: #fff;
font: 13px/1.35 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
opacity: 0;
pointer-events: none;
transition: opacity 180ms ease;
}
#accuradio-plus-help, #accuradio-lyrics-panel {
position: fixed;
top: 24px;
z-index: 2147483647;
width: 400px;
max-height: calc(100vh - 48px);
overflow: hidden;
display: flex;
flex-direction: column;
border-radius: 12px;
background: rgba(18, 18, 18, 0.96);
backdrop-filter: blur(8px);
color: #fff;
font: 13px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
box-shadow: 0 12px 36px rgba(0, 0, 0, 0.55);
border: 1px solid rgba(255, 255, 255, 0.1);
}
#accuradio-plus-help { right: 24px; overflow-y: auto; padding: 16px; }
#accuradio-lyrics-panel { left: 24px; }
#lyrics-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid rgba(255,255,255,0.1);
cursor: grab;
user-select: none;
}
#lyrics-header:active { cursor: grabbing; }
#lyrics-content {
padding: 16px;
overflow-y: auto;
max-height: 480px;
font-size: 14px;
line-height: 1.8;
scroll-behavior: smooth;
}
.lrc-line {
transition: color 150ms ease, transform 150ms ease;
opacity: 0.5;
margin-bottom: 4px;
}
.lrc-line.active {
opacity: 1;
font-weight: 700;
color: #00e676;
transform: scale(1.02);
transform-origin: left;
}
#accuradio-plus-help h3 { margin: 0 0 10px; font-size: 15px; color: #00e676; }
#accuradio-plus-help table { width: 100%; border-collapse: collapse; margin-bottom: 12px; }
#accuradio-plus-help td { padding: 4px 6px; border-bottom: 1px solid rgba(255, 255, 255, 0.08); vertical-align: middle; }
#accuradio-plus-help kbd { display: inline-block; padding: 2px 6px; border-radius: 6px; background: rgba(255, 255, 255, 0.14); font-family: inherit; font-size: 12px; }
.plus-close-btn { background: rgba(255, 255, 255, 0.14); border: none; color: #fff; padding: 3px 8px; border-radius: 6px; cursor: pointer; font-weight: bold; }
.plus-close-btn:hover { background: rgba(255, 255, 255, 0.28); }
.premium-upsell, .app-download-banner, [class*="upsell"], [class*="newsletter-popup"] { display: none !important; }
`;
(document.head || document.documentElement).appendChild(style);
// ----------------------------------------------------
// Helpers
// ----------------------------------------------------
function escapeHtml(str) {
return String(str || '').replace(/[&<>"']/g, m => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
}
function showToast(message) {
if (!document.body) return;
let toast = document.getElementById('accuradio-plus-toast');
if (!toast) {
toast = document.createElement('div');
toast.id = 'accuradio-plus-toast';
document.body.appendChild(toast);
}
toast.textContent = message;
toast.style.opacity = '1';
clearTimeout(toast._hideTimer);
toast._hideTimer = setTimeout(() => { toast.style.opacity = '0'; }, 2600);
}
function isVisible(element) {
if (!element) return false;
const rect = element.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return false;
const computed = window.getComputedStyle(element);
return computed.display !== 'none' && computed.visibility !== 'hidden';
}
function isPlusUI(element) {
return !!element?.closest?.('#accuradio-plus-help, #accuradio-plus-toast, #accuradio-lyrics-panel');
}
function isTyping(el) {
if (!el) return false;
const tag = (el.tagName || '').toUpperCase();
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
return !!(el.isContentEditable || el.closest('input, textarea, select, [contenteditable="true"]'));
}
function triggerFullClick(element) {
if (!element) return;
const target = element.closest('button, a, [role="button"]') || element;
target.click();
}
function cleanSongName(title) {
return String(title || '')
.replace(/\s\([^)]*(feat|ft|remaster|live|version|edit|mono|stereo|deluxe|mix)[^)]*\)/gi, '')
.replace(/\s\[[^\]]*(feat|ft|remaster|live|version|edit|mono|stereo|deluxe|mix)[^\]]*\]/gi, '')
.replace(/ - .*(remaster|live|edit).*$/gi, '')
.trim();
}
function isValidTrack(text) {
const value = String(text || '').trim();
if (!value || value.length < 2 || /^accuradio$/i.test(value)) return false;
// Real tracks parsed with separators bypass blocklist checking
if (value.includes(' - ')) return true;
const pageWords = /\b(log[- ]?in|login|sign[- ]?in|sign[- ]?up|register|password|reset|account|profile|settings|subscribe|premium|help|faq|privacy|terms|contact|about|advertise|search|home|free internet radio|ad|advertisement|sponsored|commercial|promo)\b/i;
return !pageWords.test(value);
}
function parseTrackInfo(raw) {
const cleaned = String(raw || '').replace(/\s+/g, ' ').trim();
const parts = cleaned.split(' - ').map(p => p.trim()).filter(Boolean);
if (parts.length >= 2) {
return { artist: parts[0], title: parts[1], album: parts.slice(2).join(' - ') || 'AccuRadio' };
}
return { artist: 'AccuRadio', title: cleaned, album: 'AccuRadio' };
}
function getNowPlaying() {
const titleSelectors = '#trackLabel, #playerTitle, #songLabel, #songTitle, #trackTitle, [class*="trackTitle" i], [class*="songTitle" i], [class*="track-title" i], [class*="song-name" i]';
const artistSelectors = '#artistLabel, #playerArtist, #artistName, [class*="artistName" i], [class*="artist-name" i], [class*="artist" i]';
const titleEl = document.querySelector(titleSelectors);
const artistEl = document.querySelector(artistSelectors);
if (titleEl && artistEl) {
const t = (titleEl.textContent || '').trim();
const a = (artistEl.textContent || '').trim();
if (t && a && !/^accuradio$/i.test(t) && !/^accuradio$/i.test(a)) {
return `${a} - ${t}`;
}
}
if (navigator.mediaSession?.metadata?.title && navigator.mediaSession.metadata.title !== 'AccuRadio') {
const meta = navigator.mediaSession.metadata;
return `${meta.artist || 'AccuRadio'} - ${meta.title}`;
}
if (document.title) {
const docT = document.title.replace(/\s*(-|\|)\s*AccuRadio.*$/i, '').replace(/^AccuRadio\s*[-|:]\s*/i, '').trim();
if (docT && !/^accuradio/i.test(docT) && !docT.includes('Free Internet')) {
return docT;
}
}
return currentTrack || '';
}
function findAlbumArt() {
if (cachedAlbumArt && lastArtTrack === currentTrack) return cachedAlbumArt;
const img = document.querySelector('img[src*="album"], img[src*="cover"], img[class*="album" i], img[class*="cover" i], #player img');
if (img?.src && !img.src.endsWith('favicon.ico')) {
cachedAlbumArt = img.src;
lastArtTrack = currentTrack;
return cachedAlbumArt;
}
return 'https://www.accuradio.com/favicon.ico';
}
// Unified player control finder
function findControl(selectors, iconRegex) {
const playerBar = document.querySelector('#player, footer, [class*="player" i], body');
if (!playerBar) return null;
for (const selector of selectors) {
const el = playerBar.querySelector(selector);
if (el && isVisible(el) && !isPlusUI(el)) return el;
}
if (iconRegex) {
const icons = playerBar.querySelectorAll('svg, i, path, span');
for (const icon of icons) {
if (isPlusUI(icon)) continue;
const str = `${icon.getAttribute('class') || ''} ${icon.getAttribute('id') || ''} ${icon.getAttribute('d') || ''} ${icon.getAttribute('data-icon') || ''}`;
if (iconRegex.test(str)) {
const clickable = icon.closest('button, a, [role="button"], div');
if (clickable && isVisible(clickable) && !isPlusUI(clickable)) return clickable;
if (isVisible(icon)) return icon;
}
}
}
return null;
}
// ----------------------------------------------------
// Playback Controls
// ----------------------------------------------------
function clickPlayPause() {
const audiosBefore = Array.from(document.querySelectorAll('audio, video'));
const isPlayingBefore = audiosBefore.some(a => !a.paused && a.currentTime > 0);
const btn = findControl([
'#playerPlayButton', '#playerPauseButton', '#playBtn', '#pauseBtn',
'[data-bind*="play" i]', '[data-bind*="pause" i]', '[aria-label*="play" i]', '[aria-label*="pause" i]'
], /play|pause/i);
if (btn) triggerFullClick(btn);
setTimeout(() => {
const audiosAfter = Array.from(document.querySelectorAll('audio, video'));
const isPlayingAfter = audiosAfter.some(a => !a.paused);
if (audiosAfter.length > 0 && isPlayingBefore === isPlayingAfter) {
audiosAfter.forEach(audio => {
if (isPlayingBefore) {
audio.pause();
} else {
audio.volume = currentVolume;
audio.muted = false;
audio.play().catch(() => {});
}
});
showToast(!isPlayingBefore ? 'Playing' : 'Paused');
} else {
showToast(isPlayingBefore ? 'Paused' : 'Playing');
}
}, 80);
}
function clickPauseOnly() {
const audios = document.querySelectorAll('audio, video');
const wasPlaying = Array.from(audios).some(a => !a.paused);
audios.forEach(m => m.pause());
if (wasPlaying) {
const pauseBtn = findControl(['#playerPauseButton', '[aria-label="pause" i]', '[title="pause" i]', '#pauseBtn'], /pause/i);
if (pauseBtn) triggerFullClick(pauseBtn);
}
}
function clickSkip() {
const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
try {
if (win.accuradio?.player?.skipTrack) { win.accuradio.player.skipTrack(); showToast('Skipping track...'); return true; }
if (win.accuradio?.player?.skip) { win.accuradio.player.skip(); showToast('Skipping track...'); return true; }
if (win.accuPlayer?.skip) { win.accuPlayer.skip(); showToast('Skipping track...'); return true; }
} catch (e) {}
const btn = findControl([
'#playerSkipButton', '#btnSkip', '#skipBtn', '#skipButton',
'[data-bind*="skip" i]', '[aria-label*="skip" i]', '[aria-label*="next" i]', '.player-skip'
], /skip|next|forward/i);
if (btn) {
triggerFullClick(btn);
showToast('Skipping track...');
return true;
}
showToast('Skip button not found.');
return false;
}
function toggleMute() {
const btn = findControl(['#playerMuteButton', '#btnMute', '#muteBtn', '[aria-label*="mute" i]'], /mute|volume-x/i);
if (btn) {
triggerFullClick(btn);
showToast('Mute toggled.');
return;
}
const medias = document.querySelectorAll('audio, video');
if (medias.length) {
const isMuted = !medias[0].muted;
medias.forEach(m => { m.muted = isMuted; });
showToast(isMuted ? 'Muted.' : 'Unmuted.');
}
}
function setGlobalVolume(targetVol) {
if (typeof targetVol !== 'number' || isNaN(targetVol)) targetVol = 0.8;
currentVolume = Math.max(0, Math.min(1, targetVol));
GM_setValue(VOLUME_KEY, currentVolume);
document.querySelectorAll('audio, video').forEach(media => {
media.volume = currentVolume;
if (currentVolume > 0 && media.muted) media.muted = false;
});
// React-compatible input range setter
const slider = document.querySelector('input[type="range"]');
if (slider && !isPlusUI(slider)) {
const min = parseFloat(slider.min || '0');
const max = parseFloat(slider.max || '100');
const val = min + ((max - min) * currentVolume);
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
if (nativeSetter) {
nativeSetter.call(slider, val);
} else {
slider.value = val;
}
slider.dispatchEvent(new Event('input', { bubbles: true }));
slider.dispatchEvent(new Event('change', { bubbles: true }));
}
showToast(`Volume: ${Math.round(currentVolume * 100)}%`);
}
function changeVolume(direction) {
const step = (Number(settings.volumeStep) || 5) / 100;
const activeMedia = document.querySelector('audio');
if (activeMedia && typeof activeMedia.volume === 'number' && !isNaN(activeMedia.volume)) {
currentVolume = activeMedia.volume;
}
setGlobalVolume(currentVolume + (direction * step));
}
// ----------------------------------------------------
// Ban List & Auto-Skip
// ----------------------------------------------------
function banCurrentArtist() {
const track = getNowPlaying();
if (!track || !isValidTrack(track)) {
showToast('No active track to ban.');
return;
}
const { artist } = parseTrackInfo(track);
if (!artist || artist === 'AccuRadio') return;
if (!bannedArtists.includes(artist.toLowerCase())) {
bannedArtists.push(artist.toLowerCase());
GM_setValue(BAN_KEY, bannedArtists);
showToast(`Banned artist: ${artist}. Skipping...`);
clickSkip();
} else {
showToast(`Artist "${artist}" is already banned.`);
}
}
function checkAutoSkipBan(track) {
if (!settings.enableAutoSkipBanned) return;
const { artist } = parseTrackInfo(track);
if (artist && bannedArtists.includes(artist.toLowerCase())) {
showToast(`Auto-skipping banned artist: ${artist}`);
setTimeout(clickSkip, 300);
}
}
// ----------------------------------------------------
// Search Shortcuts
// ----------------------------------------------------
function searchTrack(service) {
const track = getNowPlaying();
if (!track || !isValidTrack(track)) {
showToast('No active track to search.');
return;
}
const { artist, title } = parseTrackInfo(track);
const query = encodeURIComponent(`${cleanSongName(artist)} ${cleanSongName(title)}`);
const url = service === 'spotify'
? `https://open.spotify.com/search/${query}`
: `https://www.youtube.com/results?search_query=${query}`;
if (typeof GM_openInTab === 'function') {
GM_openInTab(url, { active: true });
} else {
window.open(url, '_blank');
}
}
// ----------------------------------------------------
// History & Stats
// ----------------------------------------------------
function getHistory() {
const hist = GM_getValue(HISTORY_KEY, []);
return Array.isArray(hist) ? hist : [];
}
function logHistory(track) {
if (!track || !isValidTrack(track)) return;
let history = getHistory();
if (history[0] && history[0].track === track) return;
history.unshift({ time: new Date().toISOString(), track, url: location.href });
const limit = Number(settings.historyLimit) || 100;
if (history.length > limit) history = history.slice(0, limit);
GM_setValue(HISTORY_KEY, history);
}
function exportHistoryCsv() {
const history = getHistory();
if (!history.length) { showToast('No history to export.'); return; }
const escapeCsv = v => `"${String(v || '').replace(/"/g, '""')}"`;
const csv = ['Timestamp_ISO,Local_Time,Track,URL']
.concat(history.map(item => [item.time, new Date(item.time).toLocaleString(), item.track, item.url].map(escapeCsv).join(',')))
.join('\n');
downloadFile(`accuradio-history-${new Date().toISOString().slice(0, 10)}.csv`, csv, 'text/csv');
showToast('CSV history exported.');
}
function downloadFile(filename, text, mimeType) {
const blob = new Blob([text], { type: mimeType });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// ----------------------------------------------------
// Synced Lyrics Engine (LRC Support & CSP Bypass)
// ----------------------------------------------------
function parseLrc(lrc) {
if (!lrc) return null;
const lines = lrc.split('\n');
const parsed = [];
for (const line of lines) {
const match = line.match(/^\[(\d{2}):(\d{2}(?:\.\d+)?)\](.*)/);
if (match) {
parsed.push({
time: Number(match[1]) * 60 + Number(match[2]),
text: match[3].trim()
});
}
}
return parsed.length ? parsed : null;
}
function syncLyricsScroll() {
const audio = document.querySelector('audio');
const container = document.getElementById('lyrics-content');
if (!audio || !container || !syncedLyricsData.length) return;
const curTime = audio.currentTime;
let activeIdx = -1;
for (let i = 0; i < syncedLyricsData.length; i++) {
if (curTime >= syncedLyricsData[i].time) activeIdx = i;
else break;
}
const lines = container.querySelectorAll('.lrc-line');
lines.forEach((l, idx) => {
if (idx === activeIdx) {
if (!l.classList.contains('active')) {
l.classList.add('active');
l.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
} else {
l.classList.remove('active');
}
});
}
async function fetchAndShowLyrics() {
const rawTrack = getNowPlaying();
if (!rawTrack || !isValidTrack(rawTrack)) {
showToast('No track detected. Start playback first.');
return;
}
const parsed = parseTrackInfo(rawTrack);
const cleanTitle = cleanSongName(parsed.title);
const cleanArtist = cleanSongName(parsed.artist);
const cacheKey = `${cleanArtist.toLowerCase()} - ${cleanTitle.toLowerCase()}`;
if (lyricsCache.has(cacheKey)) {
renderLyrics(lyricsCache.get(cacheKey), { artist: cleanArtist, title: cleanTitle });
return;
}
if (isFetchingLyrics) {
showToast('Lyrics request in progress...');
return;
}
isFetchingLyrics = true;
showToast(`Fetching lyrics for ${cleanTitle}...`);
const query = encodeURIComponent(`${cleanArtist} ${cleanTitle}`);
const url = `https://lrclib.net/api/search?q=${query}`;
GM_xmlhttpRequest({
method: 'GET',
url: url,
headers: { 'Accept': 'application/json' },
onload: (res) => {
isFetchingLyrics = false;
try {
if (res.status === 429) {
showToast('Lyrics rate limit. Try again in a minute.');
return;
}
const results = JSON.parse(res.responseText);
const match = Array.isArray(results) ? results[0] : null;
const lyricsPayload = match ? { synced: match.syncedLyrics, plain: match.plainLyrics } : null;
setLyricsCache(cacheKey, lyricsPayload);
renderLyrics(lyricsPayload, { artist: match?.artistName || cleanArtist, title: match?.trackName || cleanTitle });
} catch (e) {
showToast('Lyrics parsing failed.');
}
},
onerror: () => {
isFetchingLyrics = false;
showToast('Network error fetching lyrics.');
}
});
}
function renderLyrics(payload, trackInfo) {
if (!payload || (!payload.synced && !payload.plain)) {
showToast('No lyrics found for this track.');
return;
}
let panel = document.getElementById('accuradio-lyrics-panel');
if (!panel) {
panel = document.createElement('div');
panel.id = 'accuradio-lyrics-panel';
const savedPos = GM_getValue(LYRICS_POS_KEY, { left: 24, top: 24 });
panel.style.left = `${savedPos.left}px`;
panel.style.top = `${savedPos.top}px`;
document.body.appendChild(panel);
makeDraggable(panel, panel);
}
syncedLyricsData = payload.synced ? (parseLrc(payload.synced) || []) : [];
let bodyHtml = '';
if (syncedLyricsData.length) {
bodyHtml = syncedLyricsData.map(l => `<div class="lrc-line">${escapeHtml(l.text || '♪')}</div>`).join('');
} else {
bodyHtml = `<pre style="white-space:pre-wrap; margin:0; font-family:inherit;">${escapeHtml(payload.plain)}</pre>`;
}
panel.innerHTML = `
<div id="lyrics-header" title="Drag to reposition">
<strong style="font-size:14px; color:#00e676;">${escapeHtml(trackInfo.artist)} - ${escapeHtml(trackInfo.title)}</strong>
<button class="plus-close-btn" id="close-lyrics-btn">✕</button>
</div>
<div id="lyrics-content">${bodyHtml}</div>
`;
panel.querySelector('#close-lyrics-btn').addEventListener('click', () => {
panel.remove();
syncedLyricsData = [];
});
if (!timeUpdateListenerBound) {
document.addEventListener('timeupdate', e => {
if (e.target.tagName === 'AUDIO') syncLyricsScroll();
}, true);
timeUpdateListenerBound = true;
}
}
function makeDraggable(panel, trigger) {
const handle = trigger.querySelector('#lyrics-header') || trigger;
handle.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
e.preventDefault();
const startX = e.clientX - panel.offsetLeft;
const startY = e.clientY - panel.offsetTop;
const onMove = ev => {
const left = Math.max(10, Math.min(window.innerWidth - panel.offsetWidth - 10, ev.clientX - startX));
const top = Math.max(10, Math.min(window.innerHeight - panel.offsetHeight - 10, ev.clientY - startY));
panel.style.left = `${left}px`;
panel.style.top = `${top}px`;
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
GM_setValue(LYRICS_POS_KEY, { left: panel.offsetLeft, top: panel.offsetTop });
};
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ----------------------------------------------------
// Sleep Timer with Fade-Out
// ----------------------------------------------------
function promptSleepTimer() {
const input = window.prompt('Pause AccuRadio after how many minutes? (Enter 0 to cancel)', '15');
if (input === null) return;
const minutes = Number(input);
if (!Number.isFinite(minutes) || minutes <= 0) {
sleepDeadline = null;
isFadingOut = false;
showToast('Sleep timer canceled.');
return;
}
sleepDeadline = Date.now() + (minutes * 60000);
isFadingOut = false;
preFadeVolume = currentVolume;
showToast(`Sleep timer set for ${minutes} min (with fade-out).`);
}
function cancelSleepTimer() {
sleepDeadline = null;
if (isFadingOut && preFadeVolume !== null) setGlobalVolume(preFadeVolume);
isFadingOut = false;
showToast('Sleep timer canceled.');
}
// ----------------------------------------------------
// Track Change Notifications & MediaSession
// ----------------------------------------------------
function updateMediaSession(track) {
if (!('mediaSession' in navigator)) return;
const parsed = parseTrackInfo(track);
const artwork = findAlbumArt();
navigator.mediaSession.metadata = new MediaMetadata({
title: parsed.title,
artist: parsed.artist,
album: parsed.album,
artwork: [{ src: artwork }]
});
navigator.mediaSession.setActionHandler('play', clickPlayPause);
navigator.mediaSession.setActionHandler('pause', clickPauseOnly);
navigator.mediaSession.setActionHandler('nexttrack', clickSkip);
}
function triggerNotification(track) {
if (!settings.enableNotifications || typeof GM_notification !== 'function') return;
const parsed = parseTrackInfo(track);
GM_notification({
title: parsed.title,
text: `AccuRadio: ${parsed.artist}`,
image: findAlbumArt(),
timeout: 4000
});
}
function surpriseMe() {
const selectors = 'a[href*="/channel/"], a[href*="/listen/"], a[href*="/station/"], [class*="ChannelTile"], [class*="station-tile"]';
const elements = Array.from(document.querySelectorAll(selectors));
const channels = Array.from(new Set(elements.map(el => el.tagName === 'A' ? el : el.closest('a') || el)))
.filter(elem => isVisible(elem) && !isPlusUI(elem) && elem.href !== location.href);
if (!channels.length) {
showToast('No alternate channels found on page.');
return;
}
const randomChannel = channels[Math.floor(Math.random() * channels.length)];
showToast(`Surprise Me: Switching stations...`);
triggerFullClick(randomChannel);
}
function toggleHelp() {
const panel = document.getElementById('accuradio-plus-help');
if (panel) { panel.remove(); return; }
const help = document.createElement('div');
help.id = 'accuradio-plus-help';
help.innerHTML = `
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:10px;">
<h3 style="margin:0;">AccuRadio Plus Shortcuts</h3>
<button class="plus-close-btn" id="close-help-btn">✕</button>
</div>
<table>
<tr><td><kbd>Space</kbd></td><td>Play / Pause</td></tr>
<tr><td><kbd>→</kbd></td><td>Skip Track</td></tr>
<tr><td><kbd>↑</kbd> / <kbd>↓</kbd></td><td>Volume Up / Down</td></tr>
<tr><td><kbd>M</kbd></td><td>Mute / Unmute</td></tr>
<tr><td><kbd>B</kbd></td><td>Ban Artist & Skip</td></tr>
<tr><td><kbd>L</kbd></td><td>Toggle Synced Lyrics</td></tr>
<tr><td><kbd>Y</kbd> / <kbd>S</kbd></td><td>Search on YouTube / Spotify</td></tr>
<tr><td><kbd>C</kbd></td><td>Copy Now Playing</td></tr>
<tr><td><kbd>R</kbd></td><td>Surprise Me (Station Jump)</td></tr>
<tr><td><kbd>Shift</kbd> + <kbd>T</kbd></td><td>Sleep Timer (+Fade)</td></tr>
<tr><td><kbd>Shift</kbd> + <kbd>X</kbd></td><td>Cancel Sleep Timer</td></tr>
<tr><td><kbd>H</kbd></td><td>Toggle This Help Panel</td></tr>
</table>
<div style="font-size:11px; color:#aaa; text-align:center;">AccuRadio Plus v${SCRIPT_VERSION}</div>
`;
document.body.appendChild(help);
help.querySelector('#close-help-btn').addEventListener('click', () => help.remove());
}
// ----------------------------------------------------
// Keyboard Event Router
// ----------------------------------------------------
window.addEventListener('keydown', e => {
if (isTyping(e.target)) return;
// Media keys
if (e.code === 'MediaPlayPause' || e.key === 'MediaPlayPause') { e.preventDefault(); clickPlayPause(); return; }
if (e.code === 'MediaTrackNext' || e.key === 'MediaTrackNext') { e.preventDefault(); clickSkip(); return; }
// Sleep Timer Shortcuts
if (e.shiftKey && (e.code === 'KeyT' || e.key === 'T')) { e.preventDefault(); promptSleepTimer(); return; }
if (e.shiftKey && (e.code === 'KeyX' || e.key === 'X')) { e.preventDefault(); cancelSleepTimer(); return; }
if (e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
if (e.code === 'Space' || e.key === ' ') { e.preventDefault(); clickPlayPause(); return; }
if (e.code === 'ArrowRight' || e.key === 'ArrowRight') { e.preventDefault(); clickSkip(); return; }
if (e.code === 'ArrowUp' || e.key === 'ArrowUp') { e.preventDefault(); changeVolume(1); return; }
if (e.code === 'ArrowDown' || e.key === 'ArrowDown') { e.preventDefault(); changeVolume(-1); return; }
const key = (e.key || '').toLowerCase();
if (key === 'm') { e.preventDefault(); toggleMute(); return; }
if (key === 'b') { e.preventDefault(); banCurrentArtist(); return; }
if (key === 'l') { e.preventDefault(); fetchAndShowLyrics(); return; }
if (key === 'y') { e.preventDefault(); searchTrack('youtube'); return; }
if (key === 's') { e.preventDefault(); searchTrack('spotify'); return; }
if (key === 'r') { e.preventDefault(); surpriseMe(); return; }
if (key === 'h') { e.preventDefault(); toggleHelp(); return; }
if (key === 'c') {
e.preventDefault();
const track = getNowPlaying();
if (track && isValidTrack(track)) {
GM_setClipboard(track, 'text');
showToast(`Copied: ${track}`);
}
return;
}
}, true);
// ----------------------------------------------------
// Background Periodic Engine (Anti-Timeout, Sleep Fade, & Banned Auto-Skip)
// ----------------------------------------------------
function queueTrackCheck() {
if (trackCheckTimeout) return;
trackCheckTimeout = setTimeout(() => {
trackCheckTimeout = null;
const candidate = getNowPlaying();
if (!candidate || candidate === currentTrack || !isValidTrack(candidate)) return;
currentTrack = candidate;
cachedAlbumArt = null;
logHistory(currentTrack);
checkAutoSkipBan(currentTrack);
if (settings.enableMediaSession) updateMediaSession(currentTrack);
triggerNotification(currentTrack);
// Auto-refresh lyrics panel if currently open
if (document.getElementById('accuradio-lyrics-panel')) {
fetchAndShowLyrics();
}
}, 350);
}
const titleObserver = new MutationObserver(queueTrackCheck);
const titleEl = document.querySelector('title');
if (titleEl) titleObserver.observe(titleEl, { childList: true, characterData: true, subtree: true });
titleObserver.observe(document.body, { childList: true, subtree: true });
// Main background tick
setInterval(() => {
// 1. Sleep Timer Deadline & Fade Engine
if (sleepDeadline) {
const timeLeft = sleepDeadline - Date.now();
if (timeLeft <= 0) {
sleepDeadline = null;
isFadingOut = false;
clickPauseOnly();
if (preFadeVolume !== null) setGlobalVolume(preFadeVolume);
showToast('Sleep timer: playback paused.');
} else if (timeLeft <= 30000) { // 30 second smooth fade out
if (!isFadingOut) {
isFadingOut = true;
preFadeVolume = currentVolume;
}
const factor = Math.max(0, timeLeft / 30000);
const audios = document.querySelectorAll('audio, video');
audios.forEach(a => { a.volume = preFadeVolume * factor; });
}
}
// 2. Inactivity Anti-Timeout Engine (Runs also when tab is hidden/backgrounded)
const modal = document.querySelector('[role="dialog"], .modal, [class*="modal" i], [class*="dialog" i], [class*="overlay" i], [class*="popup" i]');
const searchRoot = modal || document.body;
const buttons = searchRoot.querySelectorAll('button, a, [role="button"]');
const timeoutRegex = /\b(still listening|continue listening|yes,? i'?m (here|listening))\b/i;
for (const button of buttons) {
if (isPlusUI(button)) continue;
const text = (button.innerText || button.textContent || '').trim();
if (timeoutRegex.test(text) && isVisible(button)) {
console.log('[AccuRadio Plus] Bypassed background timeout prompt.');
triggerFullClick(button);
break;
}
}
}, 5000);
// ----------------------------------------------------
// Menu Commands
// ----------------------------------------------------
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('AccuRadio: Surprise Me (Random Station)', surpriseMe);
GM_registerMenuCommand('AccuRadio: Export History as CSV', exportHistoryCsv);
GM_registerMenuCommand('AccuRadio: Clear History', () => {
GM_setValue(HISTORY_KEY, []);
showToast('History cleared.');
});
GM_registerMenuCommand('AccuRadio: Manage Banned Artists', () => {
const list = bannedArtists.join(', ');
const res = prompt('Banned artists (comma-separated):', list);
if (res !== null) {
bannedArtists = res.split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
GM_setValue(BAN_KEY, bannedArtists);
showToast(`Ban list updated (${bannedArtists.length} artists).`);
}
});
GM_registerMenuCommand('AccuRadio: Toggle Desktop Notifications', () => {
settings.enableNotifications = !settings.enableNotifications;
saveSettings();
showToast(`Track notifications ${settings.enableNotifications ? 'enabled' : 'disabled'}.`);
});
}
})();