// ==UserScript== // @name GUI-悬浮图形控制面板 // @namespace http://tampermonkey.net/ // @version 1.0.0 // @author Feny // @description 为「视频自动网页全屏|倍速播放」脚本提供悬浮图形控制面板,支持自由拖拽定位、深色/浅色主题切换 // @license GPL-3.0-only // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAAXNSR0IArs4c6QAAAqdJREFUWEftl91LFFEYxp/3jB9ESZjtSl51F1RUSgRCF/kHlF1IhiFhF65dqEQkBUErdJMStBukGwQre2NZUiCRqUiURkW65mIfqGUFsW6Ii0jY7p4Tc3Rqd5zaGVldAudynve8z28e3jMzh5Dmi1R/V0vQyRRWxgWG6x22SrcnOAhQcQIbwVtXba8y1EANSpS1xzJin5c/Dz+jRDPvGWoErwRw35zuh8ChpcXXFjbwi9k/WADA9viGgovGnxtFs6EmcApMvCdBA3oIIirl4N8NNQngmRYJiwTOE7EHHLERAmXFawQ6AdCQkRbjsZIMUvIFoV0HMSsEDjCgSK8tJqAHAEDAMWLKLOexx8tiVVDEhLLVQAtzRPcwKOUANSWCw1/rsBe6PcFz8dpfAdTFgtF+EmIvBG7pID7mZNl2zkVCFQbahzqHfYerddpNhFpdsnfqauzl8ZoEuO4JXdIKOefynnZlimxXhBbqjTZL/el8pzrAVjTGmKh12Bq1ddJs974abQDXfFMuAhQ6EodwDTHWAf6/BAoK8nD0cDEKtuVhyD+OzvvLXnyWJshyApedJ1F65M9n4tlAAF5fL168fGfJWCu2DDA61GpodLvjCdp8vfjyNWQJJGUAquvMzBzafD0yEc65KZCUAmiOo4FPEqS753VSiFUB0FxbPF244en6J8SqAoTD8zhYcjZ9AP6RCVRWNacHYPD5GJqudmBi8tvaAkxNBeUuuNv5NOkAqgUpm4FIJCrfA+r0z4bnTZmvCKCv+wrsts0JBg8fvZLGY28NfoqToFhOoOJ4CS40lMu2I28mpXFP37DpJ9YXWgZQG+Tm5mBL7qakA2aGakUAZhqbrVkH0BLoB34fzcyml5K6pd/yaicRlQlgV0q6mmwitMOpyfpVKfsFya4w73cz9xQAAAAASUVORK5CYII= // @match *://*/* // @require https://unpkg.com/@popperjs/core@2.11.8/dist/umd/popper.min.js // @require https://unpkg.com/tippy.js@6.3.7/dist/tippy-bundle.umd.min.js // @require https://unpkg.com/draggable@4.2.0/dist/draggable.min.js // @resource tippy https://unpkg.com/tippy.js@6.3.7/dist/tippy.css // @grant GM_addStyle // @grant GM_getResourceText // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @run-at document-body // ==/UserScript== (e=>{if(typeof GM_addStyle=="function"){GM_addStyle(e);return}const r=document.createElement("style");r.textContent=e,document.head.append(r)})(' @charset "UTF-8";.vc-panel-wrapper{top:30%;border:none;position:fixed;left:calc(100vw - 50px);user-select:none!important;z-index:2147483647!important;background-color:transparent!important}.vc-panel-wrapper *{color:#e0e0e0;font-size:12px;user-select:none!important;transition:border .3s,background-color .3s!important}.vc-trigger{width:32px;height:32px;cursor:pointer;line-height:32px;border-radius:50%;position:relative;text-align:center;background:#333;box-shadow:0 2px 8px #0000004d}.vc-trigger:hover{background:#444}.vc-control-panel{width:265px;overflow:hidden;border-radius:4px;background:#444;box-shadow:0 2px 8px #0000004d}.vc-panel-header{padding:8px;display:flex;align-items:center;background:#333;justify-content:space-between}.vc-theme-btn{cursor:pointer}.vc-func-group,.vc-slider-control{gap:8px;padding:8px;border-top:1px solid #555555}.vc-func-group{display:grid;grid-template-columns:repeat(3,1fr)}.vc-func-group:nth-of-type(4) .vc-control-item:nth-of-type(2) b,.vc-func-group:nth-of-type(4) .vc-control-item:nth-of-type(5) b{display:inline-block;transform:rotate(90deg)}.vc-control-item{padding:5px 0;cursor:pointer;text-align:center;border-radius:4px;background:#555}.vc-control-item:hover{background:#666}.vc-control-item b{margin-right:5px}.vc-slider-control{display:flex;flex-direction:column}.vc-slider-control button{border:none;cursor:pointer;padding:2px 8px;font-weight:700;border-radius:4px;background:#555}.vc-slider-control button:hover{background:#666}.vc-slider-label{text-align:left;transform-origin:left center}.vc-slider-row{gap:8px;display:flex;align-items:center}.vc-slider{flex:1;height:4px;border:none;outline:none;border-radius:3px;background:#d3d3d3;-webkit-appearance:none;appearance:none}.vc-slider::-webkit-slider-thumb{width:12px;height:12px;cursor:pointer;border-radius:50%;background:#9a72ff;-webkit-appearance:none;appearance:none}.vc-slider::-webkit-slider-thumb:hover{background:#a37ffe}.light-mode *{color:#333;border-color:#e0e0e0}.light-mode .vc-trigger,.light-mode .vc-control-panel{background-color:#fff}.light-mode .vc-trigger:hover{background-color:#e3e5e7}.light-mode .vc-panel-header{background-color:#f5f5f5}.light-mode .vc-control-item,.light-mode .vc-slider-control button{background-color:#f0f0f0}.light-mode .vc-control-item:hover,.light-mode .vc-slider-control button:hover{background-color:#e0e0e0}.tippy-box[data-theme~=vc-panel-wrapper]{background-color:transparent!important}.tippy-box[data-theme~=vc-panel-wrapper] .tippy-content{padding:0!important}@media (min-width: 2200px){.vc-panel-wrapper *{font-size:14px}.vc-trigger{width:45px;height:45px;line-height:45px}.vc-control-panel{width:325px}.vc-control-item{padding:7px 0}} '); (function (tippy, Draggable) { 'use strict'; const cssLoader = (e) => { const t = GM_getResourceText(e); return GM_addStyle(t), t; }; cssLoader("tippy"); class BasicStorage { constructor(name, defVal, parser = (v) => v) { Object.assign(this, { name, defVal, parser }); this.storage = { getItem: GM_getValue, setItem: GM_setValue }; } #getFinalKey(suffix) { return this.name + (suffix ? suffix : ""); } set(value, key) { this.storage.setItem(this.#getFinalKey(key), value); } get(key) { const value = this.storage.getItem(this.#getFinalKey(key)); return this.parser(value ?? this.defVal); } } const Storage = { IS_DARK_THEME: new BasicStorage("IS_DARK_THEME", true, Boolean), DRAG_POSITION: new BasicStorage("DRAG_POSITION_", { x: 0, y: 0 }) }; const Utils = { waitFor(condition, opts = {}) { const start = Date.now(); const { immediate = false, interval = 50, timeout = 3e3 } = opts; return new Promise((resolve, reject) => { const checkCondition = () => { if (Date.now() - start > timeout) return reject(new Error("检测超时")); condition() ? resolve() : setTimeout(checkCondition, interval); }; immediate ? checkCondition() : setTimeout(checkCondition, interval); }); }, onBefore(target, funcName, preExec) { const original = target[funcName]; target[funcName] = function(...args) { Promise.resolve().then(() => preExec.apply(this, args)); return original.apply(this, args); }; } }; const Layout = { createPanelWrapper() { if (this.wrapper) return; this.panelTrigger = FyTools.newEle("div", { textContent: "⚙️", className: "vc-trigger" }); this.wrapper = FyTools.newEle("div", { className: "vc-panel-wrapper" }); this.wrapper.append(this.panelTrigger); document.body.prepend(this.wrapper); }, createControlPanel() { if (this.panel) return this.panel; const els = ["FullScreen", "Operation", "Transform", "Rate"].map((name) => this[`create${name}Controls`]()); this.panel = FyTools.newEle("div", { className: "vc-control-panel" }); this.panel.append(this.createPanelHeader(), ...els); return this.panel; }, createPanelHeader() { const title = FyTools.newEle("b", { textContent: "🎬 播放控制" }); const onclick = () => this.setControlPanelTheme(!Storage.IS_DARK_THEME.get()); this.theme = FyTools.newEle("b", { className: "vc-theme-btn", textContent: "🌙", onclick }); const header = FyTools.newEle("div", { className: "vc-panel-header" }); header.append(title, this.theme); return header; }, createFullScreenControls() { const pip = () => document.exitPictureInPicture().catch(() => this.FS.player?.requestPictureInPicture()); const config = [ { text: "画中画", icon: "▣", params: [], action: pip }, { text: "网页全屏", icon: "⤢", params: ["P", { isTrusted: true }], action: this.FS.dispatchShortcut }, { text: "全屏", icon: "⛶", params: ["ENTER"], action: this.FS.dispatchShortcut } ]; return this.createControlGroup(config); }, createOperationControls() { const config = [ { text: "截图", icon: "⎙", params: [], action: this.FS.screenshot }, { text: "旋转", icon: "⟳", params: [], action: this.FS.rotateVideo }, { text: "镜像", icon: "][", params: [], action: this.FS.horizFlip }, { text: "上一帧", icon: "‹‹", params: [-1], action: this.FS.freezeFrame }, { text: "播放", icon: "▷", params: [], action: () => this.FS.playV(this.FS.player) }, { text: "下一帧", icon: "››", params: [], action: this.FS.freezeFrame } ]; return this.createControlGroup(config); }, createTransformControls() { const config = [ { text: "放大", icon: "+", params: [], action: this.FS.zoomVideo }, { text: "上移", icon: "‹‹", params: ["ALT_UP"], action: this.FS.moveVideo }, { text: "左移", icon: "‹‹", params: ["ALT_LEFT"], action: this.FS.moveVideo }, { text: "缩小", icon: "-", params: [-1], action: this.FS.zoomVideo }, { text: "下移", icon: "››", params: ["ALT_DOWN"], action: this.FS.moveVideo }, { text: "右移", icon: "››", params: ["ALT_RIGHT"], action: this.FS.moveVideo } ]; return this.createControlGroup(config); }, createControlGroup(confs) { const nodes = confs.map(({ text, icon, params = [], action = () => { } }) => { const el = FyTools.newEle("div", { className: "vc-control-item", onclick: () => action.apply(this.FS, params) }); el.append(FyTools.newEle("b", { textContent: icon }), document.createTextNode(text)); return el; }); const container = FyTools.newEle("div", { className: "vc-func-group" }); container.append(...nodes); return container; }, createRateControls() { const step = FyStorage.SPEED_STEP.get(); const setRate = (value) => this.FS.setPlaybackRate(value); const adjustRate = (plus2 = 1) => this.FS.adjustPlayRate(plus2 * step); const reset = FyTools.newEle("button", { textContent: "↺", onclick: () => setRate(1) }); const plus = FyTools.newEle("button", { textContent: "+", onclick: () => adjustRate() }); const minus = FyTools.newEle("button", { textContent: "-", onclick: () => adjustRate(-1) }); const attrs = { min: 0.1, max: 16, value: 1, step, type: "range", className: "vc-slider" }; this.slider = FyTools.newEle("input", { ...attrs, oninput: (e) => setRate(e.target.value) }); const row = FyTools.newEle("div", { className: "vc-slider-row" }); row.append(minus, this.slider, plus, reset); this.label = FyTools.newEle("label", { className: "vc-slider-label", textContent: "倍速: 1x" }); const container = FyTools.newEle("div", { className: "vc-slider-control" }); container.append(this.label, row); return container; } }; const Control = { FS: null, init() { Utils.waitFor(() => unsafeWindow.GM_E9X_FS).then(() => { this.host = location.host; this.FS = unsafeWindow.GM_E9X_FS; this.setupFunctionHooks(); this.watchPlayerChange(); }).catch(() => console.error("未安装关联的脚本")); }, watchPlayerChange() { Object.defineProperty(this.FS, "player", { set: (value) => { this.FS.__player = value; this.initControlPanel(value); }, get() { return this.__player; } }); }, setupFunctionHooks() { Utils.onBefore(this.FS, "ratechange", () => this.renderRateToPanel()); Utils.onBefore(this.FS, "onFullChange", () => (document.fullscreenElement ?? document.body).prepend(this.wrapper ?? "")); }, renderRateToPanel() { if (!this.FS.player || !this.panel) return; this.slider.value = this.FS.player.playbackRate; this.label.textContent = `倍速: ${this.FS.player.playbackRate}x`; }, initControlPanel(video) { if (!video || this.wrapper) return; this.createPanelWrapper(); this.setControlPanelTheme(); this.setupPanelTrigger(); this.setupDraggable(); }, setControlPanelTheme(isDark = Storage.IS_DARK_THEME.get()) { if (this.theme) this.theme.textContent = isDark ? "🌙" : "☀️"; this.wrapper?.classList.toggle("light-mode", !isDark); Storage.IS_DARK_THEME.set(isDark); }, setupPanelTrigger() { tippy(this.panelTrigger, { arrow: false, duration: 500, allowHTML: true, interactive: true, placement: "left", appendTo: "parent", theme: "vc-panel-wrapper", onTrigger: (instance) => { instance.setContent(this.createControlPanel()); if (this.panel) this.renderRateToPanel(), this.setControlPanelTheme(); } }); }, setupDraggable() { const cache = Storage.DRAG_POSITION; const { x, y } = cache.get(this.host); const onDragEnd = (_, x2, y2) => cache.set({ x: x2, y: y2 }, this.host); new Draggable(this.panelTrigger, { onDragEnd, setPosition: false, limit: this.getDragBounds() }).set(x, y); }, getDragBounds() { const { top, left, bottom, width } = FyTools.getRect(this.wrapper); return { x: [-left, width / 2], y: [-top, top + bottom] }; } }; const App = { ...Control, ...Layout }; App.init(); })(tippy, Draggable);