Auto Picture-in-Picture

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

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 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 }
	);
})();