YouTubeSortByDuration

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