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

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @license MIT
// @name         Youtube Save/Resume Progress
// @namespace    http://tampermonkey.net/
// @version      1.5
// @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
// @author       Costin Alexandru Sandu
// @match        https://www.youtube.com/watch*
// @icon         https://tse4.mm.bing.net/th/id/OIG3.UOFNuEtdysdoeX0tMsVU?pid=ImgGn
// @grant        none
// ==/UserScript==

(function () {
  'strict'
  var configData = {
    savedProgressAlreadySet: false,
    savingInterval: 1500,
    currentVideoId: null,
    lastSaveTime: 0,
    dependenciesURLs: {
      floatingUiCore: 'https://cdn.jsdelivr.net/npm/@floating-ui/core@1.6.0',
      floatingUiDom: 'https://cdn.jsdelivr.net/npm/@floating-ui/dom@1.6.3',
      fontAwesomeIcons: 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css'
    }
  }

  var FontAwesomeIcons = {
    trash: ['fa-solid', 'fa-trash-can']
  }

  function createIcon(iconName, color) {
    const icon = document.createElement('i')
    const cssClasses = FontAwesomeIcons[iconName]
    icon.classList.add(...cssClasses)
    icon.style.color = color
    
    return icon
  }
  // ref: https://stackoverflow.com/questions/3733227/javascript-seconds-to-minutes-and-seconds
  function fancyTimeFormat(duration) {
    // Hours, minutes and seconds
    const hrs = ~~(duration / 3600);
    const mins = ~~((duration % 3600) / 60);
    const secs = ~~duration % 60;

    // Output like "1:01" or "4:03:59" or "123:03:59"
    let ret = "";

    if (hrs > 0) {
      ret += "" + hrs + ":" + (mins < 10 ? "0" : "");
    }

    ret += "" + mins + ":" + (secs < 10 ? "0" : "");
    ret += "" + secs;

    return ret;
  }

  function executeFnInPageContext(fn) {
    const fnStringified = fn.toString()
    return window.eval('(' + fnStringified + ')' + '()')
  }

  function getVideoCurrentTime() {
    const currentTime = executeFnInPageContext(() => {
      const player = document.querySelector('#movie_player')
      return player.getCurrentTime()
    })
    return currentTime
  }

  function getVideoName() {
    const videoName = executeFnInPageContext(() => {
      const player = document.querySelector('#movie_player')
      return player.getVideoData().title
    })
    return videoName
  }

  function getVideoId() {
    if (configData.currentVideoId) {
      return configData.currentVideoId
    }
    const id = executeFnInPageContext(() => {
      const player = document.querySelector('#movie_player')
      return player.getVideoData().video_id
    })
    return id
  }

  function playerExists() {
    const exists = executeFnInPageContext(() => {
      const player = document.querySelector('#movie_player')
      return Boolean(player)
    })
    return exists
  }

  function setVideoProgress(progress) {
    window.eval('var progress =' + progress)
    executeFnInPageContext(() => {
      const player = document.querySelector('#movie_player')
      player.seekTo(window.progress)
    })
    window.eval('delete progress')
  }

  function updateLastSaved(videoProgress) {
    const lastSaveEl = document.querySelector('.last-save-info-text')
    if (lastSaveEl) {
      lastSaveEl.innerHTML = "Last save at " + fancyTimeFormat(videoProgress)
    }
  }

  function saveVideoProgress() {
    const videoProgress = getVideoCurrentTime()
    const videoId = getVideoId()

    configData.currentVideoId = videoId
    configData.lastSaveTime = Date.now()
    updateLastSaved(videoProgress)
    const idToStore = 'Youtube_SaveResume_Progress-' + videoId
    const progressData = {
      videoProgress,
      saveDate: Date.now(),
      videoName: getVideoName()
    }
    
    window.localStorage.setItem(idToStore, JSON.stringify(progressData))
  }
  function getSavedVideoList() {
    const savedVideoList = Object.entries(window.localStorage).filter(([key, value]) => key.includes('Youtube_SaveResume_Progress-'))
    return savedVideoList
  }

  function getSavedVideoProgress() {
    const videoId = getVideoId()
    const idToStore = 'Youtube_SaveResume_Progress-' + videoId
    const savedVideoData = window.localStorage.getItem(idToStore)
    const { videoProgress } = JSON.parse(savedVideoData) || {}

    return videoProgress
  }

  function videoHasChapters() {
    const chaptersSection = document.querySelector('.ytp-chapter-container[style=""]')
    const chaptersSectionDisplay = getComputedStyle(chaptersSection).display 
    return chaptersSectionDisplay !== 'none'
  }

  function setSavedProgress() {
    const savedProgress = getSavedVideoProgress();
    setVideoProgress(savedProgress)
    configData.savedProgressAlreadySet = true
  }

  // code ref: https://stackoverflow.com/questions/5525071/how-to-wait-until-an-element-exists
  function waitForElm(selector) {
    return new Promise(resolve => {
      if (document.querySelector(selector)) {
        return resolve(document.querySelector(selector));
      }

      const observer = new MutationObserver(mutations => {
        if (document.querySelector(selector)) {
          observer.disconnect();
          resolve(document.querySelector(selector));
        }
      });

      // If you get "parameter 1 is not of type 'Node'" error, see https://stackoverflow.com/a/77855838/492336
      observer.observe(document.body, {
        childList: true,
        subtree: true
      });
    });
  }

  async function onPlayerElementExist(callback) {
    await waitForElm('#movie_player')
    callback()
  }

  function isReadyToSetSavedProgress() {
    return !configData.savedProgressAlreadySet && playerExists() && getSavedVideoProgress()
  }
  function insertInfoElement(element) {
    const leftControls = document.querySelector('.ytp-left-controls')
    leftControls.appendChild(element)
  }
  function insertInfoElementInChaptersContainer(element) {
    const chaptersContainer = document.querySelector('.ytp-chapter-container[style=""]')
    chaptersContainer.style.display = 'flex'
    chaptersContainer.appendChild(element)
  }
  function updateFloatingSettingsUi() {
    const settingsButton = document.querySelector('.ysrp-settings-button')
    const settingsContainer = document.querySelector('.settings-container')
    const { flip, computePosition } = window.FloatingUIDOM
    computePosition(settingsButton, settingsContainer, { 
      placement: 'top',
      middleware: [flip()]
    }).then(({x, y}) => {
      Object.assign(settingsContainer.style, {
        left: `${x}px`,
        top: `${y}px`,
      });
    });

  }


  function setFloatingSettingsUi() {
    const settingsButton = document.querySelector('.ysrp-settings-button')
    const settingsContainer = document.querySelector('.settings-container')

    executeFnInPageContext(updateFloatingSettingsUi)

    settingsButton.addEventListener('click', () => {
      settingsContainer.style.display = settingsContainer.style.display === 'none' ? 'flex' : 'none'
      if (settingsContainer.style.display === 'flex') {
        executeFnInPageContext(updateFloatingSettingsUi)
      }
    })
  }
  
  function createSettingsUI() {
    const videos = getSavedVideoList()
    const videosCount = videos.length
    const infoElContainer = document.querySelector('.last-save-info-container')
    const infoElContainerPosition = infoElContainer.getBoundingClientRect()
    const settingsContainer = document.createElement('div')
    settingsContainer.classList.add('settings-container')

    const settingsContainerHeader = document.createElement('div')
    const settingsContainerHeaderTitle = document.createElement('h3')
    settingsContainerHeaderTitle.textContent = 'Saved Videos - (' + videosCount + ')'
    settingsContainerHeader.style.display = 'flex'
    settingsContainerHeader.style.justifyContent = 'space-between'

    const settingsContainerBody = document.createElement('div')
    settingsContainerBody.classList.add('settings-container-body')
    const settingsContainerBodyStyle = {
      display: 'flex',
      flex: '1',
      minHeight: '0',
      overflow: 'scroll'
    }
    Object.assign(settingsContainerBody.style, settingsContainerBodyStyle)

    const videosList = document.createElement('ul')
    videosList.style.display = 'flex'
    videosList.style.flexDirection = 'column'
    videosList.style.rowGap = '1rem'
    videosList.style.listStyle = 'none'
    videosList.style.marginTop = '1rem'

    videos.forEach(video => {
      const [key, value] = video
      const { videoName } = JSON.parse(value)
      const videoEl = document.createElement('li')
      const videoElText = document.createElement('span')
      videoEl.style.display = 'flex'
      videoEl.style.alignItems = 'center'

      videoElText.textContent = videoName
      videoElText.style.flex = '1'

      const deleteButton = document.createElement('button')
      const trashIcon = createIcon('trash', '#e74c3c')
      deleteButton.style.background = 'white'
      deleteButton.style.border = 'rgba(0, 0, 0, 0.3) 1px solid'
      deleteButton.style.borderRadius = '.5rem'
      deleteButton.style.marginLeft = '1rem'
      deleteButton.style.cursor = 'pointer'
      
      deleteButton.addEventListener('click', () => {
        window.localStorage.removeItem(key)
        videosList.removeChild(videoEl)
        settingsContainerHeaderTitle.textContent = 'Saved Videos - (' + (videosList.children.length) + ')'
      })

      deleteButton.appendChild(trashIcon)
      videoEl.appendChild(videoElText)
      videoEl.appendChild(deleteButton)
      videosList.appendChild(videoEl)
    })

    const settingsContainerCloseButton = document.createElement('button')
    settingsContainerCloseButton.textContent = 'x'
    settingsContainerCloseButton.addEventListener('click', () => {
      settingsContainer.style.display = 'none'
    })

    const settingsContainerStyles = {
      all: 'initial',
      position: 'absolute',
      fontFamily: 'inherit',
      flexDirection: 'column',
      top: '0',
      display: 'none',
      boxShadow: 'rgba(0, 0, 0, 0.24) 0px 3px 8px',
      border: '1px solid #d5d5d5',
      top: infoElContainerPosition.bottom + 'px',
      left: infoElContainerPosition.left + 'px',
      padding: '1rem',
      width: "50rem",
      height: '25rem',
      borderRadius: '.5rem',
      background: 'white',
      zIndex: '3000'
    }

    Object.assign(settingsContainer.style, settingsContainerStyles)
    settingsContainerBody.appendChild(videosList)
    settingsContainerHeader.appendChild(settingsContainerHeaderTitle)
    settingsContainerHeader.appendChild(settingsContainerCloseButton)
    settingsContainer.appendChild(settingsContainerHeader)
    settingsContainer.appendChild(settingsContainerBody)
    document.body.appendChild(settingsContainer)

    const savedVideos = getSavedVideoList()
    const savedVideosList = document.createElement('ul')
    

  }

  function createInfoUI() {

    const infoElContainer = document.createElement('div')
    infoElContainer.classList.add('last-save-info-container')
    const infoElText = document.createElement('span')
    const settingsButton = document.createElement('button')
    settingsButton.classList.add('ysrp-settings-button')

    settingsButton.style.background = 'white'
    settingsButton.style.border = 'rgba(0, 0, 0, 0.3) 1px solid'
    settingsButton.style.borderRadius = '.5rem'
    settingsButton.style.marginLeft = '1rem'

    const infoEl = document.createElement('div')
    infoEl.classList.add('last-save-info')
    infoElText.textContent = "Last save at :"
    infoElText.classList.add('last-save-info-text')
    infoEl.appendChild(infoElText)
    infoEl.appendChild(settingsButton)



    infoElContainer.style.all = 'initial'
    infoElContainer.style.fontFamily = 'inherit'
    infoElContainer.style.fontSize = '1.3rem'
    infoElContainer.style.marginLeft = '0.5rem'
    infoElContainer.style.display = 'flex'
    infoElContainer.style.alignItems = 'center'

    infoEl.style.textShadow = 'none'
    infoEl.style.background = 'white'
    infoEl.style.color = 'black'
    infoEl.style.padding = '.5rem'
    infoEl.style.borderRadius = '.5rem'
    
    infoElContainer.appendChild(infoEl)
    
    return infoElContainer
  }
  
  async function onChaptersReadyToMount(callback) {
    await waitForElm('.ytp-chapter-container[style=""]')
    callback()
  }

  function addFontawesomeIcons() {
    const head = document.getElementsByTagName('HEAD')[0];
    const iconsUi = document.createElement('link');
    Object.assign(iconsUi, {
      rel: 'stylesheet',
      type: 'text/css',
      href: configData.dependenciesURLs.fontAwesomeIcons
    })

    head.appendChild(iconsUi);
    iconsUi.addEventListener('load', () => {
      const icon = document.createElement('span')
      
      const settingsButton = document.querySelector('.ysrp-settings-button')
      settingsButton.appendChild(icon)
      icon.classList.add('fa-solid')
      icon.classList.add('fa-gear')
    })
  }
  function addFloatingUIDependency() {
    const floatingUiCore = document.createElement('script')
    const floatingUiDom = document.createElement('script')
    floatingUiCore.src = configData.dependenciesURLs.floatingUiCore
    floatingUiDom.src = configData.dependenciesURLs.floatingUiDom
    document.body.appendChild(floatingUiCore)
    document.body.appendChild(floatingUiDom)
    let floatingUiCoreLoaded = false
    let floatingUiDomLoaded = false
    
    floatingUiCore.addEventListener('load', () => {
      floatingUiCoreLoaded = true
      if (floatingUiCoreLoaded && floatingUiDomLoaded) {
        setFloatingSettingsUi()
      }
    })
    floatingUiDom.addEventListener('load', () => {
      floatingUiDomLoaded = true
      if (floatingUiCoreLoaded && floatingUiDomLoaded) {
        setFloatingSettingsUi()
      }
    })
  }

  function initializeDependencies() {
    addFontawesomeIcons()
    addFloatingUIDependency()
  }
  
  function initializeUI() {
    const infoEl = createInfoUI()
    insertInfoElement(infoEl)
    createSettingsUI()

    initializeDependencies()

    onChaptersReadyToMount(() => {
      insertInfoElementInChaptersContainer(infoEl)
      createSettingsUI()
    })
  }

  

  function initialize() {
    onPlayerElementExist(() => {
      initializeUI()
      if (isReadyToSetSavedProgress()) {
        setSavedProgress()
      }
    })

    setInterval(saveVideoProgress, configData.savingInterval)
  }

  initialize()
})();