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