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.2
// @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";

	class Logger {
		static log(...args) {
			console.log("[PiP Debug]", ...args);
			try {
				GM_log(...args);
			} catch (e) {}
		}

		static error(...args) {
			console.error("[PiP Error]", ...args);
			try {
				GM_log("ERROR:", ...args);
			} catch (e) {}
		}
	}

	class BrowserDetector {
		static #cachedResults = new Map();

		static #getCachedValue(key, computeValue) {
			if (!this.#cachedResults.has(key)) {
				this.#cachedResults.set(key, computeValue());
			}
			return this.#cachedResults.get(key);
		}

		static get isEdge() {
			return this.#getCachedValue("isEdge", () =>
				navigator.userAgent.includes("Edg/")
			);
		}

		static get isBrave() {
			return this.#getCachedValue(
				"isBrave",
				() =>
					window.navigator.brave?.isBrave ||
					navigator.userAgent.includes("Brave") ||
					document.documentElement.dataset.browserType === "brave"
			);
		}

		static get isChrome() {
			return this.#getCachedValue(
				"isChrome",
				() =>
					navigator.userAgent.includes("Chrome") &&
					!this.isEdge &&
					!this.isBrave
			);
		}

		static get isFirefox() {
			return this.#getCachedValue("isFirefox", () =>
				navigator.userAgent.includes("Firefox")
			);
		}

		static get isChromiumBased() {
			return this.#getCachedValue(
				"isChromiumBased",
				() => this.isChrome || this.isEdge || this.isBrave
			);
		}

		static get supportsPictureInPicture() {
			return this.#getCachedValue(
				"supportsPictureInPicture",
				() =>
					document.pictureInPictureEnabled ||
					document.documentElement.webkitSupportsPresentationMode?.(
						"picture-in-picture"
					)
			);
		}
	}

	class VideoController {
		constructor() {
			this.isTabActive = !document.hidden;
			this.isPiPRequested = false;
			this.pipInitiatedFromOtherTab = false;
			this.pipAttempts = 0;
			this.MAX_PIP_ATTEMPTS = 5;
			this.PIP_RETRY_DELAY = 500;
			this.lastVideoElement = null;
			this.videoSelectors = {
				"youtube.com": [
					".html5-main-video",
					"video.video-stream",
					"#movie_player video",
				],
				"bilibili.com": [
					".bilibili-player-video video",
					"#bilibili-player video",
					"video",
				],
			};
		}

		async getVideoElement(retryCount = 0, maxRetries = 10) {
			// Return cached element if it's still valid
			if (this.lastVideoElement?.isConnected) {
				return this.lastVideoElement;
			}

			const domain = Object.keys(this.videoSelectors).find((d) =>
				window.location.hostname.includes(d)
			);
			if (!domain) return null;

			let video = null;
			for (const selector of this.videoSelectors[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))
				);
				return this.getVideoElement(retryCount + 1, maxRetries);
			}

			Logger.log(
				video
					? "Video element found!"
					: "Failed to find video element after retries."
			);
			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;
			Logger.log(
				`Attempting PiP on ${
					BrowserDetector.isBrave
						? "Brave"
						: BrowserDetector.isEdge
						? "Edge"
						: "Chrome"
				}...`
			);

			try {
				if (BrowserDetector.isBrave || BrowserDetector.isEdge) {
					video.focus();
					await new Promise((resolve) => setTimeout(resolve, 200));
					if (video.paused) {
						await video.play().catch(() => {});
					}
				}

				// Check if Auto-PiP is supported
				if (
					"mediaSession" in navigator &&
					"setAutoplayPolicy" in navigator.mediaSession
				) {
					navigator.mediaSession.setAutoplayPolicy("allowed");
				}

				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");
			} catch (error) {
				Logger.error("PiP request failed:", error.message);
				this.pipAttempts++;

				if (this.pipAttempts < this.MAX_PIP_ATTEMPTS) {
					Logger.log(`Retrying PiP (attempt ${this.pipAttempts})...`);
					await new Promise((resolve) =>
						setTimeout(
							resolve,
							this.PIP_RETRY_DELAY * Math.pow(1.5, this.pipAttempts)
						)
					);
					return this.requestPictureInPicture(video);
				}
				Logger.error("Max PiP attempts reached");
				return false;
			}
		}

		async enablePiP(forceEnable = false) {
			try {
				const video = await this.getVideoElement();
				if (!video || (!forceEnable && !this.isVideoPlaying(video))) {
					Logger.log("Video not ready for PiP");
					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);
			}
		}

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

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

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

					// Add support for Auto-PiP
					if ("setAutoplayPolicy" in navigator.mediaSession) {
						navigator.mediaSession.setAutoplayPolicy("allowed");
					}

					// Set up playback state handlers
					const playbackHandlers = [
						"play",
						"pause",
						"seekbackward",
						"seekforward",
					];
					playbackHandlers.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");
				}
			}
		}

		initialize() {
			Logger.log("Initializing PiP controller...");

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

			// Set up PiP event handlers
			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]) => {
				document.addEventListener(event, handler, { passive: true });
			});

			// YouTube-specific handling
			if (window.location.hostname.includes("youtube.com")) {
				window.addEventListener(
					"yt-navigate-finish",
					() => {
						setTimeout(async () => {
							if (!this.isTabActive) {
								const video = await this.getVideoElement();
								if (video && this.isVideoPlaying(video)) {
									await this.enablePiP();
								}
							}
						}, 1000);
					},
					{ passive: true }
				);
			}

			this.setupMediaSession();
			this.handleVisibilityChange();
			Logger.log("Initialization complete");
		}
	}

	// Initialize the controller
	const pipController = new VideoController();
	if (document.readyState === "loading") {
		document.addEventListener("DOMContentLoaded", () =>
			pipController.initialize()
		);
	} else {
		pipController.initialize();
	}
})();