AccuRadio Plus

AccuRadio power-user mode: media keys, synced lyrics, ban list, search shortcuts, sleep fade-out, history export, and background anti-timeout.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' }[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 &amp; 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'}.`);
        });
    }
})();