Floating PIP Button = Enable Picture in Picture for mobile

Adds a floating button to toggle Picture-in-Picture mode for videos on mobile devices.

当前为 2025-02-04 提交的版本,查看 最新版本

  1. // ==UserScript==
  2. // @name Floating PIP Button = Enable Picture in Picture for mobile
  3. // @name:bg Плаващ PIP бутон = Активиране на картина в картина за мобилни устройства
  4. // @name:cs Plovoucí tlačítko PIP = Povolit obraz v obraze pro mobilní zařízení
  5. // @name:da Flydende PIP-knap = Aktiver billede i billede til mobile enheder
  6. // @name:de Schwebender PIP-Button = Bild-in-Bild für mobile Geräte aktivieren
  7. // @name:el Επιπλέων κουμπί PIP = Ενεργοποίηση εικόνας σε εικόνα για κινητές συσκευές
  8. // @name:en Floating PIP Button = Enable Picture in Picture for mobile
  9. // @name:eo Flosanta PIP-Butono = Ebligi Bildon en Bildo por poŝtelefonoj
  10. // @name:es Botón Flotante PIP = Habilita Imagen en Imagen para móvil
  11. // @name:es-la Botón Flotante PIP = Habilita Imagen en Imagen para móvil
  12. // @name:es-419 Botón Flotante PIP = Habilita Imagen en Imagen para móvil
  13. // @name:fi Kelluva PIP-painike = Ota käyttöön kuva kuvassa mobiililaitteille
  14. // @name:fr Bouton PIP flottant = Activer l'image dans l'image pour mobile
  15. // @name:fr-CA Bouton PIP flottant = Activer l'image dans l'image pour mobile
  16. // @name:he כפתור PIP צף = הפעלת תמונה בתוך תמונה לנייד
  17. // @name:hr Plutajući PIP gumb = Omogući sliku u slici za mobilne uređaje
  18. // @name:hu Lebegő PIP gomb = Kép a képben engedélyezése mobil eszközökre
  19. // @name:id Tombol PIP Mengambang = Aktifkan Gambar dalam Gambar untuk seluler
  20. // @name:it Pulsante PIP flottante = Abilita immagine nell'immagine per dispositivi mobili
  21. // @name:ja 浮動PIPボタン = モバイル用のピクチャーインピクチャーを有効にする
  22. // @name:ka მცურავი PIP ღილაკი = ჩართეთ სურათი სურათში მობილური მოწყობილობებისთვის
  23. // @name:ko 플로팅 PIP 버튼 = 모바일용 화면 속 화면 활성화
  24. // @name:nb Flytende PIP-knapp = Aktiver bilde i bilde for mobil
  25. // @name:nl Zwevende PIP-knop = Schakel beeld in beeld in voor mobiel
  26. // @name:pl Pływający przycisk PIP = Włącz obraz w obrazie dla urządzeń mobilnych
  27. // @name:pt-BR Botão PIP Flutuante = Ativar imagem em imagem para celular
  28. // @name:ro Buton PIP plutitor = Activează imagine în imagine pentru mobil
  29. // @name:sv Flytande PIP-knapp = Aktivera bild i bild för mobil
  30. // @name:th ปุ่ม PIP ลอย = เปิดใช้งานภาพในภาพสำหรับมือถือ
  31. // @name:tr Yüzen PIP Düğmesi = Mobil için Resim içinde Resim'i etkinleştir
  32. // @name:ug ھۆلۈپ تۇرغان PIP كۇنۇپكىسى = يانفونلار ئۈچۈن رەسىم ئىچىدە رەسىمنى قوزغىتىش
  33. // @name:uk Плаваюча кнопка PIP = Увімкнути картинку в картинці для мобільних пристроїв
  34. // @name:vi Nút PIP nổi = Bật chế độ Hình trong Hình cho di động
  35. // @name:zh-TW 浮動PIP按鈕 = 啟用行動裝置的畫中畫模式
  36. // @namespace https://jlcareglio.github.io/
  37. // @version 1.0.2
  38. // @description Adds a floating button to toggle Picture-in-Picture mode for videos on mobile devices.
  39. // @description:bg Добавя плаващ бутон за превключване на режим картина в картина за видеоклипове на мобилни устройства.
  40. // @description:cs Přidává plovoucí tlačítko pro přepínání režimu obraz v obraze pro videa na mobilních zařízeních.
  41. // @description:da Tilføjer en flydende knap til at skifte billede-i-billede-tilstand for videoer på mobile enheder.
  42. // @description:de Fügt eine schwebende Schaltfläche hinzu, um den Bild-in-Bild-Modus für Videos auf mobilen Geräten umzuschalten.
  43. // @description:el Προσθέτει ένα επιπλέον κουμπί για εναλλαγή της λειτουργίας εικόνας σε εικόνα για βίντεο σε κινητές συσκευές.
  44. // @description:en Adds a floating button to toggle Picture-in-Picture mode for videos on mobile devices.
  45. // @description:eo Aldonas flosantan butonon por ŝalti Bildon en Bildo-reĝimon por videoj en poŝtelefonoj.
  46. // @description:es Agrega un botón flotante para alternar el modo Imagen en Imagen para videos en dispositivos móviles.
  47. // @description:es-la Agrega un botón flotante para alternar el modo Imagen en Imagen para videos en dispositivos móviles.
  48. // @description:es-419 Agrega un botón flotante para alternar el modo Imagen en Imagen para videos en dispositivos móviles.
  49. // @description:fi Lisää kelluvan painikkeen, jolla voi vaihtaa kuva kuvassa -tilan videoille mobiililaitteissa.
  50. // @description:fr Ajoute un bouton flottant pour basculer en mode image dans l'image pour les vidéos sur les appareils mobiles.
  51. // @description:fr-CA Ajoute un bouton flottant pour basculer en mode image dans l'image pour les vidéos sur les appareils mobiles.
  52. // @description:he מוסיף כפתור צף למעבר למצב תמונה בתוך תמונה עבור סרטונים במכשירים ניידים.
  53. // @description:hr Dodaje plutajući gumb za prebacivanje načina slike u slici za videozapise na mobilnim uređajima.
  54. // @description:hu Hozzáad egy lebegő gombot a kép a képben mód váltásához videókhoz mobil eszközökön.
  55. // @description:id Menambahkan tombol mengambang untuk beralih ke mode Gambar dalam Gambar untuk video di perangkat seluler.
  56. // @description:it Aggiunge un pulsante flottante per attivare la modalità immagine nell'immagine per i video sui dispositivi mobili.
  57. // @description:ja モバイルデバイスでビデオのピクチャーインピクチャーモードを切り替えるための浮動ボタンを追加します。
  58. // @description:ka ამატებს მცურავ ღილაკს მობილური მოწყობილობებისთვის ვიდეოების სურათში სურათის რეჟიმის ჩასართავად.
  59. // @description:ko 모바일 장치에서 비디오의 화면 속 화면 모드를 전환하는 플로팅 버튼을 추가합니다.
  60. // @description:nb Legger til en flytende knapp for å bytte bilde-i-bilde-modus for videoer på mobile enheter.
  61. // @description:nl Voegt een zwevende knop toe om de modus Beeld-in-Beeld voor video's op mobiele apparaten in te schakelen.
  62. // @description:pl Dodaje pływający przycisk do przełączania trybu obraz w obrazie dla filmów na urządzeniach mobilnych.
  63. // @description:pt-BR Adiciona um botão flutuante para alternar o modo Imagem em Imagem para vídeos em dispositivos móveis.
  64. // @description:ro Adaugă un buton plutitor pentru a comuta modul imagine în imagine pentru videoclipuri pe dispozitive mobile.
  65. // @description:sv Lägger till en flytande knapp för att växla bild-i-bild-läge för videor på mobila enheter.
  66. // @description:th เพิ่มปุ่มลอยเพื่อสลับโหมดภาพในภาพสำหรับวิดีโอบนอุปกรณ์เคลื่อนที่
  67. // @description:tr Mobil cihazlarda videolar için Resim içinde Resim modunu değiştirmek için yüzen bir düğme ekler.
  68. // @description:ug يانفونلاردا ۋىدىئولار ئۈچۈن رەسىم ئىچىدە رەسىم ھالىتىنى ئالماشتۇرۇش ئۈچۈن ھۆلۈپ تۇرغان كۇنۇپكا قوشىدۇ.
  69. // @description:uk Додає плаваючу кнопку для перемикання режиму картинка в картинці для відео на мобільних пристроях.
  70. // @description:vi Thêm nút nổi để chuyển đổi chế độ Hình trong Hình cho video trên thiết bị di động.
  71. // @description:zh-TW 添加一個浮動按鈕,以切換行動裝置上的影片畫中畫模式。
  72. // @icon https://lh3.googleusercontent.com/cvfpnTKw3B67DtM1ZpJG2PNAIjP6hVMOyYy403X4FMkOuStgG1y4cjCn21vmTnnsip1dTZSVsWBA9IxutGuA3dVDWhg
  73. // @grant none
  74. // @author Jesús Lautaro Careglio Albornoz
  75. // @source https://gist.githubusercontent.com/JLCareglio/22d3f9c9752352a29006f0c90c72d193/raw/01_Floating-PIP-Button.user.js
  76. // @match *://*/*
  77. // @license MIT
  78. // @compatible firefox
  79. // @compatible edge
  80. // @compatible kiwi
  81. // @supportURL https://gist.github.com/JLCareglio/22d3f9c9752352a29006f0c90c72d193/
  82. // ==/UserScript==
  83.  
  84. (async () => {
  85. const CONSTANTS = {
  86. BUTTON: {
  87. STYLE: `
  88. .pipButton {
  89. position: fixed; background-color: rgba(0, 0, 0, 0.5); border-radius: 50%; width: 60px; height: 60px; cursor: pointer; z-index: 9999; display: none; --delete-progress: 0; isolation: isolate;
  90. transform: scale(1);
  91. transition: transform 0.1s ease-out;
  92. }
  93. .pipButton:before {
  94. pointer-events: none; content: ""; position: absolute; top: 0; bottom: 0; width: 100%; z-index: 2;
  95. background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 36 36' width='100%25' height='100%25'%3E%3Cpath d='M25,17 L17,17 L17,23 L25,23 L25,17 L25,17 Z M29,25 L29,10.98 C29,9.88 28.1,9 27,9 L9,9 C7.9,9 7,9.88 7,10.98 L7,25 C7,26.1 7.9,27 9,27 L27,27 C28.1,27 29,26.1 29,25 L29,25 Z M27,25.02 L9,25.02 L9,10.97 L27,10.97 L27,25.02 L27,25.02 Z' fill='%23fff'/%3E%3C/svg%3E") no-repeat center;
  96. }
  97. .pipButton:after {
  98. content: ""; position: absolute; inset: 0; background-color: rgba(255, 0, 0, 0.8); border-radius: 50%; transform: scale(var(--delete-progress)); transition: transform 0.5s ease; z-index: 1;
  99. }
  100. `,
  101. DEFAULT_POSITION: {
  102. right: 20,
  103. bottom: 20,
  104. },
  105. },
  106. TOUCH: {
  107. MOVE_THRESHOLD: 10,
  108. CLICK_TIMEOUT: 200,
  109. LONG_PRESS_TIMEOUT: 1000,
  110. LONG_PRESS_MOVE_THRESHOLD: 15,
  111. ANIMATION_DELAY: 300,
  112. },
  113. STORAGE: {
  114. POSITION_KEY: "pip_button_position",
  115. },
  116. };
  117.  
  118. /**
  119. * Main class to handle the PIP button and its functionality
  120. */
  121. class PIPButton {
  122. #button;
  123. #watchedVideos;
  124. #observer;
  125. #isDragging = false;
  126. #touchStartTime = 0;
  127. #dragOffset = { x: 0, y: 0 };
  128. #initialPosition = { x: 0, y: 0 };
  129. #longPressTimer = null;
  130. #longPressStartPosition = { x: 0, y: 0 };
  131. #animationTimer = null;
  132. #isManuallyHidden = false;
  133.  
  134. constructor() {
  135. this.#initializeButton();
  136. this.#initializeVideoObserver();
  137. this.#initializeDragHandlers();
  138. this.#detectInitialVideos();
  139. this.#initializeLongPressHandlers();
  140. }
  141.  
  142. /**
  143. * Initializes the button and its styles
  144. * @private
  145. */
  146. #initializeButton() {
  147. this.#button = document.createElement("div");
  148. this.#button.classList.add("pipButton");
  149. this.#injectStyles();
  150. document.body.appendChild(this.#button);
  151. this.#watchedVideos = new Set();
  152. this.#loadButtonPosition();
  153. }
  154.  
  155. /**
  156. * Injects required CSS styles
  157. * @private
  158. */
  159. #injectStyles() {
  160. const style = document.createElement("style");
  161. style.textContent = CONSTANTS.BUTTON.STYLE;
  162. document.head.appendChild(style);
  163. }
  164.  
  165. /**
  166. * Initializes the video observer
  167. * @private
  168. */
  169. #initializeVideoObserver() {
  170. this.#observer = new MutationObserver(this.#handleMutations.bind(this));
  171. this.#observer.observe(document.body, {
  172. childList: true,
  173. subtree: true,
  174. });
  175. }
  176.  
  177. /**
  178. * Handles DOM mutations to detect new videos
  179. * @private
  180. * @param {MutationRecord[]} mutations
  181. */
  182. #handleMutations(mutations) {
  183. mutations.forEach((mutation) => {
  184. mutation.addedNodes.forEach((node) => {
  185. if (node instanceof HTMLVideoElement) {
  186. this.#addVideo(node);
  187. }
  188. });
  189. });
  190. this.#updateButtonVisibility();
  191. }
  192.  
  193. /**
  194. * Adds a video to the collection of observed videos
  195. * @private
  196. * @param {HTMLVideoElement} video
  197. */
  198. #addVideo(video) {
  199. if (!this.#watchedVideos.has(video)) {
  200. this.#watchedVideos.add(video);
  201. }
  202. }
  203.  
  204. /**
  205. * Detects existing videos in the DOM on startup
  206. * @private
  207. */
  208. #detectInitialVideos() {
  209. document
  210. .querySelectorAll("video")
  211. .forEach((video) => this.#addVideo(video));
  212. this.#updateButtonVisibility();
  213. }
  214.  
  215. /**
  216. * Toggles PIP mode for the active video
  217. * @private
  218. */
  219. #togglePIP() {
  220. try {
  221. if (this.#watchedVideos.size === 0) return;
  222.  
  223. if (document.pictureInPictureElement) {
  224. document.exitPictureInPicture();
  225. return;
  226. }
  227.  
  228. const playingVideo = Array.from(this.#watchedVideos).find(
  229. (video) => !video.paused && !video.ended && video.currentTime > 0
  230. );
  231.  
  232. const videoToShow = playingVideo || Array.from(this.#watchedVideos)[0];
  233. videoToShow
  234. ?.requestPictureInPicture()
  235. .then(() => {
  236. Object.defineProperty(document, "visibilityState", {
  237. get: () => "visible",
  238. });
  239. })
  240. .catch(console.error);
  241. } catch (error) {
  242. console.error("Error toggling PIP:", error);
  243. }
  244. }
  245.  
  246. /**
  247. * Initializes event handlers for dragging
  248. * @private
  249. */
  250. #initializeDragHandlers() {
  251. this.#button.addEventListener(
  252. "mousedown",
  253. this.#handleDragStart.bind(this)
  254. );
  255. this.#button.addEventListener(
  256. "touchstart",
  257. this.#handleDragStart.bind(this)
  258. );
  259.  
  260. document.addEventListener("mousemove", this.#handleDragMove.bind(this));
  261. document.addEventListener("touchmove", this.#handleDragMove.bind(this), {
  262. passive: false,
  263. });
  264.  
  265. document.addEventListener("mouseup", this.#handleDragEnd.bind(this));
  266. document.addEventListener("touchend", this.#handleDragEnd.bind(this));
  267. document.addEventListener("touchcancel", this.#handleDragEnd.bind(this));
  268. }
  269.  
  270. /**
  271. * Handles drag start
  272. * @private
  273. * @param {MouseEvent|TouchEvent} event
  274. */
  275. #handleDragStart(event) {
  276. this.#isDragging = true;
  277. this.#button.style.transform = "scale(2)";
  278. const rect = this.#button.getBoundingClientRect();
  279. this.#initialPosition = { x: rect.left, y: rect.top };
  280.  
  281. const clientX = event.clientX || event.touches[0].clientX;
  282. const clientY = event.clientY || event.touches[0].clientY;
  283.  
  284. this.#dragOffset = {
  285. x: clientX - this.#initialPosition.x,
  286. y: clientY - this.#initialPosition.y,
  287. };
  288.  
  289. this.#touchStartTime = Date.now();
  290. event.preventDefault();
  291. event.stopPropagation();
  292. if (this.#longPressTimer) {
  293. clearTimeout(this.#longPressTimer);
  294. }
  295. }
  296.  
  297. /**
  298. * Handles movement during drag
  299. * @private
  300. * @param {MouseEvent|TouchEvent} event
  301. */
  302. #handleDragMove(event) {
  303. if (!this.#isDragging) return;
  304.  
  305. const clientX = event.clientX || event.touches[0].clientX;
  306. const clientY = event.clientY || event.touches[0].clientY;
  307.  
  308. const newPosition = this.#calculateNewPosition(
  309. clientX - this.#dragOffset.x,
  310. clientY - this.#dragOffset.y
  311. );
  312.  
  313. this.#updateButtonPosition(newPosition);
  314. event.preventDefault();
  315. event.stopPropagation();
  316. }
  317.  
  318. /**
  319. * Calculates new button position
  320. * @private
  321. * @param {number} x
  322. * @param {number} y
  323. * @returns {{x: number, y: number}}
  324. */
  325. #calculateNewPosition(x, y) {
  326. const maxX = window.innerWidth - this.#button.offsetWidth;
  327. const maxY = window.innerHeight - this.#button.offsetHeight;
  328. return {
  329. x: Math.max(0, Math.min(x, maxX)),
  330. y: Math.max(0, Math.min(y, maxY)),
  331. };
  332. }
  333.  
  334. /**
  335. * Updates button position
  336. * @private
  337. * @param {{x: number, y: number}} position
  338. */
  339. #updateButtonPosition(position) {
  340. this.#button.style.left = `${position.x}px`;
  341. this.#button.style.top = `${position.y}px`;
  342. this.#button.style.right = "auto";
  343. this.#button.style.bottom = "auto";
  344. }
  345.  
  346. /**
  347. * Handles drag end
  348. * @private
  349. * @param {MouseEvent|TouchEvent} event
  350. */
  351. #handleDragEnd(event) {
  352. if (!this.#isDragging) return;
  353.  
  354. this.#button.style.transform = "scale(1)";
  355. const distance = this.#calculateDragDistance();
  356. const elapsedTime = Date.now() - this.#touchStartTime;
  357.  
  358. if (
  359. elapsedTime < CONSTANTS.TOUCH.CLICK_TIMEOUT &&
  360. distance <= CONSTANTS.TOUCH.MOVE_THRESHOLD &&
  361. event.button !== 2
  362. )
  363. this.#togglePIP();
  364.  
  365. const position = {
  366. x: this.#button.offsetLeft,
  367. y: this.#button.offsetTop,
  368. };
  369. if (!this.#isManuallyHidden)
  370. localStorage.setItem(
  371. CONSTANTS.STORAGE.POSITION_KEY,
  372. JSON.stringify(position)
  373. );
  374.  
  375. this.#isDragging = false;
  376. event.preventDefault();
  377. event.stopPropagation();
  378. }
  379.  
  380. /**
  381. * Calculates drag distance
  382. * @private
  383. * @returns {number}
  384. */
  385. #calculateDragDistance() {
  386. const dx = this.#button.offsetLeft - this.#initialPosition.x;
  387. const dy = this.#button.offsetTop - this.#initialPosition.y;
  388. return Math.sqrt(dx * dx + dy * dy);
  389. }
  390.  
  391. /**
  392. * Updates button visibility
  393. * @private
  394. */
  395. #updateButtonVisibility() {
  396. this.#button.style.display =
  397. this.#watchedVideos.size > 0 && !this.#isManuallyHidden
  398. ? "block"
  399. : "none";
  400. }
  401.  
  402. /**
  403. * Initializes handlers for long press and right-click
  404. * @private
  405. */
  406. #initializeLongPressHandlers() {
  407. this.#button.addEventListener("contextmenu", (e) => {
  408. e.preventDefault();
  409. this.#hideButton();
  410. });
  411.  
  412. const startLongPress = (e) => {
  413. const pos = e.touches ? e.touches[0] : e;
  414. this.#longPressStartPosition = { x: pos.clientX, y: pos.clientY };
  415.  
  416. this.#button.style.setProperty("--delete-progress", "0");
  417.  
  418. this.#animationTimer = setTimeout(() => {
  419. requestAnimationFrame(() => {
  420. this.#button.style.setProperty("--delete-progress", "1");
  421. });
  422. }, CONSTANTS.TOUCH.ANIMATION_DELAY);
  423.  
  424. this.#longPressTimer = setTimeout(() => {
  425. this.#hideButton();
  426. }, CONSTANTS.TOUCH.LONG_PRESS_TIMEOUT);
  427. };
  428.  
  429. const moveDuringPress = (e) => {
  430. if (this.#longPressTimer) {
  431. const pos = e.touches ? e.touches[0] : e;
  432. const moveDistance = Math.sqrt(
  433. Math.pow(pos.clientX - this.#longPressStartPosition.x, 2) +
  434. Math.pow(pos.clientY - this.#longPressStartPosition.y, 2)
  435. );
  436.  
  437. if (moveDistance > CONSTANTS.TOUCH.LONG_PRESS_MOVE_THRESHOLD) {
  438. clearTimeout(this.#longPressTimer);
  439. clearTimeout(this.#animationTimer);
  440. this.#longPressTimer = null;
  441. this.#animationTimer = null;
  442. this.#button.style.setProperty("--delete-progress", "0");
  443. }
  444. }
  445. };
  446.  
  447. const endLongPress = () => {
  448. if (this.#longPressTimer) {
  449. clearTimeout(this.#longPressTimer);
  450. clearTimeout(this.#animationTimer);
  451. this.#button.style.setProperty("--delete-progress", "0");
  452. }
  453. };
  454.  
  455. // Touch events
  456. this.#button.addEventListener("touchstart", startLongPress);
  457. this.#button.addEventListener("touchmove", moveDuringPress);
  458. this.#button.addEventListener("touchend", endLongPress);
  459.  
  460. // Mouse events
  461. this.#button.addEventListener("mousedown", (e) => {
  462. if (e.button === 0) startLongPress(e);
  463. });
  464. this.#button.addEventListener("mousemove", moveDuringPress);
  465. this.#button.addEventListener("mouseup", endLongPress);
  466. this.#button.addEventListener("mouseleave", endLongPress);
  467. }
  468.  
  469. /**
  470. * Hides the PIP button
  471. * @private
  472. */
  473. #hideButton() {
  474. this.#isManuallyHidden = true;
  475. this.#button.style.display = "none";
  476. }
  477.  
  478. #loadButtonPosition() {
  479. const savedPosition = localStorage.getItem(
  480. CONSTANTS.STORAGE.POSITION_KEY
  481. );
  482. if (savedPosition) {
  483. const position = JSON.parse(savedPosition);
  484. this.#updateButtonPosition(position);
  485. } else {
  486. this.#button.style.right = `${CONSTANTS.BUTTON.DEFAULT_POSITION.right}px`;
  487. this.#button.style.bottom = `${CONSTANTS.BUTTON.DEFAULT_POSITION.bottom}px`;
  488. }
  489. }
  490. }
  491.  
  492. if (document.readyState === "loading")
  493. document.addEventListener("DOMContentLoaded", () => new PIPButton());
  494. else new PIPButton();
  495. })();