OutOfCycle

Fix and optimize YouTube playlist shuffle behavior

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey, Greasemonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Violentmonkey.

Voor het installeren van scripts heb je een extensie nodig, zoals Tampermonkey of Userscripts.

Voor het installeren van scripts heb je een extensie nodig, zoals {tampermonkey_link:Tampermonkey}.

Voor het installeren van scripts heb je een gebruikersscriptbeheerder nodig.

(Ik heb al een user script manager, laat me het downloaden!)

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een extensie nodig, zoals {stylus_link:Stylus}.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

Voor het installeren van gebruikersstijlen heb je een gebruikersstijlbeheerder nodig.

(Ik heb al een beheerder - laat me doorgaan met de installatie!)

// ==UserScript==
// @name         OutOfCycle
// @namespace    https://github.com/RMT120430/RMTStation_OutOfCycle
// @version      3.5.0
// @description  Fix and optimize YouTube playlist shuffle behavior
// @author       RMT120430
// @license      MIT
// @homepageURL  https://github.com/RMT120430/RMTStation_OutOfCycle
// @icon         https://github.com/RMT120430/RMTStation_OutOfCycle/blob/main/icon128.png?raw=true
// @match        https://*.youtube.com/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  'use strict';

  const STORAGE_KEY = 'OutOfCycle_v3.5.0';
  const NEAR_END_THRESHOLD = 1.5;
  const DEBUG = false;
  const SCROLL_INTERVAL_MS = 1000;
  const MAX_QUEUE = 10000;
  const SCROLL_STABLE_REQUIRED = 4;

  let state = {
    active: false,
    playlistId: null,
    queue: [],
    currentIndex: 0,
  };

  let lastUrl = location.href;

  const sleep = ms => new Promise(r => setTimeout(r, ms));

  function getParam(key) {
    return new URLSearchParams(location.search).get(key);
  }

  function isPlaylistPage() {
    return !!getParam('list') && !!getParam('v');
  }

  function isStandalonePlaylistPage() {
    return location.pathname === '/playlist' && !!getParam('list');
  }

  function log(...args) {
    if (DEBUG) console.log('[YTTRUESHUFFLE]  yttrueshuffle.user.js:45 - Out_of_Cycle.js:49', ...args);
  }

  function fisherYates(arr) {
    const a = [...arr];
    for (let i = a.length - 1; i > 0; i--) {
      const j = Math.floor(Math.random() * (i + 1));
      [a[i], a[j]] = [a[j], a[i]];
    }
    return a;
  }

  function saveState() {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
    } catch (e) {}
  }

  function loadState() {
    try {
      const saved = JSON.parse(localStorage.getItem(STORAGE_KEY));
      const currentListId = getParam('list');
      if (
        saved &&
        typeof saved.active === 'boolean' &&
        typeof saved.currentIndex === 'number' &&
        Array.isArray(saved.queue) &&
        saved.queue.every(v => typeof v === 'string') &&
        typeof saved.playlistId === 'string' &&
        saved.playlistId === currentListId
      ) {
        state = saved;
        return true;
      }
    } catch (e) {}
    return false;
  }

  function getExpectedVideoCount() {
    const elements = document.querySelectorAll('.ytContentMetadataViewModelMetadataText');
    for (const el of elements) {
      const match = (el.textContent || '').match(/([\d,]+)\s*(?:部影片|videos?)/i);
      if (match) return parseInt(match[1].replace(/,/g, ''), 10);
    }
    return null;
  }

  // ─── /playlist page: bounce scroll to load all items ────────────────────────
  async function scrollToLoadAllFromPlaylistPage() {
    setStatus('Preparing to load playlist...');
    log('Bounce-scrolling to load all playlist items');

  let stableCount = 0;
    const start = Date.now();
    const MAX_WAIT_TIME = 120000;
    const expectedCount = getExpectedVideoCount();

    while (stableCount < SCROLL_STABLE_REQUIRED && Date.now() - start < MAX_WAIT_TIME) {
      const countBeforeBounce = document.querySelectorAll('ytd-playlist-video-list-renderer ytd-playlist-video-renderer').length;
      for (let i = 0; i < 5; i++) {
       const items = document.querySelectorAll('ytd-playlist-video-list-renderer ytd-playlist-video-renderer');
        if (items.length > 0) {
          items[items.length - 1].scrollIntoView({ behavior: 'smooth', block: 'center' });
        } else {
          window.scrollTo(0, document.documentElement.scrollHeight);
        }
        await sleep(500);
        window.scrollBy({ top: -500, behavior: 'smooth' });
        await sleep(600);
      }

    // Final scroll to bottom after bounce cycles
      const latest = document.querySelectorAll('ytd-playlist-video-list-renderer ytd-playlist-video-renderer');
      if (latest.length > 0) {
        latest[latest.length - 1].scrollIntoView({ behavior: 'smooth', block: 'center' });
      }
      await sleep(SCROLL_INTERVAL_MS);

      const items = document.querySelectorAll('ytd-playlist-video-list-renderer ytd-playlist-video-renderer');
      const currentCount = items.length;

      if (expectedCount && currentCount >= expectedCount) {
        log(`Reached expected video count: ${currentCount} / ${expectedCount}`);
        break; 
      }

      if (currentCount > 0) {
        const lastItem = items[currentCount - 1];
        const indexEl = lastItem.querySelector('yt-formatted-string#index');
        const indexText = indexEl ? indexEl.textContent.trim() : '';

        if (/^\d+$/.test(indexText)) {
          const continuation = lastItem.parentElement.querySelector('ytd-continuation-item-renderer');
          if (!continuation || continuation.hasAttribute('hidden')) {
            log(`Structurally reached end of playlist. Last item index: ${indexText}`);
            break;
          }
        }
      }

      if (currentCount > 0 && currentCount === countBeforeBounce) {
        stableCount++;
      } else {
        stableCount = 0;
        setStatus(`Scrolling... ${currentCount}${expectedCount ? ` / ${expectedCount}` : ''} videos`);
      }
    }

  window.scrollTo(0, 0);
    const finalCount = document.querySelectorAll('ytd-playlist-video-list-renderer ytd-playlist-video-renderer').length;
    log('Playlist page fully loaded. Total items:', finalCount);
  }

  // ─── /playlist page: collect videoIds ────────────────────────────────────────
  function collectVideoIdsFromPlaylistPage() {
    const seen = new Set();
    const ids =[];
    document.querySelectorAll(
      'ytd-playlist-video-list-renderer ytd-playlist-video-renderer a#video-title[href*="watch?v="]'
    ).forEach(a => {
      try {
        const url = new URL(a.href, location.origin);
        const v = url.searchParams.get('v');
        if (v && !seen.has(v)) { seen.add(v); ids.push(v); }
      } catch (e) {}
    });
    if (ids.length > MAX_QUEUE) ids.length = MAX_QUEUE;
    log(`Found ${ids.length} video IDs from playlist page`);
    return ids;
  }

  // ─── Navigation ───────────────────────────────────────────────────────────────
  function clickPlaylistItem(videoId) {
    const anchors = document.querySelectorAll(
      'ytd-playlist-panel-video-renderer a[href*="watch?v="]'
    );
    for (const a of anchors) {
      try {
        const url = new URL(a.href, location.origin);
        if (url.searchParams.get('v') === videoId) {
          log('Clicking playlist item for:', videoId);
          a.click();
          return true;
        }
      } catch (e) {}
    }
    log('Playlist item not visible, navigating directly');
    const params = new URLSearchParams(location.search);
    params.set('v', videoId);
    location.assign('/watch?' + params.toString());
    return false;
  }

  // ─── Core ─────────────────────────────────────────────────────────────────────
  function playNext() {
    if (!state.active || state.queue.length === 0) return;
    state.currentIndex = (state.currentIndex + 1) % state.queue.length;
    if (state.currentIndex === 0) {
      log('Full cycle complete — reshuffling');
      state.queue = fisherYates(state.queue);
    }
    saveState();
    updateUI();
    clickPlaylistItem(state.queue[state.currentIndex]);
  }

    function playPrevious() {
    if (!state.active || state.queue.length === 0) return;
    state.currentIndex = (state.currentIndex - 1 + state.queue.length) % state.queue.length;
    saveState();
    updateUI();
    clickPlaylistItem(state.queue[state.currentIndex]);
  }

  function attachVideoListeners() {
    const video = document.querySelector('video');
    if (!video || video._tsAttached) return;
    video._tsAttached = true;

    video.addEventListener('timeupdate', () => {
      if (!state.active) return;
      const remaining = video.duration - video.currentTime;
      if (remaining > 0 && remaining <= NEAR_END_THRESHOLD && !video._tsTriggered) {
        video._tsTriggered = true;
        log('Near end — triggering next');
        playNext();
      }
    });

    video.addEventListener('ended', () => {
      if (!state.active || video._tsTriggered) return;
      video._tsTriggered = false;
      playNext();
    });

    video.addEventListener('loadstart', () => {
      video._tsTriggered = false;
    });

    log('Video listeners attached');
  }

  async function startShuffle() {
    if (state.active) return;

    if (!isStandalonePlaylistPage()) {
      const listId = getParam('list');
      if (listId) location.assign(`/playlist?list=${listId}`);
      return;
    }

    state.active = true;
    setStatus('Loading playlist...');

    await scrollToLoadAllFromPlaylistPage();
    const ids = collectVideoIdsFromPlaylistPage();

    if (ids.length === 0) {
      setStatus('❌ No videos found. Please expand the playlist.');
      state.active = false;
      return;
    }

    state.queue = fisherYates(ids);
    state.playlistId = getParam('list');
    saveState();
    location.assign(`/watch?v=${state.queue[0]}&list=${state.playlistId}`);
  }

  // ─── UI ───────────────────────────────────────────────────────────────────────
  let ui = null;

  function createUI() {
    if (document.getElementById('yts-panel')) return;

    if (!document.getElementById('yts-style')) {
      const style = document.createElement('style');
      style.id = 'yts-style';
      style.textContent = `
#yts-panel {
  position: fixed; bottom: 88px; right: 24px; z-index: 99999;
  background: #EEEFE0; border: 1.5px solid #92A7CE; border-radius: 0px;
  padding: 20px 22px; min-width: 320px;
  font-family: 'Courier New', 'Segoe UI', monospace;
  font-size: 16px; color: #73708E;
  box-shadow: 0 8px 24px rgba(115,112,142,.18);
}
#yts-panel.collapsed { min-width: auto; padding: 14px; }
#yts-header {
  display: flex; align-items: center; justify-content: space-between;
  font-size: 18px; font-weight: bold; color: #7780A6; cursor: pointer;
}
#yts-status { margin-top: 10px; margin-bottom: 14px; font-size: 14px; color: #677D6A; }
#yts-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; padding-top: 0px; padding-bottom: 14px; }
#yts-panel button {
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px; border-radius: 0px; border: 1px solid #B6C1D7;
  background: #DEE1D8; color: #73708E; font-size: 15px; cursor: pointer;
}
#yts-panel button:hover { background: #D1D8BE; border-color: #7780A6; color: #437057; }
.yts-hidden { display: none !important; }
.yts-icon { width: 18px; height: 18px; fill: currentColor; }
      `;
      document.head.appendChild(style);
    }

    ui = document.createElement('div');
    ui.id = 'yts-panel';

    function createIcon(pathData) {
      const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
      svg.setAttribute('class', 'yts-icon');
      svg.setAttribute('viewBox', '0 0 24 24');
      const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
      path.setAttribute('d', pathData);
      svg.appendChild(path);
      return svg;
    }

    const header = document.createElement('div');
    header.id = 'yts-header';
    const headerTitle = document.createElement('span');
    headerTitle.textContent = 'Out Of Cycle';
    header.appendChild(headerTitle);
    header.appendChild(createIcon('M7 10l5 5 5-5z'));

    const status = document.createElement('div');
    status.id = 'yts-status';
    status.textContent = 'Idle';

    const btnStart = document.createElement('button');
    btnStart.id = 'yts-start';
    btnStart.appendChild(createIcon('M8 5v14l11-7z'));
    btnStart.appendChild(document.createTextNode(' START'));

    const actions = document.createElement('div');
    actions.id = 'yts-actions';
    actions.style.display = 'none';

    const btnPrev = document.createElement('button');
    btnPrev.id = 'yts-prev';
    btnPrev.appendChild(createIcon('M6 6h2v12H6zm3.5 6l8.5 6V6z'));
    btnPrev.appendChild(document.createTextNode(' PREV'));

    const btnNext = document.createElement('button');
    btnNext.id = 'yts-next';
    btnNext.appendChild(createIcon('M6 5v14l8-7zM14 5v14h2V5z'));
    btnNext.appendChild(document.createTextNode(' NEXT'));

    const btnStop = document.createElement('button');
    btnStop.id = 'yts-stop';
    btnStop.appendChild(createIcon('M6 6h12v12H6z'));
    btnStop.appendChild(document.createTextNode(' STOP'));

    const btnReshuffle = document.createElement('button');
    btnReshuffle.id = 'yts-reshuffle';
    btnReshuffle.appendChild(createIcon('M10.59 9.17L5.41 4 4 5.41l5.17 5.17 1.42-1.41zM14.5 4l2.04 2.04L4 18.59 5.41 20 17.96 7.46 20 9.5V4h-5.5zm.33 9.41l-1.41 1.41 3.13 3.13L14.5 20H20v-5.5l-2.04 2.04-3.13-3.13z'));
    btnReshuffle.appendChild(document.createTextNode(' RESHUFFLE'));

    let collapsed = false;
    header.onclick = () => {
      collapsed = !collapsed;
      ui.classList.toggle('collapsed', collapsed);
      status.classList.toggle('yts-hidden', collapsed);
      btnStart.classList.toggle('yts-hidden', collapsed);
      actions.classList.toggle('yts-hidden', collapsed);
    };

    actions.appendChild(btnPrev);
    actions.appendChild(btnNext);
    actions.appendChild(btnStop);
    actions.appendChild(btnReshuffle);

    ui.appendChild(header);
    ui.appendChild(status);
    ui.appendChild(btnStart);
    ui.appendChild(actions);
    document.body.appendChild(ui);

    btnStart.onclick = async () => {
      btnStart.style.display = 'none';
      await startShuffle();
      if (state.active) {
        actions.style.display = 'grid';
      } else {
        btnStart.style.display = 'block';
      }
    };

    btnPrev.onclick = playPrevious;
    btnNext.onclick = playNext;

    btnStop.onclick = () => {
      state.active = false;
      actions.style.display = 'none';
      btnStart.style.display = 'block';
      setStatus('Stopped');
    };

    btnReshuffle.onclick = () => {
      if (state.queue.length === 0) return;
      state.queue = fisherYates(state.queue);
      state.currentIndex = 0;
      saveState();
      setStatus(`Reshuffled — ${state.queue.length} tracks`);
      clickPlaylistItem(state.queue[0]);
    };
  }

  function setStatus(msg) {
    const el = document.getElementById('yts-status');
    if (el) el.textContent = msg;
  }

  function updateUI() {
    if (!ui || state.queue.length === 0) return;
    setStatus(`Track ${state.currentIndex + 1} / ${state.queue.length}`);
  }

  // ─── Page lifecycle ───────────────────────────────────────────────────────────
  function onNavigate() {
    if (!isPlaylistPage()) return;

    setTimeout(() => attachVideoListeners(), 1500);

    if (state.active && state.queue.length > 0) {
      const currentV = getParam('v');
      const idx = state.queue.indexOf(currentV);
      if (idx >= 0 && idx !== state.currentIndex) {
        state.currentIndex = idx;
        saveState();
      }
      updateUI();
    }
  }

  function init() {
    if (!isPlaylistPage() && !isStandalonePlaylistPage()) return;

    setTimeout(() => {
      createUI();
      attachVideoListeners();

      if (loadState()) {
        state.active = true;
        const startBtn = document.getElementById('yts-start');
        const actionsEl = document.getElementById('yts-actions');
        if (startBtn) startBtn.style.display = 'none';
        if (actionsEl) actionsEl.style.display = 'grid';
        updateUI();
        setStatus(`↩ Resumed: ${state.currentIndex + 1} / ${state.queue.length}`);
        log('Session restored');
      }
    }, 2000);
  }

  window.addEventListener('yt-navigate-finish', () => {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      onNavigate();
    }
    if ((isPlaylistPage() || isStandalonePlaylistPage()) && !document.getElementById('yts-panel')) {
      init();
    }
  });

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init);
  } else {
    init();
  }

})();