采用非侵入式UI注入,精准保留原始布局。支持音量调节和双击全屏。
当前为
// ==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,
});
})();