Last active
April 17, 2025 12:12
-
-
Save vogler/451aa48d0af7b659e391fdbeeea0d9d8 to your computer and use it in GitHub Desktop.
Tampermonkey: YouTube: show time left in title
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// ==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