Auto Picture-in-Picture

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