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