Auto Picture-in-Picture

Automatically enables picture-in-picture mode for videos with improved cross-browser support

// ==UserScript==
// @name         Auto Picture-in-Picture
// @namespace    http://tampermonkey.net/
// @version      1.3
// @description  Automatically enables picture-in-picture mode for videos with improved cross-browser 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";

	// Configuration constants
	const CONFIG = Object.freeze({
		MAX_PIP_ATTEMPTS: 5,
		PIP_RETRY_DELAY: 500,
		YT_NAVIGATION_DELAY: 1000,
		VIDEO_RETRY_DELAY: 200,
		VIDEO_MAX_RETRIES: 10,
		DEBUG: false, // Set to true to enable detailed logging
		RESPECT_NATIVE: true, // Prioritize browser's native Auto PiP functionality
	});

	// Simplified logger
	const Logger = {
		log(...args) {
			if (!CONFIG.DEBUG) return;
			console.log("[PiP]", ...args);
			try {
				GM_log(...args);
			} catch {}
		},
		error(...args) {
			console.error("[PiP Error]", ...args);
			try {
				GM_log("ERROR:", ...args);
			} catch {}
		},
	};

	// Browser detector - using lazy loading and caching results
	const BrowserDetector = (() => {
		const cache = new Map();

		const getCached = (key, fn) => {
			if (!cache.has(key)) {
				cache.set(key, fn());
			}
			return cache.get(key);
		};

		const ua = navigator.userAgent;

		return {
			get isEdge() {
				return getCached("isEdge", () => ua.includes("Edg/"));
			},
			get isBrave() {
				return getCached(
					"isBrave",
					() =>
						window.navigator.brave?.isBrave ||
						ua.includes("Brave") ||
						document.documentElement.dataset.browserType === "brave"
				);
			},
			get isFirefox() {
				return getCached("isFirefox", () => ua.includes("Firefox"));
			},
			get isChromiumBased() {
				return getCached(
					"isChromiumBased",
					() =>
						(ua.includes("Chrome") && !this.isEdge && !this.isBrave) ||
						this.isEdge ||
						this.isBrave
				);
			},
			get supportsPictureInPicture() {
				return getCached(
					"supportsPictureInPicture",
					() =>
						document.pictureInPictureEnabled ||
						document.documentElement.webkitSupportsPresentationMode?.(
							"picture-in-picture"
						)
				);
			},
			// Detect if browser supports native Auto PiP
			get supportsNativeAutoPiP() {
				return getCached("supportsNativeAutoPiP", () => {
					// Chrome/Edge/Brave 137+ detection (by checking specific flags API)
					if (this.isChromiumBased) {
						// Check if auto-picture-in-picture-for-video-playback flag is enabled
						const hasAutoPiPFlag =
							"chrome" in window &&
							chrome.webviewTag !== undefined &&
							"mediaSession" in navigator &&
							"setAutoplayPolicy" in navigator.mediaSession;

						if (hasAutoPiPFlag) {
							Logger.log("Native Auto PiP detected in Chromium browser");
							return true;
						}
					}

					// Firefox 130+ detection (through specific Firefox API behavior)
					if (this.isFirefox) {
						// Firefox has no direct API detection, check version and specific behavior
						const firefoxMatch = ua.match(/Firefox\/(\d+)/);
						if (firefoxMatch && parseInt(firefoxMatch[1], 10) >= 130) {
							Logger.log(
								"Potentially using Firefox with native Auto PiP support"
							);
							return true;
						}
					}

					return false;
				});
			},
		};
	})();

	// Video controller class - handles PiP operations
	class VideoController {
		constructor() {
			this.isTabActive = !document.hidden;
			this.isPiPRequested = false;
			this.pipInitiatedFromOtherTab = false;
			this.pipAttempts = 0;
			this.lastVideoElement = null;
			this.usingNativeAutoPiP = false;

			// Domain-specific video selectors
			this.videoSelectors = {
				"youtube.com": [
					".html5-main-video",
					"video.video-stream",
					"#movie_player video",
				],
				"bilibili.com": [
					".bilibili-player-video video",
					"#bilibili-player video",
					"video",
				],
			};

			// Detect if browser supports native Auto PiP
			this.checkNativeAutoPiPSupport();

			// Bind methods to maintain correct 'this' context
			this.handleVisibilityChange = this.handleVisibilityChange.bind(this);
			this.handlePipEnter = this.handlePipEnter.bind(this);
			this.handlePipExit = this.handlePipExit.bind(this);
			this.handleYouTubeNavigation = this.handleYouTubeNavigation.bind(this);
		}

		/**
		 * Detect whether to use browser's native Auto PiP functionality
		 */
		checkNativeAutoPiPSupport() {
			if (CONFIG.RESPECT_NATIVE && BrowserDetector.supportsNativeAutoPiP) {
				this.usingNativeAutoPiP = true;
				Logger.log("Using native browser Auto PiP functionality");
			} else {
				this.usingNativeAutoPiP = false;
				Logger.log("Using script's Auto PiP implementation");
			}
		}

		/**
		 * Get the video element from the current page
		 * @param {number} retryCount - Current retry count
		 * @returns {Promise<HTMLVideoElement|null>} Video element or null
		 */
		async getVideoElement(retryCount = 0) {
			// Check if cached element is still valid
			if (this.lastVideoElement?.isConnected) {
				return this.lastVideoElement;
			}

			// Determine selectors for the current domain
			const domain = Object.keys(this.videoSelectors).find((d) =>
				window.location.hostname.includes(d)
			);

			if (!domain) return null;

			// Try all possible selectors
			for (const selector of this.videoSelectors[domain]) {
				const video = document.querySelector(selector);
				if (video) {
					this.lastVideoElement = video;
					Logger.log("Video element found");
					return video;
				}
			}

			// If no video found and maximum retries not exceeded, retry
			if (retryCount < CONFIG.VIDEO_MAX_RETRIES) {
				const delay = Math.min(
					CONFIG.VIDEO_RETRY_DELAY * (retryCount + 1),
					1000
				);
				await new Promise((resolve) => setTimeout(resolve, delay));
				return this.getVideoElement(retryCount + 1);
			}

			Logger.log("Failed to find video element after retries");
			return null;
		}

		/**
		 * Check if a video is currently playing
		 * @param {HTMLVideoElement} video - Video element
		 * @returns {boolean} Whether the video is playing
		 */
		isVideoPlaying(video) {
			return (
				video &&
				!video.paused &&
				!video.ended &&
				video.readyState > 2 &&
				video.currentTime > 0
			);
		}

		/**
		 * Request Picture-in-Picture mode
		 * @param {HTMLVideoElement} video - Video element
		 * @returns {Promise<boolean>} Whether successful
		 */
		async requestPictureInPicture(video) {
			if (!video) return false;

			try {
				// Special handling for Brave and Edge
				if (BrowserDetector.isBrave || BrowserDetector.isEdge) {
					video.focus();
					await new Promise((resolve) => setTimeout(resolve, 200));
					if (video.paused) {
						await video.play().catch(() => {});
					}
				}

				// Try to set media session autoplay policy
				if (
					"mediaSession" in navigator &&
					"setAutoplayPolicy" in navigator.mediaSession
				) {
					navigator.mediaSession.setAutoplayPolicy("allowed");
				}

				// Try to request PiP mode
				if (document.pictureInPictureEnabled) {
					await video.requestPictureInPicture();
					Logger.log("PiP activated successfully");
					this.pipAttempts = 0;
					return true;
				} else if (video.webkitSetPresentationMode) {
					await video.webkitSetPresentationMode("picture-in-picture");
					Logger.log("Safari PiP activated successfully");
					this.pipAttempts = 0;
					return true;
				}

				throw new Error("PiP not supported in this browser");
			} catch (error) {
				Logger.error(`PiP request failed: ${error.message}`);
				this.pipAttempts++;

				// Use exponential backoff strategy for retries
				if (this.pipAttempts < CONFIG.MAX_PIP_ATTEMPTS) {
					const delay =
						CONFIG.PIP_RETRY_DELAY * Math.pow(1.5, this.pipAttempts);
					Logger.log(
						`Retrying PiP (attempt ${this.pipAttempts}) in ${delay}ms`
					);
					await new Promise((resolve) => setTimeout(resolve, delay));
					return this.requestPictureInPicture(video);
				}

				Logger.error("Max PiP attempts reached");
				return false;
			}
		}

		/**
		 * Enable Picture-in-Picture mode
		 * @param {boolean} forceEnable - Whether to force enable
		 * @returns {Promise<void>}
		 */
		async enablePiP(forceEnable = false) {
			// If using native Auto PiP, skip our implementation (unless forced)
			if (this.usingNativeAutoPiP && !forceEnable) {
				Logger.log("Relying on native Auto PiP functionality");
				return;
			}

			try {
				const video = await this.getVideoElement();
				if (!video || (!forceEnable && !this.isVideoPlaying(video))) {
					return;
				}

				if (!document.pictureInPictureElement && !this.isPiPRequested) {
					const success = await this.requestPictureInPicture(video);
					if (success) {
						this.isPiPRequested = true;
						this.pipInitiatedFromOtherTab = !this.isTabActive;
					}
				}
			} catch (error) {
				Logger.error(`Enable PiP error: ${error.message}`);
			}
		}

		/**
		 * Disable Picture-in-Picture mode
		 * @returns {Promise<void>}
		 */
		async disablePiP() {
			// If using native Auto PiP, skip our implementation
			if (this.usingNativeAutoPiP) {
				Logger.log("Relying on native Auto PiP functionality for exit");
				return;
			}

			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.message}`);
				}
			}
		}

		/**
		 * Handle tab visibility changes
		 * @returns {Promise<void>}
		 */
		async handleVisibilityChange() {
			const previousState = this.isTabActive;
			this.isTabActive = !document.hidden;

			// Only process if state actually changed
			if (previousState === this.isTabActive) return;

			Logger.log(`Tab visibility: ${this.isTabActive ? "visible" : "hidden"}`);

			// If using native Auto PiP, reduce our intervention
			if (this.usingNativeAutoPiP) {
				Logger.log("Relying on native Auto PiP for visibility change");
				return;
			}

			if (this.isTabActive) {
				// Exit PiP when tab becomes active (unless initiated from another tab)
				if (!this.pipInitiatedFromOtherTab) {
					await this.disablePiP();
				}
			} else {
				// Enable PiP when tab becomes inactive
				const video = await this.getVideoElement();
				if (video && this.isVideoPlaying(video)) {
					const delay = BrowserDetector.isChromiumBased ? 200 : 0;
					setTimeout(() => this.enablePiP(true), delay);
				}
				this.pipInitiatedFromOtherTab = false;
			}
		}

		/**
		 * Set up media session handlers
		 */
		setupMediaSession() {
			if (!("mediaSession" in navigator)) return;

			try {
				// Set up PiP action handler
				if ("setActionHandler" in navigator.mediaSession) {
					navigator.mediaSession.setActionHandler(
						"enterpictureinpicture",
						async () => {
							if (!this.isTabActive) {
								await this.enablePiP(true);
							}
						}
					);

					// Set up other playback control handlers
					["play", "pause", "seekbackward", "seekforward"].forEach((action) => {
						try {
							navigator.mediaSession.setActionHandler(action, null);
						} catch {}
					});
				}

				// Allow autoplay
				if ("setAutoplayPolicy" in navigator.mediaSession) {
					navigator.mediaSession.setAutoplayPolicy("allowed");
				}

				Logger.log("Media session handlers set up");
			} catch (error) {
				Logger.log("Some media session features not supported");
			}
		}

		/**
		 * Handle PiP enter event
		 */
		handlePipEnter() {
			this.pipInitiatedFromOtherTab = !this.isTabActive;
			this.isPiPRequested = true;
			this.pipAttempts = 0;
			Logger.log("Entered PiP mode");
		}

		/**
		 * Handle PiP exit event
		 */
		handlePipExit() {
			this.isPiPRequested = false;
			this.pipInitiatedFromOtherTab = false;
			this.pipAttempts = 0;
			Logger.log("Left PiP mode");
		}

		/**
		 * Handle YouTube navigation events
		 */
		async handleYouTubeNavigation() {
			// If using native Auto PiP, reduce our intervention
			if (this.usingNativeAutoPiP) return;

			// Delay check for video after navigation
			setTimeout(async () => {
				if (!this.isTabActive) {
					const video = await this.getVideoElement();
					if (video && this.isVideoPlaying(video)) {
						await this.enablePiP();
					}
				}
			}, CONFIG.YT_NAVIGATION_DELAY);
		}

		/**
		 * Initialize the controller
		 */
		initialize() {
			Logger.log("Initializing PiP controller...");
			Logger.log(
				`Browser: ${
					BrowserDetector.isFirefox
						? "Firefox"
						: BrowserDetector.isEdge
						? "Edge"
						: BrowserDetector.isBrave
						? "Brave"
						: "Chrome"
				}`
			);
			Logger.log(
				`Native Auto PiP support: ${this.usingNativeAutoPiP ? "Yes" : "No"}`
			);

			// Set up visibility change handler
			document.addEventListener(
				"visibilitychange",
				this.handleVisibilityChange,
				{ passive: true }
			);

			// Set up PiP event handlers
			document.addEventListener("enterpictureinpicture", this.handlePipEnter, {
				passive: true,
			});
			document.addEventListener("leavepictureinpicture", this.handlePipExit, {
				passive: true,
			});

			// YouTube-specific handling
			if (window.location.hostname.includes("youtube.com")) {
				window.addEventListener(
					"yt-navigate-finish",
					this.handleYouTubeNavigation,
					{ passive: true }
				);
			}

			// Set up media session
			this.setupMediaSession();

			// Initial visibility state handling
			this.handleVisibilityChange();

			Logger.log("Initialization complete");
		}
	}

	// Initialize controller
	const pipController = new VideoController();

	// Initialize after DOM loads
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", () =>
			pipController.initialize()
		);
	} else {
		pipController.initialize();
	}
})();