Auto Picture-in-Picture

Automatically enables picture-in-picture mode for YouTube and Bilibili with improved Edge and Brave support

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

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