YouTube Subscriptions Watch Later Button

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

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         YouTube Subscriptions Watch Later Button
// @namespace    https://github.com/CharlesMagnuson/YouTube-Subscriptions-Watch-Later-Button
// @version      1.0
// @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 1.0                                     ║
╠══════════════════════════════════════════════════════════════════════════════╣
║  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();
    }

})();