Greasy Fork

视频网站自动网页全屏

支持哔哩哔哩、B站直播、腾讯视频、优酷视频、爱奇艺、芒果TV、搜狐视频、AcFun弹幕网播放页自动网页全屏,视频网站统一支持快捷键切换:全屏(F)、网页全屏(P)、下一个视频(N)、弹幕开关(D),支持任意视频倍速播放,B站播放完自动退出网页全屏

目前为 2024-12-29 提交的版本。查看 最新版本

// ==UserScript==
// @name         视频网站自动网页全屏
// @author       Feny
// @version      2.0.0
// @license      GPL-3.0-only
// @namespace    http://tampermonkey.net/
// @description  支持哔哩哔哩、B站直播、腾讯视频、优酷视频、爱奇艺、芒果TV、搜狐视频、AcFun弹幕网播放页自动网页全屏,视频网站统一支持快捷键切换:全屏(F)、网页全屏(P)、下一个视频(N)、弹幕开关(D),支持任意视频倍速播放,B站播放完自动退出网页全屏
// @note         v2.0.0 新增倍速播放功能,页面可见性监听,倍速播放具体使用说明见脚本主页
// @note        *://*/*
// @match        *://tv.sohu.com/v/*
// @match        *://www.mgtv.com/b/*
// @match      	 *://www.iqiyi.com/v_*
// @match      	 *://haokan.baidu.com/v*
// @match      	 *://v.youku.com/v_show/*
// @match      	 *://v.qq.com/x/page/*
// @match      	 *://v.qq.com/x/cover/*
// @match      	 *://v.qq.com/live/p/newtopic/*
// @match      	 *://www.acfun.cn/v/*
// @match      	 *://live.acfun.cn/live/*
// @match      	 *://www.acfun.cn/bangumi/*
// @match      	 *://live.bilibili.com/*
// @match      	 *://www.bilibili.com/list/*
// @match      	 *://www.bilibili.com/video/*
// @match      	 *://www.bilibili.com/festival/*
// @match      	 *://www.bilibili.com/cheese/play/*
// @match      	 *://www.bilibili.com/bangumi/play/*
// @grant        unsafeWindow
// @grant        GM_addStyle
// @icon         data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAqdJREFUWEftl91LFFEYxp/3jB9ESZjtSl51F1RUSgRCF/kHlF1IhiFhF65dqEQkBUErdJMStBukGwQre2NZUiCRqUiURkW65mIfqGUFsW6Ii0jY7p4Tc3Rqd5zaGVldAudynve8z28e3jMzh5Dmi1R/V0vQyRRWxgWG6x22SrcnOAhQcQIbwVtXba8y1EANSpS1xzJin5c/Dz+jRDPvGWoErwRw35zuh8ChpcXXFjbwi9k/WADA9viGgovGnxtFs6EmcApMvCdBA3oIIirl4N8NNQngmRYJiwTOE7EHHLERAmXFawQ6AdCQkRbjsZIMUvIFoV0HMSsEDjCgSK8tJqAHAEDAMWLKLOexx8tiVVDEhLLVQAtzRPcwKOUANSWCw1/rsBe6PcFz8dpfAdTFgtF+EmIvBG7pID7mZNl2zkVCFQbahzqHfYerddpNhFpdsnfqauzl8ZoEuO4JXdIKOefynnZlimxXhBbqjTZL/el8pzrAVjTGmKh12Bq1ddJs974abQDXfFMuAhQ6EodwDTHWAf6/BAoK8nD0cDEKtuVhyD+OzvvLXnyWJshyApedJ1F65M9n4tlAAF5fL168fGfJWCu2DDA61GpodLvjCdp8vfjyNWQJJGUAquvMzBzafD0yEc65KZCUAmiOo4FPEqS753VSiFUB0FxbPF244en6J8SqAoTD8zhYcjZ9AP6RCVRWNacHYPD5GJqudmBi8tvaAkxNBeUuuNv5NOkAqgUpm4FIJCrfA+r0z4bnTZmvCKCv+wrsts0JBg8fvZLGY28NfoqToFhOoOJ4CS40lMu2I28mpXFP37DpJ9YXWgZQG+Tm5mBL7qakA2aGakUAZhqbrVkH0BLoB34fzcyml5K6pd/yaicRlQlgV0q6mmwitMOpyfpVKfsFya4w73cz9xQAAAAASUVORK5CYII=
// ==/UserScript==

(function () {
    "use strict";

    const ACFUN_VIDEO_PAGE_REGEX = /acfun.cn\/v/;
    const BILI_VIDEO_PAGE_REGEX = /bilibili.com\/video/;
    const BILI_LIVE_PAGE_REGEX = /live.bilibili.com\/(blanc\/)?\d+/;
    const isLivePage = () => location.href.includes("live");
    const isBiliLive = () => location.host === "live.bilibili.com";
    if (isBiliLive() && !BILI_LIVE_PAGE_REGEX.test(location.href)) return;

    GM_addStyle(`
     .showToast {
        color: #fff !important;
        font-size: 14px !important;
        padding: 5px 15px !important;
        border-radius: 5px !important;
        position: absolute !important;
        z-index: 2147483647 !important;
        transition: opacity 500ms ease-in;
        background: rgba(0, 0, 0, .75) !important;
      }
      .showToast .playbackRate {
        margin: 0 3px !important;
        color: #FF6101 !important;
      }
    `);
    // showToast 位置样式
    const positions = {
        bottomLeft: "bottom: 20%; left: 10px;",
        center: "top: 50%; left: 50%; transform: translate(-50%, -50%);",
    };

    const selectorConfig = {
        "live.bilibili.com": { webfull: "#businessContainerElement", },
        "live.acfun.cn": { full: ".fullscreen-screen", webfull: ".fullscreen-web", danmaku: ".danmaku-enabled", },
        "tv.sohu.com": { full: ".x-fullscreen-btn", webfull: ".x-pagefs-btn", danmaku: ".tm-tmbtn", next: ".x-next-btn", },
        "haokan.baidu.com": { full: ".art-icon-fullscreen", webfull: ".art-control-fullscreenWeb", next: ".art-control-next", },
        "www.iqiyi.com": { full: ".iqp-btn-fullscreen", webfull: ".iqp-btn-webscreen", danmaku: "#barrage_switch", next: ".iqp-btn-next", },
        "www.mgtv.com": { full: ".fullscreenBtn i", webfull: ".webfullscreenBtn i", danmaku: "div[class*='danmuSwitch']", next: ".icon-next", },
        "v.qq.com": { full: ".txp_btn_fullscreen", webfull: "div[aria-label='网页全屏']", danmaku: ".barrage-switch", next: ".txp_btn_next_u", },
        "v.pptv.com": { full: ".w-zoom-container > div", webfull: ".w-expand-container > div", danmaku: ".w-barrage", next: ".w-next-container", },
        "www.acfun.cn": { full: ".fullscreen-screen", webfull: ".fullscreen-web", danmaku: ".danmaku-enabled", next: ".btn-next-part .control-btn", },
        "www.bilibili.com": { full: "div[aria-label='全屏']", webfull: "div[aria-label='网页全屏']", danmaku: ".bui-area", next: ".bpx-player-ctrl-next", },
        "v.youku.com": { full: "#fullscreen-icon", webfull: "#webfullscreen-icon", danmaku: "div[class*='switch-img_12hDa turn-']", next: ".kui-next-icon-0", },
    }

    const ZERO = 0;
    const SECOND_MS = 1000;
    const DEFAULT_PLAYBACK_RATE = 1;
    const PLAYBACK_RATE_STEP = 0.25; // 倍速步进
    const SHOW_TOAST_TIME = SECOND_MS * 5; // 提示显示时长
    const SHOW_TOAST_POSITION = positions.bottomLeft; // 提示位置
    const PLAYBACK_RATE_INCREMENT_SYMBOL = "+";
    const PLAYBACK_RATE_DECREMENT_SYMBOL = "-";
    const MESSAGE_SOURCE = "FENY_SCRIPTS_AUTO_WEB_FULLSCREEN";
    const CACHED_PLAYBACK_RATE_KEY = "FENY_SCRIPTS_V_PLAYBACK_RATE";
    const $ = (selector, context) => (context ?? document).querySelector(selector);
    const $$ = (selector, context) => (context ?? document).querySelectorAll(selector);
    const ScriptsProgram = {
        init() {
            this.setupKeydownListener();
            this.setupMutationObserver();
            this.setupUrlChangeListener();
            this.setupMouseOverListener();
            this.setupPageVisibilityListener();
        },
        video: null,
        getVideo: () => $("video[src]") ?? $("video"),
        getElement: () => $(selectorConfig[location.host]?.webfull),
        debounce(fn, delay = SECOND_MS) {
            let timer;
            return function () {
                if (timer) clearTimeout(timer);
                timer = setTimeout(() => fn.apply(this, arguments), delay);
            };
        },
        setupUrlChangeListener() {
            const _wr = (method) => {
                const original = history[method];
                history[method] = function () {
                    original.apply(history, arguments);
                    window.dispatchEvent(new Event(method));
                };
            };
            const handler = this.debounce(() => this.setupMutationObserver());
            ["popstate", "pushState", "replaceState"].forEach((t) => _wr(t) & window.addEventListener(t, handler));
        },
        setupMutationObserver() {
            this.videoListenerCycles = 0;
            const observer = new MutationObserver(() => {
                const video = this.video = this.getVideo();
                const element = this.element = this.getElement();
                if (video?.play && element) this.webFullScreen() && observer.disconnect();
                if (video?.play) this.setupVideoListener();
            });
            observer.observe(document.body, { /* attributes: true, */ childList: true, subtree: true });
            setTimeout(() => observer.disconnect(), SECOND_MS * 10);
        },
        reacquireVideo: false,
        videoListenerCycles: 0,
        videoBoundListeners: [],
        setupVideoListener() {
            if (isLivePage()) return;
            if (this.videoListenerCycles >= 5) return;
            this.videoListenerCycles++;
            this.video = this.getVideo();
            this.addVideoEventsListener();
            // console.log("setupVideoListener 循环次数:", this.videoListenerCycles);
        },
        addVideoEventsListener() {
            this.removeVideoEventsListener();
            for (const type of Object.keys(VideoListenerHandler)) {
                const handler = VideoListenerHandler[type];
                this.video.addEventListener(type, handler);
                this.videoBoundListeners.push([this.video, type, handler]);
            }
        },
        removeVideoEventsListener() {
            this.videoBoundListeners.forEach(listener => {
                const [target, type, handler] = listener;
                target.removeEventListener(type, handler);
            });
            this.videoBoundListeners = [];
        },
        rebindVideoEventsListener(video) {
            this.video = video;
            this.reacquireVideo = true;
            this.addVideoEventsListener();
        },
        setupMouseOverListener() {
            document.addEventListener('mouseover', (event) => {
                const mouseX = event.clientX;
                const mouseY = event.clientY;
                const videos = $$("video");
                for (const video of videos) {
                    const rect = video.getBoundingClientRect();
                    if (mouseX >= rect.left && mouseX <= rect.right && mouseY >= rect.top && mouseY <= rect.bottom) {
                        if (this.video == video) return;
                        this.rebindVideoEventsListener(video);
                        return;
                    }
                }
            });
        },
        setupPageVisibilityListener() {
            window.addEventListener('visibilitychange', () => {
                const video = this.video;
                const state = document.visibilityState;
                if (video) Object.is(state, "visible") ? video.play() : video.pause();
            });
        },
        showToast(content, duration = SHOW_TOAST_TIME) {
            document.querySelector(".showToast")?.remove();
            const toast = document.createElement("div");
            if (content instanceof HTMLElement) toast.appendChild(content);
            if (Object.is(typeof content, "string")) toast.textContent = content;
            toast.setAttribute("class", "showToast");
            toast.setAttribute("style", SHOW_TOAST_POSITION);
            this.video?.parentElement.parentElement.appendChild(toast);
            setTimeout(() => {
                toast.style.opacity = ZERO;
                setTimeout(() => toast.remove(), SECOND_MS / 2);
            }, duration);
        }
    };

    // 视频监听事件逻辑处理
    // this指向的是video.addEventListener
    const VideoListenerHandler = {
        loadedmetadata() {
            this.volume = 1;
            this.isToast = false;
        },
        loadeddata() {
            // this.isToast = false;
        },
        timeupdate() {
            if (this.duration === 0) return;
            const cachePlaybackRate = ScriptsProgram.getCachePlaybackRate();
            if (!cachePlaybackRate || DEFAULT_PLAYBACK_RATE === cachePlaybackRate) return;
            if (cachePlaybackRate === this.playbackRate) return;
            // console.log(`当前播放倍速为:${this.playbackRate},记忆倍速为:${cachePlaybackRate}`);
            ScriptsProgram.setPlaybackRate(cachePlaybackRate);
            if (this.isToast) return;
            ScriptsProgram.tipPlaybackRate();
            this.isToast = true;
        },
        ended() {
            this.isToast = false;
            const href = location.href;
            // if (/[a-zA-z]+:\/\/[^\s]*/.test(href)) return;
            if (!BILI_VIDEO_PAGE_REGEX.test(href) && !ACFUN_VIDEO_PAGE_REGEX.test(href)) return;
            function exitWebFullScreen() {
                const video = ScriptsProgram.video;
                if (window.innerWidth === video.offsetWidth) ScriptsProgram.getElement()?.click();
                const cancelAutoPlayNextButton = $(".bpx-player-ending-related-item-cancel"); // B站“取消连播”按钮
                if (cancelAutoPlayNextButton) cancelAutoPlayNextButton.click();
                console.log("已退出网页全屏!!");
            }
            const switchBtn = $(".video-pod .switch-btn.on");
            const podItems = $$(".video-pod .video-pod__item");
            // B站视频合集,为最后集播放或关闭了合集连播
            if (podItems.length > ZERO) {
                const lastPodItem = podItems[podItems.length - 1];
                const scrolled = lastPodItem.dataset.scrolled;
                if (scrolled === "true" || !switchBtn) exitWebFullScreen();
                return;
            }
            exitWebFullScreen();
        },
    }

    // 快捷键逻辑处理
    const KeydownHandler = {
        setupKeydownListener() {
            const handler = (event) => this.keydownHandler.call(this, event);
            window.addEventListener("keydown", handler, true);
            window.addEventListener("message", (event) => {
                const { data } = event;
                if (!data?.source) return;
                // console.log("接收到来自父窗口的消息:", data);
                if (!data.source.includes(MESSAGE_SOURCE)) return;
                // video可能多层iframe嵌套,继续往下派遣键盘事件
                if (!this.video) this.postMessageToAllIframes(data);
                if (data?.hotKey && this.video) this.execHotKeyActions(data.hotKey);
            });
        },
        keydownHandler(event) {
            const activeTagName = document.activeElement.tagName;
            if (["INPUT", "TEXTAREA"].includes(activeTagName)) return;
            const hotKey = event.key.toUpperCase();
            this.execHotKeyActions(hotKey);
            // 解决video在iframe中,不聚焦到iframe,倍速设置失败问题
            if (window.top === window && !this.video) this.postMessageToAllIframes({ hotKey })
        },
        execHotKeyActions(key) {
            const clickElement = (name, index) => {
                if (!isBiliLive()) return $(selectorConfig[location.host]?.[name])?.click();
                const control = this.getBiliLiveControlIcons();
                if (control) control[index]?.click();
            }
            const actions = {
                N: () => clickElement("next"),
                F: () => clickElement("full", ZERO),
                P: () => clickElement("webfull", 1),
                D: () => clickElement("danmaku", 3),
                A: () => this.stepPlaybackRate(PLAYBACK_RATE_INCREMENT_SYMBOL),
                S: () => this.stepPlaybackRate(PLAYBACK_RATE_DECREMENT_SYMBOL),
                "+": () => this.stepPlaybackRate(PLAYBACK_RATE_INCREMENT_SYMBOL),
                "-": () => this.stepPlaybackRate(PLAYBACK_RATE_DECREMENT_SYMBOL),
                Z: () => this.setPlaybackRate(DEFAULT_PLAYBACK_RATE) && this.showToast("已恢复正常倍速播放"),
                0: () => this.switchVideoPlayStatus(), // 是数字0,不是字母O
                "*": () => this.reacquirePlayingVideo(),
            };
            if (actions[key]) actions[key]();
            if (/^[1-9]$/.test(key)) this.setPlaybackRate(key) && this.tipPlaybackRate();
        },
        switchVideoPlayStatus() {
            const video = this.video;
            if (video) video.paused ? video.play() : video.pause();
        },
        reacquirePlayingVideo() {
            // 获取当前正在播放的video标签
            const videos = $$("video");
            for (const video of videos) {
                if (!video.paused && this.video !== video) {
                    this.rebindVideoEventsListener(video);
                }
            }
        },
        getBiliLiveControlIcons() {
            const video = this.getVideo();
            if (!video) return;
            this.simulateMouseMove(video);
            // 图标是从右到左:全屏、网页全屏、弹幕设置、弹幕开关、小窗模式,即下标[0]取到的是全屏图标
            return $$("#web-player-controller-wrap-el .right-area .icon");
        },
        postMessageToAllIframes(data) {
            $$("iframe").forEach(iframe => {
                iframe?.contentWindow?.postMessage({ source: MESSAGE_SOURCE, ...data }, '*')
            });
        },
        simulateMouseMove(target) {
            const y = target.offsetHeight / 2;
            const maxWidth = target.offsetWidth;
            const moveEvent = (x) => target.dispatchEvent(new MouseEvent("mousemove", { clientX: x, clientY: y, bubbles: true }));
            for (let i = ZERO; i < maxWidth; i += 100) moveEvent(i);
        }
    }

    // 播放倍速逻辑处理
    const VideoPlaybackRateHandler = {
        setPlaybackRate(playbackRate) {
            if (isLivePage()) return;
            if (!this.video) return;
            // 腾讯视频会有两个video标签
            if (!this.reacquireVideo && this.video !== this.getVideo()) return this.setupVideoListener();
            if (!this.video?.play) return this.showToast("设置倍速失败");
            this.video.playbackRate = playbackRate;
            this.cachePlaybackRate();
            return true;
        },
        stepPlaybackRate(v_symbol) {
            if (isLivePage()) return;
            if (!this.video) return;
            if (!this.reacquireVideo && this.video !== this.getVideo()) return this.setupVideoListener();
            if (!this.video?.play) return this.showToast("设置倍速失败");
            if (PLAYBACK_RATE_INCREMENT_SYMBOL === v_symbol) this.video.playbackRate += PLAYBACK_RATE_STEP;
            if (PLAYBACK_RATE_DECREMENT_SYMBOL === v_symbol) this.video.playbackRate -= PLAYBACK_RATE_STEP;
            if (ZERO === this.video.playbackRate) this.video.playbackRate = PLAYBACK_RATE_STEP;
            this.cachePlaybackRate();
            this.tipPlaybackRate();
        },
        cachePlaybackRate() {
            localStorage.setItem(CACHED_PLAYBACK_RATE_KEY, this.video.playbackRate);
        },
        getCachePlaybackRate() {
            const cachePlaybackRate = localStorage.getItem(CACHED_PLAYBACK_RATE_KEY);
            return parseFloat(cachePlaybackRate ?? DEFAULT_PLAYBACK_RATE);
        },
        tipPlaybackRate() {
            const span = document.createElement("span");
            span.appendChild(document.createTextNode("正在以"));
            const child = span.cloneNode(true);
            child.textContent = `${this.video.playbackRate}x`;
            child.setAttribute("class", "playbackRate");
            span.appendChild(child);
            span.appendChild(document.createTextNode("倍速播放"));
            this.showToast(span);
        },
    }

    // 网页全屏逻辑处理
    const WebFullScreenHandler = {
        webFullScreen() {
            const video = this.video;
            if (!video) return false;
            const w = video.offsetWidth;
            if (ZERO === w) return false;
            if (window.innerWidth === w) return true;
            if (isBiliLive()) return this.biliLiveWebFullScreen();
            this.element.click();
            return true;
        },
        biliLiveWebFullScreen() {
            try {
                const topWindow = unsafeWindow.top;
                topWindow.scrollTo({ top: 70 });
                const ctnr = Object.is(topWindow, window) ? $("#player-ctnr") : $(".lite-room", topWindow.document);
                topWindow.scrollTo({ top: ctnr?.getBoundingClientRect()?.top ?? 0 });
                this.element.dispatchEvent(new MouseEvent("dblclick", { bubbles: true }));
                localStorage.setItem("FULLSCREEN-GIFT-PANEL-SHOW", 0); // 关闭全屏礼物栏
                document.body.classList.add("hide-asida-area", "hide-aside-area"); // 关闭侧边聊天栏
                setTimeout(() => {
                    $("#shop-popover-vm")?.remove(); // 关闭不支持“小橙车”提示
                    $("#sidebar-vm")?.remove();
                }, SECOND_MS / 2);
                topWindow?.livePlayer?.volume(100);
                topWindow?.livePlayer?.switchQuality("10000"); // 原画画质
            } catch (error) {
                console.error("B站直播自动网页全屏异常:", error);
            }
            return true;
        },
    }

    const logicHandlers = [
        { handler: KeydownHandler },
        { handler: WebFullScreenHandler },
        { handler: VideoPlaybackRateHandler },
    ]
    //  使方法内部this指向为ScriptsProgram
    logicHandlers.forEach(({ handler }) => {
        for (const methodName of Object.keys(handler)) {
            ScriptsProgram[methodName] = handler[methodName].bind(ScriptsProgram);
        }
    });

    ScriptsProgram.init();
})();