Youtube Floating Player

Makes YouTube video float while you're reading/writing comments. 在阅读/撰写评论时让YouTube视频悬浮。

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

Advertisement:

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

Advertisement:

// ==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();
    }

})();