SuperPiP

Enable native video controls with Picture-in-Picture functionality on any website

  1. // ==UserScript==
  2. // @name SuperPiP
  3. // @namespace https://github.com/tonioriol
  4. // @version 0.1.0
  5. // @description Enable native video controls with Picture-in-Picture functionality on any website
  6. // @author SuperPiP
  7. // @match https://*/*
  8. // @match http://*/*
  9. // @grant none
  10. // @run-at document-start
  11. // @license AGPL-3.0-or-later
  12. // ==/UserScript==
  13.  
  14. (function () {
  15. "use strict";
  16.  
  17. console.log("[SuperPiP] Script started at", new Date().toISOString());
  18. console.log("[SuperPiP] Document readyState:", document.readyState);
  19. console.log("[SuperPiP] User agent:", navigator.userAgent);
  20.  
  21. // Check if video is in viewport
  22. function isVideoInViewport(video) {
  23. const rect = video.getBoundingClientRect();
  24. const viewHeight = window.innerHeight || document.documentElement.clientHeight;
  25. const viewWidth = window.innerWidth || document.documentElement.clientWidth;
  26. return (
  27. rect.top >= 0 &&
  28. rect.left >= 0 &&
  29. rect.bottom <= viewHeight &&
  30. rect.right <= viewWidth &&
  31. rect.width > 0 &&
  32. rect.height > 0
  33. );
  34. }
  35.  
  36. // Enhanced video setup for better UX
  37. function enableVideoControls(video) {
  38. // Always set controls, but only log if it's actually changing
  39. if (!video.hasAttribute("controls")) {
  40. console.log("[SuperPiP] Enabling controls for video:", video);
  41. }
  42. try {
  43. video.setAttribute("controls", "");
  44. // Set up enhanced functionality only once per video
  45. if (!video.hasAttribute('data-superpip-setup')) {
  46. video.setAttribute('data-superpip-setup', 'true');
  47. // Auto-unmute when playing (counter Instagram's nasty muting)
  48. let videoShouldBeUnmuted = false;
  49. video.addEventListener('play', () => {
  50. videoShouldBeUnmuted = true;
  51. if (video.muted) {
  52. video.muted = false;
  53. console.log("[SuperPiP] Auto-unmuted video on play");
  54. }
  55. });
  56. video.addEventListener('pause', () => {
  57. videoShouldBeUnmuted = false;
  58. });
  59. // Override the muted property to prevent programmatic muting during playback
  60. const originalMutedDescriptor = Object.getOwnPropertyDescriptor(HTMLMediaElement.prototype, 'muted');
  61. if (originalMutedDescriptor) {
  62. Object.defineProperty(video, 'muted', {
  63. get: function() {
  64. return originalMutedDescriptor.get.call(this);
  65. },
  66. set: function(value) {
  67. // If video is playing and something tries to mute it, prevent it
  68. if (value === true && videoShouldBeUnmuted && !this.paused) {
  69. console.log("[SuperPiP] Blocked attempt to mute playing video");
  70. return;
  71. }
  72. return originalMutedDescriptor.set.call(this, value);
  73. }
  74. });
  75. }
  76. // Smart autoplay: only autoplay if video is in viewport
  77. if (isVideoInViewport(video) && video.paused && video.readyState >= 2) {
  78. console.log("[SuperPiP] Autoplaying video in viewport");
  79. video.play().catch(() => {}); // Ignore autoplay policy errors
  80. }
  81. console.log("[SuperPiP] Enhanced video setup complete");
  82. }
  83. if (video.hasAttribute("controls")) {
  84. console.log("[SuperPiP] Controls enabled successfully for video");
  85. }
  86. } catch (error) {
  87. console.error("[SuperPiP] Error enabling controls:", error);
  88. }
  89. // set z-index to ensure it appears above other elements if position not relative
  90. // video.style.position = "absolute";
  91. // video.style.zIndex = "9999999999";
  92. }
  93.  
  94. // Simple PoC: Detect elements positioned on top of video
  95. function detectVideoOverlays(video) {
  96. try {
  97. const videoRect = video.getBoundingClientRect();
  98. // Skip processing if video has no dimensions (not rendered yet) but don't log
  99. if (videoRect.width === 0 || videoRect.height === 0) {
  100. return [];
  101. }
  102.  
  103. console.log("[SuperPiP] Detecting overlays for video:", video);
  104. const videoStyle = window.getComputedStyle(video);
  105. const videoZIndex = parseInt(videoStyle.zIndex) || 0;
  106.  
  107. console.log("[SuperPiP] Video rect:", videoRect);
  108. console.log("[SuperPiP] Video zIndex:", videoZIndex);
  109.  
  110. const overlays = [];
  111. const allElements = document.querySelectorAll("*");
  112.  
  113. console.log("[SuperPiP] Checking", allElements.length, "elements for overlays");
  114.  
  115. allElements.forEach((element) => {
  116. // Skip the video itself and its containers
  117. if (element === video || element.contains(video)) return;
  118.  
  119. const style = window.getComputedStyle(element);
  120. const rect = element.getBoundingClientRect();
  121. const zIndex = parseInt(style.zIndex) || 0;
  122.  
  123. // element must be within video bounds AND positioned
  124. const isPositioned = ["absolute"].includes(style.position);
  125. const isOnTop = isPositioned && zIndex >= videoZIndex;
  126. const isWithinBounds =
  127. rect.left >= videoRect.left &&
  128. rect.right <= videoRect.right &&
  129. rect.top >= videoRect.top &&
  130. rect.bottom <= videoRect.bottom;
  131. const isVisible =
  132. style.display !== "none" &&
  133. style.visibility !== "hidden" &&
  134. style.opacity !== "0";
  135.  
  136. if (isOnTop && isWithinBounds && isVisible) {
  137. overlays.push({
  138. element: element,
  139. tagName: element.tagName,
  140. classes: Array.from(element.classList),
  141. zIndex: zIndex,
  142. });
  143.  
  144. console.log("[SuperPiP] Hiding overlay element:", element.tagName, element.className);
  145. element.style.display = "none";
  146. }
  147. });
  148.  
  149. console.log("[SuperPiP] Found", overlays.length, "overlays");
  150. return overlays;
  151. } catch (error) {
  152. console.error("[SuperPiP] Error detecting overlays:", error);
  153. return [];
  154. }
  155. }
  156.  
  157. // Process all videos on the page
  158. function processVideos() {
  159. console.log("[SuperPiP] Processing videos...");
  160. const videos = document.querySelectorAll("video");
  161. console.log("[SuperPiP] Found", videos.length, "video elements");
  162. videos.forEach((video, index) => {
  163. console.log("[SuperPiP] Processing video", index + 1, "of", videos.length);
  164. enableVideoControls(video);
  165. detectVideoOverlays(video);
  166. });
  167. }
  168.  
  169. // Initialize and set up observers
  170. function init() {
  171. console.log("[SuperPiP] Initializing...");
  172. try {
  173. // Process any existing videos
  174. processVideos();
  175.  
  176. // Set up mutation observer to watch for video elements and their changes
  177. console.log("[SuperPiP] Setting up mutation observer...");
  178. const observer = new MutationObserver((mutations) => {
  179. // Pre-filter: only process mutations that might involve videos
  180. let newVideoCount = 0;
  181.  
  182. mutations.forEach((mutation) => {
  183. // Handle new nodes being added
  184. if (mutation.type === "childList") {
  185. mutation.addedNodes.forEach((node) => {
  186. if (node.nodeType === 1) { // Element node
  187. if (node.tagName === "VIDEO") {
  188. // Direct video element added
  189. enableVideoControls(node);
  190. detectVideoOverlays(node);
  191. newVideoCount++;
  192. } else if (node.querySelector) {
  193. // Check if added node contains video elements
  194. const videos = node.querySelectorAll("video");
  195. if (videos.length > 0) {
  196. videos.forEach((video) => {
  197. enableVideoControls(video);
  198. detectVideoOverlays(video);
  199. });
  200. newVideoCount += videos.length;
  201. }
  202. }
  203. }
  204. });
  205. }
  206. // Handle attribute changes on video elements
  207. if (mutation.type === "attributes" && mutation.target.tagName === "VIDEO") {
  208. const video = mutation.target;
  209. // Re-enable controls if they were removed
  210. if (mutation.attributeName === "controls" && !video.hasAttribute("controls")) {
  211. console.log("[SuperPiP] Re-enabling removed controls");
  212. enableVideoControls(video);
  213. }
  214. // Re-process overlays for any video attribute change that might affect layout
  215. if (["src", "style", "class", "width", "height"].includes(mutation.attributeName)) {
  216. detectVideoOverlays(video);
  217. }
  218. }
  219. });
  220.  
  221. // Only log when we actually processed videos
  222. if (newVideoCount > 0) {
  223. console.log("[SuperPiP] Processed", newVideoCount, "new videos");
  224. }
  225. });
  226.  
  227. // Start observing - use document.documentElement if body doesn't exist yet
  228. const target = document.body || document.documentElement;
  229. console.log("[SuperPiP] Observing target:", target.tagName);
  230. observer.observe(target, {
  231. childList: true,
  232. subtree: true,
  233. attributes: true
  234. // No attributeFilter - listen to all attributes but filter by video tagName in callback
  235. });
  236.  
  237. console.log("[SuperPiP] Mutation observer set up successfully");
  238.  
  239. // Handle video events for when videos start loading or playing
  240. console.log("[SuperPiP] Setting up video event listeners...");
  241. document.addEventListener("loadstart", (e) => {
  242. if (e.target.tagName === "VIDEO") {
  243. console.log("[SuperPiP] Video loadstart event:", e.target);
  244. enableVideoControls(e.target);
  245. detectVideoOverlays(e.target);
  246. }
  247. }, true);
  248.  
  249. document.addEventListener("loadedmetadata", (e) => {
  250. if (e.target.tagName === "VIDEO") {
  251. console.log("[SuperPiP] Video loadedmetadata event:", e.target);
  252. enableVideoControls(e.target);
  253. detectVideoOverlays(e.target);
  254. }
  255. }, true);
  256.  
  257. console.log("[SuperPiP] Event listeners set up successfully");
  258. } catch (error) {
  259. console.error("[SuperPiP] Error during initialization:", error);
  260. }
  261. }
  262.  
  263. // iOS Safari specific handling (THIS IS WHAT ENABLES PIP ON YOUTUBE SPECIALLY)
  264. console.log("[SuperPiP] Setting up iOS Safari specific handling...");
  265. document.addEventListener(
  266. "touchstart",
  267. function initOnTouch() {
  268. console.log("[SuperPiP] Touch event detected, setting up iOS PiP handling");
  269. let v = document.querySelector("video");
  270. if (v) {
  271. console.log("[SuperPiP] Found video for iOS PiP setup:", v);
  272. v.addEventListener(
  273. "webkitpresentationmodechanged",
  274. (e) => {
  275. console.log("[SuperPiP] webkitpresentationmodechanged event:", e);
  276. e.stopPropagation();
  277. },
  278. true
  279. );
  280. // Remove the touchstart listener after we've initialized
  281. document.removeEventListener("touchstart", initOnTouch);
  282. console.log("[SuperPiP] iOS PiP handling set up successfully");
  283. } else {
  284. console.log("[SuperPiP] No video found for iOS PiP setup");
  285. }
  286. },
  287. true
  288. );
  289.  
  290. // Start immediately since we're running at document-start
  291. console.log("[SuperPiP] Starting initialization...");
  292. init();
  293. console.log("[SuperPiP] Script initialization complete");
  294. })();
  295.