YouTube - Resumer

Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name          YouTube - Resumer
// @version       2.1.1
// @description   Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup.
// @author        Journey Over
// @license       MIT
// @match         *://*.youtube.com/*
// @match         *://*.youtube-nocookie.com/*
// @require       https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@0171b6b6f24caea737beafbc2a8dacd220b729d8/libs/utils/utils.min.js
// @grant         GM_setValue
// @grant         GM_getValue
// @grant         GM_deleteValue
// @grant         GM_listValues
// @grant         GM_addValueChangeListener
// @icon          https://www.google.com/s2/favicons?sz=64&domain=youtube.com
// @homepageURL   https://github.com/StylusThemes/Userscripts
// @namespace https://greasyfork.org/users/32214
// ==/UserScript==

(function() {
  'use strict';

  const logger = Logger('YT - Resumer', { debug: false });

  const MIN_SEEK_DIFFERENCE = 1.5;
  const DAYS_TO_KEEP_REGULAR = 90;
  const DAYS_TO_KEEP_SHORTS = 1;
  const DAYS_TO_KEEP_PREVIEWS = 10 / (24 * 60);
  const CLEANUP_INTERVAL_MS = 5 * 60 * 1000;

  let cleanupFunction = null;
  let currentVideoContext = { videoId: null, playlistId: null };
  let lastPlaylistId = null;

  const isExpired = status => {
    if (!status?.lastUpdated) return true;
    let daysToKeep;
    switch (status.videoType) {
      case 'short': {
        daysToKeep = DAYS_TO_KEEP_SHORTS;
        break;
      }
      case 'preview': {
        daysToKeep = DAYS_TO_KEEP_PREVIEWS;
        break;
      }
      default: {
        daysToKeep = DAYS_TO_KEEP_REGULAR;
      }
    }
    return Date.now() - status.lastUpdated > daysToKeep * 86400 * 1000;
  };

  async function getStorage() {
    const storedData = GM_getValue('yt_resumer_storage');
    return storedData || { videos: {}, playlists: {}, meta: {} };
  }

  async function setStorage(storage) {
    GM_setValue('yt_resumer_storage', storage);
  }

  async function seekVideo(player, videoElement, time) {
    if (!player || !videoElement || isNaN(time)) return;
    if (Math.abs(player.getCurrentTime() - time) > MIN_SEEK_DIFFERENCE) {
      await new Promise(resolve => {
        const onSeeked = () => {
          clearTimeout(timeout);
          videoElement.removeEventListener('seeked', onSeeked);
          resolve();
        };
        const timeout = setTimeout(onSeeked, 1500);
        videoElement.addEventListener('seeked', onSeeked, { once: true });
        // Skip buffering check on homepage to handle preview videos
        player.seekTo(time, true, { skipBufferingCheck: window.location.pathname === '/' });
        logger(`Seeking to ${Math.round(time)}s`);
      });
    }
  }

  async function resumePlayback(player, videoId, videoElement, inPlaylist = false, playlistId = '', previousPlaylistId = null) {
    try {
      const playerSize = player.getPlayerSize();
      if (playerSize.width === 0 || playerSize.height === 0) return;

      const storage = await getStorage();
      const storedData = inPlaylist ? storage.playlists[playlistId] : storage.videos[videoId];
      if (!storedData) return;

      let targetVideoId = videoId;
      let resumeTime = storedData.timestamp;

      // Handle playlist navigation - resume last watched video if switching playlists
      if (inPlaylist && storedData.videos) {
        const lastWatchedVideoId = storedData.lastWatchedVideoId;
        if (playlistId !== previousPlaylistId && lastWatchedVideoId && videoId !== lastWatchedVideoId) {
          targetVideoId = lastWatchedVideoId;
        }
        resumeTime = storedData.videos?.[targetVideoId]?.timestamp;
      }

      if (resumeTime) {
        if (inPlaylist && videoId !== targetVideoId) {
          const playlistVideos = await waitForPlaylist(player);
          const videoIndex = playlistVideos.indexOf(targetVideoId);
          if (videoIndex !== -1) player.playVideoAt(videoIndex);
        } else {
          await seekVideo(player, videoElement, resumeTime);
        }
      }
    } catch (error) {
      logger.error('Failed to resume playback', error);
    }
  }

  async function updateStatus(player, videoElement, type, playlistId = '') {
    try {
      const videoId = player.getVideoData()?.video_id;
      if (!videoId) return;

      const currentTime = videoElement.currentTime;
      if (isNaN(currentTime) || currentTime === 0) return;

      const storage = await getStorage();
      if (playlistId) {
        storage.playlists[playlistId] = storage.playlists[playlistId] || { lastWatchedVideoId: '', videos: {} };
        storage.playlists[playlistId].videos[videoId] = {
          timestamp: currentTime,
          lastUpdated: Date.now(),
          videoType: type
        };
        storage.playlists[playlistId].lastWatchedVideoId = videoId;
      } else {
        storage.videos[videoId] = {
          timestamp: currentTime,
          lastUpdated: Date.now(),
          videoType: type
        };
      }

      await setStorage(storage);
    } catch (error) {
      logger.error('Failed to update playback status', error);
    }
  }

  async function handleVideo(playerContainer, player, videoElement, skipResume = false) {
    if (cleanupFunction) cleanupFunction();

    const urlSearchParameters = new URLSearchParams(window.location.search);
    const videoId = urlSearchParameters.get('v') || player.getVideoData()?.video_id;
    if (!videoId) return;

    // Exclude "Watch Later" playlist (WL) from playlist tracking
    const playlistId = ((rawPlaylistId) => (rawPlaylistId !== 'WL' ? rawPlaylistId : null))(urlSearchParameters.get('list'));
    currentVideoContext = { videoId, playlistId };

    const isLiveStream = player.getVideoData()?.isLive;
    const isPreviewVideo = playerContainer.id === 'inline-player';
    const hasTimeParameter = urlSearchParameters.has('t');

    // Don't resume live streams or videos with explicit timestamps
    if (isLiveStream || hasTimeParameter) {
      lastPlaylistId = playlistId;
      return;
    }

    const videoType = window.location.pathname.startsWith('/shorts/') ? 'short' : isPreviewVideo ? 'preview' : 'regular';
    let hasResumed = false;

    const onTimeUpdate = () => {
      if (!hasResumed && !skipResume) {
        hasResumed = true;
        resumePlayback(player, videoId, videoElement, !!playlistId, playlistId, lastPlaylistId);
      } else {
        updateStatus(player, videoElement, videoType, playlistId);
      }
    };

    const onRemoteUpdate = async (event_) => {
      logger(`Remote update received`);
      await seekVideo(player, videoElement, event_.detail.time);
    };

    videoElement.addEventListener('timeupdate', onTimeUpdate, true);
    window.addEventListener('yt-resumer-remote-update', onRemoteUpdate, true);

    cleanupFunction = () => {
      videoElement.removeEventListener('timeupdate', onTimeUpdate, true);
      window.removeEventListener('yt-resumer-remote-update', onRemoteUpdate, true);
      currentVideoContext = { videoId: null, playlistId: null };
    };

    lastPlaylistId = playlistId;
  }

  function waitForPlaylist(player) {
    return new Promise((resolve, reject) => {
      const existingPlaylist = player.getPlaylist();
      if (existingPlaylist?.length) return resolve(existingPlaylist);

      let attempts = 0;
      const checkInterval = setInterval(() => {
        const playlist = player.getPlaylist();
        if (playlist?.length) {
          clearInterval(checkInterval);
          resolve(playlist);
        } else if (++attempts > 50) {
          clearInterval(checkInterval);
          reject('Playlist not found');
        }
      }, 100);
    });
  }

  function onStorageChange(storageKey, newStorageValue, isRemoteChange) {
    if (!isRemoteChange || !newStorageValue) return;
    // Sync playback position across tabs for current video
    let resumeTime;
    if (storageKey === currentVideoContext.playlistId && newStorageValue.videos) {
      resumeTime = newStorageValue.videos[currentVideoContext.videoId]?.timestamp;
    } else if (storageKey === currentVideoContext.videoId) {
      resumeTime = newStorageValue.timestamp;
    }
    if (resumeTime) {
      window.dispatchEvent(new CustomEvent('yt-resumer-remote-update', { detail: { time: resumeTime } }));
    }
  }

  async function cleanupOldData() {
    try {
      const storage = await getStorage();
      for (const videoId in storage.videos) {
        if (isExpired(storage.videos[videoId])) delete storage.videos[videoId];
      }
      for (const playlistId in storage.playlists) {
        let hasChanged = false;
        const playlist = storage.playlists[playlistId];
        for (const videoId in playlist.videos) {
          if (isExpired(playlist.videos[videoId])) {
            delete playlist.videos[videoId];
            hasChanged = true;
          }
        }
        if (Object.keys(playlist.videos).length === 0) delete storage.playlists[playlistId];
        else if (hasChanged) storage.playlists[playlistId] = playlist;
      }
      await setStorage(storage);
    } catch (error) {
      logger.error(`Failed to clean up stored playback statuses: ${error}`);
    }
  }

  async function periodicCleanup() {
    const storage = await getStorage();
    const lastCleanupTime = storage.meta.lastCleanup || 0;
    if (Date.now() - lastCleanupTime < CLEANUP_INTERVAL_MS) return;
    storage.meta.lastCleanup = Date.now();
    await setStorage(storage);
    logger('This tab is handling the scheduled cleanup');
    await cleanupOldData();
  }

  async function init() {
    try {
      window.addEventListener('pagehide', () => cleanupFunction?.(), true);

      await periodicCleanup();
      setInterval(periodicCleanup, CLEANUP_INTERVAL_MS);

      GM_addValueChangeListener(onStorageChange);

      logger('This tab is handling the initial load');
      window.addEventListener('pageshow', () => {
        logger('This tab is handling the video load');
        initVideoLoad();
        window.addEventListener('yt-player-updated', onVideoContainerLoad, true);
        window.addEventListener('yt-autonav-pause-player-ended', () => cleanupFunction?.(), true);
      }, { once: true });

    } catch (error) { logger.error('Initialization failed', error); }
  }

  function initVideoLoad() {
    const player = document.querySelector('#movie_player');
    if (!player) return;
    const videoElement = player.querySelector('video');
    if (videoElement) handleVideo(player, player.player_ || player, videoElement);
  }

  function onVideoContainerLoad(event_) {
    const videoContainer = event_.target;
    const playerInstance = videoContainer?.player_;
    const videoElement = videoContainer?.querySelector('video');
    if (playerInstance && videoElement) handleVideo(videoContainer, playerInstance, videoElement);
  }

  init();

})();