// ==UserScript==
// @name Reddit Media Zoom on Hover (v3.1 Fix Attempt - VI: Phóng to ảnh/video)
// @namespace http://tampermonkey.net/
// @version 3.1
// @description Zooms images, GIFs, and videos when hovering on old.reddit.com and www.reddit.com. (Fix attempt)
// @author ChatGPT & Bạn
// @match *://old.reddit.com/*
// @match *://www.reddit.com/*
// @grant none
// @license MIT
// ==/UserScript==
(function() {
'use strict';
let zoomContainer = null;
let zoomImageElement = null;
let zoomVideoElement = null;
let hoverTimeout = null;
let currentMouseEvent = null;
let currentMediaInfo = null; // Lưu thông tin media đang hiển thị/chờ hiển thị
const DELAY_MS = 150; // Giảm nhẹ delay xem có giúp không
const OFFSET_X = 15;
const OFFSET_Y = 15;
const IMAGE_EXTENSIONS_REGEX = /\.(jpg|jpeg|png|gif|webp)([\?#].*)?$/i;
const VIDEO_EXTENSIONS_REGEX = /\.(mp4|webm)([\?#].*)?$/i;
const VIDEO_DOMAIN_REGEX = /v\.redd\.it/i;
const GIFV_REGEX = /\.gifv([\?#].*)?$/i;
// --- Tạo các phần tử hiển thị (Không thay đổi nhiều) ---
function createZoomElements() {
if (zoomContainer) return;
// (Giữ nguyên code tạo container, img, video như v3.0)
zoomContainer = document.createElement('div');
zoomContainer.id = 'userscript-media-zoom-popup';
zoomContainer.style.position = 'fixed';
zoomContainer.style.border = '2px solid #ccc';
zoomContainer.style.borderRadius = '4px';
zoomContainer.style.boxShadow = '3px 3px 10px rgba(0,0,0,0.5)';
zoomContainer.style.zIndex = '99999';
zoomContainer.style.display = 'none';
zoomContainer.style.pointerEvents = 'none';
zoomContainer.style.backgroundColor = 'black';
zoomContainer.style.maxWidth = '85vw';
zoomContainer.style.maxHeight = '85vh';
zoomContainer.style.overflow = 'hidden';
zoomImageElement = document.createElement('img');
zoomImageElement.style.display = 'none'; // Bắt đầu ẩn
zoomImageElement.style.maxWidth = '100%';
zoomImageElement.style.maxHeight = 'calc(85vh - 4px)';
zoomImageElement.style.width = 'auto';
zoomImageElement.style.height = 'auto';
zoomImageElement.style.objectFit = 'contain';
zoomImageElement.style.verticalAlign = 'bottom';
zoomImageElement.onload = () => { if (zoomContainer.style.display === 'block' && currentMouseEvent) positionZoomElement(currentMouseEvent); };
zoomImageElement.onerror = () => { console.warn("Zoom Error: Failed to load image", zoomImageElement.src); hideZoom(); };
zoomVideoElement = document.createElement('video');
zoomVideoElement.style.display = 'none'; // Bắt đầu ẩn
zoomVideoElement.style.maxWidth = '100%';
zoomVideoElement.style.maxHeight = 'calc(85vh - 4px)';
zoomVideoElement.style.width = 'auto';
zoomVideoElement.style.height = 'auto';
zoomVideoElement.style.objectFit = 'contain';
zoomVideoElement.style.verticalAlign = 'bottom';
zoomVideoElement.muted = true;
zoomVideoElement.loop = true;
zoomVideoElement.autoplay = true;
zoomVideoElement.playsInline = true;
zoomVideoElement.onloadeddata = () => { if (zoomContainer.style.display === 'block' && currentMouseEvent) positionZoomElement(currentMouseEvent); zoomVideoElement.play().catch(()=>{}); };
zoomVideoElement.onerror = () => { console.warn("Zoom Error: Failed to load video", zoomVideoElement.src); hideZoom(); };
zoomContainer.appendChild(zoomImageElement);
zoomContainer.appendChild(zoomVideoElement);
document.body.appendChild(zoomContainer);
console.log("Zoom elements created."); // Log khi tạo xong
}
// --- Định vị (Không thay đổi) ---
function positionZoomElement(event) {
if (!zoomContainer || zoomContainer.style.display === 'none' || !event) return;
const vw = window.innerWidth;
const vh = window.innerHeight;
const mouseX = event.clientX;
const mouseY = event.clientY;
const containerWidth = zoomContainer.offsetWidth;
const containerHeight = zoomContainer.offsetHeight;
// Tạm thời bỏ qua định vị nếu kích thước chưa có (chờ load)
if (containerWidth === 0 || containerHeight === 0) return;
let newX = mouseX + OFFSET_X;
let newY = mouseY + OFFSET_Y;
if (newX + containerWidth > vw - OFFSET_X) { newX = mouseX - containerWidth - OFFSET_X; if (newX < OFFSET_X) newX = OFFSET_X; }
if (newY + containerHeight > vh - OFFSET_Y) { newY = mouseY - containerHeight - OFFSET_Y; if (newY < OFFSET_Y) newY = OFFSET_Y; }
zoomContainer.style.left = newX + 'px';
zoomContainer.style.top = newY + 'px';
}
// --- Lấy URL và loại media (Giữ nguyên logic, nhưng thêm log) ---
function getMediaInfo(targetElement) {
// console.log("getMediaInfo for:", targetElement); // Log đầu vào
const hostname = window.location.hostname;
let sourceUrl = null;
let mediaType = 'image';
try {
// (Logic tìm sourceUrl như v3.0)
// --- Logic cho old.reddit.com ---
if (hostname === 'old.reddit.com') {
const linkElement = targetElement.closest('a.thumbnail');
if (linkElement && linkElement.href) {
sourceUrl = linkElement.href;
if (!IMAGE_EXTENSIONS_REGEX.test(sourceUrl) && !VIDEO_EXTENSIONS_REGEX.test(sourceUrl) && !GIFV_REGEX.test(sourceUrl)) {
const postLink = targetElement.closest('.thing')?.querySelector('a.title')?.href;
if (postLink && postLink.includes('/comments/')) return null;
// Nếu không phải link media đã biết, thử giả định là ảnh nếu là link trực tiếp
if (!sourceUrl.includes('/comments/') && !sourceUrl.includes('/gallery/')) {
// Keep sourceUrl, maybe it's an image without extension? Risky.
} else {
return null; // Là link post/gallery
}
}
}
}
// --- Logic cho www.reddit.com ---
else {
let parentLinkHref = null;
const parentLink = targetElement.closest('a[href]');
if (parentLink) parentLinkHref = parentLink.href;
// 1. Ưu tiên link trực tiếp từ thẻ A cha (không phải link comment/gallery)
if (parentLinkHref && !parentLinkHref.includes('/comments/') && !parentLinkHref.includes('/gallery/')) {
if (IMAGE_EXTENSIONS_REGEX.test(parentLinkHref) || VIDEO_EXTENSIONS_REGEX.test(parentLinkHref) || GIFV_REGEX.test(parentLinkHref)) {
sourceUrl = parentLinkHref;
}
}
// 2. Thử lấy từ src của IMG nếu target là IMG và chưa có URL
if (!sourceUrl && targetElement.tagName === 'IMG' && targetElement.src && !targetElement.src.startsWith('data:')) {
if (targetElement.naturalWidth > 10 || targetElement.naturalHeight > 10 || targetElement.src.includes('redd.it') || targetElement.src.includes('imgur.com')) {
sourceUrl = targetElement.src;
}
}
// 3. Thử lấy từ background-image nếu target là DIV
if (!sourceUrl && targetElement.tagName === 'DIV' && targetElement.style.backgroundImage.includes('url(')) {
const style = targetElement.style.backgroundImage;
const match = style.match(/url\("?([^"\)]+)"?\)/);
if (match && match[1] && !match[1].startsWith('data:')) {
sourceUrl = match[1];
}
}
// 4. Tìm media lớn hơn trong post container nếu URL hiện tại là preview
if (sourceUrl && (sourceUrl.includes('preview.redd.it') || sourceUrl.includes('styles.redditmedia'))) {
const postContainer = targetElement.closest('[data-testid="post-container"], .scrollerItem, [data-testid="post-content"]');
if (postContainer) {
const videoPlayer = postContainer.querySelector('shreddit-player, video[src]');
if (videoPlayer) {
let videoSrc = videoPlayer.getAttribute('src'); // Thử lấy src trực tiếp
if(!videoSrc && videoPlayer.tagName === 'VIDEO') videoSrc = videoPlayer.currentSrc || videoPlayer.src;
// Lấy từ source tag nếu không có src trực tiếp
if (!videoSrc && videoPlayer.tagName.toLowerCase() === 'shreddit-player') {
const sources = videoPlayer.querySelectorAll('source[src]');
for(const source of sources) { if (source.src.endsWith('.mp4')) { videoSrc = source.src; break; } }
if (!videoSrc && sources.length > 0) videoSrc = sources[0].src;
}
if (videoSrc && (VIDEO_EXTENSIONS_REGEX.test(videoSrc) || VIDEO_DOMAIN_REGEX.test(videoSrc))) {
sourceUrl = videoSrc;
}
}
// Tìm ảnh chính nếu không tìm thấy video hợp lệ
if (!sourceUrl || !(VIDEO_EXTENSIONS_REGEX.test(sourceUrl) || VIDEO_DOMAIN_REGEX.test(sourceUrl))) {
const mainImage = postContainer.querySelector('img[alt="Post image"], img[data-testid="post-image"], .media-element img');
if (mainImage && mainImage.src && !mainImage.src.startsWith('data:') && !mainImage.src.includes('blur=')) {
if (!mainImage.src.includes('preview.redd.it') || sourceUrl.includes('styles.redditmedia')) {
sourceUrl = mainImage.src;
}
}
}
}
}
// 5. Fallback: Kiểm tra lại parentLinkHref
if (!sourceUrl && parentLinkHref && !parentLinkHref.includes('/comments/') && !parentLinkHref.includes('/gallery/')) {
if (IMAGE_EXTENSIONS_REGEX.test(parentLinkHref) || VIDEO_EXTENSIONS_REGEX.test(parentLinkHref) || GIFV_REGEX.test(parentLinkHref)) {
sourceUrl = parentLinkHref;
}
}
} // End logic www.reddit.com
// --- Xử lý URL tìm được ---
if (sourceUrl && sourceUrl.startsWith('http')) {
sourceUrl = sourceUrl.replace(/&/g, '&');
if (GIFV_REGEX.test(sourceUrl)) {
mediaType = 'video';
sourceUrl = sourceUrl.replace(GIFV_REGEX, '.mp4');
} else if (VIDEO_EXTENSIONS_REGEX.test(sourceUrl)) {
mediaType = 'video';
} else if (VIDEO_DOMAIN_REGEX.test(sourceUrl)) {
mediaType = 'video';
if (!sourceUrl.includes('/DASH_') && !sourceUrl.includes('m3u8') && !sourceUrl.endsWith('.mp4') && !sourceUrl.endsWith('.webm')) {
console.log("Unsupported v.redd.it link:", sourceUrl); return null;
}
} else if (IMAGE_EXTENSIONS_REGEX.test(sourceUrl)) {
mediaType = 'image';
} else if (sourceUrl.includes('external-preview.redd.it')) {
try {
const urlObj = new URL(sourceUrl);
const externalUrl = urlObj.searchParams.get('url');
if (externalUrl) {
sourceUrl = decodeURIComponent(externalUrl);
if (VIDEO_EXTENSIONS_REGEX.test(sourceUrl)) mediaType = 'video';
else if (IMAGE_EXTENSIONS_REGEX.test(sourceUrl)) mediaType = 'image';
else return null;
} else { return null; }
} catch(e) { return null; }
} else {
// Nếu không khớp đuôi file nào, và không phải preview, thử giả định là ảnh
if (!sourceUrl.includes('/comments/') && !sourceUrl.includes('/gallery/') && !sourceUrl.includes('reddit.com/user/')) {
mediaType = 'image'; // Thử coi là ảnh
console.log("Assuming image type for:", sourceUrl);
} else {
// console.log("URL type unknown or excluded:", sourceUrl);
return null;
}
}
// --- Dọn dẹp URL ---
if (mediaType === 'image' && sourceUrl.includes('preview.redd.it')) {
try {
const urlObj = new URL(sourceUrl);
const pathSegments = urlObj.pathname.split('/');
const imageIdWithExt = pathSegments[pathSegments.length - 1];
if (imageIdWithExt && imageIdWithExt.includes('.')) {
sourceUrl = `https://i.redd.it/${imageIdWithExt}`;
}
} catch(e) {/* ignore */}
}
if (mediaType === 'image' && (sourceUrl.includes('//i.redd.it/') || sourceUrl.includes('//i.imgur.com/'))) {
sourceUrl = sourceUrl.split('?')[0];
}
// console.log("Found media:", { url: sourceUrl, type: mediaType }); // Log kết quả
return { url: sourceUrl, type: mediaType };
}
} catch (error) {
console.error("Zoom Error in getMediaInfo:", error, "Element:", targetElement);
}
// console.log("No valid media found."); // Log nếu không tìm thấy
return null;
}
// --- Hàm hiển thị media zoom (Đơn giản hóa logic) ---
function showZoom(event, targetElement) {
// Hủy timeout cũ ngay lập tức
clearTimeout(hoverTimeout);
// console.log("showZoom called for:", targetElement); // Log
const mediaInfo = getMediaInfo(targetElement);
// console.log("Media info received:", mediaInfo); // Log
if (mediaInfo) {
createZoomElements(); // Đảm bảo element tồn tại
// Lưu thông tin media sẽ hiển thị và event để định vị
currentMediaInfo = mediaInfo;
currentMouseEvent = event;
// Đặt timeout để hiển thị
hoverTimeout = setTimeout(() => {
// Kiểm tra lại xem currentMediaInfo có còn là cái này không
// (Phòng trường hợp di chuột quá nhanh, chỉ hiện cái cuối cùng)
if (currentMediaInfo !== mediaInfo) {
// console.log("Hover changed before timeout, aborting display for:", mediaInfo.url);
return;
}
// console.log(`Timeout: Displaying ${mediaInfo.type}:`, mediaInfo.url); // Log
// Reset cả hai element trước
zoomImageElement.style.display = 'none';
zoomImageElement.src = '';
zoomVideoElement.style.display = 'none';
if (!zoomVideoElement.paused) zoomVideoElement.pause();
zoomVideoElement.removeAttribute('src'); // Dùng removeAttribute tốt hơn là src = '' cho video
// Set src và hiển thị element tương ứng
if (mediaInfo.type === 'image') {
zoomImageElement.src = mediaInfo.url;
zoomImageElement.style.display = 'block';
} else if (mediaInfo.type === 'video') {
zoomVideoElement.src = mediaInfo.url;
zoomVideoElement.style.display = 'block';
// Thử play lại, catch lỗi nếu có
zoomVideoElement.play().catch(e => console.warn("Video play() failed:", e));
}
// Hiển thị container và định vị
zoomContainer.style.display = 'block';
positionZoomElement(currentMouseEvent); // Định vị dùng event đã lưu
}, DELAY_MS);
} else {
// Nếu không có media info, đảm bảo không có gì đang chờ hiển thị
currentMediaInfo = null; // Xóa media đang chờ (nếu có)
// Không cần gọi hideZoom ở đây vì có thể người dùng chỉ lướt qua vùng không có ảnh
}
}
// --- Hàm ẩn media zoom (Đơn giản hóa) ---
function hideZoom() {
// console.log("hideZoom called"); // Log
clearTimeout(hoverTimeout); // Luôn xóa timeout
currentMediaInfo = null; // Xóa media đang chờ/hiển thị
currentMouseEvent = null; // Xóa event
if (zoomContainer && zoomContainer.style.display !== 'none') {
// console.log("Hiding zoom container"); // Log
zoomContainer.style.display = 'none';
zoomImageElement.src = '';
zoomImageElement.style.display = 'none';
if (!zoomVideoElement.paused) zoomVideoElement.pause();
zoomVideoElement.removeAttribute('src');
zoomVideoElement.load(); // Yêu cầu trình duyệt dừng tải thêm data
zoomVideoElement.style.display = 'none';
}
}
// --- Các bộ chọn CSS (Giữ nguyên) ---
const targetSelectors = [
'a.thumbnail img', 'a.thumbnail[href*=".gif"]', 'a.thumbnail[href*=".mp4"]', 'a.thumbnail[href*=".webm"]', 'a.thumbnail[href*=".gifv"]',
'img[alt="Post image"]', 'img[data-testid="post-image"]', 'div[data-testid="post-container"] .media-element', 'a[data-testid="post-image-container"] img', 'div[data-testid="post-image-container"] > div[style*="background-image"]',
'shreddit-player', 'video[data-testid="video-player"]', 'video.media-element',
'div[role="img"] img', 'img[data-event-action="thumbnail"]', 'div[data-click-id="media"] > div[style*="background-image"]', 'a[data-testid="external-link"] > div[style*="background-image"]',
'figure[data-testid="image-widget"] img', 'figure[data-testid="video-widget"] video',
].join(', ');
// --- Event Listeners (Điều chỉnh nhẹ) ---
document.body.addEventListener('mouseover', (event) => {
const targetElement = event.target;
const matchingElement = targetElement.closest(targetSelectors);
if (matchingElement) {
// Gọi showZoom với event và phần tử khớp được
// Logic xử lý chờ và hiển thị sẽ nằm trong showZoom
showZoom(event, matchingElement);
}
// Không cần else ở đây, mouseout sẽ xử lý việc ẩn khi rời khỏi
});
document.body.addEventListener('mouseout', (event) => {
const targetElement = event.target;
const relatedTarget = event.relatedTarget; // Element chuột di chuyển TỚI
// Tìm xem có phải đang rời khỏi một thumbnail/media hợp lệ không
const matchingElement = targetElement.closest(targetSelectors);
if (matchingElement) {
// Chỉ ẩn NẾU chuột di ra ngoài HOÀN TOÀN khỏi matchingElement VÀ KHÔNG đi vào popup
// (Popup có pointer-events: none nên kiểm tra popup không cần thiết)
if (!relatedTarget || !matchingElement.contains(relatedTarget)) {
// console.log("Mouseout detected, hiding zoom."); // Log
hideZoom();
}
} else {
// Nếu di chuột ra khỏi một phần tử KHÔNG phải là targetSelectors
// và chuột KHÔNG đi vào một targetSelectors khác, thì cũng nên ẩn
if (!relatedTarget || !relatedTarget.closest(targetSelectors)) {
// console.log("Mouseout from non-target, hiding zoom."); // Log
hideZoom();
}
}
});
document.body.addEventListener('mousemove', (event) => {
// Chỉ định vị lại nếu popup đang hiển thị
if (zoomContainer && zoomContainer.style.display === 'block') {
// Cập nhật mouse event để hàm position dùng tọa độ mới nhất
currentMouseEvent = event;
positionZoomElement(event);
}
});
console.log("Reddit Media Zoom script (v3.1 Fix Attempt) loaded.");
// Tạo sẵn element khi script load để tránh delay lần đầu
createZoomElements();
})();