Auto Picture-in-Picture

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

  1. // ==UserScript==
  2. // @name Auto Picture-in-Picture
  3. // @namespace http://tampermonkey.net/
  4. // @version 1.4
  5. // @description Automatically enables picture-in-picture mode for YouTube and Bilibili with improved Edge and Brave support
  6. // @author hong-tm
  7. // @license MIT
  8. // @icon https://raw.githubusercontent.com/hong-tm/blog-image/main/picture-in-picture.svg
  9. // @match https://www.youtube.com/*
  10. // @match https://www.bilibili.com/*
  11. // @grant GM_log
  12. // @run-at document-start
  13. // ==/UserScript==
  14.  
  15. (function () {
  16. "use strict";
  17.  
  18. const DEBUG = false;
  19. const PERFORMANCE_MONITORING = false;
  20.  
  21. class Logger {
  22. static #queue = [];
  23. static #batchTimeout = null;
  24. static #BATCH_DELAY = 100;
  25.  
  26. static #processBatch() {
  27. if (this.#queue.length === 0) return;
  28. const messages = this.#queue.splice(0);
  29. if (DEBUG) {
  30. console.log("[PiP Debug]", ...messages);
  31. try {
  32. GM_log(...messages);
  33. } catch (e) {}
  34. }
  35. }
  36.  
  37. static log(...args) {
  38. if (!DEBUG) return;
  39. this.#queue.push(...args);
  40. if (!this.#batchTimeout) {
  41. this.#batchTimeout = setTimeout(() => {
  42. this.#batchTimeout = null;
  43. this.#processBatch();
  44. }, this.#BATCH_DELAY);
  45. }
  46. }
  47.  
  48. static error(...args) {
  49. console.error("[PiP Error]", ...args);
  50. try {
  51. GM_log("ERROR:", ...args);
  52. } catch (e) {}
  53. }
  54. }
  55.  
  56. class PerformanceMonitor {
  57. static #metrics = new Map();
  58. static #enabled = PERFORMANCE_MONITORING;
  59. static #observer = null;
  60.  
  61. static start(operation) {
  62. if (!this.#enabled) return;
  63. this.#metrics.set(operation, performance.now());
  64.  
  65. // Create performance mark
  66. performance.mark(`${operation}-start`);
  67. }
  68.  
  69. static end(operation) {
  70. if (!this.#enabled) return;
  71. const startTime = this.#metrics.get(operation);
  72. if (startTime) {
  73. const duration = performance.now() - startTime;
  74. Logger.log(`Performance [${operation}]: ${duration.toFixed(2)}ms`);
  75. this.#metrics.delete(operation);
  76.  
  77. // Create performance measure
  78. performance.mark(`${operation}-end`);
  79. performance.measure(
  80. operation,
  81. `${operation}-start`,
  82. `${operation}-end`
  83. );
  84. }
  85. }
  86.  
  87. static initPerformanceObserver() {
  88. if (!this.#enabled || this.#observer) return;
  89.  
  90. try {
  91. this.#observer = new PerformanceObserver((list) => {
  92. list.getEntries().forEach((entry) => {
  93. if (entry.entryType === "measure") {
  94. Logger.log(
  95. `Performance Measure [${entry.name}]: ${entry.duration.toFixed(
  96. 2
  97. )}ms`
  98. );
  99. }
  100. });
  101. });
  102.  
  103. this.#observer.observe({ entryTypes: ["measure", "mark"] });
  104. } catch (e) {
  105. Logger.error("PerformanceObserver not supported:", e);
  106. }
  107. }
  108.  
  109. static cleanup() {
  110. if (this.#observer) {
  111. this.#observer.disconnect();
  112. this.#observer = null;
  113. }
  114. }
  115. }
  116.  
  117. class MediaCapabilitiesHelper {
  118. static async checkVideoCapabilities(video) {
  119. if (!("mediaCapabilities" in navigator)) return true;
  120.  
  121. try {
  122. const mediaConfig = {
  123. type: "file",
  124. video: {
  125. contentType:
  126. video.videoWidth > 1920
  127. ? 'video/webm; codecs="vp9"'
  128. : 'video/webm; codecs="vp8"',
  129. width: video.videoWidth,
  130. height: video.videoHeight,
  131. bitrate: 2000000,
  132. framerate: 30,
  133. },
  134. };
  135.  
  136. const result = await navigator.mediaCapabilities.decodingInfo(
  137. mediaConfig
  138. );
  139. return result.supported && result.smooth && result.powerEfficient;
  140. } catch (e) {
  141. Logger.error("Media Capabilities check failed:", e);
  142. return true;
  143. }
  144. }
  145. }
  146.  
  147. class BrowserDetector {
  148. static #cachedResults = new Map();
  149. static #browserInfo = null;
  150.  
  151. static #initBrowserInfo() {
  152. if (this.#browserInfo) return;
  153. const ua = navigator.userAgent;
  154. this.#browserInfo = {
  155. isEdge: ua.includes("Edg/"),
  156. isBrave:
  157. window.navigator.brave?.isBrave ||
  158. ua.includes("Brave") ||
  159. document.documentElement.dataset.browserType === "brave",
  160. isFirefox: ua.includes("Firefox"),
  161. supportsDocumentPiP: "documentPictureInPicture" in window,
  162. };
  163. this.#browserInfo.isChrome =
  164. ua.includes("Chrome") &&
  165. !this.#browserInfo.isEdge &&
  166. !this.#browserInfo.isBrave;
  167. this.#browserInfo.isChromiumBased =
  168. this.#browserInfo.isChrome ||
  169. this.#browserInfo.isEdge ||
  170. this.#browserInfo.isBrave;
  171. }
  172.  
  173. static #getCachedValue(key, computeValue) {
  174. if (!this.#cachedResults.has(key)) {
  175. this.#cachedResults.set(key, computeValue());
  176. }
  177. return this.#cachedResults.get(key);
  178. }
  179.  
  180. static get isEdge() {
  181. this.#initBrowserInfo();
  182. return this.#browserInfo.isEdge;
  183. }
  184.  
  185. static get isBrave() {
  186. this.#initBrowserInfo();
  187. return this.#browserInfo.isBrave;
  188. }
  189.  
  190. static get isChrome() {
  191. this.#initBrowserInfo();
  192. return this.#browserInfo.isChrome;
  193. }
  194.  
  195. static get isFirefox() {
  196. this.#initBrowserInfo();
  197. return this.#browserInfo.isFirefox;
  198. }
  199.  
  200. static get isChromiumBased() {
  201. this.#initBrowserInfo();
  202. return this.#browserInfo.isChromiumBased;
  203. }
  204.  
  205. static get supportsPictureInPicture() {
  206. return this.#getCachedValue(
  207. "supportsPictureInPicture",
  208. () =>
  209. document.pictureInPictureEnabled ||
  210. document.documentElement.webkitSupportsPresentationMode?.(
  211. "picture-in-picture"
  212. )
  213. );
  214. }
  215.  
  216. static get supportsDocumentPiP() {
  217. this.#initBrowserInfo();
  218. return this.#browserInfo.supportsDocumentPiP;
  219. }
  220. }
  221.  
  222. class VideoController {
  223. #isTabActive = !document.hidden;
  224. #isPiPRequested = false;
  225. #pipInitiatedFromOtherTab = false;
  226. #pipAttempts = 0;
  227. #lastVideoElement = null;
  228. #videoObserver = null;
  229. #eventListeners = new Set();
  230. #debounceTimers = new Map();
  231. #hasUserGesture = false;
  232.  
  233. static MAX_PIP_ATTEMPTS = 3;
  234. static PIP_RETRY_DELAY = 500;
  235. static VIDEO_SELECTORS = {
  236. "youtube.com": [
  237. ".html5-main-video",
  238. "video.video-stream",
  239. "#movie_player video",
  240. ],
  241. "bilibili.com": [
  242. ".bilibili-player-video video",
  243. "#bilibili-player video",
  244. "video",
  245. ],
  246. };
  247.  
  248. constructor() {
  249. this.#setupVideoObserver();
  250. }
  251.  
  252. #debounce(fn, delay) {
  253. return (...args) => {
  254. const key = fn.toString();
  255. if (this.#debounceTimers.has(key)) {
  256. clearTimeout(this.#debounceTimers.get(key));
  257. }
  258. this.#debounceTimers.set(
  259. key,
  260. setTimeout(() => {
  261. this.#debounceTimers.delete(key);
  262. fn.apply(this, args);
  263. }, delay)
  264. );
  265. };
  266. }
  267.  
  268. #setupVideoObserver() {
  269. this.#videoObserver = new MutationObserver(
  270. this.#debounce(() => {
  271. if (!this.#lastVideoElement?.isConnected) {
  272. this.getVideoElement().then((video) => {
  273. if (video && !this.#isTabActive && this.isVideoPlaying(video)) {
  274. this.enablePiP(true);
  275. }
  276. });
  277. }
  278. }, 200)
  279. );
  280.  
  281. this.#videoObserver.observe(document.documentElement, {
  282. childList: true,
  283. subtree: true,
  284. });
  285. }
  286.  
  287. async getVideoElement(retryCount = 0, maxRetries = 5) {
  288. PerformanceMonitor.start("getVideoElement");
  289.  
  290. if (this.#lastVideoElement?.isConnected) {
  291. PerformanceMonitor.end("getVideoElement");
  292. return this.#lastVideoElement;
  293. }
  294.  
  295. const domain = Object.keys(VideoController.VIDEO_SELECTORS).find((d) =>
  296. window.location.hostname.includes(d)
  297. );
  298. if (!domain) {
  299. PerformanceMonitor.end("getVideoElement");
  300. return null;
  301. }
  302.  
  303. let video = null;
  304. for (const selector of VideoController.VIDEO_SELECTORS[domain]) {
  305. video = document.querySelector(selector);
  306. if (video) {
  307. this.#lastVideoElement = video;
  308. break;
  309. }
  310. }
  311.  
  312. if (!video && retryCount < maxRetries) {
  313. Logger.log(
  314. `Video element not found, retrying... (${
  315. retryCount + 1
  316. }/${maxRetries})`
  317. );
  318. await new Promise((resolve) =>
  319. setTimeout(resolve, Math.min(200 * (retryCount + 1), 1000))
  320. );
  321. PerformanceMonitor.end("getVideoElement");
  322. return this.getVideoElement(retryCount + 1, maxRetries);
  323. }
  324.  
  325. Logger.log(
  326. video
  327. ? "Video element found!"
  328. : "Failed to find video element after retries."
  329. );
  330. PerformanceMonitor.end("getVideoElement");
  331. return video;
  332. }
  333.  
  334. isVideoPlaying(video) {
  335. if (!video) return false;
  336. return (
  337. !video.paused &&
  338. !video.ended &&
  339. video.readyState > 2 &&
  340. video.currentTime > 0
  341. );
  342. }
  343.  
  344. async requestPictureInPicture(video) {
  345. if (!video) return false;
  346. PerformanceMonitor.start("requestPictureInPicture");
  347.  
  348. try {
  349. // Check media capabilities first
  350. const isCapable = await MediaCapabilitiesHelper.checkVideoCapabilities(
  351. video
  352. );
  353. if (!isCapable) {
  354. Logger.log("Video playback might not be smooth or power efficient");
  355. }
  356.  
  357. // Setup media session for automatic PiP
  358. if ("mediaSession" in navigator) {
  359. try {
  360. navigator.mediaSession.setActionHandler(
  361. "enterpictureinpicture",
  362. async () => {
  363. await video.requestPictureInPicture().catch(() => {});
  364. }
  365. );
  366.  
  367. if ("setAutoplayPolicy" in navigator.mediaSession) {
  368. navigator.mediaSession.setAutoplayPolicy("allowed");
  369. }
  370.  
  371. // Set media session metadata for better system integration
  372. navigator.mediaSession.metadata = new MediaMetadata({
  373. title: document.title,
  374. artwork: [
  375. {
  376. src: document.querySelector('link[rel="icon"]')?.href || "",
  377. sizes: "96x96",
  378. type: "image/png",
  379. },
  380. ],
  381. });
  382. } catch (e) {
  383. Logger.log("Some media session features not supported");
  384. }
  385. }
  386.  
  387. // Handle browser-specific cases
  388. if (BrowserDetector.isBrave || BrowserDetector.isEdge) {
  389. video.focus();
  390. await new Promise((resolve) => setTimeout(resolve, 200));
  391. if (video.paused) {
  392. await video.play().catch(() => {});
  393. }
  394. }
  395.  
  396. // Try to enter PiP mode
  397. if (document.pictureInPictureEnabled) {
  398. try {
  399. await video.requestPictureInPicture();
  400. Logger.log("PiP activated successfully!");
  401. this.#pipAttempts = 0;
  402. PerformanceMonitor.end("requestPictureInPicture");
  403. return true;
  404. } catch (e) {
  405. // If direct PiP request fails, try using media session
  406. if ("mediaSession" in navigator) {
  407. navigator.mediaSession.metadata = new MediaMetadata({
  408. title: document.title,
  409. });
  410. Logger.log("Attempting automatic PiP via media session");
  411. // Force a visibility change to trigger PiP
  412. this.#handleVisibilityChange();
  413. return true;
  414. }
  415. throw e;
  416. }
  417. } else if (video.webkitSetPresentationMode) {
  418. await video.webkitSetPresentationMode("picture-in-picture");
  419. Logger.log("Safari PiP activated successfully!");
  420. this.#pipAttempts = 0;
  421. PerformanceMonitor.end("requestPictureInPicture");
  422. return true;
  423. }
  424. throw new Error("PiP not supported");
  425. } catch (error) {
  426. Logger.error("PiP request failed:", error.message);
  427. this.#pipAttempts++;
  428.  
  429. if (this.#pipAttempts < VideoController.MAX_PIP_ATTEMPTS) {
  430. Logger.log(`Retrying PiP (attempt ${this.#pipAttempts})...`);
  431. await new Promise((resolve) =>
  432. setTimeout(
  433. resolve,
  434. VideoController.PIP_RETRY_DELAY * Math.pow(1.5, this.#pipAttempts)
  435. )
  436. );
  437. PerformanceMonitor.end("requestPictureInPicture");
  438. return this.requestPictureInPicture(video);
  439. }
  440. Logger.error("Max PiP attempts reached");
  441. PerformanceMonitor.end("requestPictureInPicture");
  442. return false;
  443. }
  444. }
  445.  
  446. async enablePiP(forceEnable = false) {
  447. PerformanceMonitor.start("enablePiP");
  448. try {
  449. const video = await this.getVideoElement();
  450. if (!video || (!forceEnable && !this.isVideoPlaying(video))) {
  451. Logger.log("Video not ready for PiP");
  452. PerformanceMonitor.end("enablePiP");
  453. return;
  454. }
  455.  
  456. if (!document.pictureInPictureElement && !this.#isPiPRequested) {
  457. // Set initial state
  458. this.#hasUserGesture = true;
  459. const success = await this.requestPictureInPicture(video);
  460. if (success) {
  461. this.#isPiPRequested = true;
  462. this.#pipInitiatedFromOtherTab = !this.#isTabActive;
  463. }
  464. // Reset user gesture flag after attempt
  465. this.#hasUserGesture = false;
  466. }
  467. } catch (error) {
  468. Logger.error("Enable PiP error:", error);
  469. }
  470. PerformanceMonitor.end("enablePiP");
  471. }
  472.  
  473. async disablePiP() {
  474. if (document.pictureInPictureElement && !this.#pipInitiatedFromOtherTab) {
  475. try {
  476. await document.exitPictureInPicture();
  477. Logger.log("PiP mode exited");
  478. this.#isPiPRequested = false;
  479. this.#pipAttempts = 0;
  480. } catch (error) {
  481. Logger.error("Exit PiP error:", error);
  482. }
  483. }
  484. }
  485.  
  486. #handleVisibilityChange = this.#debounce(async () => {
  487. const previousState = this.#isTabActive;
  488. this.#isTabActive = !document.hidden;
  489. Logger.log(
  490. `Tab visibility changed: ${this.#isTabActive ? "visible" : "hidden"}`
  491. );
  492.  
  493. if (previousState !== this.#isTabActive) {
  494. if (this.#isTabActive) {
  495. if (!this.#pipInitiatedFromOtherTab) {
  496. await this.disablePiP();
  497. }
  498. } else {
  499. const video = await this.getVideoElement();
  500. if (video && this.isVideoPlaying(video)) {
  501. const delay = BrowserDetector.isChromiumBased ? 200 : 0;
  502. setTimeout(() => this.enablePiP(true), delay);
  503. }
  504. this.#pipInitiatedFromOtherTab = false;
  505. }
  506. }
  507. }, 100);
  508.  
  509. setupMediaSession() {
  510. if ("mediaSession" in navigator) {
  511. try {
  512. navigator.mediaSession.setActionHandler(
  513. "enterpictureinpicture",
  514. async () => {
  515. if (!this.#isTabActive) {
  516. await this.enablePiP(true);
  517. }
  518. }
  519. );
  520.  
  521. if ("setAutoplayPolicy" in navigator.mediaSession) {
  522. navigator.mediaSession.setAutoplayPolicy("allowed");
  523. }
  524.  
  525. ["play", "pause", "seekbackward", "seekforward"].forEach((action) => {
  526. try {
  527. navigator.mediaSession.setActionHandler(action, null);
  528. } catch (e) {
  529. Logger.log(`${action} handler not supported`);
  530. }
  531. });
  532.  
  533. Logger.log("Media session handlers set up");
  534. } catch (error) {
  535. Logger.log("Some media session features not supported");
  536. }
  537. }
  538. }
  539.  
  540. #addEventListeners() {
  541. const addListener = (
  542. target,
  543. event,
  544. handler,
  545. options = { passive: true }
  546. ) => {
  547. target.addEventListener(event, handler, options);
  548. this.#eventListeners.add({ target, event, handler });
  549. };
  550.  
  551. // Track user interactions to detect user gestures
  552. ["mousedown", "keydown", "touchstart"].forEach((eventType) => {
  553. addListener(document, eventType, () => {
  554. this.#hasUserGesture = true;
  555. // Reset after a short delay
  556. setTimeout(() => {
  557. this.#hasUserGesture = false;
  558. }, 1000);
  559. });
  560. });
  561.  
  562. addListener(document, "visibilitychange", this.#handleVisibilityChange);
  563.  
  564. const pipEvents = [
  565. [
  566. "enterpictureinpicture",
  567. () => {
  568. this.#pipInitiatedFromOtherTab = !this.#isTabActive;
  569. this.#isPiPRequested = true;
  570. this.#pipAttempts = 0;
  571. Logger.log("Entered PiP mode");
  572. },
  573. ],
  574. [
  575. "leavepictureinpicture",
  576. () => {
  577. this.#isPiPRequested = false;
  578. this.#pipInitiatedFromOtherTab = false;
  579. this.#pipAttempts = 0;
  580. Logger.log("Left PiP mode");
  581. },
  582. ],
  583. ];
  584.  
  585. pipEvents.forEach(([event, handler]) => {
  586. addListener(document, event, handler);
  587. });
  588.  
  589. if (window.location.hostname.includes("youtube.com")) {
  590. addListener(
  591. window,
  592. "yt-navigate-finish",
  593. this.#debounce(async () => {
  594. if (!this.#isTabActive) {
  595. const video = await this.getVideoElement();
  596. if (video && this.isVideoPlaying(video)) {
  597. await this.enablePiP();
  598. }
  599. }
  600. }, 1000)
  601. );
  602. }
  603. }
  604.  
  605. cleanup() {
  606. this.#eventListeners.forEach(({ target, event, handler }) => {
  607. target.removeEventListener(event, handler);
  608. });
  609. this.#eventListeners.clear();
  610.  
  611. if (this.#videoObserver) {
  612. this.#videoObserver.disconnect();
  613. this.#videoObserver = null;
  614. }
  615.  
  616. this.#debounceTimers.forEach((timer) => clearTimeout(timer));
  617. this.#debounceTimers.clear();
  618.  
  619. PerformanceMonitor.cleanup();
  620. }
  621.  
  622. initialize() {
  623. Logger.log("Initializing PiP controller...");
  624. PerformanceMonitor.initPerformanceObserver();
  625. this.#addEventListeners();
  626. this.setupMediaSession();
  627.  
  628. // Force immediate visibility check and PiP attempt
  629. setTimeout(() => {
  630. this.#isTabActive = !document.hidden;
  631. if (!this.#isTabActive) {
  632. this.getVideoElement().then((video) => {
  633. if (video && this.isVideoPlaying(video)) {
  634. this.enablePiP(true);
  635. }
  636. });
  637. }
  638. }, 500);
  639.  
  640. Logger.log("Initialization complete");
  641. }
  642. }
  643.  
  644. // Initialize the controller
  645. const pipController = new VideoController();
  646. if (document.readyState === "loading") {
  647. document.addEventListener("DOMContentLoaded", () =>
  648. pipController.initialize()
  649. );
  650. } else {
  651. pipController.initialize();
  652. }
  653.  
  654. // Cleanup on unload
  655. window.addEventListener(
  656. "unload",
  657. () => {
  658. pipController.cleanup();
  659. },
  660. { passive: true }
  661. );
  662. })();