bilibili_videos_timer

View Bilibili video collection time information: total duration, watched duration, remaining duration, watched proportion and real-time progress;

// ==UserScript==
// @name         bilibili_videos_timer
// @namespace    https://github.com/Peilin-zzz-Eric/bilibili_videos_timer
// @version      0.5
// @license      MIT
// @description  View Bilibili video collection time information: total duration, watched duration, remaining duration, watched proportion and real-time progress;
// @author       ezchill
// @match        https://www.bilibili.com/video/*
// @icon         https://raw.githubusercontent.com/Peilin-zzz-Eric/bilibili_videos_timer/main/icon/timer.svg
// @grant        none
// @downloadURL
// @updateURL
// ==/UserScript==

(function() {

    // 1. Time processing related functions
    // Function to format time from hours, minutes, and seconds into "hh:mm:ss"
    function formatTime(hours, minutes, seconds) {
        var hoursString = hours.toString().padStart(2, "0");
        var minutesString = minutes.toString().padStart(2, "0");
        var secondsString = seconds.toString().padStart(2, "0");
        return hoursString + ":" + minutesString + ":" + secondsString;
    }

    // Function to add two times in "hh:mm:ss" format
    function addTime(time1, time2) {
        var timeParts1 = time1.split(":");
        var timeParts2 = time2.split(":");

        var hours = parseInt(timeParts1[0]) + parseInt(timeParts2[0]);
        var minutes = parseInt(timeParts1[1]) + parseInt(timeParts2[1]);
        var seconds = parseInt(timeParts1[2]) + parseInt(timeParts2[2]);

        // Handle overflow of seconds into minutes
        if (seconds >= 60) {
            minutes += Math.floor(seconds / 60);
            seconds = seconds % 60;
        }

        // Handle overflow of minutes into hours
        if (minutes >= 60) {
            hours += Math.floor(minutes / 60);
            minutes = minutes % 60;
        }

        return formatTime(hours, minutes, seconds);
    }

    // Function to subtract time2 from time1 in "hh:mm:ss" format
    function subtractTime(time1, time2) {
        var timeParts1 = time1.split(":");
        var timeParts2 = time2.split(":");

        var hours1 = parseInt(timeParts1[0]);
        var minutes1 = parseInt(timeParts1[1]);
        var seconds1 = parseInt(timeParts1[2]);

        var hours2 = parseInt(timeParts2[0]);
        var minutes2 = parseInt(timeParts2[1]);
        var seconds2 = parseInt(timeParts2[2]);

        // Convert times to total seconds for easier calculation
        var totalSeconds1 = (hours1 * 3600) + (minutes1 * 60) + seconds1;
        var totalSeconds2 = (hours2 * 3600) + (minutes2 * 60) + seconds2;
        var diffSeconds = totalSeconds1 - totalSeconds2;

        // Convert the result back into "hh:mm:ss"
        var hours = Math.floor(diffSeconds / 3600);
        var minutes = Math.floor((diffSeconds % 3600) / 60);
        var seconds = diffSeconds % 60;

        return formatTime(hours, minutes, seconds);
    }

    // Function to calculate the percentage of time watched compared to total time
    function calculatePercentage(part, total) {
        const partInSeconds = timeToSeconds(part);
        const totalInSeconds = timeToSeconds(total);
        if (totalInSeconds === 0) return "0%";
        const percentage = (partInSeconds / totalInSeconds) * 100;
        return percentage.toFixed(2) + "%";  // Keep percentage to two decimal places
    }

    // Convert time from "hh:mm:ss" format to total seconds
    function timeToSeconds(time) {
        const timeParts = time.split(":");
        const hours = parseInt(timeParts[0]);
        const minutes = parseInt(timeParts[1]);
        const seconds = parseInt(timeParts[2]);
        return (hours * 3600) + (minutes * 60) + seconds;
    }

    // Convert total seconds back to "hh:mm:ss" format
    function formatSecondsToTime(seconds) {
        const hours = Math.floor(seconds / 3600);
        const minutes = Math.floor((seconds % 3600) / 60);
        const remainingSeconds = Math.floor(seconds % 60);
        return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
    }

    // 2. DOM related operations
    // Create and style the container for the icon
    const iconContainer = document.createElement("div");
    iconContainer.style.position = "fixed";
    iconContainer.style.bottom = "25vh";
    iconContainer.style.left = "2vh";
    iconContainer.style.cursor = "pointer";

    // Create and style the icon itself
    const icon = document.createElement("img");
    icon.src = "https://raw.githubusercontent.com/Peilin-zzz-Eric/bilibili_videos_timer/main/icon/timer.svg";
    icon.style.width = "3vh";
    icon.style.height = "3vh";

    // Create the span to display the video progress information
    var span = document.createElement("div");
    span.innerText = "";
    span.style.position = "fixed";
    span.style.bottom = "15vh";
    span.style.left = "2vh";
    span.style.color = "black";
    span.style.border = "none";
    span.style.borderRadius = "5px";
    span.style.cursor = "pointer";
    span.style.fontSize = "0.55vw";
    span.id = "my_time_info";

    //Get target element
    function getTargetElement(){
        let targetElement = document.querySelector(".video-pod__list");
        return targetElement;
    }
    let targetElement = getTargetElement();
    let isAppend = true; //Check if elements are added
    // Append the icon to its container and the container to the body
    if(targetElement){
        iconContainer.appendChild(icon);
        document.body.appendChild(iconContainer);
        document.body.appendChild(span);
        isAppend = false;
    }

    // 3. Update and monitor video playback progress logic
    let totalTime = "0:0:0";  // Total time of the video collection
    let watchedTime = "0:0:0";  // Time already watched

    // Utility function to get element by XPath
    //Return all matching nodes, stored in the order they appear in the document
    function getElementsByXPath(parent, xpath) {
        let elements = [];
        const result = document.evaluate(xpath, parent, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
        for (let i = 0; i < result.snapshotLength; i++) {
            elements.push(result.snapshotItem(i));  // Add all matched elements to the array
        }
        return elements;
    }

    //Return the first matching node
    function getElementByXPath(parent, xpath) {
        const result = document.evaluate(xpath, parent, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null);
        return result.singleNodeValue;
    }

    // Update the total and watched time information
    function update_time_info() {
        let elements = [];
        const xpathExpression = "//*[contains(@class, 'video-pod__list')]/div[contains(@class, 'video-pod__item')]";
        elements = getElementsByXPath(document, xpathExpression);

        // Calculate total time and watched time
        if (elements && elements.length > 0) {
            var date = "0:0:0";
            var Pasedate = "0:0:0";
            var onDate = "0:0:0";

            for (let i = 0; i < elements.length; i++) {
                const childElement = elements[i];
                let str = "0:0:0";
                str = getElementByXPath(childElement, ".//div[contains(@class, 'stat-item') and contains(@class, 'duration')]").innerText;
                let str_arr = str.split(":");
                if (str_arr.length == 2) str = "0:" + str;  // Convert "mm:ss" format to "hh:mm:ss"
                if (str_arr.length == 1) str = "0:0:" + str;  // Convert "ss" format to "hh:mm:ss"

                date = addTime(date, str);  // Add the duration to the total time

                // Check if this is the currently playing video
                if (childElement.classList.contains('active') || childElement.querySelector('.active')) {
                    Pasedate = date;
                    onDate = str;
                }
            }
            totalTime = date;
            watchedTime = subtractTime(Pasedate, onDate);
        }
    }

    let time_info = ""; //Timestamp for time information
    //Update real-time time
    function realtime_update_time_info(){
        const video = document.querySelector("video");
        if (video){
            const currentTime = video.currentTime;
            const duration = video.duration;
            let videoDuration = "未知时长";
            let realTimePercentage = "0%";
            if (!isNaN(currentTime) && !isNaN(duration)) {
                const currentFormatted = formatSecondsToTime(currentTime);
                if (!isNaN(duration)) {
                    videoDuration = formatSecondsToTime(duration);
                    realTimePercentage = ((currentTime / duration) * 100).toFixed(2) + "%";
                }

                // Update watched time and calculate remaining time
                const updatedWatchedTime = addTime(watchedTime, formatSecondsToTime(currentTime));
                const remainingTime = subtractTime(totalTime, updatedWatchedTime);
                const percentageWatched = calculatePercentage(updatedWatchedTime, totalTime);
                time_info = `总长:${totalTime}\n已看:${updatedWatchedTime}\n剩余:${remainingTime}\n已看占比:${percentageWatched}\n实时进度:${currentFormatted} / ${videoDuration}`;
            }
        }
    }

    let videoEventListener = null;  // Store event listener for video time updates
    let videoPasueListener = null;  // Store event listener for video time pause
    function monitorVideoTime(){
        const video = document.querySelector("video");
        if (video && !videoEventListener){
            //Listen for video time updates
            videoEventListener = () => {
                //Remove the listener when the video ends
                //To prevent realtime_update_time_info() from updating before update_time_info() finishes updating watchedTime, leading to a rollback issue
                if (video.ended) {
                    removeVideoTimeMonitor();
                    console.log("Video ended, listener removed.");
                } else {
                    realtime_update_time_info();
                    span.innerText = time_info;
                }
            };
            video.addEventListener("timeupdate", videoEventListener);
        }

    }
    //Listen for video pause
    function monitorVideoPause(){
        const video = document.querySelector("video");
        if (video && !videoPasueListener){
            videoPasueListener = () => {
                realtime_update_time_info();

            };
            video.addEventListener("pause", videoPasueListener);
        }
    }

    // 4. Event binding logic
    let isVisible = false;  // Track if the info display is visible

    // Event listener for the icon click
    iconContainer.addEventListener("click", function() {
        if (isVisible) {
            // If visible, clear the span and stop monitoring the video
            span.innerText = "";
            removeVideoTimeMonitor();
        } else {
            monitorVideoTime();
            span.innerText = time_info;
        }
        isVisible = !isVisible;  // Toggle visibility state
    });

    // Remove the time update event listener from the video
    function removeVideoTimeMonitor() {
        const video = document.querySelector("video");
        if (video && videoEventListener) {
            video.removeEventListener("timeupdate", videoEventListener);
            videoEventListener = null;  // Reset the listener
        }
    }
    function removeVideoPauseMonitor() {
        const video = document.querySelector("video");
        if (video && videoPasueListener) {
            video.removeEventListener("pause", videoPasueListener);
            videoPasueListener = null;  // Reset the listener
        }
    }

    // 5. MutationObserver listener
    // Use MutationObserver to watch for DOM changes (for video collections) and refresh data
    const observer = new MutationObserver((mutationsList) => {
        let targetElement = getTargetElement();

        if (!targetElement) {
            console.log("Target element not found. Stopping script.");
            span.innerText = "";
            iconContainer.style.display = "none";
            isVisible = false;
            removeVideoTimeMonitor();
            removeVideoPauseMonitor();
        } else {
            if(isAppend){
                iconContainer.appendChild(icon);
                document.body.appendChild(iconContainer);
                document.body.appendChild(span);
                isAppend = false;
            }
            console.log("Target element found. Restarting script.");
            iconContainer.style.display = "block";

            let targetElementChanged = false;

            // Iterate over the MutationObserver mutationsList
            // Listen for the addition, removal, or reordering of child nodes
            // When the entire targetElement is replaced, the attributes of the new targetElement cannot be observed
            // The issue of refreshing the time when switching between different video collections has been resolved
            for (let mutation of mutationsList) {
                if (mutation.type === "childList") {
                    const targetNode = mutation.target;
                    //targetElement.contains(targetNode) captures changes from collection to collection
                    //targetNode.classList.contains("active") captures changes from a single video to a collection
                    if (targetElement.contains(targetNode)||targetNode.contains(targetElement)) {
                        console.log("Child list changed, re-checking target element.");
                        targetElementChanged = true;
                        break;  //Exit the loop immediately after detecting the first change
                    }
                }
                // Listen for changes in the attributes of the child elements of the targetElement
                if (mutation.type === "attributes" && mutation.attributeName === "class") {
                    const targetNode = mutation.target;
                    if (targetElement.contains(targetNode) && targetNode.classList.contains("active")) {
                        console.log("Class changed on element: ", targetNode);
                        targetElementChanged = true;
                        break;  //Exit the loop immediately after detecting the first change
                    }
                }
            }
            if (targetElementChanged) {
                update_time_info();
                monitorVideoPause();
                if(!isVisible){
                    //During video pause, perform a video seek and reset the previously stored pause time record
                    time_info = "";
                }else if(isVisible){
                    //The updatetime listener is removed when the video ends. When the video automatically plays the next video and the timestamp is displayed, reattach the listener
                    monitorVideoTime();
                }
            }
        }
    });

    // Configure MutationObserver to monitor child node changes and attribute changes
    var targetElementParent = document.querySelector(".right-container-inner");
    observer.observe(targetElementParent, { childList: true, subtree: true, attributes: true, attributeFilter: ["class"] });

})();