Skip to content

Instantly share code, notes, and snippets.

@angeld23
Last active April 8, 2024 21:47
Show Gist options
  • Save angeld23/bb9fb17775856146a5758cf991b24db5 to your computer and use it in GitHub Desktop.
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)
"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);
})();
@gage64
Copy link

gage64 commented Apr 17, 2023

yuo're queer ( :

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment