bilibili_videos_timer

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

})();