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.
// ==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.8.1
// @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*
// @require https://update.greasyfork.org/scripts/549881/1841778/YouTube%20Helper%20API.js
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.deleteValue
// @grant GM.listValues
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_listValues
// @run-at document-idle
// @inject-into page
// @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
// ==/UserScript==
/*jshint esversion: 11 */
/* global youtubeHelperApi */
(function () {
'use strict';
const api = youtubeHelperApi;
if (!api) return console.error('Helper API not found. Likely incompatible script manager or extension settings.');
const CONSTANTS = {
DAYS_TO_REMEMBER: 90,
DAYS_TO_REMEMBER_SHORTS: 1,
DAYS_TO_REMEMBER_PREVIEWS: 10 / (24 * 60), // 10 minutes
MIN_PROCESS_THRESHOLD: 1.5,
STATIC_FINISH_SECONDS: 15,
CLEANUP_INTERVAL_MS: 300000,
MAX_COMPLETED_VIDEOS: 100000,
STORAGE_PREFIX: 'YT_AUTO_RESUME_',
COMPLETED_KEY: 'COMPLETED_VIDEOS_LIST',
LAST_CLEANUP_KEY: 'lastCleanupTimestamp',
};
const StorageManager = {
activeStorageKey: null,
completedVideosCache: new Set(),
sessionPlaylistCache: new Map(),
sessionSyncedVideos: new Set(),
resolvePlaylistId: (rawId) => rawId === 'WL' ? null : rawId,
async init() {
CrossTabSyncManager.init();
const completedArray = await api.storage.load(CONSTANTS.STORAGE_PREFIX + CONSTANTS.COMPLETED_KEY, []);
StorageManager.completedVideosCache = new Set(Array.isArray(completedArray) ? completedArray : []);
},
async getValue(key) {
try { return await api.storage.load(CONSTANTS.STORAGE_PREFIX + key); }
catch (e) { console.error(`Failed to load storage key "${key}"`, e); return null; }
},
async setValue(key, value) {
try { await api.storage.save(CONSTANTS.STORAGE_PREFIX + key, value); }
catch (e) { console.error(`Failed to set storage key "${key}"`, e); }
},
async deleteValue(key) {
await api.storage.delete(CONSTANTS.STORAGE_PREFIX + key);
},
async getPlaybackData(videoId, rawPlaylistId, forceFresh = false) {
const playlistId = StorageManager.resolvePlaylistId(rawPlaylistId);
if (playlistId) {
let playlistData = StorageManager.sessionPlaylistCache.get(playlistId);
if (!playlistData || forceFresh) {
playlistData = await StorageManager.getValue(playlistId) || {};
StorageManager.sessionPlaylistCache.set(playlistId, playlistData);
}
const isCompleted = playlistData?.completedVideos?.includes(videoId) || false;
const savedData = isCompleted ? null : playlistData?.videos?.[videoId];
return { isCompleted, savedData };
} else {
if (forceFresh) {
const freshList = await StorageManager.getValue(CONSTANTS.COMPLETED_KEY) || [];
StorageManager.completedVideosCache = new Set(Array.isArray(freshList) ? freshList : []);
}
const isCompleted = StorageManager.completedVideosCache.has(videoId);
const savedData = isCompleted ? null : await StorageManager.getValue(videoId);
return { isCompleted, savedData };
}
},
async writeVideoState(videoId, rawPlaylistId, action, data = {}) {
const playlistId = StorageManager.resolvePlaylistId(rawPlaylistId);
const { timestamp, duration, videoType } = data;
try {
if (playlistId) {
await api.storage.update(CONSTANTS.STORAGE_PREFIX + playlistId, (stored) => {
const merged = stored || { lastWatchedVideoId: '', videos: {}, completedVideos: [] };
merged.videos = merged.videos || {};
merged.completedVideos = merged.completedVideos || [];
if (action === 'SAVE_PROGRESS') {
merged.videos[videoId] = { timestamp, duration, lastUpdated: Date.now(), videoType: 'playlist' };
merged.lastWatchedVideoId = videoId;
merged.completedVideos = merged.completedVideos.filter(id => id !== videoId);
} else if (action === 'DELETE_PROGRESS') {
delete merged.videos[videoId];
} else if (action === 'MARK_COMPLETED') {
if (!merged.completedVideos.includes(videoId)) {
merged.completedVideos.push(videoId);
if (merged.completedVideos.length > CONSTANTS.MAX_COMPLETED_VIDEOS) {
merged.completedVideos.shift();
}
}
delete merged.videos[videoId];
} else if (action === 'REMOVE_COMPLETED') {
merged.completedVideos = merged.completedVideos.filter(id => id !== videoId);
}
return merged;
}, null, { strategy: 'optimistic' });
} else {
if (action === 'SAVE_PROGRESS') {
await api.storage.update(CONSTANTS.STORAGE_PREFIX + videoId, () => ({ timestamp, duration, lastUpdated: Date.now(), videoType }), null, { strategy: 'optimistic' });
if (StorageManager.completedVideosCache.has(videoId)) {
StorageManager.completedVideosCache.delete(videoId);
await api.storage.update(CONSTANTS.STORAGE_PREFIX + CONSTANTS.COMPLETED_KEY, list => Array.isArray(list) ? list.filter(id => id !== videoId) : [], [], { strategy: 'optimistic' });
}
} else if (action === 'DELETE_PROGRESS') {
await StorageManager.deleteValue(videoId);
} else if (action === 'MARK_COMPLETED') {
StorageManager.completedVideosCache.add(videoId);
await StorageManager.deleteValue(videoId);
await api.storage.update(CONSTANTS.STORAGE_PREFIX + CONSTANTS.COMPLETED_KEY, (list) => {
const filtered = Array.isArray(list) ? list.filter(id => id !== videoId) : [];
filtered.push(videoId);
if (filtered.length > CONSTANTS.MAX_COMPLETED_VIDEOS) filtered.shift();
return filtered;
}, [], { strategy: 'optimistic' });
} else if (action === 'REMOVE_COMPLETED') {
StorageManager.completedVideosCache.delete(videoId);
await api.storage.update(CONSTANTS.STORAGE_PREFIX + CONSTANTS.COMPLETED_KEY, list => Array.isArray(list) ? list.filter(id => id !== videoId) : [], [], { strategy: 'optimistic' });
}
}
CrossTabSyncManager.broadcastUpdate(action, videoId, playlistId);
if (playlistId) StorageManager.sessionPlaylistCache.delete(playlistId);
} catch (error) {
console.error(`Failed to write video state: ${action}`, error);
}
},
syncNativeProgress(videoId, rawPlaylistId, timestamp, duration) {
const playlistId = StorageManager.resolvePlaylistId(rawPlaylistId);
const syncKey = `${playlistId || 'standalone'}_${videoId}`;
if (StorageManager.sessionSyncedVideos.has(syncKey)) return;
StorageManager.sessionSyncedVideos.add(syncKey);
setTimeout(async () => {
if (duration > 0 && (timestamp / duration) >= 0.99) {
await StorageManager.writeVideoState(videoId, playlistId, 'MARK_COMPLETED');
} else {
await StorageManager.writeVideoState(videoId, playlistId, 'SAVE_PROGRESS', { timestamp, duration, videoType: 'regular' });
}
CrossTabSyncManager.dirtyVideoIds.delete(videoId);
}, 1000);
},
isExpired(statusObject) {
if (!statusObject?.lastUpdated || isNaN(statusObject.lastUpdated)) return true;
let daysToExpire = CONSTANTS.DAYS_TO_REMEMBER;
if (statusObject.videoType === 'short') daysToExpire = CONSTANTS.DAYS_TO_REMEMBER_SHORTS;
else if (statusObject.videoType === 'preview') daysToExpire = CONSTANTS.DAYS_TO_REMEMBER_PREVIEWS;
return Date.now() - statusObject.lastUpdated > daysToExpire * 86400 * 1000;
},
async cleanUpExpiredStatuses() {
try {
const now = Date.now();
const lastCleanup = await StorageManager.getValue(CONSTANTS.LAST_CLEANUP_KEY) || 0;
if (now - lastCleanup < CONSTANTS.CLEANUP_INTERVAL_MS) return;
await StorageManager.setValue(CONSTANTS.LAST_CLEANUP_KEY, now);
const keys = await api.storage.list();
const targetKeys = keys.filter(k => k.startsWith(CONSTANTS.STORAGE_PREFIX) && k !== (CONSTANTS.STORAGE_PREFIX + CONSTANTS.COMPLETED_KEY)).map(k => k.substring(CONSTANTS.STORAGE_PREFIX.length));
await Promise.all(targetKeys.map(async (key) => {
if (key === CONSTANTS.LAST_CLEANUP_KEY) return;
const storedData = await StorageManager.getValue(key);
if (!storedData) return;
if (storedData.videos !== undefined || storedData.completedVideos !== undefined) {
let hasChanged = false;
if (storedData.videos) {
for (const [videoId, data] of Object.entries(storedData.videos)) {
if (StorageManager.isExpired(data)) {
delete storedData.videos[videoId];
hasChanged = true;
}
}
}
const noActive = Object.keys(storedData.videos || {}).length === 0;
const noCompleted = (storedData.completedVideos || []).length === 0;
if (noActive && noCompleted) {
await StorageManager.deleteValue(key);
} else if (hasChanged) {
await StorageManager.setValue(key, storedData);
}
} else {
if (StorageManager.isExpired(storedData)) {
await StorageManager.deleteValue(key);
}
}
}));
} catch (error) {
console.error(`Cleanup failed:`, error);
}
},
};
const CrossTabSyncManager = {
dirtyVideoIds: new Set(),
init() {
api.broadcast.subscribe(CONSTANTS.STORAGE_PREFIX);
api.eventTarget.addEventListener(api.broadcast.EVENT_PREFIX + CONSTANTS.STORAGE_PREFIX, (e) => {
const envelope = e.detail;
if (!envelope || !envelope.data) return;
if (envelope.sourceInstanceId === api.instance.id) return;
const payload = envelope.data;
if (payload.playlistId) {
StorageManager.sessionPlaylistCache.delete(payload.playlistId);
}
if (payload.action === 'MARK_COMPLETED' && !payload.playlistId) StorageManager.completedVideosCache.add(payload.videoId);
else if ((payload.action === 'REMOVE_COMPLETED' || payload.action === 'SAVE_PROGRESS') && !payload.playlistId) StorageManager.completedVideosCache.delete(payload.videoId);
CrossTabSyncManager.dirtyVideoIds.add(payload.videoId);
});
setInterval(() => CrossTabSyncManager.refreshDirtyThumbnails(), 1000);
},
broadcastUpdate(action, videoId, playlistId) {
api.broadcast.notify(CONSTANTS.STORAGE_PREFIX, { action, videoId, playlistId });
CrossTabSyncManager.dirtyVideoIds.add(videoId);
},
refreshDirtyThumbnails() {
if (CrossTabSyncManager.dirtyVideoIds.size === 0) return;
const parentsToUpdate = [];
ThumbnailEnhancer.visibleParents.forEach(parent => {
const watchLink = parent.querySelector('a[href*="?v="], a[href*="&v="]');
if (!watchLink) return;
try {
const url = new URL(watchLink.href, window.location.origin);
const videoId = watchLink.data?.watchEndpoint?.videoId || url.searchParams.get('v');
if (CrossTabSyncManager.dirtyVideoIds.has(videoId)) {
parent.removeAttribute(ThumbnailEnhancer.processedAttribute);
parentsToUpdate.push(parent);
}
} catch(e) {}
});
if (parentsToUpdate.length > 0) {
ThumbnailEnhancer._overrideNativeResume(parentsToUpdate);
}
CrossTabSyncManager.dirtyVideoIds.clear();
}
};
const NavigationInterceptor = {
removeTimestampFromNodeHrefs(linkedElement) {
linkedElement.forEach((element) => {
try {
const url = new URL(element.href);
if (!url.searchParams.has('t')) return;
url.searchParams.delete('t');
element.href = url.toString();
element.setAttribute('data-timestamp-removed', 'true');
if (element.data?.watchEndpoint?.startTimeSeconds) delete element.data.watchEndpoint.startTimeSeconds;
} catch (error) {
console.error(`Could not parse and modify URL: ${element.href}`, error);
}
});
},
interceptLinksWithUntimedVersion() {
document.documentElement.addEventListener('click', (event) => {
const anchor = event.target.closest('a');
if (!anchor || !anchor.href || !anchor.hasAttribute('data-timestamp-removed')) return;
const isNewTabClick = event.button !== 0 || event.ctrlKey || event.metaKey;
if (isNewTabClick) return;
event.preventDefault();
event.stopImmediatePropagation();
history.pushState(null, '', anchor.href);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
}, true);
}
};
const ThumbnailEnhancer = {
visibleParents: new Set(),
observer: null,
processedAttribute: 'resume-overridden',
nativeProgressSelector: [
'ytm-thumbnail-overlay-resume-playback-renderer',
'ytd-thumbnail-overlay-resume-playback-renderer',
'yt-thumbnail-bottom-overlay-view-model'
].join(','),
globalHotZoneSelectors: [
'#container.style-scope.ytd-player',
'video.video-stream.html5-main-video',
'#inline-preview-player',
'ytd-video-preview',
],
isEnteringHotZone(targetElement, contentParent) {
if (!targetElement) return { isHot: false, element: null };
if (targetElement === contentParent || contentParent.contains(targetElement)) {
return { isHot: true, element: contentParent };
}
for (const selector of ThumbnailEnhancer.globalHotZoneSelectors) {
const matchedElement = targetElement.closest(selector);
if (matchedElement) {
return { isHot: true, element: matchedElement };
}
}
return { isHot: false, element: null };
},
_parseTimeToSeconds(timeString) {
if (!timeString) return 0;
const cleaned = timeString.replace(/[^0-9:]/g, '');
if (!cleaned) return 0;
const parsed = cleaned.split(':').reverse().reduce((total, part, index) => total + Number(part) * 60 ** index, 0);
return isNaN(parsed) ? 0 : parsed;
},
async _overrideNativeResume(contentParents) {
const dataReadPromises = contentParents.map(async (contentParent) => {
try {
if (contentParent.hasAttribute(ThumbnailEnhancer.processedAttribute)) return null;
contentParent.setAttribute(ThumbnailEnhancer.processedAttribute, 'processing');
const watchLinks = Array.from(contentParent.querySelectorAll('a[href*="?v="], a[href*="&v="]'));
if (contentParent.matches?.('a[href*="?v="], a[href*="&v="]')) watchLinks.push(contentParent);
const timedLinks = Array.from(contentParent.querySelectorAll('a[href*="?t="], a[href*="&t="]'));
if (contentParent.matches?.('a[href*="?t="], a[href*="&t="]')) timedLinks.push(contentParent);
if (!watchLinks[0]) {
contentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
return null;
}
const videoData = watchLinks[0]?.data?.watchEndpoint;
let videoId;
try {
videoId = videoData?.videoId || new URL(watchLinks[0].href, window.location.origin).searchParams.get('v');
} catch (e) { videoId = null; }
if (!videoId) {
contentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
return null;
}
let playlistId;
try {
playlistId = new URL(watchLinks[0].href, window.location.origin).searchParams.get('list');
} catch (e) { playlistId = null; }
const { isCompleted, savedData } = await StorageManager.getPlaybackData(videoId, playlistId);
return { contentParent, watchLinks, timedLinks, videoId, playlistId, isCompleted, savedData };
} catch (error) {
console.error('Failed to read data for element:', contentParent, error);
contentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
return null;
}
});
const processingData = (await Promise.all(dataReadPromises)).filter(Boolean);
for (const data of processingData) {
const { contentParent, watchLinks, timedLinks, videoId, playlistId, isCompleted, savedData } = data;
try {
contentParent.setAttribute(ThumbnailEnhancer.processedAttribute, 'true');
const contentParentRef = new WeakRef(contentParent);
const hotZoneMouseLeaveListener = (event) => {
const currentContentParent = contentParentRef.deref();
if (!currentContentParent) return;
const newTargetCheck = ThumbnailEnhancer.isEnteringHotZone(event.relatedTarget, currentContentParent);
if (newTargetCheck.isHot) {
newTargetCheck.element.addEventListener('mouseleave', hotZoneMouseLeaveListener, { once: true });
} else {
currentContentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
ThumbnailEnhancer._overrideNativeResume([currentContentParent]);
}
};
contentParent.addEventListener('mouseleave', hotZoneMouseLeaveListener, { once: true });
let urlTimestamp = null;
if (timedLinks[0]) {
try {
const tUrl = new URL(timedLinks[0].href, window.location.origin);
urlTimestamp = parseInt(tUrl.searchParams.get('t'), 10);
} catch(e) {}
}
const videoData = watchLinks[0]?.data?.watchEndpoint;
let startTime = savedData?.timestamp ?? videoData?.startTimeSeconds ?? urlTimestamp;
let videoLength = savedData?.duration;
if (!videoLength || videoLength < (savedData?.timestamp || 0)) {
videoLength = 0;
const timeTextEls = contentParent.querySelectorAll('ytm-thumbnail-overlay-time-status-renderer, ytd-thumbnail-overlay-time-status-renderer > #text, .badge-shape-wiz__text, .ytBadgeShapeText');
for (const el of timeTextEls) {
const parsed = ThumbnailEnhancer._parseTimeToSeconds(el.innerText);
if (parsed > 0) {
videoLength = parsed;
break;
}
}
}
let roundedPercentage = 0;
let overrideVisibility = false;
if (isCompleted) {
roundedPercentage = 100;
overrideVisibility = true;
} else if (savedData?.timestamp !== undefined && savedData?.duration) {
const completePercentage = (savedData.timestamp / savedData.duration) * 100;
if (completePercentage >= 99) {
StorageManager.writeVideoState(videoId, playlistId, 'MARK_COMPLETED');
}
roundedPercentage = Math.min(100, Math.max(1, completePercentage));
overrideVisibility = true;
} else {
const existingBar = contentParent.querySelector(ThumbnailEnhancer.nativeProgressSelector);
let nativeWidthPercent = 0;
if (existingBar) {
const fill = existingBar.querySelector('.thumbnail-overlay-resume-playback-progress, #progress, .ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment') || existingBar;
if (fill?.style?.width) nativeWidthPercent = parseFloat(fill.style.width);
}
let calculatedPercent = 0;
if (startTime && videoLength) calculatedPercent = (startTime / videoLength) * 100;
const finalNativePercent = calculatedPercent > 0 ? calculatedPercent : nativeWidthPercent;
if (finalNativePercent > 0 && !isNaN(finalNativePercent)) {
if (finalNativePercent >= 99) {
StorageManager.writeVideoState(videoId, playlistId, 'MARK_COMPLETED');
} else if (!startTime && videoLength > 0) {
startTime = (finalNativePercent / 100) * videoLength;
}
roundedPercentage = Math.min(100, Math.max(1, finalNativePercent));
overrideVisibility = true;
if (startTime && videoLength && savedData?.timestamp === undefined && finalNativePercent < 99) {
StorageManager.syncNativeProgress(videoId, playlistId, startTime, videoLength);
}
}
}
if (overrideVisibility) {
const barFills = ThumbnailEnhancer._buildProgressBar(contentParent, roundedPercentage);
if (barFills && barFills.length > 0) {
barFills.forEach(barFill => {
const hostElement = barFill.closest('ytm-thumbnail-overlay-resume-playback-renderer, ytd-thumbnail-overlay-resume-playback-renderer, yt-thumbnail-bottom-overlay-view-model') || barFill.parentElement;
barFill.style.transition = 'width 1s linear';
if (roundedPercentage > 0) {
barFill.style.width = `${roundedPercentage}%`;
if (hostElement) {
hostElement.style.transition = 'opacity 0.5s ease-out';
hostElement.style.opacity = '1';
hostElement.style.display = '';
hostElement.removeAttribute('hidden');
}
} else {
barFill.style.width = '0%';
if (hostElement) {
hostElement.style.transition = 'opacity 0.5s ease-out';
hostElement.style.opacity = '0';
hostElement.style.display = 'none';
}
}
});
}
}
NavigationInterceptor.removeTimestampFromNodeHrefs(timedLinks);
} catch (error) {
console.error('Failed to process resume preview for element:', contentParent, error);
contentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
}
}
},
_getRendererParent(node) {
const selectors = ['ytd-playlist-panel-video-renderer', 'ytd-rich-item-renderer', 'ytd-compact-video-renderer', 'ytd-video-renderer', 'ytd-grid-video-renderer', 'ytm-compact-video-renderer', 'ytm-item-section-renderer'].join(', ');
return node.closest(selectors) || (api.page.isMobile ? node.parentElement : node.parentElement?.parentElement);
},
_observeWatchLinksIn(root) {
const links = Array.from(root.querySelectorAll('a[href*="?v="], a[href*="&v="]'));
for (const link of links) {
if (!link.querySelector('yt-image, yt-thumbnail-view-model, .video-thumbnail-img')) continue;
const parent = ThumbnailEnhancer._getRendererParent(link);
if (parent && !parent.hasAttribute(ThumbnailEnhancer.processedAttribute) && !parent.classList.contains('ytd-playlist-sidebar-renderer')) {
parent.setAttribute('data-ytar-observed', 'true');
ThumbnailEnhancer.observer.observe(parent);
}
}
},
_buildProgressBar(contentParent, roundedPercentage) {
if (api.page.isMobile) {
const fills = Array.from(contentParent.querySelectorAll('.thumbnail-overlay-resume-playback-progress'));
const container = contentParent.querySelector('.videoThumbnailGroupOverlayBottomLeftRightGroup');
if (fills.length === 0 && container && roundedPercentage > 0) {
const renderer = document.createElement('ytm-thumbnail-overlay-resume-playback-renderer');
renderer.classList.add('videoThumbnailGroupResumePlayback');
renderer.setAttribute('data-yt-auto-resume-injected', 'true');
const fill = document.createElement('div');
fill.classList.add('thumbnail-overlay-resume-playback-progress');
renderer.appendChild(fill);
container.appendChild(renderer);
fills.push(fill);
}
return fills;
}
if (contentParent.querySelector('yt-thumbnail-view-model')) {
const fills = Array.from(contentParent.querySelectorAll('.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment'));
const viewModel = contentParent.querySelector('yt-thumbnail-view-model');
if (fills.length === 0 && viewModel && roundedPercentage > 0) {
const overlay = document.createElement('yt-thumbnail-bottom-overlay-view-model');
overlay.classList.add('ytThumbnailBottomOverlayViewModelHost');
overlay.setAttribute('data-yt-auto-resume-injected', 'true');
const host = document.createElement('yt-thumbnail-overlay-progress-bar-view-model');
host.classList.add('ytThumbnailOverlayProgressBarHost', 'ytThumbnailOverlayProgressBarHostLarge');
const barContainer = document.createElement('div');
barContainer.classList.add('ytThumbnailOverlayProgressBarHostWatchedProgressBar', 'ytThumbnailOverlayProgressBarHostUseLegacyBar');
const fill = document.createElement('div');
fill.classList.add('ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment');
barContainer.appendChild(fill);
host.appendChild(barContainer);
overlay.appendChild(host);
viewModel.appendChild(overlay);
fills.push(fill);
}
return fills;
} else {
const fills = Array.from(contentParent.querySelectorAll('#progress.ytd-thumbnail-overlay-resume-playback-renderer'));
const overlays = contentParent.querySelector('#overlays.ytd-thumbnail');
if (fills.length === 0 && overlays && roundedPercentage > 0) {
const renderer = document.createElement('ytd-thumbnail-overlay-resume-playback-renderer');
renderer.classList.add('style-scope', 'ytd-thumbnail');
renderer.setAttribute('data-yt-auto-resume-injected', 'true');
const fill = document.createElement('div');
fill.id = 'progress';
fill.classList.add('style-scope', 'ytd-thumbnail-overlay-resume-playback-renderer');
renderer.appendChild(fill);
overlays.appendChild(renderer);
fills.push(fill);
}
return fills;
}
},
start() {
ThumbnailEnhancer.observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const contentParent = entry.target;
ThumbnailEnhancer.visibleParents.add(contentParent);
ThumbnailEnhancer._overrideNativeResume([contentParent]);
} else {
ThumbnailEnhancer.visibleParents.delete(entry.target);
}
}
}, { rootMargin: '100px' });
const watchLinkScanner = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
const timedLinks = Array.from(node.querySelectorAll('a[href*="?t="], a[href*="&t="]'));
if (node.matches('a[href*="?t="], a[href*="&t="]')) timedLinks.push(node);
timedLinks.forEach(link => {
try {
const url = new URL(link.href, window.location.origin);
if (url.searchParams.has('t')) {
const v = url.searchParams.get('v');
const list = url.searchParams.get('list');
const t = parseInt(url.searchParams.get('t'), 10);
if (v && t && !isNaN(t)) {
StorageManager.getPlaybackData(v, list, false).then(({ isCompleted, savedData }) => {
if (!isCompleted && !savedData) {
StorageManager.writeVideoState(v, list, 'SAVE_PROGRESS', { timestamp: t, duration: 0, videoType: 'regular' });
}
});
}
}
} catch (e) {}
});
const watchLinks = Array.from(node.querySelectorAll('a[href*="?v="], a[href*="&v="]'));
if (node.matches('a[href*="?v="], a[href*="&v="]')) watchLinks.push(node);
watchLinks.forEach(link => {
const isVideoThumbnail = link.querySelector('yt-image, yt-thumbnail-view-model, .video-thumbnail-img');
if (!isVideoThumbnail) return;
const rendererContentParent = ThumbnailEnhancer._getRendererParent(link);
if (rendererContentParent && !rendererContentParent.hasAttribute(ThumbnailEnhancer.processedAttribute) && !rendererContentParent.classList.contains('ytd-playlist-sidebar-renderer')) {
rendererContentParent.setAttribute('data-ytar-observed', 'true');
ThumbnailEnhancer.observer.observe(rendererContentParent);
}
});
const progressBars = node.matches(ThumbnailEnhancer.nativeProgressSelector) ? [node] : Array.from(node.querySelectorAll(ThumbnailEnhancer.nativeProgressSelector));
progressBars.forEach(progressBar => {
if (progressBar.hasAttribute('data-yt-auto-resume-injected') || progressBar.closest('[data-yt-auto-resume-injected]')) return;
const parent = ThumbnailEnhancer._getRendererParent(progressBar);
if (parent && parent.hasAttribute(ThumbnailEnhancer.processedAttribute)) {
parent.removeAttribute(ThumbnailEnhancer.processedAttribute);
ThumbnailEnhancer._overrideNativeResume([parent]);
}
});
}
}
});
const targetSelector = api.page.isMobile ? 'ytm-app' : 'ytd-page-manager';
document.addEventListener('yt-navigate-finish', () => {
const targetNode = document.querySelector(targetSelector);
if (targetNode) {
ThumbnailEnhancer._observeWatchLinksIn(targetNode);
CrossTabSyncManager.refreshDirtyThumbnails();
}
});
const bootstrapper = new MutationObserver((mutations, me) => {
const targetNode = document.querySelector(targetSelector);
if (targetNode) {
me.disconnect();
watchLinkScanner.observe(targetNode, { childList: true, subtree: true });
ThumbnailEnhancer._observeWatchLinksIn(targetNode);
}
});
const existingNode = document.querySelector(targetSelector);
if (existingNode) {
watchLinkScanner.observe(existingNode, { childList: true, subtree: true });
ThumbnailEnhancer._observeWatchLinksIn(existingNode);
} else {
bootstrapper.observe(document.body, { childList: true, subtree: true });
}
}
};
const PlaybackController = {
abortController: null,
trackingInterval: null,
lastPlaylistId: null,
async getPlaylistWhenReady(playerApi, signal) {
return new Promise((resolve, reject) => {
const initialPlaylist = playerApi.getPlaylist();
if (initialPlaylist?.length > 0) return resolve(initialPlaylist);
let pollerInterval;
const cleanup = () => clearInterval(pollerInterval);
let attempts = 0;
pollerInterval = setInterval(() => {
if (signal?.aborted) {
cleanup();
return reject(new DOMException('Aborted', 'AbortError'));
}
const playlist = playerApi.getPlaylist();
if (playlist?.length > 0) {
cleanup();
resolve(playlist);
} else if (++attempts >= 50) {
cleanup();
reject(new Error('Playlist not found after 5s.'));
}
}, 100);
});
},
applySeek(playerApi, timeToSeek) {
if (!playerApi || isNaN(timeToSeek) || timeToSeek < CONSTANTS.MIN_PROCESS_THRESHOLD) return;
const deltaT = Math.abs(timeToSeek - api.video.realCurrentProgress);
if (isNaN(deltaT) || deltaT < CONSTANTS.MIN_PROCESS_THRESHOLD) return;
const videoElement = api.player.videoElement;
if (!videoElement) return;
const releaseLock = () => {
if (videoElement._ytAutoResumeSeekPending) videoElement._ytAutoResumeSeekPending = false;
};
if (videoElement._ytAutoResumeRetryPending) return;
if (videoElement.seeking && !videoElement._ytAutoResumeSeekPending) {
videoElement._ytAutoResumeRetryPending = true;
const targetVideoId = api.video.id;
const runRetry = () => {
videoElement._ytAutoResumeRetryPending = false;
if (api.video.id === targetVideoId) {
setTimeout(() => PlaybackController.applySeek(playerApi, timeToSeek), 0);
}
};
videoElement.addEventListener('seeked', runRetry, { once: true });
return;
}
videoElement.addEventListener('seeked', releaseLock, { once: true });
videoElement._ytAutoResumeSeekPending = true;
setTimeout(releaseLock, 5000);
playerApi.seekTo(timeToSeek, true);
},
async resumePlayback(navigatedFromPlaylistId = null) {
if (PlaybackController.abortController?.signal.aborted) return;
try {
const playerApi = api.apiProxy;
const videoId = api.video.id;
const rawPlaylistId = api.video.playlistId;
const playlistId = StorageManager.resolvePlaylistId(rawPlaylistId);
const inPlaylist = !!playlistId;
const { isCompleted, savedData } = await StorageManager.getPlaybackData(videoId, rawPlaylistId, true);
if (PlaybackController.abortController?.signal.aborted) return;
if (isCompleted) return;
if (playerApi.getPlayerSize().width === 0) return;
let lastPlaybackTime;
let videoToResumeId = videoId;
if (inPlaylist) {
const playlistData = await StorageManager.getValue(playlistId);
if (!playlistData?.videos) return;
const lastWatched = playlistData.lastWatchedVideoId;
if (playlistId !== navigatedFromPlaylistId && lastWatched && videoId !== lastWatched) {
videoToResumeId = lastWatched;
}
lastPlaybackTime = playlistData.videos?.[videoToResumeId]?.timestamp;
} else {
lastPlaybackTime = savedData?.timestamp;
}
if (lastPlaybackTime) {
if (inPlaylist && videoId !== videoToResumeId) {
const playlist = await PlaybackController.getPlaylistWhenReady(playerApi, PlaybackController.abortController?.signal);
if (PlaybackController.abortController?.signal.aborted) return;
const index = playlist.indexOf(videoToResumeId);
if (index !== -1) playerApi.playVideoAt(index);
} else {
setTimeout(() => PlaybackController.applySeek(playerApi, lastPlaybackTime), 0);
}
}
} catch (error) {
console.error(`Failed to resume playback: `, error);
}
},
startTracking() {
if (PlaybackController.trackingInterval) clearInterval(PlaybackController.trackingInterval);
PlaybackController.trackingInterval = setInterval(async () => {
if (PlaybackController.abortController?.signal.aborted) {
PlaybackController.stopTracking();
return;
}
const videoElement = api.player.videoElement;
if (!videoElement || videoElement.paused || videoElement.seeking) return;
const lockStatus = await api.concurrency.acquireLock(StorageManager.activeStorageKey, 2500);
if (!lockStatus || !lockStatus.acquired) return;
const videoId = api.video.id;
const duration = api.video.lengthSeconds;
const currentTime = api.video.realCurrentProgress;
if (!videoId || isNaN(duration) || isNaN(currentTime) || currentTime < CONSTANTS.MIN_PROCESS_THRESHOLD) return;
const finishThreshold = Math.min(1 + duration * 0.01, CONSTANTS.STATIC_FINISH_SECONDS);
const isFinished = duration - currentTime < finishThreshold;
const videoType = ((pageType) => {
switch (pageType) {
case 'shorts': return 'short';
case 'watch': return 'regular';
default: return 'preview';
}
})(api.page.type);
if (isFinished) {
await StorageManager.writeVideoState(videoId, api.video.playlistId, 'MARK_COMPLETED');
} else {
await StorageManager.writeVideoState(videoId, api.video.playlistId, 'SAVE_PROGRESS', { timestamp: currentTime, duration, videoType });
}
}, 1000);
},
stopTracking() {
if (PlaybackController.trackingInterval) {
clearInterval(PlaybackController.trackingInterval);
PlaybackController.trackingInterval = null;
}
if (StorageManager.activeStorageKey) {
api.concurrency.releaseLock(StorageManager.activeStorageKey).catch(console.error);
}
},
processVideo() {
PlaybackController.teardown();
PlaybackController.abortController = new AbortController();
const signal = PlaybackController.abortController.signal;
const videoId = api.video.id;
if (!videoId) return;
StorageManager.activeStorageKey = StorageManager.resolvePlaylistId(api.video.playlistId) || videoId;
api.concurrency.acquireLock(StorageManager.activeStorageKey, 2500).catch(console.error);
if (api.video.isCurrentlyLive || api.video.isTimeSpecified) {
PlaybackController.lastPlaylistId = api.video.playlistId;
return;
}
const videoElement = api.player.videoElement;
if (!videoElement) return;
let hasAttemptedResume = false;
const resumeHook = () => {
if (signal.aborted || api.player.videoElement?.seeking || api.player.videoElement?._ytAutoResumeSeekPending) return;
if (!hasAttemptedResume) {
hasAttemptedResume = true;
const isPreview = api.page.type !== 'watch' && api.page.type !== 'shorts';
if (isPreview) {
api.player.videoElement.addEventListener('timeupdate', () => {
if (!signal.aborted) PlaybackController.resumePlayback(PlaybackController.lastPlaylistId);
}, { once: true, signal });
} else {
PlaybackController.resumePlayback(PlaybackController.lastPlaylistId);
}
}
};
videoElement.addEventListener('timeupdate', resumeHook, { signal });
PlaybackController.lastPlaylistId = api.video.playlistId;
api.eventTarget.addEventListener(api.EVENTS.VIDEO_PLAY, PlaybackController.startTracking, { signal });
api.eventTarget.addEventListener(api.EVENTS.VIDEO_PAUSE, PlaybackController.stopTracking, { signal });
api.eventTarget.addEventListener(api.EVENTS.VIDEO_ENDED, PlaybackController.stopTracking, { signal });
videoElement.addEventListener('timeupdate', () => {
if (!PlaybackController.trackingInterval && !api.player.videoElement?.paused && !api.player.videoElement?.seeking) {
PlaybackController.startTracking();
}
}, { signal });
if (!videoElement.paused) PlaybackController.startTracking();
},
teardown() {
if (PlaybackController.abortController) PlaybackController.abortController.abort();
PlaybackController.stopTracking();
if (StorageManager.activeStorageKey) {
api.concurrency.releaseLock(StorageManager.activeStorageKey).catch(console.error);
}
}
};
const AutoResumeApp = {
async start() {
try {
await StorageManager.init();
StorageManager.cleanUpExpiredStatuses();
setInterval(() => StorageManager.cleanUpExpiredStatuses(), CONSTANTS.CLEANUP_INTERVAL_MS);
NavigationInterceptor.interceptLinksWithUntimedVersion();
window.addEventListener('pagehide', () => PlaybackController.teardown());
document.addEventListener('yt-autonav-pause-player-ended', async () => {
PlaybackController.stopTracking();
if (api.video.id) await StorageManager.writeVideoState(api.video.id, api.video.playlistId, 'MARK_COMPLETED');
});
ThumbnailEnhancer.start();
api.eventTarget.addEventListener(api.EVENTS.API_READY, () => PlaybackController.processVideo());
} catch (error) {
console.error(`Initialization failed: `, error);
}
},
};
AutoResumeApp.start();
})();