您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Enhanced media downloader script with support for multiple sites, subtitles and easy extensibility. Optimized for faster button appearance. Includes hypothetical JavGG fix.
当前为
// ==UserScript== // @name Enhanced_Media_Helper // @version 2.3 // @description Enhanced media downloader script with support for multiple sites, subtitles and easy extensibility. Optimized for faster button appearance. Includes hypothetical JavGG fix. // @author cores (original) & improved version & Gemini // @match https://jable.tv/videos/*/* // @match https://tokyolib.com/v/* // @match https://fs1.app/videos/*/* // @match https://cableav.tv/*/ // @match https://javgg.net/tag/to-be-release/* // @match https://javgg.net/featured/* // @match https://javgg.net/ // @match https://javgg.net/new-post/* // @match https://javgg.net/jav/* // @match https://javgg.net/star/* // @match https://javgg.net/trending/* // @include /.*javtxt.[a-z]+\/v/.*$/ // @include /.*javtext.[a-z]+\/v/.*$/ // @match https://cableav.tv/?p=* // @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.4.1/jquery.min.js // @icon data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw== // @grant GM_xmlhttpRequest // @connect api-shoulei-ssl.xunlei.com // @license MPL // @namespace cdn.bootcss.com // ==/UserScript== (function () { 'use strict'; // 配置对象 const CONFIG = { serverMode: 2, // 服务器模式:1 为 localhost,2 为 IP serverPort: 9909, // 服务器端口 alternateUrl: 'https://123av.com/zh/v/', // 备用 URL subtitleApiUrl: 'https://api-shoulei-ssl.xunlei.com/oracle/subtitle', // 字幕 API URL elementCheckInterval: 200, // 检查元素存在的间隔 (ms) elementCheckTimeout: 7000, // 检查元素存在的超时时间 (ms) }; // 工具函数对象 const UTILS = { // 获取当前页面的域名 getDomain: () => document.domain, // 从 URL 中提取视频代码 getCodeFromUrl: (url) => { const match = url.match(/\/([a-z0-9-]+)\/?$/i); // Improved regex to handle optional trailing slash return match ? match[1] : null; }, // 获取海报图像的 URL (Improved: handle cases where plyr__poster might not be the direct element) getPosterImage: () => { const videoContainer = document.querySelector('.video-player-container, .player-container, #player'); // Add more potential container selectors if (videoContainer) { const posterElem = videoContainer.querySelector('.plyr__poster, [poster]'); if (posterElem) { if (posterElem.hasAttribute('poster')) { return posterElem.getAttribute('poster'); } const backgroundImageStyle = window.getComputedStyle(posterElem).getPropertyValue('background-image'); const matches = /url\("(.+)"\)/.exec(backgroundImageStyle); return matches ? matches[1] : null; } } // Fallback for specific sites if needed const metaPoster = document.querySelector('meta[property="og:image"], meta[name="twitter:image"]'); return metaPoster ? metaPoster.content : null; }, // 获取女演员名字 (Refined selector for potentially broader compatibility) getActressNames: () => { const actressLinks = document.querySelectorAll('.video-info .info-item a[href*="/actress/"], .models-list .model a, .attributes a[href*="/star/"]'); // Combine selectors return Array.from(actressLinks) .map(link => link.getAttribute('title') || link.textContent.trim()) .filter(name => name) .filter((value, index, self) => self.indexOf(value) === index) // Unique names .join(','); }, // 构建 API URL buildApiUrl: (domain, options) => { const queryParams = Object.keys(options.query || {}) .map(key => `${encodeURIComponent(key)}=${encodeURIComponent(options.query[key])}`) .join("&"); const query = queryParams.length > 0 ? `?${queryParams}` : ""; return `http://${domain}${options.path || ''}${query}`; // Use http unless https is explicitly required/configured }, // 显示 Toast 通知 (Slightly improved styling) showToast: (message, type = 'info') => { let toastContainer = document.getElementById('custom-toast-container'); if (!toastContainer) { toastContainer = document.createElement('div'); toastContainer.id = 'custom-toast-container'; document.body.appendChild(toastContainer); } const toast = document.createElement('div'); toast.className = `custom-toast custom-toast-${type}`; toast.textContent = message; toastContainer.appendChild(toast); // Trigger fade in setTimeout(() => toast.classList.add('show'), 10); // Set timer to remove setTimeout(() => { toast.classList.remove('show'); // Remove element after fade out animation completes toast.addEventListener('transitionend', () => toast.remove()); }, 3000); }, // 复制文本到剪贴板 copyToClipboard: async (text) => { if (!text) { UTILS.showToast("没有可复制的内容", "error"); return false; } try { await navigator.clipboard.writeText(text); UTILS.showToast("内容已成功复制到剪贴板", "success"); return true; } catch (error) { UTILS.showToast("复制失败,请检查浏览器权限", "error"); console.error("Copy error:", error); // Fallback for older browsers or insecure contexts (though less likely in userscripts) try { const textArea = document.createElement("textarea"); textArea.value = text; textArea.style.position = "fixed"; // Prevent scrolling to bottom of page in MS Edge. textArea.style.top = "0"; textArea.style.left = "0"; textArea.style.opacity = "0"; document.body.appendChild(textArea); textArea.focus(); textArea.select(); const successful = document.execCommand('copy'); document.body.removeChild(textArea); if (successful) { UTILS.showToast("内容已复制 (fallback)", "success"); return true; } else { throw new Error('execCommand failed'); } } catch (fallbackError) { UTILS.showToast("复制到剪贴板时出错", "error"); console.error("Fallback copy error:", fallbackError); return false; } } }, // 添加操作按钮 (Refined structure and class names) addActionButtons: (container, videoUrl, videoCode) => { const buttonContainer = document.createElement("div"); buttonContainer.className = "emh-action-buttons"; // Use prefix to avoid conflicts // 复制下载链接按钮 (Replaces #tobod link) const copyButton = document.createElement("button"); copyButton.id = "emh-copyLink"; copyButton.className = "emh-button emh-button-copy"; copyButton.innerHTML = "📋 复制链接"; // Use emoji for visual cue copyButton.title = videoUrl || "无有效视频链接"; copyButton.dataset.videoUrl = videoUrl || ''; buttonContainer.appendChild(copyButton); // 发送数据按钮 const sendButton = document.createElement("button"); sendButton.id = "emh-sendData"; sendButton.className = "emh-button emh-button-send"; sendButton.innerHTML = "💾 发送到服务器"; // Store necessary data directly on the button for the event listener sendButton.dataset.videoUrl = videoUrl || ''; sendButton.dataset.videoCode = videoCode || ''; buttonContainer.appendChild(sendButton); // 获取字幕按钮 const subtitleButton = document.createElement("button"); subtitleButton.id = "emh-getSubtitles"; subtitleButton.className = "emh-button emh-button-subtitle"; subtitleButton.innerHTML = "📄 获取字幕"; subtitleButton.dataset.videoCode = videoCode || ''; buttonContainer.appendChild(subtitleButton); container.appendChild(buttonContainer); return buttonContainer; }, // 创建弹窗显示字幕内容 (Slight style tweaks) createSubtitleModal: (subtitleContent = null, videoCode = null) => { // Added videoCode param // Remove existing modal first const existingModal = document.getElementById('emh-subtitle-modal'); if (existingModal) existingModal.remove(); const modal = document.createElement('div'); modal.id = 'emh-subtitle-modal'; modal.className = 'emh-modal'; // Prefix class const modalContent = document.createElement('div'); modalContent.className = 'emh-modal-content'; const modalHeader = document.createElement('div'); modalHeader.className = 'emh-modal-header'; modalHeader.innerHTML = `<h3>字幕列表</h3><span class="emh-modal-close">×</span>`; modalContent.appendChild(modalHeader); const modalBody = document.createElement('div'); modalBody.className = 'emh-modal-body'; if (subtitleContent && subtitleContent.data && subtitleContent.data.length > 0) { const list = document.createElement('ul'); // Use list for semantics list.className = 'emh-subtitle-list'; subtitleContent.data.forEach((subtitle) => { const item = document.createElement('li'); item.className = 'emh-subtitle-item'; // Try to create a meaningful download filename const downloadFilename = `${videoCode || subtitle.name || 'subtitle'}.${subtitle.ext || 'srt'}`; item.innerHTML = ` <div class="emh-subtitle-info"> <h4>${subtitle.name || '未命名字幕'}</h4> <p>格式: ${subtitle.ext || '未知'} | 语言: ${subtitle.languages?.length ? subtitle.languages.join(', ') : '未知'} ${subtitle.extra_name ? '| 来源: ' + subtitle.extra_name : ''}</p> </div> ${subtitle.url ? `<a href="${subtitle.url}" target="_blank" class="emh-button emh-button-download" download="${downloadFilename}">下载</a>` : ''} `; // Use generated download filename list.appendChild(item); }); modalBody.appendChild(list); } else { modalBody.innerHTML = '<p class="emh-no-subtitle-message">未找到相关字幕</p>'; } modalContent.appendChild(modalBody); modal.appendChild(modalContent); document.body.appendChild(modal); // Event listeners for the new modal modal.querySelector('.emh-modal-close').onclick = () => modal.remove(); modal.onclick = (event) => { // Click outside to close if (event.target === modal) { modal.remove(); } }; // Display modal setTimeout(() => modal.classList.add('show'), 10); // Allow transition return modal; }, // 获取字幕 (Added loading state) fetchSubtitles: (videoCode) => { if (!videoCode) { UTILS.showToast("无法获取视频代码,无法查询字幕", "error"); return; } UTILS.showToast("正在获取字幕信息...", "info"); const button = document.getElementById('emh-getSubtitles') || document.querySelector(`.emh-subtitle-button-small[data-video-code="${videoCode}"]`); if (button) button.disabled = true; // Disable button while loading const apiUrl = `${CONFIG.subtitleApiUrl}?name=${encodeURIComponent(videoCode)}`; const handleResponse = (responseText) => { if (button) button.disabled = false; try { const data = JSON.parse(responseText); UTILS.createSubtitleModal(data, videoCode); // Pass videoCode for potential download name if (data.data && data.data.length > 0) { UTILS.showToast("成功获取字幕信息", "success"); } else { UTILS.showToast("未找到字幕", "info"); } } catch (e) { console.error("解析字幕数据时出错:", e); UTILS.showToast("解析字幕数据时出错", "error"); UTILS.createSubtitleModal(null, videoCode); // Show empty modal on error } }; const handleError = (error) => { if (button) button.disabled = false; console.error("获取字幕时出错:", error); UTILS.showToast("获取字幕时出错", "error"); UTILS.createSubtitleModal(null, videoCode); }; if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest({ method: 'GET', url: apiUrl, timeout: 15000, // Add timeout onload: (response) => handleResponse(response.responseText), onerror: handleError, ontimeout: () => { if (button) button.disabled = false; UTILS.showToast("获取字幕超时", "error"); UTILS.createSubtitleModal(null, videoCode); } }); } else { fetch(apiUrl, { signal: AbortSignal.timeout(15000) }) // Add timeout via AbortSignal .then(response => { if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); return response.text(); // Get text first to handle potential non-JSON empty response }) .then(text => { if (!text) { // Handle empty response handleResponse('{"data": []}'); // Simulate empty data } else { handleResponse(text); } }) .catch(error => { if (error.name === 'AbortError') { UTILS.showToast("获取字幕超时", "error"); } else { handleError(error); } UTILS.createSubtitleModal(null, videoCode); }); } } }; // 站点处理对象 const SITE_HANDLERS = { // JavTXT 和类似站点 javtxt: { isMatch: () => UTILS.getDomain().includes('javtxt') || UTILS.getDomain().includes('tokyolib') || UTILS.getDomain().includes('javtext'), // Define target selector here for the waitForElement function targetSelector: 'body > div.main > div.info > div.attributes > dl > dt:nth-child(2)', process: (targetElement) => { // Receive the target element if (!targetElement) { console.error("JavTXT: Target element not found."); return; } const config = { links: [ { urlTemplate: 'https://123av.com/zh/v/$code', target: '_blank', displayText: '123av' }, { urlTemplate: 'https://jable.tv/videos/$code/', target: '_blank', displayText: 'Jable' } ] }; const cleanedCode = extractCode(targetElement.innerText); if (!cleanedCode) { console.error("JavTXT: Failed to extract code."); return; } // Create container for links and buttons const controlsContainer = document.createElement('div'); controlsContainer.className = 'emh-controls-container'; // Create links config.links.forEach(linkConfig => { const link = document.createElement('a'); link.href = linkConfig.urlTemplate.replace('$code', cleanedCode); link.target = linkConfig.target; link.className = 'emh-external-link'; link.innerText = linkConfig.displayText; controlsContainer.appendChild(link); }); // Add subtitle button const subtitleButton = document.createElement('button'); subtitleButton.id = 'emh-getSubtitles'; // Consistent ID subtitleButton.className = 'emh-button emh-button-subtitle'; // Consistent class subtitleButton.innerHTML = '📄 获取字幕'; subtitleButton.dataset.videoCode = cleanedCode; controlsContainer.appendChild(subtitleButton); // Insert the controls container after the target element targetElement.parentNode.insertBefore(controlsContainer, targetElement.nextSibling); } }, // JavGG 站点 (FIXED based on HYPOTHETICAL structure - verify selectors!) // JavGG 站点 (FIXED based on ACTUAL provided structure) javgg: { isMatch: () => UTILS.getDomain().includes('javgg'), // --- UPDATED SELECTOR --- // Wait for the .data div within the article item to appear targetSelector: 'article.item.movies .data', process: () => { // process function iterates through all found items // Remove sidebar immediately if it exists const sidebar = document.querySelector("#contenedor > div > div.sidebar.right.scrolling"); if (sidebar) sidebar.remove(); const linkProviders = [ { code: "njav", url: CONFIG.alternateUrl + "$p", target: "_blank" }, { code: "jable", url: "https://jable.tv/videos/$p/", target: "_blank" }, { code: "1cili", url: "https://1cili.com/search?q=$p", target: "_blank" } ]; // --- UPDATED SELECTOR --- // Process each video entry using the correct item selector document.querySelectorAll("article.item.movies").forEach(entry => { // Select each article item // --- UPDATED SELECTORS --- // Find the .data element and the anchor tag within its h3 const dataElement = entry.querySelector(".data"); // Find the .data container const anchorTag = dataElement ? dataElement.querySelector("h3 a") : null; // Find the link inside h3 if (anchorTag) { // Check if the anchor tag was found const videoCode = anchorTag.textContent.trim(); // Get code from link's text content if (!videoCode) return; // Skip if code is empty // --- UPDATED CHECK LOCATION --- // Check if controls already added (check within dataElement) if (dataElement.querySelector('.emh-javgg-controls')) return; const controlsDiv = document.createElement('div'); controlsDiv.className = 'emh-javgg-controls'; // Container for links/buttons // Add provider links linkProviders.forEach(provider => { const newAnchorTag = document.createElement("a"); newAnchorTag.href = provider.url.replace("$p", videoCode); newAnchorTag.target = provider.target; newAnchorTag.className = 'emh-external-link-small'; newAnchorTag.textContent = provider.code; controlsDiv.appendChild(newAnchorTag); }); // Add subtitle button const subtitleButton = document.createElement('button'); subtitleButton.className = 'emh-button emh-subtitle-button-small'; subtitleButton.innerHTML = '字幕'; subtitleButton.dataset.videoCode = videoCode; controlsDiv.appendChild(subtitleButton); // --- UPDATED INSERT LOCATION --- // Append controls to the .data element, after existing content dataElement.appendChild(controlsDiv); } else { // Optional: Log if the expected structure wasn't found // console.log("EMH: Could not find '.data h3 a' within an article.item.movies", entry); } }); } }, // Jable 和类似视频站点 jable: { isMatch: () => UTILS.getDomain().includes('jable') || UTILS.getDomain().includes('cableav') || UTILS.getDomain().includes('fs1.app'), // More specific target selectors, check order matters targetSelector: '.video-toolbar, .video-info .level, .video-info .row, .text-center', process: (targetElement) => { // Receive the target element if (!targetElement) { console.error("Jable-like: Target container not found."); return; } // Check if buttons already added to this target if (targetElement.querySelector('.emh-ui-container')) return; const isCableAv = UTILS.getDomain() === "cableav.tv"; let videoUrl = ''; let videoCode = UTILS.getCodeFromUrl(window.location.href); // Extract code once // Find video URL if (!isCableAv) { // Try global variable first (common on Jable) if (typeof hlsUrl !== 'undefined' && hlsUrl) { videoUrl = hlsUrl; } else { // Fallback: Check script tags for player setup const scripts = document.querySelectorAll('script'); for (let script of scripts) { if (script.textContent.includes('player.src({')) { const match = script.textContent.match(/src:\s*['"]([^'"]+\.m3u8[^'"]*)['"]/); if (match && match[1]) { videoUrl = match[1]; break; } } } } // Add title fragment if URL found and code exists if (videoUrl && videoCode) { videoUrl += "#" + videoCode; } else if (videoUrl && !videoCode) { // Try getting code from title if URL exists but code extraction failed const titleCodeMatch = document.title.match(/^([A-Z0-9-]+)/i); if (titleCodeMatch) { videoCode = titleCodeMatch[1]; videoUrl += "#" + videoCode; } } } else { // CableAV logic const metaTag = document.head.querySelector("meta[property~='og:video:url'][content]"); if (metaTag) { videoUrl = metaTag.content; } } // Create the main UI container const uiContainer = document.createElement("div"); uiContainer.className = "emh-ui-container"; // Prefix class // Add video code display (if found) if (videoCode) { const dataElement = document.createElement("span"); // Use span for inline display dataElement.id = "emh-dataElement"; dataElement.className = "emh-data-element"; dataElement.innerHTML = `番号: ${videoCode}`; dataElement.title = "点击搜索番号 (1cili)"; dataElement.dataset.videoCode = videoCode; // Store code for click event uiContainer.appendChild(dataElement); } // Add action buttons (Copy, Send, Subtitle) UTILS.addActionButtons(uiContainer, videoUrl, videoCode); // Append the container to the target element found by waitForElement targetElement.appendChild(uiContainer); } } }; // 视频数据管理对象 const VIDEO_MANAGER = { sendVideoData: (button) => { // Receive the button that was clicked // Extract data stored on the button const videoUrl = button.dataset.videoUrl || ''; const videoCode = button.dataset.videoCode || UTILS.getCodeFromUrl(window.location.href); // Fallback if needed // Try to get other details - might need site-specific selectors if called from different pages // Using more generic selectors where possible const titleElement = document.querySelector("h4.title, h1.post-title, .video-info h4, meta[property='og:title']"); let title = titleElement ? (titleElement.content || titleElement.innerText.trim()) : document.title; if (videoCode && title.includes(videoCode)) { // Clean up title title = title.split(videoCode).pop().trim().replace(/^[-–—\s]+/, ''); // Remove code and leading separators } const posterImage = UTILS.getPosterImage(); const actress = UTILS.getActressNames(); const videoData = { code: videoCode || 'UNKNOWN', // Ensure code is not empty name: title || 'Untitled', img: posterImage || '', url: window.location.href, actress: actress || '', video: videoUrl || '' // The actual stream/download URL }; // Basic validation if (!videoData.code || videoData.code === 'UNKNOWN') { UTILS.showToast("无法获取视频代码,发送中止", "warning"); console.warn("Send data aborted, missing video code.", videoData); return; } if (!videoData.video) { UTILS.showToast("无法获取视频链接,部分信息可能缺失", "warning"); console.warn("Missing video stream URL.", videoData); // Allow sending anyway, but warn user. } console.log("Data to send:", videoData); const serverDomain = (CONFIG.serverMode === 1) ? `localhost:${CONFIG.serverPort}` : `YOUR_SERVER_IP:${CONFIG.serverPort}`; // Replace YOUR_SERVER_IP if using mode 2 if (CONFIG.serverMode === 2 && serverDomain.includes('YOUR_SERVER_IP')) { UTILS.showToast("请先在脚本中配置服务器IP地址", "error"); console.error("Server IP not configured in script for serverMode 2."); return; } const apiUrl = UTILS.buildApiUrl(serverDomain, { path: '/add', query: videoData }); // Assuming '/add' endpoint // Use GM_xmlhttpRequest for reliable background request if available if (typeof GM_xmlhttpRequest !== 'undefined') { GM_xmlhttpRequest({ method: 'GET', // Or 'POST' if your server expects it url: apiUrl, timeout: 10000, onload: (response) => { if (response.status >= 200 && response.status < 300) { UTILS.showToast("数据已发送到服务器", "success"); } else { UTILS.showToast(`服务器响应错误: ${response.status}`, "error"); console.error("Server response error:", response); } }, onerror: (error) => { UTILS.showToast("发送数据时网络错误", "error"); console.error("Send data network error:", error); }, ontimeout: () => { UTILS.showToast("发送数据超时", "error"); } }); } else { // Fallback using fetch (less reliable for background tasks, might have CORS issues) fetch(apiUrl, { mode: 'no-cors', signal: AbortSignal.timeout(10000) }) // 'no-cors' might be needed for simple GET endpoints .then(response => { // Cannot read response status in no-cors mode UTILS.showToast("数据已尝试发送 (no-cors)", "success"); }) .catch(error => { if (error.name === 'AbortError') { UTILS.showToast("发送数据超时", "error"); } else { UTILS.showToast("发送数据时出错 (fetch)", "error"); } console.error("Send data error (fetch):", error); }); } return videoData; // Return data for potential further use } }; // --- Helper Functions --- // 提取视频代码函数 (No changes needed) function extractCode(text) { if (!text) return null; // Basic code pattern: Letters-Numbers or LettersNumbers-Numbers const match = text.match(/([A-Za-z]{2,5}-?\d{2,5})/); // Fallback: If pattern doesn't match, try cleaning common additions like (Uncensored Leak) return match ? match[1].toUpperCase() : text.replace(/\s*\(.*?\)/g, '').trim().toUpperCase(); } // Function to wait for an element and then execute a callback function waitForElement(selector, callback, timeout = CONFIG.elementCheckTimeout) { const startTime = Date.now(); const intervalId = setInterval(() => { const element = document.querySelector(selector); if (element) { clearInterval(intervalId); console.log(`EMH: Element found: ${selector}`); callback(element); // Pass the found element to the callback } else if (Date.now() - startTime > timeout) { clearInterval(intervalId); console.warn(`EMH: Element "${selector}" not found within ${timeout}ms.`); callback(null); // Indicate failure by passing null } }, CONFIG.elementCheckInterval); // Check frequently } // --- Main Logic --- // 主函数 function main() { let handlerFound = false; for (const [name, handler] of Object.entries(SITE_HANDLERS)) { if (handler.isMatch()) { console.log(`EMH: Handler matched: ${name}. Waiting for target: ${handler.targetSelector || 'N/A'}`); handlerFound = true; if (handler.targetSelector) { // Wait for the specific element defined by the handler waitForElement(handler.targetSelector, (targetElement) => { // For JavGG, even if the *specific* target isn't found (maybe timeout), // we still want to run process() because it iterates over all items it *can* find. // For other sites, targetElement must exist. if (targetElement || name === 'javgg') { try { // Give the page a tiny bit more time to render siblings etc. after the target is found setTimeout(() => { handler.process(targetElement); // Pass the element }, 50); // Small delay } catch (e) { console.error(`EMH: Error processing handler ${name}:`, e); } } else { console.error(`EMH: Handler ${name} could not find its target element: ${handler.targetSelector}`); } }); } else { // If no target selector, run immediately (or with a minimal delay if needed) console.log(`EMH: Handler ${name} has no specific targetSelector, running process immediately.`); try { // Small delay for list pages might still be beneficial setTimeout(() => handler.process(null), 150); // Slightly increased delay } catch(e) { console.error(`EMH: Error processing handler ${name} immediately:`, e); } } // Assuming only one handler should match per page break; } } if (!handlerFound) { console.log("EMH: No matching handler found for this site."); } // Setup global event listeners AFTER the main logic has potentially added elements setupEventListeners(); } // 设置事件监听器 (Using event delegation) function setupEventListeners() { // Use jQuery for simplicity since it's required anyway // Using document allows listeners to work even if elements are added dynamically after setup $(document).off('.emh'); // Remove previous listeners in this namespace // Copy Link Button $(document).on('click.emh', '#emh-copyLink', function() { const url = $(this).data('videoUrl'); UTILS.copyToClipboard(url); }); // Send Data Button $(document).on('click.emh', '#emh-sendData', function() { VIDEO_MANAGER.sendVideoData(this); // Pass the button element }); // Get Subtitles Button (Main and Small) $(document).on('click.emh', '#emh-getSubtitles, .emh-subtitle-button-small', function() { const videoCode = $(this).data('videoCode'); // Get code from data attribute UTILS.fetchSubtitles(videoCode); }); // Clickable Video Code Element $(document).on('click.emh', '#emh-dataElement', function() { const code = $(this).data('videoCode'); if (code) { window.open(`https://1cili.com/search?q=${code}`, "_blank"); } }); console.log("EMH: Event listeners attached."); } // 添加自定义样式 (Optimized and using CSS Variables) function addCustomStyles() { const style = document.createElement('style'); style.textContent = ` :root{--emh-primary-color:#4285f4;--emh-secondary-color:#34a853;--emh-danger-color:#ea4335;--emh-warning-color:#fbbc05;--emh-info-color:#00acc1;--emh-light-bg:#f8f9fa;--emh-dark-text:#202124;--emh-light-text:#fff;--emh-border-color:#dadce0;--emh-button-padding:8px 16px;--emh-button-radius:6px;--emh-transition:all 0.25s cubic-bezier(0.4,0,0.2,1);}.emh-button{display:inline-flex;align-items:center;justify-content:center;padding:var(--emh-button-padding);border:none;border-radius:var(--emh-button-radius);color:var(--emh-light-text);text-align:center;text-decoration:none;font-size:14px;font-weight:500;cursor:pointer;transition:var(--emh-transition);margin:3px 5px;line-height:1.5;box-shadow:0 2px 4px rgba(0,0,0,0.15);white-space:nowrap;vertical-align:middle;position:relative;overflow:hidden;}.emh-button:hover{opacity:0.95;transform:translateY(-2px);box-shadow:0 4px 8px rgba(0,0,0,0.2);}.emh-button:active{transform:translateY(1px);box-shadow:0 1px 2px rgba(0,0,0,0.1);}.emh-button:disabled{opacity:0.6;cursor:not-allowed;transform:none;box-shadow:0 1px 2px rgba(0,0,0,0.1);}.emh-button-copy{background-color:var(--emh-primary-color);background-image:linear-gradient(to bottom right,#4285f4,#3b78e7);}.emh-button-send{background-color:var(--emh-danger-color);background-image:linear-gradient(to bottom right,#ea4335,#d23f31);}.emh-button-subtitle{background-color:var(--emh-secondary-color);background-image:linear-gradient(to bottom right,#34a853,#2e9549);}.emh-button-download{background-color:#1a73e8;background-image:linear-gradient(to bottom right,#1a73e8,#1967d2);font-size:14px;padding:6px 12px;display:inline-flex;align-items:center;gap:6px;}.emh-button-download:hover{background-color:#1967d2;color:white;text-decoration:none;}.emh-button-download::before{content:"↓";font-weight:bold;margin-right:2px;}.emh-subtitle-button-small{background-color:var(--emh-secondary-color);background-image:linear-gradient(to bottom right,#34a853,#2e9549);border:none;color:white;padding:4px 8px;margin:0 4px;text-align:center;text-decoration:none;font-size:12px;font-weight:500;cursor:pointer;border-radius:4px;transition:var(--emh-transition);vertical-align:middle;box-shadow:0 1px 2px rgba(0,0,0,0.15);}.emh-subtitle-button-small:hover{background-color:#2e9549;transform:translateY(-1px);box-shadow:0 2px 4px rgba(0,0,0,0.2);}.emh-ui-container{margin:12px 0 8px 0;text-align:center;display:flex;flex-wrap:wrap;justify-content:center;align-items:center;gap:10px;border-top:1px solid var(--emh-border-color);padding:14px 8px 6px 8px;background-color:rgba(248,249,250,0.5);border-radius:8px;}.emh-action-buttons{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;}.emh-data-element{display:inline-flex;align-items:center;padding:6px 10px;background-color:#e8f0fe;border-radius:var(--emh-button-radius);cursor:pointer;border:1px solid #d2e3fc;transition:var(--emh-transition);color:#1a73e8;font-size:14px;font-weight:500;vertical-align:middle;box-shadow:0 1px 2px rgba(0,0,0,0.05);}.emh-data-element:hover{background-color:#d2e3fc;border-color:#aecbfa;transform:translateY(-1px);box-shadow:0 2px 4px rgba(0,0,0,0.1);}.emh-data-element::after{content:" 🔍";opacity:0.7;margin-left:4px;}.emh-controls-container{margin-top:10px;padding:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap;background-color:rgba(248,249,250,0.5);border-radius:8px;}.emh-javgg-controls{margin-top:6px;display:inline-flex;flex-wrap:wrap;gap:6px;align-items:center;margin-left:10px;vertical-align:middle;padding:4px 6px;background-color:rgba(248,249,250,0.5);border-radius:6px;}.emh-external-link,.emh-external-link-small{display:inline-flex;align-items:center;justify-content:center;padding:5px 10px;background-color:#5f6368;background-image:linear-gradient(to bottom right,#5f6368,#3c4043);color:white;border-radius:var(--emh-button-radius);text-decoration:none;font-size:13px;font-weight:500;transition:var(--emh-transition);vertical-align:middle;box-shadow:0 1px 2px rgba(0,0,0,0.15);}.emh-external-link-small{font-size:12px;padding:4px 8px;border-radius:4px;}.emh-external-link:hover,.emh-external-link-small:hover{background-color:#3c4043;color:white;text-decoration:none;transform:translateY(-1px);box-shadow:0 3px 5px rgba(0,0,0,0.2);}#custom-toast-container{position:fixed;top:70px;right:20px;z-index:10000;display:flex;flex-direction:column;gap:10px;}.custom-toast{padding:14px 20px;border-radius:8px;color:var(--emh-light-text);box-shadow:0 4px 12px rgba(0,0,0,0.25);transition:opacity 0.3s ease,transform 0.3s ease;opacity:0;transform:translateX(100%);font-size:14px;font-weight:500;display:flex;align-items:center;}.custom-toast.show{opacity:1;transform:translateX(0);}.custom-toast::before{margin-right:8px;font-weight:bold;}.custom-toast-success{background-color:var(--emh-secondary-color);}.custom-toast-success::before{content:"✓";}.custom-toast-error{background-color:var(--emh-danger-color);}.custom-toast-error::before{content:"✕";}.custom-toast-info{background-color:var(--emh-info-color);}.custom-toast-info::before{content:"ℹ";}.custom-toast-warning{background-color:var(--emh-warning-color);color:#202124;}.custom-toast-warning::before{content:"⚠";}.emh-modal{display:flex;align-items:center;justify-content:center;position:fixed;z-index:9998;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgba(0,0,0,0.7);opacity:0;transition:opacity 0.3s ease;backdrop-filter:blur(3px);}.emh-modal.show{opacity:1;}.emh-modal-content{background-color:#fff;margin:auto;padding:0;border-radius:12px;width:90%;max-width:650px;box-shadow:0 8px 24px rgba(0,0,0,0.4);transform:scale(0.9);transition:transform 0.3s ease;overflow:hidden;}.emh-modal.show .emh-modal-content{transform:scale(1);}.emh-modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 24px;background-color:#f8f9fa;border-bottom:1px solid var(--emh-border-color);}.emh-modal-header h3{margin:0;color:var(--emh-dark-text);font-size:18px;font-weight:500;}.emh-modal-close{color:#5f6368;font-size:28px;font-weight:bold;cursor:pointer;line-height:1;padding:0 5px;transition:color 0.2s ease;}.emh-modal-close:hover{color:#202124;}.emh-modal-body{padding:20px 24px;max-height:65vh;overflow-y:auto;background-color:#fff;}.emh-no-subtitle-message{text-align:center;color:#5f6368;font-size:16px;padding:36px 0;}.emh-subtitle-list{list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:14px;}.emh-subtitle-item{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;padding:16px;background-color:#f8f9fa;border-radius:8px;border:1px solid #e8eaed;gap:12px;transition:transform 0.2s ease,box-shadow 0.2s ease;}.emh-subtitle-item:hover{transform:translateY(-2px);box-shadow:0 4px 8px rgba(0,0,0,0.1);}.emh-subtitle-info{flex:1 1 auto;min-width:200px;}.emh-subtitle-info h4{margin:0 0 8px 0;color:var(--emh-dark-text);font-size:16px;font-weight:500;}.emh-subtitle-info p{margin:3px 0;color:#5f6368;font-size:14px;line-height:1.5;}.emh-subtitle-item .emh-button-download{flex-shrink:0;}.emh-modal-body::-webkit-scrollbar{width:10px;}.emh-modal-body::-webkit-scrollbar-track{background:#f1f1f1;border-radius:5px;}.emh-modal-body::-webkit-scrollbar-thumb{background:#dadce0;border-radius:5px;border:2px solid #f1f1f1;}.emh-modal-body::-webkit-scrollbar-thumb:hover{background:#bdc1c6;}@media (max-width:768px){.emh-ui-container{padding:12px 6px 6px;gap:8px;}.emh-button{padding:6px 12px;font-size:13px;}.emh-modal-content{width:95%;}.emh-subtitle-item{padding:12px;}} `; document.head.appendChild(style); } // 初始化函数 (Replaces setTimeout with DOM readiness check and element waiting) function initialize() { addCustomStyles(); // Add styles immediately // Wait for the DOM to be at least interactive before running main logic if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', main); } else { main(); // DOM already loaded or interactive } console.log("EMH Initialized"); } // --- Script Execution --- initialize(); })();