YouTube Auto-Resume

This script automatically tracks and restores your YouTube playback position. This user script remembers where you left off in any video—allowing you to seamlessly continue watching even after navigating away or reloading the page. It saves your progress for each video for up to 30 days (configurable) and automatically cleans up outdated entries, ensuring a smooth and uninterrupted viewing experience every time.

// ==UserScript==
// @name              YouTube Auto-Resume
// @icon              https://www.youtube.com/img/favicon_48.png
// @author            ElectroKnight22
// @namespace         electroknight22_youtube_auto_resume_namespace
// @version           1.5.1
// @match             *://www.youtube.com/*
// @match             *://www.youtube-nocookie.com/*
// @exclude           *://music.youtube.com/*
// @grant             GM.getValue
// @grant             GM.setValue
// @grant             GM.deleteValue
// @grant             GM.listValues
// @grant             GM_getValue
// @grant             GM_setValue
// @grant             GM_deleteValue
// @grant             GM_listValues
// @license           MIT
// @description       This script automatically tracks and restores your YouTube playback position. This user script remembers where you left off in any video—allowing you to seamlessly continue watching even after navigating away or reloading the page. It saves your progress for each video for up to 30 days (configurable) and automatically cleans up outdated entries, ensuring a smooth and uninterrupted viewing experience every time.
// @homepage          https://gf.qytechs.cn/en/scripts/526798-youtube-auto-resume
// ==/UserScript==

/*jshint esversion: 11 */

(function () {
    "use strict";

    let useCompatibilityMode = false;
    let isIFrame = false;

    const activeHandlers = new WeakMap();

    const daysToRemember = 30;
    const daysToRememberShorts = 1;
    const daysToRememberPreviews = 30 / (24 * 60); // 30 minutes

    const GMCustomGetValue = useCompatibilityMode ? GM_getValue : GM.getValue;
    const GMCustomSetValue = useCompatibilityMode ? GM_setValue : GM.setValue;
    const GMCustomDeleteValue = useCompatibilityMode ? GM_deleteValue : GM.deleteValue;
    const GMCustomListValues = useCompatibilityMode ? GM_listValues : GM.listValues;

    function resumePlayback(playerApi, videoId, videoElement) {
        return new Promise(async (resolve) => {
            try {
                const playbackStatus = await GMCustomGetValue(videoId);
                const lastPlaybackTime = playbackStatus?.timestamp;
                if (!lastPlaybackTime) return resolve();

                const currentPlaybackTime = playerApi.getCurrentTime();
                if (Math.abs(currentPlaybackTime - lastPlaybackTime) > 1) {
                    const onSeeked = () => {
                        clearTimeout(seekTimeout);
                        resolve();
                    };
                    const seekTimeout = setTimeout(() => {
                        videoElement.removeEventListener('seeked', onSeeked);
                        resolve();
                    }, 1000);
                    videoElement.addEventListener('seeked', onSeeked, { once: true });
                    playerApi.seekTo(lastPlaybackTime, true, { skipBufferingCheck: true });
                } else {
                    resolve();
                }
            } catch (error) {
                console.error("Failed to resume playback: " + error);
                resolve();
            }
        });
    }

    function updatePlaybackStatus(playerApi, videoElement, videoType) {
        try {
            const liveVideoId = playerApi.getVideoData()?.video_id;
            if (!liveVideoId) return;

            const videoDuration = videoElement.duration;
            const currentPlaybackTime = videoElement.currentTime;
            const timeLeft = videoDuration - currentPlaybackTime;
            if (isNaN(timeLeft)) return;
            if (timeLeft < 1) {
                GMCustomDeleteValue(liveVideoId);
                return;
            }
            const currentPlaybackStatus = {
                timestamp: currentPlaybackTime,
                lastUpdated: Date.now(),
                videoType: videoType
            };
            GMCustomSetValue(liveVideoId, currentPlaybackStatus);
        } catch (error) {
            console.error("Failed to update playback status: " + error);
        }
    }

    function processVideo(playerContainer, playerApi, videoElement) {
        try {
            activeHandlers.get(playerContainer)?.cleanup();

            const initialVideoId = new URL(window.location.href).searchParams.get('v') || playerApi.getVideoData()?.video_id;
            if (!initialVideoId) return;

            const isPreview = playerContainer.id === 'inline-player';
            const isLive = playerApi.getVideoData().isLive;
            const timeSpecified = new URL(window.location.href).searchParams.has('t');
            const inPlaylist = new URL(window.location.href).searchParams.has('list');
            const playlistType = new URLSearchParams(window.location.search).get('list');

            if (isLive || timeSpecified || (inPlaylist && playlistType !== 'WL')) return;

            let videoType = 'regular';
            if (isPreview) {
                videoType = 'preview';
            } else if (window.location.pathname.startsWith('/shorts/')) {
                videoType = 'short';
            }

            let hasAttemptedResume = false;
            const timeUpdateHandler = async () => {
                if (!hasAttemptedResume) {
                    hasAttemptedResume = true;
                    await resumePlayback(playerApi, initialVideoId, videoElement);
                } else {
                    updatePlaybackStatus(playerApi, videoElement, videoType);
                }
            };

            videoElement.addEventListener('timeupdate', timeUpdateHandler, true);

            activeHandlers.set(playerContainer, {
                cleanup: () => videoElement.removeEventListener('timeupdate', timeUpdateHandler, true)
            });

        } catch (error) {
            console.error("Failed to process video elements: ", error);
        }
    }

    function handleVideoLoad(event) {
        const playerContainer = event.target;
        const playerApi = playerContainer?.player_;
        const videoElement = playerContainer?.querySelector('video');

        if (playerContainer && playerApi && videoElement) {
            processVideo(playerContainer, playerApi, videoElement);
        }
    }

    function handleInitialLoad() {
        const playerContainer = document.querySelector('#movie_player');
        if (playerContainer) {
            const videoElement = playerContainer.querySelector('video');
            if (!videoElement) return;
            const playerApi = playerContainer;
            processVideo(playerContainer, playerApi, videoElement);
        }
    }

    async function cleanUpStoredPlaybackStatuses() {
        try {
            const videoIds = await GMCustomListValues();
            for (const videoId of videoIds) {
                const storedPlaybackStatus = await GMCustomGetValue(videoId);
                if (!storedPlaybackStatus || isNaN(storedPlaybackStatus.lastUpdated)) {
                    await GMCustomDeleteValue(videoId);
                    continue;
                }
                const videoType = storedPlaybackStatus.videoType || 'regular';
                let daysToExpire;
                switch (videoType) {
                    case 'short': daysToExpire = daysToRememberShorts; break;
                    case 'preview': daysToExpire = daysToRememberPreviews; break;
                    default: daysToExpire = daysToRemember; break;
                }
                const threshold = daysToExpire * 86400 * 1000;
                if (Date.now() - storedPlaybackStatus.lastUpdated > threshold) {
                    await GMCustomDeleteValue(videoId);
                }
            }
        } catch (error) {
            console.error("Failed to clean up stored playback statuses: " + error);
        }
    }

    function hasGreasyMonkeyAPI() {
        if (typeof GM !== 'undefined') return true;
        if (typeof GM_info !== 'undefined') {
            useCompatibilityMode = true;
            console.warn("YouTube Auto-Resume: Running in compatibility mode.");
            return true;
        }
        return false;
    }

    async function initialize() {
        isIFrame = window.top !== window.self;
        try {
            if (!hasGreasyMonkeyAPI() || isIFrame) return;
            await cleanUpStoredPlaybackStatuses();
            setInterval(cleanUpStoredPlaybackStatuses, 300000); // 5 minutes
            window.addEventListener('pageshow', handleInitialLoad, true);
            window.addEventListener('yt-player-updated', handleVideoLoad, true);
            window.addEventListener('yt-autonav-pause-player-ended', (event) => {
                const videoId = event.target?.player?.getVideoData()?.video_id;
                if (videoId) GMCustomDeleteValue(videoId);
            }, true);
        } catch (error) {
            console.error(`Error when initializing script: ${error}.`);
        }
    }

    initialize();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址