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