您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
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 }
- );
- })();