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

目前为 2025-02-24 提交的版本。查看 最新版本

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