您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Automatically enables picture-in-picture mode for YouTube and Bilibili with improved Edge and Brave support
// ==UserScript== // @name Auto Picture-in-Picture // @namespace http://tampermonkey.net/ // @version 1.4 // @description Automatically enables picture-in-picture mode for YouTube and Bilibili with improved Edge and Brave support // @author hong-tm // @license MIT // @icon https://raw.githubusercontent.com/hong-tm/blog-image/main/picture-in-picture.svg // @match https://www.youtube.com/* // @match https://www.bilibili.com/* // @grant GM_log // @run-at document-start // ==/UserScript== (function () { "use strict"; const DEBUG = false; const PERFORMANCE_MONITORING = false; class Logger { static #queue = []; static #batchTimeout = null; static #BATCH_DELAY = 100; static #processBatch() { if (this.#queue.length === 0) return; const messages = this.#queue.splice(0); if (DEBUG) { console.log("[PiP Debug]", ...messages); try { GM_log(...messages); } catch (e) {} } } static log(...args) { if (!DEBUG) return; this.#queue.push(...args); if (!this.#batchTimeout) { this.#batchTimeout = setTimeout(() => { this.#batchTimeout = null; this.#processBatch(); }, this.#BATCH_DELAY); } } static error(...args) { console.error("[PiP Error]", ...args); try { GM_log("ERROR:", ...args); } catch (e) {} } } class PerformanceMonitor { static #metrics = new Map(); static #enabled = PERFORMANCE_MONITORING; static #observer = null; static start(operation) { if (!this.#enabled) return; this.#metrics.set(operation, performance.now()); // Create performance mark performance.mark(`${operation}-start`); } static end(operation) { if (!this.#enabled) return; const startTime = this.#metrics.get(operation); if (startTime) { const duration = performance.now() - startTime; Logger.log(`Performance [${operation}]: ${duration.toFixed(2)}ms`); this.#metrics.delete(operation); // Create performance measure performance.mark(`${operation}-end`); performance.measure( operation, `${operation}-start`, `${operation}-end` ); } } static initPerformanceObserver() { if (!this.#enabled || this.#observer) return; try { this.#observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.entryType === "measure") { Logger.log( `Performance Measure [${entry.name}]: ${entry.duration.toFixed( 2 )}ms` ); } }); }); this.#observer.observe({ entryTypes: ["measure", "mark"] }); } catch (e) { Logger.error("PerformanceObserver not supported:", e); } } static cleanup() { if (this.#observer) { this.#observer.disconnect(); this.#observer = null; } } } class MediaCapabilitiesHelper { static async checkVideoCapabilities(video) { if (!("mediaCapabilities" in navigator)) return true; try { const mediaConfig = { type: "file", video: { contentType: video.videoWidth > 1920 ? 'video/webm; codecs="vp9"' : 'video/webm; codecs="vp8"', width: video.videoWidth, height: video.videoHeight, bitrate: 2000000, framerate: 30, }, }; const result = await navigator.mediaCapabilities.decodingInfo( mediaConfig ); return result.supported && result.smooth && result.powerEfficient; } catch (e) { Logger.error("Media Capabilities check failed:", e); return true; } } } class BrowserDetector { static #cachedResults = new Map(); static #browserInfo = null; static #initBrowserInfo() { if (this.#browserInfo) return; const ua = navigator.userAgent; this.#browserInfo = { isEdge: ua.includes("Edg/"), isBrave: window.navigator.brave?.isBrave || ua.includes("Brave") || document.documentElement.dataset.browserType === "brave", isFirefox: ua.includes("Firefox"), supportsDocumentPiP: "documentPictureInPicture" in window, }; this.#browserInfo.isChrome = ua.includes("Chrome") && !this.#browserInfo.isEdge && !this.#browserInfo.isBrave; this.#browserInfo.isChromiumBased = this.#browserInfo.isChrome || this.#browserInfo.isEdge || this.#browserInfo.isBrave; } static #getCachedValue(key, computeValue) { if (!this.#cachedResults.has(key)) { this.#cachedResults.set(key, computeValue()); } return this.#cachedResults.get(key); } static get isEdge() { this.#initBrowserInfo(); return this.#browserInfo.isEdge; } static get isBrave() { this.#initBrowserInfo(); return this.#browserInfo.isBrave; } static get isChrome() { this.#initBrowserInfo(); return this.#browserInfo.isChrome; } static get isFirefox() { this.#initBrowserInfo(); return this.#browserInfo.isFirefox; } static get isChromiumBased() { this.#initBrowserInfo(); return this.#browserInfo.isChromiumBased; } static get supportsPictureInPicture() { return this.#getCachedValue( "supportsPictureInPicture", () => document.pictureInPictureEnabled || document.documentElement.webkitSupportsPresentationMode?.( "picture-in-picture" ) ); } static get supportsDocumentPiP() { this.#initBrowserInfo(); return this.#browserInfo.supportsDocumentPiP; } } class VideoController { #isTabActive = !document.hidden; #isPiPRequested = false; #pipInitiatedFromOtherTab = false; #pipAttempts = 0; #lastVideoElement = null; #videoObserver = null; #eventListeners = new Set(); #debounceTimers = new Map(); #hasUserGesture = false; static MAX_PIP_ATTEMPTS = 3; static PIP_RETRY_DELAY = 500; static VIDEO_SELECTORS = { "youtube.com": [ ".html5-main-video", "video.video-stream", "#movie_player video", ], "bilibili.com": [ ".bilibili-player-video video", "#bilibili-player video", "video", ], }; constructor() { this.#setupVideoObserver(); } #debounce(fn, delay) { return (...args) => { const key = fn.toString(); if (this.#debounceTimers.has(key)) { clearTimeout(this.#debounceTimers.get(key)); } this.#debounceTimers.set( key, setTimeout(() => { this.#debounceTimers.delete(key); fn.apply(this, args); }, delay) ); }; } #setupVideoObserver() { this.#videoObserver = new MutationObserver( this.#debounce(() => { if (!this.#lastVideoElement?.isConnected) { this.getVideoElement().then((video) => { if (video && !this.#isTabActive && this.isVideoPlaying(video)) { this.enablePiP(true); } }); } }, 200) ); this.#videoObserver.observe(document.documentElement, { childList: true, subtree: true, }); } async getVideoElement(retryCount = 0, maxRetries = 5) { PerformanceMonitor.start("getVideoElement"); if (this.#lastVideoElement?.isConnected) { PerformanceMonitor.end("getVideoElement"); return this.#lastVideoElement; } const domain = Object.keys(VideoController.VIDEO_SELECTORS).find((d) => window.location.hostname.includes(d) ); if (!domain) { PerformanceMonitor.end("getVideoElement"); return null; } let video = null; for (const selector of VideoController.VIDEO_SELECTORS[domain]) { video = document.querySelector(selector); if (video) { this.#lastVideoElement = video; break; } } if (!video && retryCount < maxRetries) { Logger.log( `Video element not found, retrying... (${ retryCount + 1 }/${maxRetries})` ); await new Promise((resolve) => setTimeout(resolve, Math.min(200 * (retryCount + 1), 1000)) ); PerformanceMonitor.end("getVideoElement"); return this.getVideoElement(retryCount + 1, maxRetries); } Logger.log( video ? "Video element found!" : "Failed to find video element after retries." ); PerformanceMonitor.end("getVideoElement"); return video; } isVideoPlaying(video) { if (!video) return false; return ( !video.paused && !video.ended && video.readyState > 2 && video.currentTime > 0 ); } async requestPictureInPicture(video) { if (!video) return false; PerformanceMonitor.start("requestPictureInPicture"); try { // Check media capabilities first const isCapable = await MediaCapabilitiesHelper.checkVideoCapabilities( video ); if (!isCapable) { Logger.log("Video playback might not be smooth or power efficient"); } // Setup media session for automatic PiP if ("mediaSession" in navigator) { try { navigator.mediaSession.setActionHandler( "enterpictureinpicture", async () => { await video.requestPictureInPicture().catch(() => {}); } ); if ("setAutoplayPolicy" in navigator.mediaSession) { navigator.mediaSession.setAutoplayPolicy("allowed"); } // Set media session metadata for better system integration navigator.mediaSession.metadata = new MediaMetadata({ title: document.title, artwork: [ { src: document.querySelector('link[rel="icon"]')?.href || "", sizes: "96x96", type: "image/png", }, ], }); } catch (e) { Logger.log("Some media session features not supported"); } } // Handle browser-specific cases if (BrowserDetector.isBrave || BrowserDetector.isEdge) { video.focus(); await new Promise((resolve) => setTimeout(resolve, 200)); if (video.paused) { await video.play().catch(() => {}); } } // Try to enter PiP mode if (document.pictureInPictureEnabled) { try { await video.requestPictureInPicture(); Logger.log("PiP activated successfully!"); this.#pipAttempts = 0; PerformanceMonitor.end("requestPictureInPicture"); return true; } catch (e) { // If direct PiP request fails, try using media session if ("mediaSession" in navigator) { navigator.mediaSession.metadata = new MediaMetadata({ title: document.title, }); Logger.log("Attempting automatic PiP via media session"); // Force a visibility change to trigger PiP this.#handleVisibilityChange(); return true; } throw e; } } else if (video.webkitSetPresentationMode) { await video.webkitSetPresentationMode("picture-in-picture"); Logger.log("Safari PiP activated successfully!"); this.#pipAttempts = 0; PerformanceMonitor.end("requestPictureInPicture"); return true; } throw new Error("PiP not supported"); } catch (error) { Logger.error("PiP request failed:", error.message); this.#pipAttempts++; if (this.#pipAttempts < VideoController.MAX_PIP_ATTEMPTS) { Logger.log(`Retrying PiP (attempt ${this.#pipAttempts})...`); await new Promise((resolve) => setTimeout( resolve, VideoController.PIP_RETRY_DELAY * Math.pow(1.5, this.#pipAttempts) ) ); PerformanceMonitor.end("requestPictureInPicture"); return this.requestPictureInPicture(video); } Logger.error("Max PiP attempts reached"); PerformanceMonitor.end("requestPictureInPicture"); return false; } } async enablePiP(forceEnable = false) { PerformanceMonitor.start("enablePiP"); try { const video = await this.getVideoElement(); if (!video || (!forceEnable && !this.isVideoPlaying(video))) { Logger.log("Video not ready for PiP"); PerformanceMonitor.end("enablePiP"); return; } if (!document.pictureInPictureElement && !this.#isPiPRequested) { // Set initial state this.#hasUserGesture = true; const success = await this.requestPictureInPicture(video); if (success) { this.#isPiPRequested = true; this.#pipInitiatedFromOtherTab = !this.#isTabActive; } // Reset user gesture flag after attempt this.#hasUserGesture = false; } } catch (error) { Logger.error("Enable PiP error:", error); } PerformanceMonitor.end("enablePiP"); } async disablePiP() { if (document.pictureInPictureElement && !this.#pipInitiatedFromOtherTab) { try { await document.exitPictureInPicture(); Logger.log("PiP mode exited"); this.#isPiPRequested = false; this.#pipAttempts = 0; } catch (error) { Logger.error("Exit PiP error:", error); } } } #handleVisibilityChange = this.#debounce(async () => { const previousState = this.#isTabActive; this.#isTabActive = !document.hidden; Logger.log( `Tab visibility changed: ${this.#isTabActive ? "visible" : "hidden"}` ); if (previousState !== this.#isTabActive) { if (this.#isTabActive) { if (!this.#pipInitiatedFromOtherTab) { await this.disablePiP(); } } else { const video = await this.getVideoElement(); if (video && this.isVideoPlaying(video)) { const delay = BrowserDetector.isChromiumBased ? 200 : 0; setTimeout(() => this.enablePiP(true), delay); } this.#pipInitiatedFromOtherTab = false; } } }, 100); setupMediaSession() { if ("mediaSession" in navigator) { try { navigator.mediaSession.setActionHandler( "enterpictureinpicture", async () => { if (!this.#isTabActive) { await this.enablePiP(true); } } ); if ("setAutoplayPolicy" in navigator.mediaSession) { navigator.mediaSession.setAutoplayPolicy("allowed"); } ["play", "pause", "seekbackward", "seekforward"].forEach((action) => { try { navigator.mediaSession.setActionHandler(action, null); } catch (e) { Logger.log(`${action} handler not supported`); } }); Logger.log("Media session handlers set up"); } catch (error) { Logger.log("Some media session features not supported"); } } } #addEventListeners() { const addListener = ( target, event, handler, options = { passive: true } ) => { target.addEventListener(event, handler, options); this.#eventListeners.add({ target, event, handler }); }; // Track user interactions to detect user gestures ["mousedown", "keydown", "touchstart"].forEach((eventType) => { addListener(document, eventType, () => { this.#hasUserGesture = true; // Reset after a short delay setTimeout(() => { this.#hasUserGesture = false; }, 1000); }); }); addListener(document, "visibilitychange", this.#handleVisibilityChange); const pipEvents = [ [ "enterpictureinpicture", () => { this.#pipInitiatedFromOtherTab = !this.#isTabActive; this.#isPiPRequested = true; this.#pipAttempts = 0; Logger.log("Entered PiP mode"); }, ], [ "leavepictureinpicture", () => { this.#isPiPRequested = false; this.#pipInitiatedFromOtherTab = false; this.#pipAttempts = 0; Logger.log("Left PiP mode"); }, ], ]; pipEvents.forEach(([event, handler]) => { addListener(document, event, handler); }); if (window.location.hostname.includes("youtube.com")) { addListener( window, "yt-navigate-finish", this.#debounce(async () => { if (!this.#isTabActive) { const video = await this.getVideoElement(); if (video && this.isVideoPlaying(video)) { await this.enablePiP(); } } }, 1000) ); } } cleanup() { this.#eventListeners.forEach(({ target, event, handler }) => { target.removeEventListener(event, handler); }); this.#eventListeners.clear(); if (this.#videoObserver) { this.#videoObserver.disconnect(); this.#videoObserver = null; } this.#debounceTimers.forEach((timer) => clearTimeout(timer)); this.#debounceTimers.clear(); PerformanceMonitor.cleanup(); } initialize() { Logger.log("Initializing PiP controller..."); PerformanceMonitor.initPerformanceObserver(); this.#addEventListeners(); this.setupMediaSession(); // Force immediate visibility check and PiP attempt setTimeout(() => { this.#isTabActive = !document.hidden; if (!this.#isTabActive) { this.getVideoElement().then((video) => { if (video && this.isVideoPlaying(video)) { this.enablePiP(true); } }); } }, 500); Logger.log("Initialization complete"); } } // Initialize the controller const pipController = new VideoController(); if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", () => pipController.initialize() ); } else { pipController.initialize(); } // Cleanup on unload window.addEventListener( "unload", () => { pipController.cleanup(); }, { passive: true } ); })();