Greasy Fork 还支持 简体中文。

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-11 提交的版本,檢視 最新版本

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