您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically saves and resumes YouTube videos from where you left off, with playlist, Shorts, and preview handling, plus automatic cleanup.
// ==UserScript== // @name YouTube - Resumer // @version 2.0.0 // @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@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/libs/gm/gmcompat.min.js // @require https://cdn.jsdelivr.net/gh/StylusThemes/Userscripts@c185c2777d00a6826a8bf3c43bbcdcfeba5a9566/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 }); /** CONFIG **/ const MIN_SEEK_DIFF = 1.5; const DAYS_REGULAR = 90; // normal videos expire after 90 days const DAYS_SHORTS = 1; // Shorts expire after 1 day const DAYS_PREVIEWS = 10 / (24 * 60); // previews expire after 10 minutes const CLEANUP_INTERVAL = 5 * 60 * 1000; // 5 minutes /** STATE **/ let activeCleanup = null; let currentContext = { videoId: null, playlistId: null }; let lastPlaylistId = null; /** UTILS **/ const isExpired = status => { if (!status?.lastUpdated) return true; let days; switch (status.videoType) { case 'short': days = DAYS_SHORTS; break; case 'preview': days = DAYS_PREVIEWS; break; default: days = DAYS_REGULAR; } return Date.now() - status.lastUpdated > days * 86400 * 1000; }; /** STORAGE HELPERS **/ async function getStorage() { const stored = await GMC.getValue('yt_resumer_storage'); return stored || { videos: {}, playlists: {}, meta: {} }; } async function setStorage(storage) { await GMC.setValue('yt_resumer_storage', storage); } /** VIDEO CONTROL **/ async function seekVideo(player, videoEl, time) { if (!player || !videoEl || isNaN(time)) return; if (Math.abs(player.getCurrentTime() - time) > MIN_SEEK_DIFF) { await new Promise(resolve => { const onSeeked = () => { clearTimeout(timeout); videoEl.removeEventListener('seeked', onSeeked); resolve(); }; const timeout = setTimeout(onSeeked, 1500); videoEl.addEventListener('seeked', onSeeked, { once: true }); player.seekTo(time, true, { skipBufferingCheck: window.location.pathname === '/' }); logger(`Seeking to ${Math.round(time)}s`); }); } } /** PLAYBACK MANAGEMENT **/ async function resumePlayback(player, videoId, videoEl, inPlaylist = false, playlistId = '', prevPlaylistId = null) { try { const playerSize = player.getPlayerSize(); if (playerSize.width === 0 || playerSize.height === 0) return; const storage = await getStorage(); const stored = inPlaylist ? storage.playlists[playlistId] : storage.videos[videoId]; if (!stored) return; let targetVideoId = videoId; let timeToResume = stored.timestamp; if (inPlaylist && stored.videos) { const lastVideo = stored.lastWatchedVideoId; if (playlistId !== prevPlaylistId && lastVideo && videoId !== lastVideo) { targetVideoId = lastVideo; } timeToResume = stored.videos?.[targetVideoId]?.timestamp; } if (timeToResume) { if (inPlaylist && videoId !== targetVideoId) { const playlist = await waitForPlaylist(player); const idx = playlist.indexOf(targetVideoId); if (idx !== -1) player.playVideoAt(idx); } else { await seekVideo(player, videoEl, timeToResume); } } } catch (err) { logger.error('Failed to resume playback', err); } } async function updateStatus(player, videoEl, type, playlistId = '') { try { const videoId = player.getVideoData()?.video_id; if (!videoId) return; const currentTime = videoEl.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: 'playlist' }; storage.playlists[playlistId].lastWatchedVideoId = videoId; } else { storage.videos[videoId] = { timestamp: currentTime, lastUpdated: Date.now(), videoType: type }; } await setStorage(storage); } catch (err) { logger.error('Failed to update playback status', err); } } /** VIDEO PROCESSING **/ async function handleVideo(playerContainer, player, videoEl, skipResume = false) { if (activeCleanup) activeCleanup(); const urlParams = new URLSearchParams(window.location.search); const videoId = urlParams.get('v') || player.getVideoData()?.video_id; if (!videoId) return; const playlistId = urlParams.get('list'); currentContext = { videoId, playlistId }; const isLive = player.getVideoData()?.isLive; const isPreview = playerContainer.id === 'inline-player'; const timeSpecified = urlParams.has('t'); if (isLive || timeSpecified) { lastPlaylistId = playlistId; return; } const videoType = window.location.pathname.startsWith('/shorts/') ? 'short' : isPreview ? 'preview' : 'regular'; let resumed = false; const onTimeUpdate = () => { if (!resumed && !skipResume) { resumed = true; resumePlayback(player, videoId, videoEl, !!playlistId, playlistId, lastPlaylistId); } else { updateStatus(player, videoEl, videoType, playlistId); } }; const onRemoteUpdate = async (event) => { logger(`Remote update received`); await seekVideo(player, videoEl, event.detail.time); }; videoEl.addEventListener('timeupdate', onTimeUpdate, true); window.addEventListener('yt-resumer-remote-update', onRemoteUpdate, true); activeCleanup = () => { videoEl.removeEventListener('timeupdate', onTimeUpdate, true); window.removeEventListener('yt-resumer-remote-update', onRemoteUpdate, true); currentContext = { videoId: null, playlistId: null }; }; lastPlaylistId = playlistId; } /** PLAYLIST HANDLER **/ function waitForPlaylist(player) { return new Promise((resolve, reject) => { const existing = player.getPlaylist(); if (existing?.length) return resolve(existing); let attempts = 0; const interval = setInterval(() => { const list = player.getPlaylist(); if (list?.length) { clearInterval(interval); resolve(list); } else if (++attempts > 50) { clearInterval(interval); reject('Playlist not found'); } }, 100); }); } /** STORAGE EVENTS **/ function onStorageChange(key, newValue, remote) { if (!remote || !newValue) return; // Broadcast update to video if it's current let time; if (key === currentContext.playlistId && newValue.videos) { time = newValue.videos[currentContext.videoId]?.timestamp; } else if (key === currentContext.videoId) { time = newValue.timestamp; } if (time) { window.dispatchEvent(new CustomEvent('yt-resumer-remote-update', { detail: { time } })); } } /** CLEANUP **/ async function cleanupOldData() { try { const storage = await getStorage(); for (const vid in storage.videos) { if (isExpired(storage.videos[vid])) delete storage.videos[vid]; } for (const pl in storage.playlists) { let changed = false; const playlist = storage.playlists[pl]; for (const vid in playlist.videos) { if (isExpired(playlist.videos[vid])) { delete playlist.videos[vid]; changed = true; } } if (Object.keys(playlist.videos).length === 0) delete storage.playlists[pl]; else if (changed) storage.playlists[pl] = playlist; } await setStorage(storage); } catch (err) { logger.error(`Failed to clean up stored playback statuses: ${err}`); } } async function periodicCleanup() { const storage = await getStorage(); const last = storage.meta.lastCleanup || 0; if (Date.now() - last < CLEANUP_INTERVAL) return; storage.meta.lastCleanup = Date.now(); await setStorage(storage); logger('This tab is handling the scheduled cleanup'); await cleanupOldData(); } /** INITIALIZATION **/ async function init() { try { window.addEventListener('pagehide', () => activeCleanup?.(), true); await periodicCleanup(); setInterval(periodicCleanup, CLEANUP_INTERVAL); GMC.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', () => activeCleanup?.(), true); }, { once: true }); } catch (err) { logger.error('Initialization failed', err); } } function initVideoLoad() { const player = document.querySelector('#movie_player'); if (!player) return; const videoEl = player.querySelector('video'); if (videoEl) handleVideo(player, player.player_ || player, videoEl); } function onVideoContainerLoad(event) { const container = event.target; const player = container?.player_; const videoEl = container?.querySelector('video'); if (player && videoEl) handleVideo(container, player, videoEl); } /** START **/ init(); })();