您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
无缝接续播放任何 YouTube 视频,从您上次离开的地方继续观看。此脚本会自动保存您的播放进度,并拥有智能播放列表处理功能:您在播放列表中的进度会被独立保存,不会影响您在其他地方观看同一视频的记录。此外,它还能以独特规则处理 Shorts 和视频预览,并会自动清理过期数据。
// ==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 1.7.3 // @match *://www.youtube.com/* // @match *://www.youtube-nocookie.com/* // @exclude *://music.youtube.com/* // @exclude *://studio.youtube.com/* // @exclude *://*.youtube.com/embed/* // @grant GM.getValue // @grant GM.setValue // @grant GM.deleteValue // @grant GM.listValues // @grant GM.addValueChangeListener // @grant GM_getValue // @grant GM_setValue // @grant GM_deleteValue // @grant GM_listValues // @grant GM_addValueChangeListener // @license MIT // @description Seamlessly continue any YouTube video exactly where you left off. This script automatically saves your playback position and features intelligent playlist handling: your progress within a playlist is saved separately for that playlist, keeping it distinct from your progress on the same video watched elsewhere. It also handles Shorts and video previews with unique rules, and automatically cleans up old data. // @description:zh-TW 無縫接續播放任何 YouTube 影片,從您上次離開的地方繼續觀看。此腳本會自動儲存您的播放進度,並擁有智慧型播放清單處理功能:您在播放清單中的進度會被獨立儲存,不會影響您在其他地方觀看同部影片的紀錄。此外,它還能以獨特規則處理 Shorts 和影片預覽,並會自動清理過期資料。 // @description:zh-CN 无缝接续播放任何 YouTube 视频,从您上次离开的地方继续观看。此脚本会自动保存您的播放进度,并拥有智能播放列表处理功能:您在播放列表中的进度会被独立保存,不会影响您在其他地方观看同一视频的记录。此外,它还能以独特规则处理 Shorts 和视频预览,并会自动清理过期数据。 // @description:ja あらゆるYouTube動画を、中断したその場所からシームレスに再生を再開します。このスクリプトは再生位置を自動的に保存し、スマートなプレイリスト処理機能を搭載。プレイリスト内での視聴進捗はそのプレイリスト専用に別途保存され、他の場所で同じ動画を視聴した際の進捗に影響を与えません。また、ショート動画やプレビューも独自のルールで処理し、古いデータは自動でクリーンアップします。 // @homepage https://greasyfork.org/scripts/526798-youtube-auto-resume // ==/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 LAST_CLEANUP_KEY = 'youtube_auto_resume_lastCleanupTimestamp'; let useCompatibilityMode = false; let isIFrame = false; let activeCleanup = null; let lastPlaylistId = null; let currentVideoContext = { videoId: null, playlistId: null }; const GMCustom = { addValueChangeListener: null, getValue: null, setValue: null, deleteValue: null, listValues: null, }; 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); console.log(`%cYouTube Auto-Resume:%c Seeking 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; // Early return if player is preloaded without being queued. const keyToFetch = inPlaylist ? playlistId : videoId; const playbackStatus = await GMCustom.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(`YouTube Auto-Resume: Failed to resume playback: ${error}`); } } async function updatePlaybackStatus(playerApi, videoElement, videoType, playlistId = '') { try { const liveVideoId = playerApi.getVideoData()?.video_id; if (!liveVideoId) return; const videoDuration = videoElement.duration; const currentPlaybackTime = videoElement.currentTime; if (isNaN(videoDuration) || isNaN(currentPlaybackTime) || currentPlaybackTime === 0) return; const finishThreshold = Math.min(videoDuration * 0.01, STATIC_FINISH_SECONDS); const isFinished = videoDuration - currentPlaybackTime < finishThreshold; if (playlistId) { const playlistData = (await GMCustom.getValue(playlistId)) || { lastWatchedVideoId: '', videos: {} }; if (isFinished) { if (playlistData.videos?.[liveVideoId]) { delete playlistData.videos[liveVideoId]; await GMCustom.setValue(playlistId, playlistData); } } else { playlistData.videos = playlistData.videos || {}; playlistData.videos[liveVideoId] = { timestamp: currentPlaybackTime, lastUpdated: Date.now(), videoType: 'playlist', }; playlistData.lastWatchedVideoId = liveVideoId; await GMCustom.setValue(playlistId, playlistData); } } else { if (isFinished) { await GMCustom.deleteValue(liveVideoId); } else { await GMCustom.setValue(liveVideoId, { timestamp: currentPlaybackTime, lastUpdated: Date.now(), videoType: videoType, }); } } } catch (error) { console.error(`YouTube Auto-Resume: 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; currentVideoContext = { videoId: initialVideoId, playlistId: searchParams.get('list') }; 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 = searchParams.has('list'); const playlistId = searchParams.get('list'); 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); } }; const remoteUpdateHandler = async (event) => { console.log('%cYouTube Auto-Resume:%c Received remote update.', 'font-weight: bold;', ''); await applySeek(playerApi, videoElement, event.detail.time); }; videoElement.addEventListener('timeupdate', timeUpdateHandler, true); window.addEventListener('youtube-auto-resume-remote-update', remoteUpdateHandler, true); activeCleanup = () => { videoElement.removeEventListener('timeupdate', timeUpdateHandler, true); window.removeEventListener('youtube-auto-resume-remote-update', remoteUpdateHandler, true); currentVideoContext = { videoId: null, playlistId: null }; }; lastPlaylistId = playlistId; } function storageChangeListener(key, oldValue, newValue, remote) { if (!remote || !newValue) return; let remoteTimestamp; if (key === currentVideoContext.playlistId && newValue.videos) { remoteTimestamp = newValue.videos[currentVideoContext.videoId]?.timestamp; } else if (key === currentVideoContext.videoId) { remoteTimestamp = newValue.timestamp; } if (remoteTimestamp) { window.dispatchEvent(new CustomEvent('youtube-auto-resume-remote-update', { detail: { time: remoteTimestamp } })); } } async function handleCleanupCycle() { const lastCleanupTime = await GMCustom.getValue(LAST_CLEANUP_KEY, 0); const now = Date.now(); if (now - lastCleanupTime < CLEANUP_INTERVAL_MS) return; await GMCustom.setValue(LAST_CLEANUP_KEY, now); console.log('%cYouTube Auto-Resume:%c This tab is handling the scheduled cleanup.', 'font-weight: bold;', ''); await cleanUpExpiredStatuses(); } async function cleanUpExpiredStatuses() { try { const keys = await GMCustom.listValues(); for (const key of keys) { if (key === LAST_CLEANUP_KEY) continue; const storedData = await GMCustom.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) { await GMCustom.deleteValue(key); } else if (hasChanged) { await GMCustom.setValue(key, storedData); } } else { if (isExpired(storedData)) { await GMCustom.deleteValue(key); } } } } catch (error) { console.error(`YouTube Auto-Resume: Failed to clean up stored playback statuses: ${error}`); } } async function migrateOldData() { try { const keys = await GMCustom.listValues(); for (const key of keys) { // This logic identifies old playlist keys based on their typical length. if (key.length > 11) { const storedData = await GMCustom.getValue(key); // The old format had `videoId` at the top level. The new format has a `videos` object. if (storedData?.videoId && !storedData.videos) { console.log( `%cYouTube Auto-Resume:%c Migrating old playlist data for key: %c${key}%c`, 'font-weight: bold;', '', 'font-weight: bold;', '' ); const newPlaylistData = { lastWatchedVideoId: storedData.videoId, videos: { [storedData.videoId]: { timestamp: storedData.timestamp, lastUpdated: storedData.lastUpdated, videoType: 'playlist', }, }, }; await GMCustom.setValue(key, newPlaylistData); } } } } catch (error) { console.error(`YouTube Auto-Resume: Failed to migrate old data: ${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); } async function handleInitialLoad() { const playerContainer = document.querySelector('#movie_player'); if (playerContainer) { const videoElement = playerContainer.querySelector('video'); const playerApi = playerContainer.player_ || playerContainer; if (videoElement && playerApi) await 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; } function hasGreasyMonkeyAPI() { if (typeof GM_info !== 'undefined') { if (typeof GM !== 'undefined' && typeof GM.addValueChangeListener === 'function') { GMCustom.addValueChangeListener = GM.addValueChangeListener; GMCustom.getValue = GM.getValue; GMCustom.setValue = GM.setValue; GMCustom.deleteValue = GM.deleteValue; GMCustom.listValues = GM.listValues; } else { useCompatibilityMode = true; GMCustom.addValueChangeListener = GM_addValueChangeListener; GMCustom.getValue = async (key, def) => GM_getValue(key, def); GMCustom.setValue = async (key, val) => GM_setValue(key, val); GMCustom.deleteValue = async (key) => GM_deleteValue(key); GMCustom.listValues = async () => GM_listValues(); } return true; } return false; } async function initialize() { isIFrame = window.top !== window.self; if (!hasGreasyMonkeyAPI() || isIFrame) return; if (useCompatibilityMode) console.warn('YouTube Auto-Resume: Running in GM compatibility mode (promisified).'); window.addEventListener( 'pagehide', () => { if (activeCleanup) activeCleanup(); }, true ); await migrateOldData(); // Migrates data from v1.6 and earlier await handleCleanupCycle(); setInterval(handleCleanupCycle, CLEANUP_INTERVAL_MS); GMCustom.addValueChangeListener(storageChangeListener); window.addEventListener('yt-navigate-finish', handleInitialLoad, true); window.addEventListener('yt-player-updated', handleVideoLoad, true); if (document.readyState === 'complete' || document.readyState === 'interactive') { handleInitialLoad(); } else { window.addEventListener('load', handleInitialLoad, { once: true }); } } initialize(); })();