// ==UserScript== // @name 抖音视频进度精确控制 // @namespace http://tampermonkey.net/ // @version 1.0 // @description 抖音视频播放功能优化:调整播放速度、精确的时间控制面板、去除暂停指示层 // @author AI // @match https://www.douyin.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=douyin.com // @grant GM_addStyle // ==/UserScript== (function() { 'use strict'; // CSS Styles GM_addStyle(` /* Remove Pause Overlay */ xg-start.xgplayer-start { display: none !important; } /* Time Control Panel */ .dy-time-panel { display: flex; align-items: center; background: rgba(255, 255, 255, 0.1); border-radius: 4px; margin-left: 10px; padding: 2px 5px; height: 32px; box-sizing: border-box; } .dy-time-group { display: flex; align-items: center; position: relative; } .dy-time-input { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: white; width: 60px; height: 28px; text-align: center; font-family: monospace; font-size: 14px; border-radius: 2px; outline: none; } .dy-time-input:focus { border-color: rgba(255,255,255,0.6); } .dy-btn-col { display: flex; flex-direction: column; justify-content: center; margin-left: 4px; height: 28px; gap: 2px; } .dy-ctrl-btn { background: rgba(255,255,255,0.15); border: none; color: white; width: 20px; height: 13px; border-radius: 2px; cursor: pointer; display: flex; align-items: center; justify-content: center; font-size: 10px; line-height: 1; padding: 0; transition: background 0.2s; } .dy-ctrl-btn:hover { background: rgba(255,255,255,0.3); } .dy-step-wrapper { display: flex; align-items: center; margin-left: 8px; font-size: 12px; color: #ccc; } .dy-step-label { margin-right: 4px; font-size: 10px; } .dy-step-input { background: transparent; border: 1px solid rgba(255,255,255,0.2); color: #ddd; width: 40px; height: 20px; text-align: center; border-radius: 2px; font-size: 11px; /* Hide spin buttons */ -moz-appearance: textfield; } .dy-step-input::-webkit-outer-spin-button, .dy-step-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } `); // Utility: Format seconds to HH:MM:SS or MM:SS function formatTime(seconds) { if (isNaN(seconds)) return "00:00"; const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = Math.floor(seconds % 60); const pad = (num) => num.toString().padStart(2, '0'); if (h > 0) { return `${pad(h)}:${pad(m)}:${pad(s)}`; } return `${pad(m)}:${pad(s)}`; } // Feature 1: Speed Control (Add & Sort) function initSpeedControl() { const speedContainerSelector = '.xgplayer-playratio-wrap'; setInterval(() => { const containers = document.querySelectorAll(speedContainerSelector); containers.forEach(container => { // Robustly find the video associated with this container const video = container.closest('.xgplayer, .xg-video-container')?.querySelector('video'); if (!video) return; // 1. Add missing speeds if needed (0.25x, 0.5x) if (!video.dataset.dySpeedListener) { video.addEventListener('ratechange', () => { // Find the container dynamically in case of DOM updates const currentContainer = video.closest('.xgplayer, .xg-video-container')?.querySelector(speedContainerSelector); if (currentContainer) syncSpeedSelection(currentContainer, video.playbackRate); }); video.dataset.dySpeedListener = 'true'; } const predefinedSpeeds = [0.25, 0.5]; // Removed 0.20x as requested // Check if we need to add our custom items if (!container.querySelector('.dy-speed-custom')) { predefinedSpeeds.forEach(rate => { // Check if speed already exists (native or custom) to avoid dupes const exists = Array.from(container.children).some(c => parseFloat(c.textContent.replace('x','')) === rate ); if (exists) return; const item = document.createElement('div'); item.className = 'xgplayer-playratio-item dy-speed-custom'; item.textContent = rate + 'x'; item.dataset.value = rate; item.dataset.id = rate.toString(); item.style.cursor = 'pointer'; item.addEventListener('click', (e) => { e.stopPropagation(); // Use the specific video element found for this container if (video) { video.playbackRate = rate; // Immediate sync for visual feedback syncSpeedSelection(container, rate); } }); container.appendChild(item); }); } // Sync selection state with actual video playbackRate syncSpeedSelection(container, video.playbackRate); // 2. Sort all items const items = Array.from(container.children); // Extract rate value helper const getRate = (el) => { // Handle "倍速" or "x" or clean text const txt = el.textContent.trim().replace('x', '').replace('倍速', ''); if (txt === '2.0') return 2.0; return parseFloat(txt); }; // Check if already sorted (ascending) let isSorted = true; for (let i = 0; i < items.length - 1; i++) { if (getRate(items[i]) > getRate(items[i+1])) { isSorted = false; break; } } if (!isSorted) { // Sort ascending items.sort((a, b) => getRate(a) - getRate(b)); // Re-append in order items.forEach(item => container.appendChild(item)); } }); }, 1500); } function syncSpeedSelection(container, currentRate) { let activeText = currentRate + 'x'; // Default fallback // Strict sync: Ensure ONLY the matching item is active Array.from(container.children).forEach(c => { const itemRate = parseFloat(c.textContent.replace('x', '').replace('倍速', '')); // Handle potential "倍速" text const isMatch = Math.abs(itemRate - currentRate) < 0.01; if (isMatch) { // Capture the exact text from the menu item (e.g. "1.0x", "0.5x") activeText = c.textContent.trim(); // Add both classes to be safe if (!c.classList.contains('select')) { c.classList.add('select'); } if (!c.classList.contains('xgplayer-playratio-active')) { c.classList.add('xgplayer-playratio-active'); } } else { if (c.classList.contains('select')) { c.classList.remove('select'); } if (c.classList.contains('xgplayer-playratio-active')) { c.classList.remove('xgplayer-playratio-active'); } } }); // Update the external display badge (e.g. "1.0x" on the button itself) const textDisplay = container.closest('.xgplayer-playback-setting')?.querySelector('.xgplayer-setting-playbackRatio'); if (textDisplay) { textDisplay.textContent = activeText; } } // Feature 2: Time Control Panel function initTimeControl() { const leftGridSelector = '.xg-left-grid'; const timeSelector = '.xgplayer-time'; setInterval(() => { const leftGrids = document.querySelectorAll(leftGridSelector); leftGrids.forEach(leftGrid => { // Check existence via class check instead of ID if (leftGrid.querySelector('.dy-time-panel')) return; const timeEl = leftGrid.querySelector(timeSelector); if (!timeEl) return; // Create Panel const panel = document.createElement('div'); // Removed ID to allow multiple instances panel.className = 'dy-time-panel'; // Video helper for this specific instance const getVideo = () => leftGrid.closest('.xgplayer, .xg-video-container')?.querySelector('video'); // --- 1. Time Group (Input + Buttons) --- const timeGroup = document.createElement('div'); timeGroup.className = 'dy-time-group'; // Input const timeInput = document.createElement('input'); timeInput.className = 'dy-time-input'; timeInput.type = 'text'; timeInput.value = '00:00'; let isEditing = false; let digitBuffer = ""; // Store raw digits entered by user // Helper to format buffer into xx:xx display function formatBuffer(buf) { const padded = buf.padEnd(4, '_'); return padded.slice(0, 2) + ':' + padded.slice(2, 4); } timeInput.addEventListener('focus', () => { isEditing = true; const video = getVideo(); if(video) { video.pause(); digitBuffer = ""; timeInput.value = "__:__"; } }); timeInput.addEventListener('blur', () => { isEditing = false; if (digitBuffer.length < 4) { const video = getVideo(); if (video) { timeInput.value = formatTime(video.currentTime); } } digitBuffer = ""; }); // Vertical Buttons column const btnCol = document.createElement('div'); btnCol.className = 'dy-btn-col'; const upBtn = document.createElement('button'); upBtn.className = 'dy-ctrl-btn'; upBtn.innerHTML = '▲'; upBtn.title = 'Add time'; const downBtn = document.createElement('button'); downBtn.className = 'dy-ctrl-btn'; downBtn.innerHTML = '▼'; downBtn.title = 'Subtract time'; const adjustTime = (e, sign) => { e.preventDefault(); e.stopPropagation(); const video = getVideo(); if (!video) return; const step = parseFloat(stepInput.value) || 0.5; let newTime = video.currentTime + (sign * step); if (newTime < 0) newTime = 0; if (newTime > video.duration) newTime = video.duration; video.currentTime = newTime; timeInput.value = formatTime(newTime); video.pause(); } upBtn.addEventListener('click', (e) => adjustTime(e, 1)); downBtn.addEventListener('click', (e) => adjustTime(e, -1)); // Add wheel listener for fine adjustment timeInput.addEventListener('wheel', (e) => { e.preventDefault(); const sign = e.deltaY < 0 ? 1 : -1; // Scrol up to add, down to sub adjustTime(e, sign); }); // Prevent clicks from propagating const stopProp = (e) => e.stopPropagation(); timeInput.addEventListener('click', stopProp); timeInput.addEventListener('mousedown', stopProp); timeInput.addEventListener('dblclick', stopProp); timeInput.addEventListener('keydown', (e) => { e.stopPropagation(); if (/^[0-9]$/.test(e.key)) { e.preventDefault(); if (digitBuffer.length < 4) { digitBuffer += e.key; timeInput.value = formatBuffer(digitBuffer); } if (digitBuffer.length === 4) { const mins = parseInt(digitBuffer.slice(0, 2), 10); const secs = parseInt(digitBuffer.slice(2, 4), 10); const video = getVideo(); if (video) { let newTime = mins * 60 + secs; if (newTime > video.duration) newTime = video.duration; video.currentTime = newTime; timeInput.value = formatTime(video.currentTime); isEditing = false; digitBuffer = ""; timeInput.blur(); } } return; } if (e.key === 'Backspace') { e.preventDefault(); if (digitBuffer.length > 0) { digitBuffer = digitBuffer.slice(0, -1); timeInput.value = formatBuffer(digitBuffer); } return; } if (e.key === 'Enter') { e.preventDefault(); if (digitBuffer.length > 0) { const padded = digitBuffer.padStart(4, '0'); const mins = parseInt(padded.slice(0, 2), 10); const secs = parseInt(padded.slice(2, 4), 10); const video = getVideo(); if (video) { let newTime = mins * 60 + secs; if (newTime > video.duration) newTime = video.duration; video.currentTime = newTime; timeInput.value = formatTime(video.currentTime); isEditing = false; digitBuffer = ""; timeInput.blur(); } } return; } if (e.key === 'Escape') { e.preventDefault(); digitBuffer = ""; isEditing = false; const video = getVideo(); if (video) timeInput.value = formatTime(video.currentTime); timeInput.blur(); return; } }); timeInput.addEventListener('input', (e) => { e.preventDefault(); e.stopPropagation(); if (isEditing) { timeInput.value = formatBuffer(digitBuffer); } }); btnCol.appendChild(upBtn); btnCol.appendChild(downBtn); timeGroup.appendChild(timeInput); timeGroup.appendChild(btnCol); // --- 2. Step Setting (Precision) --- const stepWrapper = document.createElement('div'); stepWrapper.className = 'dy-step-wrapper'; const stepLabel = document.createElement('span'); stepLabel.className = 'dy-step-label'; stepLabel.textContent = 'Step:'; const stepInput = document.createElement('input'); stepInput.className = 'dy-step-input'; stepInput.type = 'number'; stepInput.step = '0.01'; stepInput.value = '0.5'; stepWrapper.appendChild(stepLabel); stepWrapper.appendChild(stepInput); stepInput.addEventListener('click', stopProp); stepInput.addEventListener('mousedown', stopProp); stepInput.addEventListener('keydown', (e) => e.stopPropagation()); // Assemble panel.appendChild(timeGroup); panel.appendChild(stepWrapper); // Insert into DOM: After .xgplayer-time if (timeEl.nextSibling) { leftGrid.insertBefore(panel, timeEl.nextSibling); } else { leftGrid.appendChild(panel); } // Sync Input loop for this instance let lastVideoSrc = null; let lastVideoDuration = 0; // Track duration changes too const updateInputLoop = () => { if (!panel.isConnected) return; // Stop if panel removed const video = getVideo(); // Case 1: Video element missing (e.g. loading or cleared) if (!video) { if (!isEditing && timeInput.value !== '00:00') { timeInput.value = '00:00'; } lastVideoSrc = null; requestAnimationFrame(updateInputLoop); return; } // Case 2: Video source or duration changed (strict sync) // We check duration because sometimes src stays same/blob but content changes if (video.src !== lastVideoSrc || Math.abs(video.duration - lastVideoDuration) > 1) { lastVideoSrc = video.src; lastVideoDuration = video.duration; // Force reset state when video changes isEditing = false; digitBuffer = ""; timeInput.blur(); // Immediate update to new time (likely 0) timeInput.value = formatTime(video.currentTime); } // Normal Sync if (!isEditing) { const formatted = formatTime(video.currentTime); // Prevent unnecessary DOM touches if value is same if (timeInput.value !== formatted) { timeInput.value = formatted; } } requestAnimationFrame(updateInputLoop); }; requestAnimationFrame(updateInputLoop); }); // Global keydown for adaptive seek step (Left/Right arrows) // We authorize this only once if (!document.body.dataset.dyAdaptiveSeekInitialized) { document.addEventListener('keydown', (e) => { // Only care about Left/Right if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return; // Allow default if user is in an input field if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return; // Find currently visible/active video // This is heuristic: find the first video that is playing or visible const videos = Array.from(document.querySelectorAll('video')); // Improve heuristic: Prioritize the one in viewport center const activeVideo = videos.find(v => { const rec = v.getBoundingClientRect(); return rec.top >= 0 && rec.bottom <= window.innerHeight; }) || videos[0]; if (!activeVideo || isNaN(activeVideo.duration)) return; // Determine Step based on modifier keys or adaptive logic let step = 0; let forceIntercept = false; if (e.shiftKey) { step = 0.5; forceIntercept = true; } else if (e.ctrlKey) { step = 0.1; forceIntercept = true; } else { // Adaptive Seek Step Rule: 1/10th of duration, rounded to nearest 0.5, max 5s. step = activeVideo.duration / 10; step = Math.round(step * 2) / 2; if (step < 0.5) step = 0.5; if (step > 5) step = 5; // Only intercept adaptive seek if it's significantly different from native 5s if (step < 4.5) forceIntercept = true; } if (forceIntercept) { e.stopPropagation(); e.preventDefault(); const sign = e.key === 'ArrowRight' ? 1 : -1; activeVideo.currentTime = Math.min(Math.max(activeVideo.currentTime + (sign * step), 0), activeVideo.duration); } // Else: implicit native behavior (usually 5s) }, { capture: true }); // Capture phase to intercept before site handler document.body.dataset.dyAdaptiveSeekInitialized = 'true'; } }, 1000); } // Initialize initSpeedControl(); initTimeControl(); })();