YouTubeSortByDuration

Supercharges your playlist management by sorting videos by duration.

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);
        });
    }
})();