Last active
August 24, 2026 09:37
-
-
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.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