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-13 提交的版本,查看 最新版本

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