// ==UserScript== // @name UNIT3D Keybinds // @namespace https://github.com/flowerey/unit3d-scripts // @version 1.5.1 // @description Adds keybinds to UNIT3D. // @author blueberry // @match https://*/torrents* // @downloadURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/keybinds.user.js // @updateURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/keybinds.user.js.meta.js // @grant none // @run-at document-end // ==/UserScript== (function () { 'use strict'; const STORAGE_KEY = 'unit3d-keybinds-map'; const DEFAULT_BINDS = { imdb: { key: 's', ctrl: false, shift: false, alt: false }, letterboxd: { key: 'l', ctrl: false, shift: false, alt: false }, tmdb: { key: 'm', ctrl: false, shift: false, alt: false }, bluray: { key: 'x', ctrl: false, shift: false, alt: false }, nzbgeek: { key: 'd', ctrl: false, shift: false, alt: false }, trailer: { key: 't', ctrl: false, shift: false, alt: false }, edit: { key: 'e', ctrl: false, shift: false, alt: false }, back: { key: 'b', ctrl: false, shift: false, alt: false }, listNext: { key: 'j', ctrl: false, shift: false, alt: false }, listPrev: { key: 'k', ctrl: false, shift: false, alt: false }, listOpen: { key: 'Enter', ctrl: false, shift: false, alt: false }, search: { key: '/', ctrl: false, shift: false, alt: false }, help: { key: '?', ctrl: false, shift: false, alt: false } }; function loadBinds() { try { const stored = JSON.parse(localStorage.getItem(STORAGE_KEY)); if (stored && typeof stored === 'object') { const merged = { ...DEFAULT_BINDS }; for (const [action, value] of Object.entries(stored)) { if (typeof value === 'string') { merged[action] = { key: value, ctrl: false, shift: false, alt: false }; } else if (typeof value === 'object' && value.key) { merged[action] = { ...DEFAULT_BINDS[action], ...value }; } } return merged; } } catch (e) { /* ignore */ } return { ...DEFAULT_BINDS }; } function saveBinds(binds) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(binds)); } catch (e) { /* ignore */ } } const UI = { toastEl: null, toastTimeout: null, showToast(message, type = 'error') { if (!this.toastEl) { this.toastEl = document.createElement('div'); this.toastEl.id = 'unit3d-kb-toast'; Object.assign(this.toastEl.style, { position: 'fixed', top: '20px', right: '20px', padding: '12px 24px', borderRadius: '4px', zIndex: '10000', color: '#fff', fontWeight: '600', fontSize: '14px', boxShadow: '0 4px 12px rgba(0,0,0,0.3)', transition: 'all 0.3s ease', pointerEvents: 'none', opacity: '0', transform: 'translateY(-20px)' }); document.body.appendChild(this.toastEl); } this.toastEl.innerText = message; this.toastEl.style.backgroundColor = type === 'error' ? '#e74c3c' : '#2ecc71'; this.toastEl.style.opacity = '1'; this.toastEl.style.transform = 'translateY(0)'; clearTimeout(this.toastTimeout); this.toastTimeout = setTimeout(() => { this.toastEl.style.opacity = '0'; this.toastEl.style.transform = 'translateY(-20px)'; }, 3000); }, helpOverlay: null, toggleHelp(binds) { if (this.helpOverlay) { this.helpOverlay.remove(); this.helpOverlay = null; return; } this.helpOverlay = document.createElement('div'); this.helpOverlay.id = 'unit3d-kb-help'; Object.assign(this.helpOverlay.style, { position: 'fixed', top: '0', left: '0', width: '100%', height: '100%', backgroundColor: 'rgba(0,0,0,0.85)', zIndex: '20000', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }); const content = document.createElement('div'); Object.assign(content.style, { backgroundColor: '#1a1a1a', padding: '30px', borderRadius: '8px', border: '1px solid #333', maxWidth: '500px', width: '90%', color: '#ddd', fontFamily: 'system-ui, sans-serif' }); const isListPage = /\/torrents\/?$/.test(window.location.pathname); const fmt = (bind) => { if (typeof bind === 'string') return bind === ' ' ? 'Space' : bind === 'Enter' ? 'Enter' : bind.length === 1 ? bind.toUpperCase() : bind; const parts = []; if (bind.ctrl) parts.push('Ctrl'); if (bind.shift) parts.push('Shift'); if (bind.alt) parts.push('Alt'); const k = bind.key === ' ' ? 'Space' : bind.key.length === 1 ? bind.key.toUpperCase() : bind.key; parts.push(k); return parts.join('+'); }; const detailBinds = [ { key: binds.imdb, desc: 'Open IMDb' }, { key: binds.letterboxd, desc: 'Open Letterboxd' }, { key: binds.tmdb, desc: 'Open TMDB' }, { key: binds.bluray, desc: 'Open Blu-ray.com' }, { key: binds.nzbgeek, desc: 'Search NZBGeek' }, { key: binds.trailer, desc: 'Search YouTube Trailer' }, { key: binds.edit, desc: 'Edit Torrent' }, { key: binds.back, desc: 'Back to Torrents' } ]; const listBinds = [ { key: binds.listNext, desc: 'Select next torrent' }, { key: binds.listPrev, desc: 'Select previous torrent' }, { key: binds.listOpen, desc: 'Open selected torrent' }, { key: binds.search, desc: 'Focus search box' }, { key: 'Esc', desc: 'Clear selection / unfocus' } ]; const sharedBinds = [ { key: binds.help, desc: 'Show/Hide this help' } ]; let bindsHtml = ''; if (!isListPage) { bindsHtml += `
Torrent Detail
`; bindsHtml += detailBinds.map(b => `
[${fmt(b.key)}]${b.desc}
`).join(''); } if (isListPage) { bindsHtml += `
Torrent List
`; bindsHtml += listBinds.map(b => `
[${fmt(b.key)}]${b.desc}
`).join(''); } bindsHtml += `
General
`; bindsHtml += sharedBinds.map(b => `
[${fmt(b.key)}]${b.desc}
`).join(''); content.innerHTML = `

Keybinds Help

${bindsHtml}
Click anywhere to close
`; this.helpOverlay.appendChild(content); this.helpOverlay.onclick = (e) => { if (e.target.id === 'kb-settings-btn') return; this.helpOverlay.remove(); this.helpOverlay = null; }; document.body.appendChild(this.helpOverlay); content.querySelector('#kb-settings-btn').onclick = (e) => { e.stopPropagation(); this.helpOverlay.remove(); this.helpOverlay = null; this.openSettings(binds); }; }, openSettings(binds) { if (document.querySelector('.kb-settings-overlay')) return; const overlay = document.createElement('div'); overlay.className = 'kb-settings-overlay'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:29999;'; overlay.onclick = () => overlay.remove(); const panel = document.createElement('div'); panel.style.cssText = 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:#1a1a1a;border:1px solid #444;border-radius:8px;padding:20px;z-index:30000;color:#ddd;font-family:system-ui,sans-serif;min-width:340px;max-width:90vw;max-height:80vh;overflow-y:auto;box-shadow:0 8px 32px rgba(0,0,0,0.5);'; panel.onclick = (e) => e.stopPropagation(); const fields = [ { key: 'imdb', label: 'IMDb' }, { key: 'letterboxd', label: 'Letterboxd' }, { key: 'tmdb', label: 'TMDB' }, { key: 'bluray', label: 'Blu-ray.com' }, { key: 'nzbgeek', label: 'NZBGeek' }, { key: 'trailer', label: 'YouTube Trailer' }, { key: 'edit', label: 'Edit Torrent' }, { key: 'back', label: 'Back to Torrents' }, { key: 'listNext', label: 'Next (List)' }, { key: 'listPrev', label: 'Previous (List)' }, { key: 'listOpen', label: 'Open (List)' }, { key: 'search', label: 'Focus Search' }, { key: 'help', label: 'Toggle Help' } ]; panel.innerHTML = `

Customize Keybinds

${fields.map(f => { const bind = binds[f.key] || { key: '', ctrl: false, shift: false, alt: false }; return `
`; }).join('')}
`; panel.querySelector('.kb-save-btn').onclick = () => { const newBinds = { ...binds }; panel.querySelectorAll('[data-bind]').forEach(input => { const actionKey = input.dataset.bind; newBinds[actionKey] = { key: input.value.trim() || binds[actionKey].key, ctrl: !!panel.querySelector(`[data-mod="${actionKey}-ctrl"]`)?.checked, shift: !!panel.querySelector(`[data-mod="${actionKey}-shift"]`)?.checked, alt: !!panel.querySelector(`[data-mod="${actionKey}-alt"]`)?.checked, }; }); saveBinds(newBinds); overlay.remove(); UI.showToast('Keybinds saved!', 'success'); }; panel.querySelector('.kb-reset-btn').onclick = () => { panel.querySelectorAll('[data-bind]').forEach(input => { const actionKey = input.dataset.bind; const def = DEFAULT_BINDS[actionKey]; input.value = def.key; const ctrlCb = panel.querySelector(`[data-mod="${actionKey}-ctrl"]`); const shiftCb = panel.querySelector(`[data-mod="${actionKey}-shift"]`); const altCb = panel.querySelector(`[data-mod="${actionKey}-alt"]`); if (ctrlCb) ctrlCb.checked = def.ctrl; if (shiftCb) shiftCb.checked = def.shift; if (altCb) altCb.checked = def.alt; }); }; overlay.appendChild(panel); document.body.appendChild(overlay); } }; const LIST_SELECTORS = [ 'tr.torrent-search--list__no-poster-row', 'tr:has(.torrent-search--grouped__overview)', 'tr.torrent-search--list__row' ].join(', '); let currentInstance = null; class KeybindManager { constructor() { this.selectors = { title: 'h1.meta__title', metaLink: 'a.meta-id-tag' }; this.selectedIndex = -1; this.boundHandler = this.handleKeydown.bind(this); this.binds = loadBinds(); this.init(); } getLink(parentSelector) { const el = document.querySelector(`${parentSelector} ${this.selectors.metaLink}`); return el ? el.href : null; } getMediaInfo() { const h1 = document.querySelector(this.selectors.title); if (!h1) return { name: '', year: '' }; const text = h1.innerText.trim(); const match = text.match(/\((\d{4})\)/); const year = match ? match[1] : ''; const name = match ? text.split(`(${year})`)[0].trim() : text; return { name, year }; } getVisibleRows() { return Array.from(document.querySelectorAll(LIST_SELECTORS)).filter(r => r.offsetParent !== null); } highlightRow(index) { const rows = this.getVisibleRows(); rows.forEach((r, i) => { r.style.outline = i === index ? '2px solid #2ecc71' : ''; r.style.outlineOffset = i === index ? '-2px' : ''; }); this.selectedIndex = index; if (index >= 0 && rows[index]) { rows[index].scrollIntoView({ block: 'nearest', behavior: 'smooth' }); } } clearHighlight() { const rows = this.getVisibleRows(); rows.forEach(r => { r.style.outline = ''; r.style.outlineOffset = ''; }); this.selectedIndex = -1; } init() { document.removeEventListener('keydown', this.boundHandler); document.addEventListener('keydown', this.boundHandler); } destroy() { document.removeEventListener('keydown', this.boundHandler); } handleKeydown(e) { const active = document.activeElement; if (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) return; const key = e.key; const keyLower = key.toLowerCase(); const isListPage = /\/torrents\/?$/.test(window.location.pathname); const b = this.binds; const matchesBind = (bindDef, pressedKey) => { if (!bindDef || typeof bindDef !== 'object') return false; const keyMatch = bindDef.key.length === 1 ? pressedKey.toLowerCase() === bindDef.key.toLowerCase() : pressedKey === bindDef.key; return keyMatch && !!e.ctrlKey === !!bindDef.ctrl && !!e.shiftKey === !!bindDef.shift && !!e.altKey === !!bindDef.alt; }; if (key === 'Escape') { if (UI.helpOverlay) { e.preventDefault(); UI.toggleHelp(b); return; } if (isListPage) { e.preventDefault(); this.clearHighlight(); return; } } if (matchesBind(b.help, key)) { e.preventDefault(); UI.toggleHelp(b); return; } if (isListPage) { const rows = this.getVisibleRows(); if (rows.length === 0) return; if (matchesBind(b.listNext, key)) { e.preventDefault(); const next = Math.min(this.selectedIndex + 1, rows.length - 1); this.highlightRow(next); return; } if (matchesBind(b.listPrev, key)) { e.preventDefault(); const prev = Math.max(this.selectedIndex - 1, 0); this.highlightRow(prev); return; } if (matchesBind(b.listOpen, key) && this.selectedIndex >= 0) { e.preventDefault(); const link = rows[this.selectedIndex].querySelector('a[href*="/torrents/"]'); if (link) window.location.href = link.href; return; } if (matchesBind(b.search, key)) { e.preventDefault(); const searchInput = document.querySelector('input[type="search"], input[name="search"], input.form-control'); if (searchInput) searchInput.focus(); return; } } if (!isListPage) { const { name, year } = this.getMediaInfo(); const query = encodeURIComponent(`${name} ${year}`.trim()); const links = { imdb: this.getLink('.meta__imdb'), letterboxd: this.getLink('.meta__letterboxd'), tmdb: this.getLink('.meta__tmdb'), bluray: this.getLink('.meta__blu-ray') }; const actionMap = { imdb: () => links.imdb ? window.open(links.imdb, '_blank') : UI.showToast('IMDb link not found'), letterboxd: () => links.letterboxd ? window.open(links.letterboxd, '_blank') : UI.showToast('Letterboxd link not found'), tmdb: () => links.tmdb ? window.open(links.tmdb, '_blank') : UI.showToast('TMDB link not found'), bluray: () => links.bluray ? window.open(links.bluray, '_blank') : UI.showToast('Blu-ray.com link not found'), nzbgeek: () => window.open(`https://nzbgeek.info/geekseek.php?browseincludewords=${query}`, '_blank'), trailer: () => window.open(`https://www.youtube.com/results?search_query=${query}+trailer`, '_blank'), edit: () => { const path = window.location.pathname.replace(/\/$/, ''); window.location.href = `${window.location.origin}${path}/edit`; }, back: () => window.location.href = `${window.location.origin}/torrents` }; for (const [actionName, actionFn] of Object.entries(actionMap)) { if (matchesBind(b[actionName], key)) { e.preventDefault(); actionFn(); return; } } } } } const start = () => { if (currentInstance) currentInstance.destroy(); currentInstance = new KeybindManager(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', start); } else { start(); } })();