OutOfCycle

Fix and optimize YouTube playlist shuffle behavior

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

You will need to install an extension such as Tampermonkey to install this script.

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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

})();