Makes YouTube video float while you're reading/writing comments. 在阅读/撰写评论时让YouTube视频悬浮。
// ==UserScript==
// @name Youtube Floating Player
// @namespace http://tampermonkey.net/
// @version 2026-06-19
// @description Makes YouTube video float while you're reading/writing comments. 在阅读/撰写评论时让YouTube视频悬浮。
// @icon https://www.youtube.com/favicon.ico
// @author flow_heart
// @match https://www.youtube.com/*
// @exclude https://www.youtube.com/shorts/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @run-at document-start
// @license GPLv3
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
#miniyoutube { width: 310px; height: 175px; position: fixed; left: 5px; top: 60px; z-index: 2147483647; background-color: black; box-shadow: 0 0 10px #000000; transition: width 0.2s, height 0.2s; }
#mnyt-close-button { position: absolute; top: 3px; right:3px; border-radius: 50%; display: none; width: 30px; height: 30px; background: rgba(23,35,34,.75); opacity: 0.5; border: 0; cursor: pointer; font-size: 1em; text-align: center; font-family: 'Helvetica Neue',Helvetica,Arial; transition: background-color 40ms; font-weight: bold; text-decoration: none; color:#ffffff; }
#mnyt-close-button:hover { background-color: #cc181e; opacity: 1; }
.mnyt-controls { position: absolute; top: 0; left: 0; display: inline-block; height: 100%; width: 100%; }
.mnyt-play-button { margin: auto; display: block; position: relative; top: 50%; transform: translateY(-50%); border-radius: 50%; background: rgba(23,35,34,.75); opacity: 0.5; border: 0; cursor: pointer; width: 50px; height: 50px; transition: background-color 40ms; text-decoration: none; color:#ffffff; display: none; }
.mnyt-play-button:hover { background-color: #cc181e; opacity: 1; }
.mnyt-play-button-play { width: 0; height: 0; border-bottom: 10px solid transparent; border-top: 10px solid transparent; border-left: 15px solid white; margin-left: 19px; margin-right: auto; position: relative; top: 50%; transform: translateY(-50%); display: none; }
.mnyt-play-button-pause { display: none; }
.mnyt-play-button-pause:before { width: 7px; height: 20px; background-color: #FFFFFF; position: absolute; content: ""; top: 15px; left: 15px; }
.mnyt-play-button-pause:after { width: 7px; height: 20px; background-color: #FFFFFF; position: absolute; content: ""; top: 15px; right: 15px; }
.alert-mnyt { position: fixed; width: 300px; left: 50%; transform: translateX(-50%); top: 60px; z-index: 2147483647; padding: 15px; border: 1px solid transparent; border-radius: 4px; text-align: center; color: #3c763d; background-color: #dff0d8; border-color: #d6e9c6; }
.mnyt-video { top: 0px !important; left: 0px !important; width: 100% !important; height: 100% !important; }
.resizer { width: 20px; height: 20px; position:absolute; z-index: 1001; right: 0; bottom: 0; cursor: se-resize; }
.mnyt-control-icons { top: 0; left: 0; position: absolute; padding: 3px; z-index: 1001; display: none; }
.mnyt-size-button { display: block; margin-bottom: 3px; width: 30px; height: 30px; background: rgba(23,35,34,.75); opacity: 0.5; border-radius: 0.5em; border: 0; cursor: pointer; font-size: 1em; text-align: center; font-family: 'Helvetica Neue',Helvetica,Arial; transition: background-color 40ms; font-weight: bold; text-decoration: none; color:#ffffff; }
.mnyt-size-button img { vertical-align: middle; pointer-events: none; }
.mnyt-size-button:hover { background-color: #cc181e; opacity: 1; }
`);
const App = {
PLAYER_SELECTOR: '#movie_player',
VIDEO_SELECTOR: '#movie_player video',
MINI_YOUTUBE_ID: 'miniyoutube',
SIZES: {
S: { W: 310, H: 175 },
M: { W: 475, H: 268 },
L: { W: 640, H: 360 },
XL: { W: 854, H: 480 },
},
STORAGE_KEYS: { TOP: 'mnyt_top', LEFT: 'mnyt_left', H: 'mnyt_h', W: 'mnyt_w' },
isFloated: false,
originalVideoParent: null, originalVideoStyles: {},
settings: {},
_scrollHandler: null,
_waitInterval: null,
_endedHandler: null,
_playHandler: null,
_boundVideo: null,
videoEnded: false,
init: function() {
// 清理上一次可能残留的 interval
if (this._waitInterval) clearInterval(this._waitInterval);
this.loadSettings();
this._scrollHandler = this._scrollHandler || this.handleScroll.bind(this);
this._endedHandler = this._endedHandler || (() => { this.videoEnded = true; });
this._playHandler = this._playHandler || (() => { this.videoEnded = false; });
this._waitInterval = setInterval(() => {
const player = document.querySelector(this.PLAYER_SELECTOR);
const video = document.querySelector(this.VIDEO_SELECTOR);
if (player && video) {
clearInterval(this._waitInterval);
this._waitInterval = null;
window.removeEventListener('scroll', this._scrollHandler);
window.addEventListener('scroll', this._scrollHandler, { passive: true });
// 同一个 video 元素可能被 YouTube SPA 复用,先解绑旧的监听,避免重复绑定
if (this._boundVideo && this._boundVideo !== video) {
this._boundVideo.removeEventListener('ended', this._endedHandler);
this._boundVideo.removeEventListener('play', this._playHandler);
}
video.removeEventListener('ended', this._endedHandler);
video.removeEventListener('play', this._playHandler);
video.addEventListener('ended', this._endedHandler);
video.addEventListener('play', this._playHandler);
this._boundVideo = video;
// 新视频进入时,按当前实际状态初始化"是否已播放完毕"
this.videoEnded = video.ended;
}
}, 300);
},
teardown: function() {
if (this._waitInterval) { clearInterval(this._waitInterval); this._waitInterval = null; }
if (this._scrollHandler) window.removeEventListener('scroll', this._scrollHandler);
if (this._boundVideo) {
this._boundVideo.removeEventListener('ended', this._endedHandler);
this._boundVideo.removeEventListener('play', this._playHandler);
this._boundVideo = null;
}
if (this.isFloated) this.unfloatVideo();
},
handleScroll: function() {
const player = document.querySelector(this.PLAYER_SELECTOR);
if (!player || player.style.visibility === 'hidden') return;
const rect = player.getBoundingClientRect();
const isPlayerOffscreen = rect.bottom < 0;
if (isPlayerOffscreen && !this.isFloated && !this.videoEnded) this.floatVideo();
else if (!isPlayerOffscreen && this.isFloated) this.unfloatVideo();
},
floatVideo: function() {
const video = document.querySelector(this.VIDEO_SELECTOR);
if (!video || this.isFloated) return;
this.loadSettings();
this.isFloated = true;
this.originalVideoParent = video.parentNode;
this.originalVideoStyles = { width: video.style.width, height: video.style.height, position: video.style.position, top: video.style.top, left: video.style.left };
const miniScreen = document.createElement('div');
miniScreen.id = this.MINI_YOUTUBE_ID;
this.applySavedPosition(miniScreen);
video.classList.add('mnyt-video');
miniScreen.appendChild(video);
document.body.appendChild(miniScreen);
this.addControls(miniScreen, video);
this.bindControlEvents(miniScreen, video);
},
unfloatVideo: function() {
if (!this.isFloated) return;
this.saveSettings();
const video = document.querySelector(`#${this.MINI_YOUTUBE_ID} video`);
const miniScreen = document.getElementById(this.MINI_YOUTUBE_ID);
if (video && this.originalVideoParent) {
video.classList.remove('mnyt-video');
Object.assign(video.style, this.originalVideoStyles);
this.originalVideoParent.appendChild(video);
}
if (miniScreen) miniScreen.remove();
this.isFloated = false;
},
addControls: function(miniScreen, video) {
const create = (tag, props) => Object.assign(document.createElement(tag), props);
const controls = create('div', { className: 'mnyt-controls' });
const resizer = create('div', { className: 'resizer' });
const controlIcons = create('div', { className: 'mnyt-control-icons' });
for (const size in this.SIZES) {
const btn = create('button', { className: 'mnyt-size-button', id: `mnyt-${size.toLowerCase()}-button`, textContent: size });
controlIcons.appendChild(btn);
}
const playButton = create('div', { className: 'mnyt-play-button', id: 'mnyt-play-button' });
playButton.append(create('div', { className: 'mnyt-play-button-play' }), create('div', { className: 'mnyt-play-button-pause' }));
const closeButton = create('button', { id: 'mnyt-close-button', textContent: 'X' });
controls.append(resizer, controlIcons, playButton, closeButton);
miniScreen.appendChild(controls);
if (video.paused) { playButton.querySelector('.mnyt-play-button-play').style.display = 'block'; }
else { playButton.querySelector('.mnyt-play-button-pause').style.display = 'block'; }
},
bindControlEvents: function(miniScreen, video) {
const q = (sel) => miniScreen.querySelector(sel);
miniScreen.addEventListener('mouseenter', () => { ['#mnyt-close-button', '.mnyt-control-icons', '.mnyt-play-button'].forEach(s => q(s).style.display = 'block'); });
miniScreen.addEventListener('mouseleave', () => { ['#mnyt-close-button', '.mnyt-control-icons', '.mnyt-play-button'].forEach(s => q(s).style.display = 'none'); });
q('#mnyt-close-button').addEventListener('click', this.unfloatVideo.bind(this));
q('#mnyt-play-button').addEventListener('click', () => this.toggleVideo(video));
q('.resizer').addEventListener('mousedown', this.initResize.bind(this));
for (const size in this.SIZES) {
q(`#mnyt-${size.toLowerCase()}-button`).addEventListener('click', () => this.resizeScreen(this.SIZES[size].W, this.SIZES[size].H));
}
this.initDraggable(miniScreen);
},
resizeScreen: function(w, h) {
const miniScreen = document.getElementById(this.MINI_YOUTUBE_ID);
if (!miniScreen) return;
miniScreen.style.width = w + 'px';
miniScreen.style.height = h + 'px';
this.saveSettings();
},
toggleVideo: function(video) {
const play = document.querySelector('.mnyt-play-button-play');
const pause = document.querySelector('.mnyt-play-button-pause');
if (video.paused) { video.play(); play.style.display = 'none'; pause.style.display = 'block'; }
else { video.pause(); play.style.display = 'block'; pause.style.display = 'none'; }
},
loadSettings: function() { this.settings = { top: GM_getValue(this.STORAGE_KEYS.TOP), left: GM_getValue(this.STORAGE_KEYS.LEFT), h: GM_getValue(this.STORAGE_KEYS.H), w: GM_getValue(this.STORAGE_KEYS.W) }; },
saveSettings: function() {
const miniScreen = document.getElementById(this.MINI_YOUTUBE_ID);
if (!miniScreen) return;
const rect = miniScreen.getBoundingClientRect();
GM_setValue(this.STORAGE_KEYS.TOP, rect.top);
GM_setValue(this.STORAGE_KEYS.LEFT, rect.left);
GM_setValue(this.STORAGE_KEYS.H, rect.height);
GM_setValue(this.STORAGE_KEYS.W, rect.width);
},
applySavedPosition: function(miniScreen) {
let { top, left, h, w } = this.settings;
miniScreen.style.top = (top || 60) + 'px';
miniScreen.style.left = (left || window.innerWidth - (w || this.SIZES.S.W) - 20) + 'px';
miniScreen.style.height = (h || this.SIZES.S.H) + 'px';
miniScreen.style.width = (w || this.SIZES.S.W) + 'px';
},
initDraggable: function(miniScreen) {
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
const dragMouseDown = (e) => {
if (e.target.classList.contains('resizer') || e.target.closest('.mnyt-size-button, .mnyt-play-button')) return;
e.preventDefault();
pos3 = e.clientX; pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
};
const elementDrag = (e) => {
e.preventDefault();
pos1 = pos3 - e.clientX; pos2 = pos4 - e.clientY;
pos3 = e.clientX; pos4 = e.clientY;
miniScreen.style.top = (miniScreen.offsetTop - pos2) + "px";
miniScreen.style.left = (miniScreen.offsetLeft - pos1) + "px";
};
const closeDragElement = () => { document.onmouseup = null; document.onmousemove = null; this.saveSettings(); };
miniScreen.onmousedown = dragMouseDown;
},
initResize: function(e) {
e.preventDefault();
const miniScreen = document.getElementById(this.MINI_YOUTUBE_ID);
const startX = e.clientX;
const startWidth = miniScreen.offsetWidth;
const ratio = miniScreen.offsetHeight / startWidth;
const doDrag = (e) => {
const newWidth = startWidth + e.clientX - startX;
if (newWidth > 200) {
miniScreen.style.width = newWidth + 'px';
miniScreen.style.height = (newWidth * ratio) + 'px';
}
};
const stopDrag = () => {
document.removeEventListener('mousemove', doDrag);
document.removeEventListener('mouseup', stopDrag);
this.saveSettings();
};
document.addEventListener('mousemove', doDrag);
document.addEventListener('mouseup', stopDrag);
},
};
// 监听 YouTube 内部导航事件(yt-navigate-finish 是 DOM 已更新后触发的)
window.addEventListener('yt-navigate-finish', function(e) {
const detail = e.detail || {};
const pageType = detail.pageType;
const url = (detail.response && detail.response.url) || location.pathname;
const isShortsPage = url.includes('/shorts/');
const isWatchPage = pageType === 'watch' || location.pathname === '/watch';
if (isWatchPage && !isShortsPage) {
App.init();
} else {
App.teardown();
}
});
// 直接通过链接打开 /watch 页面时的兼容(无 SPA 跳转)
if (location.pathname === '/watch' && !location.pathname.startsWith('/shorts/')) {
App.init();
}
})();