Firefox 播放器

采用非侵入式UI注入,精准保留原始布局。支持音量调节和双击全屏。

当前为 2025-10-31 提交的版本,查看 最新版本

// ==UserScript==
// @name       Firefox 播放器
// @namespace    http://tampermonkey.net/
// @version      1.1
// @description  采用非侵入式UI注入,精准保留原始布局。支持音量调节和双击全屏。
// @author       Xion.Ai
// @match        *://*/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function() {
    'use strict';

    // Re-add the centering logic for local file playback.
    if (window.location.protocol === 'file:') {
        document.addEventListener('DOMContentLoaded', () => {
            if (document.body && document.body.childElementCount === 1 && document.body.firstElementChild && (document.body.firstElementChild.tagName === 'VIDEO' || document.body.firstElementChild.tagName === 'AUDIO')) {
                const styleId = '__mp_center_style';
                if (document.getElementById(styleId)) return;
                const centerCss = `
                    html, body {
                        height: 100%;
                        margin: 0;
                    }
                    body {
                        display: grid;
                        place-items: center;
                        background-color: #000;
                    }
                `;
                const styleNode = document.createElement('style');
                styleNode.id = styleId;
                styleNode.textContent = centerCss;
                document.head.appendChild(styleNode);
            }
        });
    }

    const css = `
.__mp_ui {
  position: fixed;
  height: 44px;
  display: flex !important;
  visibility: visible !important;
  align-items: center;
  gap: 8px;
  pointer-events: auto;
  opacity: 0;
  transition: opacity 160ms ease, transform 160ms ease;
  transform: translateY(6px);
  z-index: 2147483647;
}

.__mp_ui.__show {
  opacity: 1 !important;
  transform: translateY(0);
}

.__mp_btn {
  width: 36px; height: 36px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: rgba(255,255,255,0.08); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px);
  border: 1px solid rgba(255,255,255,0.06); color: rgba(255,255,255,0.95);
  cursor: pointer; user-select: none; box-shadow: 0 2px 8px rgba(0,0,0,0.25); flex-shrink: 0;
}

.__mp_btn:hover { transform: scale(1.06); transition: transform 120ms; }

.__mp_progress_container {
  position: relative;
  flex: 1 1 auto;
  margin: 0 8px;
  height: 20px;
  display: flex;
  align-items: center;
  cursor: pointer;
}

.__mp_progress {
  position: relative;
  -webkit-appearance: none; appearance: none; height: 4px; border-radius: 999px;
  background: linear-gradient(to right, #fff var(--progress-percent, 0%), rgba(255,255,255,0.18) var(--progress-percent, 0%));
  outline: none;
  width: 100%;
  margin: 0;
}

.__mp_progress::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none; width: 10px; height: 10px; border-radius: 50%;
  background: transparent; border: none; box-shadow: none;
  cursor: pointer; transition: background .15s ease;
}

.__mp_progress:hover::-webkit-slider-thumb {
  background-color: rgba(255, 255, 255, 0.6) !important;
  background-image: none !important;
  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}

.__mp_progress::-moz-range-thumb {
  width: 10px; height: 10px; border-radius: 50%;
  background: transparent; border: none; box-shadow: none;
  cursor: pointer; transition: background .15s ease;
}

.__mp_progress:hover::-moz-range-thumb {
  background-color: rgba(255, 255, 255, 0.6) !important;
  background-image: none !important;
  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
}

.__mp_time {
  color: rgba(255,255,255,0.95);
  font-size: 12px;
  font-family: monospace;
  user-select: none;
  flex-shrink: 0;
}

video { -webkit-user-select: none; -moz-user-select: none; user-select: none; }
`;
    const styleNode = document.createElement('style');
    styleNode.textContent = css;
    document.head.appendChild(styleNode);

    const clamp = (v, a, b) => Math.min(b, Math.max(a, v));

    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    const formatTime = (seconds) => {
        if (isNaN(seconds) || seconds < 0) return '00:00';
        const date = new Date(seconds * 1000);
        const hh = date.getUTCHours();
        const mm = date.getUTCMinutes();
        const ss = date.getUTCSeconds().toString().padStart(2, '0');
        if (hh) {
            return `${hh}:${mm.toString().padStart(2, '0')}:${ss}`;
        }
        return `${mm}:${ss}`;
    };

    function attachUIToVideo(video) {
        if (!video || video.dataset.__mp_attached) return;
        video.dataset.__mp_attached = '1';

        const ui = document.createElement('div');
        ui.className = '__mp_ui';
        ui.innerHTML = `
            <div class="__mp_btn __mp_play" title="Play/Pause"><svg width="14" height="14" viewBox="0 0 24 24"><path d="M8 5v14l11-7L8 5z" fill="currentColor"/></svg></div>
            <div class="__mp_progress_container">
                <input class="__mp_progress" type="range" min="0" max="100" value="0" step="0.01">
            </div>
            <div class="__mp_time">00:00 / 00:00</div>
            <div class="__mp_btn __mp_mute" title="Mute/Unmute (Scroll to adjust volume)"><svg class="mp_icon_vol_svg" width="14" height="14" viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/></svg></div>
        `;
        document.body.appendChild(ui);

        const updateUIPosition = () => {
            const videoRect = video.getBoundingClientRect();
            ui.style.display = 'flex';
            ui.style.left = `${videoRect.left + 8}px`;
            ui.style.top = `${videoRect.top + videoRect.height - 44 - 8}px`;
            ui.style.width = `${videoRect.width - 16}px`;
        };

        updateUIPosition();
        const resizeObserver = new ResizeObserver(updateUIPosition);
        resizeObserver.observe(video);
        window.addEventListener('scroll', updateUIPosition, { passive: true, capture: true });
        window.addEventListener('resize', updateUIPosition, { passive: true });

        let hideTimeout;
        const showUI = () => { clearTimeout(hideTimeout); ui.classList.add('__show'); };
        const hideUI = () => { hideTimeout = setTimeout(() => ui.classList.remove('__show'), 100); };
        video.addEventListener('mouseenter', showUI);
        ui.addEventListener('mouseenter', showUI);
        video.addEventListener('mouseleave', hideUI);
        ui.addEventListener('mouseleave', hideUI);

        video.controls = false;

        const playBtn = ui.querySelector('.__mp_play');
        const progress = ui.querySelector('.__mp_progress');
        const progressContainer = ui.querySelector('.__mp_progress_container');
        const timeDisplay = ui.querySelector('.__mp_time');

        progressContainer.addEventListener('click', e => {
            const rect = progressContainer.getBoundingClientRect();
            const clickX = e.clientX - rect.left;
            const percentage = clamp(clickX / progressContainer.offsetWidth, 0, 1);
            if (!isNaN(video.duration)) video.currentTime = video.duration * percentage;
        });

        const muteBtn = ui.querySelector('.__mp_mute');
        const volumeIcon = ui.querySelector('.mp_icon_vol_svg');

        const updatePlayIcon = () => {
            const isPaused = video.paused || video.ended;
            playBtn.querySelector('svg path').setAttribute('d', isPaused ? 'M8 5v14l11-7L8 5z' : 'M6 5h4v14H6zM14 5h4v14h-4z');
        };

        const togglePlay = (e) => {
            if (ui.contains(e.target) && e.target !== playBtn && !playBtn.contains(e.target)) return;
            video.paused || video.ended ? video.play() : video.pause();
        };

        video.addEventListener('click', togglePlay);
        playBtn.addEventListener('click', (e) => { e.stopPropagation(); togglePlay(e); });

        video.addEventListener('dblclick', e => {
            if (ui.contains(e.target)) return;
            if (!document.fullscreenElement) video.requestFullscreen().catch(err => console.error(`[MP] Fullscreen Error: ${err.message}`));
            else document.exitFullscreen();
        });

        const updateVolumeUI = () => {
            const vol = video.volume, muted = video.muted;
            if (muted || vol === 0) volumeIcon.innerHTML = `<path d="M16.5 12c0-1.77-.77-3.37-2-4.47V16.47c1.23-1.1 2-2.7 2-4.47zM5 9v6h4l5 4V5L9 9H5z" fill="currentColor"/>`;
            else if (vol < 0.5) volumeIcon.innerHTML = `<path d="M5 9v6h4l5 4V5L9 9H5z" fill="currentColor"/>`;
            else volumeIcon.innerHTML = `<path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" fill="currentColor"/>`;
        };

        muteBtn.addEventListener('click', e => { e.stopPropagation(); video.muted = !video.muted; });
        muteBtn.addEventListener('wheel', e => {
            e.preventDefault(); e.stopPropagation();
            const delta = Math.sign(e.deltaY);
            video.volume = clamp(video.volume - delta * 0.05, 0, 1);
            if (video.volume > 0) video.muted = false;
        }, { passive: false });

        let rafId;
        const tickProgress = () => {
            if (!video.isConnected) { cancelAnimationFrame(rafId); return; }
            if (!isNaN(video.duration)) {
                const val = (video.currentTime / video.duration) * 100;
                progress.value = clamp(val, 0, 100).toString();
                progress.style.setProperty('--progress-percent', `${progress.value}%`);
                timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
            }
            rafId = requestAnimationFrame(tickProgress);
        };

        video.addEventListener('play', updatePlayIcon);
        video.addEventListener('pause', updatePlayIcon);
        video.addEventListener('ended', updatePlayIcon);
        video.addEventListener('volumechange', updateVolumeUI);

        const onMetadataLoaded = () => {
            updateVolumeUI();
            if (!isNaN(video.duration)) timeDisplay.textContent = `${formatTime(video.currentTime)} / ${formatTime(video.duration)}`;
            if (!rafId) rafId = requestAnimationFrame(tickProgress);
        };

        video.addEventListener('loadedmetadata', onMetadataLoaded);
        if (video.readyState >= 1) onMetadataLoaded();

        progress.addEventListener('input', e => { if (!isNaN(video.duration)) video.currentTime = clamp(video.duration * (parseFloat(e.target.value) / 100), 0, video.duration); });

        const cleanup = () => {
            if (rafId) cancelAnimationFrame(rafId);
            resizeObserver.disconnect();
            window.removeEventListener('scroll', updateUIPosition, true);
            window.removeEventListener('resize', updateUIPosition);
            if (ui.parentElement) ui.remove();
            video.controls = true;
            delete video.dataset.__mp_attached;
            if (attachedVideos.has(video)) attachedVideos.delete(video);
        };

        // Dedicated observers for lifecycle management
        const intersectionObserver = new IntersectionObserver((entries) => {
            const entry = entries[0];
            ui.style.visibility = entry.isIntersecting ? 'visible' : 'hidden';
        });
        intersectionObserver.observe(video);

        const disconnectObserver = new MutationObserver((mutations) => {
            if (!video.isConnected) {
                cleanup();
                disconnectObserver.disconnect();
                intersectionObserver.disconnect();
            }
        });
        if (video.parentElement) {
            disconnectObserver.observe(video.parentElement, { childList: true });
        }

        updatePlayIcon();
        updateVolumeUI();

        return () => {
            cleanup();
            disconnectObserver.disconnect();
            intersectionObserver.disconnect();
        };
    }

    const attachedVideos = new WeakMap();

    function processVideo(video) {
        if (video.hasAttribute('controls')) {
            if (!attachedVideos.has(video)) {
                try {
                    const cleanup = attachUIToVideo(video);
                    if (cleanup) attachedVideos.set(video, cleanup);
                } catch (e) {
                    console.error('[MP] Error attaching to video:', e);
                }
            }
        } else {
            if (attachedVideos.has(video)) {
                const cleanup = attachedVideos.get(video);
                if (cleanup) cleanup();
                attachedVideos.delete(video);
            }
        }
    }

    function scanAndProcess() {
        document.querySelectorAll('video[controls]:not([data-__mp_attached])').forEach(video => {
            try {
                const cleanup = attachUIToVideo(video);
                if (cleanup) attachedVideos.set(video, cleanup);
            } catch (e) {
                console.error('[MP] Error attaching to video:', e);
            }
        });
    }

    // Initial scan
    scanAndProcess();

    // Global observer for discovering new videos
    const globalObserver = new MutationObserver(scanAndProcess);
    globalObserver.observe(document.documentElement, {
        childList: true,
        subtree: true,
    });

})();