Skip to content

Instantly share code, notes, and snippets.

@vogler
Last active August 24, 2026 09:37
Show Gist options
  • Select an option

  • Save vogler/451aa48d0af7b659e391fdbeeea0d9d8 to your computer and use it in GitHub Desktop.

Select an option

Save vogler/451aa48d0af7b659e391fdbeeea0d9d8 to your computer and use it in GitHub Desktop.
Tampermonkey: YouTube: show time left in title
// ==UserScript==
// @name YouTube: show time left and channel in title
// @namespace https://gist.github.com/vogler
// @downloadURL https://gist.github.com/vogler/451aa48d0af7b659e391fdbeeea0d9d8/raw/youtube-time-left.tamper.js
// @version 0.12
// @description YouTube: show time left and channel in title
// @author Ralf Vogler
// @ match https://www.youtube.com/watch?v=* // this will not work if you open youtube.com and then click on a video since it is a SPA
// @match https://www.youtube.com/*
// @grant window.onurlchange
// @noframes
// ==/UserScript==
'use strict';
// TODO check out gist into git repo and diff with this since I added some TODOs and started debugging some bugs/performance at h1/h2 below
// options
const opt = {
onLoad: true, // false: only update title after video starts playing
// position: 'start', // add to 'start' | 'end' of document.title -> just use start since it makes more sense
withRate: true, // true: divide time left by playback speed, false: ignore playback speed
stripNotifications: true, // remove leading number of notifications ('(123) ') from title
watchedMark: ' ✔︎', // when < 10s left, show original duration + watchedMark
withChannel: true,
}
const parseDuration = str => str.split(':').toReversed().reduce((a,x,i) => a + parseInt(x) * 60**i, 0); // to seconds
const formatDuration = seconds => new Date(1000 * seconds).toISOString().substr(11, 8).replace(/^[0:]+/, "");
const durationRegex = `(\\d{1,2}:)*\\d{1,2}(${opt.watchedMark})?`; // regex as string used below -> need to escape \
const durationTitleRegex = new RegExp(`^${durationRegex} `);
// meta-data for current video
const video = {
title: document.title, // original title; without, if we open in background tab, document.title == 'YouTube' on first setTitle
title2: undefined, // last title with duration we set via setTitle; used to undo YouTube's reset of title when leaving tab
channel: {
name: undefined,
slug: undefined,
},
duration: undefined,
playbackRate: undefined,
element: undefined,
};
const setTitle = timeLeftSeconds => {
let timeLeft = formatDuration(timeLeftSeconds);
// console.log('youtube-time-left:', 'timeLeft:', timeLeft);
if (timeLeftSeconds < 10) timeLeft = formatDuration(video.duration) + opt.watchedMark; // -> 'duration ✔︎'
// TODO 'timePlayed ✔︎' (sum of v.played/speed(at the time)) (maybe easier to just sum up timeWacthed in update if !v.paused)
// https://stackoverflow.com/questions/6877403/how-to-tell-if-a-video-element-is-currently-playing
let title = video.title.replace(durationTitleRegex, ''); // strip timeLeft: simpler and more reliable alternative to urlchange listener (requires @grant window.onurlchange) + MutationObserver on title
if (opt.stripNotifications) title = title.replace(/^\(\d+\) /, '');
if (opt.withChannel) {
title = title.replace(/- YouTube$/, `[${video.channel.name}] - YouTube`); // ' - YouTube' still at end of title
title = `${video.channel.slug}: ${title}`;
}
video.title2 = document.title = `${timeLeft} ${title}`; // -> 'timeLeft channel_slug: title [channel_name] [percent of duration/speed] - YouTube'
};
const setVideo = () => {
const v = document.querySelector('#movie_player video'); // video element
if (!v) return;
video.element = v;
video.channel.name = document.querySelector('#channel-name')?.innerText; // e.g. 'Marques Brownlee'
video.channel.slug = document.querySelector('#channel-name a').getAttribute('href').replace(/^\/@/, ''); // e.g. 'mkbhd'
video.duration = parseDuration(document.querySelector('.ytp-time-duration').innerText);
video.playbackRate = video.element.playbackRate; // this is only the initial rate, could update this below, but not currently used in title
return video.element;
};
(async function() {
// window.addEventListener('focus', e => console.log('window.focus')); // to debug opening page in background tab
// const v = document.querySelector('#movie_player video');
// console.log('video:', v);
// console.log('duration:', v.duration, 'playbackRate:', v.playbackRate);
// duration is not set if page is loaded in background tab, playbackRate may still be 1 if Video Speed Controller extension runs afterwards
// update time left in title
// the surrounding MutationObserver is needed for when navigating from youtube.com to a video instead of opening it in a new tab
(new MutationObserver((mutations, observer) => {
if (document.location.pathname != '/watch') return; // not on a video page
// if (!document.querySelector('#ytd-player')) return; // not enough since setting the title here will revert it to just 'YouTube'
if (!mutations.some(m => m.target.className == 'ytp-large-play-button ytp-button')) return;
// console.log(mutations);
observer.disconnect(); // only need to attach listener once
// if (document.title.match(durationTitleRegex)) return; // we can stop if the title has already been updated before
const v = setVideo();
// console.log('video:', v);
setVideo();
console.log('youtube-time-left:', video);
let timeLeft2; // only setTitle when timeLeft seconds change
const update = e => {
if (document.location.pathname != '/watch') return;
let timeLeft = (v.duration || video.duration) - v.currentTime;
if (opt.withRate) timeLeft /= v.playbackRate;
timeLeft = Math.round(timeLeft);
if (timeLeft != timeLeft2) setTitle(timeLeft);
timeLeft2 = timeLeft;
};
if (opt.onLoad) update();
// v.addEventListener('timeupdate', update, { passive: true }); // getEventListeners($('video')).timeupdate.length was either 3 or 5 (before having observer.disconnect())
v.ontimeupdate = update; // in any case, good to avoid attaching the same listener multiple times
// TODO also update on v.ratechange such that timeLeft updates even when the video is paused
})).observe(document, { subtree: true, childList: true }); // more specific document.querySelector('#page-manager') and #content was not reliable
// update title on soft navigation and undo YouTube's reset of title when leaving tab
new MutationObserver((m, o) => {
// console.log('youtube-time-left:', 'title changed:', document.title, 'from:', video.title2);
if (document.title == 'YouTube') return;
if (document.title.match(durationTitleRegex)) return; // new title already includes duration, i.e., we set it
else if (video.title != document.title) { // update orgiginal title on soft navigation to new video
console.log('youtube-time-left:', 'navigate to new title:', document.title, video.title2);
video.title = document.title;
video.title2 = undefined;
}
if (!video.title2) return; // title has not been updated yet; YouTube already sets the title 4 times before during load...
console.log('youtube-time-left:', 'title changed by YouTube to:', document.title, 'from:', video.title2);
document.title = video.title2; // undo reset of title using value from last setTitle
setVideo(); // TODO too early
// o.disconnect(); // doing this breaks title update for tabs opened in background
}).observe(document.querySelector('title'), { childList: true }); // detects when title is changed
// }).observe(document.querySelector('#title h1'), { childList: true, subtree: true, attributes: true }); // detects when title is changed
console.log('h1');
// // this doesn't fire (both for meta and link)
// new MutationObserver((m, o) => {
// console.log('url changed:', document.location.href);
// // set title and video
// // }).observe(document.querySelector('meta[property="og:url"]'), { subtree: true, childList: true, attributes: true }); // detects when url changes
// // }).observe(document.querySelector('link[rel="canonical"]'), { subtree: true, childList: true, attributes: true }); // detects when url changes
// }).observe(document.head.baseURI, { subtree: true, childList: true, attributes: true }); // detects when url changes // document.location.href
console.log('h2');
if (window.onurlchange === null) {
// feature is supported
window.addEventListener('urlchange', (info) => {
console.log('url changed:', document.location.href);
setVideo(); // TODO too early
});
}
console.log('h3');
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment