pip anywhere

(cick the pip button inside of your userscript manager menu) pip with full custom controls on any site seek bar, buffer indicator, volume, speed, skip, keyboard shortcuts, auto-hide hud. gracefully falls back to legacy pip on unsupported browsers.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name            pip anywhere
// @namespace       https://minoa.cat/
// @version         2.1.1
// @description     (cick the pip button inside of your userscript manager menu) pip with full custom controls on any site seek bar, buffer indicator, volume, speed, skip, keyboard shortcuts, auto-hide hud. gracefully falls back to legacy pip on unsupported browsers.
// @author          minoa
// @license         MIT
// @homepageURL     https://greasyfork.org/scripts/pip-anywhere
// @supportURL      https://github.com/M2noa/pip-anywhere/issues
// @match           *://*/*
// @grant           GM_registerMenuCommand
// @grant           GM_unregisterMenuCommand
// @grant           GM_getValue
// @grant           GM_setValue
// @noframes
// ==/UserScript==

;(function () {
  'use strict'

  // whether document pip is available in this browser (chrome 116+)
  const DPIP_SUPPORTED = 'documentPictureInPicture' in window

  // speed steps to cycle through
  const SPEEDS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2, 3, 4]

  // storage key
  const KEY_MODE = 'pip_mode'

  // --- persistent mode preference ---
  // 'advanced' = document pip with custom hud
  // 'basic'    = native video.requestPictureInPicture()
  // if dpip isnt supported we always use basic regardless

  let mode = GM_getValue(KEY_MODE, 'advanced')

  // menu command handles so we can re-register on toggle
  let menuPipId    = null
  let menuToggleId = null

  // --- session state ---

  let pinned    = null  // video locked for this page session
  let pipWin    = null  // documentPictureInPicture window reference
  let rafId     = null  // requestAnimationFrame handle for progress loop
  let hideTimer = null  // auto-hide timeout handle

  // saved dom position so we can restore the video element after pip closes
  let savedParent = null
  let savedNext   = null
  let savedStyles = null

  // --- video discovery ---

  function getVideos() {
    return Array.from(document.querySelectorAll('video')).filter(
      v => v.readyState > 0 && v.offsetParent !== null
    )
  }

  function getPlaying() {
    return getVideos().filter(v => !v.paused && !v.ended)
  }

  function elementCenter(el) {
    const r = el.getBoundingClientRect()
    return [r.left + r.width / 2, r.top + r.height / 2]
  }

  function closestToPoint(mx, my, list) {
    let best = null
    let bd   = Infinity
    for (const v of list) {
      const [cx, cy] = elementCenter(v)
      const d = Math.hypot(cx - mx, cy - my)
      if (d < bd) { bd = d; best = v }
    }
    return best
  }

  // --- utils ---

  function fmtTime(s) {
    s = Math.floor(s || 0)
    const h   = Math.floor(s / 3600)
    const m   = Math.floor((s % 3600) / 60)
    const sec = s % 60
    if (h > 0) {
      return h + ':' + String(m).padStart(2, '0') + ':' + String(sec).padStart(2, '0')
    }
    return m + ':' + String(sec).padStart(2, '0')
  }

  function makeSvg(path, size) {
    size = size || 16
    return (
      '<svg width="' + size + '" height="' + size +
      '" viewBox="0 0 24 24" fill="currentColor">' + path + '</svg>'
    )
  }

  const ICON = {
    play:  makeSvg('<path d="M8 5v14l11-7z"/>'),
    pause: makeSvg('<path d="M6 19h4V5H6zm8-14v14h4V5z"/>'),
    mute:  makeSvg('<path d="M16.5 12A4.5 4.5 0 0014 7.97v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.796 8.796 0 0021 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06A8.99 8.99 0 0017.73 18l1.98 2L21 18.73 4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>'),
    vol:   makeSvg('<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3A4.5 4.5 0 0014 7.97v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>'),
    back:  makeSvg('<path d="M11 18V6l-8.5 6 8.5 6zm.5-6l8.5 6V6l-8.5 6z"/>'),
    fwd:   makeSvg('<path d="M4 18l8.5-6L4 6v12zm9-12v12l8.5-6L13 6z"/>'),
    close: makeSvg('<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>')
  }

  // --- selection mode ---
  // when multiple videos are playing, outline the closest one to the mouse
  // and let the user click to confirm which one to pip

  let selecting  = false
  let selHovered = null

  function enterSelectMode(candidates, cb) {
    if (selecting) return
    selecting = true

    const prevCursor = document.documentElement.style.cursor
    document.documentElement.style.cursor = 'crosshair'

    function hl(v) {
      v.style.setProperty('outline', '2px solid #0af', 'important')
      v.style.setProperty('outline-offset', '-2px', 'important')
    }
    function unhl(v) {
      if (!v) return
      v.style.removeProperty('outline')
      v.style.removeProperty('outline-offset')
    }
    function onMove(e) {
      const next = closestToPoint(e.clientX, e.clientY, candidates)
      if (next === selHovered) return
      unhl(selHovered)
      selHovered = next
      if (selHovered) hl(selHovered)
    }
    function onKey(e) {
      if (e.key === 'Escape') cleanup()
    }
    function onClick(e) {
      e.stopPropagation()
      e.preventDefault()
      const target = selHovered
      cleanup()
      cb(target)
    }
    function cleanup() {
      selecting = false
      unhl(selHovered)
      selHovered = null
      document.documentElement.style.cursor = prevCursor
      document.removeEventListener('mousemove', onMove)
      document.removeEventListener('click', onClick, true)
      document.removeEventListener('keydown', onKey)
    }

    document.addEventListener('mousemove', onMove)
    document.addEventListener('click', onClick, true)
    document.addEventListener('keydown', onKey)
  }

  // --- styles injected into the document pip window ---

  const HUD_CSS = [
    '* { box-sizing: border-box; margin: 0; padding: 0; user-select: none; -webkit-user-select: none; }',
    'body {',
    '  background: #000; width: 100vw; height: 100vh;',
    '  overflow: hidden; display: flex;',
    '  align-items: center; justify-content: center;',
    '}',
    '#wrap { position: relative; width: 100%; height: 100%; }',
    'video {',
    '  width: 100% !important; height: 100% !important;',
    '  max-width: 100% !important; max-height: 100% !important;',
    '  object-fit: contain; display: block;',
    '}',
    '#hud {',
    '  position: absolute; bottom: 0; left: 0; right: 0;',
    '  padding: 36px 12px 10px;',
    '  background: linear-gradient(transparent, rgba(0,0,0,0.85));',
    '  transition: opacity 0.2s ease;',
    '}',
    '#hud.hidden { opacity: 0; pointer-events: none; }',
    '#prog-wrap {',
    '  position: relative; height: 4px;',
    '  background: rgba(255,255,255,0.18); border-radius: 2px;',
    '  margin-bottom: 9px; cursor: pointer;',
    '  transition: height 0.12s, margin-bottom 0.12s;',
    '}',
    '#prog-wrap:hover { height: 6px; margin-bottom: 7px; }',
    '#buf {',
    '  position: absolute; left: 0; top: 0; height: 100%;',
    '  background: rgba(255,255,255,0.22); border-radius: 2px; pointer-events: none;',
    '}',
    '#fill {',
    '  position: absolute; left: 0; top: 0; height: 100%;',
    '  background: #0af; border-radius: 2px; pointer-events: none;',
    '}',
    '#prog-thumb {',
    '  position: absolute; top: 50%; transform: translate(-50%, -50%);',
    '  width: 12px; height: 12px; background: #fff; border-radius: 50%;',
    '  pointer-events: none; opacity: 0; transition: opacity 0.15s;',
    '}',
    '#prog-wrap:hover #prog-thumb { opacity: 1; }',
    '#prog-tip {',
    '  position: absolute; bottom: 14px; transform: translateX(-50%);',
    '  background: rgba(0,0,0,0.75); color: #fff;',
    '  font-size: 10px; font-family: ui-monospace, monospace;',
    '  padding: 2px 5px; border-radius: 3px;',
    '  pointer-events: none; white-space: nowrap;',
    '  opacity: 0; transition: opacity 0.1s;',
    '}',
    '#prog-wrap:hover #prog-tip { opacity: 1; }',
    '#btns { display: flex; align-items: center; gap: 3px; }',
    'button {',
    '  background: none; border: none; color: #fff; cursor: pointer;',
    '  padding: 4px 6px; border-radius: 4px; line-height: 1;',
    '  opacity: 0.82; transition: opacity 0.12s, background 0.12s; flex-shrink: 0;',
    '}',
    'button:hover  { opacity: 1; background: rgba(255,255,255,0.12); }',
    'button:active { background: rgba(255,255,255,0.2); }',
    '#time {',
    '  font-size: 11px; color: rgba(255,255,255,0.78);',
    '  white-space: nowrap; padding: 0 4px;',
    '  font-family: ui-monospace, monospace;',
    '}',
    '.spacer { flex: 1; }',
    '#vol-wrap { display: flex; align-items: center; gap: 4px; }',
    'input[type=range] {',
    '  -webkit-appearance: none; appearance: none;',
    '  width: 64px; height: 3px;',
    '  background: rgba(255,255,255,0.28); border-radius: 2px;',
    '  outline: none; cursor: pointer;',
    '}',
    'input[type=range]::-webkit-slider-thumb {',
    '  -webkit-appearance: none; appearance: none;',
    '  width: 11px; height: 11px; background: #fff; border-radius: 50%;',
    '}',
    '#speed-btn {',
    '  font-variant-numeric: tabular-nums;',
    '  min-width: 36px; text-align: center; font-size: 11px;',
    '}',
    'svg { display: block; }',
  ].join('\n')

  // --- build and wire up the hud inside the pip window ---

  function buildHud(doc, video) {
    const wrap = doc.createElement('div')
    wrap.id = 'wrap'

    const hud = doc.createElement('div')
    hud.id = 'hud'
    hud.innerHTML = [
      '<div id="prog-wrap">',
        '<div id="buf"></div>',
        '<div id="fill"></div>',
        '<div id="prog-thumb"></div>',
        '<div id="prog-tip">0:00</div>',
      '</div>',
      '<div id="btns">',
        '<button id="btn-back"  title="back 10s">'      + ICON.back  + '</button>',
        '<button id="btn-pp"    title="play / pause">'  + (video.paused ? ICON.play : ICON.pause) + '</button>',
        '<button id="btn-fwd"   title="forward 10s">'   + ICON.fwd   + '</button>',
        '<span id="time">' + fmtTime(video.currentTime) + ' / ' + fmtTime(video.duration) + '</span>',
        '<div class="spacer"></div>',
        '<div id="vol-wrap">',
          '<button id="btn-mute" title="mute">' + (video.muted ? ICON.mute : ICON.vol) + '</button>',
          '<input type="range" id="vol-slider" min="0" max="1" step="0.02" value="' + video.volume + '">',
        '</div>',
        '<button id="speed-btn" title="cycle speed">' + video.playbackRate + 'x</button>',
        '<button id="btn-close" title="close pip">'   + ICON.close + '</button>',
      '</div>',
    ].join('')

    wrap.appendChild(video)
    wrap.appendChild(hud)
    doc.body.appendChild(wrap)

    const elPp      = hud.querySelector('#btn-pp')
    const elBack    = hud.querySelector('#btn-back')
    const elFwd     = hud.querySelector('#btn-fwd')
    const elMute    = hud.querySelector('#btn-mute')
    const elVolSldr = hud.querySelector('#vol-slider')
    const elSpeed   = hud.querySelector('#speed-btn')
    const elClose   = hud.querySelector('#btn-close')
    const elFill    = hud.querySelector('#fill')
    const elBuf     = hud.querySelector('#buf')
    const elThumb   = hud.querySelector('#prog-thumb')
    const elTip     = hud.querySelector('#prog-tip')
    const elProgW   = hud.querySelector('#prog-wrap')
    const elTime    = hud.querySelector('#time')

    // raf loop - syncs seek bar, buffer, time, and button icon states
    function tick() {
      if (!video || video.readyState < 1) {
        rafId = requestAnimationFrame(tick)
        return
      }
      const dur = video.duration || 0
      const pct = dur ? Math.min(1, video.currentTime / dur) : 0
      const pctStr = (pct * 100).toFixed(3) + '%'

      elFill.style.width  = pctStr
      elThumb.style.left  = pctStr
      elTime.textContent  = fmtTime(video.currentTime) + ' / ' + fmtTime(dur)

      if (video.buffered.length && dur) {
        elBuf.style.width =
          (video.buffered.end(video.buffered.length - 1) / dur * 100).toFixed(3) + '%'
      }

      elPp.innerHTML   = video.paused ? ICON.play : ICON.pause
      elMute.innerHTML = (video.muted || video.volume === 0) ? ICON.mute : ICON.vol

      rafId = requestAnimationFrame(tick)
    }
    rafId = requestAnimationFrame(tick)

    // play/pause
    elPp.addEventListener('click', () => video.paused ? video.play() : video.pause())

    // skip buttons
    elBack.addEventListener('click', () => {
      video.currentTime = Math.max(0, video.currentTime - 10)
    })
    elFwd.addEventListener('click', () => {
      video.currentTime = Math.min(video.duration || 0, video.currentTime + 10)
    })

    // mute toggle
    elMute.addEventListener('click', () => {
      video.muted     = !video.muted
      elVolSldr.value = video.muted ? 0 : video.volume
    })

    // volume slider
    elVolSldr.addEventListener('input', () => {
      video.volume = parseFloat(elVolSldr.value)
      video.muted  = video.volume === 0
    })

    // speed cycle
    elSpeed.addEventListener('click', () => {
      const cur               = SPEEDS.indexOf(video.playbackRate)
      const next              = SPEEDS[(cur + 1) % SPEEDS.length]
      video.playbackRate      = next
      elSpeed.textContent     = next + 'x'
    })

    // seek bar - click to jump, drag to scrub
    let scrubbing = false

    function seekFromEvent(e) {
      const r   = elProgW.getBoundingClientRect()
      const pct = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width))
      video.currentTime = pct * (video.duration || 0)
    }

    // tooltip on hover showing time at cursor position
    elProgW.addEventListener('mousemove', e => {
      const r         = elProgW.getBoundingClientRect()
      const pct       = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width))
      elTip.textContent = fmtTime(pct * (video.duration || 0))
      elTip.style.left  = (pct * 100).toFixed(3) + '%'
    })

    elProgW.addEventListener('mousedown', e => { scrubbing = true; seekFromEvent(e) })
    doc.addEventListener('mousemove',     e => { if (scrubbing) seekFromEvent(e) })
    doc.addEventListener('mouseup',       ()  => { scrubbing = false })

    // close button
    elClose.addEventListener('click', () => pipWin && pipWin.close())

    // auto-hide hud after 3s of no mouse activity
    function showHud() {
      hud.classList.remove('hidden')
      clearTimeout(hideTimer)
      hideTimer = setTimeout(() => { hud.classList.add('hidden') }, 3000)
    }
    doc.addEventListener('mousemove', showHud)
    doc.addEventListener('click',     showHud)
    showHud()

    // keyboard shortcuts when pip window has focus
    doc.addEventListener('keydown', e => {
      if (e.ctrlKey || e.metaKey || e.altKey) return
      const dur = video.duration || 0

      switch (e.key) {
        case ' ':
        case 'k':
          e.preventDefault()
          video.paused ? video.play() : video.pause()
          break
        case 'ArrowLeft':
        case 'j':
          video.currentTime = Math.max(0, video.currentTime - 10)
          break
        case 'ArrowRight':
        case 'l':
          video.currentTime = Math.min(dur, video.currentTime + 10)
          break
        case 'ArrowUp':
          e.preventDefault()
          video.volume    = Math.min(1, +(video.volume + 0.1).toFixed(2))
          elVolSldr.value = video.volume
          break
        case 'ArrowDown':
          e.preventDefault()
          video.volume    = Math.max(0, +(video.volume - 0.1).toFixed(2))
          elVolSldr.value = video.volume
          break
        case 'm':
          video.muted = !video.muted
          break
        case 'Home':
          e.preventDefault()
          video.currentTime = 0
          break
        case 'End':
          e.preventDefault()
          video.currentTime = dur
          break
        case '>':
        case '.': {
          const ni            = Math.min(SPEEDS.length - 1, SPEEDS.indexOf(video.playbackRate) + 1)
          video.playbackRate  = SPEEDS[ni]
          elSpeed.textContent = SPEEDS[ni] + 'x'
          break
        }
        case '<':
        case ',': {
          const ni            = Math.max(0, SPEEDS.indexOf(video.playbackRate) - 1)
          video.playbackRate  = SPEEDS[ni]
          elSpeed.textContent = SPEEDS[ni] + 'x'
          break
        }
        // 0-9 to jump to 0%-90% through the video (youtube-style)
        case '0': case '1': case '2': case '3': case '4':
        case '5': case '6': case '7': case '8': case '9':
          video.currentTime = dur * (parseInt(e.key, 10) / 10)
          break
      }
      showHud()
    })
  }

  // --- document pip (advanced mode) ---

  async function openDocumentPip(video) {
    if (pipWin) { pipWin.close(); pipWin = null }
    if (document.pictureInPictureElement) {
      await document.exitPictureInPicture().catch(() => {})
    }

    // remember the video's original dom position for restoration on close
    savedParent = video.parentNode
    savedNext   = video.nextSibling
    savedStyles = {
      width:     video.style.width,
      height:    video.style.height,
      position:  video.style.position,
      maxWidth:  video.style.maxWidth,
      maxHeight: video.style.maxHeight,
    }

    pinned = video

    // size window to match video aspect ratio
    const vw   = video.videoWidth  || 640
    const vh   = video.videoHeight || 360
    const winH = 360
    const winW = Math.round(winH * (vw / vh))

    let win
    try {
      win = await window.documentPictureInPicture.requestWindow({
        width:                  winW,
        height:                 winH,
        disallowReturnToOpener: false,
      })
    } catch (err) {
      // browser denied or csp blocked - fall back to legacy
      console.warn('[pip-anywhere] document pip failed, falling back to legacy:', err)
      openLegacyPip(video)
      return
    }

    pipWin = win

    const style       = win.document.createElement('style')
    style.textContent = HUD_CSS
    win.document.head.appendChild(style)

    buildHud(win.document, video)

    // restore video to its original dom position when the pip window closes.
    // wrapped in tiered try/catch because sites like cineby mutate the player
    // dom while pip is open, leaving savedParent/savedNext in stale states.
    win.addEventListener('pagehide', () => {
      if (rafId) { cancelAnimationFrame(rafId); rafId = null }
      clearTimeout(hideTimer)
      pipWin = null

      if (savedStyles) Object.assign(video.style, savedStyles)

      try {
        // tier 1: ideal - put it back exactly where it was
        if (savedParent && savedParent.isConnected) {
          if (savedNext && savedNext.isConnected && savedNext.parentNode === savedParent) {
            savedParent.insertBefore(video, savedNext)
          } else {
            savedParent.appendChild(video)
          }
        } else {
          // tier 2: parent is gone - try to find a new home via a fresh query
          // sites that rebuild the player will have a new container in the dom
          const newParent = document.querySelector(
            'video') ? null : document.body
          if (newParent) newParent.appendChild(video)
          // if a fresh video already exists in the dom the site recreated the
          // player itself, so we just let the element sit detached - it'll be
          // garbage collected and the site's player is already working fine
        }
      } catch (e) {
        // tier 3: something still went wrong (race, shadow dom, etc.)
        // swallow silently - the site will handle its own player state
        try { document.body.appendChild(video) } catch (_) {}
      }

      savedParent = savedNext = savedStyles = null
    })
  }

  // --- legacy pip (basic mode / dpip not supported) ---

  async function openLegacyPip(video) {
    pinned = video
    if (document.pictureInPictureElement === video) return
    await video.requestPictureInPicture().catch(() => {})
  }

  // --- dispatch to the right pip implementation ---

  function openPip(video) {
    if (mode === 'advanced' && DPIP_SUPPORTED) {
      openDocumentPip(video)
    } else {
      openLegacyPip(video)
    }
  }

  // --- main pip action ---

  async function triggerPip() {
    // reuse pinned video for the rest of the session
    if (pinned && document.body.contains(pinned)) {
      openPip(pinned)
      return
    }

    const all = getVideos()
    if (!all.length) return

    if (all.length === 1) { openPip(all[0]); return }

    const active = getPlaying()
    if (active.length === 1) { openPip(active[0]); return }
    if (active.length > 1)  { enterSelectMode(active, openPip); return }

    // nothing playing - pick the biggest visible video, probably the main player
    const biggest = all.reduce((a, b) => {
      const ra = a.getBoundingClientRect()
      const rb = b.getBoundingClientRect()
      return ra.width * ra.height >= rb.width * rb.height ? a : b
    })
    openPip(biggest)
  }

  // --- menu command labels ---

  function pipLabel() {
    if (!DPIP_SUPPORTED) return 'PiP'
    return mode === 'advanced' ? 'PiP  [Advanced]' : 'PiP  [Basic]'
  }

  function toggleLabel() {
    // only show the toggle if the browser actually supports document pip
    if (!DPIP_SUPPORTED) return null
    return mode === 'advanced' ? 'Switch to Basic PiP' : 'Switch to Advanced PiP'
  }

  function registerMenuCommands() {
    if (menuPipId    != null) { try { GM_unregisterMenuCommand(menuPipId)    } catch (e) {} }
    if (menuToggleId != null) { try { GM_unregisterMenuCommand(menuToggleId) } catch (e) {} }

    menuPipId = GM_registerMenuCommand(pipLabel(), triggerPip)

    const label = toggleLabel()
    if (label) menuToggleId = GM_registerMenuCommand(label, toggleMode)
  }

  function toggleMode() {
    mode = mode === 'advanced' ? 'basic' : 'advanced'
    GM_setValue(KEY_MODE, mode)
    registerMenuCommands()
  }

  // boot
  registerMenuCommands()
})()