Greasy Fork is available in English.

Youtube Save/Resume Progress

Have you ever closed a YouTube video by accident, or have you gone to another one and when you come back the video starts from 0? With this extension it won't happen anymore

  1. // ==UserScript==
  2. // @license MIT
  3. // @name Youtube Save/Resume Progress
  4. // @namespace http://tampermonkey.net/
  5. // @version 1.5.7
  6. // @description Have you ever closed a YouTube video by accident, or have you gone to another one and when you come back the video starts from 0? With this extension it won't happen anymore
  7. // @author Costin Alexandru Sandu
  8. // @match https://www.youtube.com/watch*
  9. // @icon https://raw.githubusercontent.com/SaurusLex/YoutubeSaveResumeProgress/refs/heads/master/youtube_save_resume_progress_icon.jpg
  10. // @grant none
  11. // ==/UserScript==
  12.  
  13. (function () {
  14. "strict";
  15. var configData = {
  16. sanitizer: null,
  17. savedProgressAlreadySet: false,
  18. savingInterval: 2000,
  19. currentVideoId: null,
  20. lastSaveTime: 0,
  21. dependenciesURLs: {
  22. floatingUiCore: "https://cdn.jsdelivr.net/npm/@floating-ui/core@1.6.0",
  23. floatingUiDom: "https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.6.3",
  24. fontAwesomeIcons:
  25. "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css",
  26. }
  27. };
  28.  
  29. var state = {
  30. floatingUi: {
  31. cleanUpFn: null,
  32. settingsContainer: null,
  33. }
  34. }
  35.  
  36. var FontAwesomeIcons = {
  37. trash: ["fa-solid", "fa-trash-can"],
  38. xmark: ["fa-solid", "fa-xmark"],
  39. };
  40.  
  41. function createIcon(iconName, color) {
  42. const icon = document.createElement("i");
  43. const cssClasses = FontAwesomeIcons[iconName];
  44. icon.classList.add(...cssClasses);
  45. icon.style.color = color;
  46. icon.style.fontSize = "16px";
  47.  
  48. return icon;
  49. }
  50. // ref: https://stackoverflow.com/questions/3733227/javascript-seconds-to-minutes-and-seconds
  51. function fancyTimeFormat(duration) {
  52. // Hours, minutes and seconds
  53. const hrs = ~~(duration / 3600);
  54. const mins = ~~((duration % 3600) / 60);
  55. const secs = ~~duration % 60;
  56.  
  57. // Output like "1:01" or "4:03:59" or "123:03:59"
  58. let ret = "";
  59.  
  60. if (hrs > 0) {
  61. ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
  62. }
  63.  
  64. ret += "" + mins + ":" + (secs < 10 ? "0" : "");
  65. ret += "" + secs;
  66.  
  67. return ret;
  68. }
  69.  
  70. /*function executeFnInPageContext(fn) {
  71. const fnStringified = fn.toString()
  72. return window.eval('(' + fnStringified + ')' + '()')
  73. }*/
  74.  
  75. function getVideoCurrentTime() {
  76. const player = document.querySelector("#movie_player");
  77. const currentTime = player.getCurrentTime();
  78.  
  79. return currentTime;
  80. }
  81.  
  82. function getVideoName() {
  83. const player = document.querySelector("#movie_player");
  84. const videoName = player.getVideoData().title;
  85.  
  86. return videoName;
  87. }
  88.  
  89. function getVideoId() {
  90. if (configData.currentVideoId) {
  91. return configData.currentVideoId;
  92. }
  93. const player = document.querySelector("#movie_player");
  94. const id = player.getVideoData().video_id;
  95.  
  96. return id;
  97. }
  98.  
  99. function playerExists() {
  100. const player = document.querySelector("#movie_player");
  101. const exists = Boolean(player);
  102.  
  103. return exists;
  104. }
  105.  
  106. function setVideoProgress(progress) {
  107. const player = document.querySelector("#movie_player");
  108.  
  109. player.seekTo(progress);
  110. }
  111.  
  112. function updateLastSaved(videoProgress) {
  113. const lastSaveEl = document.querySelector(".last-save-info-text");
  114. const lastSaveText = `Last save: ${fancyTimeFormat(videoProgress)}`;
  115. // This is for browsers that support Trusted Types
  116. const lastSaveInnerHtml = configData.sanitizer
  117. ? configData.sanitizer.createHTML(lastSaveText)
  118. : lastSaveText;
  119.  
  120. if (lastSaveEl) {
  121. lastSaveEl.innerHTML = lastSaveInnerHtml;
  122. }
  123. }
  124.  
  125. function saveVideoProgress() {
  126. const videoProgress = getVideoCurrentTime();
  127. updateLastSaved(videoProgress);
  128. const videoId = getVideoId();
  129.  
  130. configData.currentVideoId = videoId;
  131. configData.lastSaveTime = Date.now();
  132. const idToStore = "Youtube_SaveResume_Progress-" + videoId;
  133. const progressData = {
  134. videoProgress,
  135. saveDate: Date.now(),
  136. videoName: getVideoName(),
  137. };
  138.  
  139. window.localStorage.setItem(idToStore, JSON.stringify(progressData));
  140. }
  141. function getSavedVideoList() {
  142. const savedVideoList = Object.entries(window.localStorage).filter(
  143. ([key, value]) => key.includes("Youtube_SaveResume_Progress-")
  144. );
  145. return savedVideoList;
  146. }
  147.  
  148. function getSavedVideoProgress() {
  149. const videoId = getVideoId();
  150. const idToStore = "Youtube_SaveResume_Progress-" + videoId;
  151. const savedVideoData = window.localStorage.getItem(idToStore);
  152. const { videoProgress } = JSON.parse(savedVideoData) || {};
  153.  
  154. return videoProgress;
  155. }
  156.  
  157. function videoHasChapters() {
  158. const chaptersSection = document.querySelector(
  159. '.ytp-chapter-container[style=""]'
  160. );
  161. const chaptersSectionDisplay = getComputedStyle(chaptersSection).display;
  162. return chaptersSectionDisplay !== "none";
  163. }
  164.  
  165. function setSavedProgress() {
  166. const savedProgress = getSavedVideoProgress();
  167. setVideoProgress(savedProgress);
  168. configData.savedProgressAlreadySet = true;
  169. }
  170.  
  171. // code ref: https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
  172. function waitForElm(selector) {
  173. return new Promise((resolve) => {
  174. if (document.querySelector(selector)) {
  175. return resolve(document.querySelector(selector));
  176. }
  177.  
  178. const observer = new MutationObserver((mutations) => {
  179. if (document.querySelector(selector)) {
  180. observer.disconnect();
  181. resolve(document.querySelector(selector));
  182. }
  183. });
  184.  
  185. // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
  186. observer.observe(document.body, {
  187. childList: true,
  188. subtree: true,
  189. });
  190. });
  191. }
  192.  
  193. async function onPlayerElementExist(callback) {
  194. await waitForElm("#movie_player");
  195. callback();
  196. }
  197.  
  198. function isReadyToSetSavedProgress() {
  199. return (
  200. !configData.savedProgressAlreadySet &&
  201. playerExists() &&
  202. getSavedVideoProgress()
  203. );
  204. }
  205. function insertInfoElement(element) {
  206. const leftControls = document.querySelector(".ytp-left-controls");
  207. leftControls.appendChild(element);
  208. const chaptersContainerElelement = document.querySelector(
  209. ".ytp-chapter-container"
  210. );
  211. chaptersContainerElelement.style.flexBasis = "auto";
  212. }
  213. function insertInfoElementInChaptersContainer(element) {
  214. const chaptersContainer = document.querySelector(
  215. '.ytp-chapter-container[style=""]'
  216. );
  217. chaptersContainer.style.display = "flex";
  218. chaptersContainer.appendChild(element);
  219. }
  220. function updateFloatingSettingsUi() {
  221. const settingsButton = document.querySelector(".ysrp-settings-button");
  222. const settingsContainer = document.querySelector(".settings-container");
  223. const { flip, computePosition } = window.FloatingUIDOM;
  224. computePosition(settingsButton, settingsContainer, {
  225. placement: "top",
  226. middleware: [flip()],
  227. }).then(({ x, y }) => {
  228. Object.assign(settingsContainer.style, {
  229. left: `${x}px`,
  230. top: `${y}px`,
  231. });
  232. });
  233. }
  234.  
  235. function setFloatingSettingsUi() {
  236. const settingsButton = document.querySelector(".ysrp-settings-button");
  237. const settingsContainer = state.floatingUi.settingsContainer
  238. const { autoUpdate } = window.FloatingUIDOM;
  239.  
  240. settingsButton.addEventListener("click", () => {
  241. const exists = document.body.contains(settingsContainer)
  242. if (exists) {
  243. closeFloatingSettingsUi()
  244. } else {
  245. document.body.appendChild(settingsContainer);
  246. updateFloatingSettingsUi();
  247. state.floatingUi.cleanUpFn = autoUpdate(settingsButton, settingsContainer, updateFloatingSettingsUi);
  248. document.addEventListener('click', closeFloatingSettingsUiOnClickOutside)
  249. }
  250.  
  251. });
  252. }
  253.  
  254. function closeFloatingSettingsUiOnClickOutside(event) {
  255. const settingsButton = document.querySelector(".ysrp-settings-button");
  256. const settingsContainer = state.floatingUi.settingsContainer
  257. if (settingsContainer && !settingsContainer.contains(event.target) && !settingsButton.contains(event.target)) {
  258. closeFloatingSettingsUi();
  259. document.removeEventListener('click', closeFloatingSettingsUiOnClickOutside);
  260. }
  261. }
  262.  
  263. function closeFloatingSettingsUi() {
  264. const settingsContainer = state.floatingUi.settingsContainer
  265. settingsContainer.remove()
  266. state.floatingUi.cleanUpFn()
  267. state.floatingUi.cleanUpFn = null
  268. }
  269.  
  270. function createSettingsUI() {
  271. const videos = getSavedVideoList();
  272. const videosCount = videos.length;
  273. const infoElContainer = document.querySelector(".last-save-info-container");
  274. const infoElContainerPosition = infoElContainer.getBoundingClientRect();
  275. const settingsContainer = document.createElement("div");
  276. settingsContainer.addEventListener('click', (event) => {event.stopPropagation()})
  277. state.floatingUi.settingsContainer = settingsContainer
  278. settingsContainer.classList.add("settings-container");
  279.  
  280. const settingsContainerHeader = document.createElement("div");
  281. const settingsContainerHeaderTitle = document.createElement("h3");
  282. settingsContainerHeaderTitle.textContent =
  283. "Saved Videos - (" + videosCount + ")";
  284. settingsContainerHeader.style.display = "flex";
  285. settingsContainerHeader.style.justifyContent = "space-between";
  286.  
  287. const settingsContainerBody = document.createElement("div");
  288. settingsContainerBody.classList.add("settings-container-body");
  289. const settingsContainerBodyStyle = {
  290. display: "flex",
  291. flex: "1",
  292. minHeight: "0",
  293. overflow: "auto",
  294. };
  295. Object.assign(settingsContainerBody.style, settingsContainerBodyStyle);
  296.  
  297. const videosList = document.createElement("ul");
  298. videosList.style.display = "flex";
  299. videosList.style.flexDirection = "column";
  300. videosList.style.rowGap = "1rem";
  301. videosList.style.listStyle = "none";
  302. videosList.style.marginTop = "1rem";
  303. videosList.style.flex = "1";
  304.  
  305. videos.forEach((video) => {
  306. const [key, value] = video;
  307. const { videoName } = JSON.parse(value);
  308. const videoEl = document.createElement("li");
  309. const videoElText = document.createElement("span");
  310. videoEl.style.display = "flex";
  311. videoEl.style.alignItems = "center";
  312.  
  313. videoElText.textContent = videoName;
  314. videoElText.style.flex = "1";
  315.  
  316. const deleteButton = document.createElement("button");
  317. const trashIcon = createIcon("trash", "#e74c3c");
  318. deleteButton.style.background = "white";
  319. deleteButton.style.border = "rgba(0, 0, 0, 0.3) 1px solid";
  320. deleteButton.style.borderRadius = ".5rem";
  321. deleteButton.style.marginLeft = "1rem";
  322. deleteButton.style.cursor = "pointer";
  323.  
  324. deleteButton.addEventListener("click", () => {
  325. window.localStorage.removeItem(key);
  326. videosList.removeChild(videoEl);
  327. settingsContainerHeaderTitle.textContent =
  328. "Saved Videos - (" + videosList.children.length + ")";
  329. });
  330.  
  331. deleteButton.appendChild(trashIcon);
  332. videoEl.appendChild(videoElText);
  333. videoEl.appendChild(deleteButton);
  334. videosList.appendChild(videoEl);
  335. });
  336.  
  337. const settingsContainerCloseButton = document.createElement("button");
  338. settingsContainerCloseButton.style.background = "transparent";
  339. settingsContainerCloseButton.style.border = "none";
  340. settingsContainerCloseButton.style.cursor = "pointer";
  341.  
  342. const xmarkIcon = createIcon("xmark", "#e74c3c");
  343. settingsContainerCloseButton.appendChild(xmarkIcon);
  344. settingsContainerCloseButton.addEventListener("click", () => {
  345. closeFloatingSettingsUi()
  346. });
  347.  
  348. const settingsContainerStyles = {
  349. all: "initial",
  350. position: "absolute",
  351. fontFamily: "inherit",
  352. flexDirection: "column",
  353. top: "0",
  354. display: "flex",
  355. boxShadow: "rgba(0, 0, 0, 0.24) 0px 3px 8px",
  356. border: "1px solid #d5d5d5",
  357. top: infoElContainerPosition.bottom + "px",
  358. left: infoElContainerPosition.left + "px",
  359. padding: "1rem",
  360. width: "50rem",
  361. height: "25rem",
  362. borderRadius: ".5rem",
  363. background: "white",
  364. zIndex: "3000",
  365. };
  366.  
  367. Object.assign(settingsContainer.style, settingsContainerStyles);
  368. settingsContainerBody.appendChild(videosList);
  369. settingsContainerHeader.appendChild(settingsContainerHeaderTitle);
  370. settingsContainerHeader.appendChild(settingsContainerCloseButton);
  371. settingsContainer.appendChild(settingsContainerHeader);
  372. settingsContainer.appendChild(settingsContainerBody);
  373.  
  374. const savedVideos = getSavedVideoList();
  375. const savedVideosList = document.createElement("ul");
  376. }
  377.  
  378. function createInfoUI() {
  379. const infoElContainer = document.createElement("div");
  380. infoElContainer.classList.add("last-save-info-container");
  381. const infoElText = document.createElement("span");
  382. const settingsButton = document.createElement("button");
  383. settingsButton.classList.add("ysrp-settings-button");
  384.  
  385. settingsButton.style.background = "white";
  386. settingsButton.style.border = "rgba(0, 0, 0, 0.3) 1px solid";
  387. settingsButton.style.borderRadius = ".5rem";
  388. settingsButton.style.marginLeft = "1rem";
  389.  
  390. const infoEl = document.createElement("div");
  391. infoEl.classList.add("last-save-info");
  392. infoElText.textContent = "Last save: Loading...";
  393. infoElText.classList.add("last-save-info-text");
  394. infoEl.appendChild(infoElText);
  395. infoEl.appendChild(settingsButton)
  396.  
  397. infoElContainer.style.all = "initial";
  398. infoElContainer.style.fontFamily = "inherit";
  399. infoElContainer.style.fontSize = "1.3rem";
  400. infoElContainer.style.marginLeft = "0.5rem";
  401. infoElContainer.style.display = "flex";
  402. infoElContainer.style.alignItems = "center";
  403.  
  404. infoEl.style.textShadow = "none";
  405. infoEl.style.background = "white";
  406. infoEl.style.color = "black";
  407. infoEl.style.padding = ".5rem";
  408. infoEl.style.borderRadius = ".5rem";
  409.  
  410. infoElContainer.appendChild(infoEl);
  411.  
  412. return infoElContainer;
  413. }
  414.  
  415. async function onChaptersReadyToMount(callback) {
  416. await waitForElm('.ytp-chapter-container[style=""]');
  417. callback();
  418. }
  419.  
  420. function addFontawesomeIcons() {
  421. const head = document.getElementsByTagName("HEAD")[0];
  422. const iconsUi = document.createElement("link");
  423. Object.assign(iconsUi, {
  424. rel: "stylesheet",
  425. type: "text/css",
  426. href: configData.dependenciesURLs.fontAwesomeIcons,
  427. });
  428.  
  429. head.appendChild(iconsUi);
  430. iconsUi.addEventListener("load", () => {
  431. const icon = document.createElement("span");
  432.  
  433. const settingsButton = document.querySelector('.ysrp-settings-button')
  434. settingsButton.appendChild(icon)
  435. icon.classList.add('fa-solid')
  436. icon.classList.add('fa-gear')
  437. });
  438. }
  439.  
  440. function sanitizeScriptUrl(url) {
  441. return configData.sanitizer ? configData.sanitizer.createScriptURL(url) : url;
  442. }
  443.  
  444. function addFloatingUIDependency() {
  445. const floatingUiCore = document.createElement("script");
  446. const floatingUiDom = document.createElement("script");
  447. floatingUiCore.src = sanitizeScriptUrl(configData.dependenciesURLs.floatingUiCore);
  448. floatingUiDom.src = sanitizeScriptUrl(configData.dependenciesURLs.floatingUiDom);
  449. document.body.appendChild(floatingUiCore);
  450. document.body.appendChild(floatingUiDom);
  451. let floatingUiCoreLoaded = false;
  452. let floatingUiDomLoaded = false;
  453.  
  454.  
  455. floatingUiCore.addEventListener("load", () => {
  456. floatingUiCoreLoaded = true;
  457. if (floatingUiCoreLoaded && floatingUiDomLoaded) {
  458. setFloatingSettingsUi();
  459. }
  460. });
  461. floatingUiDom.addEventListener("load", () => {
  462. floatingUiDomLoaded = true;
  463. if (floatingUiCoreLoaded && floatingUiDomLoaded) {
  464. setFloatingSettingsUi();
  465. }
  466. });
  467. }
  468. function initializeDependencies() {
  469. addFontawesomeIcons();
  470. // FIXME: floating ui is not working for now
  471. addFloatingUIDependency()
  472. }
  473.  
  474. function initializeUI() {
  475. const infoEl = createInfoUI();
  476. insertInfoElement(infoEl);
  477. createSettingsUI()
  478.  
  479. initializeDependencies();
  480.  
  481. onChaptersReadyToMount(() => {
  482. insertInfoElementInChaptersContainer(infoEl);
  483. createSettingsUI();
  484. });
  485. }
  486.  
  487. function initialize() {
  488. if (
  489. window.trustedTypes &&
  490. window.trustedTypes.createPolicy &&
  491. !window.trustedTypes.defaultPolicy
  492. ) {
  493. const sanitizer = window.trustedTypes.createPolicy("default", {
  494. createHTML: (string, sink) => string,
  495. createScript: (string, sink) => string,
  496. createScriptURL: (string, sink) => string,
  497. });
  498.  
  499. configData.sanitizer = sanitizer;
  500. }
  501.  
  502. onPlayerElementExist(() => {
  503. initializeUI();
  504. if (isReadyToSetSavedProgress()) {
  505. setSavedProgress();
  506. }
  507. });
  508.  
  509. setInterval(saveVideoProgress, configData.savingInterval);
  510. }
  511.  
  512. initialize();
  513. })();