Cigi Spotify Translator

Extract, translate, and display Spotify lyrics with a language selector and manual translation trigger

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Cigi Spotify Translator
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Extract, translate, and display Spotify lyrics with a language selector and manual translation trigger
// @author       Raiwulf
// @match        *://*.spotify.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    const DEFAULT_LANGUAGE = 'en';
    let isTranslating = false;

    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() {
        return localStorage.getItem('spotifyLyricsTranslationLang') || DEFAULT_LANGUAGE;
    }

    function saveLanguage(lang) {
        localStorage.setItem('spotifyLyricsTranslationLang', lang);
    }

    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) {
            console.error('Translation failed:', error);
            return '[Translation Error]';
        }
    }

    async function translateLyrics() {
        if (isTranslating) return;
        isTranslating = true;

        const targetLang = getSavedLanguage();
        const lyricsDivs = document.querySelectorAll('[data-testid="fullscreen-lyric"] div');

        document.querySelectorAll('[data-translated="true"]').forEach(el => el.remove());

        const originalLines = [];
        lyricsDivs.forEach((div, index) => {
            const originalText = div.textContent.trim();
            if (originalText && originalText !== "♪") {
                originalLines.push({ index, text: originalText });
            }
        });

        const translatedLines = await Promise.all(originalLines.map(async (line) => {
            const translatedText = await translateText(line.text, targetLang);
            return { index: line.index, translatedText };
        }));

        translatedLines.forEach(({ index, translatedText }) => {
            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);
        });

        isTranslating = false;
    }

    function observeLyrics() {
        const targetNode = document.querySelector('[data-testid="lyrics-container"]') || document.querySelector('[data-testid="fullscreen-lyric"]');
        if (!targetNode) return;

        const observer = new MutationObserver(() => {
            translateLyrics();
        });

        observer.observe(targetNode, {
            childList: true,
            subtree: true,
        });
    }

    function createHeader() {
        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);
            width: 100%;
            box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
            margin: 0;
        `;

        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', () => {
                        selectButton.textContent = name;
                        dropdown.style.display = 'none';
                        saveLanguage(code);
                        document.querySelectorAll('[data-translated="true"]').forEach(el => el.remove());
                        translateLyrics();
                    });
                    optionsContainer.appendChild(option);
                });
            }
        }

        selectButton.addEventListener('click', () => {
            dropdown.style.display = dropdown.style.display === 'none' ? 'block' : 'none';
            if (dropdown.style.display === 'block') {
                searchInput.focus();
            }
        });

        searchInput.addEventListener('input', (e) => {
            createLanguageOptions(e.target.value);
        });

        document.addEventListener('click', (e) => {
            if (!selectContainer.contains(e.target)) {
                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', () => {
            document.querySelectorAll('[data-translated="true"]').forEach(el => el.remove());
            translateLyrics();
        });

        controlsContainer.appendChild(selectContainer);
        controlsContainer.appendChild(translateButton);
        header.appendChild(controlsContainer);

        const mainView = document.querySelector('.main-view-container__scroll-node-child');
        if (mainView) {
            mainView.insertBefore(header, mainView.firstChild);
        }
    }

    function waitForLyrics() {
        const lyricsContainer = document.querySelector('[data-testid="lyrics-container"]') || document.querySelector('[data-testid="fullscreen-lyric"]');
        if (lyricsContainer) {
            createHeader();
            observeLyrics();
            translateLyrics();
        } else {
            setTimeout(waitForLyrics, 1000);
        }
    }

    function checkForTranslation() {
        setInterval(() => {
            if (!document.querySelector('[data-translated="true"]') && !isTranslating) {
                translateLyrics();
            }
        }, 2000);
    }

    window.addEventListener('load', function () {
        waitForLyrics();
        checkForTranslation();
    });
})();