- // ==UserScript==
- // @name Floating PIP Button = Enable Picture in Picture for mobile
- // @name:bg Плаващ PIP бутон = Активиране на картина в картина за мобилни устройства
- // @name:cs Plovoucí tlačítko PIP = Povolit obraz v obraze pro mobilní zařízení
- // @name:da Flydende PIP-knap = Aktiver billede i billede til mobile enheder
- // @name:de Schwebender PIP-Button = Bild-in-Bild für mobile Geräte aktivieren
- // @name:el Επιπλέων κουμπί PIP = Ενεργοποίηση εικόνας σε εικόνα για κινητές συσκευές
- // @name:en Floating PIP Button = Enable Picture in Picture for mobile
- // @name:eo Flosanta PIP-Butono = Ebligi Bildon en Bildo por poŝtelefonoj
- // @name:es Botón Flotante PIP = Habilita Imagen en Imagen para móvil
- // @name:fi Kelluva PIP-painike = Ota käyttöön kuva kuvassa mobiililaitteille
- // @name:fr Bouton PIP flottant = Activer l'image dans l'image pour mobile
- // @name:fr-CA Bouton PIP flottant = Activer l'image dans l'image pour mobile
- // @name:he כפתור PIP צף = הפעלת תמונה בתוך תמונה לנייד
- // @name:hr Plutajući PIP gumb = Omogući sliku u slici za mobilne uređaje
- // @name:hu Lebegő PIP gomb = Kép a képben engedélyezése mobil eszközökre
- // @name:id Tombol PIP Mengambang = Aktifkan Gambar dalam Gambar untuk seluler
- // @name:it Pulsante PIP flottante = Abilita immagine nell'immagine per dispositivi mobili
- // @name:ja 浮動PIPボタン = モバイル用のピクチャーインピクチャーを有効にする
- // @name:ka მცურავი PIP ღილაკი = ჩართეთ სურათი სურათში მობილური მოწყობილობებისთვის
- // @name:ko 플로팅 PIP 버튼 = 모바일용 화면 속 화면 활성화
- // @name:nb Flytende PIP-knapp = Aktiver bilde i bilde for mobil
- // @name:nl Zwevende PIP-knop = Schakel beeld in beeld in voor mobiel
- // @name:pl Pływający przycisk PIP = Włącz obraz w obrazie dla urządzeń mobilnych
- // @name:pt-BR Botão PIP Flutuante = Ativar imagem em imagem para celular
- // @name:ro Buton PIP plutitor = Activează imagine în imagine pentru mobil
- // @name:sv Flytande PIP-knapp = Aktivera bild i bild för mobil
- // @name:th ปุ่ม PIP ลอย = เปิดใช้งานภาพในภาพสำหรับมือถือ
- // @name:tr Yüzen PIP Düğmesi = Mobil için Resim içinde Resim'i etkinleştir
- // @name:ug ھۆلۈپ تۇرغان PIP كۇنۇپكىسى = يانفونلار ئۈچۈن رەسىم ئىچىدە رەسىمنى قوزغىتىش
- // @name:uk Плаваюча кнопка PIP = Увімкнути картинку в картинці для мобільних пристроїв
- // @name:vi Nút PIP nổi = Bật chế độ Hình trong Hình cho di động
- // @name:zh-TW 浮動PIP按鈕 = 啟用行動裝置的畫中畫模式
- // @namespace https://jlcareglio.github.io/
- // @version 0.9.6
- // @description Adds a floating button to toggle Picture-in-Picture mode for videos on mobile devices.
- // @description:bg Добавя плаващ бутон за превключване на режим картина в картина за видеоклипове на мобилни устройства.
- // @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.
- // @description:da Tilføjer en flydende knap til at skifte billede-i-billede-tilstand for videoer på mobile enheder.
- // @description:de Fügt eine schwebende Schaltfläche hinzu, um den Bild-in-Bild-Modus für Videos auf mobilen Geräten umzuschalten.
- // @description:el Προσθέτει ένα επιπλέον κουμπί για εναλλαγή της λειτουργίας εικόνας σε εικόνα για βίντεο σε κινητές συσκευές.
- // @description:en Adds a floating button to toggle Picture-in-Picture mode for videos on mobile devices.
- // @description:eo Aldonas flosantan butonon por ŝalti Bildon en Bildo-reĝimon por videoj en poŝtelefonoj.
- // @description:es Agrega un botón flotante para alternar el modo Imagen en Imagen para videos en dispositivos móviles.
- // @description:fi Lisää kelluvan painikkeen, jolla voi vaihtaa kuva kuvassa -tilan videoille mobiililaitteissa.
- // @description:fr Ajoute un bouton flottant pour basculer en mode image dans l'image pour les vidéos sur les appareils mobiles.
- // @description:fr-CA Ajoute un bouton flottant pour basculer en mode image dans l'image pour les vidéos sur les appareils mobiles.
- // @description:he מוסיף כפתור צף למעבר למצב תמונה בתוך תמונה עבור סרטונים במכשירים ניידים.
- // @description:hr Dodaje plutajući gumb za prebacivanje načina slike u slici za videozapise na mobilnim uređajima.
- // @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.
- // @description:id Menambahkan tombol mengambang untuk beralih ke mode Gambar dalam Gambar untuk video di perangkat seluler.
- // @description:it Aggiunge un pulsante flottante per attivare la modalità immagine nell'immagine per i video sui dispositivi mobili.
- // @description:ja モバイルデバイスでビデオのピクチャーインピクチャーモードを切り替えるための浮動ボタンを追加します。
- // @description:ka ამატებს მცურავ ღილაკს მობილური მოწყობილობებისთვის ვიდეოების სურათში სურათის რეჟიმის ჩასართავად.
- // @description:ko 모바일 장치에서 비디오의 화면 속 화면 모드를 전환하는 플로팅 버튼을 추가합니다.
- // @description:nb Legger til en flytende knapp for å bytte bilde-i-bilde-modus for videoer på mobile enheter.
- // @description:nl Voegt een zwevende knop toe om de modus Beeld-in-Beeld voor video's op mobiele apparaten in te schakelen.
- // @description:pl Dodaje pływający przycisk do przełączania trybu obraz w obrazie dla filmów na urządzeniach mobilnych.
- // @description:pt-BR Adiciona um botão flutuante para alternar o modo Imagem em Imagem para vídeos em dispositivos móveis.
- // @description:ro Adaugă un buton plutitor pentru a comuta modul imagine în imagine pentru videoclipuri pe dispozitive mobile.
- // @description:sv Lägger till en flytande knapp för att växla bild-i-bild-läge för videor på mobila enheter.
- // @description:th เพิ่มปุ่มลอยเพื่อสลับโหมดภาพในภาพสำหรับวิดีโอบนอุปกรณ์เคลื่อนที่
- // @description:tr Mobil cihazlarda videolar için Resim içinde Resim modunu değiştirmek için yüzen bir düğme ekler.
- // @description:ug يانفونلاردا ۋىدىئولار ئۈچۈن رەسىم ئىچىدە رەسىم ھالىتىنى ئالماشتۇرۇش ئۈچۈن ھۆلۈپ تۇرغان كۇنۇپكا قوشىدۇ.
- // @description:uk Додає плаваючу кнопку для перемикання режиму картинка в картинці для відео на мобільних пристроях.
- // @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.
- // @description:zh-TW 添加一個浮動按鈕,以切換行動裝置上的影片畫中畫模式。
- // @icon https://lh3.googleusercontent.com/cvfpnTKw3B67DtM1ZpJG2PNAIjP6hVMOyYy403X4FMkOuStgG1y4cjCn21vmTnnsip1dTZSVsWBA9IxutGuA3dVDWhg
- // @grant none
- // @author Jesús Lautaro Careglio Albornoz
- // @source https://gist.githubusercontent.com/JLCareglio/22d3f9c9752352a29006f0c90c72d193/raw/01_Floating-PIP-Button.js
- // @match *://*/*
- // @license MIT
- // @compatible firefox
- // @compatible edge
- // @compatible kiwi
- // @supportURL https://gist.github.com/JLCareglio/22d3f9c9752352a29006f0c90c72d193
- // ==/UserScript==
-
- (async () => {
- const CONSTANTS = {
- BUTTON: {
- 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;
- }
- `,
- DEFAULT_POSITION: {
- right: 20,
- bottom: 20,
- },
- },
- TOUCH: {
- MOVE_THRESHOLD: 10,
- CLICK_TIMEOUT: 200,
- LONG_PRESS_TIMEOUT: 1000,
- LONG_PRESS_MOVE_THRESHOLD: 15,
- ANIMATION_DELAY: 300,
- },
- STORAGE: {
- POSITION_KEY: "pip_button_position",
- },
- };
-
- /**
- * Main class to handle the PIP button and its functionality
- */
- class PIPButton {
- #button;
- #watchedVideos;
- #observer;
- #isDragging = false;
- #touchStartTime = 0;
- #dragOffset = { x: 0, y: 0 };
- #initialPosition = { x: 0, y: 0 };
- #longPressTimer = null;
- #longPressStartPosition = { x: 0, y: 0 };
- #animationTimer = null;
- #isManuallyHidden = false;
-
- constructor() {
- this.#initializeButton();
- this.#initializeVideoObserver();
- this.#initializeDragHandlers();
- this.#detectInitialVideos();
- this.#initializeLongPressHandlers();
- }
-
- /**
- * Initializes the button and its styles
- * @private
- */
- #initializeButton() {
- this.#button = document.createElement("div");
- this.#button.classList.add("pipButton");
- this.#injectStyles();
- document.body.appendChild(this.#button);
- this.#watchedVideos = new Set();
- this.#loadButtonPosition();
- }
-
- /**
- * Injects required CSS styles
- * @private
- */
- #injectStyles() {
- const style = document.createElement("style");
- style.textContent = CONSTANTS.BUTTON.STYLE;
- document.head.appendChild(style);
- }
-
- /**
- * Initializes the video observer
- * @private
- */
- #initializeVideoObserver() {
- this.#observer = new MutationObserver(this.#handleMutations.bind(this));
- this.#observer.observe(document.body, {
- childList: true,
- subtree: true,
- });
- }
-
- /**
- * Handles DOM mutations to detect new videos
- * @private
- * @param {MutationRecord[]} mutations
- */
- #handleMutations(mutations) {
- mutations.forEach((mutation) => {
- mutation.addedNodes.forEach((node) => {
- if (node instanceof HTMLVideoElement) {
- this.#addVideo(node);
- }
- });
- });
- this.#updateButtonVisibility();
- }
-
- /**
- * Adds a video to the collection of observed videos
- * @private
- * @param {HTMLVideoElement} video
- */
- #addVideo(video) {
- if (!this.#watchedVideos.has(video)) {
- this.#watchedVideos.add(video);
- }
- }
-
- /**
- * Detects existing videos in the DOM on startup
- * @private
- */
- #detectInitialVideos() {
- document
- .querySelectorAll("video")
- .forEach((video) => this.#addVideo(video));
- this.#updateButtonVisibility();
- }
-
- /**
- * Toggles PIP mode for the active video
- * @private
- */
- #togglePIP() {
- try {
- if (this.#watchedVideos.size === 0) return;
-
- if (document.pictureInPictureElement) {
- document.exitPictureInPicture();
- return;
- }
-
- const playingVideo = Array.from(this.#watchedVideos).find(
- (video) => !video.paused && !video.ended && video.currentTime > 0
- );
-
- const videoToShow = playingVideo || Array.from(this.#watchedVideos)[0];
- videoToShow?.requestPictureInPicture().catch(console.error);
- } catch (error) {
- console.error("Error toggling PIP:", error);
- }
- }
-
- /**
- * Initializes event handlers for dragging
- * @private
- */
- #initializeDragHandlers() {
- this.#button.addEventListener(
- "mousedown",
- this.#handleDragStart.bind(this)
- );
- this.#button.addEventListener(
- "touchstart",
- this.#handleDragStart.bind(this)
- );
-
- document.addEventListener("mousemove", this.#handleDragMove.bind(this));
- document.addEventListener("touchmove", this.#handleDragMove.bind(this), {
- passive: false,
- });
-
- document.addEventListener("mouseup", this.#handleDragEnd.bind(this));
- document.addEventListener("touchend", this.#handleDragEnd.bind(this));
- document.addEventListener("touchcancel", this.#handleDragEnd.bind(this));
- }
-
- /**
- * Handles drag start
- * @private
- * @param {MouseEvent|TouchEvent} event
- */
- #handleDragStart(event) {
- this.#isDragging = true;
- this.#button.style.transform = "scale(2)";
- const rect = this.#button.getBoundingClientRect();
- this.#initialPosition = { x: rect.left, y: rect.top };
-
- const clientX = event.clientX || event.touches[0].clientX;
- const clientY = event.clientY || event.touches[0].clientY;
-
- this.#dragOffset = {
- x: clientX - this.#initialPosition.x,
- y: clientY - this.#initialPosition.y,
- };
-
- this.#touchStartTime = Date.now();
- event.preventDefault();
- event.stopPropagation();
- if (this.#longPressTimer) {
- clearTimeout(this.#longPressTimer);
- }
- }
-
- /**
- * Handles movement during drag
- * @private
- * @param {MouseEvent|TouchEvent} event
- */
- #handleDragMove(event) {
- if (!this.#isDragging) return;
-
- const clientX = event.clientX || event.touches[0].clientX;
- const clientY = event.clientY || event.touches[0].clientY;
-
- const newPosition = this.#calculateNewPosition(
- clientX - this.#dragOffset.x,
- clientY - this.#dragOffset.y
- );
-
- this.#updateButtonPosition(newPosition);
- event.preventDefault();
- event.stopPropagation();
- }
-
- /**
- * Calculates new button position
- * @private
- * @param {number} x
- * @param {number} y
- * @returns {{x: number, y: number}}
- */
- #calculateNewPosition(x, y) {
- const maxX = window.innerWidth - this.#button.offsetWidth;
- const maxY = window.innerHeight - this.#button.offsetHeight;
- return {
- x: Math.max(0, Math.min(x, maxX)),
- y: Math.max(0, Math.min(y, maxY)),
- };
- }
-
- /**
- * Updates button position
- * @private
- * @param {{x: number, y: number}} position
- */
- #updateButtonPosition(position) {
- this.#button.style.left = `${position.x}px`;
- this.#button.style.top = `${position.y}px`;
- this.#button.style.right = "auto";
- this.#button.style.bottom = "auto";
- }
-
- /**
- * Handles drag end
- * @private
- * @param {MouseEvent|TouchEvent} event
- */
- #handleDragEnd(event) {
- if (!this.#isDragging) return;
-
- this.#button.style.transform = "scale(1)";
- const distance = this.#calculateDragDistance();
- const elapsedTime = Date.now() - this.#touchStartTime;
-
- if (
- elapsedTime < CONSTANTS.TOUCH.CLICK_TIMEOUT &&
- distance <= CONSTANTS.TOUCH.MOVE_THRESHOLD &&
- event.button !== 2
- )
- this.#togglePIP();
-
- const position = {
- x: this.#button.offsetLeft,
- y: this.#button.offsetTop,
- };
- if (!this.#isManuallyHidden)
- localStorage.setItem(
- CONSTANTS.STORAGE.POSITION_KEY,
- JSON.stringify(position)
- );
-
- this.#isDragging = false;
- event.preventDefault();
- event.stopPropagation();
- }
-
- /**
- * Calculates drag distance
- * @private
- * @returns {number}
- */
- #calculateDragDistance() {
- const dx = this.#button.offsetLeft - this.#initialPosition.x;
- const dy = this.#button.offsetTop - this.#initialPosition.y;
- return Math.sqrt(dx * dx + dy * dy);
- }
-
- /**
- * Updates button visibility
- * @private
- */
- #updateButtonVisibility() {
- this.#button.style.display =
- this.#watchedVideos.size > 0 && !this.#isManuallyHidden
- ? "block"
- : "none";
- }
-
- /**
- * Initializes handlers for long press and right-click
- * @private
- */
- #initializeLongPressHandlers() {
- this.#button.addEventListener("contextmenu", (e) => {
- e.preventDefault();
- this.#hideButton();
- });
-
- const startLongPress = (e) => {
- const pos = e.touches ? e.touches[0] : e;
- this.#longPressStartPosition = { x: pos.clientX, y: pos.clientY };
-
- this.#button.style.setProperty("--delete-progress", "0");
-
- this.#animationTimer = setTimeout(() => {
- requestAnimationFrame(() => {
- this.#button.style.setProperty("--delete-progress", "1");
- });
- }, CONSTANTS.TOUCH.ANIMATION_DELAY);
-
- this.#longPressTimer = setTimeout(() => {
- this.#hideButton();
- }, CONSTANTS.TOUCH.LONG_PRESS_TIMEOUT);
- };
-
- const moveDuringPress = (e) => {
- if (this.#longPressTimer) {
- const pos = e.touches ? e.touches[0] : e;
- const moveDistance = Math.sqrt(
- Math.pow(pos.clientX - this.#longPressStartPosition.x, 2) +
- Math.pow(pos.clientY - this.#longPressStartPosition.y, 2)
- );
-
- if (moveDistance > CONSTANTS.TOUCH.LONG_PRESS_MOVE_THRESHOLD) {
- clearTimeout(this.#longPressTimer);
- clearTimeout(this.#animationTimer);
- this.#longPressTimer = null;
- this.#animationTimer = null;
- this.#button.style.setProperty("--delete-progress", "0");
- }
- }
- };
-
- const endLongPress = () => {
- if (this.#longPressTimer) {
- clearTimeout(this.#longPressTimer);
- clearTimeout(this.#animationTimer);
- this.#button.style.setProperty("--delete-progress", "0");
- }
- };
-
- // Touch events
- this.#button.addEventListener("touchstart", startLongPress);
- this.#button.addEventListener("touchmove", moveDuringPress);
- this.#button.addEventListener("touchend", endLongPress);
-
- // Mouse events
- this.#button.addEventListener("mousedown", (e) => {
- if (e.button === 0) startLongPress(e);
- });
- this.#button.addEventListener("mousemove", moveDuringPress);
- this.#button.addEventListener("mouseup", endLongPress);
- this.#button.addEventListener("mouseleave", endLongPress);
- }
-
- /**
- * Hides the PIP button
- * @private
- */
- #hideButton() {
- this.#isManuallyHidden = true;
- this.#button.style.display = "none";
- }
-
- #loadButtonPosition() {
- const savedPosition = localStorage.getItem(
- CONSTANTS.STORAGE.POSITION_KEY
- );
- if (savedPosition) {
- const position = JSON.parse(savedPosition);
- this.#updateButtonPosition(position);
- } else {
- this.#button.style.right = `${CONSTANTS.BUTTON.DEFAULT_POSITION.right}px`;
- this.#button.style.bottom = `${CONSTANTS.BUTTON.DEFAULT_POSITION.bottom}px`;
- }
- }
- }
-
- if (document.readyState === "loading")
- document.addEventListener("DOMContentLoaded", () => new PIPButton());
- else new PIPButton();
- })();