YouTube Subscriptions Watch Later Button

Adds a Watch Later button to videos on the YouTube subscriptions feed

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

You will need to install an extension such as Tampermonkey to install this script.

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         YouTube Subscriptions Watch Later Button
// @namespace    https://github.com/CharlesMagnuson/YouTube-Subscriptions-Watch-Later-Button
// @version      0.5
// @description  Adds a Watch Later button to videos on the YouTube subscriptions feed
// @author       CharlesMagnuson
// @match        https://www.youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

/*
╔══════════════════════════════════════════════════════════════════════════════╗
║                    YOUTUBE SUBSCRIPTIONS WATCH LATER                         ║
║                              Version 0.5                                     ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  PURPOSE:                                                                    ║
║  Adds a "Watch Later" button overlay to video thumbnails on the YouTube      ║
║  subscriptions feed (/feed/subscriptions). This replicates the functionality ║
║  that exists on channel pages but is mysteriously absent from the sub feed.  ║
║                                                                              ║
║  HOW IT WORKS:                                                               ║
║  1. Monitors for video elements appearing on the subscriptions feed          ║
║  2. Injects a Watch Later button on each thumbnail (top-left corner)         ║
║  3. Uses YouTube's internal API to add/remove videos from Watch Later        ║
║  4. Authenticates using your existing YouTube session (SAPISIDHASH)          ║
║                                                                              ║
║  BUTTON STATES:                                                              ║
║  - Clock icon (hollow): Video is NOT in Watch Later                          ║
║  - Checkmark icon (green bg): Video IS in Watch Later                        ║
║                                                                              ║
║  SECURITY NOTES:                                                             ║
║  - Only runs on youtube.com (verified by @match directive)                   ║
║  - Uses your existing authenticated session - no credentials stored          ║
║  - All API calls go to official YouTube endpoints                            ║
╚══════════════════════════════════════════════════════════════════════════════╝
*/

(function() {
    'use strict';

    // =========================================================================
    // SECTION 1: CONFIGURATION
    // =========================================================================

    // SVG paths for button icons
    const SVG_PATH_CLOCK = "M14.97,16.95L10,13.87V7h2v5.76l4.03,2.49L14.97,16.95z M12,3c-4.96,0-9,4.04-9,9s4.04,9,9,9s9-4.04,9-9S16.96,3,12,3 M12,2c5.52,0,10,4.48,10,10s-4.48,10-10,10S2,17.52,2,12S6.48,2,12,2L12,2z";
    const SVG_PATH_CHECKMARK = "M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z";

    // =========================================================================
    // SECTION 2: CSS STYLES
    // =========================================================================

    function injectStyles() {
        const styleId = 'yt-wl-custom-styles';
        if (document.getElementById(styleId)) return;

        const css = `
            .yt-wl-custom-button {
                opacity: 0.75 !important;
                transition: opacity 0.15s ease, background-color 0.15s ease !important;
            }
            
            .yt-wl-custom-button:hover {
                opacity: 1 !important;
            }
        `;

        const style = document.createElement('style');
        style.id = styleId;
        style.textContent = css;
        document.head.appendChild(style);
    }

    // =========================================================================
    // SECTION 3: AUTHENTICATION
    // =========================================================================

    /**
     * Generates SAPISIDHASH for YouTube API authentication.
     * This is the same authentication method YouTube uses internally.
     */
    async function getSApiSidHash(sapisid, origin) {
        async function sha1(str) {
            const buffer = new TextEncoder().encode(str);
            const hashBuffer = await window.crypto.subtle.digest('SHA-1', buffer);
            const hashArray = Array.from(new Uint8Array(hashBuffer));
            return hashArray.map(b => ('00' + b.toString(16)).slice(-2)).join('');
        }

        const timestamp = Date.now();
        const digest = await sha1(`${timestamp} ${sapisid} ${origin}`);
        return `${timestamp}_${digest}`;
    }

    function getSapisidCookie() {
        const cookies = document.cookie.split('; ');
        for (const cookie of cookies) {
            if (cookie.startsWith('SAPISID=')) {
                return cookie.substring(8);
            }
        }
        return null;
    }

    // =========================================================================
    // SECTION 4: YOUTUBE API
    // =========================================================================

    /**
     * Checks if a video is currently in the Watch Later playlist.
     */
    async function isVideoInWatchLater(videoId) {
        const sapisid = getSapisidCookie();
        if (!sapisid) return false;

        try {
            const sapisidhash = await getSApiSidHash(sapisid, window.origin);
            const response = await fetch(
                'https://www.youtube.com/youtubei/v1/playlist/get_add_to_playlist',
                {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `SAPISIDHASH ${sapisidhash}`
                    },
                    body: JSON.stringify({
                        context: {
                            client: {
                                clientName: 'WEB',
                                clientVersion: window.ytcfg?.data_?.INNERTUBE_CLIENT_VERSION || '2.20231219.04.00'
                            }
                        },
                        excludeWatchLater: false,
                        videoIds: [videoId]
                    })
                }
            );

            if (!response.ok) return false;

            const json = await response.json();
            const playlists = json?.contents?.[0]?.addToPlaylistRenderer?.playlists;
            if (playlists) {
                const watchLater = playlists.find(p => p.playlistAddToOptionRenderer?.playlistId === 'WL');
                if (watchLater) {
                    return watchLater.playlistAddToOptionRenderer.containsSelectedVideos === 'ALL';
                }
            }
            return false;
        } catch {
            return false;
        }
    }

    /**
     * Adds or removes a video from Watch Later.
     */
    async function toggleWatchLater(videoId, isCurrentlyInWatchLater) {
        const sapisid = getSapisidCookie();
        if (!sapisid) return false;

        try {
            const sapisidhash = await getSApiSidHash(sapisid, window.origin);
            const actionObj = isCurrentlyInWatchLater
                ? { removedVideoId: videoId, action: 'ACTION_REMOVE_VIDEO_BY_VIDEO_ID' }
                : { addedVideoId: videoId, action: 'ACTION_ADD_VIDEO' };

            const response = await fetch(
                'https://www.youtube.com/youtubei/v1/browse/edit_playlist',
                {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `SAPISIDHASH ${sapisidhash}`
                    },
                    body: JSON.stringify({
                        context: {
                            client: {
                                clientName: 'WEB',
                                clientVersion: window.ytcfg?.data_?.INNERTUBE_CLIENT_VERSION || '2.20231219.04.00'
                            }
                        },
                        actions: [actionObj],
                        playlistId: 'WL'
                    })
                }
            );

            return response.ok;
        } catch {
            return false;
        }
    }

    // =========================================================================
    // SECTION 5: VIDEO ID EXTRACTION
    // =========================================================================

    function extractVideoId(videoElement) {
        const videoLink = videoElement.querySelector('a[href*="/watch?v="]');
        if (videoLink) {
            const href = videoLink.getAttribute('href');
            const match = href.match(/[?&]v=([a-zA-Z0-9_-]{11})/);
            if (match) return match[1];
        }
        return null;
    }

    // =========================================================================
    // SECTION 6: BUTTON CREATION
    // =========================================================================

    function createWatchLaterButton(videoId) {
        const container = document.createElement('div');
        container.className = 'yt-wl-custom-button';
        container.setAttribute('data-video-id', videoId);
        container.setAttribute('data-in-watch-later', 'unknown');

        container.style.cssText = `
            position: absolute;
            top: 8px;
            left: 8px;
            width: 36px;
            height: 36px;
            background-color: rgba(0, 0, 0, 0.7);
            border-radius: 4px;
            cursor: pointer;
            display: flex;
            align-items: center;
            justify-content: center;
            z-index: 9999;
            pointer-events: auto;
            box-shadow: 0 1px 3px rgba(0,0,0,0.3);
        `;

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

        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
        path.setAttribute('d', SVG_PATH_CLOCK);
        svg.appendChild(path);
        container.appendChild(svg);

        container.title = 'Add to Watch Later';

        // Prevent mousedown from triggering thumbnail highlight
        container.addEventListener('mousedown', (e) => {
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();
        }, true);

        // Click handler with optimistic UI update
        container.addEventListener('click', async (e) => {
            // Stop ALL event propagation to prevent thumbnail highlight
            e.preventDefault();
            e.stopPropagation();
            e.stopImmediatePropagation();

            const wasInWL = container.getAttribute('data-in-watch-later') === 'true';
            const newState = !wasInWL;

            // OPTIMISTIC UPDATE: Change button immediately before API call
            container.setAttribute('data-in-watch-later', newState.toString());
            path.setAttribute('d', newState ? SVG_PATH_CHECKMARK : SVG_PATH_CLOCK);
            container.title = newState ? 'In Watch Later (click to remove)' : 'Add to Watch Later';
            container.style.backgroundColor = newState ? 'rgba(0, 100, 0, 0.8)' : 'rgba(0, 0, 0, 0.7)';

            // Make API call in background
            const success = await toggleWatchLater(videoId, wasInWL);

            // Only revert if the API call failed
            if (!success) {
                container.setAttribute('data-in-watch-later', wasInWL.toString());
                path.setAttribute('d', wasInWL ? SVG_PATH_CHECKMARK : SVG_PATH_CLOCK);
                container.title = wasInWL ? 'In Watch Later (click to remove)' : 'Add to Watch Later';
                container.style.backgroundColor = wasInWL ? 'rgba(0, 100, 0, 0.8)' : 'rgba(0, 0, 0, 0.7)';
                
                // Flash red to indicate error
                container.style.backgroundColor = 'rgba(180, 0, 0, 0.8)';
                setTimeout(() => {
                    container.style.backgroundColor = wasInWL ? 'rgba(0, 100, 0, 0.8)' : 'rgba(0, 0, 0, 0.7)';
                }, 1500);
            }
        }, true);  // Use capture phase to intercept event early

        // Check initial Watch Later status
        setTimeout(async () => {
            const isInWL = await isVideoInWatchLater(videoId);
            container.setAttribute('data-in-watch-later', isInWL.toString());
            path.setAttribute('d', isInWL ? SVG_PATH_CHECKMARK : SVG_PATH_CLOCK);
            container.title = isInWL ? 'In Watch Later (click to remove)' : 'Add to Watch Later';
            if (isInWL) {
                container.style.backgroundColor = 'rgba(0, 100, 0, 0.8)';
            }
        }, 100);

        return container;
    }

    // =========================================================================
    // SECTION 7: VIDEO PROCESSING
    // =========================================================================

    const processedElements = new Set();

    function processVideoElement(videoElement) {
        if (videoElement.hasAttribute('data-yt-wl-processed')) return;

        const videoId = extractVideoId(videoElement);
        if (!videoId) return;

        // Find thumbnail container
        let container = videoElement.querySelector('.yt-lockup-view-model__content-image');
        if (!container) container = videoElement.querySelector('#thumbnail');
        if (!container) container = videoElement.querySelector('ytd-thumbnail');
        if (!container) container = videoElement.querySelector('a[href*="/watch"]');
        if (!container) return;

        videoElement.setAttribute('data-yt-wl-processed', 'true');
        processedElements.add(videoElement);

        // Ensure container has relative positioning
        const style = window.getComputedStyle(container);
        if (style.position === 'static') {
            container.style.position = 'relative';
        }

        container.appendChild(createWatchLaterButton(videoId));
    }

    function scanForVideos() {
        if (window.location.pathname !== '/feed/subscriptions') return;

        document.querySelectorAll('ytd-rich-item-renderer').forEach(element => {
            processVideoElement(element);
        });
    }

    // =========================================================================
    // SECTION 8: PAGE OBSERVATION
    // =========================================================================

    function setupObserver() {
        const observer = new MutationObserver((mutations) => {
            if (window.location.pathname !== '/feed/subscriptions') return;

            let shouldScan = false;
            for (const mutation of mutations) {
                for (const node of mutation.addedNodes) {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (node.tagName === 'YTD-RICH-ITEM-RENDERER' ||
                            node.querySelector?.('ytd-rich-item-renderer')) {
                            shouldScan = true;
                            break;
                        }
                    }
                }
                if (shouldScan) break;
            }

            if (shouldScan) {
                clearTimeout(window._ytWLScanTimeout);
                window._ytWLScanTimeout = setTimeout(scanForVideos, 200);
            }
        });

        observer.observe(document.querySelector('ytd-app') || document.body, {
            childList: true,
            subtree: true
        });
    }

    function setupNavigationListener() {
        const originalPushState = history.pushState;
        history.pushState = function(...args) {
            originalPushState.apply(this, args);
            handleNavigation();
        };

        const originalReplaceState = history.replaceState;
        history.replaceState = function(...args) {
            originalReplaceState.apply(this, args);
            handleNavigation();
        };

        window.addEventListener('popstate', handleNavigation);

        function handleNavigation() {
            processedElements.clear();
            setTimeout(scanForVideos, 500);
        }
    }

    // =========================================================================
    // SECTION 9: INITIALIZATION
    // =========================================================================

    function init() {
        injectStyles();
        setupObserver();
        setupNavigationListener();
        scanForVideos();
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }

})();