浮動PIP按鈕 = 啟用行動裝置的畫中畫模式

添加一個浮動按鈕,以切換行動裝置上的影片畫中畫模式。

目前為 2025-02-04 提交的版本,檢視 最新版本

// ==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:es-la          Botón Flotante PIP = Habilita Imagen en Imagen para móvil
// @name:es-419         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             1.0.2
// @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:es-la   Agrega un botón flotante para alternar el modo Imagen en Imagen para videos en dispositivos móviles.
// @description:es-419  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.user.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()
          .then(() => {
            Object.defineProperty(document, "visibilityState", {
              get: () => "visible",
            });
          })
          .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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址