Skip to content

Instantly share code, notes, and snippets.

@vogler
Last active April 17, 2025 12:12
Show Gist options
  • Save vogler/451aa48d0af7b659e391fdbeeea0d9d8 to your computer and use it in GitHub Desktop.
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.11
// @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 none
// @noframes
// ==/UserScript==
'use strict';
// 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,
};
const setTitle = timeLeftSeconds => {
let timeLeft = formatDuration(timeLeftSeconds);
// console.log('youtube-time-left:', 'timeLeft:', timeLeft);
if (timeLeftSeconds < 10) timeLeft = formatDuration(video.duration) + opt.watchedMark;
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}`;
};
(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 = document.querySelector('#movie_player video'); // video element
// console.log('video:', 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 = v.playbackRate; // this is only the initial rate, could update this below, but not currently used in title
console.log('youtube-time-left:', video);
const update = e => {
if (document.location.pathname != '/watch') return;
let timeLeft = (v.duration || video.duration) - v.currentTime;
if (opt.withRate) timeLeft /= v.playbackRate;
setTitle(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
})).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
// o.disconnect(); // doing this breaks title update for tabs opened in background
}).observe(document.querySelector('title'), { childList: true }); // detects when title is changed
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment