您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Supercharges your playlist management by sorting videos by duration.
// ==UserScript== // @name YouTubeSortByDuration // @namespace https://github.com/cloph-dsp/YouTubeSortByDuration // @version 4.2 // @description Supercharges your playlist management by sorting videos by duration. // @author cloph-dsp // @originalAuthor KohGeek // @license GPL-2.0-only // @homepageURL https://github.com/cloph-dsp/YouTubeSortByDuration // @supportURL https://github.com/cloph-dsp/YouTubeSortByDuration/issues // @match http://*.youtube.com/* // @match https://*.youtube.com/* // @require https://greasyfork.org/scripts/374849-library-onelementready-es7/code/Library%20%7C%20onElementReady%20ES7.js // @grant none // @run-at document-start // ==/UserScript== // CSS styles const css = ` /* Container wrapper */ .sort-playlist { display: flex; flex-wrap: wrap; align-items: center; gap: 16px; padding: 12px; background-color: var(--yt-spec-base-background); border-bottom: 1px solid var(--yt-spec-10-percent-layer); width: 100%; box-sizing: border-box; } /* Controls grouping */ .sort-playlist-controls { display: flex; align-items: center; gap: 8px; } /* Sort button wrapper */ #sort-toggle-button { padding: 8px 16px; font-size: 14px; white-space: nowrap; cursor: pointer; background: none; outline: none; } /* Start (green) state */ .sort-button-start { background-color: #28a745; color: #fff; border: 1px solid #28a745; border-radius: 4px; transition: background-color 0.3s, transform 0.2s; } .sort-button-start:hover { background-color: #218838; transform: translateY(-1px); } /* Stop (red) state */ .sort-button-stop { background-color: #dc3545; color: #fff; border: 1px solid #dc3545; border-radius: 4px; transition: background-color 0.3s, transform 0.2s; } .sort-button-stop:hover { background-color: #c82333; transform: translateY(-1px); } /* Dropdown selector styling */ .sort-select { padding: 6px 12px; font-size: 14px; border: 1px solid var(--yt-spec-10-percent-layer); border-radius: 4px; background-color: var(--yt-spec-base-background); color: var(--yt-spec-text-primary); /* ensure text is visible */ transition: border-color 0.2s; } .sort-select option { color: var(--yt-spec-text-primary); } .sort-select:focus { border-color: var(--yt-spec-brand-link-text); box-shadow: 0 0 0 2px rgba(40, 167, 69, 0.3); outline: none; } /* Status log element */ .sort-log { margin-left: auto; padding: 6px 12px; font-size: 13px; background-color: var(--yt-spec-base-background); border: 1px solid var(--yt-spec-10-percent-layer); border-radius: 4px; color: var(--yt-spec-text-primary); white-space: nowrap; } ` const modeAvailable = [ { value: 'asc', label: 'by Shortest' }, { value: 'desc', label: 'by Longest' } ]; const debug = false; // Config let isSorting = false; let scrollLoopTime = 500; let useAdaptiveDelay = true; let baseDelayPerVideo = 5; let minDelay = 10; let maxDelay = 1000; let fastModeThreshold = 200; let sortMode = 'asc'; let autoScrollInitialVideoList = true; let log = document.createElement('div'); let stopSort = false; // Fire mouse event at coordinates let fireMouseEvent = (type, elem, centerX, centerY) => { const event = new MouseEvent(type, { view: window, bubbles: true, cancelable: true, clientX: centerX, clientY: centerY }); elem.dispatchEvent(event); }; // Simulate drag between elements let simulateDrag = (elemDrag, elemDrop) => { // Get positions let pos = elemDrag.getBoundingClientRect(); let center1X = Math.floor((pos.left + pos.right) / 2); let center1Y = Math.floor((pos.top + pos.bottom) / 2); pos = elemDrop.getBoundingClientRect(); let center2X = Math.floor((pos.left + pos.right) / 2); let center2Y = Math.floor((pos.top + pos.bottom) / 2); // Mouse events for dragged element fireMouseEvent("mousemove", elemDrag, center1X, center1Y); fireMouseEvent("mouseenter", elemDrag, center1X, center1Y); fireMouseEvent("mouseover", elemDrag, center1X, center1Y); fireMouseEvent("mousedown", elemDrag, center1X, center1Y); // Start drag fireMouseEvent("dragstart", elemDrag, center1X, center1Y); fireMouseEvent("drag", elemDrag, center1X, center1Y); fireMouseEvent("mousemove", elemDrag, center1X, center1Y); fireMouseEvent("drag", elemDrag, center2X, center2Y); fireMouseEvent("mousemove", elemDrop, center2X, center2Y); // Events over drop target fireMouseEvent("mouseenter", elemDrop, center2X, center2Y); fireMouseEvent("dragenter", elemDrop, center2X, center2Y); fireMouseEvent("mouseover", elemDrop, center2X, center2Y); fireMouseEvent("dragover", elemDrop, center2X, center2Y); // Complete drop fireMouseEvent("drop", elemDrop, center2X, center2Y); fireMouseEvent("dragend", elemDrag, center2X, center2Y); fireMouseEvent("mouseup", elemDrag, center2X, center2Y); }; // Scroll to position or page bottom let autoScroll = async (scrollTop = null) => { const element = document.scrollingElement; const destination = scrollTop !== null ? scrollTop : element.scrollHeight; element.scrollTop = destination; logActivity(`Scrolling page... 📜`); await new Promise(r => setTimeout(r, scrollLoopTime)); }; // Log message to UI let logActivity = (message) => { if (log) { log.innerText = message; } if (debug) { console.log(message); } }; // Create UI container let renderContainerElement = () => { // Remove old container if any const existing = document.querySelector('.sort-playlist'); if (existing) existing.remove(); // Create new container const element = document.createElement('div'); element.className = 'sort-playlist'; element.style.margin = '8px 0'; const controls = document.createElement('div'); controls.className = 'sort-playlist-controls'; element.appendChild(controls); // Insert above playlist video list const list = document.querySelector('ytd-playlist-video-list-renderer'); if (list && list.parentNode) { list.parentNode.insertBefore(element, list); } else { console.error('Playlist video list not found for UI injection'); } }; // Create sort toggle button let renderToggleButton = () => { const element = document.createElement('button'); element.id = 'sort-toggle-button'; element.className = 'style-scope sort-button-toggle sort-button-start'; element.innerText = 'Sort Videos'; element.onclick = async () => { if (!isSorting) { // Start isSorting = true; element.className = 'style-scope sort-button-toggle sort-button-stop'; element.innerText = 'Stop Sorting'; await activateSort(); // Reset isSorting = false; element.className = 'style-scope sort-button-toggle sort-button-start'; element.innerText = 'Sort Videos'; } else { // Stop stopSort = true; isSorting = false; element.className = 'style-scope sort-button-toggle sort-button-start'; element.innerText = 'Sort Videos'; } }; document.querySelector('.sort-playlist-controls').appendChild(element); }; // Create dropdown selector let renderSelectElement = (variable = 0, options = [], label = '') => { const element = document.createElement('select'); element.className = 'style-scope sort-select'; element.onchange = (e) => { if (variable === 0) { sortMode = e.target.value; } else if (variable === 1) { autoScrollInitialVideoList = e.target.value; } }; options.forEach((option) => { const optionElement = document.createElement('option') optionElement.value = option.value optionElement.innerText = option.label element.appendChild(optionElement) }); document.querySelector('.sort-playlist-controls').appendChild(element); }; // Create number input let renderNumberElement = (defaultValue = 0, label = '') => { const elementDiv = document.createElement('div'); elementDiv.className = 'sort-playlist-div sort-margin-right-3px'; elementDiv.innerText = label; const element = document.createElement('input'); element.type = 'number'; element.value = defaultValue; element.className = 'style-scope'; element.oninput = (e) => { scrollLoopTime = +(e.target.value) }; elementDiv.appendChild(element); document.querySelector('div.sort-playlist').appendChild(elementDiv); }; // Create status log display let renderLogElement = () => { log.className = 'style-scope sort-log'; log.innerText = 'Please wait for the playlist to fully load before sorting...'; document.querySelector('div.sort-playlist').appendChild(log); }; // Add CSS to page let addCssStyle = () => { const element = document.createElement('style'); element.textContent = css; document.head.appendChild(element); }; // Wait for YouTube to process drag let waitForYoutubeProcessing = async () => { const maxWaitTime = Math.max(scrollLoopTime * 3, 1000); const startTime = Date.now(); let isProcessed = false; logActivity(`Rearranging playlist... ⏳`); // Fast polling const pollInterval = Math.min(50, Math.max(25, scrollLoopTime * 0.1)); while (!isProcessed && Date.now() - startTime < maxWaitTime && !stopSort) { await new Promise(r => setTimeout(r, pollInterval)); const allDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder"); const currentItems = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); if (allDragPoints.length > 0 && currentItems.length > 0 && document.querySelectorAll('.ytd-continuation-item-renderer').length === 0) { isProcessed = true; const timeTaken = Date.now() - startTime; if (timeTaken > 1000) { logActivity(`Video moved successfully ✓`); } // Stabilization wait await new Promise(r => setTimeout(r, Math.min(180, scrollLoopTime * 0.25))); return true; } } // Recovery methods // Fast recovery approach if (!isProcessed) { logActivity(`Ensuring YouTube updates... ⏱️`); // Click body to refocus document.body.click(); await new Promise(r => setTimeout(r, 275)); // Check results let updatedDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder"); let allThumbnails = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); if (updatedDragPoints.length > 0 && allThumbnails.length > 0) { logActivity(`Recovered playlist view ✓`); await new Promise(r => setTimeout(r, 540)); // Force refresh window.dispatchEvent(new Event('resize')); await new Promise(r => setTimeout(r, 180)); return true; } // Try faster recovery (2 attempts) for (let i = 0; i < 2 && !stopSort; i++) { const areas = [ document.querySelector('.playlist-items'), document.body ]; // Click areas for (const area of areas) { if (area && !stopSort) { area.click(); await new Promise(r => setTimeout(r, 125)); } } // Slight scroll if (!stopSort) { const scrollElem = document.scrollingElement; const currentPos = scrollElem.scrollTop; scrollElem.scrollTop = currentPos + 10; await new Promise(r => setTimeout(r, 125)); scrollElem.scrollTop = currentPos; } // Check if successful updatedDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder"); allThumbnails = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); if (updatedDragPoints.length > 0 && allThumbnails.length > 0) { logActivity(`Recovered after attempt ${i+1} ✓`); await new Promise(r => setTimeout(r, (540 + i * 125))); // Force refresh window.dispatchEvent(new Event('resize')); await new Promise(r => setTimeout(r, 175)); return true; } await new Promise(r => setTimeout(r, 315 + i * 175)); } // Final fallback if (!stopSort) { logActivity(`Attempting final recovery method...`); document.body.click(); await new Promise(r => setTimeout(r, 225)); const playlistHeader = document.querySelector('ytd-playlist-header-renderer'); if (playlistHeader) playlistHeader.click(); await new Promise(r => setTimeout(r, 725)); // Final check updatedDragPoints = document.querySelectorAll("ytd-item-section-renderer:first-of-type yt-icon#reorder"); allThumbnails = document.querySelectorAll("ytd-item-section-renderer:first-of-type div#content a#thumbnail.inline-block.ytd-thumbnail"); if (updatedDragPoints.length > 0 && allThumbnails.length > 0) { logActivity(`Final recovery successful ✓`); await new Promise(r => setTimeout(r, 700)); // Force refresh window.dispatchEvent(new Event('resize')); await new Promise(r => setTimeout(r, 175)); return true; } logActivity(`Recovery failed - skipping operation`); return false; } } return isProcessed; }; // Check if playlist is fully loaded let isPlaylistFullyLoaded = (reportedCount, loadedCount) => { const hasSpinner = document.querySelector('.ytd-continuation-item-renderer') !== null; return reportedCount === loadedCount && !hasSpinner; }; // Check if YouTube page is ready let isYouTubePageReady = () => { const playlistContainer = document.querySelector("ytd-playlist-video-list-renderer"); const videoCountElement = document.querySelector("ytd-playlist-sidebar-primary-info-renderer #stats span:first-child"); const dragPoints = playlistContainer ? playlistContainer.querySelectorAll("yt-icon#reorder") : []; // Spinner detection const spinnerElements = document.querySelectorAll('.ytd-continuation-item-renderer, yt-icon-button.ytd-continuation-item-renderer, .circle.ytd-spinner'); const hasVisibleSpinner = Array.from(spinnerElements).some(el => { const rect = el.getBoundingClientRect(); return (rect.width > 0 && rect.height > 0 && rect.top >= 0 && rect.top <= window.innerHeight); }); const reportedCount = videoCountElement ? parseInt(videoCountElement.innerText, 10) : 0; const loadedCount = dragPoints.length; const youtubeLoadLimit = 100; // Check readiness const basicElementsReady = playlistContainer && videoCountElement && reportedCount > 0 && loadedCount > 0; const hasEnoughVideos = loadedCount >= 95 || loadedCount === reportedCount; const fullyLoaded = (!hasVisibleSpinner && hasEnoughVideos) || (loadedCount >= 0.97 * Math.min(reportedCount, youtubeLoadLimit)); const isReady = basicElementsReady && fullyLoaded; // Show status if (isReady) { if (loadedCount < reportedCount) { logActivity(`✅ Ready: ${loadedCount}/${reportedCount} videos (YT limit)`); } else { logActivity(`✅ Ready: ${loadedCount}/${reportedCount} videos`); } } else if (basicElementsReady) { if (hasVisibleSpinner) { logActivity(`⏳ Loading: ${loadedCount}/${reportedCount} videos`); } else if (loadedCount < reportedCount && loadedCount > 0) { logActivity(`🔄 Waiting to scroll: ${loadedCount}/${reportedCount} videos`); } else { logActivity(`🔄 Waiting: ${loadedCount}/${reportedCount || '?'} videos`); } } else { logActivity(`🔄 Initializing...`); } return isReady; }; let sortVideos = async (allAnchors, allDragPoints, expectedCount) => { // Verify playlist fully loaded if (allDragPoints.length !== expectedCount || allAnchors.length !== expectedCount) { logActivity("Playlist not fully loaded, waiting..."); return -1; } // Build list of current handles with durations let list = []; for (let i = 0; i < expectedCount; i++) { // Handle missing duration text gracefully const txtElem = allAnchors[i].querySelector('#text'); let timeSp = txtElem ? txtElem.innerText.trim().split(':').reverse() : ['']; let t = timeSp.length === 1 ? (sortMode === 'asc' ? Infinity : -Infinity) : parseInt(timeSp[0]) + (timeSp[1] ? parseInt(timeSp[1]) * 60 : 0) + (timeSp[2] ? parseInt(timeSp[2]) * 3600 : 0); list.push({ handle: allDragPoints[i], time: t }); } // Create sorted reference let sorted = [...list]; sorted.sort((a, b) => sortMode === 'asc' ? a.time - b.time : b.time - a.time); // Find first mismatch and move for (let i = 0; i < expectedCount; i++) { if (list[i].handle !== sorted[i].handle) { let elemDrag = sorted[i].handle; let elemDrop = list[i].handle; logActivity(`Dragging video to position ${i}`); try { simulateDrag(elemDrag, elemDrop); if (useAdaptiveDelay && expectedCount > fastModeThreshold) { // Fast static delay for large playlists await new Promise(r => setTimeout(r, 100)); } else { await waitForYoutubeProcessing(); } } catch (e) { console.error('Drag error:', e); logActivity('Error during move; retrying slowly... ⏳'); // Fallback delay and retry await new Promise(r => setTimeout(r, scrollLoopTime)); } // Return number of sorted items (index+1) to signal success return i + 1; } } // All in order return expectedCount; } // Main sorting function let activateSort = async () => { // Reset scroll cap autoScrollAttempts = 0; // Set manual sorting mode const ensureManualSort = async () => { const sortButton = document.querySelector('yt-dropdown-menu[icon-label="Ordenar"] tp-yt-paper-button, yt-dropdown-menu[icon-label="Sort"] tp-yt-paper-button'); if (!sortButton) { logActivity("Sort dropdown not found. Using current mode."); return; } // Check if dropdown is already open and close it first (clean state) const isDropdownOpen = document.querySelector('tp-yt-paper-listbox:not([hidden])'); if (isDropdownOpen) { // Click away to close the dropdown document.body.click(); await new Promise(r => setTimeout(r, 100)); } // Open the dropdown menu logActivity("Opening sort dropdown..."); sortButton.click(); await new Promise(r => setTimeout(r, 200)); // Verify dropdown is visible const dropdownMenu = document.querySelector('tp-yt-paper-listbox:not([hidden])'); if (!dropdownMenu) { // Try once more if the dropdown didn't appear sortButton.click(); await new Promise(r => setTimeout(r, 250)); } // Ensure the dropdown is visible and select the manual option const manualOption = document.querySelector('tp-yt-paper-listbox a:first-child tp-yt-paper-item'); if (manualOption) { // Check if already selected to avoid unnecessary clicks const isSelected = manualOption.hasAttribute('selected') || manualOption.classList.contains('iron-selected') || manualOption.getAttribute('aria-selected') === 'true'; if (!isSelected) { manualOption.click(); logActivity("Switched to Manual sort mode"); await new Promise(r => setTimeout(r, 250)); } else { logActivity("Manual sort mode already active"); } // Ensure dropdown is closed by clicking away if still open const stillOpen = document.querySelector('tp-yt-paper-listbox:not([hidden])'); if (stillOpen) { document.body.click(); await new Promise(r => setTimeout(r, 100)); } // Quick verification const verifySort = document.querySelector('.dropdown-trigger-text'); if (verifySort && verifySort.textContent.toLowerCase().includes('manual')) { logActivity("Manual sort mode confirmed ✓"); } return true; } else { // Fallback method if first item not found const allOptions = document.querySelectorAll('tp-yt-paper-listbox a tp-yt-paper-item'); for (const option of allOptions) { if (option.textContent.toLowerCase().includes('manual')) { option.click(); logActivity("Found and selected Manual sort mode"); await new Promise(r => setTimeout(r, 250)); // Close dropdown document.body.click(); return true; } } // Close dropdown if option not found document.body.click(); logActivity("Manual sort option not found. Using current mode."); return false; } }; const manualSortSet = await ensureManualSort(); const videoCountElement = document.querySelector("ytd-playlist-sidebar-primary-info-renderer #stats span:first-child"); let reportedVideoCount = videoCountElement ? parseInt(videoCountElement.innerText, 10) : 0; const playlistContainer = document.querySelector("ytd-playlist-video-list-renderer"); let allDragPoints = playlistContainer ? playlistContainer.querySelectorAll("yt-icon#reorder") : []; let allAnchors; // Set optimal delay based on playlist size if (useAdaptiveDelay) { const needsScrolling = reportedVideoCount > 95 && allDragPoints.length < reportedVideoCount && autoScrollInitialVideoList; if (needsScrolling) { scrollLoopTime = Math.max(500, minDelay * 8); logActivity(`Using scroll-safe speed (${scrollLoopTime}ms)`); } else { let videoCount = reportedVideoCount || allDragPoints.length; if (videoCount <= fastModeThreshold) { let fastDelay = Math.max(75, baseDelayPerVideo * Math.sqrt(videoCount * 0.75)); scrollLoopTime = Math.min(fastDelay, 350); logActivity(`Using fast mode: ${scrollLoopTime}ms}`); } else { let calculatedDelay = Math.max(100, baseDelayPerVideo * Math.log(videoCount) * 2.5); scrollLoopTime = Math.min(calculatedDelay, 800); logActivity(`Using adaptive delay: ${scrollLoopTime}ms}`); } } } let sortedCount = 0; let initialVideoCount = allDragPoints.length; let scrollRetryCount = 0; stopSort = false; let consecutiveRecoveryFailures = 0; let sortFailureCount = 0; // count sortVideos failures // Load all videos if (reportedVideoCount > allDragPoints.length && autoScrollInitialVideoList) { logActivity(`Playlist has ${reportedVideoCount} videos. Loading all...`); while (allDragPoints.length < reportedVideoCount && !stopSort && scrollRetryCount < 10) { await autoScroll(); let newDragPoints = playlistContainer ? playlistContainer.querySelectorAll("yt-icon#reorder") : []; if (newDragPoints.length > allDragPoints.length) { allDragPoints = newDragPoints; scrollRetryCount = 0; // Reset on progress logActivity(`Loading videos (${allDragPoints.length}/${reportedVideoCount})`); } else { scrollRetryCount++; logActivity(`Scroll attempt ${scrollRetryCount}/10...`); await new Promise(r => setTimeout(r, 500 + scrollRetryCount * 100)); } // Check for spinner const spinner = document.querySelector('.ytd-continuation-item-renderer'); if (!spinner && allDragPoints.length < reportedVideoCount) { logActivity(`No spinner found, but not all videos loaded. Retrying...`); await new Promise(r => setTimeout(r, 1000)); } else if (!spinner) { break; // Exit if no spinner and no new videos } } } initialVideoCount = allDragPoints.length; logActivity(initialVideoCount + " videos loaded for sorting."); if (scrollRetryCount >= 10) { logActivity("Max scroll attempts reached. Proceeding with available videos."); } let loadedLocation = document.scrollingElement.scrollTop; scrollRetryCount = 0; // Sort videos const sortStartTime = Date.now(); const maxSortTime = 900000; // 15 minutes max // Check timeout if (Date.now() - sortStartTime > maxSortTime) { logActivity("Sorting timed out after 15 minutes."); return; } // Stall detection: break if no progress after multiple cycles let lastSortedCount = -1; let stallCount = 0; while (sortedCount < initialVideoCount && stopSort === false) { if (sortedCount === lastSortedCount) { stallCount++; } else { stallCount = 0; lastSortedCount = sortedCount; } if (stallCount >= 3) { logActivity('No further progress; ending sort to avoid hang'); break; } sortFailureCount = 0; // Check timeout if (Date.now() - sortStartTime > maxSortTime) { logActivity("Sorting timed out after 15 minutes."); return; } // Reset after recovery failures if (consecutiveRecoveryFailures >= 3) { logActivity("Too many failures. Reattempting..."); await new Promise(r => setTimeout(r, 1500)); consecutiveRecoveryFailures = 0; } allDragPoints = playlistContainer ? playlistContainer.querySelectorAll("yt-icon#reorder") : []; allAnchors = playlistContainer ? playlistContainer.querySelectorAll("div#content a#thumbnail.inline-block.ytd-thumbnail") : []; scrollRetryCount = 0; // Ensure durations loaded (up to 3 auto-scroll attempts) let detailRetries = 0; while (!allAnchors[initialVideoCount - 1]?.querySelector("#text") && !stopSort && detailRetries < 3) { logActivity(`Loading video details... attempt ${detailRetries + 1}`); await autoScroll(); // Refresh references allDragPoints = playlistContainer ? playlistContainer.querySelectorAll("yt-icon#reorder") : []; allAnchors = playlistContainer ? playlistContainer.querySelectorAll("div#content a#thumbnail.inline-block.ytd-thumbnail") : []; detailRetries++; } if (detailRetries >= 3) { logActivity("Proceeding without full duration details..."); // Update expected count to actual loaded elements to avoid sort blocking initialVideoCount = allAnchors.length; allDragPoints = playlistContainer.querySelectorAll("yt-icon#reorder"); } // Sort if elements available if (allAnchors.length > 0 && allDragPoints.length > 0) { // Perform sorting; negative indicates missing durations const res = await sortVideos(allAnchors, allDragPoints, initialVideoCount); if (res < 0) { sortFailureCount++; if (sortFailureCount >= 3) { logActivity('Unable to load durations after multiple attempts; aborting sort'); sortedCount = initialVideoCount; break; } logActivity(`Retrying due to missing data (${sortFailureCount}/3)...`); await autoScroll(); await new Promise(r => setTimeout(r, 1000)); continue; } // Successful move sortedCount = res; consecutiveRecoveryFailures = 0; } else { logActivity("No video elements. Waiting..."); await new Promise(r => setTimeout(r, 1500)); consecutiveRecoveryFailures++; } } // Final status if (stopSort === true) { logActivity("Sorting canceled ⛔"); stopSort = false; } else { logActivity(`Sorting complete ✓ (${sortedCount} videos)`); // Scroll to top to ensure the completion message is visible document.scrollingElement.scrollTo({ top: 0, behavior: 'smooth' }); } }; // Initialize UI let init = () => { onElementReady('ytd-playlist-video-list-renderer', false, () => { // Avoid duplicate if (document.querySelector('.sort-playlist')) return; autoScrollInitialVideoList = true; useAdaptiveDelay = true; addCssStyle(); renderContainerElement(); renderToggleButton(); renderSelectElement(0, modeAvailable, 'Sort Order'); renderLogElement(); const checkInterval = setInterval(() => { if (isYouTubePageReady()) { logActivity('✓ Ready to sort'); clearInterval(checkInterval); } }, 1000); }); }; // Initialize script (() => { init(); // Re-init UI on in-app navigation (guard for browsers without navigation API) if (window.navigation && typeof navigation.addEventListener === 'function') { navigation.addEventListener('navigate', () => { setTimeout(() => { if (!document.querySelector('.sort-playlist')) init(); }, 500); }); } })();