Miniplayer when you scroll down under the video. (configurable!!!)
// ==UserScript==
// @name Actually Working YouTube Miniplayer
// @author Torkelicious
// @version 1.0
// @license GPL-3.0-or-later
// @description Miniplayer when you scroll down under the video. (configurable!!!)
// @icon https://www.youtube.com/favicon.ico
// @match *://www.youtube.com/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @run-at document-start
// @namespace https://greasyfork.org/users/1403155
// ==/UserScript==
(function () {
'use strict';
// these settings are user-configurable
const DEFAULTS = {
pinnedVideoSize: '480x270', // supports 'WIDTHxHEIGHT' or 'WIDTHxauto'
pinnedVideoPosition: 'Bottom right', // one of the nine presets below
};
// Persisted settings
const settings = {
pinnedVideoSize: (() => {
try {
return GM_getValue('pinnedVideoSize', DEFAULTS.pinnedVideoSize);
} catch (e) {
console.warn('Failed to load pinnedVideoSize:', e);
return DEFAULTS.pinnedVideoSize;
}
})(),
pinnedVideoPosition: (() => {
try {
return GM_getValue('pinnedVideoPosition', DEFAULTS.pinnedVideoPosition);
} catch (e) {
console.warn('Failed to load pinnedVideoPosition:', e);
return DEFAULTS.pinnedVideoPosition;
}
})(),
};
// Presets for the dropdowns
const SIZE_PRESETS = [
'320x180','426x240','480x270','640x360','854x480','960x540','1280x720',
'480xauto','640xauto','854xauto'
];
const POSITION_PRESETS = [
'Top left','Top center','Top right',
'Center left','Center','Center right',
'Bottom left','Bottom center','Bottom right'
];
// ID for config UI
const CONFIG_OVERLAY_ID = 'yt-miniplayer-config-overlay';
const CONFIG_STYLE_ID = 'yt-miniplayer-config-style';
// DOM selectors as constants
const SELECTORS = {
moviePlayer: '#movie_player',
below: '#below',
watchFlexy: 'ytd-watch-flexy',
video: '#movie_player video',
};
// State
let isReady = false;
let styleEl = null;
let sentinelObserver = null;
let flexyAttrObserver = null;
let resizeListenerAttached = false;
// Sticky only buttons
let stickyCloseBtn = null;
let stickyTopBtn = null;
// ---------------- Config UI ----------------
function ensureConfigCss() {
if (document.getElementById(CONFIG_STYLE_ID)) return;
GM_addStyle(`
#${CONFIG_OVERLAY_ID} {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center;
}
#${CONFIG_OVERLAY_ID} .panel {
background: #1f1f1f; color: #fff; min-width: 320px; max-width: 90vw;
border-radius: 10px; box-shadow: 0 10px 40px rgba(0,0,0,0.5);
padding: 16px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, "Helvetica Neue", Arial, "Noto Sans";
}
#${CONFIG_OVERLAY_ID} .panel h2 { margin: 0 0 12px 0; font-size: 16px; font-weight: 600; }
#${CONFIG_OVERLAY_ID} .row { margin: 10px 0; }
#${CONFIG_OVERLAY_ID} label { display: block; margin-bottom: 6px; font-size: 13px; color: #ddd; }
#${CONFIG_OVERLAY_ID} select {
width: 100%; padding: 6px 8px; border-radius: 8px; border: 1px solid #444; background: #2a2a2a; color: #fff;
}
#${CONFIG_OVERLAY_ID} .buttons { display: flex; gap: 8px; justify-content: flex-end; margin-top: 14px; }
#${CONFIG_OVERLAY_ID} button {
appearance: none; border: 1px solid #555; background: #2b2b2b; color: #fff; padding: 6px 12px; border-radius: 8px; cursor: pointer;
}
#${CONFIG_OVERLAY_ID} button.primary { background: #3d6ae0; border-color: #2f55b5; }
`).id = CONFIG_STYLE_ID;
}
function createEl(tag, props) {
const el = document.createElement(tag);
if (props) {
for (const [k, v] of Object.entries(props)) {
if (k === 'text') el.textContent = v;
else if (k === 'children' && Array.isArray(v)) v.forEach(c => el.appendChild(c));
else if (k === 'on') {
for (const [evt, handler] of Object.entries(v)) el.addEventListener(evt, handler);
} else el.setAttribute(k, v);
}
}
return el;
}
function optionFor(value, label) {
const o = document.createElement('option');
o.value = value;
o.textContent = label ?? value;
return o;
}
function showConfigDialog() {
ensureConfigCss();
const old = document.getElementById(CONFIG_OVERLAY_ID);
if (old && old.parentNode) old.parentNode.removeChild(old);
const overlay = createEl('div', { id: CONFIG_OVERLAY_ID });
overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
const panel = createEl('div', { class: 'panel' });
const title = createEl('h2', { text: 'Miniplayer Settings' });
// Size row
const sizeRow = createEl('div', { class: 'row' });
const sizeLabel = createEl('label', { text: 'Pinned size' });
const sizeSelect = createEl('select');
if (!SIZE_PRESETS.includes(settings.pinnedVideoSize)) {
sizeSelect.appendChild(optionFor(settings.pinnedVideoSize, `${settings.pinnedVideoSize} (current)`));
}
SIZE_PRESETS.forEach(s => sizeSelect.appendChild(optionFor(s)));
sizeSelect.value = settings.pinnedVideoSize;
// Position row
const posRow = createEl('div', { class: 'row' });
const posLabel = createEl('label', { text: 'Pinned position' });
const posSelect = createEl('select');
if (!POSITION_PRESETS.includes(settings.pinnedVideoPosition)) {
posSelect.appendChild(optionFor(settings.pinnedVideoPosition, `${settings.pinnedVideoPosition} (current)`));
}
POSITION_PRESETS.forEach(p => posSelect.appendChild(optionFor(p)));
posSelect.value = settings.pinnedVideoPosition;
// Buttons
const buttons = createEl('div', { class: 'buttons' });
const cancelBtn = createEl('button', { text: 'Cancel' });
cancelBtn.addEventListener('click', () => overlay.remove());
const saveBtn = createEl('button', { text: 'Save', class: 'primary' });
saveBtn.addEventListener('click', () => {
const newSize = sizeSelect.value;
const newPos = posSelect.value;
if (newSize !== settings.pinnedVideoSize) {
settings.pinnedVideoSize = newSize;
try {
GM_setValue('pinnedVideoSize', newSize);
} catch (e) {
console.error('Failed to save pinnedVideoSize:', e);
}
}
if (newPos !== settings.pinnedVideoPosition) {
settings.pinnedVideoPosition = newPos;
try {
GM_setValue('pinnedVideoPosition', newPos);
} catch (e) {
console.error('Failed to save pinnedVideoPosition:', e);
}
}
rebuildStickyStyle();
scheduleResize();
overlay.remove();
});
buttons.appendChild(cancelBtn);
buttons.appendChild(saveBtn);
sizeRow.appendChild(sizeLabel);
sizeRow.appendChild(sizeSelect);
posRow.appendChild(posLabel);
posRow.appendChild(posSelect);
panel.appendChild(title);
panel.appendChild(sizeRow);
panel.appendChild(posRow);
panel.appendChild(buttons);
overlay.appendChild(panel);
document.documentElement.appendChild(overlay);
sizeSelect.focus();
}
GM_registerMenuCommand('Configure Miniplayer', showConfigDialog);
// ---------------- Core ----------------
function parseSizeTokens(text) {
const m = (text || '').match(/\d+|auto/gi);
return m || ['480', '270'];
}
function getCurrentVideoAspect() {
const v = document.querySelector(SELECTORS.video);
if (v && v.videoWidth && v.videoHeight) return v.videoWidth / v.videoHeight;
const p = document.getElementById('movie_player');
if (p && p.clientWidth && p.clientHeight) return p.clientWidth / p.clientHeight;
return 16 / 9;
}
function computePinnedSize() {
const tokens = parseSizeTokens(settings.pinnedVideoSize);
const wantedW = tokens[0] && tokens[0] !== 'auto' ? parseInt(tokens[0], 10) : 480;
const wantAutoH = (tokens[1] || '').toLowerCase() === 'auto';
const w = Number.isFinite(wantedW) ? wantedW : 480;
let h;
if (wantAutoH) {
const ar = getCurrentVideoAspect();
h = Math.round(w / (ar || (16 / 9)));
} else {
const wantedH = tokens[1] && tokens[1] !== 'auto' ? parseInt(tokens[1], 10) : 270;
h = Number.isFinite(wantedH) ? wantedH : 270;
}
return [w, h];
}
// Debounced resize dispatch to avoid excessive events !!!
let resizeRafId = null;
function scheduleResize() {
if (resizeRafId) return;
resizeRafId = requestAnimationFrame(() => {
window.dispatchEvent(new Event('resize'));
resizeRafId = null;
});
}
function rebuildStickyStyle() {
const [w, h] = computePinnedSize();
const pos = (settings.pinnedVideoPosition || DEFAULTS.pinnedVideoPosition).toLowerCase();
const cssProp = { top: 'initial', bottom: 'initial', right: 'initial', left: 'initial', transform: 'initial' };
// i should make this configurable maybe?
const GAP = '10px';
// Vertical
if (pos.startsWith('top')) cssProp.top = GAP;
else if (pos.startsWith('center')) {
cssProp.top = '50vh';
cssProp.transform = 'translateY(-50%)';
} else cssProp.bottom = GAP; // default bottom
// Horizontal
if (pos.endsWith('left')) cssProp.left = GAP;
else if (pos.endsWith('center')) {
cssProp.left = '50vw';
cssProp.transform = (cssProp.transform === 'initial' ? '' : cssProp.transform + ' ') + 'translateX(-50%)';
} else cssProp.right = GAP; // default right
const css = `
/* Sticky player container positioning (size/pos only) - high specificity */
.yttw-sticky-player.yttw-sticky-player ytd-watch-flexy #player-container.ytd-watch-flexy {
position: fixed !important;
width: ${w}px !important;
height: ${h}px !important;
top: ${cssProp.top} !important;
bottom: ${cssProp.bottom} !important;
right: ${cssProp.right} !important;
left: ${cssProp.left} !important;
transform: ${cssProp.transform} !important;
z-index: 9999 !important;
overflow: hidden !important;
}
/* Fill the sticky box and avoid cropping */
.yttw-sticky-player.yttw-sticky-player #movie_player,
.yttw-sticky-player.yttw-sticky-player .html5-video-container {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
min-width: 0 !important;
min-height: 0 !important;
}
.yttw-sticky-player.yttw-sticky-player #movie_player video {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
object-fit: contain !important;
}
/* Sticky-only buttons; minimal styles */
.yttw-sticky-player ytd-watch-flexy #movie_player:hover .yttw-sticky-player-button {
opacity: 1;
visibility: visible;
}
.yttw-sticky-player-button {
display: flex !important;
align-items: center;
justify-content: center;
opacity: 0;
visibility: hidden;
position: absolute;
cursor: pointer;
top: 8px;
width: 36px;
height: 36px;
border-radius: 50%;
z-index: 60;
background-color: rgba(0, 0, 0, 0.4);
transition: opacity .2s, visibility .2s;
border: none;
padding: 0;
}
`;
if (!styleEl) {
styleEl = document.createElement('style');
styleEl.id = 'yt-miniplayer-sticky-style';
document.documentElement.appendChild(styleEl);
}
styleEl.textContent = css;
}
// Buttons when sticky is active
function createSvgIcon(paths) {
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('fill', 'white');
svg.style.width = '22px';
svg.style.height = '22px';
paths.forEach(d => {
const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
p.setAttribute('d', d);
svg.appendChild(p);
});
return svg;
}
function ensureStickyButtons() {
if (stickyCloseBtn && stickyTopBtn) return;
const moviePlayer = document.getElementById('movie_player');
if (!moviePlayer) return;
stickyCloseBtn = document.createElement('button');
stickyCloseBtn.className = 'yttw-sticky-player-button';
stickyCloseBtn.title = 'Close Miniplayer';
stickyCloseBtn.setAttribute('aria-label', 'Close Miniplayer');
stickyCloseBtn.setAttribute('role', 'button');
stickyCloseBtn.style.left = '8px';
stickyCloseBtn.appendChild(createSvgIcon([
'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'
]));
stickyCloseBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
document.body.classList.remove('yttw-sticky-player');
removeStickyButtons();
});
stickyTopBtn = document.createElement('button');
stickyTopBtn.className = 'yttw-sticky-player-button';
stickyTopBtn.title = 'Scroll to Top';
stickyTopBtn.setAttribute('aria-label', 'Scroll to Top');
stickyTopBtn.setAttribute('role', 'button');
stickyTopBtn.style.right = '8px';
stickyTopBtn.appendChild(createSvgIcon([
'M12 8l-6 6 1.41 1.41L12 10.83l4.59 4.58L18 14z'
]));
stickyTopBtn.addEventListener('click', (e) => {
e.preventDefault(); e.stopPropagation();
window.scrollTo({ top: 0, behavior: 'smooth' });
});
moviePlayer.append(stickyCloseBtn, stickyTopBtn);
}
function removeStickyButtons() {
if (stickyCloseBtn) stickyCloseBtn.remove();
if (stickyTopBtn) stickyTopBtn.remove();
stickyCloseBtn = stickyTopBtn = null;
}
// Under video check using #below
function toggleStickyPlayer(entries) {
if (!isReady) return;
const belowElement = document.getElementById('below');
if (!belowElement) return;
// Adjust sticky box in case aspect changed just before toggling
rebuildStickyStyle();
const belowTopAbs = belowElement.getBoundingClientRect().top + window.scrollY;
if (entries[0].isIntersecting) {
document.body.classList.remove('yttw-sticky-player');
removeStickyButtons();
} else {
const scrolledUnderVideo = window.scrollY >= (belowTopAbs - 1);
if (scrolledUnderVideo) {
document.body.classList.add('yttw-sticky-player');
ensureStickyButtons();
} else {
document.body.classList.remove('yttw-sticky-player');
removeStickyButtons();
}
}
scheduleResize();
}
function setupElements() {
const moviePlayer = document.getElementById('movie_player');
const belowElement = document.getElementById('below');
if (!moviePlayer || !belowElement) return;
rebuildStickyStyle();
// Sentinel at the boundary between player and below
const intersectionTrigger = document.createElement('div');
intersectionTrigger.id = 'miniplayer-intersection-trigger';
intersectionTrigger.style.position = 'absolute';
intersectionTrigger.style.top = '0';
intersectionTrigger.style.height = '1px';
if (!belowElement.style.position || belowElement.style.position === 'static') {
belowElement.style.position = 'relative';
}
belowElement.appendChild(intersectionTrigger);
// Observe the sentinel
sentinelObserver = new IntersectionObserver(toggleStickyPlayer);
sentinelObserver.observe(intersectionTrigger);
// Observe ytd-watch-flexy for mode changes (theater/miniplayer/fullscreen)
const flexy = document.querySelector(SELECTORS.watchFlexy);
if (flexy) {
flexyAttrObserver = new MutationObserver(() => {
const wasSticky = document.body.classList.contains('yttw-sticky-player');
// Hide sticky during mode transition
document.body.classList.remove('yttw-sticky-player');
removeStickyButtons();
rebuildStickyStyle();
window.requestAnimationFrame(() => {
scheduleResize();
const belowTopAbs = document.getElementById('below')?.getBoundingClientRect().top + window.scrollY || Infinity;
if (wasSticky && window.scrollY >= (belowTopAbs - 1)) {
document.body.classList.add('yttw-sticky-player');
ensureStickyButtons();
window.requestAnimationFrame(() => scheduleResize());
}
});
});
flexyAttrObserver.observe(flexy, { attributes: true, attributeFilter: ['theater', 'fullscreen', 'miniplayer'] });
}
// React to fullscreen changes
document.addEventListener('fullscreenchange', () => {
const wasSticky = document.body.classList.contains('yttw-sticky-player');
if (document.fullscreenElement) {
document.body.classList.remove('yttw-sticky-player');
removeStickyButtons();
}
rebuildStickyStyle();
scheduleResize();
if (!document.fullscreenElement && wasSticky) {
document.body.classList.add('yttw-sticky-player');
ensureStickyButtons();
window.requestAnimationFrame(() => scheduleResize());
}
});
// Recompute sticky size on viewport changes & prevent duplicate listeners)
if (!resizeListenerAttached) {
window.addEventListener('resize', rebuildStickyStyle);
resizeListenerAttached = true;
}
document.body.classList.add('miniplayer-userscript-loaded');
isReady = true;
}
function initializeMiniplayer() {
// Only run on watch pages
if (!window.location.pathname.startsWith('/watch')) {
isReady = false;
return;
}
if (document.body.classList.contains('miniplayer-userscript-loaded')) {
isReady = true;
return;
}
// Check if elements already exist before observing
if (document.getElementById('movie_player') && document.getElementById('below')) {
setupElements();
return;
}
// Wait for player and below to exist
const readyObs = new MutationObserver((mutations, obs) => {
if (document.getElementById('movie_player') && document.getElementById('below')) {
setupElements();
obs.disconnect();
}
});
readyObs.observe(document.body, { childList: true, subtree: true });
}
function cleanup() {
document.body.classList.remove('miniplayer-userscript-loaded', 'yttw-sticky-player');
isReady = false;
if (sentinelObserver) { sentinelObserver.disconnect(); sentinelObserver = null; }
if (flexyAttrObserver) { flexyAttrObserver.disconnect(); flexyAttrObserver = null; }
const oldTrigger = document.getElementById('miniplayer-intersection-trigger');
if (oldTrigger) oldTrigger.remove();
if (styleEl && styleEl.parentNode) {
styleEl.parentNode.removeChild(styleEl);
styleEl = null;
}
if (resizeListenerAttached) {
window.removeEventListener('resize', rebuildStickyStyle);
resizeListenerAttached = false;
}
removeStickyButtons();
// Remove config overlay if open during navigation
const overlay = document.getElementById(CONFIG_OVERLAY_ID);
if (overlay) overlay.remove();
}
// SPA navigation
document.addEventListener('yt-navigate-finish', () => {
cleanup();
initializeMiniplayer();
});
// Initialize on load
initializeMiniplayer();
})();