// ==UserScript== // @name Spotify Lyrics+ Stable // @namespace https://github.com/Myst1cX/spotify-web-lyrics-plus // @version 17.26 // @description Display synced and unsynced lyrics from multiple sources (LRCLIB, Spotify, KPoe, Musixmatch, Genius) in a floating popup on Spotify Web. Both formats are downloadable. Optionally toggle a line by line lyrics translation. Lyrics window can be expanded to include playback and seek controls. // @author Myst1cX // @match *://open.spotify.com/* // @grant GM_xmlhttpRequest // @grant GM_registerMenuCommand // @connect genius.com // @require https://cdn.jsdelivr.net/npm/opencc-js@1.0.5/dist/umd/full.js // @homepageURL https://github.com/Myst1cX/spotify-web-lyrics-plus // @supportURL https://github.com/Myst1cX/spotify-web-lyrics-plus/issues // @updateURL https://raw.githubusercontent.com/Myst1cX/spotify-web-lyrics-plus/main/pip-gui-stable.user.js // @downloadURL https://raw.githubusercontent.com/Myst1cX/spotify-web-lyrics-plus/main/pip-gui-stable.user.js // ==/UserScript== // LEFT TO IMPROVE (MINOR INCONVENIENCES): // 1. what i want is that by pressing on 'toggle picture in picture mode' button in lyrics+ popup's header to untoggle, which // 'returns lyric+ popup's lyric container to the base lyrics container without any video canvas element', apart from doing that, this action // at the same time also closes the opened native pip view (the one to which we reflect lyric lines, translation etc) // since user now resumes paying attention to lyric+ popup's lyric container. // additional explanation: 'toggle picture in picture mode' is a button in lyrics+ popup header. // if you click it, the lyrics+ popup's lyrics container transforms into a container that's a video element, which gives it the pip mode button that // opens native pip view. the native pip view also has a play/pause button, fullscreen button, mute button, back to tab button, and most importantly, // a close button. after clicking that close button to close native pip view, the lyrics+ popup's lyric container is still displayed as video element // (that is intended since only the 'toggle picture in picture button' can return it to original lyric container. now to iterate, i want the // 'toggle picture in picture button' when it returns lyric container to original (removing video element), to also close // the native pip view (if its still open) // 2. consider an alternative location for the "Toggle Picture-in-Picture mode" button (probably will remain in the header though) // 3. PiP mode doesn't work on mobile yet (the lyrics+ popup's lyrics container transforms into a container that's a video element, but the pip mode button - that // can then open the native pip view - doesn't appear.) // 4. Lyrics+ popup gui: css fix for the header buttons (inconsistent spacing in some; also needs to be adjusted for mobile interface) // RESOLVED (17.26.beta - merged to stable build of 17.26): CHINESE CONVERSION IS NOW ALSO REFLECTED IN THE PIP CANVAS // Had to also fix an issue which made the lyrics+ popup's lyrics container flash despite being // under the "This video is playing in Picture-in-Picture mode" overlay, upon applying chinese conversion to pip canvas. // rerenderLyrics() (Chinese conversion toggle): when PiP is active, the video element is kept // inside the lyrics container while the HTML lyric children are rebuilt silently behind it. // Old non-video children are removed, new

elements are appended with display:none so they // never become visible, and _pipSavedChildren is updated to point to the new elements. // The canvas render loop reads the new text from the hidden DOM elements and the PiP window // reflects the conversion immediately — with no visual flash in the lyrics container. // RESOLVED (17.25.beta): FIX: PiP now remains open across song transitions by protecting lyricsContainer clears. // RESOLVED (17.24.beta): ADDED PICTURE-IN-PICTURE (PiP) MODE // • Toggle PiP button added to the Lyrics+ popup header button group. // • Canvas+video approach: a hidden renders lyrics; a

lyric elements in every rendering path so // getPipLineGroupText() can look up transliteration/translation sub-lines from the live DOM. // RESOLVED (17.23): CONSISTENT SPOTIFY AND MUSIXMATCH TOKEN LOGGING; DETECT INVALID TOKEN AND CLEAR IT AUTOMATICALLY // RESOLVED (17.22): FIX: CLOSE THE DOWNLOAD DROPDOWN MENU BY CLICKING ON THE DOWNLOAD BUTTON WHILE THE DROPDOWN IS OPENED/CLICKING OUTSIDE THE DROPDOWN MENU. // RESOLVED (17.21): FIX MEMORY LEAKS IN DRAG AND RESIZE WINDOW EVENT LISTENERS // • makeDraggable IIFE: the four window event listeners (mousemove, touchmove, mouseup, touchend) // were registered as anonymous functions with no way to remove them. Every popup open/close cycle // accumulated 4 more permanent window listeners. Fixed by extracting named handler functions, // storing them on the popup element as _dragHandlers, and removing them in removePopup(). // • makeResizable IIFE: the same pattern — four window event listeners (mousemove, touchmove, // mouseup, touchend) leaked on every popup open/close cycle. Fixed by extracting named handler // functions, storing them on the popup element as _resizeHandlers, and removing them in removePopup(). // RESOLVED (17.20): CODE IMPROVEMENTS // • Added a missing flag initialisation: window.lyricsPlusPopupIsResizing = false; // • Removed a comment referencing an old FIX_EXPLANATION.md file that's no longer relevant // • Removed a stale "NEW" feature marker // • Added line breaks: /n - to "Fetching lyrics from" console logs // • Implemented automatic stripping of the Bearer prefix from the Spotify token: the user can now directly paste the raw Authorization header value without needing to delete the word "Bearer" // RESOLVED (17.19): UPDATED CONSOLE LOG MESSAGES TO REFLECT NEW CHANGES // • Providers LRCLIB, KPoe, Musixmatch, Spotify: Log now reads "Starting lyrics search (synced preferred)" - these providers support synced and unsynced lyrics, prefer synced. // • Provider Genius: Log now reads "Starting lyrics search (unsynced only)" - Genius only supports unsynced lyrics. // RESOLVED (17.18): UPDATED CONSOLE LOG MESSAGES TO REFLECT NEW CHANGES // • "Phase 2" console log message removed // • "Manual provider Phase 1" console log message added // • "Autodetect Phase 1" console log message adjusted // RESOLVED (17.17): FIX KPOE NONE TYPE LYRICS - UNSYNCED LYRIC TYPE (PREVIOUSLY TREATED AS SYNCED) // • In some cases, KPoe's Apple source returns lyrics with type: "None" and no timing fields. // parseKPoeFormat defaulted missing timestamps to 0, so every line got time: 0, // causing highlightLyrics to always land on the last line. // • Fix: ProviderKPoe.getSynced now returns null when body.type === "None", // causing the caller to fall back to getUnsynced() for correct static display. // • Fix: ProviderKPoe.findLyrics priority logic updated to Line > Word > None, // so a later attempt returning "Word" or "Line" now replaces a prior "None" result. // RESOLVED (17.16): SINGLE PROVIDER CALL PER AUTODETECT SESSION // • Refactored autodetectProviderAndLoad: each provider (except Genius) is now called only once per track // • Phase 1 fetches both synced and unsynced in a single findLyrics call; unsynced results are stored // in memory (sessionResults) as a fallback if no provider returns synced lyrics // • Phase 2 reuses the stored unsynced result from the highest-priority provider instead of making a // second network request; Genius is still called in phase 2 as a last resort (unchanged behavior) // • Manual provider tab selection: updateLyricsContent already used a single findLyrics call; now also // skips the redundant call when invoked from autodetect by accepting a pre-fetched cachedResult param // • Updated Phase 1 log: "Fetching lyrics from providers (synced preferred). Unsynced lyrics will be // stored for fallback if needed." and Phase 2 log: "No synced lyrics found. Now displaying unsynced // lyrics cached from the highest-priority provider that returned them." // • Errors are logged only once per provider per session; all other logging, caching, instrumental and // race-condition handling preserved // RESOLVED (17.15): // • Fixed KPoe on manual provider selection not checking for unsynced lyrics when synced fails // RESOLVED (17.14): // • Fixed [KPoe Debug] separator length, added lyrics fetching phase logs (synced/unsynced) and improved console logs readability // RESOLVED (17.13): DEBUG LOGGING SYSTEM // • Removed GM_registerMenuCommand('Debug: Enable') and GM_registerMenuCommand('Debug: Disable') // and removed DEBUG.enabled flag; all five wrappers (error, warn, info, log, debug) now fire // unconditionally — no toggle needed // • Only ERROR and WARN retain %c CSS styling with colors: // ERROR → console.error color #F44336 Red font-weight bold // WARN → console.warn color #FF9800 Amber/Orange font-weight bold // • INFO, LOG, DEBUG: drop %c styling entirely — all three route to console.info with the // format: emoji [Lyrics+ context] ...args // CONTEXT_EMOJI lookup maps each context string (Track, Cache, Provider, UI, …) to an emoji // • Semantic intent per level (what each level is meant to log): // LOG → song fetching and caching pipeline events only // (Cache hit/store/clear/load, Autodetect start/abort/success, Provider success, // Track changed — events that directly represent the data-fetch lifecycle) // INFO → application lifecycle events: UI, Playback, Settings // (Popup created/removed, Button injected, Song restarted, OpenCC initialized, // ResourceManager cleanup — high-level state transitions, not raw data flow) // DEBUG → verbose low-level developer details // (DOM queries, timing, state changes, seekbar, cleanup intervals, observer ops) // • Menu commands Get Cache Stats, Get Track Info, Get Repeat State: announcement console.log // color changed from #1db954 (Spotify green) to #64B5F6 (light blue) // RESOLVED (17.12): FIX ReferenceError: savePopupState is not defined // • savePopupState() was defined as a local function inside createPopup(), but // observePopupResize() lives at module scope and cannot access locals of createPopup(). // The mouseupHandler inside observePopupResize() called savePopupState(popup) and threw // "ReferenceError: savePopupState is not defined" whenever the user finished resizing. // • Fix: moved savePopupState() from inside createPopup() to module scope (just above // observePopupResize()). The function only reads window.innerWidth/Height and writes to // localStorage — it has no dependency on createPopup()'s closed-over variables — so the // move is safe. All existing callers inside createPopup() continue to work as before. // RESOLVED (17.11): FIX DEBUG MESSAGE SPAM // • Removed DEBUG calls from getCurrentTrackId() and getCurrentTrackInfo() which were // called on every interval tick (every 100ms by the progress interval and every 400ms // by the polling interval). These were the source of constant console spam when debug // mode was enabled via the menu command. // • Removed: DEBUG.debug('Track', `Track ID extracted: ...`) from getCurrentTrackId() // • Removed: DEBUG.dom.notFound(...) from getCurrentTrackId() - fired on every tick when element absent // • Removed: DEBUG.dom.notFound(...) from getCurrentTrackInfo() - fired on every tick when element absent // • Removed: DEBUG.track.detected(trackInfo) from getCurrentTrackInfo() - fired on every tick // • Track change events are still properly logged via DEBUG.track.changed() in the polling loop // • Removed observeSpotifyPlayPause/Shuffle/Repeat calls from the polling interval // (startPollingForTrackChange). These were called every 400ms, tearing down and // re-creating the three MutationObservers on each tick - causing constant // "[ResourceManager] Cleaned up/Registered observer: Play/pause/Shuffle/Repeat button state" spam. // The observers are already set up once when the popup controls are first created // (setupPlaybackControls), and they self-re-attach via setTimeout when the observed // Spotify button node is replaced - no periodic re-creation is needed. // • Removed DEBUG.debug('Button', 'Lyrics+ button already exists, skipping injection') // from addButton(). This message fired on every DOM mutation (buttonInjectionObserver and // pageObserver both watch document.body/appRoot with subtree:true), making it extremely // chatty during normal Spotify navigation. The early-return itself is kept. // • Added a guard at the top of observePopupResize(): skips re-attaching resize handlers // if popup._resizeMouseupHandler is already set, preventing "[PopupResize] Resize handlers // attached" from being logged on every DOM mutation while the popup is open. // RESOLVED (17.10): IMPROVE KPOE PROVIDER'S "🔄 TRYING BACKUP SERVER X..." LOG POSITION IN CONSOLE // • Removed the "Trying backup server X..." log from every retry site (429, 503, 500, // and catch block). Instead, added a single log at the top of fetchKPoeLyrics that fires // when serverIndex > 0 - right after the ━━━ separator and before "Starting lyrics search". // This means every backup-server attempt now has the separator FIRST, then the "Trying // backup server X..." message, then the standard search header - clear visual grouping. // RESOLVED (17.9): FIX PREVIOUSLY-CACHED SONGS LOADING INSTANTLY AFTER "DEBUG: CLEAR CACHE" // • Added cache: 'no-store' to the LRCLIB fetch() options so that provider // requests always bypass the browser HTTP cache, consistent with Musixmatch which already // used cache: 'no-store'. // • Made cache: 'no-store' the default fetchOptions for KPoe (was only set for forceReload // mode before). The &forceReload=true server-side param is unchanged for force-reload mode. // RESOLVED (17.8): BUG FIXES AND CODE QUALITY IMPROVEMENTS // • Fix: translateLyricsInPopup() now uses 'try' and 'finally' to guarantee isTranslating is reset // and translateBtn is re-enabled even if an unexpected exception occurs during translation // • Fix: Progress bar MutationObserver (attachProgressBarWatcher) is now stored on the popup // element and explicitly disconnected in removePopup(), preventing a memory leak on each // popup open/close cycle // • Fix: LyricsCache.getStats() field renamed from misleading 'maxSize' (entry count safety // limit) to 'maxEntries' to avoid confusion with the byte-based 'maxBytes' field // RESOLVED (17.7): IMPROVED KPOE'S CONSOLE LOGS FOR BETTER VISIBILITY (ADDED SEPARATORS) // • KPoe provider: Added ━━━━ separator lines between each server attempt for clear visual grouping // • KPoe provider: Fixed the 404 response (Track not found on server) to return null immediately instead of trying backup servers // (backup servers use the same upstream data source so trying them after a 404 is pointless) // RESOLVED (17.6): FIX 0-BASED INDEX IN "GET CACHE STATS" CONSOLE TABLE // • Menu command "Debug: Get Cache Stats": Cached songs table now shows indices starting from 1 instead of 0 // RESOLVED (17.5): CONSOLE LOG IMPROVEMENTS // • Kpoe provider: Console logs now also show which Kpoe server was used to fetch the lyrics // • Menu command "Debug: Get Cache Stats": "Get Cache Stats" table now has a server info column which reveals from which provider server a certain cached song was fetched // RESOLVED (17.4): ADDED TWO BACKUP SERVERS TO KPOE PROVIDER CONFIGURATION // RESOLVED (17.3): FIX KPOE PROVIDER'S CACHED LYRICS NOT UPDATING SYNC STATE // • Due to Kpoe's cached lyrics storing 'startTime' in seconds when the sync function expected 'time' in miliseconds) // • Created a normalizeLyricsTimeFormat() helper function: // • converts startTime (seconds) → time (milliseconds) when needed // • applies normalization in two locations: in loadLyricsFromCache() - when loading from cache; and in rerenderLyrics() - when re-rendering cached lyrics // RESOLVED (17.2): GENIUS PROVIDER FIX // • For not transcribed patterns, return error to prevent caching the transcribed pattern as lyrics // • return { error: "No lyrics available from Genius" }; // RESOLVED (17.1): ADDITION OF AMOLED THEME TOGGLE // RESOLVED (17.0): ADJUSTED SPACING BETWEEN HEADER BUTTONS AND BETWEEN LYRIC SOURCE TABS (improves UI in cases of resizing) // • REMOVED "ONMOUSEENTER" GRAY HOVER HIGHLIGHTING OF HEADER BUTTONS (of btnReset, downloadBtn, chineseConvBtn) // RESOLVED (16.9): REMOVED AUDIO ELEMENT FALLBACKS (audio element doesn't exist in Spotify Web Player) // • subsequently removed the getAudioElement command // RESOLVED (16.8): MOVED DEBUG COMMANDS TO MENU COMMANDS // • Debug commands now available only via userscript menu (getTrackInfo, getRepeatState, getAudioElement, getCacheStats, clearCache) // • Removed console-based LyricsPlusDebug API to reduce global scope pollution // • Fixed grammar: "Now 1 song cached" instead of "Now 1 songs cached" // RESOLVED (16.7): IMPROVED LYRICS CACHE WITH BYTE-BASED EVICTION // • Added 6 MB byte limit alongside entry count limit to prevent localStorage overflow // • Increased safety limit to 1000 entries (actual limit 150-400 songs based on size) // • Byte limit (6 MB) is now the primary constraint; entry limit is safety fallback // • Added manual cache clear option in userscript manager menu // • Renamed constant to CACHE_ENTRY_SAFETY_LIMIT for clarity // • Cache now automatically evicts based on both entry count and total size // • Users can cache significantly more songs without storage issues // RESOLVED (16.6): FIXED THE @MATCH PATTERN (VIOLENT MONKEY DID NOT CONSIDER THE USERSCRIPT AS A MATCHED SCRIPT FOR THE SITE // RESOLVED (16.5): SPLIT GENIUS FETCH ERROR MESSAGE INTO TWO (DUE TO CONNECTION ERROR/SERVICE UNAVAILABILITY AND DUE TO LACK OF LYRICS) // RESOLVED (16.4): ABORT LYRICS AUTOFETCH WHEN MANUALLY SELECTING A PROVIDER + SIMPLIFIED ERROR MESSAGES // RESOLVED (16.3): UPDATED HANDLING OF INSTRUMENTAL TRACKS FOR GENIUS PROVIDER // RESOLVED (16.2): FIX LYRIC SOURCE TAB HIGHLIGHTING LOGIC AFTER LYRICS FROM CACHED PROVIDER // RESOLVED (16.1): PREVENT LYRIC SEARCH WHEN ADVERTISEMENT DETECTED // RESOLVED (16.0): LYRICS CACHING FEATURE + REPEAT ONE SUPPORT // • Automatic caching of lyrics (up to 6 MB or 1000 songs, typically 150-400 songs) // • Instant loading from cache (no network delay) for recently played songs // • Repeat One detection: When song restarts, lyrics automatically scroll back to beginning // • Smart LRU (Least Recently Used) eviction based on both byte size and entry count // • User-friendly console logging for all cache operations // • Debug menu commands for cache operations (getCacheStats and clearCache now available via userscript menu from v16.8 onwards) // • Persists across page reloads and browser restarts via localStorage // • Typical storage: 3-6 MB (actual songs cached depends on lyrics size) // RESOLVED (15.9): FIXED REPLAY BUTTON ISSUE AT END OF SONG // • Fixed issue where songs with replay enabled would get stuck at the last second // • Added 200ms buffer when seeking near track end to prevent "ended" state // • Added detailed debug logging to seekTo() function // • Created debug helper for troubleshooting (menu commands available via userscrpt menu from v16.8 onwards) // RESOLVED (15.9): FIXED MOBILE LYRICS MODAL POSITION // RESOLVED (15.8): FIX "QUEUE" AND "CONNECT A DEVICE" PANELS // RESOLVED (15.7): FIX HIDING "NOWPLAYINGVIEW" PANEL // RESOLVED (15.6): POPUP RESTORED STATE FIX // RESOLVED (15.5): YET ANOTHER KPOE PROVIDER FIX (MORE ACCURATE ERROR HANDLING) // RESOLVED (15.4): UI TWEAKS (improved readability) // RESOLVED (15.3): UPDATED TRANSLITERATION FUNCTIONS // RESOLVED (15.2): ADDED TRANSLITERATION BUTTON AND FUNCTIONS // Only shows up on KPoe provider, when the scraped lyrics contain transliteration // RESOLVED (15.1): FIXED KPOE PROVIDER (I HOPE) // NOTE: If a song previously had lyrics but now doesn't fetch them, it's possible that you exceeded the rate limit. // Either try again sometime later or try turning on a VPN and refreshing the page. If it now loads the lyrics, your theory is right. // RESOLVED (15.0): CODE QUALITY & BUG FIX RELEASE // Duplicate IIFE patterns merged into a single scope (fixed the Reference Error in console) // Improved code mantainability and reduced bloat // Added comprehensive DEBUG system with 4 levels (ERROR, WARN, INFO, DEBUG) // Added specialized loggers: provider, dom, track, ui, perf // Performance timing for all provider operations // Memory leak fixes: added a ResourceManager for observer/listener cleanup // Fixed Genius provider failing to match songs with accented characters // • Updated normalize() function to use NFD (Unicode Normalization Form Decomposed) // • Now converts diacritics to base forms: ă→a, é→e, ñ→n, ö→o, etc. // • Should work for Romanian, Spanish, French, German, Portuguese, and all Latin-script languages // Fixed stale provider highlighting when reopening lyrics popup // Fixed thick separator lines (2-5px) caused by collapsed wrapper borders stacking // Fixed Musixmatch/LRCLIB returning "♪ Instrumental ♪" as synced lyrics // Autodetect now tries all providers before giving up // RESOLVED (14.9): FIXED THE ISSUE WHERE ANY ERROR FROM A PROVIDER WOULD SKIP THE REMAINING PROVIDERS AND BREAK THE LYRIC FETCHING LOOP // RESOLVED (14.8): FIXED FALSE POSITIVE CAUSING GENIUS TO NOT LOAD LYRICS // Genius provider was incorrectly flagging legitimate song lyrics as translation pages when artist names contained a "fan" substring // e.g., "Ștefan Costea" matched the translation keyword "fan". // RESOLVED (14.7): IMPROVED GENIUS LYRICS PROVIDER // RESOLVED (14.6): UPDATED THE LOGIC FOR HIDNG THE NOWPLAYING VIEW PANEL // RESOLVED (14.5): FIXED TRANSLATION STATE NOT RELOADING ON LYRICS RESET AND LYRICS DISAPPEARANCE BUG AFTER AN ALREADY SUCCESSFULL FETCH // RESOLVED (14.4): UPDATED THE TUTORIAL INSIDE THE SPOTIFY MODAL // RESOLVED (v14.3): GRAYISH GRADIENT STLYLING NOW ALSO APPLIED TO UNSYNCED LYRICS (more friendly to the eyes) // RESOLVED (v14.2): IMPROVED CHINESE SCRIPT DETECTION - Use OpenCC conversion-based detection instead of regex pattern // The new approach leverages OpenCC's comprehensive 10,000+ character dictionary for accurate script type identification // Replaces manual regex pattern with conversion comparison logic (if T→CN changes text, it's Traditional; if CN→T changes text, it's Simplified) // RESOLVED (v14.1): FIXED CHINESE CONVERSION - use full.js bundle instead of separate t2cn.js/cn2t.js // The separate files were overwriting each other, causing conversion to fail // RESOLVED (v14.0): KPOE PROVIDER AND LRCLIB PROVIDER FIXED (MAJOR DUB) // RESOLVED (v13.6) ADDITION OF TRADITIONAL ⇄ SIMPLIFIED (BIDIRECTIONAL) CHINESE CONVERSION VIA OPEN.CC // Reference: (https://greasyfork.org/en/scripts/555411-spotify-lyrics-trad-simplified/) // RESOLVED (v12): ADDED A GITHUB LINK TO REPOSITORY (credits to greasyfork user jayxdcode) // RESOLVED (v11): ADDITION OF SEEKBAR + COLLAPSING THE LYRIC SOURCE TAB GROUP + SETTINGS UI REVAMP // RESOLVED (v10.9): PLAYBACK BUTTONS' CORRECT REFLECTION OF PAGE ACTION NO LONGER RESTRICTED TO ENGLISH LOCALE: // Shuffle button and repeat button icons now clone directly from Spotify's visible DOM elements // Language-independent detection using computed color (green = active) and SVG path structure // Shuffle button found by SVG icon patterns instead of aria-label text // Static SVGs are kept as fallbacks when DOM elements are not available // WHEN THE TIME IS RIGHT: // Improve google translation, currently only translates line by line (tho it outputs all lines instantly, line by line causes lack of content awareness = lower quality translation) // Lol spotify ad getting detected as track in console. Maybe do something to block them. Also refresh Spotifuck userscript adblock method. // • Object { id: "Spotify-Advertisement", title: "Spotify", artist: "Advertisement", album: "", duration: 26000, uri: "", trackId: null } // CONSIDER CONVERTING TO BROWSER EXTENSION: // Converting the userscript into a browser extension would unlock two things: // 1. Possibilitate having a floating popup ui with spotify lyrics (always on top) that works on other sites too, outside open.spotify.com // 2. Auto fetch spotify token for user when it expires and apply it --> tried, CSP prevents it. (plan was: maybe for Musixmatch too if user logged in inside browser) // PROBABLY NOT: // Add Deezer provider (synced and unsynced) // deezer.js with api link > https://github.com/bertigert/Deezer-Lyrics-Sync/blob/main/lyrics_sync.user.js // Fix and uncomment Netease provider; api implementation example: https://github.com/Natoune/SpotifyMobileLyricsAPI/blob/main/src%2Ffetchers.ts (function () { 'use strict'; // ------------------------ // State Variables // ------------------------ let highlightTimer = null; let pollingInterval = null; let progressInterval = null; // interval for progress bar updates let currentTrackId = null; // Race Condition Prevention (fixes bug where advertisements overwrite song lyrics) let currentSearchId = null; // Tracks the ID of the currently active lyrics search let searchIdCounter = 0; // Monotonically increasing counter for guaranteed unique search IDs let currentSyncedLyrics = null; let currentUnsyncedLyrics = null; let currentLyricsContainer = null; let currentLyricsMetadata = null; // Store metadata (including server info for KPoe) let lastTranslatedLang = null; let translationPresent = false; let isTranslating = false; let transliterationPresent = false; let isShowingSyncedLyrics = false; let originalChineseScriptType = null; // 'traditional', 'simplified', or null let lastPlaybackPosition = 0; // Track playback position for repeat detection let lastTrackDuration = 0; // Track duration for repeat detection // PiP State let pipVideo = null; let pipCanvas = null; let pipCtx = null; let pipAnimationFrame = null; let isPipActive = false; let isPagePipActive = false; let pipResizeObserver = null; let pipResizeRafPending = false; let pipIgnoreMediaControlEvent = false; let pipLastFrameAt = 0; let pipWindowResizeFallbackActive = false; // ------------------------ // Constants & Configuration // ------------------------ const TIMING = { HIGHLIGHT_INTERVAL_MS: 50, // How often to update synced lyrics highlighting POLLING_INTERVAL_MS: 400, // How often to check for track changes OPENCC_RETRY_DELAY_MS: 100, // Initial delay for OpenCC initialization retries BUTTON_ADD_RETRY_MS: 1000, // Delay between button injection attempts DRAG_DEBOUNCE_MS: 1500, // Debounce time after dragging before auto-resize PROGRESS_WATCH_DEBOUNCE_MS: 300, // Debounce for progress bar watcher }; const LIMITS = { OPENCC_MAX_RETRIES: 3, // Max retries for OpenCC initialization BUTTON_ADD_MAX_RETRIES: 10, // Max retries for button injection }; const STORAGE_KEYS = { TRANSLITERATION_ENABLED: 'lyricsPlusTransliterationEnabled', TRANSLATION_LANG: 'lyricsPlusTranslationLang', TRANSLATOR_VISIBLE: 'lyricsPlusTranslatorVisible', FONT_SIZE: 'lyricsPlusFontSize', CHINESE_CONVERSION: 'lyricsPlusChineseConversion', LYRICS_CACHE: 'lyricsPlusCache_v1', }; // ------------------------ // PiP Configuration // ------------------------ const PIP_CANVAS_H_PADDING = 60; const PIP_CANVAS_DEFAULT_SIZE = 640; const PIP_CANVAS_MIN_SIZE = 360; const PIP_CANVAS_MAX_SIZE = 1080; const PIP_FRAME_THROTTLE_MS = 33; const PIP_MEDIA_SYNC_GRACE_MS = 1200; const PIP_SAFARI_SHOW_LETTER_STYLE = 'position:absolute;left:calc(100% - 1px);bottom:calc(100% - 1px)'; // ------------------------ // Lyrics Cache Module // ------------------------ const LyricsCache = { // Safety limit for entry count (actual limit is typically 150-400 songs based on 6 MB size constraint) CACHE_ENTRY_SAFETY_LIMIT: 1000, // Generous safety limit; byte limit is primary constraint MAX_BYTES: 6 * 1024 * 1024, // Maximum cache size in bytes (6 MB) - PRIMARY LIMIT /** * Get all cached lyrics from localStorage * @returns {Object} Cache object with trackId keys */ getAll() { try { const cached = localStorage.getItem(STORAGE_KEYS.LYRICS_CACHE); return cached ? JSON.parse(cached) : {}; } catch (e) { console.warn('[Lyrics+] ⚠️ Could not load cached lyrics from storage:', e); return {}; } }, /** * Save cache to localStorage * @param {Object} cache - Cache object to save */ saveAll(cache) { try { localStorage.setItem(STORAGE_KEYS.LYRICS_CACHE, JSON.stringify(cache)); } catch (e) { console.warn('[Lyrics+] ⚠️ Could not save lyrics to cache:', e); } }, /** * Get cached lyrics for a specific track * @param {string} trackId - Spotify track ID * @returns {Object|null} Cached lyrics data or null if not found */ get(trackId) { if (!trackId) return null; const cache = this.getAll(); const entry = cache[trackId]; if (entry) { console.log(`💾 [Lyrics+] Found cached lyrics! Loading instantly without network request...`); DEBUG.log('Cache', `Found cached lyrics for track: ${trackId}`); // Update timestamp to mark as recently used (LRU) entry.timestamp = Date.now(); this.saveAll(cache); return entry; } console.log(`🔍 [Lyrics+] No cached lyrics found for this song - fetching from providers...`); DEBUG.debug('Cache', `No cached lyrics found for track: ${trackId}`); return null; }, /** * Save lyrics to cache with LRU eviction (count and byte-based) * @param {string} trackId - Spotify track ID * @param {Object} data - Lyrics data to cache */ set(trackId, data) { if (!trackId || !data) return; const cache = this.getAll(); // Add/update entry with timestamp cache[trackId] = { ...data, timestamp: Date.now() }; // Build array of entries with their sizes const entriesWithSize = Object.entries(cache).map(([key, entry]) => { const size = new Blob([JSON.stringify(entry)]).size; return { key, entry, size }; }); // Sort by timestamp (oldest first) entriesWithSize.sort((a, b) => a.entry.timestamp - b.entry.timestamp); // Track total bytes and evict oldest entries if needed let totalBytes = 0; const remainingEntries = []; for (const item of entriesWithSize) { totalBytes += item.size; remainingEntries.push(item); } // Evict oldest entries while exceeding limits let evictedCount = 0; while (remainingEntries.length > this.CACHE_ENTRY_SAFETY_LIMIT || totalBytes > this.MAX_BYTES) { if (remainingEntries.length === 0) break; const evicted = remainingEntries.shift(); totalBytes -= evicted.size; evictedCount++; DEBUG.debug('Cache', `Evicted old entry: ${evicted.key} (size: ${evicted.size} bytes)`); } // Reconstruct cache from remaining entries const newCache = {}; for (const item of remainingEntries) { newCache[item.key] = item.entry; } this.saveAll(newCache); const cacheSize = Object.keys(newCache).length; const totalKB = Math.round(totalBytes / 1024); const maxKB = Math.round(this.MAX_BYTES / 1024); if (evictedCount > 0) { console.log(`💾 [Lyrics+] Removed ${evictedCount} oldest cached song(s) to stay within limits (max ${maxKB} KB)`); } const songWord = cacheSize === 1 ? 'song' : 'songs'; console.log(`✅ [Lyrics+] Lyrics saved to cache! Now have ${cacheSize} ${songWord} (${totalKB} KB of ${maxKB} KB) cached for instant replay`); DEBUG.log('Cache', `Cached lyrics for track: ${trackId}, total size: ${totalKB} KB`); }, /** * Clear all cached lyrics */ clear() { try { localStorage.removeItem(STORAGE_KEYS.LYRICS_CACHE); console.log('🗑️ [Lyrics+] All cached lyrics cleared successfully'); DEBUG.log('Cache', 'Cache cleared'); } catch (e) { console.warn('[Lyrics+] ⚠️ Could not clear cache:', e); } }, /** * Get cache statistics for debugging * @returns {Object} Cache statistics */ getStats() { const cache = this.getAll(); const entries = Object.entries(cache); // Calculate total bytes let totalBytes = 0; const entriesWithDetails = entries.map(([id, data]) => { const size = new Blob([JSON.stringify(data)]).size; totalBytes += size; // Extract server information from metadata let serverInfo = 'N/A'; if (data.metadata?.server) { const serverUrl = data.metadata.server; // Determine server label for KPoe servers if (serverUrl.includes('lyricsplus.prjktla.workers.dev')) { serverInfo = 'Primary'; } else if (serverUrl.includes('lyricsplus-seven.vercel.app')) { serverInfo = 'Backup 1'; } else if (serverUrl.includes('lyrics-plus-backend.vercel.app')) { serverInfo = 'Backup 2'; } else { // For other servers, show abbreviated URL serverInfo = serverUrl.replace(/^https?:\/\//, '').substring(0, 40); } } else if (data.provider) { // For the rest of the providers (LRCLIB, Spotify, Musixmatch, Genius) that only use one server serverInfo = 'Primary'; } return { trackId: id, provider: data.provider, server: serverInfo, hasSynced: !!data.synced, hasUnsynced: !!data.unsynced, timestamp: new Date(data.timestamp).toISOString(), sizeBytes: size }; }); return { size: entries.length, safetyLimit: this.CACHE_ENTRY_SAFETY_LIMIT, maxEntries: this.CACHE_ENTRY_SAFETY_LIMIT, // Entry count safety limit (primary constraint is maxBytes) totalBytes: totalBytes, maxBytes: this.MAX_BYTES, totalKB: Math.round(totalBytes / 1024), maxKB: Math.round(this.MAX_BYTES / 1024), entries: entriesWithDetails }; } }; // Context-to-emoji mapping for DEBUG wrapper labels const CONTEXT_EMOJI = { Track: '🎵', // music note Cache: '💾', // floppy disk Provider: '🔌', // electric plug Autodetect: '🔍', // magnifying glass UI: '💻', // laptop / UI ResourceManager: '🔧', // wrench / resource management OpenCC: '🔤', // input symbol for latin letters Button: '🔘', // radio button DOM: '📄', // page facing up Performance: '⚡', // lightning / speed Cleanup: '🧹', // broom Seekbar: '⏩', // fast-forward PopupResize: '🔄', // arrows / resize Translation: '🌐', // globe with meridians }; // ------------------------ // Debug Logging Infrastructure // ------------------------ const DEBUG = { // Log levels with prefixes error: (context, ...args) => { console.error(`%c[Lyrics+ ERROR] [${context}]`, 'color: #F44336; font-weight: bold;', ...args); }, warn: (context, ...args) => { console.warn(`%c[Lyrics+ WARN] [${context}]`, 'color: #FF9800; font-weight: bold;', ...args); }, info: (context, ...args) => { console.info(`${CONTEXT_EMOJI[context] || '▸'} [Lyrics+ ${context}]`, ...args); }, log: (context, ...args) => { console.info(`${CONTEXT_EMOJI[context] || '▸'} [Lyrics+ ${context}]`, ...args); }, debug: (context, ...args) => { console.info(`${CONTEXT_EMOJI[context] || '▸'} [Lyrics+ ${context}]`, ...args); }, // Specialized logging helpers provider: { start: (providerName, operation, trackInfo) => { const lyricsType = operation === 'getSynced' ? 'synced' : 'unsynced'; DEBUG.debug('Provider', `Checking ${providerName} for ${lyricsType} lyrics:`, { track: trackInfo.title, artist: trackInfo.artist, album: trackInfo.album }); }, success: (providerName, operation, lyricsType, lineCount) => { DEBUG.log('Provider', `✓ ${providerName} ${operation} succeeded:`, { type: lyricsType, lines: lineCount }); }, failure: (providerName, operation, error) => { const lyricsType = operation === 'getSynced' ? 'synced' : 'unsynced'; DEBUG.warn('Provider', `✗ ${providerName} (${lyricsType}) failed:`, error); }, timing: (providerName, operation, durationMs) => { const lyricsType = operation === 'getSynced' ? 'synced' : 'unsynced'; DEBUG.debug('Provider', `⚡ ${providerName} (${lyricsType}) took ${durationMs}ms`); } }, dom: { notFound: (selector, context) => { DEBUG.warn('DOM', `Element not found: ${selector}`, context ? `Context: ${context}` : ''); }, found: (selector, element) => { DEBUG.debug('DOM', `Element found: ${selector}`, element); }, query: (selector, count) => { DEBUG.debug('DOM', `Query "${selector}" returned ${count} elements`); } }, track: { changed: (oldId, newId, trackInfo) => { DEBUG.log('Track', `Track changed: ${oldId || 'none'} → ${newId}`, trackInfo); }, detected: (trackInfo) => { DEBUG.debug('Track', 'Track info detected:', trackInfo); } }, ui: { popupCreated: () => { DEBUG.info('UI', 'Popup created'); }, popupRemoved: () => { DEBUG.info('UI', 'Popup removed'); }, buttonClick: (buttonName) => { DEBUG.debug('UI', `Button clicked: ${buttonName}`); }, stateChange: (stateName, value) => { DEBUG.debug('UI', `State change: ${stateName} = ${value}`); } }, perf: { start: (operation) => { const startTime = performance.now(); return { end: () => { const duration = performance.now() - startTime; DEBUG.debug('Performance', `${operation} took ${duration.toFixed(2)}ms`); return duration; } }; } } }; // Global flags for popup state management (shared with resize observer in setupPopupAutoResize) window.lyricsPlusPopupIgnoreProportion = false; window.lastProportion = { w: null, h: null }; window.lyricsPlusPopupIsDragging = false; window.lyricsPlusPopupIsResizing = false; // ------------------------ // Resource Management & Cleanup System // ------------------------ // Centralized tracking of all observers, listeners, and timers for proper cleanup const ResourceManager = { observers: [], windowListeners: [], // Register a MutationObserver, IntersectionObserver, or ResizeObserver registerObserver(observer, description) { this.observers.push({ observer, description }); DEBUG.debug('ResourceManager', `Registered observer: ${description}`); return observer; }, // Register a window event listener registerWindowListener(eventType, handler, description) { this.windowListeners.push({ eventType, handler, description }); window.addEventListener(eventType, handler); DEBUG.debug('ResourceManager', `Registered window listener: ${eventType} (${description})`); }, // Cleanup all registered resources cleanup() { DEBUG.info('ResourceManager', `Cleaning up ${this.observers.length} observers and ${this.windowListeners.length} window listeners`); // Disconnect all observers this.observers.forEach(({ observer, description }) => { try { observer.disconnect(); DEBUG.debug('ResourceManager', `Disconnected observer: ${description}`); } catch (e) { DEBUG.error('ResourceManager', `Failed to disconnect observer ${description}:`, e); } }); this.observers = []; // Remove all window listeners this.windowListeners.forEach(({ eventType, handler, description }) => { try { window.removeEventListener(eventType, handler); DEBUG.debug('ResourceManager', `Removed window listener: ${eventType} (${description})`); } catch (e) { DEBUG.error('ResourceManager', `Failed to remove listener ${description}:`, e); } }); this.windowListeners = []; }, // Cleanup specific observer cleanupObserver(observer) { const index = this.observers.findIndex(item => item.observer === observer); if (index !== -1) { const { description } = this.observers[index]; try { observer.disconnect(); this.observers.splice(index, 1); DEBUG.debug('ResourceManager', `Cleaned up observer: ${description}`); } catch (e) { DEBUG.error('ResourceManager', `Failed to cleanup observer ${description}:`, e); } } } }; // ------------------------ // Pre-initialized OpenCC converters (created once at startup) // ------------------------ // Using the full.js bundle, we initialize converters at startup to avoid // the issue of individual t2cn.js and cn2t.js files overwriting each other let openccT2CN = null; // Traditional to Simplified Chinese converter let openccCN2T = null; // Simplified Chinese to Traditional converter let openccInitialized = false; // Flag to prevent duplicate initialization attempts // Initialize OpenCC converters with retry mechanism // @require scripts should load before the userscript executes, but we add // a retry mechanism as a safety measure in case of any timing issues function initOpenCCConverters(retries = LIMITS.OPENCC_MAX_RETRIES, delay = TIMING.OPENCC_RETRY_DELAY_MS) { if (openccInitialized) return; // Already initialized, don't retry DEBUG.debug('OpenCC', `Initialization attempt (${LIMITS.OPENCC_MAX_RETRIES - retries + 1}/${LIMITS.OPENCC_MAX_RETRIES})`); try { if (typeof OpenCC !== 'undefined' && OpenCC.Converter) { // The full.js bundle exposes OpenCC.Converter which takes { from, to } options // Supported locales: 'cn' (Simplified), 't' (Traditional Taiwan), 'tw' (Traditional Taiwan with phrases), // 'twp' (Traditional Taiwan with phrases and idioms), 'hk' (Traditional Hong Kong), 'jp' (Japanese Shinjitai) openccT2CN = OpenCC.Converter({ from: 't', to: 'cn' }); openccCN2T = OpenCC.Converter({ from: 'cn', to: 't' }); openccInitialized = true; DEBUG.info('OpenCC', 'Converters initialized successfully (t↔cn)'); } else if (retries > 0) { // OpenCC not available yet, retry after a short delay DEBUG.debug('OpenCC', `Not available yet, retrying in ${delay}ms (${retries} retries left)`); setTimeout(() => initOpenCCConverters(retries - 1, delay * 2), delay); } else { DEBUG.warn('OpenCC', 'Not available after all retries'); } } catch (e) { DEBUG.error('OpenCC', 'Initialization error:', e); } } // Attempt initialization immediately initOpenCCConverters(); /* NowPlayingView logic: Using the original `.zjCIcN96KsMfWwRo` container approach. The `.zjCIcN96KsMfWwRo` is the panel where NPV, Queue, and Connect a device are all displayed after clicking their respective buttons. We apply the hiding style ONLY when .zjCIcN96KsMfWwRo contains NowPlayingView (identified by aria-label="Now playing view" or .NowPlayingView class). This ensures Queue and Connect modals remain unaffected while NowPlayingView is hidden. The container is collapsed to zero width, allowing the rest of the UI to expand and fill the area. NowPlayingView and its DOM structure remain fully accessible to JavaScript for track information and lyrics fetching (ProviderSpotify needs it). */ const styleId = 'lyricsplus-hide-npv-style'; if (!document.getElementById(styleId)) { const style = document.createElement('style'); style.id = styleId; style.textContent = ` .zjCIcN96KsMfWwRo:has([aria-label="Now playing view"]), .zjCIcN96KsMfWwRo:has(.NowPlayingView) { min-width: 0 !important; max-width: 0 !important; flex-basis: 0 !important; overflow: hidden !important; } .wJiY1vDfuci2a4db { /* The "Show Now Playing view" button */ display: none !important; } `; document.head.appendChild(style); } // ------------------------ // Utils.js Functions // ------------------------ // --- Translation Language List and Utilities --- const TRANSLATION_LANGUAGES = { en: 'English', es: 'Spanish', fr: 'French', de: 'German', it: 'Italian', pt: 'Portuguese', ru: 'Russian', ja: 'Japanese', ko: 'Korean', zh: 'Chinese', ar: 'Arabic', hi: 'Hindi', tr: 'Turkish', af: 'Afrikaans', sq: 'Albanian', am: 'Amharic', hy: 'Armenian', az: 'Azerbaijani', eu: 'Basque', be: 'Belarusian', bn: 'Bengali', bs: 'Bosnian', bg: 'Bulgarian', ca: 'Catalan', ceb: 'Cebuano', co: 'Corsican', hr: 'Croatian', cs: 'Czech', da: 'Danish', nl: 'Dutch', eo: 'Esperanto', et: 'Estonian', fi: 'Finnish', fy: 'Frisian', gl: 'Galician', ka: 'Georgian', el: 'Greek', gu: 'Gujarati', ht: 'Haitian Creole', ha: 'Hausa', haw: 'Hawaiian', he: 'Hebrew', hmn: 'Hmong', hu: 'Hungarian', is: 'Icelandic', ig: 'Igbo', id: 'Indonesian', ga: 'Irish', jv: 'Javanese', kn: 'Kannada', kk: 'Kazakh', km: 'Khmer', rw: 'Kinyarwanda', ku: 'Kurdish', ky: 'Kyrgyz', lo: 'Lao', la: 'Latin', lv: 'Latvian', lt: 'Lithuanian', lb: 'Luxembourgish', mk: 'Macedonian', mg: 'Malagasy', ms: 'Malay', ml: 'Malayalam', mt: 'Maltese', mi: 'Maori', mr: 'Marathi', mn: 'Mongolian', my: 'Myanmar (Burmese)', ne: 'Nepali', no: 'Norwegian', ny: 'Nyanja (Chichewa)', or: 'Odia (Oriya)', ps: 'Pashto', fa: 'Persian', pl: 'Polish', pa: 'Punjabi', ro: 'Romanian', sm: 'Samoan', gd: 'Scots Gaelic', sr: 'Serbian', st: 'Sesotho', sn: 'Shona', sd: 'Sindhi', si: 'Sinhala', sk: 'Slovak', sl: 'Slovenian', so: 'Somali', su: 'Sundanese', sw: 'Swahili', sv: 'Swedish', tl: 'Tagalog (Filipino)', tg: 'Tajik', ta: 'Tamil', tt: 'Tatar', te: 'Telugu', th: 'Thai', tk: 'Turkmen', uk: 'Ukrainian', ur: 'Urdu', ug: 'Uyghur', uz: 'Uzbek', vi: 'Vietnamese', cy: 'Welsh', xh: 'Xhosa', yi: 'Yiddish', yo: 'Yoruba', zu: 'Zulu' }; function getSavedTranslationLang() { return localStorage.getItem('lyricsPlusTranslationLang') || 'en'; } function saveTranslationLang(lang) { localStorage.setItem('lyricsPlusTranslationLang', lang); } // --- Chinese Conversion Settings (Traditional to Simplified) --- function isChineseConversionEnabled() { return localStorage.getItem('lyricsPlusChineseConversion') === 'true'; } function setChineseConversionEnabled(enabled) { localStorage.setItem('lyricsPlusChineseConversion', enabled ? 'true' : 'false'); } async function translateText(text, targetLang) { const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(text)}`; try { const response = await fetch(url); const data = await response.json(); return data[0][0][0]; } catch (error) { DEBUG.error('Translation', 'Failed to translate text:', error); return '[Translation Error]'; } } const Utils = { normalize(str) { if (!str) return ""; // Remove full-width/half-width, accents, etc. return str.normalize("NFKC") .replace(/[’‘“”–]/g, "'") .replace(/[\u2018-\u201F]/g, "'") .replace(/[\u3000-\u303F]/g, "") .replace(/[^\w\s\-\.&!']/g, '') .replace(/\s{2,}/g, ' ') .trim(); }, removeExtraInfo(str) { return str.replace(/\(.*?\)|\[.*?]|\{.*?}/g, '').trim(); }, removeSongFeat(str) { // Remove "feat. ...", "ft. ...", etc. return str.replace(/\s*(?:feat\.?|ft\.?|featuring)\s+[^\-]+/i, '').trim(); }, containsHanCharacter(str) { return /[\u4e00-\u9fa5]/.test(str); }, // Detect the Chinese script type using OpenCC converters // Uses conversion behavior to determine script type - more reliable than character lists // Returns 'traditional', 'simplified', or null if no Chinese detectChineseScriptType(str) { if (!str || !this.containsHanCharacter(str)) return null; // Use OpenCC converters to detect script type via conversion comparison // If T→CN conversion changes the text, it's Traditional Chinese // If CN→T conversion changes the text, it's Simplified Chinese // This approach leverages OpenCC's comprehensive character mappings try { if (!openccT2CN || !openccCN2T) { // Fallback if converters aren't initialized DEBUG.warn('OpenCC', 'Converters not initialized for script detection'); return 'simplified'; // Default assumption } // Use full text for accurate detection (no sampling) // This ensures all characters are checked for proper script type identification const asSimplified = openccT2CN(str); const asTraditional = openccCN2T(str); const changedToSimplified = asSimplified !== str; const changedToTraditional = asTraditional !== str; // If converting T→CN changes text but CN→T doesn't, it's Traditional if (changedToSimplified && !changedToTraditional) { return 'traditional'; } // If converting CN→T changes text but T→CN doesn't, it's Simplified else if (changedToTraditional && !changedToSimplified) { return 'simplified'; } // If both change it, use length comparison (Traditional usually has fewer chars after T→CN) else if (changedToSimplified && changedToTraditional) { return asSimplified.length < str.length ? 'traditional' : 'simplified'; } // If neither changes, characters are common to both - assume simplified else { return 'simplified'; } } catch (e) { DEBUG.warn('OpenCC', 'Script type detection error:', e); return 'simplified'; // Default assumption on error } }, capitalize(str, lower = false) { if (!str) return ''; return (lower ? str.toLowerCase() : str).replace(/(?:^|\s|["'([{])+\S/g, match => match.toUpperCase()); }, // Convert Traditional Chinese to Simplified Chinese using opencc-js // Uses pre-initialized converter from the full.js bundle toSimplifiedChinese(str) { if (!str) return str; try { // Use pre-initialized converter (created at startup from full.js bundle) if (openccT2CN) { return openccT2CN(str); } // Fallback: try to create converter on-the-fly if not initialized // Only attempt if not already initialized (prevents race conditions) if (!openccInitialized && typeof OpenCC !== 'undefined' && OpenCC.Converter) { const converter = OpenCC.Converter({ from: 't', to: 'cn' }); openccT2CN = converter; // Cache for future use return converter(str); } // Converter not available, return original DEBUG.warn('OpenCC', 'T→CN converter not available'); return str; } catch (e) { DEBUG.error('OpenCC', 'Traditional to Simplified conversion error:', e); return str; } }, // Convert Simplified Chinese to Traditional Chinese using opencc-js // Uses pre-initialized converter from the full.js bundle toTraditionalChinese(str) { if (!str) return str; try { // Use pre-initialized converter (created at startup from full.js bundle) if (openccCN2T) { return openccCN2T(str); } // Fallback: try to create converter on-the-fly if not initialized // Only attempt if not already initialized (prevents race conditions) if (!openccInitialized && typeof OpenCC !== 'undefined' && OpenCC.Converter) { const converter = OpenCC.Converter({ from: 'cn', to: 't' }); openccCN2T = converter; // Cache for future use return converter(str); } // Converter not available, return original DEBUG.warn('OpenCC', 'CN→T converter not available'); return str; } catch (e) { DEBUG.error('OpenCC', 'Simplified to Traditional conversion error:', e); return str; } }, parseLocalLyrics(plain) { if (!plain) return { unsynced: null, synced: null }; const timeTagRegex = /\[(\d{1,2}):(\d{1,2})(?:\.(\d{1,3}))?\]/g; const synced = []; const unsynced = []; const lines = plain.split(/\r?\n/); for (const line of lines) { let matched = false; let lastIndex = 0; let text = line; const times = []; let m; while ((m = timeTagRegex.exec(line)) !== null) { matched = true; const min = parseInt(m[1], 10); const sec = parseInt(m[2], 10); const ms = m[3] ? parseInt(m[3].padEnd(3, '0'), 10) : 0; const time = min * 60000 + sec * 1000 + ms; times.push(time); lastIndex = m.index + m[0].length; } if (matched) { text = line.substring(lastIndex).trim(); times.forEach(time => { synced.push({ time, text }); }); } else { if (line.trim().length > 0) { unsynced.push({ text: line.trim() }); } } } synced.sort((a, b) => a.time - b.time); return { synced: synced.length > 0 ? synced : null, unsynced: unsynced.length > 0 ? unsynced : null }; } }; function clamp(val, min, max) { return Math.max(min, Math.min(max, val)); } function makeSafeFilename(str) { // Remove illegal Windows filename characters, collapse spaces return str.replace(/[\/\\:\*\?"<>\|]/g, '').replace(/\s+/g, ' ').trim(); } // --- Download Synced Lyrics as LRC --- function downloadSyncedLyrics(syncedLyrics, trackInfo, providerName) { if (!syncedLyrics || !syncedLyrics.length) return; let lines = syncedLyrics.map(line => { let ms = Number(line.time) || 0; let min = String(Math.floor(ms / 60000)).padStart(2, '0'); let sec = String(Math.floor((ms % 60000) / 1000)).padStart(2, '0'); let hundredths = String(Math.floor((ms % 1000) / 10)).padStart(2, '0'); return `[${min}:${sec}.${hundredths}] ${line.text}`; }).join('\n'); let title = makeSafeFilename(trackInfo?.title || "lyrics"); let artist = makeSafeFilename(trackInfo?.artist || "unknown"); let filename = `${artist} - ${title}.lrc`; // Try application/octet-stream for better compatibility (helps detect as .lrc in mobile browser) let blob = new Blob([lines], { type: "application/octet-stream" }); let a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); } // --- Download Unsynced Lyrics as TXT --- function downloadUnsyncedLyrics(unsyncedLyrics, trackInfo, providerName) { if (!unsyncedLyrics || !unsyncedLyrics.length) return; let lines = unsyncedLyrics.map(line => line.text).join('\n'); let title = makeSafeFilename(trackInfo?.title || "lyrics"); let artist = makeSafeFilename(trackInfo?.artist || "unknown"); let filename = `${artist} - ${title}.txt`; let blob = new Blob([lines], { type: "text/plain" }); let a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); } const style = document.createElement('style'); style.textContent = ` .hide-scrollbar::-webkit-scrollbar { display: none; } .hide-scrollbar { scrollbar-width: none !important; ms-overflow-style: none !important; } `; document.head.appendChild(style); // ------------------------ // Utility Functions // ------------------------ function getCurrentTrackId() { const contextLink = document.querySelector('a[data-testid="context-link"][data-context-item-type="track"][href*="uri=spotify%3Atrack%3A"]'); if (contextLink) { const href = contextLink.getAttribute('href'); const match = decodeURIComponent(href).match(/spotify:track:([a-zA-Z0-9]{22})/); if (match) { return match[1]; } } return null; } function getCurrentTrackInfo() { const titleEl = document.querySelector('[data-testid="context-item-info-title"]'); const artistEl = document.querySelector('[data-testid="context-item-info-subtitles"]'); const durationEl = document.querySelector('[data-testid="playback-duration"]'); const positionEl = document.querySelector('[data-testid="playback-position"]'); const trackId = getCurrentTrackId(); if (!titleEl || !artistEl) { return null; } const title = titleEl.textContent.trim(); const artist = artistEl.textContent.trim(); // Calculate duration properly - playback-duration may show remaining time (prefixed with '-') let duration = 0; if (durationEl) { const raw = durationEl.textContent.trim(); if (raw.startsWith('-')) { // Remaining time format: add current position + remaining to get total duration const remainMs = timeStringToMs(raw); const posMs = positionEl ? timeStringToMs(positionEl.textContent) : 0; duration = posMs + remainMs; } else { // Direct duration format duration = timeStringToMs(raw); } } const trackInfo = { id: `${title}-${artist}`, title, artist, album: "", duration, uri: "", trackId }; return trackInfo; } function timeStringToMs(str) { if (!str) return 0; // Remove leading minus for "-2:04" cases (Spotify shows remaining as -mm:ss) const cleaned = str.replace(/^-/, '').trim(); const parts = cleaned.split(":").map((p) => parseInt(p)); if (parts.length === 2) return (parts[0] * 60 + parts[1]) * 1000; if (parts.length === 3) return (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 1000; return 0; } /** * Detects if a track is a Spotify advertisement. * Advertisements typically have "Advertisement" in the artist field. * Examples: "Advertisement • 1 of 1", "Advertisement", etc. * * @param {Object} trackInfo - Track information object with artist field * @returns {boolean} - True if track is an advertisement */ function isAdvertisement(trackInfo) { if (!trackInfo || !trackInfo.artist) return false; // Check if artist contains "Advertisement" (case-insensitive) const artist = trackInfo.artist.toLowerCase(); return artist.includes('advertisement'); } function timeoutPromise(ms) { return new Promise((_, reject) => setTimeout(() => reject(new Error("Lyrics not found")), ms)); } function getAnticipationOffset() { return Number(localStorage.getItem("lyricsPlusAnticipationOffset") || 1000); } function setAnticipationOffset(val) { localStorage.setItem("lyricsPlusAnticipationOffset", val); } function isSpotifyPlaying() { // Try using Spotify's play/pause button aria-label (robust, language-universal) let playPauseBtn = document.querySelector('[data-testid="control-button-playpause"]') || document.querySelector('[aria-label]'); function isVisible(el) { if (!el) return false; const style = window.getComputedStyle(el); return el.offsetParent !== null && style.display !== "none" && style.visibility !== "hidden" && style.opacity !== "0"; } if (playPauseBtn && isVisible(playPauseBtn)) { const label = (playPauseBtn.getAttribute('aria-label') || '').toLowerCase(); if (labelMeansPause(label)) return true; // "Pause" means music is playing if (labelMeansPlay(label)) return false; // "Play" means music is paused/stopped } // Default: assume not playing return false; } // ============================================= // Picture-in-Picture (PiP) // ============================================= function isSafariBrowser() { const ua = navigator.userAgent || ''; return /Safari/i.test(ua) && !/Chrome|Chromium|CriOS|Edg|OPR|Firefox/i.test(ua); } function applyHiddenPipVideoStyle() { if (!pipVideo) return; Object.assign(pipVideo.style, { position: 'fixed', left: '-9999px', top: '-9999px', width: '1px', height: '1px', opacity: '0', pointerEvents: 'none', }); } function findSpotifyVolumeControl() { return document.querySelector('[data-testid="volume-bar"]') || document.querySelector('[data-testid="volume-bar"] input[type="range"]') || document.querySelector('input[aria-label*="Volume"]'); } function setSpotifyVolumeLevel(level) { const volumeControl = findSpotifyVolumeControl(); if (!volumeControl) return false; let input = null; if (volumeControl instanceof HTMLInputElement && volumeControl.type === 'range') { input = volumeControl; } else { input = volumeControl.querySelector('input[type="range"]'); } if (!(input instanceof HTMLInputElement)) return false; const min = Number(input.min || 0); const max = Number(input.max || 1); const clamped = Math.min(1, Math.max(0, level)); const rawValue = min + ((max - min) * clamped); input.value = String(rawValue); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); return true; } function getSpotifyVolumeLevel() { const volumeControl = findSpotifyVolumeControl(); let input = null; if (volumeControl instanceof HTMLInputElement && volumeControl.type === 'range') { input = volumeControl; } else if (volumeControl) { input = volumeControl.querySelector('input[type="range"]'); } if (!(input instanceof HTMLInputElement)) return null; const min = Number(input.min || 0); const max = Number(input.max || 1); const current = Number(input.value || 0); if (!Number.isFinite(max - min) || max === min) return current > 0 ? 1 : 0; if (max < min) return null; return (current - min) / (max - min); } function syncPipMediaStateFromSpotify() { if (!pipVideo) return; const spotifyPlaying = isSpotifyPlaying(); const spotifyVolume = getSpotifyVolumeLevel(); pipIgnoreMediaControlEvent = true; try { if (spotifyPlaying && pipVideo.paused) { pipVideo.play().catch(() => {}); } else if (!spotifyPlaying && !pipVideo.paused) { pipVideo.pause(); } if (spotifyVolume !== null) { pipVideo.volume = Math.max(0, Math.min(1, spotifyVolume)); pipVideo.muted = spotifyVolume <= 0.001; } } finally { queueMicrotask(() => { pipIgnoreMediaControlEvent = false; }); } } function handlePipVideoPlay() { if (pipIgnoreMediaControlEvent) return; if (isSpotifyPlaying()) return; const btn = findSpotifyPlayPauseButton(); if (!btn) return; pipIgnoreMediaControlEvent = true; btn.click(); setTimeout(() => { pipIgnoreMediaControlEvent = false; syncPipMediaStateFromSpotify(); }, PIP_MEDIA_SYNC_GRACE_MS); } function handlePipVideoPause() { if (pipIgnoreMediaControlEvent) return; if (!isSpotifyPlaying()) return; const btn = findSpotifyPlayPauseButton(); if (!btn) return; pipIgnoreMediaControlEvent = true; btn.click(); setTimeout(() => { pipIgnoreMediaControlEvent = false; syncPipMediaStateFromSpotify(); }, PIP_MEDIA_SYNC_GRACE_MS); } function handlePipVideoVolumeChange() { if (pipIgnoreMediaControlEvent) return; if (pipVideo.muted || pipVideo.volume <= 0.001) { setSpotifyVolumeLevel(0); } else { setSpotifyVolumeLevel(pipVideo.volume); } } function updatePipCanvasSize() { if (!pipCanvas || !pipVideo) return; const rect = pipVideo.getBoundingClientRect(); const side = Math.max( PIP_CANVAS_MIN_SIZE, Math.min(PIP_CANVAS_MAX_SIZE, Math.round(Math.max(rect.width || 0, rect.height || 0, PIP_CANVAS_DEFAULT_SIZE))) ); if (pipCanvas.width !== side || pipCanvas.height !== side) { pipCanvas.width = side; pipCanvas.height = side; pipVideo.width = side; pipVideo.height = side; } } function setupPipResizeTracking() { if (!pipVideo || pipResizeObserver) return; if (typeof ResizeObserver === 'function') { pipResizeObserver = new ResizeObserver(() => { if (pipResizeRafPending) return; pipResizeRafPending = true; requestAnimationFrame(() => { pipResizeRafPending = false; updatePipCanvasSize(); }); }); pipResizeObserver.observe(pipVideo); } else if (!pipWindowResizeFallbackActive) { window.addEventListener('resize', updatePipCanvasSize, { passive: true }); pipWindowResizeFallbackActive = true; } } function cleanupPipResizeTracking() { if (pipResizeObserver) { try { pipResizeObserver.disconnect(); } catch {} pipResizeObserver = null; } if (pipWindowResizeFallbackActive) { window.removeEventListener('resize', updatePipCanvasSize); pipWindowResizeFallbackActive = false; } pipResizeRafPending = false; } /** * Gets the displayed text and sub-lines (transliteration / translation) for a given * lyric line index. Reads from the live DOM so Chinese conversion and other visual * changes are always reflected in the PiP canvas. */ function getPipLineGroupText(lineIndex) { if (!currentLyricsContainer) return []; const base = currentLyricsContainer.querySelector(`p[data-lyrics-line-index="${lineIndex}"]`); if (!(base instanceof HTMLElement)) return []; const lines = []; const baseText = (base.textContent || '').trim(); if (baseText) lines.push(baseText); let next = base.nextElementSibling; while (next && !(next.tagName.toUpperCase() === 'P' && next.hasAttribute('data-lyrics-line-index'))) { const isTransliteration = next.getAttribute('data-transliteration') === 'true'; const isTranslation = next.getAttribute('data-translated') === 'true'; if (isTransliteration || isTranslation) { const text = (next.textContent || '').trim(); if (text) lines.push(isTranslation ? `~TL~${text}` : `~TR~${text}`); } next = next.nextElementSibling; } return lines; } function splitPipTextToLines(ctx, text, maxWidth) { const cleaned = (text || '').trim(); if (!cleaned) return []; const words = cleaned.split(/\s+/); const out = []; let line = ''; for (let i = 0; i < words.length; i++) { const candidate = line ? `${line} ${words[i]}` : words[i]; if (ctx.measureText(candidate).width <= maxWidth) { line = candidate; } else if (line) { out.push(line); line = words[i]; } else { out.push(words[i]); } } if (line) out.push(line); return out; } function flattenPipBlockRows(ctx, texts, maxWidth, primaryFont, primaryLineHeight, secondaryFont, secondaryLineHeight, color, blockKind) { const rows = []; texts.forEach((text, index) => { const isTranslation = typeof text === 'string' && text.startsWith('~TL~'); const isTransliteration = typeof text === 'string' && text.startsWith('~TR~'); const cleanText = isTranslation || isTransliteration ? text.slice(4) : text; const rowFont = index === 0 ? primaryFont : secondaryFont; const rowLineHeight = index === 0 ? primaryLineHeight : secondaryLineHeight; ctx.font = rowFont; splitPipTextToLines(ctx, cleanText, maxWidth).forEach(line => { let resolvedColor = color; if (isTranslation) { resolvedColor = 'rgba(160, 160, 160, 0.9)'; } else if (isTransliteration && blockKind === 'active') { resolvedColor = '#1db954'; } else if (isTransliteration) { resolvedColor = '#9a9a9a'; } rows.push({ text: line, font: rowFont, lineHeight: rowLineHeight, color: resolvedColor }); }); }); return rows; } /** * Inserts pipVideo into the lyrics container and hides its HTML children. * The native browser renders "playing in picture-in-picture" on the video element * in the main page; the PiP window shows the canvas-rendered lyrics. */ function enterPipInLyricsContainer() { const lyricsContainer = document.getElementById('lyrics-plus-content'); if (!lyricsContainer || !pipVideo) return; // Save and hide existing children so they are not visible behind the video const savedChildren = Array.from(lyricsContainer.children).map(el => ({ el, display: el.style.display, })); lyricsContainer._pipSavedChildren = savedChildren; savedChildren.forEach(({ el }) => { el.style.display = 'none'; }); // Make container a positioning context and fill it with the video lyricsContainer.style.position = 'relative'; Object.assign(pipVideo.style, { position: 'absolute', top: '0', left: '0', width: '100%', height: '100%', opacity: '1', pointerEvents: 'auto', zIndex: '1', backgroundColor: 'transparent', }); lyricsContainer.insertBefore(pipVideo, lyricsContainer.firstChild); } /** * Removes pipVideo from the lyrics container and restores the HTML lyric children. * Falls back gracefully if the container is already gone (popup was closed). */ function exitPipFromLyricsContainer() { const lyricsContainer = document.getElementById('lyrics-plus-content'); if (lyricsContainer && pipVideo && pipVideo.parentElement === lyricsContainer) { lyricsContainer.removeChild(pipVideo); lyricsContainer.style.position = ''; if (lyricsContainer._pipSavedChildren) { lyricsContainer._pipSavedChildren.forEach(({ el, display }) => { el.style.display = display; }); delete lyricsContainer._pipSavedChildren; } } else if (pipVideo && pipVideo.parentElement) { pipVideo.parentElement.removeChild(pipVideo); } applyHiddenPipVideoStyle(); if (document.body && pipVideo && !pipVideo.parentNode) document.body.appendChild(pipVideo); } /** * Safely detaches pipVideo from lyricsContainer before any innerHTML/textContent wipe, * keeping it in document.body so native PiP stays open during the mutation. * Call this immediately before clearing lyricsContainer when PiP is active. * Re-insert with enterPipInLyricsContainer() after the container is rebuilt. */ function pipVideoDetachIfInContainer() { if (!(isPipActive || isPagePipActive) || !pipVideo) return; const lyricsContainer = document.getElementById('lyrics-plus-content'); if (lyricsContainer && pipVideo.parentElement === lyricsContainer) { lyricsContainer.removeChild(pipVideo); delete lyricsContainer._pipSavedChildren; if (!pipVideo.parentNode) document.body.appendChild(pipVideo); } } /** * Creates the hidden and

Set your Musixmatch User Token
How to retrieve your token:
1. Go to Musixmatch and click on Login.
2. Select [Community] as your product.
3. Open DevTools (Press F12 or Right click and Inspect).
4. Go to the Network tab > Click on the www.musixmatch.com domain > Cookies.
5. Right-click on the content of the musixmatchUserToken and select Copy value.
6. Go to JSON Formatter > Paste the content > Click Process.
7. Copy the value of web-desktop-app-v1.0 > Paste the token below and press Save.
WARNING: Keep your token private! Do not share it with others.
`; const input = document.createElement("input"); input.type = "text"; input.placeholder = "Enter your Musixmatch user token here"; input.value = localStorage.getItem("lyricsPlusMusixmatchToken") || ""; box.appendChild(input); // Footer with Save & Cancel const footer = document.createElement("div"); footer.className = "modal-footer"; const btnSave = document.createElement("button"); btnSave.textContent = "Save"; btnSave.className = "lyrics-btn"; btnSave.onclick = () => { localStorage.setItem("lyricsPlusMusixmatchToken", input.value.trim()); modal.remove(); // Optionally: reload lyrics if popup open and provider is Musixmatch const popup = document.getElementById("lyrics-plus-popup"); if (popup && Providers.current === "Musixmatch") { const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) lyricsContainer.textContent = "Loading lyrics..."; updateLyricsContent(popup, getCurrentTrackInfo()); } }; const btnCancel = document.createElement("button"); btnCancel.textContent = "Cancel"; btnCancel.className = "lyrics-btn"; btnCancel.onclick = () => modal.remove(); footer.appendChild(btnSave); footer.appendChild(btnCancel); box.appendChild(footer); // Close (X) button box.querySelector('#lyrics-plus-musixmatch-modal-close').onclick = () => modal.remove(); modal.appendChild(box); document.body.appendChild(modal); // Focus input for fast paste input.focus(); } function parseMusixmatchSyncedLyrics(subtitleBody) { // Split into lines const lines = subtitleBody.split(/\r?\n/); const synced = []; // Regex for [mm:ss.xx] or [mm:ss,xx] const timeRegex = /\[(\d{1,2}):(\d{2})([.,]\d{1,3})?\]/; for (const line of lines) { const match = line.match(timeRegex); if (match) { const min = parseInt(match[1], 10); const sec = parseInt(match[2], 10); const frac = match[3] ? parseFloat(match[3].replace(',', '.')) : 0; const timeMs = (min * 60 + sec + frac) * 1000; // Remove all timestamps (sometimes multiple) to get clean lyric text const text = line.replace(/\[(\d{1,2}):(\d{2})([.,]\d{1,3})?\]/g, '').trim(); synced.push({ time: timeMs, text: text || '♪' }); } } return synced; } async function fetchMusixmatchLyrics(songInfo, lyricsType = 'auto') { console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(`[Musixmatch Debug] Starting lyrics search (synced preferred)`); console.log("[Musixmatch Debug] Input info:", { artist: songInfo.artist, title: songInfo.title }); const token = localStorage.getItem("lyricsPlusMusixmatchToken"); if (!token) { DEBUG.info('Provider', 'Musixmatch: No token found in localStorage.'); console.log("[Musixmatch Debug] ✗ No token found - double click on the provider to set it up."); return { error: "Double click on the Musixmatch provider to set up your token." }; } console.log("[Musixmatch Debug] ✓ Token found (length:", token.length, "characters)"); // Step 1: Get track info const trackUrl = `https://apic-desktop.musixmatch.com/ws/1.1/matcher.track.get?` + `q_track=${encodeURIComponent(songInfo.title)}&` + `q_artist=${encodeURIComponent(songInfo.artist)}&` + `format=json&usertoken=${encodeURIComponent(token)}&app_id=web-desktop-app-v1.0`; console.log("[Musixmatch Debug] Step 1: Fetching track info"); console.log("[Musixmatch Debug] Track URL:", trackUrl.replace(token, '***TOKEN***')); try { const trackResponse = await fetch(trackUrl, { headers: { 'user-agent': navigator.userAgent, 'referer': 'https://www.musixmatch.com/', }, cache: 'no-store', }); console.log(`[Musixmatch Debug] Track response status: ${trackResponse.status}`); if (!trackResponse.ok) { if (trackResponse.status === 401) { localStorage.removeItem("lyricsPlusMusixmatchToken"); DEBUG.info('Provider', 'Musixmatch 401: Token expired or invalid. Cleared from storage.'); console.log("[Musixmatch Debug] ✗ Authentication failed - token expired or invalid. Cleared from storage."); return { error: "Double click on the Musixmatch provider to set up your token." }; } else if (trackResponse.status === 404) { console.log("[Musixmatch Debug] ✗ Track not found in Musixmatch database"); return { error: "Track not found in Musixmatch database" }; } console.log(`[Musixmatch Debug] ✗ Track request failed: ${trackResponse.status}`); return { error: `Track lookup failed (HTTP ${trackResponse.status})` }; } const trackBody = await trackResponse.json(); const bodyStatusCode = trackBody?.message?.header?.status_code; if (bodyStatusCode === 401) { localStorage.removeItem("lyricsPlusMusixmatchToken"); DEBUG.info('Provider', 'Musixmatch 401: Token expired or invalid. Cleared from storage.'); console.log("[Musixmatch Debug] ✗ Authentication failed - token expired or invalid. Cleared from storage."); return { error: "Double click on the Musixmatch provider to set up your token." }; } const track = trackBody?.message?.body?.track; if (!track) { console.log("[Musixmatch Debug] ✗ No track data in response"); return { error: "Track not found in Musixmatch database" }; } console.log("[Musixmatch Debug] ✓ Track found:", { trackId: track.track_id, trackName: track.track_name, artistName: track.artist_name, hasLyrics: track.has_lyrics, instrumental: track.instrumental }); if (track.instrumental) { console.log("[Musixmatch Debug] ⚠ Track marked as instrumental (no lyrics)"); return { instrumental: true }; } // Step 2: Fetch synced lyrics via subtitles.get const subtitleUrl = `https://apic-desktop.musixmatch.com/ws/1.1/track.subtitles.get?` + `track_id=${track.track_id}&format=json&app_id=web-desktop-app-v1.0&usertoken=${encodeURIComponent(token)}`; console.log("[Musixmatch Debug] Step 2: Fetching synced lyrics (subtitles)"); const subtitleResponse = await fetch(subtitleUrl, { headers: { 'user-agent': navigator.userAgent, 'referer': 'https://www.musixmatch.com/', }, cache: 'no-store', }); console.log(`[Musixmatch Debug] Subtitle response status: ${subtitleResponse.status}`); if (subtitleResponse.ok) { const subtitleBody = await subtitleResponse.json(); const subtitleList = subtitleBody?.message?.body?.subtitle_list; if (subtitleList && subtitleList.length > 0) { const subtitleObj = subtitleList[0]?.subtitle; if (subtitleObj?.subtitle_body) { console.log("[Musixmatch Debug] ✓ Synced lyrics found!"); const synced = parseMusixmatchSyncedLyrics(subtitleObj.subtitle_body); console.log(`[Musixmatch Debug] Parsed ${synced.length} synced lyric lines`); if (synced.length > 0) return { synced }; } } console.log("[Musixmatch Debug] No synced lyrics in subtitle response"); } else { console.log(`[Musixmatch Debug] Subtitle request failed: ${subtitleResponse.status}`); } // Step 3: fallback to unsynced lyrics const lyricsUrl = `https://apic-desktop.musixmatch.com/ws/1.1/track.lyrics.get?` + `track_id=${track.track_id}&format=json&app_id=web-desktop-app-v1.0&usertoken=${encodeURIComponent(token)}`; console.log("[Musixmatch Debug] Step 3: Fetching unsynced lyrics (fallback)"); const lyricsResponse = await fetch(lyricsUrl, { headers: { 'user-agent': navigator.userAgent, 'referer': 'https://www.musixmatch.com/', }, cache: 'no-store', }); console.log(`[Musixmatch Debug] Lyrics response status: ${lyricsResponse.status}`); if (!lyricsResponse.ok) { console.log(`[Musixmatch Debug] ✗ Lyrics request failed: ${lyricsResponse.status}`); return { error: `Lyrics fetch failed (HTTP ${lyricsResponse.status})` }; } const lyricsBody = await lyricsResponse.json(); const unsyncedRaw = lyricsBody?.message?.body?.lyrics?.lyrics_body; if (unsyncedRaw) { const unsynced = unsyncedRaw.split("\n").map(line => ({ text: line })); console.log(`[Musixmatch Debug] ✓ Unsynced lyrics found! (${unsynced.length} lines)`); return { unsynced }; } console.log("[Musixmatch Debug] ✗ No lyrics found in any format"); return { error: "No lyrics available from Musixmatch" }; } catch (e) { console.error("[Musixmatch Debug] ✗ Fetch error:", e.message || e); return { error: "Musixmatch request failed - connection error or service unreachable" }; } } // Extract synced lyrics from the fetchMusixmatchLyrics result function musixmatchGetSynced(body) { if (!body || !body.synced) { return null; } return body.synced.map(line => ({ text: line.text, time: Math.round(line.time ?? line.startTime ?? 0), })); } // Extract unsynced lyrics from the fetchMusixmatchLyrics result function musixmatchGetUnsynced(body) { if (!body || !body.unsynced) { return null; } return body.unsynced.map(line => ({ text: line.text })); } const ProviderMusixmatch = { async findLyrics(info, lyricsType = 'auto') { try { const data = await fetchMusixmatchLyrics(info, lyricsType); if (!data) { return { error: "No lyrics available from Musixmatch" }; } if (data.error) { // If the error is about missing token, show that instead if (data.error.includes("Double click on the Musixmatch provider")) { return { error: data.error }; } return { error: "No lyrics available from Musixmatch" }; } return data; } catch (e) { return { error: "Musixmatch request failed - connection error or service unreachable" }; } }, getUnsynced: musixmatchGetUnsynced, getSynced: musixmatchGetSynced, }; // --- Genius --- async function fetchGeniusLyrics(info, lyricsType = 'auto') { console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(`[Genius Debug] Starting lyrics search (unsynced only)`); console.log("[Genius Debug] Input info:", { artist: info.artist, title: info.title, album: info.album, duration: info.duration }); const titles = new Set([ info.title, Utils.removeExtraInfo(info.title), Utils.removeSongFeat(info.title), Utils.removeSongFeat(Utils.removeExtraInfo(info.title)), ]); console.log("[Genius Debug] Title variants to try:", Array.from(titles)); function generateNthIndices(start = 1, step = 4, max = 25) { const arr = []; for (let i = start; i <= max; i += step) arr.push(i); return arr; } function cleanQuery(title) { return title .replace(/\b(remastered|explicit|deluxe|live|version|edit|remix|radio edit|radio|bonus track|bonus|special edition|expanded|edition)\b/gi, '') .replace(/\b(radio|spotify|lyrics|calendar|release|singles|top|annotated|playlist)\b/gi, '') .replace(/\b\d{4}\b/g, '') .replace(/[-–—]+$/g, '') .replace(/\s+/g, ' ') .trim(); } function normalize(str) { // Use NFD (Canonical Decomposition) to decompose diacritics into base + combining marks // Then remove the combining marks (Unicode range \u0300-\u036f) // This converts: ă→a, é→e, ñ→n, ö→o, etc. // Finally, remove all remaining non-alphanumeric characters return str .toLowerCase() .normalize('NFD') .replace(/[\u0300-\u036f]/g, '') // Remove combining diacritical marks .replace(/[^a-z0-9]/gi, ''); } function normalizeArtists(artist) { return artist .toLowerCase() // Universal normalization: remove all types of additions/metadata // Handles: (ROU), [UK], {Producer}, etc. .replace(/\s*[\(\[\{][^\)\]\}]*[\)\]\}]/g, '') // Remove common suffixes that don't help matching .replace(/\s*(?:& the [a-z]+|and friends?|& co\.?)$/i, '') // Normalize "The" prefix for better matching .replace(/^the\s+/i, '') .split(/,|&|feat|ft|\band\b/gi) .map(s => s.trim()) .filter(Boolean) .map(normalize); } /** * Check if one artist name contains another (fuzzy matching). * Helps match "Swisher" with "Swisher ROU" even if normalization missed something. * @param {string} artistA - First artist name (normalized) * @param {string} artistB - Second artist name (normalized) * @returns {boolean} True if names overlap significantly */ function artistNameContains(artistA, artistB) { if (artistA === artistB) return true; // Minimum 3 chars to avoid false matches on very short names if (artistA.length < 3 || artistB.length < 3) return false; // Require 70% overlap to prevent false positives like "Art" matching "Artist" // and at least 4 characters must overlap if (artistA.includes(artistB)) { return artistB.length >= Math.max(artistA.length * 0.7, 4); } if (artistB.includes(artistA)) { return artistA.length >= Math.max(artistB.length * 0.7, 4); } return false; } /** * Calculate artist overlap with fuzzy matching support. * Tracks both exact and fuzzy matches to weight them differently in scoring. * @param {Set} targetArtists - Artists from Spotify track * @param {Set} resultArtists - Artists from Genius result * @returns {{exactMatches: number, fuzzyMatches: number, totalMatches: number}} */ function calculateArtistOverlap(targetArtists, resultArtists) { let exactMatches = 0; let fuzzyMatches = 0; const matchedResults = new Set(); // Track to avoid double-counting for (const target of targetArtists) { // First try exact match if (resultArtists.has(target)) { exactMatches++; matchedResults.add(target); } else { // Try fuzzy match (substring matching) for (const result of resultArtists) { if (!matchedResults.has(result) && artistNameContains(target, result)) { fuzzyMatches++; matchedResults.add(result); break; } } } } return { exactMatches, fuzzyMatches, totalMatches: exactMatches + fuzzyMatches }; } function extractFeaturedArtistsFromTitle(title) { const matches = title.match(/\((?:feat\.?|ft\.?|with)\s+([^)]+)\)/i); if (!matches) return []; return matches[1].split(/,|&|and/).map(s => normalize(s.trim())); } function hasVersionKeywords(title) { // Covers single words and phrases (bonus track, deluxe edition, etc.) return /\b(remix|deluxe|version|edit|live|explicit|remastered|bonus track|bonus|edition|expanded|special edition)\b/i.test(title); } // Scoring constants for artist matching const SCORE_PERFECT_MATCH = 10; // All artists matched const SCORE_EXACT_BONUS = 2; // Bonus when all matches are exact (not fuzzy) const SCORE_ALMOST_PERFECT = 8; // Missing only 1 artist const SCORE_ALMOST_EXACT_BONUS = 1; // Bonus for mostly exact matches const SCORE_PARTIAL_BASE = 4; // Base score for partial matches const SCORE_PARTIAL_RANGE = 4; // Additional points based on match ratio (4-8 range) const SCORE_EXACT_MATCH_BONUS = 0.5; // Small bonus per exact match in partial scenarios const PENALTY_MISSING_ARTIST = 0.3; // Reduced penalty since Genius metadata may be incomplete const SCORE_MIN_ARTIST_THRESHOLD = 3; // Minimum score to continue evaluation // Scoring constants for title matching const SCORE_TITLE_PERFECT = 7; // Exact title match const SCORE_TITLE_GOOD_OVERLAP = 5; // Good substring overlap (≥70%) const SCORE_TITLE_PARTIAL = 3; // Partial overlap (<70%) const SCORE_TITLE_SHORT = 2; // Very short title (< MIN_TITLE_LENGTH) const SCORE_TITLE_NO_MATCH = 1; // No overlap const SCORE_VERSION_ADJUSTMENT = 1; // Bonus/penalty for version keyword match/mismatch const PENALTY_NO_TITLE_OVERLAP = 2; // Penalty when titles don't overlap at all const MIN_TITLE_LENGTH = 5; // Minimum title length for reliable matching const MIN_TITLE_OVERLAP_RATIO = 0.7; // Minimum overlap ratio for good score // True for translations, covers, etc (not original lyric pages!) const translationKeywords = [ "translation", "übersetzung", "перевод", "çeviri", "traducción", "traduções", "traduction", "traductions", "traduzione", "traducciones-al-espanol", "fordítás", "fordítások", "tumaczenie", "tłumaczenie", "polskie tłumaczenie", "magyar fordítás", "turkce çeviri", "russian translations", "deutsche übersetzung", "genius users", "official translation", "genius russian translations", "genius deutsche übersetzungen", "genius türkçe çeviriler", "polskie tłumaczenia genius", "genius magyar fordítások", "genius traducciones al espanol", "genius traduzioni italiane", "genius traductions françaises", "genius turkce ceviriler", ]; function containsTranslationKeyword(s) { if (!s) return false; const lower = s.toLowerCase(); return translationKeywords.some(k => lower.includes(k)); } function isTranslationPage(result) { return ( containsTranslationKeyword(result.primary_artist?.name) || containsTranslationKeyword(result.title) || containsTranslationKeyword(result.url) ); } function isSimpleOriginalUrl(url) { try { const path = new URL(url).pathname.toLowerCase(); if (/^\/[a-z0-9-]+-lyrics$/.test(path)) return true; const parts = path.split('/').pop().split('-'); if (parts.length >= 3 && parts.slice(-1)[0] === "lyrics") { if (parts.some(part => translationKeywords.some(k => part.includes(k)))) return false; return true; } return false; } catch { return false; } } const includedNthIndices = generateNthIndices(); // Try up to 5 pages of results for each title variant const maxPages = 5; for (const title of titles) { const cleanTitle = cleanQuery(title); console.log(`[Genius Debug] Trying title variant: "${title}" → cleaned: "${cleanTitle}"`); for (let page = 1; page <= maxPages; page++) { const query = encodeURIComponent(`${info.artist} ${cleanTitle}`); const searchUrl = `https://genius.com/api/search/multi?per_page=5&page=${page}&q=${query}`; console.log(`[Genius Debug] Page ${page}: Searching with query: "${info.artist} ${cleanTitle}"`); console.log(`[Genius Debug] URL: ${searchUrl}`); try { const searchRes = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: searchUrl, headers: { Accept: "application/json", "User-Agent": navigator.userAgent, }, onload: resolve, onerror: reject, ontimeout: reject, timeout: 5000, }); }); const searchJson = JSON.parse(searchRes.responseText); const hits = searchJson?.response?.sections?.flatMap(s => s.hits) || []; const songHits = hits.filter(h => h.type === "song"); console.log(`[Genius Debug] Page ${page}: Received ${songHits.length} song results`); songHits.forEach((hit, idx) => { const result = hit.result; console.log(`[Genius Debug] Result ${idx + 1}:`, { title: result.title, artist: result.primary_artist?.name, url: result.url }); }); for (const hit of songHits) { const result = hit.result; } const targetArtists = new Set(normalizeArtists(info.artist)); const targetTitleNorm = normalize(Utils.removeExtraInfo(info.title)); const targetHasVersion = hasVersionKeywords(info.title); console.log("[Genius Debug] Target (Spotify) normalization:", { originalArtist: info.artist, normalizedArtists: Array.from(targetArtists), originalTitle: info.title, cleanedTitle: Utils.removeExtraInfo(info.title), normalizedTitle: targetTitleNorm, hasVersionKeywords: targetHasVersion }); // Dynamic threshold based on artist count (calculated once, used consistently) // Single artist: need strong match (≥8) to prevent false positives // Multi-artist: more lenient (≥6) since metadata may be incomplete const matchThreshold = targetArtists.size === 1 ? 8 : 6; console.log(`[Genius Debug] Match threshold for ${targetArtists.size} artist(s): ${matchThreshold}`); let bestScore = -Infinity; let fallbackScore = -Infinity; let song = null; let fallbackSong = null; for (const hit of songHits) { const result = hit.result; // Only consider original (non-translation) Genius lyrics pages if (isTranslationPage(result) || !isSimpleOriginalUrl(result.url)) { console.log(`[Genius Debug] ⊗ Skipping "${result.title}" - translation page or non-simple URL`); continue; } const primary = normalizeArtists(result.primary_artist?.name || ''); const featured = extractFeaturedArtistsFromTitle(result.title || ''); // Also extract artists from Genius metadata arrays if available // This helps match songs where featured/producer artists are in the Spotify credits const featuredFromAPI = (result.featured_artists || []) .map(a => a.name) .flatMap(name => normalizeArtists(name)); const producersFromAPI = (result.producer_artists || []) .map(a => a.name) .flatMap(name => normalizeArtists(name)); const resultArtists = new Set([...primary, ...featured, ...featuredFromAPI, ...producersFromAPI]); const resultTitleNorm = normalize(Utils.removeExtraInfo(result.title || '')); const resultHasVersion = hasVersionKeywords(result.title || ''); console.log(`[Genius Debug] Candidate: "${result.title}" by ${result.primary_artist?.name}`); console.log(`[Genius Debug] Genius normalization:`, { originalArtist: result.primary_artist?.name, normalizedArtists: Array.from(resultArtists), originalTitle: result.title, cleanedTitle: Utils.removeExtraInfo(result.title), normalizedTitle: resultTitleNorm, hasVersionKeywords: resultHasVersion }); // Use enhanced fuzzy artist matching const overlap = calculateArtistOverlap(targetArtists, resultArtists); const totalArtists = targetArtists.size; console.log(`[Genius Debug] Artist matching:`, { targetArtists: Array.from(targetArtists), resultArtists: Array.from(resultArtists), exactMatches: overlap.exactMatches, fuzzyMatches: overlap.fuzzyMatches, totalMatches: overlap.totalMatches, totalArtists: totalArtists }); // Guard against empty artist set (should not happen in practice) if (totalArtists === 0) continue; const artistOverlapCount = overlap.totalMatches; const exactMatchCount = overlap.exactMatches; // Dynamic artist scoring based on match quality and artist count let artistScore = 0; if (artistOverlapCount === 0) { artistScore = 0; // no artist overlap, reject } else if (artistOverlapCount === totalArtists) { // Perfect match - all artists found artistScore = SCORE_PERFECT_MATCH; // Bonus for exact matches vs fuzzy if (exactMatchCount === totalArtists) artistScore += SCORE_EXACT_BONUS; } else if (artistOverlapCount >= totalArtists - 1) { // Almost perfect (missing only 1 artist) artistScore = SCORE_ALMOST_PERFECT; if (exactMatchCount >= totalArtists - 1) artistScore += SCORE_ALMOST_EXACT_BONUS; } else if (artistOverlapCount >= 1) { // Partial match - score based on percentage matched const matchRatio = artistOverlapCount / totalArtists; artistScore = SCORE_PARTIAL_BASE + (matchRatio * SCORE_PARTIAL_RANGE); // Bonus for exact matches artistScore += exactMatchCount * SCORE_EXACT_MATCH_BONUS; // Reduced penalty for missing artists (metadata may be incomplete) const missingArtists = totalArtists - artistOverlapCount; artistScore -= missingArtists * PENALTY_MISSING_ARTIST; } console.log(`[Genius Debug] Artist score: ${artistScore} (threshold: ${SCORE_MIN_ARTIST_THRESHOLD})`); // Minimum artist threshold - must have at least some artist match if (artistScore < SCORE_MIN_ARTIST_THRESHOLD) { console.log(`[Genius Debug] ⊗ Rejected: artist score below threshold`); continue; } // Title scoring with better substring validation to prevent false positives let titleScore = 0; if (resultTitleNorm === targetTitleNorm) { // Perfect title match titleScore = SCORE_TITLE_PERFECT; } else if (resultTitleNorm.includes(targetTitleNorm) || targetTitleNorm.includes(resultTitleNorm)) { // Substring match - validate it's significant const shorter = resultTitleNorm.length < targetTitleNorm.length ? resultTitleNorm : targetTitleNorm; const longer = resultTitleNorm.length >= targetTitleNorm.length ? resultTitleNorm : targetTitleNorm; const overlapRatio = shorter.length / longer.length; // Penalize short titles that might be common words ("Yesterday" vs "Yesterday's Dream") if (shorter.length < MIN_TITLE_LENGTH) { titleScore = SCORE_TITLE_SHORT; } else if (overlapRatio >= MIN_TITLE_OVERLAP_RATIO) { titleScore = SCORE_TITLE_GOOD_OVERLAP; } else { titleScore = SCORE_TITLE_PARTIAL; } } else { titleScore = SCORE_TITLE_NO_MATCH; } // Version keywords adjustment (remix, live, etc.) if (targetHasVersion) { if (resultHasVersion) titleScore += SCORE_VERSION_ADJUSTMENT; else titleScore -= SCORE_VERSION_ADJUSTMENT; } else { if (!resultHasVersion) titleScore += SCORE_VERSION_ADJUSTMENT; else titleScore -= SCORE_VERSION_ADJUSTMENT; } console.log(`[Genius Debug] Title comparison:`, { targetNorm: targetTitleNorm, resultNorm: resultTitleNorm, exactMatch: resultTitleNorm === targetTitleNorm, titleScore: titleScore }); // Calculate final score with weighted components let score = artistScore + titleScore; // Apply penalty for poor matches (no title overlap at all) if (!resultTitleNorm.includes(targetTitleNorm) && !targetTitleNorm.includes(resultTitleNorm)) { score -= PENALTY_NO_TITLE_OVERLAP; } console.log(`[Genius Debug] Final score: ${score} (artistScore: ${artistScore} + titleScore: ${titleScore})`); console.log(`[Genius Debug] Threshold: ${matchThreshold}, Current best: ${bestScore}`); // Check if this result meets the threshold and is better than current best if (score > bestScore && score >= matchThreshold && (!targetHasVersion || resultHasVersion)) { console.log(`[Genius Debug] ✓ NEW BEST MATCH!`); bestScore = score; song = result; } else if ( score > fallbackScore && score >= matchThreshold - 1 && // Slightly lower threshold for fallback (!resultHasVersion || !targetHasVersion) ) { console.log(`[Genius Debug] ✓ New fallback candidate`); fallbackScore = score; fallbackSong = result; } else { console.log(`[Genius Debug] ⊗ Not selected (score too low or version mismatch)`); } } if (!song && fallbackSong) { console.log(`[Genius Debug] Using fallback song: "${fallbackSong.title}"`); song = fallbackSong; bestScore = fallbackScore; } // Final check: ensure we have a song that meets the minimum threshold if (bestScore < matchThreshold || !song?.url) { console.log(`[Genius Debug] No suitable match found on page ${page} (bestScore: ${bestScore}, threshold: ${matchThreshold})`); continue; } console.log(`[Genius Debug] ✓✓✓ SELECTED: "${song.title}" by ${song.primary_artist?.name}`); console.log(`[Genius Debug] Fetching lyrics from: ${song.url}`); const htmlRes = await new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: song.url, headers: { Accept: "text/html", "User-Agent": navigator.userAgent, }, onload: resolve, onerror: reject, ontimeout: reject, timeout: 5000, }); }); const doc = new DOMParser().parseFromString(htmlRes.responseText, "text/html"); const lyricsRoot = [...doc.querySelectorAll('div')].find(el => [...el.classList].some(cls => cls.includes('Lyrics__Root')) ); if (!lyricsRoot) { console.warn("[Genius] No .Lyrics__Root found"); continue; } const containers = [...lyricsRoot.querySelectorAll('div')].filter(el => [...el.classList].some(cls => cls.includes('Lyrics__Container')) ); if (containers.length === 0) { console.warn("[Genius] No .Lyrics__Container found inside .Lyrics__Root"); continue; } const relevantContainersSet = new Set(); containers.forEach(container => { const parent = container.parentElement; const siblings = [...parent.children]; const nthIndex = siblings.indexOf(container) + 1; if (includedNthIndices.includes(nthIndex)) { relevantContainersSet.add(container); } }); containers.forEach(container => { if (relevantContainersSet.has(container)) return; const classList = [...container.classList].map(c => c.toLowerCase()); const text = container.textContent.trim().toLowerCase(); if ( classList.some(cls => cls.includes('header') || cls.includes('readmore') || cls.includes('annotation') || cls.includes('credit') || cls.includes('footer') ) || !text || text.length < 10 || text.includes('read more') || text.includes('lyrics') || text.includes('©') ) { return; } relevantContainersSet.add(container); }); const relevantContainers = Array.from(relevantContainersSet); let lyrics = ''; function walk(node) { for (const child of node.childNodes) { if (child.nodeType === Node.ELEMENT_NODE) { const classList = [...child.classList].map(c => c.toLowerCase()); if (classList.some(cls => cls.includes('header') || cls.includes('readmore') || cls.includes('annotation') || cls.includes('credit') || cls.includes('footer') )) continue; } if (child.nodeType === Node.TEXT_NODE) { lyrics += child.textContent; } else if (child.nodeName === "BR") { lyrics += "\n"; } else if (child.nodeType === Node.ELEMENT_NODE) { walk(child); if (/div|p|section/i.test(child.nodeName)) lyrics += "\n"; } } } relevantContainers.forEach(container => { walk(container); lyrics += "\n"; }); lyrics = lyrics.replace(/\n{2,}/g, "\n").trim(); if (!lyrics) { console.warn("[Genius] Extracted lyrics are empty"); continue; } return { plainLyrics: lyrics }; } catch (e) { console.error("[Genius Debug] Fetch or parse error:", e); continue; } } } console.log("[Genius Debug] ✗✗✗ No lyrics found after trying all title variants and pages"); console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); return { error: "No lyrics available from Genius" }; } function parseGeniusLyrics(raw) { if (!raw) return { unsynced: null }; const lines = raw .split(/\r?\n/) .map(line => line.trim()) .filter(line => line && !/^(\[.*\])$/.test(line)); // skip pure section headers return { unsynced: lines.map(text => ({ text })), }; } const ProviderGenius = { async findLyrics(info, lyricsType = 'auto') { try { const data = await fetchGeniusLyrics(info, lyricsType); if (!data) { return { error: "Genius request failed - connection error or service unreachable" }; } // If data has an error from the fetch function aka was unable to parse or fetch from Genius, return as is ("No lyrics available from Genius") if (data.error) { return data; } // Check if lyrics indicate no lyrics available or instrumental track if (data.plainLyrics) { const lines = parseGeniusLyrics(data.plainLyrics).unsynced; // Patterns for tracks where lyrics aren't transcribed yet const notTranscribedPatterns = [ /lyrics for this song have yet to be transcribed/i, /we do not have the lyrics for/i, /be the first to add the lyrics/i, /please check back once the song has been released/i, /add lyrics on genius/i ]; // Patterns for instrumental tracks const instrumentalTrackPatterns = [ /this song is an instrumental/i ]; if (lines.length === 1) { // Check for instrumental tracks first const instrumentalMatch = instrumentalTrackPatterns.find(rx => rx.test(lines[0].text)); if (instrumentalMatch) { console.log(`[Genius Debug] ⚠ Track is instrumental - matched pattern: ${instrumentalMatch} in text: "${lines[0].text}"`); return { instrumental: true }; } // Check for not transcribed patterns const notTranscribedMatch = notTranscribedPatterns.find(rx => rx.test(lines[0].text)); if (notTranscribedMatch) { console.log(`[Genius Debug] ⚠ No lyrics available for this track - matched pattern: ${notTranscribedMatch} in text: "${lines[0].text}"`); // For not transcribed patterns, return error to prevent caching the transcribed pattern as lyrics return { error: "No lyrics available from Genius" }; } } } return data; } catch (e) { return { error: "Genius request failed - connection error or service unreachable" }; } }, getUnsynced(body) { if (!body?.plainLyrics) return null; const lines = parseGeniusLyrics(body.plainLyrics).unsynced; return lines; }, getSynced() { return null; }, }; // --- Spotify --- function showSpotifyTokenModal() { // Remove any existing modal const old = document.getElementById("lyrics-plus-spotify-modal"); if (old) old.remove(); // Inject style for the modal, only once if (!document.getElementById("lyrics-plus-spotify-modal-style")) { const style = document.createElement("style"); style.id = "lyrics-plus-spotify-modal-style"; style.textContent = ` #lyrics-plus-spotify-modal { position: fixed; left: 0; top: 0; width: 100vw; height: 100vh; background: rgba(0,0,0,0.7); z-index: 100001; display: flex; align-items: center; justify-content: center; } #lyrics-plus-spotify-modal-box { background: #181818; color: #fff; border-radius: 14px; padding: 30px 28px 22px 28px; min-width: 350px; max-width: 90vw; box-shadow: 0 2px 24px #000b; font-family: inherit; position: relative; box-sizing: border-box; } #lyrics-plus-spotify-modal-title { color: #1db954; font-size: 1.35em; font-weight: 700; margin-bottom: 13px; text-align: center; letter-spacing: 0.3px; } #lyrics-plus-spotify-modal .modal-footer { display: flex; justify-content: flex-end; gap: 25px; margin-top: 18px; padding: 0; } #lyrics-plus-spotify-modal .lyrics-btn { background: #222; color: #fff; border: none; border-radius: 20px; padding: 8px 0; font-size: 15px; font-weight: 600; cursor: pointer; box-shadow: 0 1px 4px #0003; transition: background 0.13s, color 0.13s; outline: none; min-width: 90px; width: 90px; text-align: center; flex: 0 0 90px; margin: 0; } #lyrics-plus-spotify-modal .lyrics-btn:hover { background: #1db954; color: #181818; } #lyrics-plus-spotify-modal-close { background: #222; color: #fff; border: none; border-radius: 14px; font-size: 1.25em; font-weight: 700; width: 36px; height: 36px; padding: 0; display: flex; align-items: center; justify-content: center; position: absolute; top: 10px; right: 10px; cursor: pointer; transition: background 0.13s, color 0.13s; z-index: 1; line-height: 1; margin: 0; } #lyrics-plus-spotify-modal-close:hover { background: #1db954; color: #181818; } #lyrics-plus-spotify-modal a { color: #1db954; text-decoration: none; transition: color .12s; font-weight: 600; } #lyrics-plus-spotify-modal a:hover { color: #fff; text-decoration: underline; } #lyrics-plus-spotify-modal input[type="text"], #lyrics-plus-musixmatch-modal input[type="password"] { background: #222; color: #fff; border: 1px solid #333; border-radius: 5px; width: 100%; padding: 8px 10px; margin: 14px 0 8px 0; font-size: 1em; box-sizing: border-box; display: block; } `; document.head.appendChild(style); } const modal = document.createElement("div"); modal.id = "lyrics-plus-spotify-modal"; const box = document.createElement("div"); box.id = "lyrics-plus-spotify-modal-box"; box.innerHTML = `
Set your Spotify User Token
How to retrieve your token:
1. Go to Spotify Web Player and log in. Play a song.
2. Open DevTools (Press F12 or Right click and Inspect).
3. Go to the Network tab and search for "spclient".
4. You may have to wait a little for it to load.
5. Click on one of the spclient domains and go to the Headers section.
6. Under Response Headers, locate the authorization request header.
7. If there isn't one, try a different spclient domain.
8. Right-click on the content of the authorization request header and select Copy value.
9. Paste the token below and press Save.
WARNING: Keep your token private! Do not share it with others.
`; const input = document.createElement("input"); input.type = "text"; input.placeholder = "Enter your Spotify user token here"; input.value = localStorage.getItem("lyricsPlusSpotifyToken") || ""; box.appendChild(input); // Footer with Save & Cancel const footer = document.createElement("div"); footer.className = "modal-footer"; const btnSave = document.createElement("button"); btnSave.textContent = "Save"; btnSave.className = "lyrics-btn"; btnSave.onclick = () => { const rawValue = input.value.trim(); const bearerPrefix = "Bearer "; const tokenValue = rawValue.startsWith(bearerPrefix) ? rawValue.slice(bearerPrefix.length) : rawValue; localStorage.setItem("lyricsPlusSpotifyToken", tokenValue); modal.remove(); // Optionally: reload lyrics if popup open and provider is Spotify const popup = document.getElementById("lyrics-plus-popup"); if (popup && Providers.current === "Spotify") { const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) lyricsContainer.textContent = "Loading lyrics..."; updateLyricsContent(popup, getCurrentTrackInfo()); } }; const btnCancel = document.createElement("button"); btnCancel.textContent = "Cancel"; btnCancel.className = "lyrics-btn"; btnCancel.onclick = () => modal.remove(); footer.appendChild(btnSave); footer.appendChild(btnCancel); box.appendChild(footer); // Close (X) button box.querySelector('#lyrics-plus-spotify-modal-close').onclick = () => modal.remove(); modal.appendChild(box); document.body.appendChild(modal); // Focus input for fast paste input.focus(); } const ProviderSpotify = { async findLyrics(info, lyricsType = 'auto') { console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); console.log(`[Spotify Debug] Starting lyrics search (synced preferred)`); console.log("[Spotify Debug] Input info:", { trackId: info.trackId, title: info.title, artist: info.artist }); const token = localStorage.getItem("lyricsPlusSpotifyToken"); if (!token) { DEBUG.info('Provider', 'Spotify: No token found in localStorage.'); console.log("[Spotify Debug] ✗ No token found - double click on the provider to set it up."); return { error: "Double click on the Spotify provider to set up your token.\n" + "A fresh token is required every hour/upon page reload for security." }; } console.log("[Spotify Debug] ✓ Token found (length:", token.length, "characters)"); if (!info.trackId) { console.log("[Spotify Debug] ✗ No trackId provided in song info"); return { error: "Cannot fetch Spotify lyrics - track ID not available" }; } const endpoint = `https://spclient.wg.spotify.com/color-lyrics/v2/track/${info.trackId}?format=json&vocalRemoval=false&market=from_token`; console.log("[Spotify Debug] Request endpoint:", endpoint); console.log("[Spotify Debug] Using Authorization: Bearer ***TOKEN***"); try { const res = await fetch(endpoint, { method: "GET", headers: { "app-platform": "WebPlayer", "User-Agent": navigator.userAgent, "Authorization": "Bearer " + token, }, }); console.log(`[Spotify Debug] Response status: ${res.status} ${res.statusText}`); if (!res.ok) { const text = await res.text(); console.log("[Spotify Debug] Response body:", text.substring(0, 200)); if (res.status === 401) { localStorage.removeItem("lyricsPlusSpotifyToken"); DEBUG.info('Provider', 'Spotify 401: Token expired or invalid. Cleared from storage.'); console.log("[Spotify Debug] ✗ Authentication failed - token expired or invalid. Cleared from storage."); return { error: "Double click on the Spotify provider and follow the instructions. Spotify requires a fresh token every hour/upon page reload for security." }; } if (res.status === 404) { console.log("[Spotify Debug] ✗ Track not found or no lyrics available"); return { error: "Track not found or no lyrics available from Spotify" }; } if (res.status === 403) { console.log("[Spotify Debug] ✗ Access forbidden - check token permissions"); return { error: "Access denied by Spotify - please refresh your token" }; } console.log(`[Spotify Debug] ✗ Request failed: ${res.status} ${res.statusText}`); return { error: `Spotify lyrics request failed (HTTP ${res.status})` }; } let data; try { data = await res.json(); } catch (jsonErr) { const text = await res.text(); console.error("[Spotify Debug] ✗ Failed to parse JSON. Raw response:", text.substring(0, 200)); return { error: "Invalid response format from Spotify" }; } console.log("[Spotify Debug] Response data:", { hasLyrics: !!(data && data.lyrics), hasLines: !!(data && data.lyrics && data.lyrics.lines), lineCount: data?.lyrics?.lines?.length || 0, syncType: data?.lyrics?.syncType, language: data?.lyrics?.language }); // Adapt to your UI's expected data shape: if (!data || !data.lyrics || !data.lyrics.lines || !data.lyrics.lines.length) { console.log("[Spotify Debug] ✗ No lyric lines in API response"); return { error: "No lyrics available from Spotify" }; } console.log(`[Spotify Debug] ✓ Lyrics found! Type: ${data.lyrics.syncType}, Lines: ${data.lyrics.lines.length}, Language: ${data.lyrics.language || 'unknown'}`); return data.lyrics; } catch (e) { console.error("[Spotify Debug] ✗ Fetch error:", e.message || e); return { error: "Spotify request failed - connection error or service unreachable" }; } }, getSynced(data) { if (Array.isArray(data.lines) && data.syncType === "LINE_SYNCED") { return data.lines.map(line => ({ time: line.startTimeMs, text: line.words })); } return null; }, getUnsynced(data) { // Accept both unsynced and fallback if lines exist if (Array.isArray(data.lines) && (data.syncType === "UNSYNCED" || data.syncType !== "LINE_SYNCED")) { return data.lines.map(line => ({ text: line.words })); } return null; }, }; // --- Providers List --- const Providers = { list: ["LRCLIB", "Spotify", "KPoe", "Musixmatch", "Genius"], map: { "LRCLIB": ProviderLRCLIB, "Spotify": ProviderSpotify, "KPoe": ProviderKPoe, "Musixmatch": ProviderMusixmatch, "Genius": ProviderGenius, }, current: "LRCLIB", getCurrent() { return this.map[this.current]; }, setCurrent(name) { if (this.map[name]) this.current = name; } }; // ------------------------ // UI and Popup Functions // ------------------------ function removePopup() { DEBUG.ui.popupRemoved(); // Clear all intervals if (highlightTimer) { clearInterval(highlightTimer); highlightTimer = null; DEBUG.debug('Cleanup', 'highlightTimer cleared'); } if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; DEBUG.debug('Cleanup', 'pollingInterval cleared'); } if (progressInterval) { clearInterval(progressInterval); progressInterval = null; DEBUG.debug('Cleanup', 'progressInterval cleared'); } // Clean up popup-specific observers const existing = document.getElementById("lyrics-plus-popup"); if (existing) { // Disconnect all popup-attached observers if (existing._playPauseObserver) { ResourceManager.cleanupObserver(existing._playPauseObserver); existing._playPauseObserver = null; } if (existing._shuffleObserver) { ResourceManager.cleanupObserver(existing._shuffleObserver); existing._shuffleObserver = null; } if (existing._repeatObserver) { ResourceManager.cleanupObserver(existing._repeatObserver); existing._repeatObserver = null; } // Remove window mouseup handler for resize if (existing._resizeMouseupHandler) { window.removeEventListener("mouseup", existing._resizeMouseupHandler); DEBUG.debug('Cleanup', 'Removed mouseup handler for resize'); existing._resizeMouseupHandler = null; } // Remove drag window event listeners if (existing._dragHandlers) { const { onDragMouseMove, onDragTouchMove, onDragMouseUp, onDragTouchEnd } = existing._dragHandlers; window.removeEventListener("mousemove", onDragMouseMove); window.removeEventListener("touchmove", onDragTouchMove); window.removeEventListener("mouseup", onDragMouseUp); window.removeEventListener("touchend", onDragTouchEnd); existing._dragHandlers = null; DEBUG.debug('Cleanup', 'Removed drag window event listeners'); } // Remove resize window event listeners if (existing._resizeHandlers) { const { onResizeMouseMove, onResizeTouchMove, onResizeMouseUp, onResizeTouchEnd } = existing._resizeHandlers; window.removeEventListener("mousemove", onResizeMouseMove); window.removeEventListener("touchmove", onResizeTouchMove); window.removeEventListener("mouseup", onResizeMouseUp); window.removeEventListener("touchend", onResizeTouchEnd); existing._resizeHandlers = null; DEBUG.debug('Cleanup', 'Removed resize window event listeners'); } // Disconnect progress bar watcher observer if (existing._progressBarWatcher) { try { existing._progressBarWatcher.disconnect(); } catch (e) { DEBUG.error('Cleanup', 'Failed to disconnect progress bar watcher:', e); } existing._progressBarWatcher = null; DEBUG.debug('Cleanup', 'Progress bar watcher disconnected'); } // Clear popup references existing._playPauseBtn = null; existing._shuffleBtn = null; existing._repeatBtn = null; existing._prevBtn = null; existing._nextBtn = null; existing._lyricsTabs = null; // Close PiP if active — the popup is required for PiP to function closePip(); existing.remove(); DEBUG.debug('Cleanup', 'Popup element and all observers removed from DOM'); } } function observeSpotifyShuffle(popup) { if (!popup || !popup._shuffleBtn) return; if (popup._shuffleObserver) { ResourceManager.cleanupObserver(popup._shuffleObserver); } // Use the new language-independent function to find the shuffle button const shuffleBtn = findSpotifyShuffleButton(); if (!shuffleBtn) return; const observer = new MutationObserver(() => { updateShuffleButton(popup._shuffleBtn.button, popup._shuffleBtn.iconWrapper); // Re-attach observer if the node is replaced setTimeout(() => observeSpotifyShuffle(popup), 0); }); observer.observe(shuffleBtn, { attributes: true, attributeFilter: ['aria-label', 'class', 'style'] }); popup._shuffleObserver = ResourceManager.registerObserver(observer, 'Shuffle button state'); } function observeSpotifyRepeat(popup) { if (!popup || !popup._repeatBtn) return; if (popup._repeatObserver) { ResourceManager.cleanupObserver(popup._repeatObserver); } // Use the new language-independent function to find the repeat button const repeatBtn = findSpotifyRepeatButton(); if (!repeatBtn) return; const observer = new MutationObserver(() => { updateRepeatButton(popup._repeatBtn.button, popup._repeatBtn.iconWrapper); // Re-attach observer if the node is replaced setTimeout(() => observeSpotifyRepeat(popup), 0); }); observer.observe(repeatBtn, { attributes: true, attributeFilter: ['aria-label', 'class', 'style', 'aria-checked'] }); popup._repeatObserver = ResourceManager.registerObserver(observer, 'Repeat button state'); } function observeSpotifyPlayPause(popup) { if (!popup || !popup._playPauseBtn) return; if (popup._playPauseObserver) { ResourceManager.cleanupObserver(popup._playPauseObserver); } // Use the new language-independent function to find the play/pause button const spBtn = findSpotifyPlayPauseButton(); if (!spBtn) return; const observer = new MutationObserver(() => { if (popup._playPauseBtn) { updatePlayPauseButton(popup._playPauseBtn.button, popup._playPauseBtn.iconWrapper); } }); observer.observe(spBtn, { attributes: true, attributeFilter: ['aria-label', 'class', 'style'] }); popup._playPauseObserver = ResourceManager.registerObserver(observer, 'Play/pause button state'); } function createPopup() { DEBUG.ui.popupCreated(); removePopup(); // Clear current provider so no provider is highlighted while searching for lyrics Providers.current = null; // Load saved proportion from localStorage (stored as ratios of window size) const savedProportion = localStorage.getItem('lyricsPlusPopupProportion'); let pos = null; let shouldSaveDefaultPosition = false; if (savedProportion) { try { const proportion = JSON.parse(savedProportion); // Convert proportions to absolute pixel values for initial positioning if (proportion.w !== undefined && proportion.h !== undefined && proportion.x !== undefined && proportion.y !== undefined) { pos = { left: window.innerWidth * proportion.x, top: window.innerHeight * proportion.y, width: window.innerWidth * proportion.w, height: window.innerHeight * proportion.h }; DEBUG.debug('UI', 'Loaded saved popup proportion and converted to pixels', pos); } } catch { pos = null; DEBUG.warn('UI', 'Failed to parse saved popup proportion'); } } const popup = document.createElement("div"); popup.id = "lyrics-plus-popup"; function getSpotifyLyricsContainerRect() { const el = document.querySelector('.main-view-container'); if (!el || !el.getBoundingClientRect) { return null; } const rect = el.getBoundingClientRect(); return rect; } // Usage: if (pos && pos.left !== null && pos.top !== null && pos.width && pos.height) { Object.assign(popup.style, { position: "fixed", left: pos.left + "px", top: pos.top + "px", width: pos.width + "px", height: pos.height + "px", minWidth: "360px", minHeight: "240px", backgroundColor: "#121212", color: "white", borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.9)", fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", zIndex: 100000, display: "flex", flexDirection: "column", overflow: "hidden", padding: "0", userSelect: "none", right: "auto", bottom: "auto" }); } else { // fallback to container or default shouldSaveDefaultPosition = true; let rect = getSpotifyLyricsContainerRect(); if (rect) { Object.assign(popup.style, { position: "fixed", left: rect.left + "px", top: rect.top + "px", width: rect.width + "px", height: rect.height + "px", minWidth: "360px", minHeight: "240px", backgroundColor: "#121212", color: "white", borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.9)", fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", zIndex: 100000, display: "flex", flexDirection: "column", overflow: "hidden", padding: "0", userSelect: "none", right: "auto", bottom: "auto" }); } else { // fallback Object.assign(popup.style, { position: "fixed", bottom: "87px", right: "0px", left: "auto", top: "auto", width: "360px", height: "79.5vh", minWidth: "360px", minHeight: "240px", backgroundColor: "#121212", color: "white", borderRadius: "12px", boxShadow: "0 0 20px rgba(0, 0, 0, 0.9)", fontFamily: "'Segoe UI', Tahoma, Geneva, Verdana, sans-serif", zIndex: 100000, display: "flex", flexDirection: "column", overflow: "hidden", padding: "0", userSelect: "none", }); } } // Header with title and close button - drag handle const headerWrapper = document.createElement("div"); headerWrapper.id = "lyrics-plus-header-wrapper"; Object.assign(headerWrapper.style, { padding: "12px", borderBottom: "1px solid #333", backgroundColor: "#121212", zIndex: 10, cursor: "move", userSelect: "none", }); const header = document.createElement("div"); header.style.display = "flex"; header.style.justifyContent = "space-between"; header.style.alignItems = "center"; const title = document.createElement("h3"); title.textContent = "Lyrics+"; title.style.margin = "0"; title.style.fontWeight = "600"; title.style.color = "#cfcfcf"; // similar to github icon background color // Restore Default Position and Size button for the header const btnReset = document.createElement("button"); btnReset.title = "Restore Default Position and Size"; Object.assign(btnReset.style, { cursor: "pointer", background: "none", border: "none", borderRadius: "5px", width: "28px", height: "28px", color: "#fff", fontWeight: "bold", fontSize: "18px", display: "flex", justifyContent: "center", alignItems: "center", userSelect: "none" }); console.info("✅ [Lyrics+ UI] Restore default position button created"); btnReset.innerHTML = ` `; // Default Position and Size of the Popup Gui btnReset.onclick = () => { console.info("🔄 [Lyrics+ UI] Restore default position button clicked"); const rect = getSpotifyLyricsContainerRect(); if (rect) { Object.assign(popup.style, { position: "fixed", left: rect.left + "px", top: rect.top + "px", width: rect.width + "px", height: rect.height + "px", right: "auto", bottom: "auto", zIndex: 100000 }); savePopupState(popup); console.info("✅ [Lyrics+ UI] Position restored to Spotify lyrics container position"); } else { Object.assign(popup.style, { position: "fixed", bottom: "87px", right: "0px", left: "auto", top: "auto", width: "360px", height: "79.5vh", zIndex: 100000 }); savePopupState(popup); console.info("✅ [Lyrics+ UI] Position restored to default position (bottom-right corner)"); } }; // --- Translation controls dropdown, translate button, and remove translation button --- const translationControls = document.createElement('div'); translationControls.style.display = 'flex'; translationControls.style.alignItems = 'center'; translationControls.style.justifyContent = 'space-between'; translationControls.style.width = '100%'; translationControls.style.gap = '8px'; console.info("✅ [Lyrics+ UI] Translation controls container created"); const controlHeight = '28px'; const fontSize = '13px'; // Language selector (dropdown) const langSelect = document.createElement('select'); for (const [code, name] of Object.entries(TRANSLATION_LANGUAGES)) { const opt = document.createElement('option'); opt.value = code; opt.textContent = name; langSelect.appendChild(opt); } langSelect.value = getSavedTranslationLang(); langSelect.title = 'Select translation language'; langSelect.style.flex = '1'; langSelect.style.minWidth = '0'; langSelect.style.height = controlHeight; langSelect.style.background = '#333'; langSelect.style.color = '#e0e0e0'; langSelect.style.border = 'none'; langSelect.style.borderRadius = '5px'; langSelect.style.fontSize = fontSize; langSelect.style.fontWeight = '400'; langSelect.style.boxSizing = 'border-box'; console.info("✅ [Lyrics+ UI] Translation language dropdown created, current language:", getSavedTranslationLang()); langSelect.onchange = () => { saveTranslationLang(langSelect.value); console.info("📝 [Lyrics+ UI] Translation language changed to:", langSelect.value); removeTranslatedLyrics(); lastTranslatedLang = null; }; // Translate button const translateBtn = document.createElement('button'); translateBtn.textContent = 'Translate'; translateBtn.style.flex = '1'; translateBtn.style.minWidth = '0'; translateBtn.style.height = controlHeight; translateBtn.style.background = '#1aa34a'; translateBtn.style.color = '#e0e0e0'; translateBtn.style.border = 'none'; translateBtn.style.borderRadius = '5px'; translateBtn.style.fontSize = fontSize; translateBtn.style.fontWeight = '600'; translateBtn.style.cursor = 'pointer'; translateBtn.style.boxSizing = 'border-box'; console.info("✅ [Lyrics+ UI] Translate button created"); translateBtn.onclick = translateLyricsInPopup; const removeBtn = document.createElement('button'); removeBtn.textContent = 'Original'; // Remove Translation Button removeBtn.style.flex = '1'; removeBtn.style.minWidth = '0'; removeBtn.style.height = controlHeight; removeBtn.style.background = '#333'; removeBtn.style.color = '#e0e0e0'; removeBtn.style.border = 'none'; removeBtn.style.borderRadius = '5px'; removeBtn.style.fontSize = fontSize; removeBtn.style.fontWeight = '600'; removeBtn.style.cursor = 'pointer'; removeBtn.style.boxSizing = 'border-box'; console.info("✅ [Lyrics+ UI] Remove translation button ('Original') created"); removeBtn.onclick = () => { console.info("🌐 [Lyrics+ Translation] Remove translation button clicked - showing original lyrics"); removeTranslatedLyrics(); lastTranslatedLang = null; }; // Append controls in order: left, center, right translationControls.appendChild(langSelect); translationControls.appendChild(translateBtn); translationControls.appendChild(removeBtn); const closeBtn = document.createElement("button"); closeBtn.textContent = "×"; closeBtn.title = "Close Lyrics+"; Object.assign(closeBtn.style, { cursor: "pointer", background: "none", border: "none", color: "white", fontSize: "18px", fontWeight: "bold", lineHeight: "1", userSelect: "auto", height: "32px", display: "flex", alignItems: "center", justifyContent: "center", boxSizing: "border-box", }); closeBtn.onclick = () => { savePopupState(popup); removePopup(); stopPollingForTrackChange(); }; // --- Translation Toggle Button --- const translationToggleBtn = document.createElement("button"); translationToggleBtn.textContent = "🌐"; translationToggleBtn.title = "Show/hide translation controls"; Object.assign(translationToggleBtn.style, { cursor: "pointer", background: "none", border: "none", color: "white", fontSize: "16px", lineHeight: "1", }); // --- Transliteration Toggle Button --- const transliterationToggleBtn = document.createElement("button"); transliterationToggleBtn.textContent = "🔡"; transliterationToggleBtn.title = "Show transliteration"; Object.assign(transliterationToggleBtn.style, { cursor: "pointer", background: "none", border: "none", color: "white", fontSize: "16px", lineHeight: "1", display: "none", // Hidden by default, shown when transliteration data is available }); console.info("✅ [Lyrics+ UI] Transliteration button created (hidden by default, shows when transliteration data available)"); // --- Chinese Conversion Button (Traditional ⇄ Simplified) --- // Styled to match other header buttons const chineseConvBtn = document.createElement("button"); chineseConvBtn.id = "lyrics-plus-chinese-conv-btn"; chineseConvBtn.textContent = "繁→简"; // Default, will be updated based on detected script chineseConvBtn.title = "Convert Chinese script"; Object.assign(chineseConvBtn.style, { cursor: "pointer", background: "none", border: "none", color: "white", fontSize: "16px", lineHeight: "1", padding: "0 4px 0 0", // (top, right, bottom, left) /* Manually fixing the spacing between chineseConvBtn and btnReset: set to 4px on the right, so it no longer borders on translationToggleBtn Spacing left unchanged on the left - btnReset already spacing similar to chineseConvBtn's of 4px applied from before */ display: "none", // Hidden by default, shown when Chinese lyrics are present }); // Helper to update button text based on original script type and conversion state // For Traditional lyrics (繁): "繁→简" (convert) / "繁←简" (revert) // For Simplified lyrics (简): "简→繁" (convert) / "简←繁" (revert) function updateChineseConvBtnText() { const isConverted = isChineseConversionEnabled(); if (originalChineseScriptType === 'traditional') { chineseConvBtn.textContent = isConverted ? "简" : "繁"; chineseConvBtn.title = isConverted ? "Revert to Traditional Chinese" : "Convert to Simplified Chinese"; } else { // Simplified lyrics chineseConvBtn.textContent = isConverted ? "繁" : "简"; chineseConvBtn.title = isConverted ? "Revert to Simplified Chinese" : "Convert to Traditional Chinese"; } } popup._updateChineseConvBtnText = updateChineseConvBtnText; chineseConvBtn.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const newState = !isChineseConversionEnabled(); setChineseConversionEnabled(newState); // Update button text to show new conversion direction updateChineseConvBtnText(); // Re-render cached lyrics with new conversion setting (no provider reload) rerenderLyrics(popup); }; // Store reference on popup for access in updateLyricsContent popup._chineseConvBtn = chineseConvBtn; popup._transliterationToggleBtn = transliterationToggleBtn; // --- Download Synced Lyrics Button --- const downloadBtnWrapper = document.createElement("div"); downloadBtnWrapper.style.position = "relative"; // For dropdown positioning const downloadBtn = document.createElement("button"); downloadBtn.title = "Download lyrics"; Object.assign(downloadBtn.style, { background: "none", color: "#fff", border: "none", borderRadius: "5px", cursor: "pointer", width: "28px", height: "28px", display: "none", alignItems: "center", justifyContent: "center", transition: "none", position: "relative" }); downloadBtn.innerHTML = ` `; // Dropdown menu for download types const downloadDropdown = document.createElement("div"); downloadDropdown.id = "lyrics-plus-download-dropdown"; downloadBtn._dropdown = downloadDropdown; Object.assign(downloadDropdown.style, { position: "absolute", top: "110%", left: "0", minWidth: "90px", backgroundColor: "#121212", border: "1px solid #444", borderRadius: "8px", boxShadow: "0 2px 12px #0009", zIndex: 99999, display: "none", flexDirection: "column", padding: "4px 4px" }); downloadDropdown.tabIndex = -1; const syncOption = document.createElement("button"); syncOption.id = "lyrics-plus-download-sync"; syncOption.textContent = "Synced"; Object.assign(syncOption.style, { background: "#121212", color: "#fff", border: "none", padding: "8px 10px", cursor: "pointer", textAlign: "left", fontSize: "14px", borderRadius: "5px" }); syncOption.onmouseenter = () => { syncOption.style.background = "#333"; syncOption.style.color = "#fff"; }; syncOption.onmouseleave = () => { syncOption.style.background = "#121212"; syncOption.style.color = "#fff"; }; const unsyncOption = document.createElement("button"); unsyncOption.id = "lyrics-plus-download-unsync"; unsyncOption.textContent = "Unsynced"; Object.assign(unsyncOption.style, { background: "#121212", color: "#fff", border: "none", padding: "8px 10px", cursor: "pointer", textAlign: "left", fontSize: "14px", borderRadius: "5px" }); unsyncOption.onmouseenter = () => { unsyncOption.style.background = "#333"; unsyncOption.style.color = "#fff"; }; unsyncOption.onmouseleave = () => { unsyncOption.style.background = "#121212"; unsyncOption.style.color = "#fff"; }; downloadDropdown.appendChild(syncOption); downloadDropdown.appendChild(unsyncOption); downloadBtnWrapper.appendChild(downloadBtn); downloadBtnWrapper.appendChild(downloadDropdown); console.info("✅ [Lyrics+ UI] Download button created and added to DOM"); // Logic for showing/hiding the dropdown and downloading let currentHideHandler = null; const removeHideHandler = () => { if (currentHideHandler) { document.removeEventListener("mousedown", currentHideHandler, { capture: true }); document.removeEventListener("contextmenu", currentHideHandler, { capture: true }); currentHideHandler = null; } }; downloadBtn.onclick = (e) => { // Always show dropdown if at least one download option is available let hasSynced = !!currentSyncedLyrics; let hasUnsynced = !!currentUnsyncedLyrics; // Show/hide options syncOption.style.display = hasSynced ? "" : "none"; unsyncOption.style.display = hasUnsynced ? "" : "none"; if (hasSynced || hasUnsynced) { if (downloadDropdown.style.display === "flex") { downloadDropdown.style.display = "none"; removeHideHandler(); return; } downloadDropdown.style.display = "flex"; setTimeout(() => { removeHideHandler(); const hide = (ev) => { if (!downloadDropdown.contains(ev.target) && !downloadBtn.contains(ev.target)) { downloadDropdown.style.display = "none"; removeHideHandler(); } }; currentHideHandler = hide; document.addEventListener("mousedown", hide, { capture: true }); document.addEventListener("contextmenu", hide, { capture: true }); }, 1); } else { // Fallback: try to extract from DOM as plain const popup = document.getElementById("lyrics-plus-popup"); if (!popup) return; const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (!lyricsContainer) return; const lines = Array.from(lyricsContainer.querySelectorAll('p')).map(p => ({ text: p.textContent })); if (lines.length) downloadUnsyncedLyrics(lines, getCurrentTrackInfo(), Providers.current); } }; // Set up dropdown options syncOption.onclick = (e) => { downloadDropdown.style.display = "none"; console.info("💾 [Lyrics+ UI] Download synced lyrics clicked"); if (currentSyncedLyrics) downloadSyncedLyrics(currentSyncedLyrics, getCurrentTrackInfo(), Providers.current); }; unsyncOption.onclick = (e) => { downloadDropdown.style.display = "none"; console.info("💾 [Lyrics+ UI] Download unsynced lyrics clicked"); if (currentUnsyncedLyrics) downloadUnsyncedLyrics(currentUnsyncedLyrics, getCurrentTrackInfo(), Providers.current); }; // --- Font Size Selector --- const fontSizeSelect = document.createElement("select"); fontSizeSelect.id = "lyrics-plus-font-size-select"; fontSizeSelect.title = "Change lyrics font size"; fontSizeSelect.style.cursor = "pointer"; fontSizeSelect.style.background = "#121212"; fontSizeSelect.style.border = "none"; fontSizeSelect.style.color = "white"; fontSizeSelect.style.fontSize = "14px"; fontSizeSelect.style.lineHeight = "1"; ["16", "22", "28", "32", "38", "44", "50", "56"].forEach(size => { const opt = document.createElement("option"); opt.value = size; opt.textContent = size + "px"; fontSizeSelect.appendChild(opt); }); fontSizeSelect.value = localStorage.getItem("lyricsPlusFontSize") || "22"; console.info("✅ [Lyrics+ UI] Font size selector created with options: 16-56px, current value:", fontSizeSelect.value + "px"); fontSizeSelect.onchange = () => { localStorage.setItem("lyricsPlusFontSize", fontSizeSelect.value); console.info("📝 [Lyrics+ UI] Font size changed to:", fontSizeSelect.value + "px"); const lyricsContent = document.getElementById("lyrics-plus-content"); if (lyricsContent) { lyricsContent.style.fontSize = fontSizeSelect.value + "px"; } }; // Toggle offset section const offsetToggleBtn = document.createElement("button"); offsetToggleBtn.textContent = "⚙️"; offsetToggleBtn.title = "Show/hide timing offset"; offsetToggleBtn.style.cursor = "pointer"; offsetToggleBtn.style.background = "none"; offsetToggleBtn.style.border = "none"; offsetToggleBtn.style.color = "white"; offsetToggleBtn.style.fontSize = "16px"; offsetToggleBtn.style.lineHeight = "1"; const titleBar = document.createElement("div"); titleBar.style.display = "flex"; titleBar.style.alignItems = "center"; titleBar.appendChild(title); // GitHub profile icon const ghIcon = document.createElement('div'); Object.assign(ghIcon.style, { display: 'flex', alignItems: 'center', paddingLeft: '6px', fontSize: '14px' }); ghIcon.innerHTML = ``; titleBar.appendChild(ghIcon); header.appendChild(titleBar); // Button group right side const buttonGroup = document.createElement("div"); buttonGroup.style.display = "flex"; buttonGroup.style.alignItems = "center"; buttonGroup.style.gap = "4px"; buttonGroup.appendChild(downloadBtnWrapper); buttonGroup.appendChild(fontSizeSelect); buttonGroup.appendChild(btnReset); buttonGroup.appendChild(chineseConvBtn); buttonGroup.appendChild(translationToggleBtn); buttonGroup.appendChild(transliterationToggleBtn); buttonGroup.appendChild(offsetToggleBtn); // PiP toggle button const pipToggleBtn = document.createElement("button"); pipToggleBtn.id = "lyrics-plus-pip-btn"; pipToggleBtn.title = "Toggle Picture-in-Picture"; pipToggleBtn.innerHTML = ``; Object.assign(pipToggleBtn.style, { background: 'none', border: 'none', cursor: 'pointer', color: 'white', padding: '4px', borderRadius: '4px', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', lineHeight: '1', }); pipToggleBtn.addEventListener('mouseenter', () => { if (!isPipActive) pipToggleBtn.style.color = 'rgba(255,255,255,0.7)'; }); pipToggleBtn.addEventListener('mouseleave', () => { if (!isPipActive) pipToggleBtn.style.color = 'white'; }); pipToggleBtn.onclick = togglePip; buttonGroup.appendChild(pipToggleBtn); buttonGroup.appendChild(closeBtn); header.appendChild(buttonGroup); headerWrapper.appendChild(header); // Tabs container const tabs = document.createElement("div"); tabs.style.display = "flex"; tabs.style.marginTop = "12px"; tabs.style.gap = "8px"; // --- PATCH: Separate single-click and double-click handlers for provider tabs --- let providerClickTimer = null; Providers.list.forEach(name => { const btn = document.createElement("button"); btn.textContent = name; btn.style.flex = "1"; btn.style.minWidth = "0"; btn.style.padding = "6px"; btn.style.borderRadius = "6px"; btn.style.border = "none"; btn.style.cursor = "pointer"; btn.style.backgroundColor = (Providers.current === name) ? "#1aa34a" : "#333"; btn.style.color = "#e0e0e0"; btn.style.fontWeight = "600"; btn.style.overflow = "hidden"; btn.style.textOverflow = "ellipsis"; btn.style.whiteSpace = "nowrap"; btn.style.boxSizing = "border-box"; btn.onclick = async (e) => { if (providerClickTimer) return; // already waiting for double-click, skip providerClickTimer = setTimeout(async () => { // Abort any ongoing autofetch by invalidating the current search ID // This prevents the autofetch loop from continuing when user manually selects a provider currentSearchId = null; console.log(`🛑 [Lyrics+] User manually selected ${name} provider - aborting any ongoing autofetch`); Providers.setCurrent(name); updateTabs(tabs); await updateLyricsContent(popup, getCurrentTrackInfo()); providerClickTimer = null; }, 250); }; btn.ondblclick = (e) => { e.preventDefault(); if (providerClickTimer) { clearTimeout(providerClickTimer); providerClickTimer = null; } // Double-click (desktop/mobile) for Musixmatch settings if (name === "Musixmatch") { showMusixmatchTokenModal(); } // Double-click (desktop/mobile) for Spotify settings if (name === "Spotify") { showSpotifyTokenModal(); } }; tabs.appendChild(btn); }); headerWrapper.appendChild(tabs); popup._lyricsTabs = tabs; // Lyrics container const lyricsContainer = document.createElement("div"); lyricsContainer.id = "lyrics-plus-content"; Object.assign(lyricsContainer.style, { flex: "1", overflowY: "auto", overflowX: "hidden", padding: "12px", whiteSpace: "pre-wrap", fontSize: "22px", lineHeight: "1.5", backgroundColor: "#121212", userSelect: "text", textAlign: "center", }); lyricsContainer.style.fontSize = (localStorage.getItem("lyricsPlusFontSize") || "22") + "px"; // Add horizontal padding to ensure lyrics never overflow lyricsContainer.style.paddingLeft = "10.0%"; lyricsContainer.style.paddingRight = "10.0%"; async function translateLinesBatch(lines, targetLang) { if (!lines.length) return []; // Build the URL with multiple q= parameters (the right way!) const baseUrl = "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=" + targetLang + "&dt=t"; const url = baseUrl + lines.map(line => "&q=" + encodeURIComponent(line)).join(''); try { const response = await fetch(url); const data = await response.json(); // data[0] is an array of arrays: [[translated, original, ...], ...] return data[0].map(item => item[0]); } catch (error) { console.error('Batch translation failed:', error); return lines.map(_ => '[Translation Error]'); } } function removeTranslatedLyrics() { const translatedEls = lyricsContainer.querySelectorAll('[data-translated="true"]'); translatedEls.forEach(el => el.remove()); translationPresent = false; lastTranslatedLang = null; } async function translateLyricsInPopup() { if (!lyricsContainer || isTranslating) return; const targetLang = getSavedTranslationLang(); console.info("🌐 [Lyrics+ Translation] Translate button clicked, target language:", targetLang); if (translationPresent && lastTranslatedLang === targetLang) return; isTranslating = true; translateBtn.disabled = true; try { removeTranslatedLyrics(); const pEls = Array.from(lyricsContainer.querySelectorAll('p')); const linesToTranslate = pEls.filter(el => el.textContent.trim() && el.textContent.trim() !== "♪"); await Promise.all(linesToTranslate.map(async (p) => { const originalText = p.textContent.trim(); const translatedText = await translateText(originalText, targetLang); const translationDiv = document.createElement('div'); translationDiv.textContent = translatedText; translationDiv.style.color = 'gray'; translationDiv.setAttribute('data-translated', 'true'); // Find correct insertion point: after transliteration if it exists, otherwise after lyric let insertionPoint = p.nextSibling; // Check if next sibling is a transliteration div if (insertionPoint && insertionPoint.nodeType === 1 && insertionPoint.getAttribute('data-transliteration') === 'true') { // Transliteration exists - insert translation AFTER it insertionPoint = insertionPoint.nextSibling; } p.parentNode.insertBefore(translationDiv, insertionPoint); })); lastTranslatedLang = targetLang; translationPresent = true; } finally { translateBtn.disabled = false; isTranslating = false; } } function removeTransliterationLyrics() { const transliterationEls = lyricsContainer.querySelectorAll('[data-transliteration="true"]'); transliterationEls.forEach(el => el.remove()); transliterationPresent = false; } function showTransliterationInPopup() { if (!lyricsContainer || transliterationPresent) return; const pEls = Array.from(lyricsContainer.querySelectorAll('p[data-transliteration-text]')); pEls.forEach((p) => { const transliterationText = p.getAttribute('data-transliteration-text'); const transliterationDiv = document.createElement('div'); transliterationDiv.textContent = transliterationText; // Use #9a9a9a (lighter gray than translation) for better distinction transliterationDiv.style.color = '#9a9a9a'; transliterationDiv.style.fontSize = '0.85em'; // Slightly smaller transliterationDiv.style.marginTop = '2px'; transliterationDiv.style.marginBottom = '8px'; transliterationDiv.style.transition = "color 0.15s, filter 0.13s, opacity 0.13s"; transliterationDiv.setAttribute('data-transliteration', 'true'); // Always insert transliteration immediately after lyric line // If translation exists, insert before it; otherwise after lyric let insertionPoint = p.nextSibling; // Check if the next sibling is a translation div if (insertionPoint && insertionPoint.nodeType === 1 && insertionPoint.getAttribute('data-translated') === 'true') { // Translation exists - insert transliteration before it p.parentNode.insertBefore(transliterationDiv, insertionPoint); } else { // No translation or next sibling is something else - insert after lyric p.parentNode.insertBefore(transliterationDiv, insertionPoint); } }); transliterationPresent = true; } // Translator Controls Container const translatorWrapper = document.createElement("div"); translatorWrapper.id = "lyrics-plus-translator-wrapper"; translatorWrapper.style.display = "block"; translatorWrapper.style.background = "#121212"; translatorWrapper.style.borderBottom = "none"; // Will be set to "1px solid #333" if visible translatorWrapper.style.padding = "8px 12px"; translatorWrapper.style.transition = "max-height 0.3s, padding 0.3s"; translatorWrapper.style.overflow = "hidden"; translatorWrapper.style.maxHeight = "0"; translatorWrapper.style.pointerEvents = "none"; let translatorVisible = localStorage.getItem('lyricsPlusTranslatorVisible'); if (translatorVisible === null) translatorVisible = false; else translatorVisible = JSON.parse(translatorVisible); if (translatorVisible) { translatorWrapper.style.maxHeight = "100px"; translatorWrapper.style.pointerEvents = ""; translatorWrapper.style.padding = "8px 12px"; translatorWrapper.style.borderBottom = "1px solid #333"; translationToggleBtn.title = "Hide translation controls"; } else { translatorWrapper.style.maxHeight = "0"; translatorWrapper.style.pointerEvents = "none"; translatorWrapper.style.padding = "0 12px"; translatorWrapper.style.borderBottom = "none"; translationToggleBtn.title = "Show translation controls"; } translatorWrapper.appendChild(translationControls); translationToggleBtn.onclick = () => { translatorVisible = !translatorVisible; localStorage.setItem('lyricsPlusTranslatorVisible', JSON.stringify(translatorVisible)); if (translatorVisible) { translatorWrapper.style.maxHeight = "100px"; translatorWrapper.style.pointerEvents = ""; translatorWrapper.style.padding = "8px 12px"; translatorWrapper.style.borderBottom = "1px solid #333"; translationToggleBtn.title = "Hide translation controls"; } else { translatorWrapper.style.maxHeight = "0"; translatorWrapper.style.pointerEvents = "none"; translatorWrapper.style.padding = "0 12px"; translatorWrapper.style.borderBottom = "none"; translationToggleBtn.title = "Show translation controls"; } }; transliterationToggleBtn.onclick = () => { if (transliterationPresent) { removeTransliterationLyrics(); localStorage.setItem(STORAGE_KEYS.TRANSLITERATION_ENABLED, 'false'); transliterationToggleBtn.title = "Show transliteration"; console.info("🔤 [Lyrics+ UI] Transliteration button clicked: HIDDEN"); } else { showTransliterationInPopup(); localStorage.setItem(STORAGE_KEYS.TRANSLITERATION_ENABLED, 'true'); transliterationToggleBtn.title = "Hide transliteration"; console.info("🔤 [Lyrics+ UI] Transliteration button clicked: SHOWN"); } }; // Offset Settings UI const offsetWrapper = document.createElement("div"); offsetWrapper.style.display = "flex"; offsetWrapper.style.alignItems = "center"; offsetWrapper.style.justifyContent = "space-between"; offsetWrapper.style.padding = "8px 12px"; offsetWrapper.style.background = "#121212"; offsetWrapper.style.borderBottom = "none"; // Will be set by applyOffsetVisibility offsetWrapper.style.fontSize = "15px"; offsetWrapper.style.width = "100%"; const offsetLabel = document.createElement("div"); offsetLabel.innerHTML = `Adjust lyrics timing (ms):
lower = appear later, higher = appear earlier`; offsetLabel.style.color = "#fff"; // Compact input+spinner container const inputStack = document.createElement("div"); inputStack.style.position = "relative"; inputStack.style.display = "inline-block"; inputStack.style.marginLeft = "16px"; inputStack.style.height = "28px"; inputStack.style.width = "68px"; // The input itself - compact! const offsetInput = document.createElement("input"); offsetInput.type = "number"; offsetInput.min = "-5000"; offsetInput.max = "5000"; offsetInput.step = "50"; offsetInput.value = getAnticipationOffset(); offsetInput.style.width = "68px"; offsetInput.style.height = "28px"; offsetInput.style.background = "#222"; offsetInput.style.color = "#fff"; offsetInput.style.border = "1px solid #444"; offsetInput.style.borderRadius = "6px"; offsetInput.style.padding = "2px 24px 2px 6px"; offsetInput.style.boxSizing = "border-box"; offsetInput.style.fontSize = "14px"; offsetInput.style.MozAppearance = "textfield"; offsetInput.style.appearance = "textfield"; // Spinner container const spinnerContainer = document.createElement("div"); spinnerContainer.style.position = "absolute"; spinnerContainer.style.right = "0"; spinnerContainer.style.top = "0"; spinnerContainer.style.height = "28px"; spinnerContainer.style.width = "24px"; spinnerContainer.style.display = "flex"; spinnerContainer.style.flexDirection = "column"; spinnerContainer.style.justifyContent = "center"; spinnerContainer.style.zIndex = "2"; const iconFill = "rgba(255, 255, 255, 0.85)"; // Up button const upBtn = document.createElement("button"); upBtn.innerHTML = ` `; upBtn.style.background = "#333"; upBtn.style.border = "none"; upBtn.style.borderRadius = "2px 2px 0 0"; upBtn.style.width = "24px"; upBtn.style.height = "14px"; upBtn.style.cursor = "pointer"; upBtn.style.padding = "0"; upBtn.tabIndex = -1; upBtn.onmouseover = () => upBtn.style.background = "#444"; upBtn.onmouseout = () => upBtn.style.background = "#333"; // Down button const downBtn = document.createElement("button"); downBtn.innerHTML = ` `; downBtn.style.background = "#333"; downBtn.style.border = "none"; downBtn.style.borderRadius = "0 0 2px 2px"; downBtn.style.width = "24px"; downBtn.style.height = "14px"; downBtn.style.cursor = "pointer"; downBtn.style.padding = "0"; downBtn.tabIndex = -1; downBtn.onmouseover = () => downBtn.style.background = "#444"; downBtn.onmouseout = () => downBtn.style.background = "#333"; // Shared value update function function saveAndApplyOffset() { let val = parseInt(offsetInput.value, 10) || 0; if (val > 5000) val = 5000; if (val < -5000) val = -5000; offsetInput.value = val; setAnticipationOffset(val); if (currentSyncedLyrics && currentLyricsContainer) { highlightSyncedLyrics(currentSyncedLyrics, currentLyricsContainer); } } upBtn.onclick = (e) => { e.preventDefault(); let val = parseInt(offsetInput.value, 10) || 0; val += 50; if (val > 5000) val = 5000; offsetInput.value = val; saveAndApplyOffset(); }; downBtn.onclick = (e) => { e.preventDefault(); let val = parseInt(offsetInput.value, 10) || 0; val -= 50; if (val < -5000) val = -5000; offsetInput.value = val; saveAndApplyOffset(); }; offsetInput.addEventListener("change", saveAndApplyOffset); offsetInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { saveAndApplyOffset(); offsetInput.blur(); } }); spinnerContainer.appendChild(upBtn); spinnerContainer.appendChild(downBtn); inputStack.appendChild(offsetInput); inputStack.appendChild(spinnerContainer); offsetWrapper.appendChild(offsetLabel); offsetWrapper.appendChild(inputStack); // Add tabs visibility toggle as a separate settings row const tabsToggleWrapper = document.createElement("div"); tabsToggleWrapper.id = "lyrics-plus-tabs-toggle-wrapper"; tabsToggleWrapper.style.display = "flex"; tabsToggleWrapper.style.alignItems = "center"; tabsToggleWrapper.style.justifyContent = "space-between"; tabsToggleWrapper.style.padding = "8px 12px"; tabsToggleWrapper.style.background = "#121212"; tabsToggleWrapper.style.borderBottom = "none"; // Will be set by applyOffsetVisibility tabsToggleWrapper.style.transition = "max-height 0.3s, padding 0.3s"; tabsToggleWrapper.style.overflow = "hidden"; const tabsToggleLabel = document.createElement("div"); tabsToggleLabel.textContent = "Show lyrics source tabs"; tabsToggleLabel.style.color = "#fff"; tabsToggleLabel.style.fontSize = "15px"; const tabsToggleCheckbox = document.createElement("input"); tabsToggleCheckbox.type = "checkbox"; tabsToggleCheckbox.id = "lyrics-plus-tabs-toggle"; tabsToggleCheckbox.className = "lyrics-plus-checkbox"; tabsToggleCheckbox.style.cursor = "pointer"; console.info("✅ [Lyrics+ Settings] Tabs toggle created (Show lyrics source tabs)"); tabsToggleWrapper.appendChild(tabsToggleLabel); tabsToggleWrapper.appendChild(tabsToggleCheckbox); // Add seekbar visibility toggle as a separate settings row const seekbarToggleWrapper = document.createElement("div"); seekbarToggleWrapper.id = "lyrics-plus-seekbar-toggle-wrapper"; seekbarToggleWrapper.style.display = "flex"; seekbarToggleWrapper.style.alignItems = "center"; seekbarToggleWrapper.style.justifyContent = "space-between"; seekbarToggleWrapper.style.padding = "8px 12px"; seekbarToggleWrapper.style.background = "#121212"; seekbarToggleWrapper.style.borderBottom = "none"; // Will be set by applyOffsetVisibility seekbarToggleWrapper.style.transition = "max-height 0.3s, padding 0.3s"; seekbarToggleWrapper.style.overflow = "hidden"; const seekbarToggleLabel = document.createElement("div"); seekbarToggleLabel.textContent = "Show seekbar"; seekbarToggleLabel.style.color = "#fff"; seekbarToggleLabel.style.fontSize = "15px"; const seekbarToggleCheckbox = document.createElement("input"); seekbarToggleCheckbox.type = "checkbox"; seekbarToggleCheckbox.id = "lyrics-plus-seekbar-toggle-settings"; seekbarToggleCheckbox.className = "lyrics-plus-checkbox"; seekbarToggleCheckbox.style.cursor = "pointer"; console.info("✅ [Lyrics+ Settings] Seekbar toggle created (Show seekbar)"); seekbarToggleWrapper.appendChild(seekbarToggleLabel); seekbarToggleWrapper.appendChild(seekbarToggleCheckbox); // Add playback controls visibility toggle as a separate settings row const controlsToggleWrapper = document.createElement("div"); controlsToggleWrapper.id = "lyrics-plus-controls-toggle-wrapper"; controlsToggleWrapper.style.display = "flex"; controlsToggleWrapper.style.alignItems = "center"; controlsToggleWrapper.style.justifyContent = "space-between"; controlsToggleWrapper.style.padding = "8px 12px"; controlsToggleWrapper.style.background = "#121212"; controlsToggleWrapper.style.borderBottom = "none"; // Will be set by applyOffsetVisibility controlsToggleWrapper.style.transition = "max-height 0.3s, padding 0.3s"; controlsToggleWrapper.style.overflow = "hidden"; const controlsToggleLabel = document.createElement("div"); controlsToggleLabel.textContent = "Show playback controls"; controlsToggleLabel.style.color = "#fff"; controlsToggleLabel.style.fontSize = "15px"; const controlsToggleCheckbox = document.createElement("input"); controlsToggleCheckbox.type = "checkbox"; controlsToggleCheckbox.id = "lyrics-plus-controls-toggle-settings"; controlsToggleCheckbox.className = "lyrics-plus-checkbox"; controlsToggleCheckbox.style.cursor = "pointer"; console.info("✅ [Lyrics+ Settings] Playback controls toggle created (Show playback controls)"); controlsToggleWrapper.appendChild(controlsToggleLabel); controlsToggleWrapper.appendChild(controlsToggleCheckbox); // Add AMOLED theme toggle as a separate settings row const themeToggleWrapper = document.createElement("div"); themeToggleWrapper.id = "lyrics-plus-theme-toggle-wrapper"; themeToggleWrapper.style.display = "flex"; themeToggleWrapper.style.alignItems = "center"; themeToggleWrapper.style.justifyContent = "space-between"; themeToggleWrapper.style.padding = "8px 12px"; themeToggleWrapper.style.background = "#121212"; themeToggleWrapper.style.borderBottom = "none"; // Will be set by applyOffsetVisibility themeToggleWrapper.style.transition = "max-height 0.3s, padding 0.3s"; themeToggleWrapper.style.overflow = "hidden"; const themeToggleLabel = document.createElement("div"); themeToggleLabel.textContent = "Enable AMOLED theme"; themeToggleLabel.style.color = "#fff"; themeToggleLabel.style.fontSize = "15px"; const themeToggleCheckbox = document.createElement("input"); themeToggleCheckbox.type = "checkbox"; themeToggleCheckbox.id = "lyrics-plus-theme-toggle-settings"; themeToggleCheckbox.className = "lyrics-plus-checkbox"; themeToggleCheckbox.style.cursor = "pointer"; console.info("✅ [Lyrics+ Settings] Theme toggle created (Enable AMOLED theme)"); themeToggleWrapper.appendChild(themeToggleLabel); themeToggleWrapper.appendChild(themeToggleCheckbox); // Playback Controls Bar const controlsBar = document.createElement("div"); Object.assign(controlsBar.style, { display: "flex", justifyContent: "center", alignItems: "center", gap: "8px", padding: "8px 12px", borderTop: "1px solid #333", backgroundColor: "#121212", userSelect: "none", }); offsetWrapper.id = "lyrics-plus-offset-wrapper"; controlsBar.id = "lyrics-plus-controls-bar"; offsetWrapper.style.transition = "max-height 0.3s, padding 0.3s"; offsetWrapper.style.overflow = "hidden"; controlsBar.style.transition = "max-height 0.3s"; controlsBar.style.overflow = "hidden"; let offsetVisible = localStorage.getItem('lyricsPlusOffsetVisible'); if (offsetVisible === null) offsetVisible = true; else offsetVisible = JSON.parse(offsetVisible); let controlsVisible = localStorage.getItem('lyricsPlusControlsVisible'); if (controlsVisible === null) controlsVisible = true; else controlsVisible = JSON.parse(controlsVisible); let seekbarVisible = localStorage.getItem('lyricsPlusSeekbarVisible'); if (seekbarVisible === null) seekbarVisible = true; else seekbarVisible = JSON.parse(seekbarVisible); let tabsVisible = localStorage.getItem('lyricsPlusTabsVisible'); if (tabsVisible === null) tabsVisible = true; else tabsVisible = JSON.parse(tabsVisible); let amoledThemeEnabled = localStorage.getItem('lyricsPlusTheme'); if (amoledThemeEnabled === null) amoledThemeEnabled = false; else amoledThemeEnabled = JSON.parse(amoledThemeEnabled); // Theme color constants const THEME_COLOR_DEFAULT = "#121212"; const THEME_COLOR_AMOLED = "#000"; const THEME_HOVER_DEFAULT = "#333"; const THEME_HOVER_AMOLED = "#1a1a1a"; const OFFSET_WRAPPER_PADDING = "8px 12px"; // Helper functions to apply visibility states (reduces duplication) function applyTabsVisibility(visible) { if (visible) { tabs.style.display = "flex"; tabs.style.marginTop = "12px"; } else { tabs.style.display = "none"; tabs.style.marginTop = "0"; } } function applyControlsVisibility(visible) { if (visible) { controlsBar.style.maxHeight = "80px"; controlsBar.style.opacity = "1"; controlsBar.style.pointerEvents = ""; } else { controlsBar.style.maxHeight = "0"; controlsBar.style.opacity = "0"; controlsBar.style.pointerEvents = "none"; } } function applyProgressWrapperVisibility(visible) { // Note: This function should only be called after progressWrapper is created if (!progressWrapper) return; if (visible) { progressWrapper.style.maxHeight = "50px"; progressWrapper.style.padding = "8px 12px"; progressWrapper.style.opacity = "1"; progressWrapper.style.pointerEvents = ""; } else { progressWrapper.style.maxHeight = "0"; progressWrapper.style.padding = "0 12px"; progressWrapper.style.opacity = "0"; progressWrapper.style.pointerEvents = "none"; } } function applyOffsetVisibility(visible) { if (visible) { offsetWrapper.style.maxHeight = "200px"; offsetWrapper.style.pointerEvents = ""; offsetWrapper.style.padding = "8px 12px"; offsetWrapper.style.borderBottom = "1px solid #333"; tabsToggleWrapper.style.maxHeight = "50px"; tabsToggleWrapper.style.pointerEvents = ""; tabsToggleWrapper.style.padding = "8px 12px"; tabsToggleWrapper.style.borderBottom = "1px solid #333"; seekbarToggleWrapper.style.maxHeight = "50px"; seekbarToggleWrapper.style.pointerEvents = ""; seekbarToggleWrapper.style.padding = "8px 12px"; seekbarToggleWrapper.style.borderBottom = "1px solid #333"; controlsToggleWrapper.style.maxHeight = "50px"; controlsToggleWrapper.style.pointerEvents = ""; controlsToggleWrapper.style.padding = "8px 12px"; controlsToggleWrapper.style.borderBottom = "1px solid #333"; themeToggleWrapper.style.maxHeight = "50px"; themeToggleWrapper.style.pointerEvents = ""; themeToggleWrapper.style.padding = "8px 12px"; themeToggleWrapper.style.borderBottom = "1px solid #333"; } else { offsetWrapper.style.maxHeight = "0"; offsetWrapper.style.pointerEvents = "none"; offsetWrapper.style.padding = "0 12px"; offsetWrapper.style.borderBottom = "none"; tabsToggleWrapper.style.maxHeight = "0"; tabsToggleWrapper.style.pointerEvents = "none"; tabsToggleWrapper.style.padding = "0 12px"; tabsToggleWrapper.style.borderBottom = "none"; seekbarToggleWrapper.style.maxHeight = "0"; seekbarToggleWrapper.style.pointerEvents = "none"; seekbarToggleWrapper.style.padding = "0 12px"; seekbarToggleWrapper.style.borderBottom = "none"; controlsToggleWrapper.style.maxHeight = "0"; controlsToggleWrapper.style.pointerEvents = "none"; controlsToggleWrapper.style.padding = "0 12px"; controlsToggleWrapper.style.borderBottom = "none"; themeToggleWrapper.style.maxHeight = "0"; themeToggleWrapper.style.pointerEvents = "none"; themeToggleWrapper.style.padding = "0 12px"; themeToggleWrapper.style.borderBottom = "none"; } } function applyAmoledTheme(enabled) { // Apply theme by toggling a CSS class on body - much more efficient! if (enabled) { document.body.classList.add('lyrics-plus-amoled-theme'); } else { document.body.classList.remove('lyrics-plus-amoled-theme'); } } offsetToggleBtn.onclick = () => { offsetVisible = !offsetVisible; localStorage.setItem('lyricsPlusOffsetVisible', JSON.stringify(offsetVisible)); applyOffsetVisibility(offsetVisible); offsetToggleBtn.title = offsetVisible ? "Hide timing offset" : "Show timing offset"; }; // Seekbar checkbox change handler (in settings) seekbarToggleCheckbox.onchange = () => { seekbarVisible = seekbarToggleCheckbox.checked; localStorage.setItem('lyricsPlusSeekbarVisible', JSON.stringify(seekbarVisible)); applyProgressWrapperVisibility(seekbarVisible); console.info("📝 [Lyrics+ Settings] Seekbar visibility toggled:", seekbarVisible ? "SHOWN" : "HIDDEN"); }; // Playback controls checkbox change handler (in settings) controlsToggleCheckbox.onchange = () => { controlsVisible = controlsToggleCheckbox.checked; localStorage.setItem('lyricsPlusControlsVisible', JSON.stringify(controlsVisible)); applyControlsVisibility(controlsVisible); console.info("📝 [Lyrics+ Settings] Playback controls visibility toggled:", controlsVisible ? "SHOWN" : "HIDDEN"); }; // Theme toggle checkbox change handler (in settings) themeToggleCheckbox.onchange = () => { amoledThemeEnabled = themeToggleCheckbox.checked; localStorage.setItem('lyricsPlusTheme', JSON.stringify(amoledThemeEnabled)); applyAmoledTheme(amoledThemeEnabled); console.info("📝 [Lyrics+ Settings] AMOLED theme toggled:", amoledThemeEnabled ? "ENABLED" : "DISABLED"); }; // Apply initial visibility states applyOffsetVisibility(offsetVisible); applyControlsVisibility(controlsVisible); applyTabsVisibility(tabsVisible); applyAmoledTheme(amoledThemeEnabled); // Set initial button titles based on visibility states offsetToggleBtn.title = offsetVisible ? "Hide timing offset" : "Show timing offset"; // Initialize checkboxes state seekbarToggleCheckbox.checked = seekbarVisible; controlsToggleCheckbox.checked = controlsVisible; themeToggleCheckbox.checked = amoledThemeEnabled; console.info("📝 [Lyrics+ Settings] Seekbar initial state:", seekbarVisible ? "SHOWN" : "HIDDEN"); console.info("📝 [Lyrics+ Settings] Playback controls initial state:", controlsVisible ? "SHOWN" : "HIDDEN"); console.info("📝 [Lyrics+ Settings] AMOLED theme initial state:", amoledThemeEnabled ? "ENABLED" : "DISABLED"); // Initialize and handle tabs toggle checkbox in settings tabsToggleCheckbox.checked = tabsVisible; console.info("📝 [Lyrics+ Settings] Tabs initial state:", tabsVisible ? "SHOWN" : "HIDDEN"); tabsToggleCheckbox.onchange = () => { tabsVisible = tabsToggleCheckbox.checked; localStorage.setItem('lyricsPlusTabsVisible', JSON.stringify(tabsVisible)); applyTabsVisibility(tabsVisible); console.info("📝 [Lyrics+ Settings] Tabs visibility toggled:", tabsVisible ? "SHOWN" : "HIDDEN"); }; // Create Spotify-style control buttons function createSpotifyControlButton(type, ariaLabel, onClick) { const button = document.createElement("button"); button.setAttribute("aria-label", ariaLabel); button.setAttribute("data-encore-id", "buttonTertiary"); button.setAttribute("tabindex", "0"); // Base button styling to match Spotify Object.assign(button.style, { display: "inline-flex", alignItems: "center", justifyContent: "center", position: "relative", border: "none", borderRadius: "50%", cursor: "pointer", textDecoration: "none", color: "rgba(255, 255, 255, 0.7)", backgroundColor: "transparent", minWidth: "32px", height: "32px", padding: "8px", fontSize: "16px", fontWeight: "400", transition: "all 0.2s ease", userSelect: "none", outline: "none" }); // Icon wrapper const iconWrapper = document.createElement("span"); iconWrapper.setAttribute("aria-hidden", "true"); Object.assign(iconWrapper.style, { display: "flex", alignItems: "center", justifyContent: "center", width: "16px", height: "16px" }); button.appendChild(iconWrapper); // Hover/focus effects button.addEventListener("mouseenter", () => { // Only brighten if not green/active const isActive = button.classList.contains("active"); if (isActive) { button.style.color = "#1db954"; } else { button.style.color = "rgba(255, 255, 255, 1)"; } button.style.transform = "scale(1.04)"; }); button.addEventListener("mouseleave", () => { const isActive = button.classList.contains("active"); button.style.color = isActive ? "#1db954" : "rgba(255, 255, 255, 0.7)"; button.style.transform = "scale(1)"; }); button.addEventListener("blur", () => { button.style.outline = "none"; }); // Click handler button.addEventListener("click", onClick); return { button, iconWrapper }; } // Create main play/pause button (larger, primary style) function createPlayPauseButton(onClick) { const button = document.createElement("button"); button.setAttribute("aria-label", "Play"); button.setAttribute("data-testid", "lyrics-plus-playpause"); button.setAttribute("data-encore-id", "buttonPrimary"); button.setAttribute("tabindex", "0"); // Primary button styling (larger, prominent) Object.assign(button.style, { display: "inline-flex", alignItems: "center", justifyContent: "center", position: "relative", border: "none", borderRadius: "50%", cursor: "pointer", textDecoration: "none", color: "#000", backgroundColor: "#fff", minWidth: "32px", height: "32px", padding: "8px", fontSize: "16px", fontWeight: "400", transition: "all 0.2s ease", userSelect: "none", outline: "none" }); // Icon wrapper const iconWrapper = document.createElement("span"); iconWrapper.setAttribute("aria-hidden", "true"); Object.assign(iconWrapper.style, { display: "flex", alignItems: "center", justifyContent: "center", width: "16px", height: "16px" }); button.appendChild(iconWrapper); // Hover/focus effects button.addEventListener("mouseenter", () => { button.style.transform = "scale(1.04)"; }); button.addEventListener("mouseleave", () => { button.style.transform = "scale(1)"; }); button.addEventListener("blur", () => { button.style.outline = "none"; }); // Click handler button.addEventListener("click", onClick); return { button, iconWrapper }; } function sendSpotifyCommand(command) { // Map commands to their language-independent finder functions const buttonFinders = { shuffle: findSpotifyShuffleButton, playpause: findSpotifyPlayPauseButton, next: findSpotifyNextButton, previous: findSpotifyPreviousButton, repeat: findSpotifyRepeatButton }; const findButton = buttonFinders[command]; const btn = findButton ? findButton() : null; if (btn) { console.info("🎵 [Lyrics+ Playback] Command sent to Spotify:", command.toUpperCase()); btn.click(); // If on mobile, try touch events as a fallback if (btn.offsetParent !== null && /Android|iPhone|iPad|iPod/i.test(navigator.userAgent)) { btn.dispatchEvent(new TouchEvent('touchstart', {bubbles:true, cancelable:true})); btn.dispatchEvent(new TouchEvent('touchend', {bubbles:true, cancelable:true})); } } else { console.warn("Spotify control button not found for:", command); } } // Create all control buttons const { button: btnShuffle, iconWrapper: shuffleIconWrapper } = createSpotifyControlButton( "shuffle", "Enable shuffle", () => { sendSpotifyCommand("shuffle"); setTimeout(() => updateShuffleButton(btnShuffle, shuffleIconWrapper), 100); } ); console.info("✅ [Lyrics+ Playback] Shuffle button created"); const { button: btnPrevious, iconWrapper: prevIconWrapper } = createSpotifyControlButton( "previous", "Previous", () => sendSpotifyCommand("previous") ); // Use DOM-cloned icon from Spotify's visible button updatePreviousButtonIcon(prevIconWrapper); console.info("✅ [Lyrics+ Playback] Previous button created"); const { button: btnPlayPause, iconWrapper: playIconWrapper } = createPlayPauseButton( () => { sendSpotifyCommand("playpause"); setTimeout(() => updatePlayPauseButton(btnPlayPause, playIconWrapper), 100); } ); console.info("✅ [Lyrics+ Playback] Play/Pause button created"); const { button: btnNext, iconWrapper: nextIconWrapper } = createSpotifyControlButton( "next", "Next", () => sendSpotifyCommand("next") ); // Use DOM-cloned icon from Spotify's visible button updateNextButtonIcon(nextIconWrapper); console.info("✅ [Lyrics+ Playback] Next button created"); const { button: btnRepeat, iconWrapper: repeatIconWrapper } = createSpotifyControlButton( "repeat", "Enable repeat", () => { sendSpotifyCommand("repeat"); setTimeout(() => updateRepeatButton(btnRepeat, repeatIconWrapper), 100); } ); console.info("✅ [Lyrics+ Playback] Repeat button created"); // Initialize button states using DOM-cloned icons from Spotify's visible buttons updateShuffleButton(btnShuffle, shuffleIconWrapper); updatePlayPauseButton(btnPlayPause, playIconWrapper); updateRepeatButton(btnRepeat, repeatIconWrapper); // Store references for later updates popup._shuffleBtn = { button: btnShuffle, iconWrapper: shuffleIconWrapper }; popup._playPauseBtn = { button: btnPlayPause, iconWrapper: playIconWrapper }; popup._repeatBtn = { button: btnRepeat, iconWrapper: repeatIconWrapper }; popup._prevBtn = { iconWrapper: prevIconWrapper }; popup._nextBtn = { iconWrapper: nextIconWrapper }; controlsBar.appendChild(btnShuffle); controlsBar.appendChild(btnPrevious); controlsBar.appendChild(btnPlayPause); controlsBar.appendChild(btnNext); controlsBar.appendChild(btnRepeat); // Add a realtime progress bar element (dynamic progress bar) const progressWrapper = document.createElement("div"); progressWrapper.id = "lyrics-plus-progress-wrapper"; progressWrapper.style.display = "flex"; progressWrapper.style.alignItems = "center"; progressWrapper.style.gap = "8px"; progressWrapper.style.padding = "8px 12px"; progressWrapper.style.borderTop = "1px solid #222"; progressWrapper.style.background = "#111"; progressWrapper.style.boxSizing = "border-box"; progressWrapper.style.transition = "max-height 0.3s, padding 0.3s, opacity 0.3s"; progressWrapper.style.overflow = "hidden"; const timeNow = document.createElement("div"); timeNow.id = "lyrics-plus-time-now"; timeNow.textContent = "0:00"; timeNow.style.color = "#bbb"; timeNow.style.fontSize = "12px"; timeNow.style.width = "44px"; timeNow.style.textAlign = "right"; const progressInput = document.createElement("input"); progressInput.type = "range"; progressInput.id = "lyrics-plus-progress"; progressInput.min = "0"; progressInput.max = "100"; progressInput.step = "1"; progressInput.value = "0"; Object.assign(progressInput.style, { flex: "1", appearance: "none", height: "6px", borderRadius: "3px", background: "linear-gradient(90deg, #1db954 0%, #1db954 0%, #444 0%)", outline: "none", margin: "0", }); // Simple styling for thumb (dynamic progress bar) const thumbStyle = document.createElement("style"); thumbStyle.textContent = ` #lyrics-plus-progress::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: #fff; box-shadow: 0 0 0 4px rgba(29,185,84,0.12); cursor: pointer; } #lyrics-plus-progress::-moz-range-thumb { width: 12px; height: 12px; border-radius: 50%; background: #fff; cursor: pointer; } `; document.head.appendChild(thumbStyle); // Custom dark mode checkbox styles const checkboxStyle = document.createElement("style"); checkboxStyle.textContent = ` .lyrics-plus-checkbox { -webkit-appearance: none; -moz-appearance: none; appearance: none; width: 18px; height: 18px; border: 2px solid #555; border-radius: 4px; background: #282828; cursor: pointer; position: relative; transition: all 0.2s ease; } .lyrics-plus-checkbox:hover { border-color: #888; background: #333; } .lyrics-plus-checkbox:checked { background: #1db954; border-color: #1db954; } .lyrics-plus-checkbox:checked::after { content: ''; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); } .lyrics-plus-checkbox:focus { outline: none; box-shadow: 0 0 0 2px rgba(29, 185, 84, 0.3); } /* AMOLED Theme CSS - Applied once to parent container */ .lyrics-plus-amoled-theme #lyrics-plus-popup, .lyrics-plus-amoled-theme #lyrics-plus-header-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-translator-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-tabs-toggle-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-seekbar-toggle-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-controls-toggle-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-theme-toggle-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-offset-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-content, .lyrics-plus-amoled-theme #lyrics-plus-controls-bar, .lyrics-plus-amoled-theme #lyrics-plus-progress-wrapper, .lyrics-plus-amoled-theme #lyrics-plus-font-size-select, .lyrics-plus-amoled-theme #lyrics-plus-download-dropdown, .lyrics-plus-amoled-theme #lyrics-plus-download-sync, .lyrics-plus-amoled-theme #lyrics-plus-download-unsync { background: #000 !important; background-color: #000 !important; } /* Modal theme */ .lyrics-plus-amoled-theme #lyrics-plus-musixmatch-modal-box, .lyrics-plus-amoled-theme #lyrics-plus-spotify-modal-box { background: #000 !important; } /* Hover states for AMOLED theme */ .lyrics-plus-amoled-theme #lyrics-plus-download-sync:hover, .lyrics-plus-amoled-theme #lyrics-plus-download-unsync:hover { background: #1a1a1a !important; } `; document.head.appendChild(checkboxStyle); const timeTotal = document.createElement("div"); timeTotal.id = "lyrics-plus-time-total"; timeTotal.textContent = "0:00"; timeTotal.style.color = "#bbb"; timeTotal.style.fontSize = "12px"; timeTotal.style.width = "44px"; timeTotal.style.textAlign = "left"; progressWrapper.appendChild(timeNow); progressWrapper.appendChild(progressInput); progressWrapper.appendChild(timeTotal); console.info("✅ [Lyrics+ Seekbar] Progress bar (seekbar) created with time display"); // Apply initial visibility state for progressWrapper (must be after progressWrapper is created) applyProgressWrapperVisibility(seekbarVisible); popup.appendChild(headerWrapper); popup.appendChild(translatorWrapper); popup.appendChild(tabsToggleWrapper); popup.appendChild(seekbarToggleWrapper); popup.appendChild(controlsToggleWrapper); popup.appendChild(themeToggleWrapper); popup.appendChild(offsetWrapper); popup.appendChild(lyricsContainer); popup.appendChild(controlsBar); popup.appendChild(progressWrapper); const container = document.querySelector('.main-view-container'); if (container) { container.appendChild(popup); } else { document.body.appendChild(popup); } // Save initial state if using default position (not restored from saved state) if (shouldSaveDefaultPosition) { savePopupState(popup); } (function makeDraggable(el, handle) { let isDragging = false; let startX, startY; let origX, origY; // Mouse events handle.addEventListener("mousedown", (e) => { isDragging = true; window.lyricsPlusPopupIsDragging = true; startX = e.clientX; startY = e.clientY; const rect = el.getBoundingClientRect(); origX = rect.left; origY = rect.top; document.body.style.userSelect = "none"; }); // Touch events handle.addEventListener("touchstart", (e) => { if (e.touches.length !== 1) return; isDragging = true; window.lyricsPlusPopupIsDragging = true; startX = e.touches[0].clientX; startY = e.touches[0].clientY; const rect = el.getBoundingClientRect(); origX = rect.left; origY = rect.top; document.body.style.userSelect = "none"; }); const onDragMouseMove = (e) => { if (!isDragging) return; const dx = e.clientX - startX; const dy = e.clientY - startY; let newX = origX + dx; let newY = origY + dy; const maxX = window.innerWidth - el.offsetWidth; const maxY = window.innerHeight - el.offsetHeight; newX = Math.min(Math.max(0, newX), maxX); newY = Math.min(Math.max(0, newY), maxY); el.style.left = `${newX}px`; el.style.top = `${newY}px`; el.style.right = "auto"; el.style.bottom = "auto"; el.style.position = "fixed"; }; const onDragTouchMove = (e) => { if (!isDragging || e.touches.length !== 1) return; const dx = e.touches[0].clientX - startX; const dy = e.touches[0].clientY - startY; let newX = origX + dx; let newY = origY + dy; const maxX = window.innerWidth - el.offsetWidth; const maxY = window.innerHeight - el.offsetHeight; newX = Math.min(Math.max(0, newX), maxX); newY = Math.min(Math.max(0, newY), maxY); el.style.left = `${newX}px`; el.style.top = `${newY}px`; el.style.right = "auto"; el.style.bottom = "auto"; el.style.position = "fixed"; e.preventDefault(); }; const onDragMouseUp = () => { if (isDragging) { isDragging = false; document.body.style.userSelect = ""; window.lyricsPlusPopupLastDragged = Date.now(); savePopupState(el); setTimeout(() => { window.lyricsPlusPopupIsDragging = false; }, 200); } }; const onDragTouchEnd = () => { if (isDragging) { isDragging = false; document.body.style.userSelect = ""; window.lyricsPlusPopupLastDragged = Date.now(); savePopupState(el); setTimeout(() => { window.lyricsPlusPopupIsDragging = false; }, 200); } }; window.addEventListener("mousemove", onDragMouseMove); window.addEventListener("touchmove", onDragTouchMove, { passive: false }); window.addEventListener("mouseup", onDragMouseUp); window.addEventListener("touchend", onDragTouchEnd); // Store handlers on the element so they can be removed when the popup is destroyed el._dragHandlers = { onDragMouseMove, onDragTouchMove, onDragMouseUp, onDragTouchEnd }; })(popup, headerWrapper); // Create a larger invisible hit area const resizerHitArea = document.createElement("div"); Object.assign(resizerHitArea.style, { position: "absolute", right: "0px", bottom: "0px", width: "48px", // much larger for finger touch height: "48px", zIndex: 19, // just below visible resizer background: "transparent", touchAction: "none", }); // Create the visual resizer const resizer = document.createElement("div"); Object.assign(resizer.style, { width: "16px", height: "16px", position: "absolute", right: "4px", bottom: "4px", cursor: "nwse-resize", backgroundColor: "rgba(255, 255, 255, 0.1)", borderTop: "1.5px solid rgba(255, 255, 255, 0.15)", borderLeft: "1.5px solid rgba(255, 255, 255, 0.15)", boxSizing: "border-box", zIndex: 20, clipPath: "polygon(100% 0, 0 100%, 100% 100%)" }); popup.appendChild(resizerHitArea); popup.appendChild(resizer); (function makeResizable(el, handle) { let isResizing = false; let startX, startY; let startWidth, startHeight; function startResize(e) { e.preventDefault(); isResizing = true; window.lyricsPlusPopupIsResizing = true; if (e.type === "mousedown") { startX = e.clientX; startY = e.clientY; } else if (e.type === "touchstart" && e.touches.length === 1) { startX = e.touches[0].clientX; startY = e.touches[0].clientY; } startWidth = el.offsetWidth; startHeight = el.offsetHeight; document.body.style.userSelect = "none"; } handle.addEventListener("mousedown", startResize); handle.addEventListener("touchstart", startResize); // Also attach to the hit area! resizerHitArea.addEventListener("mousedown", startResize); resizerHitArea.addEventListener("touchstart", startResize); const onResizeMouseMove = (e) => { if (!isResizing) return; const dx = e.clientX - startX; const dy = e.clientY - startY; let newWidth = startWidth + dx; let newHeight = startHeight + dy; const minWidth = 360; // match your minWidth style const minHeight = 240; // match your minHeight style const maxWidth = window.innerWidth - el.offsetLeft; const maxHeight = window.innerHeight - el.offsetTop; newWidth = clamp(newWidth, minWidth, maxWidth); newHeight = clamp(newHeight, minHeight, maxHeight); el.style.width = newWidth + "px"; el.style.height = newHeight + "px"; }; const onResizeTouchMove = (e) => { if (!isResizing || e.touches.length !== 1) return; const dx = e.touches[0].clientX - startX; const dy = e.touches[0].clientY - startY; let newWidth = startWidth + dx; let newHeight = startHeight + dy; const minWidth = 360; const minHeight = 240; const maxWidth = window.innerWidth - el.offsetLeft; const maxHeight = window.innerHeight - el.offsetTop; newWidth = clamp(newWidth, minWidth, maxWidth); newHeight = clamp(newHeight, minHeight, maxHeight); el.style.width = newWidth + "px"; el.style.height = newHeight + "px"; e.preventDefault(); }; const onResizeMouseUp = () => { if (isResizing) { isResizing = false; document.body.style.userSelect = ""; savePopupState(el); window.lyricsPlusPopupIsResizing = false; } }; const onResizeTouchEnd = () => { if (isResizing) { isResizing = false; document.body.style.userSelect = ""; savePopupState(el); window.lyricsPlusPopupIsResizing = false; } }; window.addEventListener("mousemove", onResizeMouseMove); window.addEventListener("touchmove", onResizeTouchMove, { passive: false }); window.addEventListener("mouseup", onResizeMouseUp); window.addEventListener("touchend", onResizeTouchEnd); // Store handlers on the element so they can be removed when the popup is destroyed el._resizeHandlers = { onResizeMouseMove, onResizeTouchMove, onResizeMouseUp, onResizeTouchEnd }; })(popup, resizer); observeSpotifyPlayPause(popup); observeSpotifyShuffle(popup); observeSpotifyRepeat(popup); const info = getCurrentTrackInfo(); if (info) { currentTrackId = info.id; const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) lyricsContainer.textContent = "Loading lyrics..."; autodetectProviderAndLoad(popup, info); } // --- DYNAMIC PROGRESS BAR: PROGRESS UPDATES AND SEEKING LOGIC --- // This section implements robust detection and seeking for Spotify's progress bar, // supporting both CSS-driven progress bars (using --progress-bar-transform) and // native range inputs, with fallback to visible position/duration text. // No interpolation - we just read directly from Spotify's DOM every 100ms. // If Spotify's DOM updates slowly, we show what Spotify shows. This avoids // any jumps or sync issues from our own interpolation logic. /** * findSpotifyRangeInput() * Attempts to find Spotify's native range input for playback progress. * Fallback order: * 1. Hidden numeric input[type=range] with max > 0 (preferred - most accurate) * 2. Visible range inputs with max > 0 * 3. Any range input with numeric max/min/step * @returns {HTMLInputElement|null} */ function findSpotifyRangeInput() { try { // Collect all range inputs in the document const allRanges = Array.from(document.querySelectorAll('input[type="range"]')); // Filter for hidden ranges with max > 0 (preferred - Spotify often uses hidden inputs) const hiddenRanges = allRanges.filter(inp => { const max = Number(inp.max); // Check if hidden: not visible in DOM (hidden-visually class, or offsetParent null) // Use specific class matching to avoid false positives like 'unhidden' const isHidden = inp.offsetParent === null || inp.closest('label.hidden-visually') !== null || inp.closest('.hidden-visually') !== null || inp.closest('[class~="hidden"]') !== null; return isHidden && max > 0; }); if (hiddenRanges.length > 0) { // Prefer the one with the largest max value (likely the playback progress) hiddenRanges.sort((a, b) => Number(b.max) - Number(a.max)); return hiddenRanges[0]; } // Fallback: visible range inputs with max > 0 const visibleRanges = allRanges.filter(inp => { const max = Number(inp.max); return inp.offsetParent !== null && max > 0; }); if (visibleRanges.length > 0) { visibleRanges.sort((a, b) => Number(b.max) - Number(a.max)); return visibleRanges[0]; } // Last resort: any range with valid numeric attributes const anyValid = allRanges.find(inp => inp.max && !isNaN(Number(inp.max)) && Number(inp.max) > 0 && inp.step && !isNaN(Number(inp.step)) ); return anyValid || null; } catch (e) { console.warn('findSpotifyRangeInput error:', e); return null; } } /** * readSpotifyProgressBarPercent() * Parses the --progress-bar-transform CSS variable from [data-testid="progress-bar"] * to get the current playback progress as a percentage (0-100). * Falls back to approximating from handle geometry when CSS var is unavailable. * @returns {number|null} Percentage (0-100) or null if unavailable */ function readSpotifyProgressBarPercent() { try { const progressBar = document.querySelector('[data-testid="progress-bar"]'); if (!progressBar) return null; // Try reading the --progress-bar-transform CSS variable const computedStyle = window.getComputedStyle(progressBar); const transformVar = computedStyle.getPropertyValue('--progress-bar-transform'); if (transformVar) { // Parse "34.747558241173564%" -> 34.747558241173564 // Use precise regex to match valid decimal numbers (including '0', '0.0', '.5', etc.) const match = transformVar.trim().match(/^(\d*\.?\d+)%?$/); if (match) { const pct = parseFloat(match[1]); if (!isNaN(pct) && pct >= 0 && pct <= 100) { return pct; } } } // Fallback: approximate from handle position relative to bar width const handle = progressBar.querySelector('[data-testid="progress-bar-handle"]'); const barRect = progressBar.getBoundingClientRect(); if (handle && barRect.width > 0) { const handleRect = handle.getBoundingClientRect(); // Handle center position relative to bar start const handleCenter = handleRect.left + handleRect.width / 2; const barStart = barRect.left; const barWidth = barRect.width; const pct = ((handleCenter - barStart) / barWidth) * 100; if (!isNaN(pct) && pct >= 0 && pct <= 100) { return pct; } } return null; } catch (e) { console.warn('readSpotifyProgressBarPercent error:', e); return null; } } /** * formatMs(ms) * Converts milliseconds to a human-readable time string (m:ss). * @param {number} ms - Milliseconds * @returns {string} Formatted time string */ function formatMs(ms) { if (!ms || isNaN(ms)) return "0:00"; const s = Math.floor(ms / 1000); const m = Math.floor(s / 60); const sec = s % 60; return `${m}:${String(sec).padStart(2, '0')}`; } /** * updateProgressUIFromSpotify() * Updates the popup's progressInput, timeNow, timeTotal, and background gradient * from Spotify's playback state. * * No interpolation - we just read directly from Spotify's DOM every 100ms * and display that. This avoids any jumps or sync issues. * * Fallback order for reading position: * (a) Visible playback-position/playback-duration text (most reliable - matches what user sees) * (b) Native range input * (c) CSS-driven progress-bar percent + computed duration from text/trackInfo */ function updateProgressUIFromSpotify() { try { let spotifyPosMs = null; let spotifyDurMs = null; // --- (a) Try visible playback-position text first (most reliable - matches what user sees) --- const posEl = document.querySelector('[data-testid="playback-position"]'); const durEl = document.querySelector('[data-testid="playback-duration"]'); if (posEl) { const posMs = timeStringToMs(posEl.textContent); let durMs = 0; if (durEl) { const raw = durEl.textContent.trim(); if (raw.startsWith('-')) { const remainMs = timeStringToMs(raw); durMs = posMs + remainMs; } else { durMs = timeStringToMs(raw); } } // Fallback for duration: try getCurrentTrackInfo().duration if (durMs <= 0) { const trackInfo = getCurrentTrackInfo(); if (trackInfo && trackInfo.duration > 0) { durMs = trackInfo.duration; } } if (durMs > 0) { spotifyPosMs = posMs; spotifyDurMs = durMs; } } // --- (b) Fallback: Try native range input --- if (spotifyPosMs === null) { const spotifyRange = findSpotifyRangeInput(); if (spotifyRange) { const max = Number(spotifyRange.max) || 0; const val = Number(spotifyRange.value) || 0; if (max > 0) { spotifyPosMs = val; spotifyDurMs = max; } } } // --- (c) Fallback: Try CSS-driven progress-bar percent + computed duration --- if (spotifyPosMs === null) { const cssPercent = readSpotifyProgressBarPercent(); if (cssPercent !== null) { // Need to determine total duration to compute position let durMs = 0; // Try getting duration from visible playback-duration text const durElCss = document.querySelector('[data-testid="playback-duration"]'); if (durElCss) { const raw = durElCss.textContent.trim(); if (!raw.startsWith('-')) { durMs = timeStringToMs(raw); } } // Fallback: try getCurrentTrackInfo().duration if (durMs <= 0) { const trackInfo = getCurrentTrackInfo(); if (trackInfo && trackInfo.duration > 0) { durMs = trackInfo.duration; } } // If remaining time format, compute total from position + remaining if (durMs <= 0 && durElCss) { const raw = durElCss.textContent.trim(); if (raw.startsWith('-')) { const posElCss = document.querySelector('[data-testid="playback-position"]'); const posMs = posElCss ? timeStringToMs(posElCss.textContent) : 0; const remainMs = timeStringToMs(raw); durMs = posMs + remainMs; } } if (durMs > 0) { spotifyPosMs = (cssPercent / 100) * durMs; spotifyDurMs = durMs; } } } // If we couldn't get position from any source, show zeros if (spotifyPosMs === null || spotifyDurMs === null || spotifyDurMs <= 0) { progressInput.max = "100"; progressInput.value = "0"; progressInput.style.background = `linear-gradient(90deg, #1db954 0%, #444 0%)`; timeNow.textContent = "0:00"; timeTotal.textContent = "0:00"; return; } // --- No interpolation: Just display what Spotify reports --- // This is the simplest approach - we show exactly what Spotify's DOM says. // If Spotify updates slowly, our display updates slowly too. But we avoid // any jumps or sync issues from trying to interpolate/predict positions. const displayPosMs = clamp(spotifyPosMs, 0, spotifyDurMs); // Update the UI progressInput.max = String(spotifyDurMs); progressInput.value = String(displayPosMs); const pct = (displayPosMs / spotifyDurMs) * 100; progressInput.style.background = `linear-gradient(90deg, #1db954 ${pct}%, #444 ${pct}%)`; timeNow.textContent = formatMs(displayPosMs); timeTotal.textContent = formatMs(spotifyDurMs); } catch (e) { console.warn('updateProgressUIFromSpotify error:', e); } } /** * applySeekEndBuffer(ms, durationMs, bufferMs) * Prevents seeking to exact track end by applying a buffer. * This avoids the audio "ended" state that conflicts with repeat functionality. * @param {number} ms - Target seek position in milliseconds * @param {number} durationMs - Track duration in milliseconds * @param {number} bufferMs - Buffer size in milliseconds (default 200ms) * @returns {number} Safe seek position */ function applySeekEndBuffer(ms, durationMs, bufferMs = 200) { if (ms >= durationMs - bufferMs) { DEBUG.debug('Seekbar', `Applied end buffer: ${ms}ms → ${durationMs - bufferMs}ms to prevent "ended" state`); return durationMs - bufferMs; } return ms; } /** * seekTo(ms) * Attempts to seek Spotify's playback to the specified position in milliseconds. * Fallback order: * (a) Hidden/native range input value + dispatch input/change + pointer events * (b) Emulate pointer/mouse events on CSS progress-bar handle (last resort) * @param {number} ms - Target position in milliseconds * @returns {boolean} Whether seeking was attempted */ function seekTo(ms) { try { const SEEK_END_BUFFER_MS = 200; DEBUG.debug('Seekbar', `Seeking to ${ms}ms (${formatMs(ms)})`); // --- (a) Try hidden/native range input --- const spotifyRange = findSpotifyRangeInput(); if (spotifyRange) { try { const max = Number(spotifyRange.max) || 0; if (max > 0) { const safeMs = applySeekEndBuffer(ms, max, SEEK_END_BUFFER_MS); // Set the value spotifyRange.value = String(clamp(safeMs, 0, max)); // Dispatch input and change events spotifyRange.dispatchEvent(new Event('input', { bubbles: true })); spotifyRange.dispatchEvent(new Event('change', { bubbles: true })); // Also try pointer events for better compatibility // Note: We omit 'view' property as it can cause errors in Firefox extensions const rangeRect = spotifyRange.getBoundingClientRect(); const percentage = clamp(safeMs, 0, max) / max; const clientX = rangeRect.left + rangeRect.width * percentage; const clientY = rangeRect.top + rangeRect.height / 2; try { const pointerDownEvent = new PointerEvent('pointerdown', { bubbles: true, cancelable: true, clientX, clientY, button: 0, buttons: 1 }); const pointerUpEvent = new PointerEvent('pointerup', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); spotifyRange.dispatchEvent(pointerDownEvent); spotifyRange.dispatchEvent(pointerUpEvent); } catch (pointerErr) { // Pointer events failed, try mouse events instead const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); const mouseUpEvent = new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); spotifyRange.dispatchEvent(mouseDownEvent); spotifyRange.dispatchEvent(mouseUpEvent); } DEBUG.debug('Seekbar', `✓ Seeked via range input to ${safeMs}ms`); return true; } } catch (e) { console.warn('seekTo: Failed to set range input', e); } } // --- (b) Emulate pointer events on CSS progress-bar handle (last resort) --- const progressBar = document.querySelector('[data-testid="progress-bar"]'); if (progressBar) { try { const barRect = progressBar.getBoundingClientRect(); if (barRect.width > 0) { // Determine duration to calculate percentage let durMs = 0; // Try range input max const range = findSpotifyRangeInput(); if (range && Number(range.max) > 0) { durMs = Number(range.max); } // Fallback: visible text if (durMs <= 0) { const durEl = document.querySelector('[data-testid="playback-duration"]'); const posEl = document.querySelector('[data-testid="playback-position"]'); if (durEl) { const raw = durEl.textContent.trim(); if (raw.startsWith('-')) { const posMs = posEl ? timeStringToMs(posEl.textContent) : 0; const remainMs = timeStringToMs(raw); durMs = posMs + remainMs; } else { durMs = timeStringToMs(raw); } } } // Fallback: track info if (durMs <= 0) { const trackInfo = getCurrentTrackInfo(); if (trackInfo && trackInfo.duration > 0) { durMs = trackInfo.duration; } } if (durMs > 0) { const safeMs = applySeekEndBuffer(ms, durMs, SEEK_END_BUFFER_MS); const percentage = clamp(safeMs, 0, durMs) / durMs; const clientX = barRect.left + barRect.width * percentage; const clientY = barRect.top + barRect.height / 2; // Try the handle first, then the progress bar const handle = progressBar.querySelector('[data-testid="progress-bar-handle"]'); const target = handle || progressBar; // Try pointer events first (without 'view' property to avoid Firefox extension issues) try { const downEvent = new PointerEvent('pointerdown', { bubbles: true, cancelable: true, clientX, clientY, button: 0, buttons: 1, pointerType: 'mouse' }); const moveEvent = new PointerEvent('pointermove', { bubbles: true, cancelable: true, clientX, clientY, button: 0, buttons: 1, pointerType: 'mouse' }); const upEvent = new PointerEvent('pointerup', { bubbles: true, cancelable: true, clientX, clientY, button: 0, buttons: 0, pointerType: 'mouse' }); target.dispatchEvent(downEvent); target.dispatchEvent(moveEvent); target.dispatchEvent(upEvent); } catch (pointerErr) { // Pointer events failed, continue to mouse events } // Also try mouse events as fallback const mouseDownEvent = new MouseEvent('mousedown', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); const mouseUpEvent = new MouseEvent('mouseup', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, clientX, clientY, button: 0 }); progressBar.dispatchEvent(mouseDownEvent); progressBar.dispatchEvent(mouseUpEvent); progressBar.dispatchEvent(clickEvent); DEBUG.debug('Seekbar', `✓ Seeked via progress-bar pointer events to ${safeMs}ms`); return true; } } } catch (e) { console.warn('seekTo: Failed to emulate pointer events on progress bar', e); } } return false; } catch (e) { console.warn('seekTo error:', e); return false; } } // --- Progress bar watcher for DOM node swaps --- let progressBarWatcherAttached = false; let progressBarWatcherTimeout = null; // Closure variable for debounce timeout /** * attachProgressBarWatcher() * Installs a MutationObserver on document.body to detect when Spotify may swap * DOM nodes (e.g., during navigation or track changes) and re-runs updateProgressUIFromSpotify(). * The observer is idempotent - calling multiple times only installs one observer. */ function attachProgressBarWatcher() { if (progressBarWatcherAttached) return; // Idempotent progressBarWatcherAttached = true; try { const observer = new MutationObserver((mutations) => { // Check if any mutation affects progress-related elements let shouldUpdate = false; for (const mutation of mutations) { if (mutation.type === 'childList') { // Check added/removed nodes for progress bar elements const relevantSelectors = [ '[data-testid="progress-bar"]', '[data-testid="progress-bar-handle"]', '[data-testid="playback-position"]', '[data-testid="playback-duration"]', 'input[type="range"]' ]; const checkNodes = (nodes) => { for (const node of nodes) { if (node.nodeType !== Node.ELEMENT_NODE) continue; for (const sel of relevantSelectors) { if (node.matches && node.matches(sel)) return true; if (node.querySelector && node.querySelector(sel)) return true; } } return false; }; if (checkNodes(mutation.addedNodes) || checkNodes(mutation.removedNodes)) { shouldUpdate = true; break; } } else if (mutation.type === 'attributes') { // Check if style attribute changed on progress bar (CSS var updates) if (mutation.target.matches && mutation.target.matches('[data-testid="progress-bar"]') && mutation.attributeName === 'style') { shouldUpdate = true; break; } } } if (shouldUpdate) { // Debounce updates to avoid excessive calls if (!progressBarWatcherTimeout) { progressBarWatcherTimeout = setTimeout(() => { progressBarWatcherTimeout = null; try { updateProgressUIFromSpotify(); } catch (e) { console.warn('Progress bar watcher update error:', e); } }, 100); } } }); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style'] }); // Store observer on popup element so it can be disconnected when popup is removed popup._progressBarWatcher = observer; } catch (e) { console.warn('attachProgressBarWatcher error:', e); progressBarWatcherAttached = false; } } // --- Event handlers for popup progress input --- let userSeeking = false; progressInput.addEventListener('input', (e) => { userSeeking = true; // Show immediate feedback while dragging const val = Number(progressInput.value) || 0; const max = Number(progressInput.max) || 1; const pct = (val / max) * 100; progressInput.style.background = `linear-gradient(90deg, #1db954 ${pct}%, #444 ${pct}%)`; timeNow.textContent = formatMs(val); }); // Reset userSeeking if user releases mouse outside the element or touch is cancelled const resetSeeking = () => { userSeeking = false; }; progressInput.addEventListener('mouseleave', resetSeeking); progressInput.addEventListener('touchcancel', resetSeeking); progressInput.addEventListener('blur', resetSeeking); // Commit seek on mouseup/touchend const commitSeek = (e) => { const val = Number(progressInput.value) || 0; userSeeking = false; console.info("⏩ [Lyrics+ Seekbar] User seeked to position:", formatMs(val)); // Just seek - no interpolation state to manage seekTo(val); }; progressInput.addEventListener('change', commitSeek); progressInput.addEventListener('mouseup', commitSeek); progressInput.addEventListener('touchend', commitSeek); // --- Start progress bar watcher and interval --- // Wire attachProgressBarWatcher() to run once popup is created attachProgressBarWatcher(); // Start interval to refresh progress // Using 100ms interval for smooth interpolated updates if (progressInterval) { clearInterval(progressInterval); progressInterval = null; } progressInterval = setInterval(() => { // Don't auto-update while user is actively dragging if (document.activeElement === progressInput || userSeeking) return; updateProgressUIFromSpotify(); }, 100); startPollingForTrackChange(popup); } // Re-render cached lyrics without fetching from provider (used for Chinese conversion toggle) function rerenderLyrics(popup) { const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (!lyricsContainer) return; // If no cached lyrics, nothing to re-render if (!currentSyncedLyrics && !currentUnsyncedLyrics) return; const chineseConvBtn = popup._chineseConvBtn; const shouldConvertChinese = isChineseConversionEnabled(); // Update button text based on conversion state if (chineseConvBtn && popup._updateChineseConvBtnText) { popup._updateChineseConvBtnText(); } // Helper function to convert text if needed (bidirectional) const convertText = (text) => { if (shouldConvertChinese && text && Utils.containsHanCharacter(text)) { if (originalChineseScriptType === 'traditional') { return Utils.toSimplifiedChinese(text); } else { return Utils.toTraditionalChinese(text); } } return text; }; // Reset translation state when re-rendering lyrics translationPresent = false; transliterationPresent = false; lastTranslatedLang = null; // When PiP is active the video element covers the lyrics container. Rebuild the // hidden lyric children in-place without ever removing pipVideo — this prevents the // container from being briefly uncovered (no visual flash behind the PiP overlay). const pipIsInContainer = (isPipActive || isPagePipActive) && pipVideo && pipVideo.parentElement === lyricsContainer; if (pipIsInContainer) { // Remove all children except pipVideo (they are already display:none) Array.from(lyricsContainer.children).forEach(child => { if (child !== pipVideo) lyricsContainer.removeChild(child); }); } else { lyricsContainer.innerHTML = ""; } const transliterationEnabled = localStorage.getItem(STORAGE_KEYS.TRANSLITERATION_ENABLED) === 'true'; let hasTransliterationData = false; if (currentSyncedLyrics) { isShowingSyncedLyrics = true; currentSyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } if (pipIsInContainer) p.style.display = 'none'; lyricsContainer.appendChild(p); }); // Normalize cached lyrics time format for proper syncing (especially for KPoe provider) highlightSyncedLyrics(normalizeLyricsTimeFormat(currentSyncedLyrics), lyricsContainer); } else if (currentUnsyncedLyrics) { isShowingSyncedLyrics = false; currentUnsyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; p.style.color = "white"; p.style.fontWeight = "400"; p.style.filter = "blur(0.7px)"; p.style.opacity = "0.8"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } if (pipIsInContainer) p.style.display = 'none'; lyricsContainer.appendChild(p); }); // For unsynced, always allow user scroll lyricsContainer.style.overflowY = "auto"; lyricsContainer.style.pointerEvents = ""; lyricsContainer.classList.remove('hide-scrollbar'); lyricsContainer.style.scrollbarWidth = ""; lyricsContainer.style.msOverflowStyle = ""; } if (pipIsInContainer) { // Update _pipSavedChildren so exitPipFromLyricsContainer() correctly restores the // newly built elements when PiP is eventually toggled off. const newChildren = Array.from(lyricsContainer.children).filter(c => c !== pipVideo); lyricsContainer._pipSavedChildren = newChildren.map(el => ({ el, display: '' })); } // Show/hide transliteration button based on data availability const transliterationBtn = popup._transliterationToggleBtn; if (transliterationBtn) { transliterationBtn.style.display = hasTransliterationData ? "inline-block" : "none"; console.info("📝 [Lyrics+ UI] Transliteration button visibility updated:", hasTransliterationData ? "SHOWN (transliteration data available)" : "HIDDEN (no transliteration data)"); } // Show transliteration if enabled and data is available if (transliterationEnabled && hasTransliterationData) { showTransliterationInPopup(); if (transliterationBtn) { transliterationBtn.title = "Hide transliteration"; } } } /** * Helper function to hide UI buttons for instrumental tracks * @param {HTMLElement} popup - The popup element */ function hideButtonsForInstrumental(popup) { const downloadBtn = popup.querySelector('button[title="Download lyrics"]'); const downloadDropdown = downloadBtn ? downloadBtn._dropdown : null; const chineseConvBtn = popup._chineseConvBtn; const transliterationBtn = popup._transliterationToggleBtn; if (downloadBtn) { downloadBtn.style.display = "none"; if (downloadDropdown) downloadDropdown.style.display = "none"; } if (chineseConvBtn) chineseConvBtn.style.display = "none"; if (transliterationBtn) transliterationBtn.style.display = "none"; } /** * Helper function to cache instrumental track data * @param {string} trackId - Track ID * @param {string} provider - Provider name that detected instrumental * @param {Object} trackInfo - Track information */ function cacheInstrumentalTrack(trackId, provider, trackInfo) { LyricsCache.set(trackId, { provider: null, // No specific provider since instrumental means no lyrics from any source synced: null, unsynced: null, instrumental: true, error: "♪ Instrumental Track ♪\n\nThis track has no lyrics", trackInfo: { title: trackInfo.title, artist: trackInfo.artist, album: trackInfo.album, duration: trackInfo.duration } }); console.log(`✅ [Lyrics+] Instrumental track cached (detected by ${provider}) - will show "no lyrics" message on future plays`); } /** * Normalize lyrics time format for syncing * Converts startTime (seconds) to time (milliseconds) if needed * @param {Array} lyrics - Array of lyric lines * @returns {Array} Normalized lyrics with time in milliseconds */ function normalizeLyricsTimeFormat(lyrics) { if (!lyrics || !Array.isArray(lyrics)) return lyrics; return lyrics.map(line => ({ ...line, time: line.time ?? Math.round((line.startTime || 0) * 1000) })); } /** * Load and display lyrics from cache * @param {HTMLElement} popup - The popup element * @param {Object} info - Track information * @param {Object} cachedData - Cached lyrics data * @returns {boolean} True if successfully loaded from cache */ function loadLyricsFromCache(popup, info, cachedData) { if (!popup || !info || !cachedData) return false; const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (!lyricsContainer) return false; console.log(`✨ [Lyrics+] Loading lyrics from cache for "${info.title}" by ${info.artist}`); // Display provider with server info if available (for KPoe) let providerDisplay = cachedData.provider || 'Unknown'; if (cachedData.provider === 'KPoe' && cachedData.metadata?.server) { const serverUrl = cachedData.metadata.server; let serverLabel = 'Unknown server'; if (serverUrl.includes('lyricsplus.prjktla.workers.dev')) { serverLabel = 'Primary'; } else if (serverUrl.includes('lyricsplus-seven.vercel.app')) { serverLabel = 'Backup 1'; } else if (serverUrl.includes('lyrics-plus-backend.vercel.app')) { serverLabel = 'Backup 2'; } providerDisplay = `KPoe - ${serverLabel}`; } console.log(` 📦 Source: ${providerDisplay} (previously fetched)`); DEBUG.log('Cache', `Loading lyrics from cache for: ${info.title} - ${info.artist}`); currentLyricsContainer = lyricsContainer; currentSyncedLyrics = cachedData.synced; currentUnsyncedLyrics = cachedData.unsynced; currentLyricsMetadata = cachedData.metadata || null; // Restore metadata from cache // Reset translation state translationPresent = false; transliterationPresent = false; lastTranslatedLang = null; // Set the provider to the cached one if (cachedData.provider) { Providers.setCurrent(cachedData.provider); if (popup._lyricsTabs) updateTabs(popup._lyricsTabs); } const downloadBtn = popup.querySelector('button[title="Download lyrics"]'); const downloadDropdown = downloadBtn ? downloadBtn._dropdown : null; const chineseConvBtn = popup._chineseConvBtn; // Check if cached lyrics contain Chinese characters const lyrics = cachedData.synced || cachedData.unsynced || []; const hasChineseLyrics = lyrics.some(line => line.text && Utils.containsHanCharacter(line.text)); if (hasChineseLyrics) { const allLyricsText = lyrics.map(line => line.text || '').join(''); originalChineseScriptType = Utils.detectChineseScriptType(allLyricsText); } else { originalChineseScriptType = null; } // Show/hide Chinese conversion button if (chineseConvBtn) { if (hasChineseLyrics && originalChineseScriptType) { chineseConvBtn.style.display = "inline-flex"; if (popup._updateChineseConvBtnText) { popup._updateChineseConvBtnText(); } } else { chineseConvBtn.style.display = "none"; } } const shouldConvertChinese = isChineseConversionEnabled(); const convertText = (text) => { if (shouldConvertChinese && text && Utils.containsHanCharacter(text)) { if (originalChineseScriptType === 'traditional') { return Utils.toSimplifiedChinese(text); } else { return Utils.toTraditionalChinese(text); } } return text; }; pipVideoDetachIfInContainer(); lyricsContainer.innerHTML = ""; const transliterationEnabled = localStorage.getItem(STORAGE_KEYS.TRANSLITERATION_ENABLED) === 'true'; let hasTransliterationData = false; if (currentSyncedLyrics) { isShowingSyncedLyrics = true; currentSyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } lyricsContainer.appendChild(p); }); // Normalize cached lyrics time format for proper syncing (especially for KPoe provider) highlightSyncedLyrics(normalizeLyricsTimeFormat(currentSyncedLyrics), lyricsContainer); } else if (currentUnsyncedLyrics) { isShowingSyncedLyrics = false; currentUnsyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; p.style.color = "white"; p.style.fontWeight = "400"; p.style.filter = "blur(0.7px)"; p.style.opacity = "0.8"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } lyricsContainer.appendChild(p); }); lyricsContainer.style.overflowY = "auto"; lyricsContainer.style.pointerEvents = ""; lyricsContainer.classList.remove('hide-scrollbar'); lyricsContainer.style.scrollbarWidth = ""; lyricsContainer.style.msOverflowStyle = ""; } // Show/hide transliteration button const transliterationBtn = popup._transliterationToggleBtn; if (transliterationBtn) { transliterationBtn.style.display = hasTransliterationData ? "inline-block" : "none"; } // Show transliteration if enabled if (transliterationEnabled && hasTransliterationData) { showTransliterationInPopup(); if (transliterationBtn) { transliterationBtn.title = "Hide transliteration"; } } // Show/hide download button if (downloadBtn) { if (lyricsContainer.querySelectorAll('p').length > 0) { downloadBtn.style.display = "inline-flex"; } else { downloadBtn.style.display = "none"; if (downloadDropdown) downloadDropdown.style.display = "none"; } } // Re-insert pipVideo after lyrics are rebuilt so PiP stays open during track transitions if (isPipActive || isPagePipActive) enterPipInLyricsContainer(); return true; } async function updateLyricsContent(popup, info, cachedResult = null) { if (!info) return; const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (!lyricsContainer) return; currentLyricsContainer = lyricsContainer; currentSyncedLyrics = null; currentUnsyncedLyrics = null; // Reset translation state when loading new lyrics translationPresent = false; transliterationPresent = false; lastTranslatedLang = null; pipVideoDetachIfInContainer(); lyricsContainer.textContent = "Loading lyrics..."; if (isPipActive || isPagePipActive) enterPipInLyricsContainer(); const downloadBtn = popup.querySelector('button[title="Download lyrics"]'); const downloadDropdown = downloadBtn ? downloadBtn._dropdown : null; const chineseConvBtn = popup._chineseConvBtn; const provider = Providers.getCurrent(); let result; if (cachedResult !== null) { result = cachedResult; } else { console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(`🎵 [Lyrics+] \n\nFetching lyrics from the manually selected provider.\nSynced lyrics are preferred.\nIf only unsynced lyrics are found, they will be displayed from the provider.`); result = await provider.findLyrics(info, 'synced'); } // Check if track is marked as instrumental - convert to error if (result.instrumental) { console.log(`🎵 [Lyrics+] Track is instrumental (no lyrics) - detected by ${Providers.current}`); result.error = "♪ Instrumental Track ♪\n\nThis track has no lyrics"; // Cache the instrumental status before proceeding to error handling cacheInstrumentalTrack(info.id, Providers.current, info); // Clear provider highlighting since instrumental means no lyrics from any source Providers.current = null; if (popup._lyricsTabs) updateTabs(popup._lyricsTabs, true); } if (result.error) { lyricsContainer.textContent = result.error; if (downloadBtn) { downloadBtn.style.display = "none"; console.info("📝 [Lyrics+ UI] Download button hidden (lyrics error)"); } if (downloadDropdown) downloadDropdown.style.display = "none"; if (chineseConvBtn) chineseConvBtn.style.display = "none"; return; } let synced = provider.getSynced(result); let unsynced = provider.getUnsynced(result); // Check if lyrics contain Chinese characters and detect script type const lyrics = synced || unsynced || []; const hasChineseLyrics = lyrics.some(line => line.text && Utils.containsHanCharacter(line.text)); // Detect original Chinese script type from the lyrics if (hasChineseLyrics) { const allLyricsText = lyrics.map(line => line.text || '').join(''); originalChineseScriptType = Utils.detectChineseScriptType(allLyricsText); } else { originalChineseScriptType = null; } // Show/hide Chinese conversion button - for both Traditional and Simplified Chinese lyrics // Now supports bidirectional conversion via opencc-js (t2cn and cn2t) if (chineseConvBtn) { if (hasChineseLyrics && originalChineseScriptType) { chineseConvBtn.style.display = "inline-flex"; // Update button text to show conversion direction if (popup._updateChineseConvBtnText) { popup._updateChineseConvBtnText(); } } else { chineseConvBtn.style.display = "none"; } } // Check if Chinese conversion is enabled const shouldConvertChinese = isChineseConversionEnabled(); // Helper function to convert text if needed (bidirectional) const convertText = (text) => { if (shouldConvertChinese && text && Utils.containsHanCharacter(text)) { if (originalChineseScriptType === 'traditional') { return Utils.toSimplifiedChinese(text); } else { return Utils.toTraditionalChinese(text); } } return text; }; pipVideoDetachIfInContainer(); lyricsContainer.innerHTML = ""; // Set globals for download currentSyncedLyrics = (synced && synced.length > 0) ? synced : null; currentUnsyncedLyrics = (unsynced && unsynced.length > 0) ? unsynced : null; const transliterationEnabled = localStorage.getItem(STORAGE_KEYS.TRANSLITERATION_ENABLED) === 'true'; let hasTransliterationData = false; if (currentSyncedLyrics) { isShowingSyncedLyrics = true; currentSyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } lyricsContainer.appendChild(p); }); highlightSyncedLyrics(currentSyncedLyrics, lyricsContainer); } else if (currentUnsyncedLyrics) { isShowingSyncedLyrics = false; currentUnsyncedLyrics.forEach(({ text, transliteration }, idx) => { const p = document.createElement("p"); p.setAttribute('data-lyrics-line-index', String(idx)); p.textContent = convertText(text); p.style.margin = "0 0 6px 0"; p.style.transition = "transform 0.18s, color 0.15s, filter 0.13s, opacity 0.13s"; p.style.color = "white"; p.style.fontWeight = "400"; p.style.filter = "blur(0.7px)"; p.style.opacity = "0.8"; if (transliteration) { p.setAttribute('data-transliteration-text', transliteration); hasTransliterationData = true; } lyricsContainer.appendChild(p); }); // For unsynced, always allow user scroll lyricsContainer.style.overflowY = "auto"; lyricsContainer.style.pointerEvents = ""; lyricsContainer.classList.remove('hide-scrollbar'); lyricsContainer.style.scrollbarWidth = ""; lyricsContainer.style.msOverflowStyle = ""; } else { isShowingSyncedLyrics = false; // Always allow user scroll for unsynced or empty lyricsContainer.style.overflowY = "auto"; lyricsContainer.style.pointerEvents = ""; lyricsContainer.classList.remove('hide-scrollbar'); lyricsContainer.style.scrollbarWidth = ""; lyricsContainer.style.msOverflowStyle = ""; if (!lyricsContainer.textContent.trim()) { lyricsContainer.textContent = `No lyrics available from ${Providers.current}`; } currentSyncedLyrics = null; currentUnsyncedLyrics = null; } // Re-insert pipVideo after lyrics are rebuilt so PiP stays open during track transitions if (isPipActive || isPagePipActive) enterPipInLyricsContainer(); // Show/hide transliteration button based on data availability const transliterationBtn = popup._transliterationToggleBtn; if (transliterationBtn) { transliterationBtn.style.display = hasTransliterationData ? "inline-block" : "none"; console.info("📝 [Lyrics+ UI] Transliteration button visibility updated:", hasTransliterationData ? "SHOWN (transliteration data available)" : "HIDDEN (no transliteration data)"); } // Show transliteration if enabled and data is available if (transliterationEnabled && hasTransliterationData) { showTransliterationInPopup(); if (transliterationBtn) { transliterationBtn.title = "Hide transliteration"; } } // Show/hide download button appropriately - only use the variables already declared above! if (downloadBtn) { if (lyricsContainer.querySelectorAll('p').length > 0) { downloadBtn.style.display = "inline-flex"; console.info("📝 [Lyrics+ UI] Download button shown (lyrics loaded successfully)"); } else { downloadBtn.style.display = "none"; console.info("📝 [Lyrics+ UI] Download button hidden (no lyrics to display)"); if (downloadDropdown) downloadDropdown.style.display = "none"; } } // Cache lyrics for future use (repeat one, recent songs) if (currentSyncedLyrics || currentUnsyncedLyrics) { LyricsCache.set(info.id, { provider: Providers.current, synced: currentSyncedLyrics, unsynced: currentUnsyncedLyrics, metadata: currentLyricsMetadata, // Store metadata (e.g., KPoe server info) trackInfo: { title: info.title, artist: info.artist, album: info.album, duration: info.duration } }); } } // Change priority order of providers async function autodetectProviderAndLoad(popup, info, forceRefresh = false) { // Skip lyrics search for advertisements - when ad ends, real song will trigger new search if (isAdvertisement(info)) { console.log(`📢 [Lyrics+] Advertisement detected - skipping lyrics search`); return; } // ═══════════════════════════════════════════════════════════════════════════ // RACE CONDITION PREVENTION: Search ID Tracking // ═══════════════════════════════════════════════════════════════════════════ // For non-advertisement tracks, we use search ID tracking to handle // rapid song changes (e.g., skipping tracks, shuffle, autoplay). // ═══════════════════════════════════════════════════════════════════════════ // Generate a unique search ID for this search request // Using both performance.now() and a counter for guaranteed uniqueness const searchId = `${info.id}_${performance.now()}_${++searchIdCounter}`; currentSearchId = searchId; // Helper function to check if this search is still current // Returns false if a newer search has superseded this one const isSearchStillCurrent = () => { if (currentSearchId !== searchId) { DEBUG.log('Autodetect', `Search aborted - newer search has started`); return false; } return true; }; // Clear current provider so no provider is highlighted while searching for lyrics // This fixes the edge case where cached lyrics from the previous song left a provider // highlighted, and the next song's search would show that stale highlight Providers.current = null; if (popup._lyricsTabs) updateTabs(popup._lyricsTabs, true); // Check cache first unless forcing refresh if (!forceRefresh) { const cachedData = LyricsCache.get(info.id); if (cachedData) { // Handle cached instrumental tracks - display error message if (cachedData.instrumental && cachedData.error) { console.log(`🎵 [Lyrics+] Loaded instrumental track from cache - no lyrics available`); DEBUG.log('Autodetect', `Loaded instrumental from cache in <1ms`); // Clear provider highlighting Providers.current = null; if (popup._lyricsTabs) updateTabs(popup._lyricsTabs, true); // Display error message const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) { lyricsContainer.textContent = cachedData.error; } // Hide buttons hideButtonsForInstrumental(popup); return; } const success = loadLyricsFromCache(popup, info, cachedData); if (success) { console.log(`⚡ [Lyrics+] Lyrics loaded instantly from cache (no internet needed!)`); DEBUG.log('Autodetect', `Loaded from cache in <1ms using ${cachedData.provider}`); return; } } } console.log(`🔍 [Lyrics+] Searching for lyrics: "${info.title}" by ${info.artist}`); DEBUG.log('Autodetect', 'Starting provider autodetect', info); const startTime = performance.now(); const mainProviders = ["LRCLIB", "Spotify", "KPoe", "Musixmatch"]; const sessionResults = []; // { name, result } - stores providers that returned unsynced lyrics (but not synced) for phase 2 fallback console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); console.log(`🎵 [Lyrics+] \n\nFetching lyrics from providers LRCLIB, Spotify, KPoe and Musixmatch.\nSynced lyrics are preferred.\nIf a provider only finds unsynced lyrics, they will be stored in the autodetect logic's memory.\nIf no synced lyrics are found on any provider, unsynced lyrics will be cached from the highest-priority provider that returned them.\nIf no lyrics were found at all, Genius provider (unsynced lyrics only) will be tried.`); for (const name of mainProviders) { try { const providerStartTime = performance.now(); DEBUG.provider.start(name, 'getSynced', info); const provider = Providers.map[name]; const result = await provider.findLyrics(info, 'synced'); // ═══ CHECKPOINT 1: After async provider call ═══ // While waiting for the provider API response, a new song may have started. // Check if we're still the current search. If not, abort to prevent // outdated results from continuing to search and potentially overwriting UI. if (!isSearchStillCurrent()) return; const providerDuration = performance.now() - providerStartTime; if (result && !result.error) { // Check if track is marked as instrumental by the provider // Instrumental tracks have no lyrics, so we should stop searching and cache this result if (result.instrumental) { if (!isSearchStillCurrent()) return; console.log(`🎵 [Lyrics+] Track is instrumental (no lyrics) - detected by ${name}`); DEBUG.log('Autodetect', `Track marked as instrumental by ${name}`); // Convert instrumental to an error result result.error = "♪ Instrumental Track ♪\n\nThis track has no lyrics"; // Hide buttons and cache the instrumental status hideButtonsForInstrumental(popup); cacheInstrumentalTrack(info.id, name, info); // Don't highlight any provider since instrumental means no lyrics from any source Providers.current = null; if (popup._lyricsTabs) updateTabs(popup._lyricsTabs, true); // Display error message through the standard error path const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) { lyricsContainer.textContent = result.error; } const totalDuration = performance.now() - startTime; DEBUG.log('Autodetect', `Completed in ${totalDuration.toFixed(2)}ms - instrumental track detected by ${name}`); return; } const synced = provider.getSynced(result); if (synced && synced.length > 0) { // ═══ CHECKPOINT 2: Before UI update with lyrics ═══ // Found lyrics! But before updating UI, verify we're STILL current. // This prevents: Old search finds lyrics after new search already updated UI. if (!isSearchStillCurrent()) return; DEBUG.provider.success(name, 'getSynced', 'synced', synced.length); DEBUG.provider.timing(name, 'getSynced', providerDuration.toFixed(2)); // Store metadata if available (e.g., KPoe server info) currentLyricsMetadata = result?.metadata || null; Providers.setCurrent(name); if (popup._lyricsTabs) updateTabs(popup._lyricsTabs); await updateLyricsContent(popup, info, result); const totalDuration = performance.now() - startTime; DEBUG.log('Autodetect', `Completed successfully in ${totalDuration.toFixed(2)}ms using ${name}`); return; } // No synced lyrics - check for unsynced to store for phase 2 fallback const unsynced = provider.getUnsynced(result); if (unsynced && unsynced.length > 0) { DEBUG.debug('Provider', `${name} returned unsynced lyrics only, stored for phase 2`); sessionResults.push({ name, result }); } else { DEBUG.debug('Provider', `${name} getSynced returned empty lyrics`); } } else { DEBUG.provider.failure(name, 'getSynced', result?.error || 'No result'); } DEBUG.provider.timing(name, 'getSynced', providerDuration.toFixed(2)); } catch (error) { // If a provider fails for any reason, continue looking for lyrics in other providers // Without this try-catch, an error would skip the remaining providers and stop the loop. DEBUG.provider.failure(name, 'getSynced', error); } } // ═══ CHECKPOINT: Before phase 2 ═══ if (!isSearchStillCurrent()) return; // Check stored results from phase 1 (highest-priority provider first) for (const { name, result } of sessionResults) { if (!isSearchStillCurrent()) return; const provider = Providers.map[name]; const unsynced = provider.getUnsynced(result); if (unsynced && unsynced.length > 0) { DEBUG.provider.success(name, 'getUnsynced', 'unsynced', unsynced.length); // No separate timing to log - this result was already fetched during phase 1 // Store metadata if available (e.g., KPoe server info) currentLyricsMetadata = result?.metadata || null; Providers.setCurrent(name); if (popup._lyricsTabs) updateTabs(popup._lyricsTabs); await updateLyricsContent(popup, info, result); const totalDuration = performance.now() - startTime; DEBUG.log('Autodetect', `Completed successfully in ${totalDuration.toFixed(2)}ms using ${name}`); return; } } // No unsynced from main providers - try Genius (unsynced only, unchanged) try { const providerStartTime = performance.now(); DEBUG.provider.start('Genius', 'getUnsynced', info); const provider = Providers.map['Genius']; const result = await provider.findLyrics(info, 'unsynced'); // ═══ CHECKPOINT 1: After async provider call ═══ if (!isSearchStillCurrent()) return; const providerDuration = performance.now() - providerStartTime; if (result && !result.error) { const unsynced = provider.getUnsynced(result); if (unsynced && unsynced.length > 0) { // ═══ CHECKPOINT 2: Before UI update with lyrics ═══ if (!isSearchStillCurrent()) return; DEBUG.provider.success('Genius', 'getUnsynced', 'unsynced', unsynced.length); DEBUG.provider.timing('Genius', 'getUnsynced', providerDuration.toFixed(2)); currentLyricsMetadata = result?.metadata || null; Providers.setCurrent('Genius'); if (popup._lyricsTabs) updateTabs(popup._lyricsTabs); await updateLyricsContent(popup, info, result); const totalDuration = performance.now() - startTime; DEBUG.log('Autodetect', `Completed successfully in ${totalDuration.toFixed(2)}ms using Genius`); return; } else { DEBUG.debug('Provider', `Genius getUnsynced returned empty lyrics`); } } else { DEBUG.provider.failure('Genius', 'getUnsynced', result?.error || 'No result'); } DEBUG.provider.timing('Genius', 'getUnsynced', providerDuration.toFixed(2)); } catch (error) { // If a provider fails for any reason, continue to "no lyrics found" DEBUG.provider.failure('Genius', 'getUnsynced', error); } // ═══ CHECKPOINT 3: Before "No lyrics found" message ═══ // Checked all providers, no lyrics found. Before showing error message, // verify we're still current. This is CRITICAL for the advertisement scenario: // - Song search finds nothing after checking all providers // - But advertisement already started and found lyrics // - Without this check, song search would overwrite ad lyrics with "No lyrics found" // With this check: Song search aborts, ad lyrics remain on screen ✓ if (!isSearchStillCurrent()) return; // Unselect any provider Providers.current = null; if (popup._lyricsTabs) updateTabs(popup._lyricsTabs, true); const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) lyricsContainer.textContent = "No lyrics found from any provider"; currentSyncedLyrics = null; currentLyricsContainer = lyricsContainer; // Reset translation state when no lyrics are found translationPresent = false; transliterationPresent = false; lastTranslatedLang = null; const totalDuration = performance.now() - startTime; DEBUG.warn('Autodetect', `No lyrics found after checking all providers (${totalDuration.toFixed(2)}ms)`); } function startPollingForTrackChange(popup) { if (pollingInterval) clearInterval(pollingInterval); pollingInterval = setInterval(() => { const info = getCurrentTrackInfo(); if (!info) return; // Get current playback position const posEl = document.querySelector('[data-testid="playback-position"]'); const currentPosition = posEl ? timeStringToMs(posEl.textContent) : 0; // Detect song restart (for repeat one): same track ID but position reset to near 0 // This happens when repeat one is enabled and song ends const RESTART_THRESHOLD_MS = 5000; // If position jumps from >5s to <5s, it's a restart const isRestart = ( info.id === currentTrackId && lastPlaybackPosition > RESTART_THRESHOLD_MS && currentPosition < RESTART_THRESHOLD_MS ); if (isRestart) { console.log(`🔁 [Lyrics+] Song restarted! Repeat One detected for "${info.title}"`); console.log(` ⏮️ Resetting lyrics scroll to the beginning...`); DEBUG.info('Track', `Song restarted (repeat one): ${info.title} - Position: ${lastPlaybackPosition}ms → ${currentPosition}ms`); // For repeat one, just reset scroll to beginning (lyrics already cached) if (currentLyricsContainer && isShowingSyncedLyrics) { const firstLine = currentLyricsContainer.querySelector('p'); if (firstLine) { firstLine.scrollIntoView({ behavior: "smooth", block: "center" }); console.log(` ✅ Lyrics scrolled back to start (cached lyrics, no loading needed!)`); DEBUG.debug('Track', 'Scroll reset to beginning for repeat one'); } } } // Track changed to a different song if (info.id !== currentTrackId) { DEBUG.track.changed(currentTrackId, info.id, info); currentTrackId = info.id; lastPlaybackPosition = 0; lastTrackDuration = info.duration || 0; const lyricsContainer = popup.querySelector("#lyrics-plus-content"); if (lyricsContainer) { pipVideoDetachIfInContainer(); lyricsContainer.textContent = "Loading lyrics..."; if (isPipActive || isPagePipActive) enterPipInLyricsContainer(); } autodetectProviderAndLoad(popup, info); } // Update last position for next iteration lastPlaybackPosition = currentPosition; // Update all button states using DOM-cloned icons from Spotify's visible buttons if (popup && popup._playPauseBtn) { updatePlayPauseButton(popup._playPauseBtn.button, popup._playPauseBtn.iconWrapper); } if (popup && popup._shuffleBtn) { updateShuffleButton(popup._shuffleBtn.button, popup._shuffleBtn.iconWrapper); } if (popup && popup._repeatBtn) { updateRepeatButton(popup._repeatBtn.button, popup._repeatBtn.iconWrapper); } // Update prev/next button icons from Spotify's DOM if (popup && popup._prevBtn) { updatePreviousButtonIcon(popup._prevBtn.iconWrapper); } if (popup && popup._nextBtn) { updateNextButtonIcon(popup._nextBtn.iconWrapper); } }, TIMING.POLLING_INTERVAL_MS); } function stopPollingForTrackChange() { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } } function addButton(maxRetries = LIMITS.BUTTON_ADD_MAX_RETRIES) { let attempts = 0; const tryAdd = () => { // const nowPlayingViewBtn = document.querySelector('[data-testid="control-button-npv"]'); // NowPlayingView control button is no longer a fallback as it has been removed in a Spotify UI revamp change const micBtn = document.querySelector('[data-testid="lyrics-button"]'); const targetBtn = micBtn; // previously: nowPlayingViewBtn || micBtn; const controls = targetBtn?.parentElement; if (!controls) { if (attempts < maxRetries) { attempts++; DEBUG.debug('Button', `Injection attempt ${attempts}/${maxRetries} - controls not found, retrying...`); setTimeout(tryAdd, TIMING.BUTTON_ADD_RETRY_MS); } else { DEBUG.error('Button', `Failed to inject Lyrics+ button after ${maxRetries} attempts`); } return; } if (document.getElementById("lyrics-plus-btn")) { return; } const btn = document.createElement("button"); btn.id = "lyrics-plus-btn"; btn.title = "Show Lyrics+"; btn.textContent = "Lyrics+"; DEBUG.info('Button', 'Lyrics+ button injected successfully'); Object.assign(btn.style, { backgroundColor: "#1aa34a", border: "none", borderRadius: "20px", color: "#e0e0e0", fontWeight: "600", fontSize: "14px", padding: "6px 12px", marginLeft: "8px", userSelect: "none", cursor: "pointer", }); btn.onclick = () => { let popup = document.getElementById("lyrics-plus-popup"); if (popup) { removePopup(); stopPollingForTrackChange(); return; } createPopup(); }; controls.insertBefore(btn, targetBtn); }; tryAdd(); } // Global observer to inject Lyrics+ button when DOM changes const buttonInjectionObserver = new MutationObserver(() => { addButton(); }); ResourceManager.registerObserver(buttonInjectionObserver, 'Global button injection (document.body)'); buttonInjectionObserver.observe(document.body, { childList: true, subtree: true }); function init() { // Apply AMOLED theme if enabled in localStorage let savedTheme = localStorage.getItem('lyricsPlusTheme'); if (savedTheme === null) savedTheme = false; else savedTheme = JSON.parse(savedTheme); if (savedTheme) { document.body.classList.add('lyrics-plus-amoled-theme'); console.info("🎨 [Lyrics+ Init] AMOLED theme applied on page load"); } else { console.info("🎨 [Lyrics+ Init] Default theme active (AMOLED disabled)"); } addButton(); } const appRoot = document.querySelector('#main'); if (appRoot) { const pageObserver = new MutationObserver(() => { addButton(); }); ResourceManager.registerObserver(pageObserver, 'Page observer (appRoot)'); pageObserver.observe(appRoot, { childList: true, subtree: true }); } // ------------------------ // Popup Auto-Resize Setup // ------------------------ // The popup will always keep the same proportion of the window as last set by the user. // Try to load last saved proportion from localStorage function loadProportion() { try { const stored = JSON.parse(localStorage.getItem("lyricsPlusPopupProportion") || "{}"); if (stored.w && stored.h) { window.lastProportion = stored; } } catch {} } loadProportion(); function applyProportionToPopup(popup) { if (window.lyricsPlusPopupIsResizing || window.lyricsPlusPopupIgnoreProportion || window.lyricsPlusPopupIsDragging) { return; } // Skip applying proportion if user has dragged the popup recently if (window.lyricsPlusPopupLastDragged && (Date.now() - window.lyricsPlusPopupLastDragged) < TIMING.DRAG_DEBOUNCE_MS) { return; } if (!popup || !window.lastProportion.w || !window.lastProportion.h || window.lastProportion.x === undefined || window.lastProportion.y === undefined) { return; } popup.style.width = (window.innerWidth * window.lastProportion.w) + "px"; popup.style.height = (window.innerHeight * window.lastProportion.h) + "px"; popup.style.left = (window.innerWidth * window.lastProportion.x) + "px"; popup.style.top = (window.innerHeight * window.lastProportion.y) + "px"; popup.style.right = "auto"; popup.style.bottom = "auto"; popup.style.position = "fixed"; } function savePopupState(el) { const rect = el.getBoundingClientRect(); window.lastProportion = { w: rect.width / window.innerWidth, h: rect.height / window.innerHeight, x: rect.left / window.innerWidth, y: rect.top / window.innerHeight }; localStorage.setItem('lyricsPlusPopupProportion', JSON.stringify(window.lastProportion)); } // Call this after user resizes the popup: function observePopupResize() { const popup = document.getElementById("lyrics-plus-popup"); if (!popup) return; // Guard: skip if resize handlers are already attached to this popup instance if (popup._resizeMouseupHandler) return; let isResizing = false; const resizer = Array.from(popup.children).find(el => el.style && el.style.cursor === "nwse-resize" ); if (!resizer) return; const mousedownHandler = () => { isResizing = true; }; const mouseupHandler = () => { if (isResizing) { savePopupState(popup); } isResizing = false; }; resizer.addEventListener("mousedown", mousedownHandler); // Store handler on popup for cleanup popup._resizeMouseupHandler = mouseupHandler; window.addEventListener("mouseup", mouseupHandler); DEBUG.debug('PopupResize', 'Resize handlers attached'); } // Listen for popup creation to hook the resizer const popupResizeObserver = new MutationObserver(() => { const popup = document.getElementById("lyrics-plus-popup"); if (popup) { applyProportionToPopup(popup); observePopupResize(); } }); ResourceManager.registerObserver(popupResizeObserver, 'Popup resize observer'); popupResizeObserver.observe(document.body, { childList: true, subtree: true }); // On window resize, apply saved proportion const windowResizeHandler = () => { const popup = document.getElementById("lyrics-plus-popup"); if (popup) { applyProportionToPopup(popup); } }; ResourceManager.registerWindowListener("resize", windowResizeHandler, 'Popup proportion on window resize'); // Register menu commands for debug functions GM_registerMenuCommand('Debug: Clear Cache', () => { const stats = LyricsCache.getStats(); const confirmMsg = `Clear lyrics cache?\n\nCurrent cache: ${stats.size} songs (${stats.totalKB} KB of ${stats.maxKB} KB)\n\nThis will remove all cached lyrics and they will need to be fetched again.`; if (confirm(confirmMsg)) { LyricsCache.clear(); alert(`✅ Cache cleared successfully!\n\nAll ${stats.size} cached songs have been removed.`); } }); GM_registerMenuCommand('Debug: Get Cache Stats', () => { const stats = LyricsCache.getStats(); console.log('%c[Lyrics+] Cache Statistics:', 'color: #64B5F6; font-weight: bold;', stats); console.log(` Cache size: ${stats.size}/${stats.maxEntries} songs`); if (stats.entries.length > 0) { const tableData = {}; stats.entries.forEach((entry, i) => { tableData[i + 1] = entry; }); console.table(tableData); } alert( 'Cache statistics have been logged to the console.\n' + 'Open DevTools (Press F12 or Right click and Inspect), then select the Logs tab under Console to view it.' ); }); GM_registerMenuCommand('Debug: Get Track Info', () => { const info = getCurrentTrackInfo(); console.log('%c[Lyrics+] Current Track Info:', 'color: #64B5F6; font-weight: bold;', info); alert( 'Track information has been logged to the console.\n' + 'Open DevTools (Press F12 or Right click and Inspect), then select the Logs tab under Console to view it.' ); }); GM_registerMenuCommand('Debug: Get Repeat State', () => { const state = getRepeatState(); console.log('%c[Lyrics+] Repeat State:', 'color: #64B5F6; font-weight: bold;', state); alert( 'Repeat state has been logged to the console.\n' + 'Open DevTools (Press F12 or Right click and Inspect), then select the Logs tab under Console to view it.' ); }); init(); })();