Greasy Fork is available in English.
Save and restore video playback time using Firestore
// ==UserScript==
// @name Video time tracker (Firestore)
// @namespace http://tampermonkey.net/
// @version 1.4
// @description Save and restore video playback time using Firestore
// @author Bui Quoc Dung
// @match *://*/*
// @grant GM_xmlhttpRequest
// @run-at document-end
// ==/UserScript==
(function () {
"use strict";
const CONFIG = {
FIRESTORE_URL: "",
SAVE_INTERVAL: 1 * 1000,// seconds *1000
MIN_TRACK_TIME: 20 * 60, // minutes * 60
REMOVE_TIME_INTERVAL: 10,// Days before data removal
REMOVE_TIME: "08:30"// Daily cleanup time (24h format)
};
const state = {
video: null,
videoId: "",
lastSaveTime: 0,
saveIntervalId: null,
isYouTube: false,
savedTime: null
};
function getVideoID() {
const url = new URL(window.location.href);
const queryId = url.searchParams.get("v") || url.searchParams.get("id");
if (queryId) return queryId;
const lastSegment = url.pathname.split('/').filter(Boolean).pop();
if (lastSegment && /^[a-zA-Z0-9_-]+$/.test(lastSegment)) return lastSegment;
return btoa(url.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 100);
}
function firestoreRequest(method, path = "", data = null) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method,
url: `${CONFIG.FIRESTORE_URL}${path}`,
headers: data ? { "Content-Type": "application/json" } : {},
data: data ? JSON.stringify(data) : undefined,
onload: (response) => {
try {
resolve(response.status === 200 ? JSON.parse(response.responseText) : null);
} catch (error) {
reject(error);
}
},
onerror: reject
});
});
}
async function loadSavedProgress() {
try {
const data = await firestoreRequest("GET", `/${state.videoId}`);
const savedTime = parseInt(data?.fields?.time?.integerValue);
if (savedTime > 0) {
state.savedTime = savedTime;
if (state.video) {
applySavedTime();
}
}
} catch (error) {
console.error('Error loading saved progress:', error);
}
}
function applySavedTime(retryCount = 0) {
if (!state.video || !state.savedTime) return;
const maxRetries = 10;
const retryDelay = 500;
if (state.video.readyState >= 2 && state.video.duration > 0 && !isNaN(state.video.duration)) {
try {
state.video.currentTime = state.savedTime;
console.log(`Applied saved time: ${state.savedTime}s`);
state.savedTime = null;
} catch (error) {
console.error('Error setting currentTime:', error);
if (retryCount < maxRetries) {
setTimeout(() => applySavedTime(retryCount + 1), retryDelay);
}
}
} else if (retryCount < maxRetries) {
setTimeout(() => applySavedTime(retryCount + 1), retryDelay);
}
}
async function savePlaybackProgress() {
const { video } = state;
if (!video || video.paused || video.ended) return;
if (video.duration < CONFIG.MIN_TRACK_TIME && !isNaN(video.duration)) return;
const currentTime = Math.floor(video.currentTime);
if (currentTime < 5) return;
if (Date.now() - state.lastSaveTime >= CONFIG.SAVE_INTERVAL) {
try {
await firestoreRequest("PATCH", `/${state.videoId}`, {
fields: {
time: { integerValue: currentTime.toString() },
date: { stringValue: new Date().toISOString().split('T')[0] },
url: { stringValue: window.location.href }
}
});
state.lastSaveTime = Date.now();
} catch (error) {
console.error('Failed to save progress:', error);
}
}
}
function findVideo() {
if (state.isYouTube) {
let checkCount = 0;
const maxChecks = 60;
const checkPlayer = setInterval(() => {
checkCount++;
const video = document.querySelector('.html5-main-video');
if (video && !video.dataset.processed) {
setTimeout(() => {
if (video.readyState >= 1 || video.duration > 0) {
clearInterval(checkPlayer);
video.dataset.processed = true;
state.video = video;
initializeVideo();
}
}, 1000);
}
if (checkCount >= maxChecks) {
clearInterval(checkPlayer);
}
}, 500);
return;
}
const plyrVideo = typeof Plyr !== "undefined" && Plyr.instances.length > 0
? Plyr.instances[0].elements.container.querySelector("video")
: null;
const video = document.querySelector("video") || plyrVideo;
if (video && !video.dataset.processed) {
video.dataset.processed = true;
state.video = video;
if (video.duration >= CONFIG.MIN_TRACK_TIME || isNaN(video.duration)) {
initializeVideo();
}
} else if (!video) {
setTimeout(findVideo, 1000);
}
}
function setupEventListeners() {
if (state.saveIntervalId) clearInterval(state.saveIntervalId);
state.saveIntervalId = setInterval(savePlaybackProgress, CONFIG.SAVE_INTERVAL);
const events = ['pause', 'seeked'];
events.forEach(event => state.video.addEventListener(event, savePlaybackProgress));
window.addEventListener("beforeunload", savePlaybackProgress);
state.video.addEventListener("ended", () => {
if (state.saveIntervalId) {
clearInterval(state.saveIntervalId);
state.saveIntervalId = null;
}
});
state.video.addEventListener('playing', () => {
if (state.savedTime && state.video.currentTime < 10) {
applySavedTime();
}
}, { once: true });
}
function initializeVideo() {
if (!state.video) return;
const handleMetadata = () => {
loadSavedProgress().then(() => {
if (state.savedTime) {
applySavedTime();
}
});
setupEventListeners();
};
if (state.video.duration && !isNaN(state.video.duration)) {
handleMetadata();
} else {
state.video.addEventListener('loadedmetadata', handleMetadata, { once: true });
setTimeout(() => {
if (!state.video.duration || isNaN(state.video.duration)) {
handleMetadata();
}
}, 2000);
}
}
async function removeOldData() {
try {
const data = await firestoreRequest("GET");
if (data?.documents) {
data.documents.forEach(async (doc) => {
const videoDate = doc.fields?.date?.stringValue;
if (videoDate) {
const daysDifference = (new Date() - new Date(videoDate)) / (1000 * 60 * 60 * 24);
if (daysDifference > CONFIG.REMOVE_TIME_INTERVAL) {
const docId = doc.name.split("/").pop();
await firestoreRequest("DELETE", `/${docId}`);
}
}
});
}
} catch (error) {
console.error('Error removing old data:', error);
}
}
function scheduleDailyCleanup() {
setInterval(() => {
const now = new Date();
const currentTime = `${now.getHours().toString().padStart(2, "0")}:${now.getMinutes().toString().padStart(2, "0")}`;
if (currentTime === CONFIG.REMOVE_TIME) {
removeOldData();
}
}, 60000);
}
function monitorUrlChanges() {
let previousUrl = location.href;
setInterval(() => {
if (location.href !== previousUrl) {
previousUrl = location.href;
if (state.video) {
state.video.dataset.processed = false;
if (state.saveIntervalId) {
clearInterval(state.saveIntervalId);
state.saveIntervalId = null;
}
}
state.video = null;
state.savedTime = null;
state.videoId = getVideoID();
findVideo();
}
}, 1000);
}
function init() {
state.isYouTube = location.hostname.includes('youtube.com');
state.videoId = getVideoID();
findVideo();
monitorUrlChanges();
scheduleDailyCleanup();
if (!state.isYouTube) {
const observer = new MutationObserver(() => {
if (!state.video || !state.video.isConnected) {
const newVideo = document.querySelector("video");
if (newVideo && !newVideo.dataset.processed) {
state.video = newVideo;
findVideo();
}
}
});
observer.observe(document.body, { childList: true, subtree: true });
}
}
init();
})();