YouTube 乾淨短網址分享器

取代 YouTube 分享按鈕,複製不含追蹤參數的短網址到剪貼簿。

// ==UserScript==
// @name         YouTube 乾淨短網址分享器
// @name:en      Clean YouTube Short URL Sharer
// @name:zh-TW   YouTube 乾淨短網址分享器
// @name:ja      YouTubeクリーンショートURLシェア
// @name:es      Compartidor limpio de URL corta de YouTube
// @name:de      Sauberer YouTube-Kurzlink-Teiler
// @description  取代 YouTube 分享按鈕,複製不含追蹤參數的短網址到剪貼簿。
// @description:en Replaces the YouTube Share button with a cleaner version that copies a tracking-free short URL using GM_setClipboard.
// @description:zh-TW 取代 YouTube 分享按鈕,複製不含追蹤參數的短網址到剪貼簿。
// @description:ja YouTubeの共有ボタンを置き換え、トラッキングなしの短縮URLをクリップボードにコピーします。
// @description:es Reemplaza el botón de compartir de YouTube por uno que copia una URL corta sin rastreo al portapapeles.
// @description:de Ersetzt die YouTube-Teilen-Schaltfläche durch eine saubere Version, die einen trackingfreien Kurzlink kopiert.

// @author       Max
// @namespace    https://github.com/Max46656
// @license      MPL2.0

// @icon         https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @match        https://www.youtube.com/*
// @match        https://www.youtube.com/watch*
// @grant        GM_setClipboard
// @grant        GM.info
// @version      1.0.2
// ==/UserScript==


class YouTubeShortUrlCopier {
    constructor() {
        const existingCopier = document.querySelector('#yscsb-timestamp-checkbox-wrapper');
        if(existingCopier){
            return;
        }
        this.shareButtonSelector = '#actions yt-button-view-model button-view-model button';
        this.notificationDuration = 1200;
        this.pollInterval = 100;
        this.maxAttempts = 30;
        this.attempts = 0;
        this.timestampEnabled = false;
        this.init();
    }

    init() {
        this.i18n = new LocalizationManager();
        this.injectStyles();
        this.waitForShareButton();
    }

    waitForShareButton() {
        const interval = setInterval(() => {
            const originalButton = document.querySelector(this.shareButtonSelector);
            this.attempts++;

            if (originalButton) {
                clearInterval(interval);
                this.replaceShareButton(originalButton);
            } else if (this.attempts >= this.maxAttempts) {
                clearInterval(interval);
                console.warn(this.i18n.get('max_retry', { name: GM_info.script.name }));
            }
        }, this.pollInterval);
    }

    replaceShareButton(originalButton) {
        const wrapper = this.createSegmentedShareButtons(originalButton);
        originalButton.parentNode.replaceChild(wrapper, originalButton);
    }

    createSegmentedShareButtons(originalButton) {
        const wrapper = document.createElement('div');
        wrapper.className = 'ytSegmentedLikeDislikeButtonViewModelSegmentedButtonsWrapper';

        const shareButton = this.createCustomShareButton(originalButton, true);
        const timestampButton = this.createTimestampCheckboxButton();

        wrapper.appendChild(shareButton);
        wrapper.appendChild(timestampButton);

        return wrapper;
    }

    createCustomShareButton(originalButton, isSegmentedStart = false) {
        const button = document.createElement('button');
        button.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading yt-spec-button-shape-next--enable-backdrop-filter-experiment';
        if (isSegmentedStart) {
            button.classList.add('yt-spec-button-shape-next--segmented-start');
        }

        const iconContainer = document.createElement('div');
        iconContainer.className = 'yt-spec-button-shape-next__icon';
        const span = document.createElement('span');
        span.className = 'yt-icon-shape style-scope yt-icon yt-spec-icon-shape';

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
        svg.setAttribute('height', '24');
        svg.setAttribute('width', '24');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('focusable', 'false');
        svg.setAttribute('aria-hidden', 'true');
        svg.style.pointerEvents = 'none';
        svg.style.display = 'inherit';
        svg.style.width = '100%';
        svg.style.height = '100%';

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', 'M15 5.63 20.66 12 15 18.37V14h-1c-3.96 0-7.14 1-9.75 3.09 1.84-4.07 5.11-6.4 9.89-7.1l.86-.13V5.63M14 3v6C6.22 10.13 3.11 15.33 2 21c2.78-3.97 6.44-6 12-6v6l8-9-8-9z');

        svg.appendChild(path);
        span.appendChild(svg);
        iconContainer.appendChild(span);

        const textDiv = document.createElement('div');
        textDiv.textContent = this.i18n.get('share');

        button.appendChild(iconContainer);
        button.appendChild(textDiv);

        button.addEventListener('click', () => this.handleButtonClick());
        return button;
    }

    createTimestampCheckboxButton() {
        const existing = document.querySelector('#yscsb-timestamp-checkbox-wrapper');
        if (existing) return existing;

        const wrapper = document.createElement('label');
        wrapper.id = 'yscsb-timestamp-checkbox-wrapper';
        wrapper.className = 'yt-spec-button-shape-next yt-spec-button-shape-next--tonal yt-spec-button-shape-next--mono yt-spec-button-shape-next--size-m yt-spec-button-shape-next--icon-leading yt-spec-button-shape-next--segmented-end yt-spec-button-shape-next--enable-backdrop-filter-experiment';
        wrapper.style.display = 'inline-flex';
        wrapper.style.alignItems = 'center';
        wrapper.style.cursor = 'pointer';

        const checkbox = document.createElement('input');
        checkbox.type = 'checkbox';
        checkbox.id = 'yscsb-timestamp-checkbox';
        checkbox.style.display = 'none';

        checkbox.addEventListener('change', () => {
            this.timestampEnabled = checkbox.checked;
            wrapper.classList.toggle('selected', this.timestampEnabled);
        });

        const iconContainer = document.createElement('div');
        iconContainer.className = 'yt-spec-button-shape-next__icon';

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', 'M12 1.5C6.2 1.5 1.5 6.2 1.5 12S6.2 22.5 12 22.5 22.5 17.8 22.5 12 17.8 1.5 12 1.5zm0 19c-4.7 0-8.5-3.8-8.5-8.5S7.3 3.5 12 3.5 20.5 7.3 20.5 12 16.7 20.5 12 20.5zm.75-13v4.25H17v1.5h-5.25V7.5h1.5z');

        const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
        svg.setAttribute('viewBox', '0 0 24 24');
        svg.setAttribute('width', '24');
        svg.setAttribute('height', '24');

        svg.appendChild(path);
        iconContainer.appendChild(svg);

        const textDiv = document.createElement('div');
        textDiv.textContent = this.i18n.get('timestamp');

        wrapper.appendChild(checkbox);
        wrapper.appendChild(iconContainer);
        wrapper.appendChild(textDiv);

        return wrapper;
    }

    handleButtonClick() {
        const shortUrl = this.getShortUrl();
        if (shortUrl) {
            this.copyToClipboard(shortUrl);
            this.showNotification(this.i18n.get('copied', { name: GM_info.script.name, url: shortUrl }));
        } else {
            this.showNotification(this.i18n.get('format_error', { name: GM_info.script.name }));
        }
    }

    getShortUrl() {
        const currentUrl = new URL(window.location.href);
        const videoId = currentUrl.searchParams.get('v');
        let shortUrl = videoId ? `https://youtu.be/${videoId}` : null;

        if (shortUrl && this.timestampEnabled) {
            const videoTime = document.querySelector('video').currentTime;
            const time = Math.floor(videoTime ? videoTime : 0);
            if (time > 0) shortUrl += `?t=${time}`;
        }

        return shortUrl;
    }

    copyToClipboard(text) {
        GM_setClipboard(text, 'text');
    }

    showNotification(message) {
        const notification = document.createElement('div');
        notification.textContent = message;
        Object.assign(notification.style, {
            position: 'fixed',
            top: '50%',
            left: '50%',
            transform: 'translate(-50%, -50%)',
            backgroundColor: 'rgba(0, 0, 0, 0.7)',
            color: 'white',
            padding: '10px 16px',
            borderRadius: '8px',
            zIndex: '10000',
            fontSize: '14px',
            whiteSpace: 'nowrap'
        });

        document.body.appendChild(notification);
        setTimeout(() => notification.remove(), this.notificationDuration);
    }
    injectStyles() {
        const style = document.createElement('style');
        style.textContent = `
          #yscsb-timestamp-checkbox-wrapper.selected {
              background-color: #c4302b;
              color: white;
          }
          #yscsb-timestamp-checkbox-wrapper.selected svg path {
              fill: white;
          }
      `;
        document.head.appendChild(style);
    }
}


class LocalizationManager {
    constructor() {
        const lang = navigator.language.toLowerCase();
        if (lang.startsWith('zh')) this.lang = 'zh';
        else if (lang.startsWith('ja')) this.lang = 'ja';
        else if (lang.startsWith('es')) this.lang = 'es';
        else if (lang.startsWith('de')) this.lang = 'de';
        else this.lang = 'en';
    }

    get(key, replacements = {}) {
        const template = this.messages[this.lang][key] || this.messages['en'][key] || key;
        return template.replace(/\{(\w+)\}/g, (_, name) => replacements[name] || '');
    }

    messages = {
        en: {
            copied:        "{name}:short URL copied: {url}",
            format_error:  "{name}:short URL format changed, please wait for script update",
            no_title:      "{name}:could not find <title> element",
            max_retry:     "{name}:exceeded retry limit, share button not found",
            share:         "Share",
            timestamp:     "Timestamp"
        },
        zh: {
            copied:        "{name}:縮網址已被複製: {url}",
            format_error:  "{name}:縮網址格式改變,請等待腳本更新",
            no_title:      "{name}:找不到 <title> 元素",
            max_retry:     "{name}:超過最大重試次數,無法找到分享按鈕",
            share:         "分享",
            timestamp:     "時間戳"
        },
        ja: {
            copied:        "{name}:の短縮URLをコピーしました: {url}",
            format_error:  "{name}:の短縮URL形式が変更されました。スクリプトの更新をお待ちください",
            no_title:      "{name}:は <title> 要素を見つけられませんでした",
            max_retry:     "{name}:は最大試行回數を超え、共有ボタンが見つかりません",
            share:         "共有",
            timestamp:     "タイムスタンプ"
        },
        es: {
            copied:        "{name}:URL corta copiada: {url}",
            format_error:  "{name}:El formato de URL corta ha cambiado. Espera una actualización del script",
            no_title:      "{name}:no pudo encontrar el elemento <title>",
            max_retry:     "{name}:superó el número máximo de intentos. Botón de compartir no encontrado",
            share:         "Compartir",
            timestamp:     "Hora"
        },
        de: {
            copied:        "{name}:Kurzlink wurde kopiert: {url}",
            format_error:  "{name}:Kurzlink-Format wurde geändert. Bitte auf ein Skript-Update warten",
            no_title:      "{name}:konnte das <title>-Element nicht finden",
            max_retry:     "{name}:hat die maximale Anzahl an Versuchen überschritten. Teilen-Schaltfläche nicht gefunden",
            share:         "Teilen",
            timestamp:     "Zeitstempel"
        }
    };
}

class TitleObserver {
    constructor(onNavigate) {
        this.currentTitle = document.title;
        this.onNavigate = onNavigate;
        this.observe();
    }

    observe() {
        const titleElement = document.querySelector('title');
        if (!titleElement) {
            console.warn('找不到 <title> 元素');
            return;
        }

        const observer = new MutationObserver(() => {
            if (document.title !== this.currentTitle) {
                this.currentTitle = document.title;
                if (window.location.href.startsWith('https://www.youtube.com/watch?v=')) {
                    this.onNavigate();
                }
            }
        });

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


const johnTheTrackingStoper = new YouTubeShortUrlCopier();
new TitleObserver(() => johnTheTrackingStoper.waitForShareButton());