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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==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'}.`);
        });
    }
})();