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

当前为 2024-11-08 提交的版本,查看 最新版本

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