Plovoucí tlačítko PIP = Povolit obraz v obraze pro mobilní zařízení

Přidává plovoucí tlačítko pro přepínání režimu obraz v obraze pro videa na mobilních zařízeních.

  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.1.0
  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: `.pipButton { 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; transform: scale(1); transition: transform 0.1s ease-out; } .pipButton:before { pointer-events: none; content: ""; position: absolute; top: 0; bottom: 0; width: 100%; z-index: 2; 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; } .pipButton:after { 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; }`,
  88. DEFAULT_POSITION: {
  89. right: 20,
  90. bottom: 20,
  91. },
  92. },
  93. TOUCH: {
  94. MOVE_THRESHOLD: 10,
  95. CLICK_TIMEOUT: 200,
  96. LONG_PRESS_TIMEOUT: 1000,
  97. LONG_PRESS_MOVE_THRESHOLD: 15,
  98. ANIMATION_DELAY: 300,
  99. },
  100. STORAGE: {
  101. POSITION_KEY: "pip_button_position",
  102. },
  103. };
  104.  
  105. class PIPButton {
  106. button;
  107. watchedVideos;
  108. observer;
  109. isDragging = false;
  110. touchStartTime = 0;
  111. dragOffset = { x: 0, y: 0 };
  112. initialPosition = { x: 0, y: 0 };
  113. longPressTimer = null;
  114. longPressStartPosition = { x: 0, y: 0 };
  115. animationTimer = null;
  116. isManuallyHidden = false;
  117.  
  118. constructor() {
  119. this.initializeButton();
  120. this.initializeVideoObserver();
  121. this.initializeDragHandlers();
  122. this.detectInitialVideos();
  123. this.initializeLongPressHandlers();
  124. }
  125.  
  126. initializeButton() {
  127. this.button = document.createElement("div");
  128. this.button.classList.add("pipButton");
  129. this.injectStyles();
  130. document.body.appendChild(this.button);
  131. this.watchedVideos = new Set();
  132. this.loadButtonPosition();
  133. this.checkButtonPosition();
  134. window.addEventListener("resize", () => this.checkButtonPosition());
  135. }
  136.  
  137. injectStyles() {
  138. const style = document.createElement("style");
  139. style.textContent = CONSTANTS.BUTTON.STYLE;
  140. document.head.appendChild(style);
  141. }
  142.  
  143. initializeVideoObserver() {
  144. this.observer = new MutationObserver(this.handleMutations.bind(this));
  145. this.observer.observe(document.body, {
  146. childList: true,
  147. subtree: true,
  148. });
  149. }
  150.  
  151. handleMutations(mutations) {
  152. mutations.forEach((mutation) => {
  153. mutation.addedNodes.forEach((node) => {
  154. if (node instanceof HTMLVideoElement) {
  155. this.addVideo(node);
  156. }
  157. });
  158. });
  159. this.updateButtonVisibility();
  160. }
  161.  
  162. addVideo(video) {
  163. if (!this.watchedVideos.has(video)) {
  164. this.watchedVideos.add(video);
  165. }
  166. }
  167.  
  168. detectInitialVideos() {
  169. document
  170. .querySelectorAll("video")
  171. .forEach((video) => this.addVideo(video));
  172. this.updateButtonVisibility();
  173. }
  174.  
  175. togglePIP() {
  176. try {
  177. if (this.watchedVideos.size === 0) return;
  178. if (document.pictureInPictureElement) {
  179. document.exitPictureInPicture();
  180. return;
  181. }
  182. const playingVideo = Array.from(this.watchedVideos).find(
  183. (video) => !video.paused && !video.ended && video.currentTime > 0
  184. );
  185. const videoToShow = playingVideo || Array.from(this.watchedVideos)[0];
  186. videoToShow
  187. ?.requestPictureInPicture()
  188. .then(() => {
  189. Object.defineProperty(document, "visibilityState", {
  190. get: () => "visible",
  191. });
  192. })
  193. .catch(console.error);
  194. } catch (error) {
  195. console.error("Error toggling PIP:", error);
  196. }
  197. }
  198.  
  199. initializeDragHandlers() {
  200. this.button.addEventListener(
  201. "mousedown",
  202. this.handleDragStart.bind(this)
  203. );
  204. this.button.addEventListener(
  205. "touchstart",
  206. this.handleDragStart.bind(this)
  207. );
  208. document.addEventListener("mousemove", this.handleDragMove.bind(this));
  209. document.addEventListener("touchmove", this.handleDragMove.bind(this), {
  210. passive: false,
  211. });
  212. document.addEventListener("mouseup", this.handleDragEnd.bind(this));
  213. document.addEventListener("touchend", this.handleDragEnd.bind(this));
  214. document.addEventListener("touchcancel", this.handleDragEnd.bind(this));
  215. }
  216.  
  217. handleDragStart(event) {
  218. this.isDragging = true;
  219. this.button.style.transform = "scale(2)";
  220. const rect = this.button.getBoundingClientRect();
  221. this.initialPosition = { x: rect.left, y: rect.top };
  222. const clientX = event.clientX || event.touches[0].clientX;
  223. const clientY = event.clientY || event.touches[0].clientY;
  224. this.dragOffset = {
  225. x: clientX - this.initialPosition.x,
  226. y: clientY - this.initialPosition.y,
  227. };
  228. this.touchStartTime = Date.now();
  229. event.preventDefault();
  230. event.stopPropagation();
  231. if (this.longPressTimer) {
  232. clearTimeout(this.longPressTimer);
  233. }
  234. }
  235.  
  236. handleDragMove(event) {
  237. if (!this.isDragging) return;
  238. const clientX = event.clientX || event.touches[0].clientX;
  239. const clientY = event.clientY || event.touches[0].clientY;
  240. const newPosition = this.calculateNewPosition(
  241. clientX - this.dragOffset.x,
  242. clientY - this.dragOffset.y
  243. );
  244. this.updateButtonPosition(newPosition);
  245. event.preventDefault();
  246. event.stopPropagation();
  247. }
  248.  
  249. calculateNewPosition(x, y) {
  250. const maxX = window.innerWidth - this.button.offsetWidth;
  251. const maxY = window.innerHeight - this.button.offsetHeight;
  252. return {
  253. x: Math.max(0, Math.min(x, maxX)),
  254. y: Math.max(0, Math.min(y, maxY)),
  255. };
  256. }
  257.  
  258. updateButtonPosition(position) {
  259. this.button.style.left = `${position.x}px`;
  260. this.button.style.top = `${position.y}px`;
  261. this.button.style.right = "auto";
  262. this.button.style.bottom = "auto";
  263. }
  264.  
  265. handleDragEnd(event) {
  266. if (!this.isDragging) return;
  267. this.button.style.transform = "scale(1)";
  268. const distance = this.calculateDragDistance();
  269. const elapsedTime = Date.now() - this.touchStartTime;
  270. if (
  271. elapsedTime < CONSTANTS.TOUCH.CLICK_TIMEOUT &&
  272. distance <= CONSTANTS.TOUCH.MOVE_THRESHOLD &&
  273. event.button !== 2
  274. )
  275. this.togglePIP();
  276. const position = {
  277. x: this.button.offsetLeft,
  278. y: this.button.offsetTop,
  279. };
  280. if (!this.isManuallyHidden)
  281. localStorage.setItem(
  282. CONSTANTS.STORAGE.POSITION_KEY,
  283. JSON.stringify(position)
  284. );
  285. this.isDragging = false;
  286. event.preventDefault();
  287. event.stopPropagation();
  288. }
  289.  
  290. calculateDragDistance() {
  291. const dx = this.button.offsetLeft - this.initialPosition.x;
  292. const dy = this.button.offsetTop - this.initialPosition.y;
  293. return Math.sqrt(dx * dx + dy * dy);
  294. }
  295.  
  296. updateButtonVisibility() {
  297. this.button.style.display =
  298. this.watchedVideos.size > 0 && !this.isManuallyHidden
  299. ? "block"
  300. : "none";
  301. }
  302.  
  303. initializeLongPressHandlers() {
  304. this.button.addEventListener("contextmenu", (e) => {
  305. e.preventDefault();
  306. this.hideButton();
  307. });
  308. const startLongPress = (e) => {
  309. const pos = e.touches ? e.touches[0] : e;
  310. this.longPressStartPosition = { x: pos.clientX, y: pos.clientY };
  311. this.button.style.setProperty("--delete-progress", "0");
  312. this.animationTimer = setTimeout(() => {
  313. requestAnimationFrame(() => {
  314. this.button.style.setProperty("--delete-progress", "1");
  315. });
  316. }, CONSTANTS.TOUCH.ANIMATION_DELAY);
  317. this.longPressTimer = setTimeout(() => {
  318. this.hideButton();
  319. }, CONSTANTS.TOUCH.LONG_PRESS_TIMEOUT);
  320. };
  321. const moveDuringPress = (e) => {
  322. if (this.longPressTimer) {
  323. const pos = e.touches ? e.touches[0] : e;
  324. const moveDistance = Math.sqrt(
  325. Math.pow(pos.clientX - this.longPressStartPosition.x, 2) +
  326. Math.pow(pos.clientY - this.longPressStartPosition.y, 2)
  327. );
  328. if (moveDistance > CONSTANTS.TOUCH.LONG_PRESS_MOVE_THRESHOLD) {
  329. clearTimeout(this.longPressTimer);
  330. clearTimeout(this.animationTimer);
  331. this.longPressTimer = null;
  332. this.animationTimer = null;
  333. this.button.style.setProperty("--delete-progress", "0");
  334. }
  335. }
  336. };
  337. const endLongPress = () => {
  338. if (this.longPressTimer) {
  339. clearTimeout(this.longPressTimer);
  340. clearTimeout(this.animationTimer);
  341. this.button.style.setProperty("--delete-progress", "0");
  342. }
  343. };
  344. // Touch events
  345. this.button.addEventListener("touchstart", startLongPress);
  346. this.button.addEventListener("touchmove", moveDuringPress);
  347. this.button.addEventListener("touchend", endLongPress);
  348. // Mouse events
  349. this.button.addEventListener("mousedown", (e) => {
  350. if (e.button === 0) startLongPress(e);
  351. });
  352. this.button.addEventListener("mousemove", moveDuringPress);
  353. this.button.addEventListener("mouseup", endLongPress);
  354. this.button.addEventListener("mouseleave", endLongPress);
  355. }
  356.  
  357. hideButton() {
  358. this.isManuallyHidden = true;
  359. this.button.style.display = "none";
  360. }
  361.  
  362. loadButtonPosition() {
  363. const savedPosition = localStorage.getItem(
  364. CONSTANTS.STORAGE.POSITION_KEY
  365. );
  366. if (savedPosition) {
  367. const position = JSON.parse(savedPosition);
  368. this.updateButtonPosition(position);
  369. } else {
  370. this.button.style.right = `${CONSTANTS.BUTTON.DEFAULT_POSITION.right}px`;
  371. this.button.style.bottom = `${CONSTANTS.BUTTON.DEFAULT_POSITION.bottom}px`;
  372. }
  373. }
  374.  
  375. checkButtonPosition() {
  376. const rect = this.button.getBoundingClientRect();
  377. const windowWidth = window.innerWidth;
  378. const windowHeight = window.innerHeight;
  379.  
  380. const newLeft = Math.max(
  381. 0,
  382. Math.min(rect.left, windowWidth - rect.width)
  383. );
  384. const newTop = Math.max(
  385. 0,
  386. Math.min(rect.top, windowHeight - rect.height)
  387. );
  388.  
  389. if (newLeft !== rect.left || newTop !== rect.top)
  390. this.updateButtonPosition({ x: newLeft, y: newTop });
  391. }
  392. }
  393.  
  394. if (document.readyState === "loading")
  395. document.addEventListener("DOMContentLoaded", () => new PIPButton());
  396. else new PIPButton();
  397. })();