Greasy Fork is available in English.
(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.
// ==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()
})()