// ==UserScript== // @name YouTube Auto-Resume // @name:zh-TW YouTube 自動續播 // @name:zh-CN YouTube 自动续播 // @name:ja YouTube 自動レジューム // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @author ElectroKnight22 // @namespace electroknight22_youtube_auto_resume_namespace // @version 2.1.0 // @match *://www.youtube.com/* // @match *://m.youtube.com/* // @match *://www.youtube-nocookie.com/* // @exclude *://music.youtube.com/* // @exclude *://studio.youtube.com/* // @exclude *://*.youtube.com/embed/* // @exclude *://www.youtube.com/live_chat* // @grant none // @license MIT // @description Seamlessly continue any YouTube video where you left off. This script automatically saves your playback position and features intelligent playlist handling: your progress within a playlist is saved separately, keeping it distinct from your progress on the same video watched elsewhere. Old data is cleaned up automatically. // @description:zh-TW 無縫接續播放任何 YouTube 影片,從您上次離開的地方繼續觀看。此腳本會自動儲存您的播放進度,並擁有智慧型播放清單處理功能:您在播放清單中的進度會被獨立儲存,不會影響您在其他地方觀看同部影片的紀錄。此外,它還能以獨特規則處理 Shorts 和影片預覽,並會自動清理過期資料。 // @description:zh-CN 无缝接续播放任何 YouTube 视频,从您上次离开的地方继续观看。此脚本会自动保存您的播放进度,并拥有智能播放列表处理功能:您在播放列表中的进度会被独立保存,不会影响您在其他地方观看同一视频的记录。此外,它还能以独特规则处理 Shorts 和视频预览,并会自动清理过期数据。 // @description:ja あらゆるYouTube動画を、中断したその場所からシームレスに再生を再開します。このスクリプトは再生位置を自動的に保存し、スマートなプレイリスト処理機能を搭載。プレイリスト内での視聴進捗はそのプレイリスト専用に別途保存され、他の場所で同じ動画を視聴した際の進捗に影響を与えません。また、ショート動画やプレビューも独自のルールで処理し、古いデータは自動でクリーンアップします。 // @homepage https://greasyfork.org/scripts/526798-youtube-auto-resume // @downloadURL https://update.greasyfork.icu/scripts/526798/YouTube%20Auto-Resume.user.js // @updateURL https://update.greasyfork.icu/scripts/526798/YouTube%20Auto-Resume.meta.js // ==/UserScript== /*jshint esversion: 11 */ (function () { 'use strict'; const MIN_SEEK_DIFFERENCE_SECONDS = 1.5; const daysToRemember = 30; const daysToRememberShorts = 1; const daysToRememberPreviews = 10 / (24 * 60); // 10 minutes const STATIC_FINISH_SECONDS = 90; const CLEANUP_INTERVAL_MS = 300000; // 5 minutes const STORAGE_PREFIX = 'YT_AUTO_RESUME_'; const FOCUS_LOCK_KEY = `${STORAGE_PREFIX}focusLock`; const LAST_CLEANUP_KEY = 'lastCleanupTimestamp'; const TAB_ID = crypto.randomUUID(); const IS_IFRAME = window.top !== window.self; let activeCleanup = null; let lastPlaylistId = null; let currentVideoContext = { videoId: null, playlistId: null, storageKey: null }; const StorageManager = { getValue: (key) => { try { const value = localStorage.getItem(STORAGE_PREFIX + key); return value ? JSON.parse(value) : null; } catch (error) { console.error(`Failed to parse storage key "${key}"`, error); return null; } }, setValue: (key, value) => { try { localStorage.setItem(STORAGE_PREFIX + key, JSON.stringify(value)); } catch (error) { console.error(`Failed to set storage key "${key}"`, error); } }, deleteValue: (key) => { localStorage.removeItem(STORAGE_PREFIX + key); }, listValues: () => { return Object.keys(localStorage) .filter((key) => key.startsWith(STORAGE_PREFIX)) .map((key) => key.substring(STORAGE_PREFIX.length)); }, }; async function claimFocus() { if (currentVideoContext.storageKey) { StorageManager.setValue(FOCUS_LOCK_KEY, { tabId: TAB_ID, key: currentVideoContext.storageKey, lastFocused: Date.now(), }); } } async function hasWritePermission() { if (!currentVideoContext.storageKey) return false; const focusLock = StorageManager.getValue(FOCUS_LOCK_KEY); if (!focusLock) return true; // If no lock exists, any tab can claim it. return focusLock.key === currentVideoContext.storageKey && focusLock.tabId === TAB_ID; } async function applySeek(playerApi, videoElement, timeToSeek) { if (!playerApi || !videoElement || isNaN(timeToSeek)) return; const currentPlaybackTime = playerApi.getCurrentTime(); if (Math.abs(currentPlaybackTime - timeToSeek) > MIN_SEEK_DIFFERENCE_SECONDS) { await new Promise((resolve) => { const onSeeked = () => { clearTimeout(seekTimeout); videoElement.removeEventListener('seeked', onSeeked); resolve(); }; const seekTimeout = setTimeout(onSeeked, 1500); videoElement.addEventListener('seeked', onSeeked, { once: true }); playerApi.seekTo(timeToSeek, true, { skipBufferingCheck: window.location.pathname === '/' ? true : false }); console.log(`%cSeeking video to ${Math.round(timeToSeek)}s`, 'font-weight: bold;'); }); } } async function resumePlayback(playerApi, videoId, videoElement, inPlaylist = false, playlistId = '', navigatedFromPlaylistId = null) { try { const playerSize = playerApi.getPlayerSize(); if (playerSize.width === 0 || playerSize.height === 0) return; const keyToFetch = inPlaylist ? playlistId : videoId; const playbackStatus = StorageManager.getValue(keyToFetch); if (!playbackStatus) return; let lastPlaybackTime; let videoToResumeId = videoId; if (inPlaylist) { if (!playbackStatus.videos) return; const lastWatchedFromStorage = playbackStatus.lastWatchedVideoId; if (playlistId !== navigatedFromPlaylistId && lastWatchedFromStorage && videoId !== lastWatchedFromStorage) { videoToResumeId = lastWatchedFromStorage; } lastPlaybackTime = playbackStatus.videos?.[videoToResumeId]?.timestamp; } else { lastPlaybackTime = playbackStatus.timestamp; } if (lastPlaybackTime) { if (inPlaylist && videoId !== videoToResumeId) { const playlist = await getPlaylistWhenReady(playerApi); const index = playlist.indexOf(videoToResumeId); if (index !== -1) playerApi.playVideoAt(index); } else { await applySeek(playerApi, videoElement, lastPlaybackTime); } } } catch (error) { console.error(`Failed to resume playback: ${error}`); } } async function updatePlaybackStatus(playerApi, videoElement, videoType, playlistId = '') { try { if (!(await hasWritePermission())) return; const liveVideoId = playerApi.getVideoData()?.video_id; if (!liveVideoId) return; const videoDuration = videoElement.duration; const currentPlaybackTime = videoElement.currentTime; if (isNaN(videoDuration) || isNaN(currentPlaybackTime) || currentPlaybackTime < 0.5) return; const finishThreshold = Math.min(videoDuration * 0.01, STATIC_FINISH_SECONDS); const isFinished = videoDuration - currentPlaybackTime < finishThreshold; if (playlistId) { const playlistData = StorageManager.getValue(playlistId) || { lastWatchedVideoId: '', videos: {} }; if (isFinished) { if (playlistData.videos?.[liveVideoId]) { delete playlistData.videos[liveVideoId]; StorageManager.setValue(playlistId, playlistData); } } else { playlistData.videos = playlistData.videos || {}; playlistData.videos[liveVideoId] = { timestamp: currentPlaybackTime, lastUpdated: Date.now(), videoType: 'playlist', }; playlistData.lastWatchedVideoId = liveVideoId; StorageManager.setValue(playlistId, playlistData); } } else { if (isFinished) { StorageManager.deleteValue(liveVideoId); } else { StorageManager.setValue(liveVideoId, { timestamp: currentPlaybackTime, lastUpdated: Date.now(), videoType: videoType, }); } } } catch (error) { console.error(`Failed to update playback status: ${error}`); } } async function processVideo(playerContainer, playerApi, videoElement) { if (activeCleanup) activeCleanup(); const pageUrl = new URL(window.location.href); const searchParams = pageUrl.searchParams; const initialVideoId = searchParams.get('v') || playerApi.getVideoData()?.video_id; if (!initialVideoId) return; const playlistId = searchParams.get('list'); currentVideoContext = { videoId: initialVideoId, playlistId: playlistId, storageKey: playlistId || initialVideoId, }; await claimFocus(); const isPreview = playerContainer.id === 'inline-player'; const isLive = playerApi.getVideoData().isLive; const timeSpecified = searchParams.has('t'); if (isLive || timeSpecified) { lastPlaylistId = searchParams.get('list'); return; } const inPlaylist = !!playlistId; let videoType = pageUrl.pathname.startsWith('/shorts/') ? 'short' : isPreview ? 'preview' : 'regular'; let hasAttemptedResume = false; const timeupdateHandler = () => { if (!hasAttemptedResume) { hasAttemptedResume = true; resumePlayback(playerApi, initialVideoId, videoElement, inPlaylist, playlistId, lastPlaylistId); } else { updatePlaybackStatus(playerApi, videoElement, videoType, playlistId); } }; videoElement.removeEventListener('timeupdate', timeupdateHandler); videoElement.addEventListener('timeupdate', timeupdateHandler); activeCleanup = () => { currentVideoContext = { videoId: null, playlistId: null, storageKey: null }; }; lastPlaylistId = playlistId; } async function handleCleanupCycle() { const lastCleanupTime = StorageManager.getValue(LAST_CLEANUP_KEY) || 0; const now = Date.now(); if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) return; StorageManager.setValue(LAST_CLEANUP_KEY, now); console.log('%cThis tab is handling the scheduled cleanup.', 'font-weight: bold;'); await cleanUpExpiredStatuses(); } async function cleanUpExpiredStatuses() { try { const keys = StorageManager.listValues(); for (const key of keys) { if (key === LAST_CLEANUP_KEY || key === FOCUS_LOCK_KEY) continue; const storedData = StorageManager.getValue(key); if (!storedData) continue; if (storedData.videos) { let hasChanged = false; for (const videoId in storedData.videos) { if (isExpired(storedData.videos[videoId])) { delete storedData.videos[videoId]; hasChanged = true; } } if (Object.keys(storedData.videos).length === 0) { StorageManager.deleteValue(key); } else if (hasChanged) { StorageManager.setValue(key, storedData); } } else { if (isExpired(storedData)) { StorageManager.deleteValue(key); } } } } catch (error) { console.error(`Failed to clean up stored playback statuses: ${error}`); } } function getPlaylistWhenReady(playerApi) { return new Promise((resolve, reject) => { const initialPlaylist = playerApi.getPlaylist(); if (initialPlaylist?.length > 0) return resolve(initialPlaylist); let hasResolved = false, pollerInterval = null; const cleanup = () => { window.removeEventListener('yt-playlist-data-updated', startPolling); if (pollerInterval) clearInterval(pollerInterval); }; const startPolling = () => { if (hasResolved) return; let attempts = 0; pollerInterval = setInterval(() => { const playlist = playerApi.getPlaylist(); if (playlist?.length > 0) { hasResolved = true; cleanup(); resolve(playlist); } else if (++attempts >= 50) { hasResolved = true; cleanup(); reject(new Error('Playlist not found after 5s.')); } }, 100); }; window.addEventListener('yt-playlist-data-updated', startPolling, { once: true }); setTimeout(() => { if (!hasResolved) startPolling(); }, 1000); }); } function handleVideoLoad(event) { const playerContainer = event.target; const playerApi = playerContainer?.player_; const videoElement = playerContainer?.querySelector('video'); if (playerApi && videoElement) processVideo(playerContainer, playerApi, videoElement); } function handleInitialLoad() { let playerContainer = null; if (window.location.pathname === '/watch') { playerContainer = document.querySelector('#movie_player'); } else if (window.location.pathname.startsWith('/shorts')) { playerContainer = document.querySelector('#shorts-player'); } if (playerContainer) { const videoElement = playerContainer.querySelector('video'); const playerApi = playerContainer.player_ || playerContainer; if (videoElement && playerApi) processVideo(playerContainer, playerApi, videoElement); } } function isExpired(statusObject) { if (!statusObject?.lastUpdated || isNaN(statusObject.lastUpdated)) return true; let daysToExpire; switch (statusObject.videoType || 'regular') { case 'short': daysToExpire = daysToRememberShorts; break; case 'preview': daysToExpire = daysToRememberPreviews; break; default: daysToExpire = daysToRemember; break; } return Date.now() - statusObject.lastUpdated > daysToExpire * 86400 * 1000; } async function initialize() { try { window.addEventListener( 'pagehide', () => { if (activeCleanup) activeCleanup(); } ); window.addEventListener('focus', claimFocus); const setupEventListeners = () => { const IS_MOBILE = window.location.hostname === 'm.youtube.com'; const playerUpdateEvent = IS_MOBILE ? 'state-navigateend' : 'yt-player-updated'; handleInitialLoad(); window.addEventListener(playerUpdateEvent, handleVideoLoad); window.addEventListener( 'yt-autonav-pause-player-ended', () => { if (activeCleanup) activeCleanup(); } ); }; if (IS_IFRAME) { // In an iframe, events can be less reliable. Rely on pageshow. window.addEventListener('pageshow', setupEventListeners); } else { // On the main page, listen for SPA and back/forward navigation. window.addEventListener('yt-page-data-updated', setupEventListeners); window.addEventListener('pageshow', setupEventListeners); } // Run cleanup cycle logic independently await handleCleanupCycle(); setInterval(handleCleanupCycle, CLEANUP_INTERVAL_MS); } catch (error) { console.error(`Initialization failed: ${error}`); } } initialize(); })();