Detects videos on any site with universal network interception, HLS support, pagination, resizable UI, and optimized players.
// ==UserScript==
// @name Video Detector & Player (Universal)
// @namespace http://tampermonkey.net/
// @version 1.4.0
// @description Detects videos on any site with universal network interception, HLS support, pagination, resizable UI, and optimized players.
// @author Manus
// @match *://*/*
// @grant GM_download
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_xmlhttpRequest
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/hls.js/1.4.12/hls.min.js
// @connect *
// @run-at document-start
// ==/UserScript==
(function() {
'use strict';
// --- Configuration ---
const CONFIG = {
autoSaveDir: GM_getValue('autoSaveDir', 'Video_Downloads'),
maxConcurrentDownloads: 3,
videoExtensions: new Set(['.mp4', '.webm', '.mkv', '.mov', '.m4v', '.m3u8', '.mpd', '.ts', '.avi', '.flv', '.wmv']),
videoKeywords: new Set(['video', 'media', 'stream', 'play', 'movie', 'film', 'clip', 'content']),
batchSize: 10,
scanDebounce: 500,
itemsPerPage: GM_getValue('itemsPerPage', 6),
currentPage: 1,
panelSize: {
width: GM_getValue('panelWidth', 380),
height: GM_getValue('panelHeight', 600)
}
};
// --- Styles (optimized with CSS containment and custom player controls) ---
GM_addStyle(`
#vd-root-container {
all: initial;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
box-sizing: border-box;
contain: content;
}
#vd-root-container * {
box-sizing: border-box !important;
margin: 0;
padding: 0;
}
/* Main Panel */
.vd-panel {
position: fixed;
bottom: 20px;
right: 20px;
background: #1a1a1a;
color: #ffffff;
border: 1px solid #333;
border-radius: 12px;
padding: 16px;
z-index: 2147483640;
width: ${CONFIG.panelSize.width}px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
display: flex;
flex-direction: column;
gap: 12px;
transition: transform 0.2s ease, opacity 0.2s ease;
will-change: transform, opacity;
contain: layout style;
resize: both;
overflow: auto;
min-width: 280px;
min-height: 200px;
max-width: 90vw;
max-height: 90vh;
}
.vd-panel.vd-hidden {
transform: translateY(150%);
opacity: 0;
pointer-events: none;
}
/* Panel Resize Controls */
.vd-resize-controls {
display: flex;
gap: 8px;
align-items: center;
justify-content: flex-end;
padding: 4px 0;
}
.vd-resize-btn {
background: #333;
border: 1px solid #555;
color: #fff;
padding: 4px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
}
.vd-resize-btn:hover {
background: #444;
}
.vd-panel .header { display: flex; justify-content: space-between; align-items: center; }
.vd-panel .title { font-weight: bold; font-size: 16px; color: #4CAF50; }
.vd-panel .close-btn { cursor: pointer; font-size: 18px; color: #888; padding: 4px; }
.vd-panel .close-btn:hover { color: #fff; }
.vd-panel .stats { font-size: 13px; color: #aaa; }
.vd-panel .action-btn {
background: #4CAF50;
color: white;
border: none;
padding: 10px;
border-radius: 6px;
cursor: pointer;
font-weight: 600;
font-size: 14px;
text-align: center;
transition: background 0.2s;
}
.vd-panel .action-btn:hover { background: #45a049; }
.vd-panel .secondary-btn { background: #333; margin-top: -4px; }
.vd-panel .secondary-btn:hover { background: #444; }
/* Overlay/Modal */
.vd-overlay {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0,0,0,0.85);
z-index: 2147483647;
display: none;
justify-content: center;
align-items: center;
backdrop-filter: blur(4px);
contain: strict;
}
.vd-modal {
background: #111;
width: 95%;
height: 95%;
max-width: 1600px;
border-radius: 12px;
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid #333;
box-shadow: 0 20px 60px rgba(0,0,0,0.8);
contain: content;
}
.vd-modal-header {
padding: 16px 24px;
background: #1a1a1a;
border-bottom: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.vd-modal-title { font-size: 18px; font-weight: bold; color: #fff; }
.vd-modal-close { cursor: pointer; font-size: 24px; color: #888; }
/* Grid Layout */
.vd-grid {
flex: 1;
padding: 24px;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 20px;
background: #0a0a0a;
align-content: start;
contain: content;
will-change: scroll-position;
}
/* Video Card */
.vd-card {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 8px;
overflow: hidden;
display: flex;
flex-direction: column;
height: fit-content;
contain: layout style;
transition: border-color 0.2s;
}
.vd-card:hover {
border-color: #555;
}
/* Custom Video Player */
.vd-video-container {
width: 100%;
aspect-ratio: 16/9;
background: #000;
position: relative;
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
contain: strict;
cursor: pointer;
}
.vd-video-container video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
max-height: 100%;
}
/* Custom Controls */
.vd-player-controls {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
padding: 20px 10px 5px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.vd-video-container:hover .vd-player-controls,
.vd-video-container.vd-controls-visible .vd-player-controls {
opacity: 1;
pointer-events: auto;
}
.vd-progress-bar {
width: 100%;
height: 4px;
background: rgba(255,255,255,0.2);
cursor: pointer;
margin-bottom: 8px;
position: relative;
}
.vd-progress-filled {
height: 100%;
background: #4CAF50;
width: 0%;
transition: width 0.1s;
}
.vd-controls-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.vd-control-btn {
background: rgba(255,255,255,0.1);
border: none;
color: white;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s;
white-space: nowrap;
}
.vd-control-btn:hover {
background: rgba(255,255,255,0.2);
}
.vd-time-display {
color: white;
font-size: 11px;
margin-left: auto;
}
.vd-speed-menu {
position: absolute;
bottom: 40px;
right: 10px;
background: #333;
border: 1px solid #555;
border-radius: 4px;
padding: 4px 0;
display: none;
z-index: 10;
}
.vd-speed-menu.active {
display: block;
}
.vd-speed-option {
padding: 6px 16px;
cursor: pointer;
font-size: 12px;
color: #fff;
}
.vd-speed-option:hover {
background: #444;
}
.vd-placeholder {
position: absolute;
color: #555;
font-size: 14px;
text-align: center;
z-index: 1;
pointer-events: none;
}
.vd-card-info { padding: 12px; display: flex; flex-direction: column; gap: 8px; }
.vd-card-name { font-size: 13px; color: #eee; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: 500; }
.vd-card-url { font-size: 11px; color: #666; word-break: break-all; max-height: 32px; overflow: hidden; line-height: 1.4; }
.vd-url-copy-btn {
background: #2a2a2a;
border: 1px solid #444;
color: #ccc;
padding: 4px 8px;
border-radius: 3px;
cursor: pointer;
font-size: 11px;
margin-top: 4px;
}
.vd-url-copy-btn:hover {
background: #3a3a3a;
}
.vd-card-actions { display: flex; gap: 8px; margin-top: 4px; }
.vd-card-btn {
flex: 1;
padding: 8px 4px;
font-size: 12px;
border-radius: 4px;
border: 1px solid #444;
background: #2a2a2a;
color: #ccc;
cursor: pointer;
text-align: center;
font-weight: 500;
transition: all 0.15s;
}
.vd-card-btn:hover { background: #3a3a3a; color: #fff; border-color: #666; }
.vd-card-btn.primary { background: #4CAF50; border-color: #4CAF50; color: #fff; }
.vd-card-btn.primary:hover { background: #45a049; }
/* Pagination */
.vd-pagination {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 12px;
background: #1a1a1a;
border-top: 1px solid #333;
flex-shrink: 0;
}
.vd-page-btn {
background: #333;
border: 1px solid #555;
color: #fff;
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
min-width: 35px;
}
.vd-page-btn:hover:not(:disabled) {
background: #444;
}
.vd-page-btn.active {
background: #4CAF50;
border-color: #4CAF50;
}
.vd-page-btn:disabled {
opacity: 0.3;
cursor: not-allowed;
}
.vd-page-info {
color: #aaa;
font-size: 13px;
margin: 0 8px;
}
.vd-modal-footer {
padding: 16px 24px;
background: #1a1a1a;
border-top: 1px solid #333;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.vd-stats-footer { font-size: 14px; color: #aaa; }
/* Mobile adjustments */
@media (max-width: 600px) {
.vd-grid { grid-template-columns: 1fr; padding: 12px; }
.vd-modal { width: 100%; height: 100%; border-radius: 0; }
.vd-panel { width: calc(100% - 40px); left: 20px; right: 20px; bottom: 10px; }
.vd-player-controls { padding: 15px 5px 3px; }
.vd-control-btn { padding: 3px 6px; font-size: 10px; }
}
`);
// --- State Management ---
const state = {
videos: new Map(),
isModalOpen: false,
observer: null,
intersectionObserver: null,
hlsPlayers: new Set(),
scanTimer: null,
lastScanTime: 0,
currentPage: CONFIG.currentPage,
itemsPerPage: CONFIG.itemsPerPage,
totalPages: 1,
activePlayers: new Set(),
playerTimers: new Map()
};
// --- Helper Functions ---
const getFileName = (url, index) => {
try {
const urlObj = new URL(url);
let name = urlObj.pathname.split('/').pop() || '';
name = name.split('?')[0];
if (!name || !name.includes('.') || name.length < 3) {
const ext = url.includes('.m3u8') ? '.m3u8' : (url.includes('.mpd') ? '.mpd' : '.mp4');
name = `video_${index}${ext}`;
}
return decodeURIComponent(name).replace(/[^a-zA-Z0-9._-]/g, '_');
} catch {
return `video_${index}.mp4`;
}
};
const copyToClipboard = async (text) => {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {}
const el = document.createElement('textarea');
el.value = text;
el.style.position = 'fixed';
el.style.opacity = '0';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
return true;
};
// --- Network Interception (optimized) ---
function initNetworkSniffer() {
const videoCheckBuffer = [];
let bufferTimer = null;
const processVideoBuffer = () => {
if (videoCheckBuffer.length === 0) return;
const urlsToCheck = videoCheckBuffer.splice(0);
urlsToCheck.forEach(url => checkAndAddVideo(url));
};
const queueVideoCheck = (url) => {
if (!url || typeof url !== 'string') return;
const lowerUrl = url.toLowerCase();
if (!isPotentialVideoUrl(lowerUrl)) return;
videoCheckBuffer.push(url);
if (videoCheckBuffer.length > 100) {
processVideoBuffer();
return;
}
if (!bufferTimer) {
bufferTimer = setTimeout(() => {
bufferTimer = null;
processVideoBuffer();
}, 100);
}
};
const originalFetch = window.fetch;
window.fetch = function(...args) {
try {
const url = typeof args[0] === 'string' ? args[0] :
(args[0] instanceof URL ? args[0].href :
(args[0]?.url || ''));
queueVideoCheck(url);
} catch {}
return originalFetch.apply(this, args);
};
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
try {
queueVideoCheck(url);
} catch {}
return originalOpen.apply(this, arguments);
};
}
const isPotentialVideoUrl = (lowerUrl) => {
if (CONFIG.videoExtensions.has(lowerUrl.slice(-4)) ||
CONFIG.videoExtensions.has(lowerUrl.slice(-5))) {
return true;
}
return lowerUrl.includes('.m3u8') ||
lowerUrl.includes('.mpd') ||
lowerUrl.includes('googlevideo.com') ||
lowerUrl.includes('/video/') ||
lowerUrl.includes('videoplayback') ||
lowerUrl.includes('video_url');
};
function checkAndAddVideo(url) {
if (!url || typeof url !== 'string' || state.videos.has(url)) return;
const lowerUrl = url.toLowerCase();
if (lowerUrl.includes('analytics') ||
lowerUrl.includes('log') ||
lowerUrl.includes('tracking') ||
lowerUrl.length < 10) return;
if (!isPotentialVideoUrl(lowerUrl)) return;
if (CONFIG.videoExtensions.has(lowerUrl.slice(-4))) {
if (lowerUrl.endsWith('.m4a') || lowerUrl.endsWith('.mp3')) return;
}
state.videos.set(url, {
url,
addedAt: Date.now(),
selected: true,
playerInstance: null
});
updateUI();
}
// --- Core Logic ---
function scanForVideos() {
const now = Date.now();
if (now - state.lastScanTime < CONFIG.scanDebounce) return;
state.lastScanTime = now;
if (window.requestIdleCallback) {
requestIdleCallback(() => performScan());
} else {
setTimeout(performScan, 0);
}
}
function performScan() {
const videoUrls = new Set();
document.querySelectorAll('video[src], video source[src]').forEach(el => {
const src = el.tagName === 'SOURCE' ? el.src : el.currentSrc || el.src;
if (src && !src.startsWith('blob:')) {
videoUrls.add(src);
}
});
const links = document.querySelectorAll('a[href]');
for (let i = 0; i < links.length; i++) {
const href = links[i].href;
if (href && isPotentialVideoUrl(href.toLowerCase())) {
videoUrls.add(href);
}
}
const pageText = document.documentElement.innerHTML;
const videoRegex = /https?:\/\/[^\s"'<>]+?\.(mp4|m3u8|webm|mkv|mov|mpd|avi|flv)(?:\?[^\s"'<>]*)?/gi;
let match;
while ((match = videoRegex.exec(pageText)) !== null) {
videoUrls.add(match[0]);
}
const urlsArray = Array.from(videoUrls);
for (let i = 0; i < urlsArray.length; i += CONFIG.batchSize) {
const batch = urlsArray.slice(i, i + CONFIG.batchSize);
setTimeout(() => {
batch.forEach(url => checkAndAddVideo(url));
}, 0);
}
}
function initObservers() {
state.observer = new MutationObserver((mutations) => {
let shouldScan = false;
for (let i = 0; i < mutations.length; i++) {
const mutation = mutations[i];
if (mutation.addedNodes.length > 0) {
for (let j = 0; j < mutation.addedNodes.length; j++) {
const node = mutation.addedNodes[j];
if (node.nodeType === 1) {
const tagName = node.tagName?.toLowerCase();
if (tagName === 'video' || tagName === 'source' ||
tagName === 'a' || tagName === 'div' ||
tagName === 'iframe' || tagName === 'script') {
shouldScan = true;
break;
}
}
}
if (shouldScan) break;
}
}
if (shouldScan) {
clearTimeout(state.scanTimer);
state.scanTimer = setTimeout(scanForVideos, CONFIG.scanDebounce);
}
});
if (document.body) {
state.observer.observe(document.body, {
childList: true,
subtree: true
});
}
state.intersectionObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const video = entry.target;
if (entry.isIntersecting && video.dataset.src && !video.dataset.loaded) {
loadVideoSource(video, video.dataset.src);
state.intersectionObserver.unobserve(video);
} else if (!entry.isIntersecting && !video.paused) {
// Pause video when out of view to save resources
video.pause();
}
});
}, {
threshold: 0.1,
rootMargin: '100px'
});
}
function loadVideoSource(videoEl, url) {
videoEl.dataset.loaded = "true";
if (url.includes('.m3u8') && typeof Hls !== 'undefined' && Hls.isSupported()) {
const hls = new Hls({
enableWorker: true,
lowLatencyMode: true,
maxBufferLength: 30,
maxMaxBufferLength: 60
});
hls.loadSource(url);
hls.attachMedia(videoEl);
state.hlsPlayers.add(hls);
} else {
videoEl.src = url;
videoEl.load();
}
}
// --- UI Components ---
let panelEl, overlayEl, gridEl;
function createUI() {
if (document.getElementById('vd-root-container')) return;
const root = document.createElement('div');
root.id = 'vd-root-container';
document.body.appendChild(root);
// Panel with resize controls
panelEl = document.createElement('div');
panelEl.className = 'vd-panel vd-hidden';
root.appendChild(panelEl);
// Overlay/Modal
overlayEl = document.createElement('div');
overlayEl.className = 'vd-overlay';
overlayEl.innerHTML = `
<div class="vd-modal">
<div class="vd-modal-header">
<div class="vd-modal-title">Detected Videos</div>
<div class="vd-modal-close">✕</div>
</div>
<div class="vd-grid"></div>
<div class="vd-pagination"></div>
<div class="vd-modal-footer">
<div class="vd-stats-footer">0 videos selected</div>
<div style="display: flex; gap: 12px;">
<button class="vd-card-btn" id="vd-btn-select-all">Select All</button>
<button class="vd-card-btn primary" id="vd-btn-download-zip">Download Selected (ZIP)</button>
</div>
</div>
</div>
`;
root.appendChild(overlayEl);
gridEl = overlayEl.querySelector('.vd-grid');
// Event listeners
overlayEl.addEventListener('click', handleOverlayClick);
overlayEl.querySelector('#vd-btn-select-all').addEventListener('click', handleSelectAll);
overlayEl.querySelector('#vd-btn-download-zip').addEventListener('click', downloadZip);
initNetworkSniffer();
scanForVideos();
}
function updateUI() {
if (!panelEl) return;
const count = state.videos.size;
if (count > 0) {
panelEl.classList.remove('vd-hidden');
panelEl.innerHTML = `
<div class="header">
<div class="title">Video Detector</div>
<div class="close-btn">✕</div>
</div>
<div class="stats">${count} video(s) detected</div>
<div class="vd-resize-controls">
<button class="vd-resize-btn" data-action="width-minus">W-</button>
<button class="vd-resize-btn" data-action="width-plus">W+</button>
<button class="vd-resize-btn" data-action="height-minus">H-</button>
<button class="vd-resize-btn" data-action="height-plus">H+</button>
</div>
<button class="action-btn">Review Videos</button>
<button class="action-btn secondary-btn">Download All</button>
`;
panelEl.querySelector('.close-btn').addEventListener('click', (e) => {
e.stopPropagation();
panelEl.classList.add('vd-hidden');
});
panelEl.querySelector('.action-btn').addEventListener('click', () => toggleModal(true));
panelEl.querySelector('.secondary-btn').addEventListener('click', downloadZip);
// Resize controls
panelEl.querySelectorAll('.vd-resize-btn').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
handleResize(e.target.dataset.action);
});
});
}
}
function handleResize(action) {
const currentWidth = panelEl.offsetWidth;
const currentHeight = panelEl.offsetHeight;
const step = 50;
switch(action) {
case 'width-plus':
CONFIG.panelSize.width = Math.min(currentWidth + step, window.innerWidth * 0.9);
GM_setValue('panelWidth', CONFIG.panelSize.width);
break;
case 'width-minus':
CONFIG.panelSize.width = Math.max(currentWidth - step, 280);
GM_setValue('panelWidth', CONFIG.panelSize.width);
break;
case 'height-plus':
CONFIG.panelSize.height = Math.min(currentHeight + step, window.innerHeight * 0.9);
GM_setValue('panelHeight', CONFIG.panelSize.height);
break;
case 'height-minus':
CONFIG.panelSize.height = Math.max(currentHeight - step, 200);
GM_setValue('panelHeight', CONFIG.panelSize.height);
break;
}
panelEl.style.width = CONFIG.panelSize.width + 'px';
panelEl.style.height = CONFIG.panelSize.height + 'px';
}
function handleOverlayClick(e) {
const target = e.target;
if (target.classList.contains('vd-modal-close') || target === overlayEl) {
toggleModal(false);
return;
}
// Player controls
if (target.classList.contains('vd-control-btn')) {
handlePlayerControl(target);
return;
}
if (target.classList.contains('vd-progress-bar') || target.classList.contains('vd-progress-filled')) {
handleProgressBarClick(target, e);
return;
}
if (target.classList.contains('vd-speed-option')) {
handleSpeedSelection(target);
return;
}
if (target.classList.contains('vd-url-copy-btn')) {
handleUrlCopy(target);
return;
}
const card = target.closest('.vd-card');
if (!card) return;
const videoUrl = card.dataset.url;
const video = state.videos.get(videoUrl);
if (!video) return;
if (target.classList.contains('vd-copy')) {
copyToClipboard(videoUrl);
const oldText = target.textContent;
target.textContent = 'Copied!';
setTimeout(() => target.textContent = oldText, 2000);
} else if (target.classList.contains('vd-open')) {
window.open(videoUrl, '_blank');
} else if (target.classList.contains('vd-dl')) {
GM_download({
url: videoUrl,
name: getFileName(videoUrl, Array.from(state.videos.keys()).indexOf(videoUrl)),
saveAs: true
});
} else if (target.classList.contains('vd-checkbox')) {
video.selected = target.checked;
updateStats();
}
}
function handlePlayerControl(btn) {
const videoContainer = btn.closest('.vd-video-container');
const video = videoContainer?.querySelector('video');
if (!video) return;
const action = btn.dataset.action;
const card = btn.closest('.vd-card');
const videoUrl = card?.dataset.url;
switch(action) {
case 'play-pause':
if (video.paused) {
video.play();
btn.textContent = '⏸';
} else {
video.pause();
btn.textContent = '▶';
}
break;
case 'fullscreen':
if (videoContainer.requestFullscreen) {
videoContainer.requestFullscreen();
} else if (video.webkitRequestFullscreen) {
video.webkitRequestFullscreen();
}
break;
case 'speed':
toggleSpeedMenu(btn);
break;
case 'zoom-in':
video.style.transform = `scale(${Math.min(getVideoScale(video) + 0.1, 2)})`;
break;
case 'zoom-out':
video.style.transform = `scale(${Math.max(getVideoScale(video) - 0.1, 0.5)})`;
break;
case 'color':
cycleColorFilter(video);
break;
}
}
function getVideoScale(video) {
const transform = video.style.transform;
const match = transform.match(/scale\(([\d.]+)\)/);
return match ? parseFloat(match[1]) : 1;
}
function toggleSpeedMenu(btn) {
const container = btn.closest('.vd-video-container');
const speedMenu = container.querySelector('.vd-speed-menu');
if (speedMenu) {
speedMenu.classList.toggle('active');
}
}
function handleSpeedSelection(btn) {
const speed = parseFloat(btn.dataset.speed);
const container = btn.closest('.vd-video-container');
const video = container.querySelector('video');
const speedMenu = container.querySelector('.vd-speed-menu');
if (video) {
video.playbackRate = speed;
}
if (speedMenu) {
speedMenu.classList.remove('active');
}
const speedBtn = container.querySelector('[data-action="speed"]');
if (speedBtn) {
speedBtn.textContent = `${speed}x`;
}
}
function cycleColorFilter(video) {
const filters = [
'none',
'brightness(1.2) contrast(1.1)',
'saturate(1.5)',
'sepia(0.5)',
'hue-rotate(90deg)',
'grayscale(1)'
];
const currentFilter = video.style.filter || 'none';
const currentIndex = filters.indexOf(currentFilter);
const nextIndex = (currentIndex + 1) % filters.length;
video.style.filter = filters[nextIndex];
}
function handleProgressBarClick(target, e) {
const progressBar = target.classList.contains('vd-progress-bar') ? target : target.parentElement;
const container = progressBar.closest('.vd-video-container');
const video = container.querySelector('video');
if (!video) return;
const rect = progressBar.getBoundingClientRect();
const clickX = e.clientX - rect.left;
const percentage = clickX / rect.width;
video.currentTime = percentage * video.duration;
}
function handleUrlCopy(btn) {
const card = btn.closest('.vd-card');
const videoUrl = card?.dataset.url;
if (videoUrl) {
copyToClipboard(videoUrl);
const oldText = btn.textContent;
btn.textContent = 'Copied!';
setTimeout(() => btn.textContent = oldText, 2000);
}
}
function handleSelectAll() {
const allSelected = Array.from(state.videos.values()).every(v => v.selected);
state.videos.forEach(v => v.selected = !allSelected);
renderGrid();
}
function toggleModal(show) {
state.isModalOpen = show;
overlayEl.style.display = show ? 'flex' : 'none';
if (show) {
renderGrid();
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
// Cleanup all players
cleanupPlayers();
}
}
function cleanupPlayers() {
state.hlsPlayers.forEach(p => p.destroy());
state.hlsPlayers.clear();
state.activePlayers.forEach(video => {
if (!video.paused) {
video.pause();
}
video.removeAttribute('src');
video.load();
});
state.activePlayers.clear();
state.playerTimers.forEach(timer => clearInterval(timer));
state.playerTimers.clear();
}
function updateStats() {
const selectedCount = Array.from(state.videos.values()).filter(v => v.selected).length;
overlayEl.querySelector('.vd-stats-footer').textContent = `${selectedCount} videos selected`;
}
function updatePagination() {
const paginationEl = overlayEl.querySelector('.vd-pagination');
if (!paginationEl) return;
const totalVideos = state.videos.size;
state.totalPages = Math.ceil(totalVideos / state.itemsPerPage) || 1;
if (state.currentPage > state.totalPages) {
state.currentPage = state.totalPages;
}
let paginationHTML = `
<button class="vd-page-btn" data-page="prev" ${state.currentPage === 1 ? 'disabled' : ''}>←</button>
`;
// Show page numbers with ellipsis for many pages
const maxVisiblePages = 7;
let startPage = Math.max(1, state.currentPage - Math.floor(maxVisiblePages / 2));
let endPage = Math.min(state.totalPages, startPage + maxVisiblePages - 1);
if (endPage - startPage < maxVisiblePages - 1) {
startPage = Math.max(1, endPage - maxVisiblePages + 1);
}
if (startPage > 1) {
paginationHTML += `<button class="vd-page-btn" data-page="1">1</button>`;
if (startPage > 2) {
paginationHTML += `<span class="vd-page-info">...</span>`;
}
}
for (let i = startPage; i <= endPage; i++) {
paginationHTML += `<button class="vd-page-btn ${i === state.currentPage ? 'active' : ''}" data-page="${i}">${i}</button>`;
}
if (endPage < state.totalPages) {
if (endPage < state.totalPages - 1) {
paginationHTML += `<span class="vd-page-info">...</span>`;
}
paginationHTML += `<button class="vd-page-btn" data-page="${state.totalPages}">${state.totalPages}</button>`;
}
paginationHTML += `
<button class="vd-page-btn" data-page="next" ${state.currentPage === state.totalPages ? 'disabled' : ''}>→</button>
<span class="vd-page-info">Page ${state.currentPage} of ${state.totalPages}</span>
`;
paginationEl.innerHTML = paginationHTML;
// Add event listeners
paginationEl.querySelectorAll('.vd-page-btn').forEach(btn => {
btn.addEventListener('click', () => {
const page = btn.dataset.page;
if (page === 'prev' && state.currentPage > 1) {
state.currentPage--;
} else if (page === 'next' && state.currentPage < state.totalPages) {
state.currentPage++;
} else if (page !== 'prev' && page !== 'next') {
state.currentPage = parseInt(page);
}
renderGrid();
});
});
}
function createPlayerControls(videoUrl, fileName) {
return `
<div class="vd-player-controls">
<div class="vd-progress-bar">
<div class="vd-progress-filled"></div>
</div>
<div class="vd-controls-row">
<button class="vd-control-btn" data-action="play-pause">▶</button>
<button class="vd-control-btn" data-action="fullscreen">⛶</button>
<button class="vd-control-btn" data-action="speed">1x</button>
<button class="vd-control-btn" data-action="zoom-in">🔍+</button>
<button class="vd-control-btn" data-action="zoom-out">🔍-</button>
<button class="vd-control-btn" data-action="color">🎨</button>
<span class="vd-time-display">0:00 / 0:00</span>
</div>
<div class="vd-speed-menu">
<div class="vd-speed-option" data-speed="0.25">0.25x</div>
<div class="vd-speed-option" data-speed="0.5">0.5x</div>
<div class="vd-speed-option" data-speed="0.75">0.75x</div>
<div class="vd-speed-option" data-speed="1">1x</div>
<div class="vd-speed-option" data-speed="1.25">1.25x</div>
<div class="vd-speed-option" data-speed="1.5">1.5x</div>
<div class="vd-speed-option" data-speed="2">2x</div>
</div>
</div>
`;
}
function renderGrid() {
const fragment = document.createDocumentFragment();
const videoList = Array.from(state.videos.values()).sort((a, b) => b.addedAt - a.addedAt);
const startIndex = (state.currentPage - 1) * state.itemsPerPage;
const endIndex = Math.min(startIndex + state.itemsPerPage, videoList.length);
const pageVideos = videoList.slice(startIndex, endIndex);
updateStats();
updatePagination();
pageVideos.forEach((video, idx) => {
const card = document.createElement('div');
card.className = 'vd-card';
card.dataset.url = video.url;
const globalIdx = startIndex + idx;
const fileName = getFileName(video.url, globalIdx);
card.innerHTML = `
<div class="vd-video-container">
<div class="vd-placeholder">Loading player...</div>
${createPlayerControls(video.url, fileName)}
</div>
<div class="vd-card-info">
<div class="vd-card-name" title="${fileName}">${fileName}</div>
<div class="vd-card-url" title="${video.url}">${video.url}</div>
<button class="vd-url-copy-btn">Copy URL</button>
<div class="vd-card-actions">
<button class="vd-card-btn vd-open">Open</button>
<button class="vd-card-btn primary vd-dl">Download</button>
</div>
<label style="display: flex; align-items: center; gap: 8px; margin-top: 4px; cursor: pointer;">
<input type="checkbox" ${video.selected ? 'checked' : ''} class="vd-checkbox">
<span style="font-size: 12px; color: #aaa;">Select for ZIP</span>
</label>
</div>
`;
const videoContainer = card.querySelector('.vd-video-container');
const videoEl = document.createElement('video');
videoEl.controls = false; // Hide native controls
videoEl.muted = true;
videoEl.preload = 'metadata';
videoEl.dataset.src = video.url;
videoEl.playsInline = true;
state.intersectionObserver.observe(videoEl);
state.activePlayers.add(videoEl);
setupPlayerEvents(videoEl, videoContainer);
videoContainer.querySelector('.vd-placeholder').after(videoEl);
fragment.appendChild(card);
});
gridEl.innerHTML = '';
gridEl.appendChild(fragment);
}
function setupPlayerEvents(videoEl, container) {
videoEl.addEventListener('loadedmetadata', () => {
const placeholder = container.querySelector('.vd-placeholder');
if (placeholder) placeholder.style.display = 'none';
const timeDisplay = container.querySelector('.vd-time-display');
if (timeDisplay) {
timeDisplay.textContent = `0:00 / ${formatTime(videoEl.duration)}`;
}
});
videoEl.addEventListener('timeupdate', () => {
const progressFilled = container.querySelector('.vd-progress-filled');
const timeDisplay = container.querySelector('.vd-time-display');
if (progressFilled && videoEl.duration) {
const percentage = (videoEl.currentTime / videoEl.duration) * 100;
progressFilled.style.width = percentage + '%';
}
if (timeDisplay) {
timeDisplay.textContent = `${formatTime(videoEl.currentTime)} / ${formatTime(videoEl.duration)}`;
}
});
videoEl.addEventListener('play', () => {
const playBtn = container.querySelector('[data-action="play-pause"]');
if (playBtn) playBtn.textContent = '⏸';
// Auto-hide controls
container.classList.add('vd-controls-visible');
setTimeout(() => {
if (!videoEl.paused) {
container.classList.remove('vd-controls-visible');
}
}, 3000);
});
videoEl.addEventListener('pause', () => {
const playBtn = container.querySelector('[data-action="play-pause"]');
if (playBtn) playBtn.textContent = '▶';
container.classList.add('vd-controls-visible');
});
videoEl.addEventListener('error', () => {
const placeholder = container.querySelector('.vd-placeholder');
if (placeholder) placeholder.textContent = 'Error loading video';
});
videoEl.addEventListener('click', () => {
if (videoEl.paused) {
videoEl.play();
} else {
videoEl.pause();
}
});
// Show controls on mouse move
container.addEventListener('mousemove', () => {
container.classList.add('vd-controls-visible');
clearTimeout(container._controlsTimeout);
container._controlsTimeout = setTimeout(() => {
if (!videoEl.paused) {
container.classList.remove('vd-controls-visible');
}
}, 3000);
});
}
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
async function downloadZip() {
const selectedVideos = Array.from(state.videos.values()).filter(v => v.selected);
if (selectedVideos.length === 0) {
alert('No videos selected!');
return;
}
const btn = document.querySelector('#vd-btn-download-zip') || document.querySelector('.vd-panel .secondary-btn');
const originalText = btn.textContent;
btn.textContent = 'Preparing ZIP...';
btn.disabled = true;
try {
const zip = new JSZip();
const folder = zip.folder(CONFIG.autoSaveDir);
const batchSize = CONFIG.maxConcurrentDownloads;
for (let i = 0; i < selectedVideos.length; i += batchSize) {
const batch = selectedVideos.slice(i, i + batchSize);
const promises = batch.map(async (video, batchIdx) => {
try {
const response = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: video.url,
responseType: 'blob',
onload: resolve,
onerror: reject,
timeout: 30000
});
});
const fileName = getFileName(video.url, i + batchIdx);
folder.file(fileName, response.response);
} catch (e) {
console.error('Failed to download:', video.url, e);
}
});
await Promise.all(promises);
btn.textContent = `Downloading... ${Math.min(i + batchSize, selectedVideos.length)}/${selectedVideos.length}`;
}
btn.textContent = 'Creating ZIP...';
const content = await zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
compressionOptions: { level: 6 }
});
const url = URL.createObjectURL(content);
const a = document.createElement('a');
a.href = url;
a.download = `${CONFIG.autoSaveDir}_${new Date().getTime()}.zip`;
a.click();
URL.revokeObjectURL(url);
} catch (e) {
alert('Error creating ZIP: ' + e.message);
} finally {
btn.textContent = originalText;
btn.disabled = false;
}
}
function init() {
createUI();
initObservers();
GM_registerMenuCommand('Open Video Detector', () => toggleModal(true));
GM_registerMenuCommand('Reset UI', () => {
const root = document.getElementById('vd-root-container');
if (root) root.remove();
createUI();
});
if (document.body) {
scanForVideos();
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => setTimeout(init, 100));
} else {
setTimeout(init, 100);
}
})();