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.6.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*
// @require https://update.greasyfork.org/scripts/549881/1772265/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,
STORAGE_PREFIX: 'YT_AUTO_RESUME_',
FOCUS_LOCK_KEY: 'focusLock',
LAST_CLEANUP_KEY: 'lastCleanupTimestamp',
TAB_ID: crypto.randomUUID(),
};
const ResumeStorageManager = {
activeStorageKey: null,
async getValue(key) {
try { return await api.loadFromStorage(CONSTANTS.STORAGE_PREFIX + key); }
catch (e) { console.error(`Failed to parse storage key "${key}"`, e); return null; }
},
async setValue(key, value) {
try { await api.saveToStorage(CONSTANTS.STORAGE_PREFIX + key, value); }
catch (e) { console.error(`Failed to set storage key "${key}"`, e); }
},
async deleteValue(key) {
await api.deleteFromStorage(CONSTANTS.STORAGE_PREFIX + key);
},
async listValues() {
const list = await api.listFromStorage();
return list.filter(k => k.startsWith(CONSTANTS.STORAGE_PREFIX)).map(k => k.substring(CONSTANTS.STORAGE_PREFIX.length));
},
async claimFocus() {
if (ResumeStorageManager.activeStorageKey) {
await ResumeStorageManager.setValue(CONSTANTS.FOCUS_LOCK_KEY, {
tabId: CONSTANTS.TAB_ID,
key: ResumeStorageManager.activeStorageKey,
lastFocused: Date.now(),
});
}
},
async hasWritePermission() {
if (!ResumeStorageManager.activeStorageKey) return false;
const focusLock = await ResumeStorageManager.getValue(CONSTANTS.FOCUS_LOCK_KEY);
if (!focusLock) return true;
return focusLock.key === ResumeStorageManager.activeStorageKey && focusLock.tabId === CONSTANTS.TAB_ID;
},
async handleCleanupCycle() {
const lastCleanupTime = (await ResumeStorageManager.getValue(CONSTANTS.LAST_CLEANUP_KEY)) || 0;
const now = Date.now();
if (now - lastCleanupTime < CONSTANTS.CLEANUP_INTERVAL_MS) return;
await ResumeStorageManager.setValue(CONSTANTS.LAST_CLEANUP_KEY, now);
await ResumeStorageManager.cleanUpExpiredStatuses();
},
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 keys = await ResumeStorageManager.listValues();
const operations = keys.map(async (key) => {
if (key === CONSTANTS.LAST_CLEANUP_KEY || key === CONSTANTS.FOCUS_LOCK_KEY) return;
const storedData = await ResumeStorageManager.getValue(key);
if (!storedData) return;
if (storedData.videos) {
let hasChanged = false;
for (const videoId in storedData.videos) {
if (ResumeStorageManager.isExpired(storedData.videos[videoId])) {
delete storedData.videos[videoId];
hasChanged = true;
}
}
if (Object.keys(storedData.videos).length === 0) await ResumeStorageManager.deleteValue(key);
else if (hasChanged) await ResumeStorageManager.setValue(key, storedData);
} else {
if (ResumeStorageManager.isExpired(storedData)) await ResumeStorageManager.deleteValue(key);
}
});
await Promise.all(operations);
} catch (error) {
console.error(`Failed to clean up stored playback statuses: ${error}`);
}
}
};
const PlaybackController = {
abortController: null,
lastPlaylistId: null,
async getPlaylistWhenReady(playerApi) {
return new Promise((resolve, reject) => {
const initialPlaylist = playerApi.getPlaylist();
if (initialPlaylist?.length > 0) return resolve(initialPlaylist);
let hasResolved = false;
let 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);
};
document.addEventListener('yt-playlist-data-updated', startPolling, { once: true });
setTimeout(() => { if (!hasResolved) startPolling(); }, 1000);
});
},
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) console.log('Seek refused. Retry task already queued.');
if (videoElement.seeking && !videoElement._ytAutoResumeSeekPending) {
videoElement._ytAutoResumeRetryPending = true;
const targetVideoId = api.video.id;
const runRetry = () => {
videoElement._ytAutoResumeRetryPending = false;
if (api.video.id === targetVideoId) {
console.log('Browser finished seeking. Retrying auto-resume...');
setTimeout(() => PlaybackController.applySeek(playerApi, timeToSeek), 0);
}
};
videoElement.addEventListener('seeked', runRetry, { once: true });
return console.log('Browser busy running other seek tasks. Retry queued.');
}
videoElement.addEventListener('seeked', releaseLock, { once: true });
videoElement._ytAutoResumeSeekPending = true;
playerApi.seekTo(timeToSeek, true);
const formattedTimeString = new Date(timeToSeek * 1000).toISOString().slice(11, 19);
console.log(`%cSeeking video to ${timeToSeek.toFixed(2)}s (${formattedTimeString})`, 'font-weight: bold;');
},
async resumePlayback(navigatedFromPlaylistId = null) {
if (PlaybackController.abortController?.signal.aborted) return;
try {
const playerApi = api.apiProxy;
const inPlaylist = !!api.video.playlistId;
const playlistId = api.video.playlistId;
const videoId = api.video.id;
const playerSize = playerApi.getPlayerSize();
if (playerSize.width === 0 || playerSize.height === 0) return;
const keyToFetch = inPlaylist ? playlistId : videoId;
const playbackStatus = await ResumeStorageManager.getValue(keyToFetch);
if (PlaybackController.abortController?.signal.aborted || !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 PlaybackController.getPlaylistWhenReady(playerApi);
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);
}
},
async updatePlaybackStatus(videoType, playlistId = '') {
try {
if (!(await ResumeStorageManager.hasWritePermission())) return;
const liveVideoId = api.video.id;
if (!liveVideoId) return;
const videoDuration = api.video.lengthSeconds;
const currentPlaybackTime = api.video.realCurrentProgress;
if (isNaN(videoDuration) || isNaN(currentPlaybackTime) || currentPlaybackTime < CONSTANTS.MIN_PROCESS_THRESHOLD) return;
const finishThreshold = Math.min(1 + videoDuration * 0.01, CONSTANTS.STATIC_FINISH_SECONDS);
const isFinished = videoDuration - currentPlaybackTime < finishThreshold;
if (playlistId) {
const playlistData = (await ResumeStorageManager.getValue(playlistId)) || { lastWatchedVideoId: '', videos: {} };
if (isFinished) {
if (playlistData.videos?.[liveVideoId]) {
delete playlistData.videos[liveVideoId];
await ResumeStorageManager.setValue(playlistId, playlistData);
}
} else {
playlistData.videos = playlistData.videos || {};
playlistData.videos[liveVideoId] = {
timestamp: currentPlaybackTime,
duration: videoDuration,
lastUpdated: Date.now(),
videoType: 'playlist',
};
playlistData.lastWatchedVideoId = liveVideoId;
await ResumeStorageManager.setValue(playlistId, playlistData);
}
} else {
if (isFinished) {
await ResumeStorageManager.deleteValue(liveVideoId);
} else {
await ResumeStorageManager.setValue(liveVideoId, {
timestamp: currentPlaybackTime,
duration: videoDuration,
lastUpdated: Date.now(),
videoType: videoType,
});
}
}
} catch (error) {
console.error(`Failed to update playback status: `, error);
}
},
processVideo() {
if (PlaybackController.abortController) PlaybackController.abortController.abort();
PlaybackController.abortController = new AbortController();
const signal = PlaybackController.abortController.signal;
const videoElement = api.player.videoElement;
const videoId = api.video.id;
if (!videoId) return;
const playlistId = api.video.playlistId === 'WL' ? null : api.video.playlistId;
ResumeStorageManager.activeStorageKey = playlistId || videoId;
ResumeStorageManager.claimFocus();
const isLive = api.video.isCurrentlyLive;
const timeSpecified = api.video.isTimeSpecified;
if (isLive || timeSpecified) {
PlaybackController.lastPlaylistId = api.video.playlistId;
return;
}
let hasAttemptedResume = false;
let isResuming = false;
const timeupdateHandler = () => {
if (signal.aborted || videoElement.seeking || videoElement?._ytAutoResumeSeekPending) return;
const videoType = ((pageType) => {
switch (pageType) {
case 'shorts': return 'short';
case 'watch': return 'regular';
default: return 'preview';
}
})(api.page.type);
if (!hasAttemptedResume) {
hasAttemptedResume = true;
isResuming = true;
if (videoType === 'preview') {
videoElement.addEventListener('timeupdate', () => {
if (!signal.aborted) PlaybackController.resumePlayback(PlaybackController.lastPlaylistId).finally(() => isResuming = false);
}, { once: true, signal });
} else {
PlaybackController.resumePlayback(PlaybackController.lastPlaylistId).finally(() => isResuming = false);
}
} else {
if (isResuming) return;
PlaybackController.updatePlaybackStatus(videoType, playlistId);
}
};
videoElement.addEventListener('timeupdate', timeupdateHandler, { signal });
// Re-claim focus on interactions and visibility changes
const focusHandler = () => { if (!signal.aborted) ResumeStorageManager.claimFocus(); };
window.addEventListener('focus', focusHandler, { signal });
PlaybackController.lastPlaylistId = playlistId;
},
teardown() {
if (PlaybackController.abortController) PlaybackController.abortController.abort();
}
};
const NavigationInterceptor = {
removeTimestampFromNodeHrefs(linkedElement) {
linkedElement.forEach((element) => {
try {
const url = new URL(element.href);
if (url.searchParams.has('t')) {
url.searchParams.delete('t');
element.href = url.toString();
element.setAttribute('data-timestamp-removed', 'true');
}
} catch (error) {
console.error(`Could not parse and modify URL: ${element.href}`, error);
}
});
},
getAllTimedLinks(element = document) {
return element.querySelectorAll('a[href*="?t="], a[href*="&t="]');
},
getAllWatchLinks(element = document) {
return element.querySelectorAll('a[href*="?v="], a[href*="&v="]');
},
getSecondsFromTimedLink(linkElement) {
try {
if (!linkElement) return 0;
const url = new URL(linkElement.href);
const timeValue = url.searchParams.get('t');
return timeValue === null ? null : parseInt(timeValue, 10);
} catch {
return null;
}
},
videoIdFromTimedLink(linkElement) {
try {
if (!linkElement) return null;
const url = new URL(linkElement.href);
return url.searchParams.get('v');
} catch {
return null;
}
},
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;
try {
const url = new URL(anchor.href);
if (url.searchParams.has('t')) url.searchParams.delete('t');
const newUrl = url.toString();
anchor.href = newUrl;
if (isNewTabClick) return;
event.preventDefault();
event.stopImmediatePropagation();
history.pushState(null, '', newUrl);
window.dispatchEvent(new PopStateEvent('popstate', { state: null }));
} catch (error) {
console.warn('Could not parse link href:', anchor.href, error);
}
}, true);
}
};
const ThumbnailEnhancer = {
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',
],
thumbnailIntersectionObserver: null,
init() {
ThumbnailEnhancer.thumbnailIntersectionObserver = new IntersectionObserver((entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
ThumbnailEnhancer._overrideNativeResume([entry.target]);
observer.unobserve(entry.target);
}
}
}, { rootMargin: '80px' });
},
_parseTimeToSeconds(timeString) {
return timeString.trim().split(':').reverse().reduce((total, part, index) => total + Number(part) * (60 ** index), 0);
},
_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 };
},
_buildProgressBarMobile(contentParent, roundedPercentage) {
let progressBarFill = contentParent.querySelector('.thumbnail-overlay-resume-playback-progress');
const progressBarContainerParent = contentParent.querySelector('.videoThumbnailGroupOverlayBottomLeftRightGroup');
if (!progressBarFill && progressBarContainerParent && roundedPercentage > 0) {
const thumbnailProgressBarRenderer = document.createElement('ytm-thumbnail-overlay-resume-playback-renderer');
thumbnailProgressBarRenderer.classList.add('videoThumbnailGroupResumePlayback');
const newProgressBar = document.createElement('div');
newProgressBar.classList.add('thumbnail-overlay-resume-playback-progress');
thumbnailProgressBarRenderer.appendChild(newProgressBar);
progressBarContainerParent.appendChild(thumbnailProgressBarRenderer);
progressBarFill = newProgressBar;
}
return progressBarFill;
},
_buildProgressBarDesktopLegacy(contentParent, roundedPercentage) {
let progressBarFill = contentParent.querySelector('#progress.ytd-thumbnail-overlay-resume-playback-renderer');
const thumbnailOverlays = contentParent.querySelector('#overlays.ytd-thumbnail');
if (!progressBarFill && thumbnailOverlays && roundedPercentage > 0) {
const thumbnailProgressBarRenderer = document.createElement('ytd-thumbnail-overlay-resume-playback-renderer');
thumbnailProgressBarRenderer.classList.add('style-scope', 'ytd-thumbnail');
const newProgressBar = document.createElement('div');
newProgressBar.id = 'progress';
newProgressBar.classList.add('style-scope', 'ytd-thumbnail-overlay-resume-playback-renderer');
thumbnailProgressBarRenderer.appendChild(newProgressBar);
thumbnailOverlays.appendChild(thumbnailProgressBarRenderer);
progressBarFill = newProgressBar;
}
return progressBarFill;
},
_buildProgressBarDesktopModern(contentParent, roundedPercentage) {
let progressBarFill = contentParent.querySelector('.ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment');
const ytThumbnailViewModel = contentParent.querySelector('yt-thumbnail-view-model');
if (!progressBarFill && ytThumbnailViewModel && roundedPercentage > 0) {
const thumbnailBottomOverlay = document.createElement('yt-thumbnail-bottom-overlay-view-model');
thumbnailBottomOverlay.classList.add('ytThumbnailBottomOverlayViewModelHost');
const resumeProgressBarModel = document.createElement('yt-thumbnail-overlay-progress-bar-view-model');
resumeProgressBarModel.classList.add('ytThumbnailOverlayProgressBarHost', 'ytThumbnailOverlayProgressBarHostLarge');
const progressBarContainer = document.createElement('div');
progressBarContainer.classList.add('ytThumbnailOverlayProgressBarHostWatchedProgressBar', 'ytThumbnailOverlayProgressBarHostUseLegacyBar');
const newProgressBarSegment = document.createElement('div');
newProgressBarSegment.classList.add('ytThumbnailOverlayProgressBarHostWatchedProgressBarSegment');
progressBarContainer.appendChild(newProgressBarSegment);
resumeProgressBarModel.appendChild(progressBarContainer);
thumbnailBottomOverlay.appendChild(resumeProgressBarModel);
ytThumbnailViewModel.appendChild(thumbnailBottomOverlay);
progressBarFill = newProgressBarSegment;
}
return progressBarFill;
},
_buildProgressBar(contentParent, roundedPercentage) {
const isPlaylistPage = window.location.pathname === '/playlist';
const isSearchPage = window.location.pathname === '/results';
if (api.page.isMobile) return ThumbnailEnhancer._buildProgressBarMobile(contentParent, roundedPercentage);
if (isPlaylistPage || isSearchPage) return ThumbnailEnhancer._buildProgressBarDesktopLegacy(contentParent, roundedPercentage);
return ThumbnailEnhancer._buildProgressBarDesktopModern(contentParent, roundedPercentage);
},
async _overrideNativeResume(contentParents) {
const dataReadPromises = contentParents.map(async (contentParent) => {
try {
if (contentParent.hasAttribute(ThumbnailEnhancer.processedAttribute)) return null;
const watchLinks = NavigationInterceptor.getAllWatchLinks(contentParent);
const timedLinks = NavigationInterceptor.getAllTimedLinks(contentParent);
const videoData = watchLinks[0]?.data?.watchEndpoint;
const videoId = videoData?.videoId ?? NavigationInterceptor.videoIdFromTimedLink(watchLinks[0]);
if (!videoId) return null;
const savedData = await ResumeStorageManager.getValue(videoId);
return { contentParent, timedLinks, videoData, videoId, savedData };
} catch (error) {
console.error('Failed to read data for element:', contentParent, error);
return null;
}
});
const processingData = (await Promise.all(dataReadPromises)).filter(Boolean);
for (const data of processingData) {
const { contentParent, timedLinks, videoData, videoId, savedData } = data;
if (!videoId) continue;
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 startTime = savedData?.timestamp ?? videoData?.startTimeSeconds ?? NavigationInterceptor.getSecondsFromTimedLink(timedLinks[0]);
let videoLength = savedData?.duration;
if (!savedData?.timestamp || !savedData?.duration || savedData.duration < savedData.timestamp) {
const timeText = api.page.isMobile
? contentParent.querySelector('ytm-thumbnail-overlay-time-status-renderer')?.innerText
: contentParent.querySelector('ytd-thumbnail-overlay-time-status-renderer > #text')?.innerText;
videoLength = ThumbnailEnhancer._parseTimeToSeconds(timeText ?? '0');
ResumeStorageManager.setValue(videoId, {
timestamp: startTime,
duration: videoLength,
lastUpdated: Date.now(),
videoType: 'regular',
});
}
const completePercentage = videoLength === 0 ? 0 : (startTime / videoLength) * 100;
const roundedPercentage = completePercentage === 0 || completePercentage >= 99 ? 0 : Math.max(1, Math.round(completePercentage));
const previewWidth = `${roundedPercentage}%`;
const progressBarFill = ThumbnailEnhancer._buildProgressBar(contentParent, roundedPercentage);
if (progressBarFill) {
progressBarFill.style.width = previewWidth;
if (roundedPercentage === 0) progressBarFill.parentElement.style.opacity = '0';
}
NavigationInterceptor.removeTimestampFromNodeHrefs(timedLinks);
} catch (error) {
console.error('Failed to process resume preview for element:', contentParent, error);
contentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
}
}
},
observeThumbnails(targetNode) {
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (addedNode.nodeType !== Node.ELEMENT_NODE) continue;
const watchLinks = NavigationInterceptor.getAllWatchLinks(addedNode);
watchLinks.forEach((watchLink) => {
const isVideoThumbnail = watchLink.querySelector('yt-image') || watchLink.querySelector('yt-thumbnail-view-model') || watchLink.querySelector('.video-thumbnail-img');
if (!isVideoThumbnail) return;
const rendererContentParent = api.page.isMobile ? watchLink.parentElement : watchLink.parentElement.parentElement;
if (rendererContentParent && !rendererContentParent.hasAttribute(ThumbnailEnhancer.processedAttribute) && !rendererContentParent.classList.contains('ytd-playlist-sidebar-renderer')) {
ThumbnailEnhancer.thumbnailIntersectionObserver.observe(rendererContentParent);
}
});
const progressBars = addedNode.matches(ThumbnailEnhancer.nativeProgressSelector) ? [addedNode] : addedNode.querySelectorAll(ThumbnailEnhancer.nativeProgressSelector);
progressBars.forEach((progressBar) => {
const watchLink = progressBar.closest('a');
if (!watchLink) return;
const rendererContentParent = api.page.isMobile ? watchLink.parentElement : watchLink.parentElement.parentElement;
if (rendererContentParent && rendererContentParent.hasAttribute(ThumbnailEnhancer.processedAttribute)) {
rendererContentParent.removeAttribute(ThumbnailEnhancer.processedAttribute);
ThumbnailEnhancer._overrideNativeResume([rendererContentParent]);
}
});
}
}
});
observer.observe(targetNode, { childList: true, subtree: true });
},
processInitialThumbnails(rootElement = document) {
const watchLinks = NavigationInterceptor.getAllWatchLinks(rootElement);
watchLinks.forEach((watchLink) => {
const isVideoThumbnail = watchLink.querySelector('yt-image') || watchLink.querySelector('yt-thumbnail-view-model') || watchLink.querySelector('.video-thumbnail-img');
if (!isVideoThumbnail) return;
const rendererContentParent = api.page.isMobile ? watchLink.parentElement : watchLink.parentElement.parentElement;
if (rendererContentParent && !rendererContentParent.hasAttribute(ThumbnailEnhancer.processedAttribute) && !rendererContentParent.classList.contains('ytd-playlist-sidebar-renderer')) {
ThumbnailEnhancer.thumbnailIntersectionObserver.observe(rendererContentParent);
}
});
},
start() {
ThumbnailEnhancer.init();
const targetSelector = api.page.isMobile ? 'ytm-app' : 'ytd-page-manager';
const bootstrapper = new MutationObserver((mutations, me) => {
const targetNode = document.querySelector(targetSelector);
if (targetNode) {
me.disconnect();
ThumbnailEnhancer.observeThumbnails(targetNode);
ThumbnailEnhancer.processInitialThumbnails(targetNode);
}
});
bootstrapper.observe(document.body, { childList: true, subtree: true });
}
};
const AutoResumeApp = {
start() {
try {
// Setup independent cyclic cleanup
ResumeStorageManager.handleCleanupCycle();
setInterval(() => ResumeStorageManager.handleCleanupCycle(), CONSTANTS.CLEANUP_INTERVAL_MS);
// Setup specific pagehide & ended cleanups
window.addEventListener('pagehide', () => PlaybackController.teardown());
document.addEventListener('yt-autonav-pause-player-ended', async () => {
PlaybackController.teardown();
await ResumeStorageManager.deleteValue(api.video.id);
});
NavigationInterceptor.interceptLinksWithUntimedVersion();
ThumbnailEnhancer.start();
// Core video processing trigger
api.eventTarget.addEventListener('yt-helper-api-ready', () => PlaybackController.processVideo());
} catch (error) {
console.error(`Initialization failed: `, error);
}
}
};
AutoResumeApp.start();
})();