YouTube Time Toggle

Toggles between elapsed time and remaining time with a simple click on the timestamp.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         YouTube Time Toggle
// @namespace    YTTimeToggle
// @version      1.0.1
// @description  Toggles between elapsed time and remaining time with a simple click on the timestamp.
// @author       Farhan Sakib Socrates
// @match        *://www.youtube.com/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // State variable to track whether to show remaining time or elapsed time
    let isShowingRemainingTime = false;
    let isDraggingSeekbar = false; // State variable to track seekbar dragging

    // DOM element references
    let timeDisplaySpan = null; // Our custom span to display time
    let videoElement = null; // The YouTube video element
    let timeDisplayContainer = null; // The main clickable container (.ytp-time-display.notranslate)
    let timeContentsContainer = null; // The specific parent for current/duration time (.ytp-time-contents)
    let timeCurrentElement = null; // YouTube's native current time element (.ytp-time-current)
    let timeSeparatorElement = null; // YouTube's native time separator element (.ytp-time-separator)
    let timeDurationElement = null; // YouTube's native duration element (.ytp-time-duration)
    let progressBar = null; // The YouTube progress bar element (.ytp-progress-bar)

    /**
     * Formats a given number of seconds into M:SS or H:MM:SS format.
     * @param {number} totalSeconds - The total number of seconds to format.
     * @returns {string} The formatted time string.
     */
    function formatTime(totalSeconds) {
        // Ensure seconds are non-negative
        totalSeconds = Math.max(0, totalSeconds);

        const hours = Math.floor(totalSeconds / 3600);
        const minutes = Math.floor((totalSeconds % 3600) / 60);
        const remainingSeconds = Math.floor(totalSeconds % 60);

        let formatted = '';

        if (hours > 0) {
            formatted += hours + ':';
            formatted += (minutes < 10 ? '0' : '') + minutes + ':';
        } else {
            formatted += minutes + ':';
        }
        formatted += (remainingSeconds < 10 ? '0' : '') + remainingSeconds;

        return formatted;
    }

    /**
     * Updates the text content of the custom time display span.
     * It calculates the time to display based on the current mode (elapsed or remaining).
     * @param {number} [manualTime] - Optional: A specific time in seconds to display,
     * used during seekbar dragging. If not provided,
     * videoElement.currentTime is used.
     */
    function updateTimeDisplay(manualTime = null) {
        if (!videoElement || !timeDisplaySpan) {
            // If essential elements are not available, show a placeholder if the span exists
            if (timeDisplaySpan) {
                timeDisplaySpan.textContent = '--:-- / --:--';
            }
            return;
        }

        // Use manualTime if provided (during drag), otherwise use actual video currentTime
        let currentTime = (manualTime !== null) ? manualTime : videoElement.currentTime;
        let duration = videoElement.duration;

        // Defensive checks for NaN values to prevent "NaN / NaN" display
        if (isNaN(currentTime)) {
            currentTime = 0;
        }
        // If duration is NaN or 0, it means video metadata might not be fully loaded or it's a live stream without a known end.
        // In such cases, we can't calculate remaining time reliably.
        if (isNaN(duration) || duration === 0) {
            // If duration is unknown, display elapsed time only or a placeholder for total
            timeDisplaySpan.textContent = formatTime(currentTime) + ' / --:--';
            return; // Exit early as remaining time calculation is not possible
        }


        let displayTime;
        let prefix = '';

        if (isShowingRemainingTime) {
            displayTime = duration - currentTime;
            prefix = '-'; // Add a minus sign for remaining time
        } else {
            displayTime = currentTime;
        }

        // Update the text content of our custom span with the formatted time and include the separator
        timeDisplaySpan.textContent = prefix + formatTime(displayTime) + ' / ';
    }

    /**
     * Handles mousemove event during seekbar dragging to update time in real-time.
     * This function will be attached to the document when dragging starts.
     */
    function handleSeekbarMouseMoveDuringDrag() {
        if (isDraggingSeekbar && progressBar) {
            // Read the aria-valuenow attribute for the current scrub position
            const scrubTime = parseFloat(progressBar.getAttribute('aria-valuenow'));
            if (!isNaN(scrubTime)) {
                updateTimeDisplay(scrubTime); // Pass the scrub time to update the display
            }
        }
    }

    /**
     * Initializes the userscript by finding necessary DOM elements, injecting the overlay,
     * setting up event listeners, and performing the initial time display update.
     * @returns {boolean} True if initialization was successful, false otherwise.
     */
    function initializePlayer() {
        // Find the main video player and time display elements based on the current YouTube structure
        videoElement = document.querySelector('video');
        timeDisplayContainer = document.querySelector('.ytp-time-display.notranslate'); // The main clickable area
        timeContentsContainer = document.querySelector('.ytp-time-display.notranslate .ytp-time-contents');
        timeCurrentElement = document.querySelector('.ytp-time-display.notranslate .ytp-time-current');
        timeSeparatorElement = document.querySelector('.ytp-time-display.notranslate .ytp-time-separator');
        timeDurationElement = document.querySelector('.ytp-time-display.notranslate .ytp-time-duration');
        progressBar = document.querySelector('.ytp-progress-bar'); // Get the progress bar element

        // If any essential element is not found, return false to indicate that the player is not ready
        if (!videoElement || !timeDisplayContainer || !timeContentsContainer || !timeCurrentElement || !timeSeparatorElement || !timeDurationElement || !progressBar) {
            // console.log('YouTube Elapsed/Remaining Time Toggle: Essential elements not found yet.');
            return false;
        }

        // Check if our custom span already exists to prevent re-initialization
        if (timeContentsContainer.querySelector('.yt-custom-time-display')) {
            // console.log('YouTube Elapsed/Remaining Time Toggle: Already initialized.');
            return true; // Already initialized
        }

        // 1. Hide YouTube’s native current-time element and separator
        // Setting display to none ensures they don't take up space in the flex container.
        timeCurrentElement.style.display = 'none';
        timeSeparatorElement.style.display = 'none';

        // 2. Inject our custom <span> into .ytp-time-contents
        timeDisplaySpan = document.createElement('span');
        timeDisplaySpan.className = 'yt-custom-time-display'; // Custom class for easy identification
        // Inherit styling from the parent for seamless integration
        timeDisplaySpan.style.color = 'inherit';
        timeDisplaySpan.style.fontFamily = 'inherit';
        timeDisplaySpan.style.fontSize = 'inherit';
        timeDisplaySpan.style.fontWeight = 'inherit';
        timeDisplaySpan.style.lineHeight = '1'; // Ensure single line height
        timeDisplaySpan.style.whiteSpace = 'nowrap'; // Prevent wrapping of the time string

        // Insert our custom span directly before the duration element within the contents container
        // This makes it flow naturally with the duration element.
        timeContentsContainer.insertBefore(timeDisplaySpan, timeDurationElement);

        // 3. Update the overlay:
        // Normal playback updates
        videoElement.addEventListener('timeupdate', () => {
            // Only update via timeupdate if not currently dragging
            if (!isDraggingSeekbar) {
                updateTimeDisplay();
            }
        });
        // The 'seeked' event is crucial for updating after a seek operation is complete.
        // Introduce a small delay to ensure videoElement.currentTime is stable.
        videoElement.addEventListener('seeked', () => {
            setTimeout(() => {
                updateTimeDisplay(); // Update using videoElement.currentTime after a short delay
            }, 50); // 50ms delay
        });
        videoElement.addEventListener('durationchange', updateTimeDisplay);

        // Real-time update while dragging the seekbar
        progressBar.addEventListener('mousedown', () => {
            isDraggingSeekbar = true;
            // Attach mousemove listener to the document to capture movement anywhere on the page during drag
            document.addEventListener('mousemove', handleSeekbarMouseMoveDuringDrag);
        });

        // Use document for mouseup to catch releases even if mouse leaves the progress bar
        document.addEventListener('mouseup', () => {
            if (isDraggingSeekbar) {
                isDraggingSeekbar = false;
                // Remove the document-level mousemove listener
                document.removeEventListener('mousemove', handleSeekbarMouseMoveDuringDrag);
                // The 'seeked' event listener (with its delay) will handle the final update.
            }
        });


        // 5. Click on the time area toggles the display mode
        // Attach the click listener to the larger timeDisplayContainer.
        timeDisplayContainer.style.cursor = 'pointer'; // Indicate the larger area is clickable
        timeDisplayContainer.addEventListener('click', (event) => {
            // Prevent the click from bubbling up to other elements that might have listeners
            event.stopPropagation();
            isShowingRemainingTime = !isShowingRemainingTime;
            updateTimeDisplay();
        });

        // Initial update
        updateTimeDisplay();

        console.log('YouTube Elapsed/Remaining Time Toggle: Initialized successfully.');
        return true;
    }

    // Main MutationObserver to detect player readiness
    // This observer watches for the presence of the main player controls container.
    const mainObserver = new MutationObserver((mutations, obs) => {
        // Check for a key element that indicates the player controls are loaded
        if (document.querySelector('.ytp-chrome-bottom')) {
            if (initializePlayer()) {
                obs.disconnect(); // Player initialized, no need to observe anymore for initial load
            }
        }
    });

    // Start observing the document body for changes in its children and descendants.
    mainObserver.observe(document.body, { childList: true, subtree: true });

    // Handle SPA navigation (YouTube's internal page changes without full reload)
    // This observer watches for URL changes, which often indicate a new video load.
    let lastUrl = location.href;
    const urlChangeObserver = new MutationObserver(() => {
        if (location.href !== lastUrl) {
            lastUrl = location.href;
            console.log('YouTube Elapsed/Remaining Time Toggle: URL changed, re-initializing.');

            // Clean up any existing custom span from the previous video/page
            const existingSpan = document.querySelector('.yt-custom-time-display');
            if (existingSpan) {
                existingSpan.remove();
            }
            // Reset native time elements' styles in case they were hidden by a previous run
            const nativeCurrentTime = document.querySelector('.ytp-time-current');
            if (nativeCurrentTime) nativeCurrentTime.style.display = '';
            const nativeSeparator = document.querySelector('.ytp-time-separator');
            if (nativeSeparator) nativeSeparator.style.display = '';

            // Re-start the main observer for the new page load
            mainObserver.disconnect();
            mainObserver.observe(document.body, { childList: true, subtree: true });
            // Also try immediate initialization for the new page
            initializePlayer();
        }
    });
    urlChangeObserver.observe(document, { subtree: true, childList: true });

    // Initial check on page load if elements are already present when the script first runs
    if (document.readyState === 'complete' || document.readyState === 'interactive') {
        initializePlayer();
    }

})();