YouTube No Autoplay

Simple autoplay disabler that survives YouTube navigation

// ==UserScript==
// @name         YouTube No Autoplay
// @version      1.1
// @description  Simple autoplay disabler that survives YouTube navigation
// @author       enterdot
// @match        https://www.youtube.com/*
// @match        https://youtube.com/*
// @run-at       document-start
// @grant        none
// @namespace    youtube-no-autoplay
// ==/UserScript==

(function() {
  'use strict';

  window.YouTubeNoAutoplay = {

    // Config variables
    DEBUG: true,
    ENGAGEMENT_THRESHOLD: 5,
    RETRY_DELAY: 500,
    TRANSITION_DELAY: 500,
    MAX_RETRIES: 10,

    // State variables
    observer: null,
    lastUrl: '',
    autoplayProcessed: false,
    retryCount: 0,
    userIsEngaged: false,
    engagementWatcher: null,
    isInitialized: false,

    log: function(message) {
      if (this.DEBUG) {
        console.log(`[YouTube No Autoplay]: ${message}`);
      }
    },

    parseUrlTimeSkip: function(url) {
      // Extract time skip from URL like &t=10s or &t=1m30s
      const match = url.match(/[&?]t=([0-9]+(?:h[0-9]*)?(?:m[0-9]*)?(?:s)?|[0-9]+)/);
      if (!match) return 0;

      const timeStr = match[1];

      // Handle formats like "90" (just seconds), "1m30s", "1h2m30s"
      if (/^\d+$/.test(timeStr)) {
        return parseInt(timeStr);
      }

      let totalSeconds = 0;
      const hours = timeStr.match(/(\d+)h/);
      const minutes = timeStr.match(/(\d+)m/);
      const seconds = timeStr.match(/(\d+)s/);

      if (hours) totalSeconds += parseInt(hours[1]) * 3600;
      if (minutes) totalSeconds += parseInt(minutes[1]) * 60;
      if (seconds) totalSeconds += parseInt(seconds[1]);

      return totalSeconds;
    },

    setupEngagementWatcher: function() {
      const video = document.querySelector('video');

      if (!video) {
        this.log('Video element not found for engagement tracking.');
        setTimeout(() => this.setupEngagementWatcher(), 1000);
        return;
      }

      if (this.engagementWatcher) {
        video.removeEventListener('timeupdate', this.engagementWatcher);
      }

      // Calculate adjusted threshold based on URL time skip
      const urlTimeSkip = this.parseUrlTimeSkip(window.location.href);
      const adjustedThreshold = this.ENGAGEMENT_THRESHOLD + urlTimeSkip;

      const self = this;
      this.engagementWatcher = function() {
        if (video.currentTime > adjustedThreshold && !self.userIsEngaged) {
          self.userIsEngaged = true;
          self.log(`User engagement threshold reached. ${video.currentTime.toFixed(1)}s > ${adjustedThreshold}s (${self.ENGAGEMENT_THRESHOLD}s + ${urlTimeSkip}s), allowing autoplay.`);

          video.removeEventListener('timeupdate', self.engagementWatcher);
          self.engagementWatcher = null;
        }
      };

      video.addEventListener('timeupdate', this.engagementWatcher);
      this.log(`Engagement tracker set up, threshold is ${adjustedThreshold}s (${this.ENGAGEMENT_THRESHOLD}s + ${urlTimeSkip}s).`);
    },

    processAutoplay: function() {

      if (this.userIsEngaged) {
        this.log(`User is engaged, skipping automatic disabling of autoplay.`);
        return false;
      }

      const autoplayElement = document.querySelector('.ytp-autonav-toggle-button');

      if (!autoplayElement) {
        if (this.retryCount < this.MAX_RETRIES) {
          this.retryCount++;
          this.log(`Autoplay element not found, retrying... (${this.retryCount}/${this.MAX_RETRIES})`);
          setTimeout(() => this.processAutoplay(), this.RETRY_DELAY);
          return false;
        } else {
          this.log(`Autoplay element not found after ${this.MAX_RETRIES} retries.`);
          return false;
        }
      }

      this.retryCount = 0;

      const isEnabled = autoplayElement.getAttribute('aria-checked') === 'true';

      if (isEnabled) {
        this.log('Disabling autoplay...');
        try {
          autoplayElement.click();
          this.log('Autoplay state toggled.');
          this.autoplayProcessed = true;
          return true;
        } catch (error) {
          this.log(`Error toggling autoplay, ${error.message}.`);
          return false;
        }
      } else {
        this.log('Autoplay already disabled, no action needed.');
        this.autoplayProcessed = true;
        return false;
      }
    },

    handleNavigation: function() {
      const currentUrl = window.location.href;

      if (currentUrl !== this.lastUrl && currentUrl.includes('/watch')) {
        this.log(`Navigation detected, new URL is ${currentUrl}.`);
        this.lastUrl = currentUrl;

        // Reset state for new URL
        this.userIsEngaged = false;
        this.autoplayProcessed = false;
        this.retryCount = 0;

        setTimeout(() => this.setupEngagementWatcher(), 500);
        setTimeout(() => this.processAutoplay(), 1500);
      }
    },

    setupObserver: function() {
      if (this.observer) {
        this.observer.disconnect();
      }

      // Capture reference for use in observer
      const self = this;

      this.observer = new MutationObserver((mutations) => {
        let needsProcessing = false;

        mutations.forEach((mutation) => {
          if (mutation.type === 'attributes' &&
            mutation.target.classList.contains('ytp-autonav-toggle-button') &&
            mutation.attributeName === 'aria-checked') { // Autoplay toggle state changes

            const newValue = mutation.target.getAttribute('aria-checked');
            self.log(`Observed autoplay state change to ${newValue}.`);

            if (newValue === 'true') {
              needsProcessing = true;

              if (self.autoplayProcessed) {
                self.log('Autoplay re-enabled after processing, re-processing...');
                self.autoplayProcessed = false;
              }
            }
          } else if (mutation.type === 'childList') { // Autoplay elements additions
            const addedNodes = Array.from(mutation.addedNodes);
            const hasAutoplayElement = addedNodes.some(node =>
              node.nodeType === 1 && (
                (node.classList && node.classList.contains('ytp-autonav-toggle-button')) ||
                (node.querySelector && node.querySelector('.ytp-autonav-toggle-button'))
              )
            );

            if (hasAutoplayElement && !self.autoplayProcessed) {
              needsProcessing = true;
              self.log('Autoplay element added to DOM.');
            }
          }
        });

        if (needsProcessing) {
          // Delay for state changes to handle rapid transitions
          setTimeout(() => self.processAutoplay(), self.TRANSITION_DELAY);
        }
      });

      this.observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['aria-checked']
      });

      this.log('Observer set up completed.');
    },

    initialize: function() {
      if (this.isInitialized) {
        this.log('Already initialized, no action needed.');
        return;
      }

      this.log('Initializing...');

      const self = this;
      const init = () => {
        self.setupObserver();
        self.handleNavigation(); // Process current page

        // Hook into navigation events
        const originalPushState = history.pushState;
        const originalReplaceState = history.replaceState;

        history.pushState = function() {
          originalPushState.apply(history, arguments);
          setTimeout(() => self.handleNavigation(), 100);
        };

        history.replaceState = function() {
          originalReplaceState.apply(history, arguments);
          setTimeout(() => self.handleNavigation(), 100);
        };

        window.addEventListener('popstate', () => self.handleNavigation());

        self.isInitialized = true;
        self.log('Initialization was completed successfully.');
      };

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

    // Test function for debugging
    test: function() {
      console.log('=== YouTube No Autoplay Test ===');
      console.log('Script initialized:', this.isInitialized);
      console.log('Current URL:', window.location.href);
      console.log('URL time skip:', this.parseUrlTimeSkip(window.location.href), 'seconds');
      console.log('Base engagement threshold:', this.ENGAGEMENT_THRESHOLD, 'seconds');
      console.log('Adjusted threshold:', this.ENGAGEMENT_THRESHOLD + this.parseUrlTimeSkip(window.location.href), 'seconds');
      console.log('User engaged:', this.userIsEngaged);

      const video = document.querySelector('video');
      console.log('Video element found:', !!video);
      if (video) {
        console.log('Video current time:', video.currentTime.toFixed(1) + 's');
      }

      const autoplayElement = document.querySelector('.ytp-autonav-toggle-button');
      console.log('Autoplay element found:', !!autoplayElement);
      if (autoplayElement) {
        console.log('Autoplay enabled:', autoplayElement.getAttribute('aria-checked') === 'true');
      }

      console.log('=== End Test ===');
    }
  };

  window.testYouTubeNoAutoplay = () => window.YouTubeNoAutoplay.test();

  window.YouTubeNoAutoplay.initialize();

})();