YouTube - Resumer

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

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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();

})();