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

Version vom 24.02.2025. Aktuellste Version

// ==UserScript==
// @license MIT
// @name         Youtube Save/Resume Progress
// @namespace    http://tampermonkey.net/
// @version      1.5.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
// @author       Costin Alexandru Sandu
// @match        https://www.youtube.com/watch*
// @icon         https://raw.githubusercontent.com/SaurusLex/YoutubeSaveResumeProgress/refs/heads/master/youtube_save_resume_progress_icon.jpg
// @grant        none
// ==/UserScript==

(function () {
  "strict";
  var configData = {
    sanitizer: null,
    savedProgressAlreadySet: false,
    savingInterval: 2000,
    currentVideoId: null,
    lastSaveTime: 0,
    dependenciesURLs: {
      floatingUiCore: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]",
      floatingUiDom: "https://cdn.jsdelivr.net/npm/@floating-ui/[email protected]",
      fontAwesomeIcons:
        "https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css",
    },
  };

  var FontAwesomeIcons = {
    trash: ["fa-solid", "fa-trash-can"],
    xmark: ["fa-solid", "fa-xmark"],
  };

  function createIcon(iconName, color) {
    const icon = document.createElement("i");
    const cssClasses = FontAwesomeIcons[iconName];
    icon.classList.add(...cssClasses);
    icon.style.color = color;
    icon.style.fontSize = "16px";

    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 player = document.querySelector("#movie_player");
    const currentTime = player.getCurrentTime();

    return currentTime;
  }

  function getVideoName() {
    const player = document.querySelector("#movie_player");
    const videoName = player.getVideoData().title;

    return videoName;
  }

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

    return id;
  }

  function playerExists() {
    const player = document.querySelector("#movie_player");
    const exists = Boolean(player);

    return exists;
  }

  function setVideoProgress(progress) {
    const player = document.querySelector("#movie_player");

    player.seekTo(progress);
  }

  function updateLastSaved(videoProgress) {
    const lastSaveEl = document.querySelector(".last-save-info-text");
    const lastSaveText = `Last save: ${fancyTimeFormat(videoProgress)}`;
    // This is for browsers that support Trusted Types
    const lastSaveInnerHtml = configData.sanitizer
      ? configData.sanitizer.createHTML(lastSaveText)
      : lastSaveText;

    if (lastSaveEl) {
      lastSaveEl.innerHTML = lastSaveInnerHtml;
    }
  }

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

    configData.currentVideoId = videoId;
    configData.lastSaveTime = Date.now();
    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);
    const chaptersContainerElelement = document.querySelector(
      ".ytp-chapter-container"
    );
    chaptersContainerElelement.style.flexBasis = "auto";
  }
  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");

    updateFloatingSettingsUi();

    settingsButton.addEventListener("click", () => {
      settingsContainer.style.display =
        settingsContainer.style.display === "none" ? "flex" : "none";
      if (settingsContainer.style.display === "flex") {
        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: "auto",
    };
    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";
    videosList.style.flex = "1";

    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.style.background = "transparent";
    settingsContainerCloseButton.style.border = "none";
    settingsContainerCloseButton.style.cursor = "pointer";

    const xmarkIcon = createIcon("xmark", "#e74c3c");
    settingsContainerCloseButton.appendChild(xmarkIcon);
    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: Loading...";
    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 sanitizeScriptUrl(url) {
    return configData.sanitizer ? configData.sanitizer.createScriptURL(url) : url;
  }

  function addFloatingUIDependency() {
    const floatingUiCore = document.createElement("script");
    const floatingUiDom = document.createElement("script");
    floatingUiCore.src = sanitizeScriptUrl(configData.dependenciesURLs.floatingUiCore);
    floatingUiDom.src = sanitizeScriptUrl(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();
    // FIXME: floating ui is not working for now
    addFloatingUIDependency()
  }

  function initializeUI() {
    const infoEl = createInfoUI();
    insertInfoElement(infoEl);
    createSettingsUI()

    initializeDependencies();

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

  function initialize() {
    if (
      window.trustedTypes &&
      window.trustedTypes.createPolicy &&
      !window.trustedTypes.defaultPolicy
    ) {
      const sanitizer = window.trustedTypes.createPolicy("default", {
        createHTML: (string, sink) => string,
        createScript: (string, sink) => string,
        createScriptURL: (string, sink) => string,
      });

      configData.sanitizer = sanitizer;
    }

    onPlayerElementExist(() => {
      initializeUI();
      if (isReadyToSetSavedProgress()) {
        setSavedProgress();
      }
    });

    setInterval(saveVideoProgress, configData.savingInterval);
  }

  initialize();
})();