Last active
April 8, 2024 21:47
-
-
Save angeld23/bb9fb17775856146a5758cf991b24db5 to your computer and use it in GitHub Desktop.
YouTube Video Persistence Userscript (saves your place so you can come back later)
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
"use strict"; | |
// ==UserScript== | |
// @name YouTube Video Persistence | |
// @namespace http://tampermonkey.net/ | |
// @version 1.4 | |
// @description Makes YouTube videos persistent, saving your place so you can come back later. | |
// @author angeld23 | |
// @match *://*.youtube.com/* | |
// @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com | |
// @grant none | |
// ==/UserScript== | |
const endThresholdSeconds = 30; // if you're at least this close to the end of the video, the saved time resets | |
const leadTimeSeconds = 3; // lead time for progress restoring, e.g. if you left the video 30 seconds in and have a `leadTimeSeconds` of 3, it will be restored to 0:27. | |
const expirationTimeSeconds = 24 * 60 * 60; // expiration time for a video's persistence data. don't make this infinite or saved data will never be cleaned up. | |
/// CHANGELOG /// | |
/* | |
* v1.4 (Jul 12, 2023): | |
* * Fixed persistence data not saving for a video if you came to it directly from the home page | |
* * Migrated to TypeScript (sorry for the JSDoc types being gone lololo) | |
* | |
* v1.3 (May 30, 2023): | |
* + Video pause state now saves, so clicking on previously paused YouTube tabs that got restored won't jumpscare you with the video immedietly playing | |
* * Modified persistence data format | |
* | |
* v1.2 (May 27, 2023): | |
* * Fixed an issue with miniplayer mode | |
* | |
* v1.1 (Apr 20, 2023): | |
* * Fixed video ID fetching when changing pages | |
* * Fixed persistence data conflict with multiple tabs running the script at once | |
* | |
* v1.0 (Apr 16, 2023): | |
* + Initial release | |
*/ | |
////////////////////////////////////////////////////////////// | |
(() => { | |
function log(message) { | |
console.log(`[YT Persistence Userscript] ${message}`); | |
} | |
/** | |
* Calls the provided callback when the document is loaded | |
*/ | |
function onReady(fn) { | |
if (document.readyState != "loading") { | |
fn(); | |
} else { | |
document.addEventListener("DOMContentLoaded", fn); | |
} | |
} | |
/** | |
* Extracts a YouTube video ID from a URL string | |
* @returns The video ID, or undefined if there is none | |
*/ | |
function extractVideoId(url) { | |
const urlObject = new URL(url); | |
const pathname = urlObject.pathname; | |
if (pathname.startsWith("/shorts")) { | |
return pathname.slice(8); | |
} | |
return urlObject.searchParams.get("v") ?? undefined; | |
} | |
/** | |
* Saves the provided Map to localStorage | |
* @param dataMap The data to save | |
* @param localStorageKey The localStorage key to save at | |
*/ | |
function save(dataMap, localStorageKey) { | |
localStorage.setItem(localStorageKey, JSON.stringify(Array.from(dataMap.entries()))); | |
} | |
/** | |
* Maps persistence data versions with functions that transform the data into the latest format | |
*/ | |
const persistenceDataTransformers = { | |
["undefined"]: (unknownOldData) => { | |
// the first version, without a dataVersion property | |
const oldData = unknownOldData; | |
return { | |
currentTimeSeconds: oldData.currentTime, | |
videoDurationSeconds: oldData.videoDuration, | |
savedAtUnixMilliseconds: oldData.lastTimeWatched, | |
isPaused: false, | |
dataVersion: "2", | |
}; | |
}, | |
["1"]: (unknownOldData) => { | |
// the second version, with the word "milliseconds" misspelled which of course calls for a migration | |
const oldData = unknownOldData; | |
return { | |
currentTimeSeconds: oldData.currentTimeSeconds, | |
videoDurationSeconds: oldData.videoDurationSeconds, | |
savedAtUnixMilliseconds: oldData.savedAtUnixMiliseconds, | |
isPaused: oldData.isPaused, | |
dataVersion: "2", | |
}; | |
}, | |
["2"]: (unknownOldData) => { | |
return unknownOldData; | |
}, | |
}; | |
/** | |
* Loads, prunes, and sanitizes VideoPersistenceData from localStorage at a given key. | |
* @param localStorageKey The localStorage key to load from | |
* @returns A Map pairing video IDs with their stored VideoPersistenceData | |
*/ | |
function load(localStorageKey) { | |
const savedVideoProgresses = new Map(JSON.parse(localStorage.getItem(localStorageKey) ?? "[]")); | |
const videoProgresses = new Map(); | |
// Sanitize and prune | |
savedVideoProgresses.forEach((unknownData, videoId) => { | |
const version = String(unknownData.dataVersion); | |
const transformer = persistenceDataTransformers[version]; | |
if (!transformer) { | |
log("NO TRANSFORMER FOR DATA VERSION " + version); | |
return; | |
} | |
const data = transformer(unknownData); | |
// expiration | |
if (Date.now() - data.savedAtUnixMilliseconds > expirationTimeSeconds * 1000) { | |
return; | |
} | |
// end threshold | |
if (data.videoDurationSeconds - data.currentTimeSeconds < endThresholdSeconds) { | |
data.currentTimeSeconds = 0; | |
data.isPaused = false; | |
} | |
videoProgresses.set(videoId, data); | |
}); | |
return videoProgresses; | |
} | |
const storageKey = "ANGELS_SPECIAL_SOUP"; | |
// Video persistence data is stored in (storageKey) + "_" + (the first character of the video ID) to help avoid a large JSON | |
// This wasn't always the case, so we've gotta migrate from the old monolithic key if needed | |
if (localStorage.getItem(storageKey)) { | |
const oldData = load(storageKey); | |
oldData.forEach((data, id) => { | |
const key = storageKey + "_" + id[0]; | |
const newMap = load(key); | |
newMap.set(id, data); | |
save(newMap, key); | |
}); | |
localStorage.removeItem(storageKey); | |
} | |
function getVideoElement() { | |
return document.querySelector("video.html5-main-video") ?? undefined; | |
} | |
function run() { | |
const startPage = location.href; | |
const video = getVideoElement(); | |
const id = extractVideoId(location.href); | |
if (!video) { | |
log("No video element on this page."); | |
// wait for a video element to appear | |
const videoElementCheckIntervalId = setInterval(() => { | |
if (getVideoElement()) { | |
clearInterval(videoElementCheckIntervalId); | |
setTimeout(run, 100); | |
return; | |
} | |
}, 100); | |
return; | |
} | |
if (!id) { | |
log(`No video ID found in ${location.href}.`); | |
// wait for the URL to change | |
const locationChangeIntervalId = setInterval(() => { | |
if (location.href !== startPage) { | |
clearInterval(locationChangeIntervalId); | |
setTimeout(run, 100); | |
return; | |
} | |
}, 100); | |
return; | |
} | |
log(`Handling persistence for video ID ${id}.`); | |
const suffixedKey = storageKey + "_" + id[0]; | |
const data = load(suffixedKey).get(id); | |
// apply persistence data | |
// unless the page already opened with the time automatically set to something (e.g. "t" param in the url or expanding the miniplayer) | |
if (data && video.currentTime < 1) { | |
video.currentTime = data.currentTimeSeconds - leadTimeSeconds; | |
if (data.isPaused) { | |
video.pause(); | |
// youtube automatically unpauses after page finishes loading, so we have to pause again | |
video.muted = true; // temp mute since the video's gonna unpause for a split second | |
video.addEventListener( | |
"play", | |
() => { | |
video.pause(); | |
video.muted = false; | |
}, | |
{ | |
once: true, | |
} | |
); | |
} | |
} | |
// saved data update loop | |
const saveIntervalId = setInterval(() => { | |
// when you click a new video, the userscript doesn't reset because it's not the same as reloading | |
// so handle it manually | |
if (location.href !== startPage) { | |
clearInterval(saveIntervalId); | |
setTimeout(run, 500); | |
return; | |
} | |
const persistenceData = load(suffixedKey); | |
persistenceData.set(id, { | |
currentTimeSeconds: video.currentTime, | |
videoDurationSeconds: video.duration, | |
savedAtUnixMilliseconds: Date.now(), | |
isPaused: video.paused, | |
dataVersion: "2", | |
}); | |
save(persistenceData, suffixedKey); | |
}, 1000); | |
} | |
onReady(run); | |
})(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
yuo're queer ( :