// ==UserScript==
// @name Cigi Spotify Translator
// @namespace http://tampermonkey.net/
// @version 1.0.fork
// @description Extract, translate, and display Spotify lyrics with a language selector and manual translation trigger
// @author Raiwulf, Myst1cX (fork)
// @match *://*.spotify.com/*
// @grant GM_registerMenuCommand
// @grant GM_unregisterMenuCommand
// @grant GM_getValue
// @grant GM_setValue
// @license MIT
// @homepageURL https://github.com/Myst1cX/cigi-spotify-translator-fork
// @supportURL https://github.com/Myst1cX/cigi-spotify-translator-fork/issues
// @updateURL https://raw.githubusercontent.com/Myst1cX/cigi-spotify-translator-fork/main/cigi-spotify-translator-fork.user.js
// @downloadURL https://raw.githubusercontent.com/Myst1cX/cigi-spotify-translator-fork/main/cigi-spotify-translator-fork.user.js
// ==/UserScript==
// Original version: 1.0
// GREASYFORK LINK: https://greasyfork.org/en/scripts/523415-cigi-spotify-translator
// Changelog: 1.0.fork
// Full rewrite of translation triggering, lyrics persistence, header positioning, and caching.
// Translate is now a manual, on-demand action (was: auto-translated on every lyrics DOM change
// plus a 2s polling loop). A generation-counter state machine prevents race conditions when
// clicking Translate repeatedly or switching languages mid-run.
// Lyrics detection updated for Spotify's current DOM ([data-testid="lyrics-line"]; the old
// [data-testid="fullscreen-lyric"/"lyrics-container"] selectors were retired by Spotify).
// Translations no longer disappear during playback or when seeking - a repair-only
// MutationObserver reinserts them from an in-memory cache whenever Spotify's React re-render
// wipes them out, with zero extra network calls.
// The language/Translate header is now fixed-position and stays pinned to the top of the
// screen regardless of scrolling (synced to .main-view-container via ResizeObserver + resize
// listener), and fully mounts/unmounts with the lyrics view opening/closing, cleaning up all
// observers, timeouts, and state each time.
// Added a persistent, cross-session, cross-language translation cache (GM_setValue/GM_getValue),
// so re-opening a song/language you've already translated loads instantly with no network call.
// Count-based LRU eviction; viewable and clearable via new Tampermonkey menu commands ("Cigi:
// Show Translation Cache" / "Cigi: Clear Translation Cache").
// Added two small fade-in icons next to the Translate button as visual feedback: one for a
// fresh translation just saved to the cache, one for a translation loaded from the cache.
// Both share a single positioning slot so they render in the exact same spot.
// Added a toggleable "Debug Logging (console)" option in the Tampermonkey menu for optional
// verbose console output - off by default, no page reload required to toggle.
(function () {
'use strict';
const DEFAULT_LANGUAGE = 'en';
let isTranslating = false;
let headerElement = null;
let headerSpacerElement = null;
let headerResizeObserver = null;
let newlyCachedIconElement = null;
let newlyCachedIconHideTimeout = null;
let cacheHitIconElement = null;
let cacheHitIconHideTimeout = null;
// ---- Debug logging (toggle via Tampermonkey menu, off by default) ----
let debugEnabled = GM_getValue('cigiDebugLogging', false);
let debugMenuId = null;
function dbgLog(msg, ...extra) {
if (!debugEnabled) return;
console.log(`%c[Cigi Translator] ${msg}`, 'color:#1db954;font-weight:bold;', ...extra);
}
function registerDebugMenu() {
const label = (debugEnabled ? '✅' : '❌') + ' Debug Logging (console)';
debugMenuId = GM_registerMenuCommand(label, toggleDebugLogging);
}
function toggleDebugLogging() {
debugEnabled = !debugEnabled;
GM_setValue('cigiDebugLogging', debugEnabled);
console.log(`[Cigi Translator] Debug logging ${debugEnabled ? 'enabled' : 'disabled'}`);
if (debugMenuId !== null) {
GM_unregisterMenuCommand(debugMenuId);
}
registerDebugMenu();
}
registerDebugMenu();
const languages = {
// Popular 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',
// Rest in alphabetical order
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 getSavedLanguage() {
const lang = localStorage.getItem('spotifyLyricsTranslationLang') || DEFAULT_LANGUAGE;
dbgLog(`getSavedLanguage() -> "${lang}"`);
return lang;
}
function saveLanguage(lang) {
dbgLog(`saveLanguage() saving "${lang}"`);
localStorage.setItem('spotifyLyricsTranslationLang', lang);
}
async function translateText(text, targetLang) {
dbgLog(`translateText() requesting translation to "${targetLang}" for: "${text}"`);
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();
const result = data[0][0][0];
dbgLog(`translateText() result: "${result}"`);
return result;
} catch (error) {
console.error('Translation failed:', error);
dbgLog('translateText() failed', error);
return '[Translation Error]';
}
}
// Generation counter: each translateLyrics() run gets a ticket number.
// If a newer run starts before an older one's network requests resolve,
// the older run's results are discarded on arrival instead of being written to the DOM.
let translateGeneration = 0;
let translatingForLang = null;
// Repair mechanism: Spotify's lyrics view is React-owned. When a line's
// synced-highlight state changes (playback reaching it, or the user clicking
// to seek), React re-renders that line's children and wipes our foreign
// data-translated div, even though nothing about the translation itself changed.
// We cache original-text -> translated-text pairs from the last successful run
// and, if Spotify wipes a line, reinsert its translation from that cache.
// This is a pure DOM patch: no translateText() calls, no network requests.
let lineTranslationCache = new Map();
let repairObserver = null;
function disconnectRepairObserver() {
if (repairObserver) {
repairObserver.disconnect();
repairObserver = null;
dbgLog('disconnectRepairObserver() repair observer disconnected');
}
}
function repairMissingTranslations() {
const lines = document.querySelectorAll('[data-testid="lyrics-line"]');
let repaired = 0;
lines.forEach(line => {
if (line.querySelector('[data-translated="true"]')) return;
const textDiv = Array.from(line.children).find(child => !child.hasAttribute('data-translated'));
if (!textDiv) return;
const originalText = textDiv.textContent.trim();
const cachedTranslation = lineTranslationCache.get(originalText);
if (!cachedTranslation) return;
const translationDiv = document.createElement('div');
translationDiv.style.color = 'gray';
translationDiv.style.fontStyle = 'italic';
translationDiv.textContent = cachedTranslation;
translationDiv.setAttribute('data-translated', 'true');
textDiv.parentNode.insertBefore(translationDiv, textDiv.nextSibling);
repaired++;
});
if (repaired > 0) {
dbgLog(`repairMissingTranslations() Spotify re-render wiped ${repaired} line(s), reinserted from cache (no network calls)`);
}
}
function attachRepairObserver() {
disconnectRepairObserver();
const anyLine = document.querySelector('[data-testid="lyrics-line"]');
if (!anyLine || !anyLine.parentNode) {
dbgLog('attachRepairObserver() no lyrics-line parent found, repair observer NOT attached');
return;
}
repairObserver = new MutationObserver(repairMissingTranslations);
repairObserver.observe(anyLine.parentNode, { childList: true, subtree: true });
dbgLog('attachRepairObserver() watching for Spotify re-renders that wipe translations (repair only, no network calls)');
}
// ---- Song identity: prefer the real Spotify trackId, fall back to title-artist ----
// Same anchor selector Web Lyrics+ uses. Confirmed present (this session's live-DOM
// check) while lyrics are open for a regular playing track; not separately re-verified
// for playlist/album/search/radio contexts - the title-artist fallback below covers
// those if the anchor ever turns out to be missing there.
function getCurrentTrackId() {
const anchor = document.querySelector('a[data-testid="context-link"][data-context-item-type="track"][href*="uri=spotify%3Atrack%3A"]');
if (!anchor) return null;
const href = decodeURIComponent(anchor.getAttribute('href') || '');
const match = href.match(/spotify:track:([a-zA-Z0-9]{22})/);
return match ? match[1] : null;
}
function getCurrentTrackLabel() {
const titleEl = document.querySelector('[data-testid="context-item-info-title"]');
const artistEl = document.querySelector('[data-testid="context-item-info-subtitles"]');
const title = titleEl ? titleEl.textContent.trim() : '';
const artist = artistEl ? artistEl.textContent.trim() : '';
if (title && artist) return `${title} — ${artist}`;
return title || artist || 'Unknown track';
}
function getCurrentSongIdentity() {
const trackId = getCurrentTrackId();
if (trackId) {
dbgLog(`getCurrentSongIdentity() resolved trackId "${trackId}"`);
return { id: trackId, label: getCurrentTrackLabel() };
}
const titleEl = document.querySelector('[data-testid="context-item-info-title"]');
const artistEl = document.querySelector('[data-testid="context-item-info-subtitles"]');
const title = titleEl ? titleEl.textContent.trim() : '';
const artist = artistEl ? artistEl.textContent.trim() : '';
if (!title && !artist) {
dbgLog('getCurrentSongIdentity() no trackId and no title/artist found, cache lookup will be skipped');
return { id: null, label: null };
}
const fallbackId = `${title}-${artist}`;
dbgLog(`getCurrentSongIdentity() no trackId anchor found, falling back to title-artist id "${fallbackId}"`);
return { id: fallbackId, label: getCurrentTrackLabel() };
}
// ---- Persistent translation cache (cross-session, cross-language) ----
// Distinct from lineTranslationCache above: that one is session-only, per-run, and
// exists purely to feed the repair observer. This one survives page reloads and is
// keyed per song+language so switching back to a previously-used language for the
// same song can resolve instantly with zero network calls.
const CIGI_CACHE_STORAGE_KEY = 'cigiTranslationCache_v1';
const CIGI_CACHE_COUNT_LIMIT = 300; // generous safety net; eviction is count-based, not byte-based
const PersistentCache = {
getAll() {
try {
return JSON.parse(GM_getValue(CIGI_CACHE_STORAGE_KEY, '{}'));
} catch (e) {
console.warn('[Cigi Translator] PersistentCache.getAll() failed to parse stored cache, resetting', e);
return {};
}
},
saveAll(cache) {
try {
GM_setValue(CIGI_CACHE_STORAGE_KEY, JSON.stringify(cache));
} catch (e) {
console.warn('[Cigi Translator] PersistentCache.saveAll() failed to write, translation still rendered but not cached', e);
}
},
get(trackId, lang) {
const key = `${trackId}::${lang}`;
const cache = this.getAll();
const entry = cache[key];
if (!entry) {
console.log(`[Cigi Translator] No cached translation for "${key}"`);
dbgLog(`PersistentCache.get() miss for key "${key}"`);
return null;
}
console.log(`[Cigi Translator] Loaded translation from cache: "${entry.trackLabel}" (${lang})`);
dbgLog(`PersistentCache.get() hit for key "${key}", ${entry.lines.length} lines, last cached ${new Date(entry.timestamp).toISOString()}`);
entry.timestamp = Date.now(); // LRU touch
cache[key] = entry;
this.saveAll(cache);
return entry;
},
set(trackId, lang, trackLabel, lines) {
const key = `${trackId}::${lang}`;
const cache = this.getAll();
const isNewEntry = !cache[key];
cache[key] = { trackId, lang, trackLabel, lines, timestamp: Date.now() };
this.evictIfNeeded(cache);
this.saveAll(cache);
if (isNewEntry) {
dbgLog(`PersistentCache.set() new entry cached for "${trackLabel}" (${lang}), key "${key}", ${lines.length} lines`);
} else {
dbgLog(`PersistentCache.set() existing entry overwritten for key "${key}", ${lines.length} lines`);
}
},
evictIfNeeded(cache) {
const keys = Object.keys(cache);
if (keys.length <= CIGI_CACHE_COUNT_LIMIT) return;
const oldestFirst = keys
.map(key => ({ key, timestamp: cache[key].timestamp || 0 }))
.sort((a, b) => a.timestamp - b.timestamp);
const excess = oldestFirst.length - CIGI_CACHE_COUNT_LIMIT;
for (let i = 0; i < excess; i++) {
delete cache[oldestFirst[i].key];
}
dbgLog(`PersistentCache.evictIfNeeded() evicted ${excess} oldest entr${excess === 1 ? 'y' : 'ies'} (count limit ${CIGI_CACHE_COUNT_LIMIT})`);
},
clear() {
try {
GM_setValue(CIGI_CACHE_STORAGE_KEY, '{}');
} catch (e) {
console.warn('[Cigi Translator] PersistentCache.clear() failed', e);
}
},
getStats() {
const cache = this.getAll();
const entries = Object.values(cache).map(entry => {
const sizeBytes = new Blob([JSON.stringify(entry)]).size;
return {
trackLabel: entry.trackLabel,
lang: entry.lang,
lineCount: entry.lines.length,
sizeKB: (sizeBytes / 1024).toFixed(2),
cached: new Date(entry.timestamp).toISOString()
};
});
const totalKB = (entries.reduce((sum, e) => sum + parseFloat(e.sizeKB), 0)).toFixed(2);
return { count: entries.length, totalKB, entries };
}
};
function showTranslationCache() {
const stats = PersistentCache.getStats();
console.log(`[Cigi Translator] Translation cache: ${stats.count} entries, ${stats.totalKB} KB`);
const rows = {};
stats.entries.forEach((e, i) => {
rows[i + 1] = {
Track: e.trackLabel,
Language: e.lang,
Lines: e.lineCount,
'Size (KB)': e.sizeKB,
Cached: e.cached
};
});
console.table(rows);
alert('Cigi Translator: cache logged to console. Open DevTools to view.');
}
function clearTranslationCache() {
const stats = PersistentCache.getStats();
const confirmed = confirm(`Clear Cigi translation cache? Currently ${stats.count} entries, ${stats.totalKB} KB.`);
if (!confirmed) return;
PersistentCache.clear();
alert('Cigi Translator: translation cache cleared.');
}
GM_registerMenuCommand('Cigi: Show Translation Cache', showTranslationCache);
GM_registerMenuCommand('Cigi: Clear Translation Cache', clearTranslationCache);
function showNewlyCachedIcon() {
if (!newlyCachedIconElement) return;
if (newlyCachedIconHideTimeout) {
clearTimeout(newlyCachedIconHideTimeout);
newlyCachedIconHideTimeout = null;
}
newlyCachedIconElement.style.opacity = '1';
newlyCachedIconHideTimeout = setTimeout(() => {
if (newlyCachedIconElement) newlyCachedIconElement.style.opacity = '0';
newlyCachedIconHideTimeout = null;
}, 2000);
dbgLog('showNewlyCachedIcon() displayed newly-cached icon, fading after 2s');
}
function showCacheHitIcon() {
if (!cacheHitIconElement) return;
if (cacheHitIconHideTimeout) {
clearTimeout(cacheHitIconHideTimeout);
cacheHitIconHideTimeout = null;
}
cacheHitIconElement.style.opacity = '1';
cacheHitIconHideTimeout = setTimeout(() => {
if (cacheHitIconElement) cacheHitIconElement.style.opacity = '0';
cacheHitIconHideTimeout = null;
}, 2000);
dbgLog('showCacheHitIcon() displayed cache-hit icon, fading after 2s');
}
async function translateLyrics() {
const targetLang = getSavedLanguage();
const myGeneration = ++translateGeneration;
isTranslating = true;
translatingForLang = targetLang;
disconnectRepairObserver(); // old cache/observer belong to the previous run, no longer valid
dbgLog(`translateLyrics() started (gen ${myGeneration}, lang "${targetLang}")`);
const lyricsDivs = document.querySelectorAll('[data-testid="lyrics-line"] div');
dbgLog(`translateLyrics() found ${lyricsDivs.length} candidate lyric divs, target lang "${targetLang}"`);
const originalLines = [];
lyricsDivs.forEach((div, index) => {
const originalText = div.textContent.trim();
if (originalText && originalText !== "♪") {
originalLines.push({ index, text: originalText });
}
});
dbgLog(`translateLyrics() ${originalLines.length} lines queued for translation`);
const identity = getCurrentSongIdentity();
const cachedEntry = identity.id ? PersistentCache.get(identity.id, targetLang) : null;
if (cachedEntry) {
if (myGeneration !== translateGeneration) {
dbgLog(`translateLyrics() gen ${myGeneration} superseded before cache-hit render, discarding`);
return;
}
const translationByOriginal = new Map(cachedEntry.lines.map(pair => [pair.original, pair.translated]));
lineTranslationCache = new Map();
let inserted = 0;
originalLines.forEach(({ index }) => {
const targetDiv = lyricsDivs[index];
const originalText = targetDiv.textContent.trim();
const translatedText = translationByOriginal.get(originalText);
if (translatedText === undefined) return;
const translationDiv = document.createElement('div');
translationDiv.style.color = 'gray';
translationDiv.style.fontStyle = 'italic';
translationDiv.textContent = translatedText;
translationDiv.setAttribute('data-translated', 'true');
targetDiv.parentNode.insertBefore(translationDiv, targetDiv.nextSibling);
lineTranslationCache.set(originalText, translatedText);
inserted++;
});
isTranslating = false;
translatingForLang = null;
dbgLog(`translateLyrics() finished from cache (gen ${myGeneration}), inserted ${inserted} translation lines`);
showCacheHitIcon();
attachRepairObserver();
return;
}
const translatedLines = await Promise.all(originalLines.map(async (line) => {
const translatedText = await translateText(line.text, targetLang);
return { index: line.index, translatedText };
}));
if (myGeneration !== translateGeneration) {
dbgLog(`translateLyrics() gen ${myGeneration} superseded by gen ${translateGeneration}, discarding results`);
return;
}
lineTranslationCache = new Map();
translatedLines.forEach(({ index, translatedText }, i) => {
const targetDiv = lyricsDivs[index];
const translationDiv = document.createElement('div');
translationDiv.style.color = 'gray';
translationDiv.style.fontStyle = 'italic';
translationDiv.textContent = translatedText;
translationDiv.setAttribute('data-translated', 'true');
targetDiv.parentNode.insertBefore(translationDiv, targetDiv.nextSibling);
lineTranslationCache.set(originalLines[i].text, translatedText);
});
if (identity.id && translatedLines.length > 0) {
const pairs = originalLines.map((line, i) => ({ original: line.text, translated: translatedLines[i].translatedText }));
PersistentCache.set(identity.id, targetLang, identity.label, pairs);
showNewlyCachedIcon();
}
isTranslating = false;
translatingForLang = null;
dbgLog(`translateLyrics() finished (gen ${myGeneration}), inserted ${translatedLines.length} translation lines`);
attachRepairObserver();
}
function createHeader() {
dbgLog('createHeader() building UI');
const header = document.createElement('div');
header.style.cssText = `
display: flex;
justify-content: center;
align-items: center;
padding: 12px 0;
background: rgba(40, 40, 40, 0.95);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
margin: 0;
position: fixed;
z-index: 9999;
`;
const controlsContainer = document.createElement('div');
controlsContainer.style.cssText = `
display: flex;
align-items: center;
justify-content: center;
gap: 15px;
max-width: 600px;
width: 90%;
`;
const selectContainer = document.createElement('div');
selectContainer.style.cssText = `
position: relative;
flex: 0 1 200px;
min-width: 120px;
`;
const selectButton = document.createElement('button');
selectButton.style.cssText = `
width: 100%;
padding: 8px;
border: none;
border-radius: 4px;
background: rgba(80, 80, 80, 1);
color: white;
font-size: 14px;
text-align: left;
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
`;
selectButton.textContent = languages[getSavedLanguage()];
const dropdown = document.createElement('div');
dropdown.style.cssText = `
display: none;
position: absolute;
top: 100%;
left: 0;
right: 0;
background: rgba(40, 40, 40, 0.98);
border-radius: 4px;
margin-top: 4px;
max-height: 300px;
overflow-y: auto;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
`;
const searchInput = document.createElement('input');
searchInput.style.cssText = `
width: calc(100% - 16px);
margin: 8px;
padding: 8px;
border: none;
border-radius: 4px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 14px;
`;
searchInput.placeholder = 'Search language...';
const optionsContainer = document.createElement('div');
optionsContainer.style.cssText = `
padding: 8px 0;
`;
function createLanguageOptions(filter = '') {
optionsContainer.innerHTML = '';
// Separate popular and other languages
const popularLanguages = [
'en', 'tr', 'pl', 'es', 'fr', 'de', 'pt',
'ja', 'it', 'nl'
];
const entries = Object.entries(languages);
const filteredEntries = entries.filter(([_, name]) =>
name.toLowerCase().includes(filter.toLowerCase())
);
// Separate and sort entries
const popularEntries = filteredEntries.filter(([code]) =>
popularLanguages.includes(code)
).sort((a, b) =>
popularLanguages.indexOf(a[0]) - popularLanguages.indexOf(b[0])
);
const otherEntries = filteredEntries.filter(([code]) =>
!popularLanguages.includes(code)
);
// Create divider if both sections have items
if (popularEntries.length > 0 && otherEntries.length > 0) {
const divider = document.createElement('div');
divider.style.cssText = `
padding: 8px 16px;
color: #888;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
`;
divider.textContent = 'Other Languages';
// Create and append all options
[...popularEntries, divider, ...otherEntries].forEach(entry => {
if (entry instanceof HTMLElement) {
optionsContainer.appendChild(entry);
return;
}
const [code, name] = entry;
const option = document.createElement('div');
option.style.cssText = `
padding: 8px 16px;
cursor: pointer;
color: white;
&:hover {
background: rgba(255, 255, 255, 0.1);
}
`;
option.textContent = name;
option.addEventListener('click', () => {
dbgLog(`language option clicked: "${name}" (${code})`);
selectButton.textContent = name;
dropdown.style.display = 'none';
saveLanguage(code);
});
optionsContainer.appendChild(option);
});
}
}
selectButton.addEventListener('click', () => {
dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
dbgLog(`language dropdown ${dropdown.style.display === 'block' ? 'opened' : 'closed'}`);
if (dropdown.style.display === 'block') {
searchInput.focus();
}
});
searchInput.addEventListener('input', (e) => {
dbgLog(`language search filter changed: "${e.target.value}"`);
createLanguageOptions(e.target.value);
});
document.addEventListener('click', (e) => {
if (!selectContainer.contains(e.target)) {
if (dropdown.style.display !== 'none') {
dbgLog('outside click detected, closing language dropdown');
}
dropdown.style.display = 'none';
}
});
createLanguageOptions();
dropdown.appendChild(searchInput);
dropdown.appendChild(optionsContainer);
selectContainer.appendChild(selectButton);
selectContainer.appendChild(dropdown);
const translateButton = document.createElement('button');
translateButton.textContent = 'Translate';
translateButton.style.cssText = `
padding: 8px 16px;
background-color: #1db954;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
min-width: 100px;
`;
translateButton.addEventListener('click', () => {
const targetLang = getSavedLanguage();
if (isTranslating && translatingForLang === targetLang) {
dbgLog(`Translate clicked again for same language "${targetLang}" while translating - ignoring, letting current run finish`);
return;
}
if (isTranslating) {
dbgLog(`Translate clicked with new language "${targetLang}" while translating "${translatingForLang}" - aborting current run, clearing lines, starting new run`);
} else {
dbgLog(`Translate clicked, starting fresh run for "${targetLang}"`);
}
document.querySelectorAll('[data-translated="true"]').forEach(el => el.remove());
translateLyrics();
});
// Transient icon shown briefly the moment a fresh translation gets stored into the
// persistent cache (see showNewlyCachedIcon()). Stays in the DOM at all times,
// opacity-toggled + CSS-transitioned rather than display-toggled, so the fade
// actually animates instead of popping in/out.
const cacheIcon = document.createElement('span');
cacheIcon.setAttribute('data-cigi-newly-cached-icon', 'true');
cacheIcon.style.cssText = `
display: inline-flex;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
cacheIcon.innerHTML = ``;
newlyCachedIconElement = cacheIcon;
// Transient icon shown briefly on an actual cache hit (lyrics pulled from the
// persistent cache, network skipped entirely - see showCacheHitIcon()). Same size,
// same opacity/timeout/cleanup logic as the newly-cached icon above, different SVG
// and a separate element/timeout so the two can't stomp on each other if both fire
// in quick succession (e.g. rapid language switches).
// v1.8: rebuilt from closed primitives only (/) - the old open-path
// fills auto-closed with stray diagonals, which both glitched the shape and skewed
// its footprint vs. the folder icon above. Badge circle/checkmark is copy-pasted
// from that folder icon so the two align identically when swapped in the header.
const cacheHitIcon = document.createElement('span');
cacheHitIcon.setAttribute('data-cigi-cache-hit-icon', 'true');
cacheHitIcon.style.cssText = `
display: inline-flex;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
pointer-events: none;
`;
cacheHitIcon.innerHTML = ``;
cacheHitIconElement = cacheHitIcon;
// Both icons share ONE flex slot (not two adjacent ones) so they render in the
// exact same position - opacity:0 still reserves layout space, so two separate
// flex children would sit side-by-side (one `gap` apart) whenever only one was
// visible. Absolutely positioning both inside this single relatively-positioned
// wrapper makes them overlap identically instead.
const iconSlot = document.createElement('span');
iconSlot.style.cssText = `
position: relative;
display: inline-block;
width: 20px;
height: 20px;
`;
cacheIcon.style.position = 'absolute';
cacheIcon.style.top = '0';
cacheIcon.style.left = '0';
cacheHitIcon.style.position = 'absolute';
cacheHitIcon.style.top = '0';
cacheHitIcon.style.left = '0';
iconSlot.appendChild(cacheIcon);
iconSlot.appendChild(cacheHitIcon);
controlsContainer.appendChild(selectContainer);
controlsContainer.appendChild(translateButton);
controlsContainer.appendChild(iconSlot);
header.appendChild(controlsContainer);
const mainView = document.querySelector('.main-view-container__scroll-node-child');
if (!mainView) {
dbgLog('createHeader() main view container not found, header NOT mounted');
return;
}
// Appended to body (not into the scroll node) so position:fixed isn't affected by
// any transform OverlayScrollbars applies to an ancestor - see 1.5 changelog note.
document.body.appendChild(header);
headerElement = header;
// In-flow spacer reserves the vertical space the header used to occupy when it was
// a normal child, so lyric lines don't render underneath the now out-of-flow header.
const spacer = document.createElement('div');
spacer.setAttribute('data-cigi-header-spacer', 'true');
mainView.insertBefore(spacer, mainView.firstChild);
headerSpacerElement = spacer;
positionHeader();
const mainViewContainer = document.querySelector('.main-view-container');
if (mainViewContainer) {
headerResizeObserver = new ResizeObserver(() => positionHeader());
headerResizeObserver.observe(mainViewContainer);
}
window.addEventListener('resize', positionHeader);
dbgLog('createHeader() header mounted (fixed, rect-synced to .main-view-container)');
}
// Keeps the fixed-position header aligned with .main-view-container's current box
// (left/top/width) and keeps the spacer's height matched to the header's rendered height.
function positionHeader() {
if (!headerElement) return;
const mainViewContainer = document.querySelector('.main-view-container');
if (!mainViewContainer) {
dbgLog('positionHeader() .main-view-container not found, skipping');
return;
}
const rect = mainViewContainer.getBoundingClientRect();
headerElement.style.left = `${rect.left}px`;
headerElement.style.top = `${rect.top}px`;
headerElement.style.width = `${rect.width}px`;
if (headerSpacerElement) {
headerSpacerElement.style.height = `${headerElement.offsetHeight}px`;
}
dbgLog(`positionHeader() left=${Math.round(rect.left)} top=${Math.round(rect.top)} width=${Math.round(rect.width)}`);
}
function removeHeader() {
if (headerElement) {
headerElement.remove();
headerElement = null;
dbgLog('removeHeader() header removed from DOM');
}
if (headerSpacerElement) {
headerSpacerElement.remove();
headerSpacerElement = null;
}
if (headerResizeObserver) {
headerResizeObserver.disconnect();
headerResizeObserver = null;
dbgLog('removeHeader() header resize observer disconnected');
}
window.removeEventListener('resize', positionHeader);
if (newlyCachedIconHideTimeout) {
clearTimeout(newlyCachedIconHideTimeout);
newlyCachedIconHideTimeout = null;
}
newlyCachedIconElement = null; // was a child of headerElement, already removed above
if (cacheHitIconHideTimeout) {
clearTimeout(cacheHitIconHideTimeout);
cacheHitIconHideTimeout = null;
}
cacheHitIconElement = null; // was a child of headerElement, already removed above
// A translation may still be in-flight against lyric lines Spotify is about to
// tear down. Bump the generation so its results get discarded on arrival instead
// of being written into now-detached nodes (which would throw on parentNode.insertBefore).
translateGeneration++;
isTranslating = false;
translatingForLang = null;
lineTranslationCache = new Map();
disconnectRepairObserver();
}
function watchLyricsPresence() {
const lyricsPresent = !!document.querySelector('[data-testid="lyrics-line"]');
if (lyricsPresent && !headerElement) {
dbgLog('watchLyricsPresence() lyrics container opened, mounting header (no auto-translate)');
createHeader();
} else if (!lyricsPresent && headerElement) {
dbgLog('watchLyricsPresence() lyrics container closed, unmounting header');
removeHeader();
}
setTimeout(watchLyricsPresence, 1000);
}
window.addEventListener('load', function () {
dbgLog('window load event fired, starting script');
watchLyricsPresence();
});
})();