您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Track and find your last watched video on YouTube subscriptions page. Drag the green box to mark videos, with automatic search and history backup.
// ==UserScript== // @name YouTube Last Watched Video Tracker // @namespace https://greasyfork.org/users/1513610 // @version 1.0.0 // @description Track and find your last watched video on YouTube subscriptions page. Drag the green box to mark videos, with automatic search and history backup. // @author NAABO // @match https://www.youtube.com/feed/subscriptions* // @grant GM_setValue // @grant GM_getValue // @run-at document-end // @license MIT // ==/UserScript== /* * YouTube Last Watched Video Tracker * * HOW TO USE: * 1. Go to YouTube subscriptions page * 2. You'll see a green "DRAG TO MARK VIDEO" box * 3. Drag it onto any video to mark as your last watched * 4. When you return, the script will automatically find and highlight that video * 5. Check browser console (F12) to see your video history * * FEATURES: * - Automatic video search with smart scrolling * - Video history backup (last 10 videos) * - Drag and drop interface * - Cancellable operations * - Accessibility support * * PRIVACY: * - All data stored locally in your browser * - No external requests or tracking * - No personal data collection */ (function() { 'use strict'; // ==================== CONFIGURATION ==================== const CONFIG = { SCROLL_DELAY_BASE: 800, SCROLL_DELAY_MIN: 400, SCROLL_DELAY_MAX: 1200, SCROLL_STEP: 500, VISIBILITY_CHECK_INTERVAL: 500, MAX_SEARCH_ATTEMPTS: 200, NOTIFICATION_DURATION: 3000, DEBOUNCE_DELAY: 300, ANIMATION_DURATION: 300, DROP_DETECTION_DELAY: 50, MAX_HISTORY_SIZE: 10, CONTENT_LOAD_TIMEOUT: 30000, // 30 seconds timeout for content loading RETRY_ATTEMPTS: 3 }; const SELECTORS = { VIDEO_CONTAINER: 'ytd-rich-item-renderer', VIDEO_LINK: 'a[href*="/watch?v="]', SUBSCRIPTIONS_PATH: '/feed/subscriptions' }; const STYLES = { SUCCESS: { bg: 'linear-gradient(135deg, #00ff41, #00cc33)', color: '#000', shadow: '0 4px 20px rgba(0, 255, 65, 0.4)' }, ERROR: { bg: 'linear-gradient(135deg, #ff4757, #ff3742)', color: '#fff', shadow: '0 4px 20px rgba(255, 71, 87, 0.4)' }, INFO: { bg: 'linear-gradient(135deg, #3742fa, #2f3542)', color: '#fff', shadow: '0 4px 20px rgba(55, 66, 250, 0.4)' } }; const VERSION = '1.0.0'; // ==================== UTILITY CLASSES ==================== class Logger { static log(msg) { console.log(`[YT Tracker v${VERSION}] ${new Date().toLocaleTimeString()} - ${msg}`); } static warn(msg) { console.warn(`[YT Tracker v${VERSION}] ${new Date().toLocaleTimeString()} - ${msg}`); } static error(msg) { console.error(`[YT Tracker v${VERSION}] ${new Date().toLocaleTimeString()} - ${msg}`); } static welcome() { console.group(`🎥 YouTube Last Watched Video Tracker v${VERSION}`); console.log('✅ Script loaded successfully!'); console.log('📖 How to use:'); console.log(' 1. Drag the green box onto any video to mark as last watched'); console.log(' 2. Script will automatically find that video when you return'); console.log(' 3. Your last 3 videos are shown here as backup'); console.log('🔒 Privacy: All data stored locally, no tracking'); console.groupEnd(); } static history(videos) { if (videos.length === 0) { console.log('📺 No video history yet. Drag the green box onto a video to start tracking!'); return; } console.group('📺 Last 3 Watched Videos History'); videos.forEach((video, index) => { const timeAgo = this._getTimeAgo(video.timestamp); console.log(`${index + 1}. Video ID: ${video.id} | ${timeAgo} | https://youtube.com/watch?v=${video.id}`); }); console.groupEnd(); } static _getTimeAgo(timestamp) { const now = Date.now(); const diff = now - timestamp; const minutes = Math.floor(diff / 60000); const hours = Math.floor(diff / 3600000); const days = Math.floor(diff / 86400000); if (days > 0) return `${days} day${days > 1 ? 's' : ''} ago`; if (hours > 0) return `${hours} hour${hours > 1 ? 's' : ''} ago`; if (minutes > 0) return `${minutes} minute${minutes > 1 ? 's' : ''} ago`; return 'Just now'; } } class Utils { static debounce(func, delay) { let timeoutId; return (...args) => { clearTimeout(timeoutId); timeoutId = setTimeout(() => func.apply(this, args), delay); }; } static throttle(func, delay) { let lastExecution = 0; return (...args) => { const now = Date.now(); if (now - lastExecution >= delay) { lastExecution = now; return func.apply(this, args); } }; } static getVideoId(url) { if (!url || typeof url !== 'string') return null; const match = url.match(/[?&]v=([a-zA-Z0-9_-]{11})/); return match ? match[1] : null; } static isSubscriptionsPage() { return location.href.includes(SELECTORS.SUBSCRIPTIONS_PATH); } static async sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } static safeJsonParse(json, fallback = null) { try { return JSON.parse(json); } catch (error) { Logger.warn(`JSON parse failed: ${error.message}`); return fallback; } } static safeJsonStringify(obj, fallback = '[]') { try { return JSON.stringify(obj); } catch (error) { Logger.warn(`JSON stringify failed: ${error.message}`); return fallback; } } } // ==================== ENHANCED STORAGE MANAGER ==================== class StorageManager { static save(key, value) { try { GM_setValue(key, value); Logger.log(`Saved ${key}: ${value}`); return true; } catch (error) { Logger.error(`Save failed for ${key}: ${error.message}`); return false; } } static get(key, defaultValue = null) { try { const value = GM_getValue(key, defaultValue); return value; } catch (error) { Logger.error(`Get failed for ${key}: ${error.message}`); return defaultValue; } } static saveVideo(videoId) { if (!videoId || typeof videoId !== 'string') { Logger.error('Invalid video ID provided'); return false; } try { // Save current video (backward compatibility) this.save('lastVideo', videoId); // Get existing history let history = this.getVideoHistory(); // Remove if already exists (to avoid duplicates) history = history.filter(video => video.id !== videoId); // Add new video at the beginning history.unshift({ id: videoId, timestamp: Date.now(), url: `https://youtube.com/watch?v=${videoId}` }); // Limit history size if (history.length > CONFIG.MAX_HISTORY_SIZE) { history = history.slice(0, CONFIG.MAX_HISTORY_SIZE); } // Save updated history const historyJson = Utils.safeJsonStringify(history); this.save('videoHistory', historyJson); // Display recent history in console this.displayRecentHistory(); return true; } catch (error) { Logger.error(`Failed to save video with history: ${error.message}`); return false; } } static getSavedVideo() { return this.get('lastVideo'); } static getVideoHistory() { const historyJson = this.get('videoHistory', '[]'); const history = Utils.safeJsonParse(historyJson, []); // Validate history entries return history.filter(video => video && typeof video === 'object' && video.id && typeof video.id === 'string' && video.timestamp && typeof video.timestamp === 'number' ); } static displayRecentHistory() { const history = this.getVideoHistory(); const recent = history.slice(0, 3); Logger.history(recent); } static getVideoFromHistory(index) { const history = this.getVideoHistory(); return history[index] || null; } // Migration for existing users static migrateToHistoryFormat() { try { const existingVideo = this.getSavedVideo(); const existingHistory = this.getVideoHistory(); if (existingVideo && existingHistory.length === 0) { const historyEntry = { id: existingVideo, timestamp: Date.now() - (24 * 60 * 60 * 1000), // Set as 1 day ago url: `https://youtube.com/watch?v=${existingVideo}` }; const historyJson = Utils.safeJsonStringify([historyEntry]); this.save('videoHistory', historyJson); Logger.log(`Migrated existing video ${existingVideo} to history format`); return true; } return false; } catch (error) { Logger.error(`Migration failed: ${error.message}`); return false; } } // Clear all data (for troubleshooting) static clearAllData() { try { this.save('lastVideo', ''); this.save('videoHistory', '[]'); Logger.log('All data cleared'); return true; } catch (error) { Logger.error(`Clear data failed: ${error.message}`); return false; } } } // ==================== NOTIFICATION SYSTEM ==================== class NotificationManager { constructor() { this.notifications = new WeakMap(); this.searchNotification = null; } show(text, type = 'success') { if (!text || typeof text !== 'string') return; this._removeExistingNotification(); const notification = this._createNotification(text, type); if (!notification) return; document.body.appendChild(notification); requestAnimationFrame(() => { notification.style.opacity = '1'; notification.style.transform = 'translateX(-50%) translateY(0)'; }); setTimeout(() => this._hideNotification(notification), CONFIG.NOTIFICATION_DURATION); } showSearch(text, progress = 0) { if (!text || typeof text !== 'string') return; if (this.searchNotification) { this._updateSearchNotification(text, progress); return; } this.searchNotification = this._createSearchNotification(text, progress); if (this.searchNotification) { document.body.appendChild(this.searchNotification); } } hideSearch() { if (this.searchNotification) { this._hideNotification(this.searchNotification); this.searchNotification = null; } } _removeExistingNotification() { const existing = document.querySelector('.yt-tracker-notif'); if (existing) existing.remove(); } _createNotification(text, type) { try { const style = STYLES[type.toUpperCase()] || STYLES.SUCCESS; const notification = document.createElement('div'); notification.className = 'yt-tracker-notif'; notification.textContent = text; notification.setAttribute('role', 'alert'); notification.setAttribute('aria-live', 'polite'); notification.style.cssText = ` position: fixed !important; top: 20px !important; left: 50% !important; transform: translateX(-50%) translateY(-20px) !important; background: ${style.bg} !important; color: ${style.color} !important; padding: 12px 24px !important; border-radius: 25px !important; z-index: 999999 !important; font-weight: bold !important; font-size: 14px !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif !important; box-shadow: ${style.shadow} !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; backdrop-filter: blur(10px) !important; transition: all ${CONFIG.ANIMATION_DURATION}ms ease !important; opacity: 0 !important; max-width: 400px !important; word-wrap: break-word !important; `; return notification; } catch (error) { Logger.error(`Failed to create notification: ${error.message}`); return null; } } _createSearchNotification(text, progress) { try { const notification = document.createElement('div'); notification.className = 'yt-tracker-search-notif'; notification.setAttribute('role', 'dialog'); notification.setAttribute('aria-label', 'Search Progress'); notification.style.cssText = ` position: fixed !important; top: 20px !important; left: 50% !important; transform: translateX(-50%) !important; background: linear-gradient(135deg, #1e3799, #0c2461) !important; color: #ffffff !important; padding: 18px 28px 22px 28px !important; border-radius: 25px !important; z-index: 999999 !important; font-weight: bold !important; font-size: 14px !important; box-shadow: 0 8px 32px rgba(30, 55, 153, 0.4) !important; display: flex !important; align-items: center !important; gap: 15px !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif !important; border: 1px solid rgba(255, 255, 255, 0.1) !important; backdrop-filter: blur(10px) !important; min-width: 320px !important; max-width: 500px !important; flex-direction: column !important; `; notification.innerHTML = this._getSearchNotificationHTML(text, progress); this._attachCancelHandler(notification); return notification; } catch (error) { Logger.error(`Failed to create search notification: ${error.message}`); return null; } } _getSearchNotificationHTML(text, progress) { const clampedProgress = Math.min(Math.max(progress, 0), 100); return ` <div style="display: flex !important; align-items: center !important; gap: 15px !important; width: 100% !important;"> <span style="flex: 1 !important;">${text}</span> <button class="cancel-search-btn" style=" background: rgba(255, 87, 87, 0.2) !important; border: 2px solid rgba(255, 87, 87, 0.6) !important; color: #ff5757 !important; font-weight: bold !important; font-size: 16px !important; width: 28px !important; height: 28px !important; border-radius: 50% !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important; line-height: 1 !important; transition: all 0.2s ease !important; flex-shrink: 0 !important; " title="Cancel search" aria-label="Cancel search">×</button> </div> <div style=" width: 100% !important; height: 4px !important; background: rgba(255, 255, 255, 0.1) !important; border-radius: 2px !important; overflow: hidden !important; margin-top: 8px !important; "> <div class="progress-bar" style=" height: 100% !important; background: linear-gradient(90deg, #00ff41, #00cc33) !important; border-radius: 2px !important; transition: width 0.3s ease !important; width: ${clampedProgress}% !important; box-shadow: 0 0 8px rgba(0, 255, 65, 0.6) !important; "></div> </div> `; } _attachCancelHandler(notification) { if (!notification) return; const cancelBtn = notification.querySelector('.cancel-search-btn'); if (cancelBtn) { cancelBtn.addEventListener('click', () => { this.hideSearch(); window.dispatchEvent(new CustomEvent('ytTrackerSearchCancel')); }); cancelBtn.addEventListener('mouseenter', () => { cancelBtn.style.background = 'rgba(255, 87, 87, 0.4) !important'; }); cancelBtn.addEventListener('mouseleave', () => { cancelBtn.style.background = 'rgba(255, 87, 87, 0.2) !important'; }); } } _updateSearchNotification(text, progress) { if (!this.searchNotification) return; const textSpan = this.searchNotification.querySelector('span'); const progressBar = this.searchNotification.querySelector('.progress-bar'); if (textSpan) textSpan.textContent = text; if (progressBar) { const clampedProgress = Math.min(Math.max(progress, 0), 100); progressBar.style.width = `${clampedProgress}%`; } } _hideNotification(notification) { if (!notification || !notification.parentNode) return; notification.style.opacity = '0'; notification.style.transform = notification.classList.contains('yt-tracker-search-notif') ? 'translateX(-50%) translateY(-20px)' : 'translateX(-50%) translateY(-40px)'; setTimeout(() => { if (notification.parentNode) { notification.remove(); } }, CONFIG.ANIMATION_DURATION); } } // ==================== DRAG BOX COMPONENT ==================== class DragBox { constructor() { this.element = null; this.isDragging = false; this.visibilityTimer = null; this.boundHandlers = new Map(); this.isPositioned = false; this.retryCount = 0; this._createStyleSheet(); } create() { try { this._removeExisting(); this.element = document.createElement('div'); this.element.id = 'yt-tracker-box'; this.element.setAttribute('role', 'button'); this.element.setAttribute('aria-label', 'Drag to mark video as last watched'); this.element.setAttribute('tabindex', '0'); this._applyInitialStyles(); this._setContent('DRAG TO<br>MARK VIDEO'); document.body.appendChild(this.element); this._setupEventListeners(); this._startVisibilityGuard(); this.isPositioned = false; this.retryCount = 0; Logger.log('Drag box created successfully'); return true; } catch (error) { Logger.error(`Failed to create drag box: ${error.message}`); this._retryCreate(); return false; } } _retryCreate() { if (this.retryCount < CONFIG.RETRY_ATTEMPTS) { this.retryCount++; Logger.log(`Retrying drag box creation (attempt ${this.retryCount}/${CONFIG.RETRY_ATTEMPTS})`); setTimeout(() => this.create(), 1000 * this.retryCount); } else { Logger.error('Failed to create drag box after all retry attempts'); } } destroy() { try { this._stopVisibilityGuard(); this._removeEventListeners(); if (this.element && this.element.parentNode) { this.element.remove(); } this.element = null; this.isPositioned = false; Logger.log('Drag box destroyed'); } catch (error) { Logger.error(`Failed to destroy drag box: ${error.message}`); } } resetToFloating() { if (!this.element) return; try { Logger.log('Resetting drag box to floating state'); this.isPositioned = false; this.element.classList.remove('yt-tracker-pulsing'); this._applyInitialStyles(); this._setContent('DRAG TO<br>MARK VIDEO'); this.element.style.transition = 'all 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55)'; setTimeout(() => { if (this.element) { this.element.style.transition = 'all 0.3s ease'; } }, 500); } catch (error) { Logger.error(`Failed to reset drag box: ${error.message}`); } } positionOnVideo(container) { if (!this.element || !container) return; try { const rect = container.getBoundingClientRect(); const scrollTop = window.pageYOffset || document.documentElement.scrollTop; const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; this.element.style.position = 'absolute'; this.element.style.left = (rect.left + scrollLeft + 10) + 'px'; this.element.style.top = (rect.top + scrollTop + 10) + 'px'; this.element.style.right = 'auto'; this._updateForPositioned(); this._setContent('✓ LAST<br>WATCHED'); this.isPositioned = true; Logger.log('Box positioned on video successfully'); } catch (error) { Logger.error(`Failed to position drag box: ${error.message}`); } } _createStyleSheet() { if (document.querySelector('#yt-tracker-styles')) return; try { const style = document.createElement('style'); style.id = 'yt-tracker-styles'; style.textContent = ` @keyframes yt-tracker-pulse { 0% { box-shadow: 0 0 15px #00ff00, 0 0 30px rgba(0, 255, 0, 0.4); transform: scale(1); } 50% { box-shadow: 0 0 25px #00ff00, 0 0 50px rgba(0, 255, 0, 0.6); transform: scale(1.02); } 100% { box-shadow: 0 0 15px #00ff00, 0 0 30px rgba(0, 255, 0, 0.4); transform: scale(1); } } .yt-tracker-pulsing { animation: yt-tracker-pulse 2s ease-in-out infinite !important; } @keyframes yt-tracker-glow { 0%, 100% { text-shadow: 0 0 5px #00ff00; } 50% { text-shadow: 0 0 15px #00ff00, 0 0 25px rgba(0, 255, 0, 0.8); } } .yt-tracker-glow-text { animation: yt-tracker-glow 1.5s ease-in-out infinite !important; } #yt-tracker-box:focus { outline: 2px solid #00ff41 !important; outline-offset: 2px !important; } #yt-tracker-box:hover { opacity: 0.9 !important; transform: scale(1.05) !important; } `; document.head.appendChild(style); } catch (error) { Logger.error(`Failed to create stylesheet: ${error.message}`); } } _removeExisting() { const existing = document.querySelector('#yt-tracker-box'); if (existing) existing.remove(); } _applyInitialStyles() { if (!this.element) return; this.element.style.cssText = ` position: fixed !important; width: 130px !important; height: 85px !important; background: linear-gradient(135deg, rgba(0, 255, 0, 0.25), rgba(0, 200, 0, 0.15)) !important; border: 3px solid #00ff41 !important; border-radius: 12px !important; cursor: move !important; z-index: 2147483647 !important; display: block !important; visibility: visible !important; opacity: 1 !important; pointer-events: auto !important; user-select: none !important; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Arial, sans-serif !important; box-shadow: 0 0 20px rgba(0, 255, 65, 0.4), inset 0 0 20px rgba(0, 255, 0, 0.1) !important; top: 100px !important; right: 20px !important; backdrop-filter: blur(5px) !important; transition: all 0.3s ease !important; `; } _setContent(text) { if (!this.element) return; this.element.innerHTML = ` <div class="yt-tracker-glow-text" style=" text-align: center !important; color: #00ff41 !important; font-weight: bold !important; font-size: 12px !important; margin-top: 22px !important; text-shadow: 0 0 8px #00ff41 !important; line-height: 1.3 !important; pointer-events: none !important; "> ${text} </div> `; } _updateForPositioned() { if (!this.element) return; this.element.style.background = 'linear-gradient(135deg, rgba(0, 255, 65, 0.3), rgba(0, 200, 50, 0.2))'; this.element.style.width = '140px'; this.element.style.height = '90px'; this.element.classList.add('yt-tracker-pulsing'); } _setupEventListeners() { if (!this.element) return; try { // Mouse events const mouseDownHandler = this._createMouseDownHandler(); this.element.addEventListener('mousedown', mouseDownHandler); this.boundHandlers.set('mousedown', mouseDownHandler); // Keyboard events for accessibility const keyDownHandler = (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); const rect = this.element.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; window.dispatchEvent(new CustomEvent('ytTrackerDrop', { detail: { x: centerX, y: centerY } })); } }; this.element.addEventListener('keydown', keyDownHandler); this.boundHandlers.set('keydown', keyDownHandler); } catch (error) { Logger.error(`Failed to setup event listeners: ${error.message}`); } } _createMouseDownHandler() { return (e) => { try { if (e.button !== 0) return; this.isDragging = true; const rect = this.element.getBoundingClientRect(); const startX = rect.left; const startY = rect.top; const mouseX = e.clientX; const mouseY = e.clientY; this.element.style.opacity = '0.8'; this.element.style.transform = 'scale(1.05)'; const mouseMoveHandler = (e) => { if (!this.isDragging) return; const newX = startX + (e.clientX - mouseX); const newY = startY + (e.clientY - mouseY); this.element.style.position = 'fixed'; this.element.style.left = newX + 'px'; this.element.style.top = newY + 'px'; this.element.style.right = 'auto'; }; const mouseUpHandler = (e) => { if (!this.isDragging) return; this.isDragging = false; this.element.style.opacity = '1'; this.element.style.transform = 'scale(1)'; document.removeEventListener('mousemove', mouseMoveHandler); document.removeEventListener('mouseup', mouseUpHandler); setTimeout(() => { window.dispatchEvent(new CustomEvent('ytTrackerDrop', { detail: { x: e.clientX, y: e.clientY } })); }, CONFIG.DROP_DETECTION_DELAY); }; document.addEventListener('mousemove', mouseMoveHandler); document.addEventListener('mouseup', mouseUpHandler); e.preventDefault(); } catch (error) { Logger.error(`Mouse handler error: ${error.message}`); } }; } _removeEventListeners() { for (const [event, handler] of this.boundHandlers) { if (this.element) { this.element.removeEventListener(event, handler); } } this.boundHandlers.clear(); } _startVisibilityGuard() { this._stopVisibilityGuard(); this.visibilityTimer = setInterval(() => { if (this.element && this.element.parentNode) { this.element.style.display = 'block'; this.element.style.visibility = 'visible'; this.element.style.opacity = this.element.style.opacity || '1'; if (!document.body.contains(this.element)) { document.body.appendChild(this.element); } } }, CONFIG.VISIBILITY_CHECK_INTERVAL); } _stopVisibilityGuard() { if (this.visibilityTimer) { clearInterval(this.visibilityTimer); this.visibilityTimer = null; } } } // ==================== VIDEO SEARCH ENGINE ==================== class VideoSearchEngine { constructor(notificationManager) { this.notificationManager = notificationManager; this.isSearching = false; this.searchCancelled = false; this.scrollDelay = CONFIG.SCROLL_DELAY_BASE; window.addEventListener('ytTrackerSearchCancel', () => { this.cancelSearch(); }); } async searchForVideo(videoId) { if (!videoId || this.isSearching) return false; try { this.isSearching = true; this.searchCancelled = false; this.scrollDelay = CONFIG.SCROLL_DELAY_BASE; Logger.log(`Starting search for video: ${videoId}`); this.notificationManager.showSearch('🔍 Searching for your last watched video...', 0); if (this._findVideoInCurrentView(videoId)) { this.isSearching = false; return true; } const result = await this._performSmartSearch(videoId); this.isSearching = false; if (this.searchCancelled) { this.notificationManager.show('🚫 Search cancelled', 'error'); return false; } return result; } catch (error) { Logger.error(`Search error: ${error.message}`); this.isSearching = false; this.notificationManager.hideSearch(); this.notificationManager.show('❌ Search failed', 'error'); return false; } } cancelSearch() { this.searchCancelled = true; this.isSearching = false; this.notificationManager.hideSearch(); Logger.log('Search cancelled by user'); } _findVideoInCurrentView(videoId) { try { const containers = document.querySelectorAll(SELECTORS.VIDEO_CONTAINER); for (const container of containers) { const links = container.querySelectorAll(SELECTORS.VIDEO_LINK); for (const link of links) { if (Utils.getVideoId(link.href) === videoId) { Logger.log(`Found video ${videoId} in current view`); this._highlightFoundVideo(container); return true; } } } return false; } catch (error) { Logger.error(`Error finding video in current view: ${error.message}`); return false; } } async _performSmartSearch(videoId) { let attempts = 0; let lastScrollPosition = 0; let stuckCount = 0; let lastVideoCount = this._countCurrentVideos(); while (this.isSearching && !this.searchCancelled && attempts < CONFIG.MAX_SEARCH_ATTEMPTS) { attempts++; const currentScrollPosition = window.pageYOffset || document.documentElement.scrollTop; if (currentScrollPosition === lastScrollPosition) { stuckCount++; if (stuckCount >= 3) { this.notificationManager.hideSearch(); this.notificationManager.show('❌ Reached end - video not found', 'error'); Logger.log('Reached end of page, video not found'); return false; } } else { stuckCount = 0; } lastScrollPosition = currentScrollPosition; const currentVideoCount = this._countCurrentVideos(); const newVideosLoaded = currentVideoCount - lastVideoCount; this._adjustScrollDelay(newVideosLoaded); lastVideoCount = currentVideoCount; window.scrollBy(0, CONFIG.SCROLL_STEP); if (attempts % 3 === 0) { const progressEstimate = Math.min((attempts * 2), 90); this.notificationManager.showSearch( `🔍 Searching... (${attempts} scrolls, ${currentVideoCount} videos)`, progressEstimate ); } await Utils.sleep(this.scrollDelay); if (this._findVideoInCurrentView(videoId)) { return true; } if (attempts % 15 === 0) { Logger.log(`Search progress: attempt ${attempts}, delay: ${this.scrollDelay}ms, videos: ${currentVideoCount}`); } } if (!this.searchCancelled) { this.notificationManager.hideSearch(); this.notificationManager.show('❌ Video not found after extensive search', 'error'); } return false; } _highlightFoundVideo(container) { try { this.notificationManager.hideSearch(); container.scrollIntoView({ behavior: 'smooth', block: 'center' }); setTimeout(() => { window.dispatchEvent(new CustomEvent('ytTrackerVideoFound', { detail: { container } })); this.notificationManager.show('🎯 Found your video!'); }, 1500); } catch (error) { Logger.error(`Error highlighting video: ${error.message}`); } } _countCurrentVideos() { try { return document.querySelectorAll(SELECTORS.VIDEO_CONTAINER).length; } catch (error) { return 0; } } _adjustScrollDelay(newVideosLoaded) { if (newVideosLoaded === 0) { this.scrollDelay = Math.max(CONFIG.SCROLL_DELAY_MIN, this.scrollDelay - 100); } else if (newVideosLoaded > 10) { this.scrollDelay = Math.min(CONFIG.SCROLL_DELAY_MAX, this.scrollDelay + 200); } } } // ==================== MAIN TRACKER CLASS ==================== class YouTubeVideoTracker { constructor() { this.dragBox = null; this.notificationManager = new NotificationManager(); this.searchEngine = new VideoSearchEngine(this.notificationManager); this.currentVideoId = null; this.isInitialized = false; this.urlChangeObserver = null; this.lastUrl = location.href; this._setupEventListeners(); } async init() { try { if (!Utils.isSubscriptionsPage()) { Logger.log('Not on subscriptions page, skipping initialization'); return; } if (this.isInitialized) { Logger.log('Already initialized, skipping'); return; } Logger.log('Initializing YouTube video tracker...'); Logger.welcome(); // Migrate existing data const migrated = StorageManager.migrateToHistoryFormat(); if (migrated) { Logger.log('Successfully migrated existing data'); } if (!(await this._waitForContent())) { Logger.error('Failed to load YouTube content within timeout'); this.notificationManager.show('⚠️ YouTube content loading slowly. Tracker may not work properly.', 'error'); return; } const dragBoxCreated = this._createDragBox(); if (dragBoxCreated) { this.notificationManager.show('💚 YouTube Tracker ready! Drag the green box to mark videos.', 'success'); } else { this.notificationManager.show('❌ Failed to create tracker interface', 'error'); return; } // Look for saved video const savedVideoId = StorageManager.getSavedVideo(); if (savedVideoId) { this.currentVideoId = savedVideoId; Logger.log(`Looking for saved video: ${savedVideoId}`); setTimeout(() => { StorageManager.displayRecentHistory(); }, 1000); setTimeout(() => { this.searchEngine.searchForVideo(savedVideoId); }, 2000); } else { // First-time user setTimeout(() => { Logger.log('Welcome! This is your first time using the tracker.'); StorageManager.displayRecentHistory(); }, 1000); } this.isInitialized = true; Logger.log('Tracker initialization complete'); } catch (error) { Logger.error(`Initialization failed: ${error.message}`); this.notificationManager.show('❌ Tracker initialization failed', 'error'); } } destroy() { try { Logger.log('Destroying tracker...'); if (this.dragBox) { this.dragBox.destroy(); this.dragBox = null; } if (this.urlChangeObserver) { this.urlChangeObserver.disconnect(); this.urlChangeObserver = null; } this.searchEngine.cancelSearch(); this.isInitialized = false; Logger.log('Tracker destroyed'); } catch (error) { Logger.error(`Destroy failed: ${error.message}`); } } _createDragBox() { try { if (this.dragBox) { this.dragBox.destroy(); } this.dragBox = new DragBox(); return this.dragBox.create(); } catch (error) { Logger.error(`Failed to create drag box: ${error.message}`); return false; } } _setupEventListeners() { try { window.addEventListener('ytTrackerDrop', (e) => { this._handleDrop(e.detail.x, e.detail.y); }); window.addEventListener('ytTrackerVideoFound', (e) => { if (this.dragBox && e.detail.container) { this.dragBox.positionOnVideo(e.detail.container); } }); window.addEventListener('ytTrackerSearchCancel', () => { if (this.dragBox && this.dragBox.isPositioned) { Logger.log('Resetting drag box due to search cancellation'); this.dragBox.resetToFloating(); } }); this._setupUrlChangeDetection(); } catch (error) { Logger.error(`Failed to setup event listeners: ${error.message}`); } } _setupUrlChangeDetection() { try { const checkUrlChange = Utils.debounce(() => { if (location.href !== this.lastUrl) { this.lastUrl = location.href; this._handleUrlChange(); } }, CONFIG.DEBOUNCE_DELAY); this.urlChangeObserver = new MutationObserver(checkUrlChange); this.urlChangeObserver.observe(document, { subtree: true, childList: true }); } catch (error) { Logger.error(`Failed to setup URL change detection: ${error.message}`); } } _handleUrlChange() { Logger.log(`URL changed to: ${location.href}`); this.searchEngine.cancelSearch(); if (Utils.isSubscriptionsPage()) { setTimeout(() => this.init(), 1000); } else { this.destroy(); } } async _handleDrop(x, y) { try { Logger.log(`Handling drop at coordinates: ${x}, ${y}`); if (!this.dragBox || !this.dragBox.element) { Logger.error('Drag box not available'); return; } const originalPointerEvents = this.dragBox.element.style.pointerEvents; this.dragBox.element.style.pointerEvents = 'none'; await new Promise(resolve => requestAnimationFrame(resolve)); const element = document.elementFromPoint(x, y); this.dragBox.element.style.pointerEvents = originalPointerEvents; if (!element) { Logger.warn('Drop target not found at coordinates'); this.notificationManager.show('❌ Drop target not found!', 'error'); return; } const videoContainer = element.closest(SELECTORS.VIDEO_CONTAINER); if (!videoContainer) { Logger.warn('Drop target is not a video container'); this.notificationManager.show('❌ Please drop on a video!', 'error'); return; } const videoLink = videoContainer.querySelector(SELECTORS.VIDEO_LINK); if (!videoLink) { Logger.warn('Video link not found in container'); this.notificationManager.show('❌ Video link not found!', 'error'); return; } const videoId = Utils.getVideoId(videoLink.href); if (!videoId) { Logger.warn('Could not extract video ID from link'); this.notificationManager.show('❌ Could not get video ID!', 'error'); return; } Logger.log(`Attempting to save video ID: ${videoId}`); if (StorageManager.saveVideo(videoId)) { this.currentVideoId = videoId; this.dragBox.positionOnVideo(videoContainer); this.notificationManager.show('✅ Video marked as last watched!', 'success'); Logger.log(`Successfully marked video ${videoId} as last watched`); } else { Logger.error('Failed to save video to storage'); this.notificationManager.show('❌ Failed to save video!', 'error'); } } catch (error) { Logger.error(`Drop handling failed: ${error.message}`); this.notificationManager.show('❌ Error processing drop', 'error'); } } async _waitForContent() { const startTime = Date.now(); for (let i = 0; i < 30; i++) { await Utils.sleep(1000); if (Date.now() - startTime > CONFIG.CONTENT_LOAD_TIMEOUT) { Logger.warn('Content loading timeout reached'); break; } const videos = document.querySelectorAll(SELECTORS.VIDEO_CONTAINER); if (videos.length > 0) { Logger.log(`Content ready - ${videos.length} videos found`); return true; } } return false; } } // ==================== INITIALIZATION ==================== let tracker = null; let initializationAttempts = 0; const startTracker = () => { try { Logger.log(`Starting YouTube Last Watched Video Tracker v${VERSION}`); if (tracker) { tracker.destroy(); } tracker = new YouTubeVideoTracker(); if (Utils.isSubscriptionsPage()) { setTimeout(() => tracker.init(), 1000); } } catch (error) { Logger.error(`Failed to start tracker: ${error.message}`); // Retry initialization initializationAttempts++; if (initializationAttempts < CONFIG.RETRY_ATTEMPTS) { Logger.log(`Retrying initialization (attempt ${initializationAttempts}/${CONFIG.RETRY_ATTEMPTS})`); setTimeout(startTracker, 2000 * initializationAttempts); } else { Logger.error('Failed to initialize after all attempts'); } } }; // Cleanup on page unload window.addEventListener('beforeunload', () => { if (tracker) { tracker.destroy(); } }); // Error handling for uncaught errors window.addEventListener('error', (e) => { if (e.message && e.message.includes('YT Tracker')) { Logger.error(`Uncaught error: ${e.message}`); } }); // Start when DOM is ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startTracker); } else { startTracker(); } // Expose some functions for debugging (only in console) if (typeof window !== 'undefined') { window.ytTracker = { version: VERSION, clearData: () => StorageManager.clearAllData(), showHistory: () => StorageManager.displayRecentHistory(), restart: () => { if (tracker) tracker.destroy(); startTracker(); } }; } })();