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()
})();