Floating PIP Button = Enable Picture in Picture for mobile

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

As of 2025-02-01. See the latest version.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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();
})();