// ==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();
})();