On-hover Steam thumbnail, description, Steam Ratings, user-defined tags (same as Steam store page), release date, and a direct "Open on Steam" link for 1337x game torrent titles
// ==UserScript==
// @name 1337x - Steam Hover Preview
// @namespace https://greasyfork.org/en/users/1340389-deonholo
// @version 3.4.0
// @description On-hover Steam thumbnail, description, Steam Ratings, user-defined tags (same as Steam store page), release date, and a direct "Open on Steam" link for 1337x game torrent titles
// @icon https://greasyfork.s3.us-east-2.amazonaws.com/x432yc9hx5t6o2gbe9ccr7k5l6u8
// @author DeonHolo
// @license MIT
// @match *://*.1337x.to/*
// @match *://*.1337x.ws/*
// @match *://*.1337x.is/*
// @match *://*.1337x.gd/*
// @match *://*.x1337x.cc/*
// @match *://*.1337x.st/*
// @match *://*.x1337x.ws/*
// @match *://*.1337x.eu/*
// @match *://*.1337x.se/*
// @match *://*.x1337x.eu/*
// @match *://*.x1337x.se/*
// @match http://l337xdarkkaqfwzntnfk5bmoaroivtl6xsbatabvlb52umg6v3ch44yd.onion/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @connect store.steampowered.com
// @connect steamcdn-a.akamaihd.net
// @run-at document-idle
// ==/UserScript==
(() => {
'use strict';
const tip = document.createElement('div');
tip.className = 'steamHoverTip';
const SEL = 'table.torrent-list td.name a[href^="/torrent/"], table.torrents td.name a[href^="/torrent/"], table.table-list td.name a[href^="/torrent/"]';
const MIN_INTERVAL = 50;
const MAX_CACHE = 100;
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours for persistent cache
const MEMORY_CACHE_TTL = 15 * 60 * 1000; // 15 min for in-memory during session
const HIDE_DELAY = 100;
const FADE_DURATION = 200;
const API_TIMEOUT = 8000;
const SHOW_DELAY = 0;
const STORAGE_KEY = 'steamHoverCache_v1';
const CONCURRENT_HIDDEN = 4; // Fetch 4 games at once when tab is hidden
// Flag to pause preloading when user is actively hovering
let userHovering = false;
let isPageHidden = document.hidden || false;
// Page Visibility API - detect when user leaves/returns
document.addEventListener('visibilitychange', () => {
isPageHidden = document.hidden;
if (isPageHidden) {
console.log('[Steam Hover] Tab hidden - enabling fast preload mode');
} else {
console.log('[Steam Hover] Tab visible - switching to normal mode');
}
});
// Persistent cache: Load from storage on init
function loadPersistentCache() {
try {
const stored = GM_getValue(STORAGE_KEY, null);
if (stored) {
const parsed = JSON.parse(stored);
const now = Date.now();
let loaded = 0;
for (const [key, value] of Object.entries(parsed)) {
// Only load if not expired
if (value.ts && (now - value.ts) < CACHE_TTL) {
apiCache.set(key, value);
loaded++;
}
}
console.log(`[Steam Hover] Loaded ${loaded} cached games from storage`);
}
} catch (e) {
console.warn('[Steam Hover] Failed to load persistent cache:', e);
}
}
// Persistent cache: Save to storage (debounced)
let saveTimeout = null;
function savePersistentCache() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
try {
const obj = {};
const now = Date.now();
for (const [key, value] of apiCache.entries()) {
// Only save valid entries that aren't expired
if (value.data && value.ts && (now - value.ts) < CACHE_TTL) {
obj[key] = value;
}
}
GM_setValue(STORAGE_KEY, JSON.stringify(obj));
} catch (e) {
console.warn('[Steam Hover] Failed to save persistent cache:', e);
}
}, 1000); // Debounce saves by 1 second
}
// Concurrent fetch helper for hidden tab mode
async function fetchBatch(names) {
const promises = names.map(name =>
fetchSteam(name).catch(() => null)
);
await Promise.all(promises);
}
async function preloadAll() {
const links = Array.from(document.querySelectorAll(SEL));
const toFetch = [];
for (const link of links) {
const name = cleanName(link.textContent);
if (name && !apiCache.has(name)) {
toFetch.push(name);
}
}
// Remove duplicates
const uniqueNames = [...new Set(toFetch)];
console.log(`[Steam Hover] Preloading ${uniqueNames.length} games...`);
let i = 0;
while (i < uniqueNames.length) {
// Pause preloading if user is hovering
while (userHovering && !isPageHidden) {
await new Promise(r => setTimeout(r, 200));
}
if (isPageHidden) {
// Fast mode: fetch multiple games concurrently
const batch = uniqueNames.slice(i, i + CONCURRENT_HIDDEN);
await fetchBatch(batch);
i += CONCURRENT_HIDDEN;
await new Promise(r => setTimeout(r, MIN_INTERVAL)); // Brief pause between batches
} else {
// Normal mode: fetch one at a time
await fetchSteam(uniqueNames[i]).catch(() => { });
i++;
await new Promise(r => setTimeout(r, MIN_INTERVAL * 2));
}
}
console.log(`[Steam Hover] Preloading complete!`);
}
// Start preloading after page is idle
window.addEventListener('load', () => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => preloadAll(), { timeout: 3000 });
} else {
setTimeout(preloadAll, 2000);
}
});
GM_addStyle(`
.steamHoverTip {
position: absolute;
padding: 8px;
background: rgba(240, 240, 240, 0.97);
border: 1px solid #555;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
z-index: 2147483647;
max-width: 310px;
font-size: 12px;
line-height: 1.45;
display: none;
white-space: normal !important;
overflow-wrap: break-word;
color: #111;
opacity: 0;
transition: opacity ${FADE_DURATION}ms ease-in-out;
pointer-events: none;
}
.steamHoverTip p {
margin: 0 0 5px 0;
padding: 0;
}
.steamHoverTip p:last-child {
margin-bottom: 0;
}
.steamHoverTip img {
display: block;
width: 100%;
margin-bottom: 8px;
border-radius: 2px;
}
.steamHoverTip strong {
color: #000;
}
.steamHoverTip .steamRating,
.steamHoverTip .steamTags,
.steamHoverTip .steamReleaseDate {
margin-top: 8px;
font-size: 12px;
color: #333;
}
.steamHoverTip .steamReleaseDate {
margin-top: 2px;
font-size: 11px;
color: #555;
}
.steamHoverTip .steamTags strong,
.steamHoverTip .steamRating strong {
color: #111;
margin-right: 4px;
}
.steamHoverTip .ratingStars {
color: #f5c518;
margin-right: 6px;
letter-spacing: 1px;
font-size: 14px;
display: inline-block;
vertical-align: middle;
}
.steamHoverTip .ratingText {
vertical-align: middle;
}
.steamHoverTip a {
color: #0645ad;
text-decoration: underline;
cursor: pointer;
}
.steamHoverTip .loadingContainer {
display: flex;
align-items: center;
gap: 8px;
}
.steamHoverTip .spinner {
width: 18px;
height: 18px;
border: 2px solid #ddd;
border-top-color: #1b2838;
border-radius: 50%;
animation: steamSpinner 0.8s linear infinite;
flex-shrink: 0;
}
@keyframes steamSpinner {
to { transform: rotate(360deg); }
}
/* Magnet Download Button in Tooltip - matches existing link style */
.steamHoverTip .magnetDownloadBtn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 0;
margin: 0;
border: none;
background: none;
color: #66c0f4;
font-size: 12px;
cursor: pointer;
text-decoration: underline;
font-family: inherit;
}
.steamHoverTip .magnetDownloadBtn:hover {
color: #fff;
}
.steamHoverTip .magnetDownloadBtn.loading {
pointer-events: none;
opacity: 0.7;
text-decoration: none;
}
.steamHoverTip .tipActions {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 8px;
padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.1);
}
`);
const apiCache = new Map();
loadPersistentCache(); // Load cached data from previous sessions
let lastRequest = 0;
let hoverId = 0;
let showTimeout = null;
let hideTimeout = null;
let displayTimeout = null;
let currentFetch = null;
let trackingMove = false;
let lastMoveEvent = null;
let currentHoveredLink = null;
document.body.appendChild(tip);
function pruneCache(map) {
if (map.size > MAX_CACHE) {
map.delete(map.keys().next().value);
}
}
function getRatingStars(percent, desc) {
const filled = '★';
const empty = '☆';
const p = parseInt(percent, 10);
let stars = '';
if (!isNaN(p)) {
if (p >= 95) stars = filled.repeat(5);
else if (p >= 80) stars = filled.repeat(4) + empty;
else if (p >= 70) stars = filled.repeat(3) + empty.repeat(2);
else if (p >= 40) stars = filled.repeat(2) + empty.repeat(3);
else if (p >= 20) stars = filled + empty.repeat(4);
else stars = empty.repeat(5);
} else if (desc) {
const d = desc.toLowerCase();
if (d.includes('overwhelmingly positive')) stars = filled.repeat(5);
else if (d.includes('very positive')) stars = filled.repeat(4) + empty;
else if (d.includes('mostly positive')) stars = filled.repeat(4) + empty;
else if (d.includes('positive')) stars = filled.repeat(4) + empty;
else if (d.includes('mixed')) stars = filled.repeat(3) + empty.repeat(2);
else if (d.includes('mostly negative')) stars = filled.repeat(2) + empty.repeat(3);
else if (d.includes('negative')) stars = filled + empty.repeat(4);
else if (d.includes('very negative')) stars = filled + empty.repeat(4);
else if (d.includes('overwhelmingly negative')) stars = filled + empty.repeat(4);
}
return stars ? `<span class="ratingStars">${stars}</span>` : '';
}
function cleanName(raw) {
// Early exclusions for non-games
if (/soundtrack|ost|demo|dlc pack|artbook|season pass|multiplayer crack|trainer/i.test(raw)) {
return null;
}
let name = raw.trim();
// Remove bracketed prefixes like [Bober Bros], [FitGirl], etc. at the START
name = name.replace(/^\[[^\]]*\]\s*/g, '');
// Normalize separators: dots and underscores to spaces
name = name.replace(/[._]/g, ' ');
// Remove common technical suffixes
name = name.replace(/\s+(x64|x86|64bit|32bit|64-bit|32-bit)\b/gi, '');
name = name.replace(/\s+MULTI\d*\b/gi, '');
name = name.replace(/\s+(incl|incl\.|including)\s+.*/gi, '');
// Strip years and season/episode markers
name = name.replace(/\(\d{4}\)/, '').replace(/S\d{1,2}(E\d{1,2})?/, '').trim();
// Remove bracketed content with known group/repack patterns
name = name.replace(/\[[^\]]*(?:Repack|FitGirl|DODI|ElAmigos|GOG|P2P|ISO)\][^\]]*$/gi, '').trim();
// Split on version/build/technical markers AND brackets/parentheses
const delim = /(?:\s-\s|\(|\[|\bUpdate\b|\bBuild\b|\bHotfix\b|\bPatch\b|v\d[\d.]*|v\s+\d|\bCrack\b|\bFixed?\b|\bLinux\b|\bMac\b|\bMacOS\b|\bWindows\b|\bPortable\b|\bREPACK\b|\bRIP\b)/i;
name = name.split(delim)[0].trim();
// Expanded scene group removal (at end of name)
const sceneGroups = /\s*[-\s](CODEX|CPY|SKIDROW|PLAZA|HOODLUM|FLT|DOGE|DARKSiDERS|EMPRESS|RUNE|TENOKE|TiNYiSO|ElAmigos|FitGirl|DODI|RAZOR1911|RELOADED|PROPHET|FAIRLIGHT|GOG|P2P|STEAM|STEAMPUNKS|3DM|ALI213|ANOMALY|KAOS|REVOLT|SiMPLEX|ISO|elamigos|Bober\s*Bros)$/i;
name = name.replace(sceneGroups, '').trim();
// Remove "The", "Sid Meier's", etc. ONLY if > 3 words remain
const words = name.split(/\s+/);
if (words.length > 3) {
name = name.replace(/^(The|Sid Meier'?s|Tom Clancy'?s)\s+/i, '').trim();
}
// Clean up extra whitespace
name = name.replace(/\s{2,}/g, ' ').trim();
return name.length >= 2 ? name : null;
}
function gmFetch(url, responseType = 'json', timeout = API_TIMEOUT) {
const wait = Math.max(0, MIN_INTERVAL - (Date.now() - lastRequest));
return new Promise(resolve => setTimeout(resolve, wait))
.then(() => new Promise((resolve, reject) => {
lastRequest = Date.now();
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: responseType,
timeout: timeout,
headers: {
'Accept-Language': 'en-US,en;q=0.9'
},
onload: (res) => {
if (res.status >= 200 && res.status < 300) {
if (responseType === 'json') {
if (typeof res.response === 'object' && res.response !== null) {
resolve(res.response);
} else {
try {
resolve(JSON.parse(res.responseText));
} catch (e) {
console.error(`JSON parse error for ${url}:`, e, res.responseText);
reject(new Error(`JSON parse error for ${url}`));
}
}
} else {
resolve(res.response || res.responseText);
}
} else {
console.warn(`HTTP ${res.status} for ${url}`);
reject(new Error(`HTTP ${res.status} for ${url}`));
}
},
onerror: (err) => {
console.error(`Network error for ${url}:`, err);
reject(new Error(`Network error for ${url}: ${err.statusText || err.error || 'Unknown'}`));
},
ontimeout: () => {
console.warn(`Timeout ${timeout}ms for ${url}`);
reject(new Error(`Timeout ${timeout}ms for ${url}`));
},
onabort: () => {
console.warn(`Aborted request for ${url}`);
reject(new Error(`Aborted request for ${url}`));
}
});
}));
}
// Fallback strategy: progressively remove words from the end
async function fetchSteamWithFallback(originalName) {
const words = originalName.split(/\s+/);
// Try progressively shorter versions (full name, then remove 1 word, 2 words, etc.)
// Stop when we have at least 2 words remaining
for (let i = 0; i <= Math.min(words.length - 2, 3); i++) {
const tryName = words.slice(0, words.length - i).join(' ');
if (tryName.length < 2) continue;
const result = await fetchSteam(tryName);
if (result) return result;
}
return null;
}
async function fetchSteam(name) {
const now = Date.now();
const hit = apiCache.get(name);
if (hit && now - hit.ts < CACHE_TTL) {
return hit.data;
}
let appId = null;
// First: Search for the game
try {
const searchUrl = `https://store.steampowered.com/api/storesearch/?cc=us&l=en&term=${encodeURIComponent(name)}`;
const searchRes = await gmFetch(searchUrl, 'json');
let result = searchRes?.items?.[0];
if (searchRes?.items?.length > 1) {
const exactMatch = searchRes.items.find(item => item.name.toLowerCase() === name.toLowerCase());
if (exactMatch) {
result = exactMatch;
}
}
appId = result?.id;
if (!appId) {
throw new Error('No suitable AppID found in search results.');
}
} catch (err) {
console.warn(`Steam search failed for "${name}":`, err.message);
apiCache.set(name, { data: null, ts: now });
pruneCache(apiCache);
return null;
}
// Second: Fetch details and reviews in parallel for speed
let reviewInfo = null;
let appData = null;
try {
const detailsUrl = `https://store.steampowered.com/api/appdetails?appids=${appId}&cc=us&l=en`;
const reviewUrl = `https://store.steampowered.com/appreviews/${appId}?json=1&language=all&purchase_type=all&filter=summary`;
const [detailsRes, reviewRes] = await Promise.all([
gmFetch(detailsUrl, 'json').catch(() => null),
gmFetch(reviewUrl, 'json').catch(() => null)
]);
if (detailsRes?.[appId]?.success) {
appData = detailsRes[appId].data;
} else {
throw new Error('Failed to fetch app details or API indicated failure.');
}
if (reviewRes?.success && reviewRes.query_summary) {
const summary = reviewRes.query_summary;
const percent = summary.total_reviews ? Math.round((summary.total_positive / summary.total_reviews) * 100) : null;
reviewInfo = {
desc: summary.review_score_desc || 'No Reviews',
percent: percent,
total: summary.total_reviews || 0
};
}
} catch (err) {
console.warn(`Steam details/reviews fetch failed for AppID ${appId}:`, err.message);
if (!appData) {
apiCache.set(name, { data: null, ts: now });
pruneCache(apiCache);
return null;
}
}
// Third: Get user-defined tags by scraping the Steam store page
let tags = [];
if (appData) {
try {
const storePageUrl = `https://store.steampowered.com/app/${appId}/`;
const storeHtml = await gmFetch(storePageUrl, 'text');
if (storeHtml) {
const parser = new DOMParser();
const doc = parser.parseFromString(storeHtml, 'text/html');
const tagElements = doc.querySelectorAll('a.app_tag');
tags = Array.from(tagElements)
.map(el => el.textContent.trim())
.filter(tag => tag && tag !== '+')
.slice(0, 5);
}
} catch (tagErr) {
console.warn(`[Steam Hover] Failed to fetch tags for AppID ${appId}:`, tagErr.message);
// Fallback to genres if tag scraping fails
tags = (appData.genres || []).map(g => g.description).slice(0, 5);
}
}
const data = {
...appData,
tags: tags,
reviewInfo: reviewInfo,
releaseDate: appData.release_date?.date || null,
storeUrl: `https://store.steampowered.com/app/${appId}/`
};
apiCache.set(name, { data: data, ts: now });
pruneCache(apiCache);
savePersistentCache(); // Save to storage for future sessions
return data;
}
function positionTip(ev) {
if (!tip) return;
let x = ev.pageX + 15;
let y = ev.pageY + 15;
const tipWidth = tip.offsetWidth;
const tipHeight = tip.offsetHeight;
const margin = 10;
const scrollX = window.scrollX || window.pageXOffset;
const scrollY = window.scrollY || window.pageYOffset;
const viewWidth = window.innerWidth;
const viewHeight = window.innerHeight;
if (x + tipWidth + margin > scrollX + viewWidth) {
x = ev.pageX - tipWidth - 15;
if (x < scrollX + margin) {
x = scrollX + margin;
}
}
if (x < scrollX + margin) {
x = scrollX + margin;
}
if (y + tipHeight + margin > scrollY + viewHeight) {
let yAbove = ev.pageY - tipHeight - 15;
if (yAbove > scrollY + margin) {
y = yAbove;
} else {
y = scrollY + viewHeight - tipHeight - margin;
if (y < scrollY + margin) {
y = scrollY + margin;
}
}
}
if (y < scrollY + margin) {
y = scrollY + margin;
}
tip.style.left = `${x}px`;
tip.style.top = `${y}px`;
}
function startHideAnimation() {
if (tip.style.display !== 'none' && tip.style.opacity !== '0') {
tip.style.opacity = '0';
tip.style.pointerEvents = 'none';
trackingMove = false;
clearTimeout(displayTimeout);
displayTimeout = setTimeout(() => {
tip.style.display = 'none';
}, FADE_DURATION);
} else if (tip.style.display !== 'none') {
clearTimeout(displayTimeout);
displayTimeout = setTimeout(() => { tip.style.display = 'none'; }, FADE_DURATION);
}
}
function actuallyHideTip() {
hoverId++;
currentFetch = null;
currentHoveredLink = null;
clearTimeout(showTimeout);
startHideAnimation();
}
function scheduleHideTip() {
clearTimeout(hideTimeout);
clearTimeout(displayTimeout);
hideTimeout = setTimeout(actuallyHideTip, HIDE_DELAY);
}
function cancelHideTip() {
clearTimeout(hideTimeout);
clearTimeout(displayTimeout);
if (tip.style.display === 'block' && tip.style.opacity === '0') {
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
}
}
function triggerShowAndFadeIn(event, gameName) {
cancelHideTip();
clearTimeout(displayTimeout);
tip.innerHTML = `<div class="loadingContainer"><div class="spinner"></div><span>Loading <strong>${gameName}</strong>…</span></div>`;
positionTip(event);
tip.style.display = 'block';
void tip.offsetHeight;
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
}
tip.addEventListener('mouseenter', () => {
cancelHideTip();
if (trackingMove) {
trackingMove = false;
}
});
tip.addEventListener('mouseleave', () => {
scheduleHideTip();
});
document.addEventListener('mouseover', async (e) => {
const targetLink = e.target.closest(SEL);
const isOverTip = tip.contains(e.target);
if (targetLink || isOverTip) {
cancelHideTip();
}
if (!targetLink || (targetLink === currentHoveredLink && !trackingMove)) {
return;
}
if (currentHoveredLink && targetLink !== currentHoveredLink && tip.style.display === 'block') {
tip.style.opacity = '0';
tip.style.pointerEvents = 'none';
tip.style.display = 'none';
hoverId++;
trackingMove = false;
currentFetch = null;
}
currentHoveredLink = targetLink;
userHovering = true;
const rawName = targetLink.textContent;
let gameName = cleanName(rawName);
// Fallback: if cleanName returned null, use a basic cleaned version
if (!gameName) {
// Basic cleanup: split on brackets/parens, take first part
gameName = rawName.trim()
.replace(/^\[[^\]]*\]\s*/g, '') // Remove [brackets] at start
.replace(/[._]/g, ' ') // Normalize separators
.split(/[\(\[]/)[0] // Split on ( or [
.split(/\s+/)
.slice(0, 4) // First 4 words
.join(' ')
.trim();
if (!gameName || gameName.length < 2) {
currentHoveredLink = null;
userHovering = false;
return;
}
}
clearTimeout(showTimeout);
const thisId = ++hoverId;
trackingMove = true;
lastMoveEvent = e;
triggerShowAndFadeIn(e, gameName);
showTimeout = setTimeout(async () => {
if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
if (!currentHoveredLink || currentHoveredLink !== targetLink) {
trackingMove = false;
}
return;
}
currentFetch = fetchSteamWithFallback(gameName);
const data = await currentFetch;
currentFetch = null;
if (hoverId !== thisId || !currentHoveredLink || currentHoveredLink !== targetLink) {
if (!currentHoveredLink || currentHoveredLink !== targetLink) {
trackingMove = false;
}
return;
}
if (!data) {
const searchUrl = `https://store.steampowered.com/search/?term=${encodeURIComponent(gameName)}`;
tip.innerHTML = `<p>No Steam info found for<br><strong>${gameName}</strong></p><p><a href="${searchUrl}" target="_blank" rel="noopener noreferrer">Search on Steam</a></p>`;
} else {
const tagsHtml = data.tags?.length ?
`<p class="steamTags"><strong>Tags:</strong> ${data.tags.join(' • ')}</p>` :
'';
const reviewHtml = (data.reviewInfo && data.reviewInfo.desc !== 'N/A' && data.reviewInfo.desc !== 'No Reviews') ?
`<p class="steamRating"><strong>Rating:</strong> ${getRatingStars(data.reviewInfo.percent, data.reviewInfo.desc)}<span class="ratingText">${data.reviewInfo.desc}${data.reviewInfo.total ? ` | ${data.reviewInfo.total.toLocaleString()} reviews` : ''}</span></p>` :
'';
const releaseDateHtml = data.releaseDate ?
`<p class="steamReleaseDate"><strong>Released:</strong> ${data.releaseDate}</p>` :
'';
tip.innerHTML = `
${data.header_image ? `<img src="${data.header_image}" alt="${data.name || gameName}" onerror="this.style.display='none'">` : ''}
<p><strong>${data.name || gameName}</strong></p>
${releaseDateHtml}
<p>${data.short_description || 'No description available.'}</p>
${reviewHtml}
${tagsHtml}
<div class="tipActions">
${data.storeUrl ? `<a href="${data.storeUrl}" target="_blank" rel="noopener noreferrer">🎮 Open on Steam</a>` : '<span></span>'}
<button class="magnetDownloadBtn" data-torrent-url="${window.location.origin}${targetLink.getAttribute('href')}">
🧲 Magnet Download
</button>
</div>
`;
}
if (hoverId === thisId && currentHoveredLink === targetLink) {
positionTip(lastMoveEvent);
trackingMove = false;
tip.style.opacity = '1';
tip.style.pointerEvents = 'auto';
} else {
startHideAnimation();
}
}, SHOW_DELAY);
}, true);
document.addEventListener('mouseout', (e) => {
const leavingCurrentLink = currentHoveredLink && currentHoveredLink === e.target.closest(SEL);
const destinationIsTip = tip.contains(e.relatedTarget);
if (leavingCurrentLink && !destinationIsTip) {
scheduleHideTip();
currentHoveredLink = null;
userHovering = false;
}
}, true);
document.addEventListener('pointermove', (e) => {
if (trackingMove && tip.style.display === 'block') {
lastMoveEvent = e;
positionTip(e);
}
}, { capture: true, passive: true });
// ═══════════════════════════════════════════════════════════════════════════
// MAGNET DOWNLOAD BUTTON (in hover card)
// ═══════════════════════════════════════════════════════════════════════════
// Magnet link cache to avoid re-fetching
const magnetCache = new Map();
// Fetch magnet link from torrent page
async function fetchMagnetLink(torrentUrl) {
// Ensure full URL
const fullUrl = torrentUrl.startsWith('http')
? torrentUrl
: window.location.origin + torrentUrl;
console.log('[Magnet Download] Fetching:', fullUrl);
// Check cache first
if (magnetCache.has(fullUrl)) {
console.log('[Magnet Download] Cache hit!');
return magnetCache.get(fullUrl);
}
// Use regular fetch - same origin so cookies are included automatically
const response = await fetch(fullUrl, {
method: 'GET',
credentials: 'same-origin',
headers: {
'Accept': 'text/html'
}
});
console.log('[Magnet Download] Response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, 'text/html');
const magnetLink = doc.querySelector('a[href^="magnet:"]');
if (magnetLink) {
const magnet = magnetLink.getAttribute('href');
magnetCache.set(fullUrl, magnet);
console.log('[Magnet Download] Found magnet link!');
return magnet;
} else {
console.error('[Magnet Download] No magnet link found in page');
throw new Error('Magnet link not found');
}
}
// Handle clicks on magnet download button inside tooltip
tip.addEventListener('click', async (e) => {
const btn = e.target.closest('.magnetDownloadBtn');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
if (btn.classList.contains('loading')) return;
const torrentUrl = btn.dataset.torrentUrl;
console.log('[Magnet Download] Torrent URL:', torrentUrl);
if (!torrentUrl) {
console.error('[Magnet Download] No torrent URL found in button data');
return;
}
// Show loading state
const originalContent = btn.innerHTML;
btn.classList.add('loading');
btn.textContent = '⏳ Loading...';
try {
const magnet = await fetchMagnetLink(torrentUrl);
console.log('[Magnet Download] Got magnet link:', magnet.substring(0, 60) + '...');
// Open magnet link - this triggers qBittorrent!
window.location.href = magnet;
// Show success briefly
btn.classList.remove('loading');
btn.textContent = '✓ Opening...';
setTimeout(() => {
btn.innerHTML = originalContent;
}, 2000);
} catch (err) {
console.error('[Magnet Download] Error:', err);
btn.classList.remove('loading');
btn.textContent = '✗ Failed - ' + err.message;
setTimeout(() => {
btn.innerHTML = originalContent;
}, 3000);
}
});
console.log("1337x Steam Hover Preview script loaded.");
})();