Suite communautaire pour tr4ker.net — thème "Encre & Signal", palette (Ctrl/Cmd+K), recherche instantanée (Meilisearch 345k, corrige la recherche du site), chat sur toutes les pages, salon vocal, HUD ratio + évolution, compteur de non-lus dans l'onglet, assistant IA optionnel (désactivé par défaut). Outil non officiel.
// ==UserScript==
// @name TR4KER+
// @namespace https://t4x.ekaii.fr
// @version 0.18.0
// @description Suite communautaire pour tr4ker.net — thème "Encre & Signal", palette (Ctrl/Cmd+K), recherche instantanée (Meilisearch 345k, corrige la recherche du site), chat sur toutes les pages, salon vocal, HUD ratio + évolution, compteur de non-lus dans l'onglet, assistant IA optionnel (désactivé par défaut). Outil non officiel.
// @license MIT
// @author communauté TR4KER
// @match https://tr4ker.net/*
// @run-at document-start
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_setClipboard
// @grant GM_registerMenuCommand
// @grant GM_xmlhttpRequest
// @grant GM_addValueChangeListener
// @connect t4search.ekaii.fr
// @connect t4ai.ekaii.fr
// @connect t4x.ekaii.fr
// @connect forgejo.ekaii.fr
// @connect voice.ekaii.fr
// @noframes
// @homepageURL https://greasyfork.org/en/scripts
// ==/UserScript==
/*
* TR4KER+ v0.1 — pure client-side. See docs/PLAN.md.
* Design: reskin by overriding the site's Material Design 3 CSS custom properties (classname-independent);
* all net-new UI lives in one shadow-DOM host on <html> so it survives SPA re-renders and bundle drift.
* v0.1 is read/navigation only (open, download-the-.torrent, copy) — no write actions.
*/
(function () {
'use strict';
// ---- single-inject guard -------------------------------------------------
if (window.__T4X__) { return; }
const VERSION = '0.18.0';
window.__T4X__ = VERSION;
const IS_MAC = /Mac|iPhone|iPad/.test(navigator.platform);
const SAFE_BOOT = /[?&]t4x=safe(\b|=)/.test(location.search);
// ---- settings store ------------------------------------------------------
const DEFAULTS = {
enabled: true,
theme: true, // reskin
polish: true, // structural polish layer
palette: true, // command palette
chat: true, // global chat drawer
hud: true, // status/ratio pill
ai: false, // AI assistant dock (t4ai) — OPT-IN, off until the user enables it
updates: true, // check the Forgejo manifest for a newer userscript
accent: 'indigo', // indigo | cyan | amber | rose
};
// GM storage is primary; mirror to localStorage so prefs survive even if GM storage is unavailable
// (sandbox/container/private-mode quirks). Read GM first, fall back to the localStorage mirror.
const store = {
get(k, d) {
try { const v = GM_getValue(k, undefined); if (v !== undefined) return v; } catch (e) {}
try { const ls = localStorage.getItem('t4x:' + k); if (ls != null) return JSON.parse(ls); } catch (e) {}
return d;
},
set(k, v) {
try { GM_setValue(k, v); } catch (e) {}
try { localStorage.setItem('t4x:' + k, JSON.stringify(v)); } catch (e) {}
},
};
function prefs() { return Object.assign({}, DEFAULTS, store.get('t4x.prefs', {})); }
function setPref(k, v) { const p = prefs(); p[k] = v; store.set('t4x.prefs', p); return p; }
// v0.18 — the AI dock becomes opt-in. setPref() persists the WHOLE merged object, so anyone who
// ever touched a setting already has `ai:true` frozen in storage and would not see the new default.
// One-shot migration: turn it off once, then never touch the user's choice again.
function migrateOptInAi() {
if (store.get('t4x.mig.ai-optin', false)) return;
const raw = store.get('t4x.prefs', null);
if (raw && raw.ai === true) { raw.ai = false; store.set('t4x.prefs', raw); }
store.set('t4x.mig.ai-optin', true);
}
migrateOptInAi();
let P = prefs();
const log = (...a) => { try { console.debug('%cTR4KER+', 'color:#93a5ff', ...a); } catch (e) {} };
// ==========================================================================
// DESIGN TOKENS — "Encre & Signal" (override the site's M3 custom properties)
// ==========================================================================
// Accent groups: each is a complete, AA-checked primary/secondary/gradient set for dark+light.
const ACCENTS = {
indigo: { d: { primary: '#93a5ff', onp: '#101638', sec: '#2b3050', onsec: '#dfe3ff', grad: 'linear-gradient(135deg,#93a5ff,#67e8f9)' },
l: { primary: '#4256c5', onp: '#ffffff', sec: '#dfe4ff', onsec: '#1a2050', grad: 'linear-gradient(135deg,#4256c5,#0891b2)' } },
cyan: { d: { primary: '#5fd0e6', onp: '#00363f', sec: '#123b44', onsec: '#c9f2fb', grad: 'linear-gradient(135deg,#5fd0e6,#93a5ff)' },
l: { primary: '#0e7490', onp: '#ffffff', sec: '#cbeef6', onsec: '#04333f', grad: 'linear-gradient(135deg,#0e7490,#4256c5)' } },
amber: { d: { primary: '#f5c869', onp: '#3a2c00', sec: '#3f3413', onsec: '#ffe6a8', grad: 'linear-gradient(135deg,#f5c869,#fb7185)' },
l: { primary: '#8a6100', onp: '#ffffff', sec: '#ffe7ad', onsec: '#2b1f00', grad: 'linear-gradient(135deg,#8a6100,#b45309)' } },
rose: { d: { primary: '#ffb0c2', onp: '#57061f', sec: '#54263a', onsec: '#ffd9e2', grad: 'linear-gradient(135deg,#ffb0c2,#93a5ff)' },
l: { primary: '#b0224f', onp: '#ffffff', sec: '#ffd9e2', onsec: '#3f0021', grad: 'linear-gradient(135deg,#b0224f,#4256c5)' } },
};
function themeCSS() {
const a = ACCENTS[P.accent] || ACCENTS.indigo;
const dark = `
--surface:#0f1117 !important; --surface-dim:#0a0c10 !important;
--surface-container-low:#151823 !important; --surface-container:#1a1e2b !important;
--surface-container-high:#232838 !important; --surface-container-highest:#2d3345 !important;
--surface-bright:#343b4f !important;
--primary:${a.d.primary} !important; --on-primary:${a.d.onp} !important; --on-primary-fixed:#dfe4ff !important;
--on-surface:#e6e8f2 !important; --on-surface-variant:#b8bdd1 !important;
--outline-variant:#3a4056 !important; --inverse-surface:#e6e8f2 !important;
--secondary-container:${a.d.sec} !important; --on-secondary-container:${a.d.onsec} !important;
--error:#ffb4ab !important; --ann-color:#ffd28a !important; --notif:${a.d.primary} !important;
--t4x-seed:#4ade80; --t4x-leech:#fb7185; --t4x-fl:#fbbf24; --t4x-grad:${a.d.grad};`;
const light = `
--surface:#f6f7fb !important; --surface-dim:#e8eaf2 !important;
--surface-container-low:#eef0f7 !important; --surface-container:#e7eaf4 !important;
--surface-container-high:#dfe3f0 !important; --surface-container-highest:#d7dcec !important;
--surface-bright:#ffffff !important;
--primary:${a.l.primary} !important; --on-primary:${a.l.onp} !important; --on-primary-fixed:#1a1c1c !important;
--on-surface:#171a26 !important; --on-surface-variant:#454b61 !important;
--outline-variant:#d3d7e6 !important; --inverse-surface:#2a2a2a !important;
--secondary-container:${a.l.sec} !important; --on-secondary-container:${a.l.onsec} !important;
--error:#ba1a1a !important; --ann-color:#8a6100 !important; --notif:${a.l.primary} !important;
--t4x-seed:#16a34a; --t4x-leech:#e11d48; --t4x-fl:#b45309; --t4x-grad:${a.l.grad};`;
return `:root{${dark}} :root[data-theme=dark]{${dark}} [data-theme=light]{${light}}`;
}
// Structural polish — stable primitives only (element/attribute/aria), no hashed classnames.
const POLISH_CSS = `
html{ -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility; }
*{ scrollbar-width:thin; scrollbar-color:var(--surface-container-highest) transparent; }
::-webkit-scrollbar{ width:10px; height:10px; }
::-webkit-scrollbar-thumb{ background:var(--surface-container-highest); border-radius:999px; border:2px solid transparent; background-clip:content-box; }
::-webkit-scrollbar-thumb:hover{ background:var(--outline-variant); }
:focus-visible{ outline:2px solid var(--primary) !important; outline-offset:2px; border-radius:6px; }
::selection{ background:color-mix(in srgb, var(--primary) 32%, transparent); }
td, th{ font-variant-numeric:tabular-nums; }
a{ text-underline-offset:2px; }
`;
// run cb once <html> exists (at true document-start even documentElement can be null)
function whenHtml(cb) {
if (document.documentElement) { cb(); return; }
try {
const mo = new MutationObserver(() => { if (document.documentElement) { mo.disconnect(); cb(); } });
mo.observe(document, { childList: true, subtree: true });
} catch (e) { const t = setInterval(() => { if (document.documentElement) { clearInterval(t); cb(); } }, 5); }
}
function keepLast(styleEl, marker) {
styleEl.setAttribute('data-t4x', marker);
const place = () => {
const head = document.head || document.documentElement;
if (!head) return;
if (styleEl.parentNode !== head || head.lastElementChild !== styleEl) {
try { head.appendChild(styleEl); } catch (e) {}
}
};
place();
const target = document.head || document.documentElement;
if (target) {
try {
const mo = new MutationObserver(() => { if (styleEl.__t4xLive) place(); });
mo.observe(target, { childList: true });
styleEl.__t4xMo = mo;
} catch (e) {}
}
styleEl.__t4xLive = true;
}
let themeStyle = null;
function applyTheme() {
if (!P.theme && !P.polish) { removeTheme(); return; }
let css = '';
if (P.theme) css += themeCSS();
if (P.polish) css += '\n' + POLISH_CSS;
if (!themeStyle) { themeStyle = document.createElement('style'); themeStyle.textContent = css; keepLast(themeStyle, 'theme'); }
else themeStyle.textContent = css;
}
function removeTheme() { if (themeStyle) { themeStyle.__t4xLive = false; themeStyle.remove(); themeStyle = null; } }
// ==========================================================================
// SHADOW HOST — one root for all overlays; isolated from the site's CSS.
// ==========================================================================
let host = null, root = null;
function ensureHost() {
if (host && document.documentElement.contains(host)) return root;
// React detached our host but the NODE still exists with its shadow root + every overlay inside →
// re-attach the same node rather than recreating an empty one (which would orphan pal/chat/pill/etc.)
if (host && host.shadowRoot) {
try { document.documentElement.appendChild(host); root = host.shadowRoot; return root; } catch (e) {}
}
host = document.getElementById('t4x-root');
if (!host) {
host = document.createElement('div');
host.id = 't4x-root';
host.style.cssText = 'all:initial; position:fixed; inset:0 auto auto 0; width:0; height:0; z-index:2147483000;';
document.documentElement.appendChild(host);
}
root = host.shadowRoot || host.attachShadow({ mode: 'open' });
if (!root.querySelector('style[data-t4x-shell]')) {
const s = document.createElement('style');
s.setAttribute('data-t4x-shell', '');
s.textContent = SHELL_CSS();
root.appendChild(s);
}
return root;
}
// Re-anchor host if React ever detaches it.
function watchHost() {
try {
const mo = new MutationObserver(() => {
if (!document.documentElement.contains(host)) ensureHost();
});
mo.observe(document.documentElement, { childList: true });
} catch (e) {}
}
// shadow stylesheet reads the site's live M3 tokens so overlays match theme + reskin.
function SHELL_CSS() {
return `
:host{ all:initial;
/* readability-critical glass tokens live here (always injected, regardless of the reskin toggle) and
derive from the site's live --surface/--on-surface, so panels track whatever theme is actually active
— reskin ON (our overridden --surface) or OFF (the site's own light/dark). Single source of truth. */
--t4x-glass: color-mix(in srgb, var(--surface,#0f1117) 86%, transparent);
--t4x-hair: color-mix(in srgb, var(--on-surface,#93a5ff) 14%, transparent); }
*{ box-sizing:border-box; font-family:'Geist Variable',Inter,system-ui,sans-serif; }
.veil{ position:fixed; inset:0; background:rgba(5,7,12,.55); backdrop-filter:blur(2px); z-index:2147483005;
opacity:0; transition:opacity .16s ease; pointer-events:none; }
.veil.on{ opacity:1; pointer-events:auto; }
.pal{ position:fixed; left:50%; top:14vh; z-index:2147483006; transform:translateX(-50%) translateY(8px) scale(.985);
width:min(680px,92vw); max-height:70vh; display:flex; flex-direction:column; opacity:0;
transition:opacity .18s ease, transform .18s cubic-bezier(.3,0,0,1); pointer-events:none;
background:var(--t4x-glass, rgba(15,17,23,.85)); border:1px solid var(--t4x-hair, rgba(147,165,255,.14));
border-radius:20px; box-shadow:0 24px 64px rgba(0,0,0,.5); overflow:hidden;
backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2); }
.pal.on{ opacity:1; transform:translateX(-50%) translateY(0) scale(1); pointer-events:auto; }
.pal header{ display:flex; align-items:center; gap:10px; padding:14px 16px; border-bottom:1px solid var(--outline-variant,#3a4056); }
.pal .glyph{ width:20px; height:20px; border-radius:6px; background:var(--t4x-grad,linear-gradient(135deg,#93a5ff,#67e8f9)); flex:0 0 auto; }
.pal input{ flex:1; background:transparent; border:0; outline:0; color:var(--on-surface,#e6e8f2);
font-size:16px; line-height:24px; padding:2px 0; }
.pal input::placeholder{ color:var(--on-surface-variant,#b8bdd1); }
.pal .hintk{ font:12px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1);
border:1px solid var(--outline-variant,#3a4056); border-radius:6px; padding:4px 6px; }
.list{ overflow-y:auto; padding:6px; }
.grp{ font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1);
padding:10px 12px 4px; }
.row{ display:flex; align-items:center; gap:10px; padding:9px 12px; border-radius:12px; cursor:pointer;
color:var(--on-surface,#e6e8f2); }
.row .ic{ font-family:'Material Symbols Outlined'; font-size:20px; width:24px; text-align:center;
color:var(--on-surface-variant,#b8bdd1); flex:0 0 auto; }
.row .main{ flex:1; min-width:0; }
.row .title{ font-size:14px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.row .sub{ font-size:12px; color:var(--on-surface-variant,#b8bdd1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.row .chips{ display:flex; gap:6px; flex:0 0 auto; }
.chip{ font:11px/1 'JetBrains Mono',monospace; padding:4px 7px; border-radius:999px;
background:var(--surface-container-high,#232838); color:var(--on-surface-variant,#b8bdd1); }
.chip.seed{ color:var(--t4x-seed,#4ade80); } .chip.leech{ color:var(--t4x-leech,#fb7185); }
.chip.fl{ color:#0f1117; background:var(--t4x-fl,#fbbf24); font-weight:600; }
.row[aria-selected="true"]{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
.row[aria-selected="true"] .ic, .row[aria-selected="true"] .sub{ color:var(--on-secondary-container,#dfe3ff); }
.empty{ padding:26px 16px; text-align:center; color:var(--on-surface-variant,#b8bdd1); font-size:13px; }
.pal footer{ display:flex; gap:14px; padding:8px 14px; border-top:1px solid var(--outline-variant,#3a4056);
font-size:11px; color:var(--on-surface-variant,#b8bdd1); }
.pal footer b{ font-family:'JetBrains Mono',monospace; font-weight:500; color:var(--on-surface,#e6e8f2); }
.spin{ width:14px; height:14px; border-radius:50%; border:2px solid var(--outline-variant,#3a4056);
border-top-color:var(--primary,#93a5ff); animation:t4spin .7s linear infinite; }
@keyframes t4spin{ to{ transform:rotate(360deg); } }
/* toasts sit above EVERYTHING (z 2147483010) and are purely informational — neither the stack
nor the toasts themselves may ever eat a click on the page underneath. */
.toasts{ position:fixed; right:18px; bottom:130px; z-index:2147483010; display:flex; flex-direction:column; gap:10px; align-items:flex-end;
pointer-events:none; }
.toast{ pointer-events:none; background:var(--t4x-glass,rgba(15,17,23,.85)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14));
color:var(--on-surface,#e6e8f2); border-radius:14px; padding:12px 14px; font-size:13px; max-width:340px;
box-shadow:0 12px 32px rgba(0,0,0,.4); backdrop-filter:blur(18px) saturate(160%);
transform:translateY(8px); opacity:0; transition:opacity .2s ease, transform .2s cubic-bezier(.3,0,0,1); }
.toast.on{ transform:translateY(0); opacity:1; }
.wm{ position:absolute; right:12px; top:12px; font:600 11px/1 'JetBrains Mono',monospace;
color:var(--on-surface-variant,#b8bdd1); opacity:.7; }
@media (prefers-reduced-motion: reduce){ .pal,.veil,.toast{ transition:opacity .12s linear !important; transform:none !important; } }
`;
}
// ==========================================================================
// SPA route awareness — patch history so the palette can navigate & features react.
// ==========================================================================
function patchHistory() {
if (history.__t4xPatched) return; history.__t4xPatched = true;
const fire = () => { try { window.dispatchEvent(new Event('t4x:locationchange')); } catch (e) {} };
for (const m of ['pushState', 'replaceState']) {
const orig = history[m];
history[m] = function () { const r = orig.apply(this, arguments); fire(); return r; };
}
window.addEventListener('popstate', fire);
}
function navigate(path) {
path = String(path || '');
// legacy recents stored the wrong plural route; the torrent detail page is /torrent/<slug> (singular)
path = path.replace(/^\/torrents\/([^/?#]+)/, '/torrent/$1');
// tr4ker's router ignores synthetic popstate, so a pushState nudge silently no-ops → do a real navigation.
// (Going to a torrent/wiki page from the palette is a page change anyway; a full load is expected + reliable.)
location.assign(path);
}
// ==========================================================================
// API client — same-origin, credentialed, single-flight + short TTL cache.
// ==========================================================================
const inflight = new Map();
const cache = new Map();
async function apiGet(path, { ttl = 15000, timeout = 5000 } = {}) {
const now = Date.now();
const c = cache.get(path);
if (c && now - c.t < ttl) return c.v;
if (inflight.has(path)) return inflight.get(path);
const ctl = new AbortController();
const to = setTimeout(() => ctl.abort(), timeout);
const p = fetch(path, { credentials: 'include', headers: { accept: 'application/json' }, signal: ctl.signal })
.then(async (r) => {
if (!r.ok) throw new Error('HTTP ' + r.status);
const j = await r.json();
cache.set(path, { t: Date.now(), v: j });
return j;
})
.finally(() => { clearTimeout(to); inflight.delete(path); });
inflight.set(path, p);
return p;
}
// never cache identity
async function me() { return apiGet('/api/me', { ttl: 30000 }); }
// ---- Meilisearch (self-hosted, full 345k catalogue, instant + typo-tolerant) ----
const MEILI_URL = 'https://t4search.ekaii.fr';
const MEILI_KEY = '6fc1913be46eb01c7ca8ade9eb3f7e03824667e9f1313040557afe5b9232d2bb'; // search-only tenant key
let meiliCooldown = 0;
function meiliSearch(q, opts) {
opts = opts || {};
if (Date.now() < meiliCooldown) return Promise.resolve(null);
return new Promise((res) => {
let done = false;
const finish = (v) => { if (!done) { done = true; res(v); } };
const to = setTimeout(() => finish(null), 4000);
const body = { q: q, limit: opts.limit || 8, attributesToRetrieve: ['title', 'slug', 'seeders', 'size_bytes', 'category_label', 'year'] };
if (opts.filter) body.filter = opts.filter;
if (opts.sort) body.sort = opts.sort;
try {
GM_xmlhttpRequest({
method: 'POST', url: MEILI_URL + '/indexes/catalogue/search',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + MEILI_KEY },
data: JSON.stringify(body),
onload: (r) => { clearTimeout(to); try { const j = JSON.parse(r.responseText); finish(Array.isArray(j.hits) ? j.hits : null); } catch (e) { finish(null); } },
onerror: () => { clearTimeout(to); meiliCooldown = Date.now() + 60000; finish(null); },
ontimeout: () => { clearTimeout(to); finish(null); },
timeout: 4500,
});
} catch (e) { finish(null); }
});
}
function mapMeiliHit(h) {
return { slug: h.slug, name: h.title, size_bytes: h.size_bytes, seeders: h.seeders,
parent_cat_name: h.category_label, parent_cat_slug: (h.category_label || '').toLowerCase(), year: h.year, _meili: true };
}
async function getTorrents(q, facets) {
const o = { limit: 8 };
if (facets && facets.filter) o.filter = facets.filter;
if (facets && facets.sort) o.sort = facets.sort;
const hits = await meiliSearch(q, o);
if (hits) return hits.map(mapMeiliHit);
// API fallback has no facet support; still returns relevance-ordered results.
try { const j = await apiGet('/api/torrents?q=' + encodeURIComponent(q), { ttl: 20000, timeout: 6000 }); return pickArray(j, ['torrents', 'items', 'data']).slice(0, 8); }
catch (e) { return []; }
}
function fmtSize(b) {
if (b == null) return '';
const u = ['o', 'Ko', 'Mo', 'Go', 'To']; let i = 0, n = Number(b);
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return (n < 10 && i > 0 ? n.toFixed(1) : Math.round(n)) + ' ' + u[i];
}
function pickArray(j, keys) {
if (Array.isArray(j)) return j;
for (const k of keys) if (j && Array.isArray(j[k])) return j[k];
// fall back to first array-valued property
if (j && typeof j === 'object') for (const v of Object.values(j)) if (Array.isArray(v)) return v;
return [];
}
// ==========================================================================
// TOASTS
// ==========================================================================
let toastWrap = null;
function toast(msg, ms = 3800) {
ensureHost();
if (!toastWrap) { toastWrap = document.createElement('div'); toastWrap.className = 'toasts'; root.appendChild(toastWrap); }
const t = document.createElement('div'); t.className = 'toast'; t.textContent = msg;
toastWrap.appendChild(t);
requestAnimationFrame(() => t.classList.add('on'));
setTimeout(() => { t.classList.remove('on'); setTimeout(() => t.remove(), 260); }, ms);
}
// ==========================================================================
// OVERLAY INERTNESS — class-of-bug guard
// --------------------------------------------------------------------------
// An `opacity:0` overlay is INVISIBLE BUT STILL HIT-TESTABLE. Every fixed-position panel we
// leave in the DOM while "closed" therefore silently swallows page clicks, and any button
// inside it (e.g. the voice panel's "Rejoindre", parked over the pill in the bottom-right
// corner) re-fires on every click there — that is the "je ne peux plus rien cliquer / ça
// rouvre le vocal à l'infini" bug. Toggling pointer-events by hand at each call site is
// fragile: one forgotten path brings the whole page down.
//
// So instead of trusting call sites, we sweep: any direct child of our shadow root that is
// position:fixed and computes to invisible (opacity 0 / visibility hidden / display none)
// is forced inert. Panels explicitly mark themselves `data-t4open="1"` while opening so a
// sweep landing mid-transition can never blank a panel that is on its way in.
// Only ever writes `none` or `''` (never `auto`) so CSS stays the source of truth.
// ==========================================================================
function markOpen(el, on) {
if (!el) return;
if (on) { el.dataset.t4open = '1'; el.style.pointerEvents = ''; el.__t4pe = 'auto'; }
else { delete el.dataset.t4open; }
scheduleSweep();
}
function inertSweep() {
if (!root) return;
for (const el of root.children) {
if (!el.style || el.tagName === 'STYLE') continue;
if (el.dataset && el.dataset.t4open === '1') {
if (el.__t4pe !== 'auto') { el.style.pointerEvents = ''; el.__t4pe = 'auto'; }
continue;
}
let cs; try { cs = getComputedStyle(el); } catch (e) { continue; }
if (cs.position !== 'fixed') continue;
const dead = cs.visibility === 'hidden' || cs.display === 'none' || parseFloat(cs.opacity) === 0;
const want = dead ? 'none' : 'auto';
if (el.__t4pe !== want) { el.style.pointerEvents = dead ? 'none' : ''; el.__t4pe = want; }
}
}
let _sweepT = null;
function scheduleSweep() {
// sweep once the CSS transition has settled (opacity only reaches 0 at the end of the fade)
if (_sweepT) clearTimeout(_sweepT);
_sweepT = setTimeout(() => { _sweepT = null; inertSweep(); }, 320);
requestAnimationFrame(inertSweep);
}
// Panic hatch: close every panel and force everything inert. Bound to Ctrl/⌘+Shift+X and
// exposed in the Tampermonkey menu, so a stuck UI is always one keystroke from recoverable.
function closeAllOverlays(quiet) {
const safe = (f) => { try { f(); } catch (e) {} };
safe(closePalette); safe(closeDrawer); safe(closeAi); safe(closeRequests); safe(closeVoice);
safe(() => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } });
safe(() => { if (settingsEl) settingsEl(); });
safe(inertSweep);
setTimeout(inertSweep, 340);
if (!quiet) toast('Panneaux TR4KER+ fermés — l’interface est débloquée.');
}
// ==========================================================================
// UPDATE CHECK — is a newer TR4KER+ published?
// --------------------------------------------------------------------------
// Tampermonkey only polls @updateURL on its own schedule (once a day by default, and never at
// all if the user disabled auto-update), so a fix can sit unseen for a long time. We poll a tiny
// manifest ourselves — version + notes, a few hundred bytes, at most once every 6 h — and, when
// something newer is out, surface it: pill button, palette entry, launcher entry, one toast.
// Clicking it opens the raw .user.js, which Tampermonkey intercepts with its install/update page.
// ==========================================================================
const UPDATE_MANIFEST = 'https://forgejo.ekaii.fr/tr4ker/tr4ker-plus/raw/branch/main/version.json';
const UPDATE_SCRIPT = 'https://forgejo.ekaii.fr/tr4ker/tr4ker-plus/raw/branch/main/tr4ker-plus.user.js';
const UPDATE_EVERY = 6 * 3600 * 1000;
const updateState = { available: false, latest: null, notes: '', checkedAt: 0, checking: false };
// numeric-segment compare; missing segments read as 0 so "0.18" < "0.18.1"
function verCmp(a, b) {
const pa = String(a).split('.').map(n => parseInt(n, 10) || 0);
const pb = String(b).split('.').map(n => parseInt(n, 10) || 0);
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
const d = (pa[i] || 0) - (pb[i] || 0);
if (d) return d < 0 ? -1 : 1;
}
return 0;
}
function applyUpdateState() {
if (pillUpBtn) {
pillUpBtn.style.display = updateState.available ? '' : 'none';
pillUpBtn.title = updateState.available ? ('TR4KER+ v' + updateState.latest + ' disponible — cliquer pour mettre à jour') : 'Mise à jour disponible';
}
}
function openUpdate() {
// Tampermonkey intercepts any navigation to a *.user.js and shows its install/update screen
try { window.open(UPDATE_SCRIPT, '_blank', 'noreferrer'); } catch (e) { location.href = UPDATE_SCRIPT; }
}
function checkUpdate(manual) {
if (updateState.checking) return;
if (!manual && !pref0('updates', true)) return;
const last = store.get('t4x.update', { at: 0, latest: null, notes: '' });
if (!manual && Date.now() - (last.at || 0) < UPDATE_EVERY) {
// still inside the cache window — reuse the last verdict, don't hit the network
if (last.latest && verCmp(last.latest, VERSION) > 0) { updateState.available = true; updateState.latest = last.latest; updateState.notes = last.notes || ''; applyUpdateState(); }
return;
}
updateState.checking = true;
try {
GM_xmlhttpRequest({
method: 'GET', url: UPDATE_MANIFEST + '?t=' + Date.now(),
headers: { 'Accept': 'application/json' }, timeout: 8000,
onload: (r) => {
updateState.checking = false;
let j = null; try { j = JSON.parse(r.responseText || '{}'); } catch (e) {}
if (!j || !j.version) { if (manual) toast('Impossible de lire le manifeste de version.'); return; }
updateState.checkedAt = Date.now();
store.set('t4x.update', { at: Date.now(), latest: j.version, notes: j.notes || '' });
if (verCmp(j.version, VERSION) > 0) {
const first = !updateState.available;
updateState.available = true; updateState.latest = j.version; updateState.notes = j.notes || '';
applyUpdateState();
if (first || manual) toast('TR4KER+ v' + j.version + ' est dispo (tu es en ' + VERSION + ')' + (j.notes ? ' — ' + j.notes : '') + '. Clique l’icône ⭳ de la pastille pour l’installer.', 9000);
} else {
updateState.available = false; updateState.latest = j.version; applyUpdateState();
if (manual) toast('TR4KER+ est à jour (v' + VERSION + ').');
}
},
onerror: () => { updateState.checking = false; if (manual) toast('Vérification impossible (Forgejo injoignable).'); },
ontimeout: () => { updateState.checking = false; if (manual) toast('Vérification : délai dépassé.'); },
});
} catch (e) { updateState.checking = false; if (manual) toast('GM_xmlhttpRequest indisponible.'); }
}
// ==========================================================================
// DEMANDES overlay — sort-by-recent + "mes demandes" filter (suggestions: gaudruche, John654987)
// Robust shadow-DOM panel over /api/requests (no React-DOM mutation). Reachable from ⌘K anywhere.
// ==========================================================================
const reqState = { sort: 'recent', mine: false, data: null };
let reqPanel = null, reqListEl = null;
function tParse(ts) { return typeof ts === 'number' ? ts : (Date.parse(ts) || 0); }
function ago(ts) {
const t = tParse(ts); if (!t) return '';
const s = Math.max(0, (Date.now() - t) / 1000);
if (s < 90) return 'à l’instant';
const m = s / 60; if (m < 60) return 'il y a ' + Math.round(m) + ' min';
const h = m / 60; if (h < 24) return 'il y a ' + Math.round(h) + ' h';
const d = h / 24; if (d < 31) return 'il y a ' + Math.round(d) + ' j';
return 'il y a ' + Math.round(d / 30) + ' mois';
}
function isMine(r) {
const uid = chat.myId; const uname = (chat.myName || '').toLowerCase();
if (uid != null && (r.requester_id === uid || (r.requester && r.requester.id === uid) || r.user_id === uid || r.created_by === uid)) return true;
const rn = (r.requester_username || (r.requester && r.requester.username) || r.username || '').toLowerCase();
return !!uname && rn === uname;
}
function reqBounty(r) { return Number(r.total_bounty != null ? r.total_bounty : (r.bounty_credits || 0)) || 0; }
function ceReq(tag, css, txt) { const e = document.createElement(tag); if (css) e.style.cssText = css; if (txt != null) e.textContent = txt; return e; }
async function ensureIdentity() {
if (chat.myId != null) return;
try { const m = await me(); chat.myId = m.id; chat.myName = m.username; chat.myRole = m.role || chat.myRole; } catch (e) {}
}
function renderRequests() {
if (!reqListEl) return;
let list = (reqState.data || []).slice();
if (reqState.mine) list = list.filter(isMine);
list.sort(reqState.sort === 'recent'
? (a, b) => tParse(b.created_at) - tParse(a.created_at)
: (a, b) => reqBounty(b) - reqBounty(a));
reqListEl.replaceChildren();
if (!list.length) { reqListEl.appendChild(ceReq('div', 'padding:24px;text-align:center;opacity:.6', reqState.mine ? 'Tu n’as aucune demande en cours.' : 'Aucune demande.')); return; }
for (const r of list) {
const row = ceReq('div', 'display:flex;gap:10px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.06))'); row.className='t4x-reqrow';
const main = ceReq('div', 'flex:1;min-width:0');
const title = ceReq('div', 'font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis', r.title || '(sans titre)');
const sub = ceReq('div', 'font-size:11px;opacity:.6;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis',
[r.category_name || r.category_label || r.category, ago(r.created_at), r.status].filter(Boolean).join(' · '));
main.append(title, sub);
const b = reqBounty(r);
const bounty = ceReq('span', 'flex:0 0 auto;font-size:12px;font-weight:700;color:var(--primary,#93a5ff)', b ? Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(b) : '');
row.append(main, bounty);
if (r.linked_torrent_slug) {
row.style.cursor = 'pointer'; row.title = 'Ouvrir le torrent lié';
row.addEventListener('click', () => { closeRequests(); navigate('/torrent/' + r.linked_torrent_slug); });
}
reqListEl.appendChild(row);
}
}
function buildRequests() {
ensureHost();
const veil = ceReq('div', 'position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483000;opacity:0;transition:opacity .15s;pointer-events:none'); veil.className='t4x-reqveil';
const panel = ceReq('div', 'position:fixed;z-index:2147483001;top:8vh;left:50%;transform:translateX(-50%) scale(.98);width:min(640px,94vw);max-height:82vh;display:flex;flex-direction:column;background:var(--t4x-glass,rgba(15,17,23,.9));backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.5);opacity:0;transition:opacity .15s,transform .15s;overflow:hidden'); panel.className='t4x-reqpanel';
const head = ceReq('div', 'display:flex;align-items:center;gap:10px;padding:14px 16px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.1))');
head.appendChild(ceReq('div', 'font-weight:700', 'Demandes'));
const spacer = ceReq('div', 'flex:1'); head.appendChild(spacer);
const chipCss = (on) => 'font:inherit;font-size:11px;line-height:1;padding:6px 10px;border-radius:999px;cursor:pointer;border:1px solid ' + (on ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.15))') + ';background:' + (on ? 'var(--primary,#93a5ff)' : 'transparent') + ';color:' + (on ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)');
const chips = {};
const mkc = (key, label, apply) => { const b = document.createElement('button'); b.type = 'button'; b.textContent = label; b.addEventListener('click', () => { apply(); syncChips(); renderRequests(); }); chips[key] = b; head.appendChild(b); return b; };
mkc('recent', 'Récentes', () => reqState.sort = 'recent');
mkc('bounty', 'Récompense', () => reqState.sort = 'bounty');
mkc('mine', 'Mes demandes', () => reqState.mine = !reqState.mine);
const x = ceReq('button', 'font:inherit;margin-left:4px;padding:6px 9px;border-radius:8px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', '✕');
x.addEventListener('click', closeRequests); head.appendChild(x);
function syncChips() {
chips.recent.style.cssText = chipCss(reqState.sort === 'recent');
chips.bounty.style.cssText = chipCss(reqState.sort === 'bounty');
chips.mine.style.cssText = chipCss(reqState.mine);
}
reqPanel = { veil, panel, syncChips };
reqListEl = ceReq('div', 'overflow-y:auto;flex:1'); reqListEl.className='t4x-reqlist';
panel.append(head, reqListEl);
root.append(veil, panel);
veil.addEventListener('click', closeRequests);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && reqPanel && reqPanel.veil.style.opacity === '1') { e.preventDefault(); closeRequests(); } }, true);
syncChips();
}
async function openRequests(preset) {
if (!P.palette) return; // gated with the palette feature flag
if (preset === 'mine') { reqState.mine = true; reqState.sort = 'recent'; }
else if (preset === 'recent') { reqState.mine = false; reqState.sort = 'recent'; }
if (!reqPanel) buildRequests();
reqPanel.syncChips();
markOpen(reqPanel.veil, true); markOpen(reqPanel.panel, true);
requestAnimationFrame(() => { reqPanel.veil.style.opacity = '1'; reqPanel.veil.style.pointerEvents = 'auto'; reqPanel.panel.style.opacity = '1'; reqPanel.panel.style.pointerEvents = 'auto'; reqPanel.panel.style.transform = 'translateX(-50%) scale(1)'; });
reqListEl.replaceChildren(ceReq('div', 'padding:24px;text-align:center;opacity:.6', 'Chargement…'));
await ensureIdentity();
try { const j = await apiGet('/api/requests?limit=100', { ttl: 30000, timeout: 8000 }); reqState.data = pickArray(j, ['requests', 'items', 'data']); }
catch (e) { reqListEl.replaceChildren(ceReq('div', 'padding:24px;text-align:center;opacity:.6', 'Impossible de charger les demandes.')); return; }
renderRequests();
}
function closeRequests() {
if (!reqPanel) return;
reqPanel.veil.style.opacity = '0'; reqPanel.veil.style.pointerEvents = 'none'; reqPanel.panel.style.opacity = '0'; reqPanel.panel.style.pointerEvents = 'none'; reqPanel.panel.style.transform = 'translateX(-50%) scale(.98)';
markOpen(reqPanel.veil, false); markOpen(reqPanel.panel, false);
}
// ==========================================================================
// VOICE — community rooms via self-hosted LiveKit SFU (voice.ekaii.fr). Task #9.
// Runs the LiveKit client in a voice.ekaii.fr companion WINDOW (window.open) — tr4ker CSP blocks both an
// in-page WebSocket (connect-src) and a cross-origin iframe (frame-src). See services/voice/token /embed.
// ==========================================================================
const VOICE_URL = 'https://voice.ekaii.fr';
const VOICE_ROOMS = ['salon-general', 'cinema', 'series', 'detente'];
const voice = { win: null, connected: false, connecting: false, room: null, muted: false, participants: [], count: 0, poll: null, listener: false, panel: null, opened: false, listEl: null, statusEl: null };
// tr4ker's CSP blocks BOTH an in-page WebSocket (connect-src) AND a cross-origin iframe (frame-src→'self').
// A separate top-level window (window.open) is NOT subject to the opener's CSP, so the LiveKit client runs
// freely in a small companion window on voice.ekaii.fr; state/commands travel over postMessage.
function setupVoiceListener() {
if (voice.listener) return; voice.listener = true;
window.addEventListener('message', (e) => {
if (e.origin !== VOICE_URL) return; const d = e.data; if (!d || !d.__t4voice) return; onVoiceMessage(d);
});
}
function onVoiceMessage(d) {
if (d.type === 'state') {
voice.connecting = false; voice.connected = !!d.connected; voice.room = d.room || voice.room; voice.muted = !!d.muted;
voice.participants = Array.isArray(d.participants) ? d.participants : []; voice.count = d.count || (voice.connected ? 1 : 0);
renderVoiceState(); updateParticipants();
} else if (d.type === 'left') {
voice.connected = false; voice.connecting = false; voice.participants = []; voice.count = 0; renderVoiceState(); updateParticipants();
} else if (d.type === 'error') {
voice.connecting = false; voice.connected = false; renderVoiceState(); updateParticipants();
toast('Voix : ' + (d.message || 'échec') + ' (bêta).', 6000);
}
}
function watchVoiceWin() {
if (voice.poll) return;
voice.poll = setInterval(() => {
if (!voice.win || voice.win.closed) {
voice.win = null; voice.connected = false; voice.connecting = false; voice.participants = []; voice.count = 0;
clearInterval(voice.poll); voice.poll = null; renderVoiceState(); updateParticipants();
}
}, 1500);
}
function updateParticipants() {
if (!voice.listEl) return;
voice.listEl.replaceChildren();
const rows = [];
if (voice.connected) {
rows.push({ name: 'toi', me: true });
for (const n of (voice.participants || [])) rows.push({ name: typeof n === 'string' ? n : (n && n.name) || '?' });
}
if (!rows.length) { voice.listEl.appendChild(ceReq('div', 'padding:14px;opacity:.6;text-align:center', voice.connecting ? 'Connexion…' : 'Personne pour l’instant.')); return; }
for (const p of rows) {
const row = ceReq('div', 'display:flex;align-items:center;gap:8px;padding:7px 12px');
const dot = ceReq('span', 'width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--t4x-seed,#4ade80)');
const nm = ceReq('span', 'flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis', p.name + (p.me ? ' (toi)' : ''));
row.append(dot, nm); voice.listEl.appendChild(row);
}
}
let _lastJoin = 0;
async function joinVoice(room) {
if (voice.win && !voice.win.closed) { try { voice.win.focus(); } catch (e) {} return; }
// cooldown: a stray/repeated click must never be able to spawn a stack of popups
if (Date.now() - _lastJoin < 2000) return;
_lastJoin = Date.now();
setupVoiceListener();
await ensureIdentity();
const name = chat.myName || 'invité';
const url = VOICE_URL + '/embed?room=' + encodeURIComponent(room) + '&name=' + encodeURIComponent(name);
const w = window.open(url, 't4x-voice', 'width=400,height=560,menubar=no,toolbar=no,location=no,status=no,resizable=yes');
if (!w) { toast('Autorise les pop-ups pour ouvrir le salon vocal.', 6000); return; }
voice.win = w; voice.room = room; voice.connecting = true; renderVoiceState(); updateParticipants(); watchVoiceWin();
}
function leaveVoice() {
try { if (voice.win && !voice.win.closed) { voice.win.postMessage({ __t4cmd: 1, cmd: 'leave' }, VOICE_URL); voice.win.close(); } } catch (e) {}
voice.win = null; voice.connected = false; voice.connecting = false; voice.room = null; voice.participants = []; voice.count = 0;
renderVoiceState(); updateParticipants();
}
function toggleMute() {
if (!voice.connected || !voice.win) return;
try { voice.win.postMessage({ __t4cmd: 1, cmd: 'mute' }, VOICE_URL); } catch (e) {} // popup echoes new muted state back
}
function renderVoiceState() {
// persistent indicator on the pill mic button, even when the panel is closed (mic may still be live)
if (pillVoiceBtn) {
const on = voice.connected;
pillVoiceBtn.style.boxShadow = on ? '0 0 0 2px var(--t4x-seed,#4ade80)' : '';
pillVoiceBtn.title = on ? ('Salon vocal « ' + (voice.room || '') + ' » — micro ' + (voice.muted ? 'coupé' : 'actif')) : 'Salon vocal';
}
if (!voice.statusEl) return;
const s = voice.statusEl; const on = voice.connected;
s.joinBtn.style.display = on ? 'none' : '';
s.joinBtn.disabled = voice.connecting; s.joinBtn.textContent = voice.connecting ? 'Connexion…' : 'Rejoindre';
s.leaveBtn.style.display = on ? '' : 'none';
s.muteBtn.style.display = on ? '' : 'none';
s.muteBtn.textContent = voice.muted ? 'Activer le micro' : 'Couper le micro';
s.roomSel.disabled = on || voice.connecting;
s.label.textContent = voice.connecting ? 'Connexion…' : on ? ('Dans « ' + voice.room + ' »') : 'Choisis un salon';
}
function buildVoice() {
ensureHost();
// NO full-screen veil: the voice panel is a small corner widget, not a modal. A veil over the
// whole page for a 340px panel is what made the site feel frozen, and it had to be dismissed
// before anything else could be clicked. Click-outside still closes it (listener below).
// Parked ABOVE the pill (bottom:136px) — at bottom:20px it sat exactly on top of the pill's
// buttons, so its own "Rejoindre" caught clicks aimed at the pill.
const panel = ceReq('div', 'position:fixed;z-index:2147483003;bottom:136px;right:18px;width:min(340px,92vw);display:flex;flex-direction:column;background:var(--t4x-glass,rgba(15,17,23,.9));backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 24px 70px rgba(0,0,0,.5);opacity:0;visibility:hidden;transform:translateY(8px);transition:opacity .15s,transform .15s,visibility .15s;overflow:hidden'); panel.className = 't4x-voicepanel';
const head = ceReq('div', 'display:flex;align-items:center;gap:8px;padding:12px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.1))');
head.appendChild(ceReq('div', 'font-weight:700;flex:1', 'Salon vocal'));
const x = ceReq('button', 'font:inherit;padding:5px 8px;border-radius:8px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0)', '✕');
x.addEventListener('click', closeVoice); head.appendChild(x);
const body = ceReq('div', 'padding:12px 14px;display:flex;flex-direction:column;gap:10px');
const label = ceReq('div', 'font-size:12px;opacity:.7', 'Choisis un salon');
const roomSel = document.createElement('select'); roomSel.style.cssText = 'font:inherit;padding:8px;border-radius:8px;background:var(--surface-container-high,#1a1d26);color:var(--on-surface,#e6e9f2);border:1px solid var(--outline-variant,rgba(255,255,255,.15))';
VOICE_ROOMS.forEach(r => { const o = document.createElement('option'); o.value = r; o.textContent = '# ' + r; roomSel.appendChild(o); });
const btnRow = ceReq('div', 'display:flex;gap:8px');
const bcss = (accent) => 'font:inherit;flex:1;padding:9px;border-radius:9px;cursor:pointer;border:1px solid ' + (accent ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.18))') + ';background:' + (accent ? 'var(--primary,#93a5ff)' : 'transparent') + ';color:' + (accent ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)');
const joinBtn = ceReq('button', bcss(true), 'Rejoindre'); joinBtn.addEventListener('click', () => joinVoice(roomSel.value));
const leaveBtn = ceReq('button', bcss(false), 'Quitter'); leaveBtn.addEventListener('click', leaveVoice);
const muteBtn = ceReq('button', bcss(false), 'Couper le micro'); muteBtn.addEventListener('click', toggleMute);
btnRow.append(joinBtn, leaveBtn, muteBtn);
voice.listEl = ceReq('div', 'max-height:200px;overflow-y:auto;border-top:1px solid var(--outline-variant,rgba(255,255,255,.08));margin:0 -14px -12px;padding:6px 2px');
body.append(label, roomSel, btnRow);
panel.append(head, body, voice.listEl);
root.append(panel);
document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && voice.opened) { e.preventDefault(); closeVoice(); } }, true);
// click-outside dismiss, without a veil: composed path lets us see through the shadow boundary
document.addEventListener('pointerdown', (e) => {
if (!voice.opened) return;
const path = (e.composedPath && e.composedPath()) || [];
if (path.indexOf(panel) !== -1) return;
if (pillEl && path.indexOf(pillEl) !== -1) return; // the pill's own mic button toggles it
closeVoice();
}, true);
voice.panel = { panel };
voice.statusEl = { label, roomSel, joinBtn, leaveBtn, muteBtn };
renderVoiceState(); updateParticipants();
}
function openVoice() {
if (!pref0('voice', true)) { toast('Salon vocal désactivé dans les réglages.'); return; }
if (!voice.panel) buildVoice();
if (voice.opened) return;
voice.opened = true;
const p = voice.panel.panel;
markOpen(p, true);
p.style.visibility = 'visible';
requestAnimationFrame(() => { p.style.opacity = '1'; p.style.transform = 'translateY(0)'; });
}
function closeVoice() {
if (!voice.panel) return;
voice.opened = false;
const p = voice.panel.panel;
p.style.opacity = '0'; p.style.visibility = 'hidden'; p.style.transform = 'translateY(8px)';
markOpen(p, false);
}
function toggleVoice() { voice.opened ? closeVoice() : openVoice(); }
// ==========================================================================
// COMMAND PALETTE + universal search
// ==========================================================================
// routes verified from the rendered logged-in nav (2026-07-30)
const ROUTES = [
{ t: 'Accueil', p: '/', ic: 'home' },
{ t: 'Torrents', p: '/torrents', ic: 'movie' },
{ t: 'Wiki', p: '/wiki', ic: 'menu_book' },
{ t: 'Communauté (chat)', p: '/communaute', ic: 'forum' },
{ t: 'Migrations', p: '/migrations', ic: 'move_up' },
{ t: 'Exclusivités', p: '/exclusivite', ic: 'star' },
{ t: 'Boutique', p: '/shop', ic: 'storefront' },
{ t: 'Uploader un torrent', p: '/upload', ic: 'upload' },
{ t: 'Mes uploads', p: '/my-uploads', ic: 'inventory_2' },
{ t: 'Succès / badges', p: '/achievements', ic: 'trophy' },
{ t: 'Mon compte', p: '/mon-compte', ic: 'account_circle' },
{ t: 'Paramètres', p: '/settings', ic: 'settings' },
];
function actions() {
return [
// AI entries only surface when the user opted in (v0.18)
...(pref0('ai', false) ? [
{ t: 'Demander à l’assistant TR4KER+', ic: 'auto_awesome', run: openAi },
{ t: 'Expliquer cette page', ic: 'help', run: () => askAi('Explique-moi cette page et ce que je peux y faire.', true) },
] : []),
...(updateState.available ? [{ t: 'Mettre à jour TR4KER+ (v' + updateState.latest + ' dispo)', ic: 'system_update_alt', run: openUpdate }] : []),
{ t: 'Demandes : les plus récentes', ic: 'schedule', run: () => openRequests('recent') },
{ t: 'Demandes : les miennes', ic: 'person', run: () => openRequests('mine') },
{ t: 'Ouvrir le chat', ic: 'forum', run: openDrawer },
{ t: 'Salon vocal (bêta)', ic: 'mic', run: openVoice },
{ t: 'Basculer thème clair/sombre', ic: 'contrast', run: toggleSiteTheme },
{ t: 'Copier ma passkey', ic: 'key', run: copyPasskey },
{ t: 'Copier ma clé API (tr4k_)', ic: 'vpn_key', run: copyApiKey },
{ t: 'TR4KER+ : changer d’accent', ic: 'palette', run: cycleAccent },
{ t: 'TR4KER+ : réglages', ic: 'tune', run: openSettings },
{ t: 'TR4KER+ : recharger le thème', ic: 'refresh', run: () => { P = prefs(); applyTheme(); toast('Thème rechargé'); } },
];
}
function toggleSiteTheme() {
const el = document.documentElement;
const cur = el.getAttribute('data-theme') || 'dark';
const next = cur === 'light' ? 'dark' : 'light';
el.setAttribute('data-theme', next);
P = setPref('siteTheme', next); // remember the light/dark choice across reloads
}
// re-apply the saved light/dark choice (the site resets data-theme on every reload / SPA nav)
function applySiteTheme() {
const t = P.siteTheme;
if ((t === 'light' || t === 'dark') && document.documentElement) {
if (document.documentElement.getAttribute('data-theme') !== t) document.documentElement.setAttribute('data-theme', t);
}
}
async function copyPasskey() {
try { const m = await me(); if (m && m.passkey) { GM_setClipboard(m.passkey); toast('Passkey copiée'); } else toast('Passkey introuvable'); }
catch (e) { toast('Erreur: ' + e.message); }
}
async function copyApiKey() {
try { const m = await me(); if (m && m.api_key) { GM_setClipboard(m.api_key); toast('Clé API copiée'); } else toast('Clé API introuvable'); }
catch (e) { toast('Erreur: ' + e.message); }
}
function cycleAccent() {
const order = ['indigo', 'cyan', 'amber', 'rose'];
const next = order[(order.indexOf(P.accent) + 1) % order.length];
P = setPref('accent', next); applyTheme(); toast('Accent : ' + next);
}
// frecency for empty-state recents
function frec() { return store.get('t4x.frecency', {}); }
function bump(id, label, path) {
const f = frec(); const e = f[id] || { c: 0, label, path }; e.c++; e.last = Date.now(); e.label = label; e.path = path;
f[id] = e; store.set('t4x.frecency', f);
}
function recents(n) {
const f = frec();
return Object.entries(f).map(([id, e]) => ({ id, ...e }))
.sort((a, b) => (b.last || 0) - (a.last || 0)).slice(0, n);
}
let pal = null, palInput = null, palList = null, palSpin = null;
let selectable = [], activeIdx = -1, searchSeq = 0, palOpen = false, debTimer = null;
// search facets (Shrex's suggestion: filter by period / recency). Persist in-memory across opens.
let palYear = '', palSort = ''; // palYear: '' | '2026' | '2025' | '2024' | 'old' ; palSort: '' | 'recent' | 'seeders'
function facetOpts() {
const o = {};
if (palYear === 'old') o.filter = 'year < 2024';
else if (palYear) o.filter = 'year = ' + palYear;
if (palSort === 'recent') o.sort = ['pub_date_ts:desc'];
else if (palSort === 'seeders') o.sort = ['seeders:desc'];
return o;
}
function buildPalette() {
ensureHost();
const veil = document.createElement('div'); veil.className = 'veil';
pal = document.createElement('div'); pal.className = 'pal'; pal.setAttribute('role', 'dialog'); pal.setAttribute('aria-modal', 'true'); pal.setAttribute('aria-label', 'Palette de commandes TR4KER+');
const header = document.createElement('header');
const glyph = document.createElement('div'); glyph.className = 'glyph';
palInput = document.createElement('input'); palInput.type = 'text'; palInput.placeholder = 'Rechercher torrents, wiki, membres — ou une action…';
palInput.setAttribute('role', 'combobox'); palInput.setAttribute('aria-expanded', 'true'); palInput.setAttribute('aria-autocomplete', 'list');
palInput.spellcheck = false; palInput.autocapitalize = 'off'; palInput.autocomplete = 'off';
palSpin = document.createElement('div'); palSpin.style.cssText = 'width:16px;flex:0 0 auto;display:flex;align-items:center;justify-content:center;';
const esc = document.createElement('span'); esc.className = 'hintk'; esc.textContent = 'esc';
header.append(glyph, palInput, palSpin, esc);
const facetBar = buildFacetBar();
palList = document.createElement('div'); palList.className = 'list'; palList.setAttribute('role', 'listbox');
const footer = document.createElement('footer');
footer.innerHTML = '<span><b>↵</b> ouvrir</span><span><b>⌘↵</b> télécharger</span><span><b>↑↓</b> naviguer</span>';
const wm = document.createElement('div'); wm.className = 'wm'; wm.textContent = 'T4+';
pal.append(header, facetBar, palList, footer, wm);
root.append(veil, pal);
pal.__veil = veil;
veil.addEventListener('click', closePalette);
palInput.addEventListener('input', onInput);
palInput.addEventListener('keydown', onKey);
}
// Facet bar under the search input — period filter + recency sort (Meili year/pub_date_ts, both indexed).
let facetChips = [];
function buildFacetBar() {
const bar = document.createElement('div'); bar.className = 't4x-facets';
bar.style.cssText = 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:8px 14px;border-bottom:1px solid var(--outline-variant,rgba(255,255,255,.08));';
const lbl = document.createElement('span'); lbl.textContent = 'Période'; lbl.style.cssText = 'font-size:10px;letter-spacing:.06em;text-transform:uppercase;opacity:.55;margin-right:2px;'; bar.appendChild(lbl);
facetChips = [];
const mkChip = (label, kind, val) => {
const b = document.createElement('button'); b.type = 'button'; b.textContent = label;
b.style.cssText = 'font:inherit;font-size:11px;line-height:1;padding:5px 9px;border-radius:999px;cursor:pointer;border:1px solid var(--outline-variant,rgba(255,255,255,.15));background:transparent;color:var(--on-surface,#cdd3e0);transition:background .12s,color .12s;';
b.__kind = kind; b.__val = val; facetChips.push(b);
b.addEventListener('click', () => {
if (kind === 'year') palYear = (palYear === val ? '' : val);
else palSort = (palSort === val ? '' : val);
paintFacets();
const q = palInput.value.trim(); if (q.length >= 2) { palSpin.innerHTML = '<div class="spin"></div>'; search(q); }
});
return b;
};
[['Tous', ''], ['2026', '2026'], ['2025', '2025'], ['2024', '2024'], ['+ ancien', 'old']]
.forEach(([l, v]) => bar.appendChild(mkChip(l, 'year', v)));
const sep = document.createElement('span'); sep.textContent = 'Tri'; sep.style.cssText = 'font-size:10px;letter-spacing:.06em;text-transform:uppercase;opacity:.55;margin:0 2px 0 8px;'; bar.appendChild(sep);
[['Pertinence', ''], ['Récent', 'recent'], ['Seeders', 'seeders']]
.forEach(([l, v]) => bar.appendChild(mkChip(l, 'sort', v)));
paintFacets();
return bar;
}
function paintFacets() {
facetChips.forEach(b => {
const active = (b.__kind === 'year' ? palYear : palSort) === b.__val;
b.style.background = active ? 'var(--primary,#93a5ff)' : 'transparent';
b.style.color = active ? 'var(--on-primary,#0b0d13)' : 'var(--on-surface,#cdd3e0)';
b.style.borderColor = active ? 'var(--primary,#93a5ff)' : 'var(--outline-variant,rgba(255,255,255,.15))';
});
}
function openPalette() {
if (!P.palette) return;
if (!pal) buildPalette();
palOpen = true;
pal.__veil.classList.add('on'); pal.classList.add('on');
markOpen(pal, true); markOpen(pal.__veil, true);
palInput.value = ''; render('');
setTimeout(() => palInput.focus(), 10);
}
function closePalette() {
palOpen = false;
if (pal) { pal.classList.remove('on'); pal.__veil.classList.remove('on'); markOpen(pal, false); markOpen(pal.__veil, false); }
}
function togglePalette() { palOpen ? closePalette() : openPalette(); }
function onInput() {
const q = palInput.value.trim();
render(q);
clearTimeout(debTimer);
if (q.length >= 2) { palSpin.innerHTML = '<div class="spin"></div>'; debTimer = setTimeout(() => search(q), 160); }
else palSpin.innerHTML = '';
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); closePalette(); return; }
if (e.key === 'ArrowDown') { e.preventDefault(); move(1); }
else if (e.key === 'ArrowUp') { e.preventDefault(); move(-1); }
else if (e.key === 'Enter') {
e.preventDefault();
const it = selectable[activeIdx];
if (it) it.__act ? it.__act(e) : activate(it, e);
}
}
function move(d) {
if (!selectable.length) return;
activeIdx = (activeIdx + d + selectable.length) % selectable.length;
selectable.forEach((it, i) => it.el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false'));
const el = selectable[activeIdx].el; el.scrollIntoView({ block: 'nearest' });
palInput.setAttribute('aria-activedescendant', el.id || '');
}
function activate(it, e) {
if (it.kind === 'action') { closePalette(); try { it.run(); } catch (err) { toast('Erreur: ' + err.message); } return; }
if (it.kind === 'route') { bump('r:' + it.p, it.t, it.p); closePalette(); navigate(it.p); return; }
if (it.kind === 'torrent') {
const path = '/torrent/' + it.slug;
if ((IS_MAC ? e.metaKey : e.ctrlKey)) { downloadTorrent(it.slug, it.name); return; }
bump('t:' + it.slug, it.name, path); closePalette(); navigate(path); return;
}
if (it.kind === 'wiki') { const path = '/wiki/' + it.slug; bump('w:' + it.slug, it.title, path); closePalette(); navigate(path); return; }
if (it.kind === 'user') { const path = '/u/' + it.name; closePalette(); navigate(path); return; }
if (it.kind === 'recent') { closePalette(); navigate(it.path); return; }
}
async function downloadTorrent(slug, name) {
try {
toast('Téléchargement du .torrent…', 2000);
const r = await fetch('/api/torrents/' + encodeURIComponent(slug) + '/download', { credentials: 'include' });
if (!r.ok) throw new Error('HTTP ' + r.status);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = (name || slug).replace(/[\\/:*?"<>|]+/g, '_') + '.torrent';
document.documentElement.appendChild(a); a.click(); a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
toast('.torrent téléchargé');
} catch (e) { toast('Échec du téléchargement: ' + e.message); }
}
// ---- rendering -----------------------------------------------------------
let rowSeq = 0;
function mkRow(sel) {
const el = document.createElement('div'); el.className = 'row'; el.id = 't4row-' + (rowSeq++);
el.setAttribute('role', 'option'); el.setAttribute('aria-selected', 'false');
if (sel) {
el.addEventListener('mousemove', () => { activeIdx = selectable.indexOf(sel); selectable.forEach((it, i) => it.el.setAttribute('aria-selected', i === activeIdx ? 'true' : 'false')); });
el.addEventListener('click', (e) => { activeIdx = selectable.indexOf(sel); activate(sel, e); });
sel.el = el;
}
return el;
}
function icon(name) { const s = document.createElement('span'); s.className = 'ic'; s.textContent = name; return s; }
function group(label) { const g = document.createElement('div'); g.className = 'grp'; g.textContent = label; return g; }
function fuzzy(q, s) { // simple subsequence score
q = q.toLowerCase(); s = (s || '').toLowerCase(); let i = 0, sc = 0, run = 0;
for (let j = 0; j < s.length && i < q.length; j++) { if (s[j] === q[i]) { i++; run++; sc += run; } else run = 0; }
return i === q.length ? sc + (s.startsWith(q) ? 50 : 0) : -1;
}
function render(q, remote) {
selectable = []; activeIdx = -1;
const frag = document.createElement('div');
// Actions + routes (local, instant)
const acts = actions().map(a => ({ kind: 'action', ...a }));
const routes = ROUTES.map(r => ({ kind: 'route', ...r }));
if (!q) {
const rec = recents(6);
if (rec.length) {
frag.appendChild(group('Récents'));
rec.forEach(r => { const it = { kind: 'recent', path: r.path, label: r.label }; const el = mkRow(it);
el.append(icon('history'), mkMain(r.label, r.path)); frag.appendChild(el); selectable.push(it); });
}
frag.appendChild(group('Aller à'));
routes.forEach(r => addSimple(frag, r, r.ic, r.t));
frag.appendChild(group('Actions'));
acts.forEach(a => addSimple(frag, a, a.ic, a.t));
} else {
const matchedR = routes.map(r => ({ r, s: fuzzy(q, r.t) })).filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.r);
const matchedA = acts.map(a => ({ a, s: fuzzy(q, a.t) })).filter(x => x.s >= 0).sort((a, b) => b.s - a.s).map(x => x.a);
if (matchedR.length) { frag.appendChild(group('Aller à')); matchedR.forEach(r => addSimple(frag, r, r.ic, r.t)); }
if (matchedA.length) { frag.appendChild(group('Actions')); matchedA.forEach(a => addSimple(frag, a, a.ic, a.t)); }
// remote results
if (remote) {
if (remote.torrents && remote.torrents.length) {
frag.appendChild(group('Torrents')); remote.torrents.forEach(t => addTorrent(frag, t));
}
if (remote.wiki && remote.wiki.length) {
frag.appendChild(group('Wiki')); remote.wiki.forEach(w => addWiki(frag, w));
}
if (remote.users && remote.users.length) {
frag.appendChild(group('Membres')); remote.users.forEach(u => addUser(frag, u));
}
}
if (!selectable.length) {
const e = document.createElement('div'); e.className = 'empty';
e.textContent = remote ? 'Aucun résultat.' : 'Recherche…';
frag.appendChild(e);
}
}
palList.replaceChildren(frag);
if (selectable.length) move(1);
}
function mkMain(title, sub) {
const m = document.createElement('div'); m.className = 'main';
const t = document.createElement('div'); t.className = 'title'; t.textContent = title; m.appendChild(t);
if (sub) { const s = document.createElement('div'); s.className = 'sub'; s.textContent = sub; m.appendChild(s); }
return m;
}
function addSimple(frag, obj, ic, title) {
const it = Object.assign({}, obj); const el = mkRow(it);
el.append(icon(ic), mkMain(title)); frag.appendChild(el); selectable.push(it);
}
function addTorrent(frag, t) {
const it = { kind: 'torrent', slug: t.slug, name: t.name };
const el = mkRow(it);
const main = mkMain(t.name, [t.parent_cat_name || t.parent_cat_slug, t.year].filter(Boolean).join(' · '));
const chips = document.createElement('div'); chips.className = 'chips';
if (t.is_freeleech) { const c = document.createElement('span'); c.className = 'chip fl'; c.textContent = 'FL'; chips.appendChild(c); }
if (t.seeders != null) { const c = document.createElement('span'); c.className = 'chip seed'; c.textContent = '▲' + t.seeders; chips.appendChild(c); }
if (t.leechers != null) { const c = document.createElement('span'); c.className = 'chip leech'; c.textContent = '▼' + t.leechers; chips.appendChild(c); }
if (t.size_bytes != null) { const c = document.createElement('span'); c.className = 'chip'; c.textContent = fmtSize(t.size_bytes); chips.appendChild(c); }
el.append(icon(catIcon(t)), main, chips); frag.appendChild(el); selectable.push(it);
}
function catIcon(t) {
const s = (t.parent_cat_slug || '') + (t.sub_cat_slug || '');
if (/film|movie/.test(s)) return 'movie'; if (/serie|tv/.test(s)) return 'live_tv';
if (/livre|book|bd|comic|manga/.test(s)) return 'menu_book'; if (/audio|music|musi/.test(s)) return 'music_note';
if (/game|jeu/.test(s)) return 'sports_esports'; return 'download';
}
function addWiki(frag, w) {
const slug = w.slug || w.article_slug; const title = w.title || w.heading || slug;
const it = { kind: 'wiki', slug, title };
const el = mkRow(it); el.append(icon('menu_book'), mkMain(title, w.category_name || w.excerpt || '')); frag.appendChild(el); selectable.push(it);
}
function addUser(frag, u) {
const name = u.username || u.name || u.handle; if (!name) return;
const it = { kind: 'user', name };
const el = mkRow(it); el.append(icon('account_circle'), mkMain(name, u.role || '')); frag.appendChild(el); selectable.push(it);
}
async function search(q) {
const seq = ++searchSeq;
const out = { torrents: [], wiki: [], users: [] };
// progressive: paint each section as it resolves so one slow endpoint never blocks the rest
const paint = () => { if (seq === searchSeq && palOpen) render(q, out); };
const jobs = [
getTorrents(q, facetOpts()).then(t => { out.torrents = t; paint(); }).catch(() => {}),
apiGet('/api/wiki/search?q=' + encodeURIComponent(q), { ttl: 60000, timeout: 4000 })
.then(j => { out.wiki = pickArray(j, ['results', 'articles', 'items', 'data']).slice(0, 5); paint(); }).catch(() => {}),
apiGet('/api/users/search?q=' + encodeURIComponent(q), { ttl: 60000, timeout: 4000 })
.then(j => { out.users = pickArray(j, ['results', 'users', 'items', 'data']).slice(0, 5); paint(); }).catch(() => {}),
];
await Promise.allSettled(jobs);
if (seq !== searchSeq || !palOpen) return; // stale
palSpin.innerHTML = '';
render(q, out);
}
// ==========================================================================
// WEBSOCKET MANAGER — tee the SPA's socket (no 2nd connection); own-socket fallback.
// ==========================================================================
const NativeWS = window.WebSocket;
const wsListeners = new Set();
let teedWS = null, ownWS = null, ownBackoff = 1, ownWanted = false;
function onFrame(cb) { wsListeners.add(cb); return () => wsListeners.delete(cb); }
function emitFrame(f, own) { for (const cb of wsListeners) { try { cb(f, own); } catch (e) {} } }
function handleRaw(data, own) {
let f; try { f = JSON.parse(data); } catch (e) { return; }
try { const w = (window.__T4X_WS__ = window.__T4X_WS__ || { n: 0, types: {} }); w.n++; w.types[f.type] = (w.types[f.type] || 0) + 1; } catch (e) {}
if (f.type === 'ping') { if (own && ownWS && ownWS.readyState === 1) { try { ownWS.send('{"type":"pong"}'); } catch (e) {} } return; }
if (f.type === 'notification') { try { if (f.title || f.body) toast((f.title ? f.title + ' — ' : '') + (f.body || ''), 6000); } catch (e) {} }
emitFrame(f, own);
}
function patchWS() {
if (window.WebSocket && window.WebSocket.__t4x) return;
function T4WS(url, protocols) {
const s = protocols === undefined ? new NativeWS(url) : new NativeWS(url, protocols);
try {
if (String(url).indexOf('/api/ws') !== -1) {
teedWS = s;
// SPA socket is now the source of truth → tear down any redundant own socket to avoid duplicate frames
ownWanted = false; if (ownWS) { try { ownWS.close(); } catch (e) {} ownWS = null; }
s.addEventListener('message', (ev) => handleRaw(ev.data, false));
s.addEventListener('close', () => { if (teedWS === s) teedWS = null; });
}
} catch (e) {}
return s; // SPA receives a genuine native socket
}
try {
T4WS.prototype = NativeWS.prototype;
['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'].forEach(k => { T4WS[k] = NativeWS[k]; });
T4WS.__t4x = true;
window.WebSocket = T4WS;
} catch (e) {}
}
function liveSocket() {
if (teedWS && teedWS.readyState === 1) return teedWS;
if (ownWS && ownWS.readyState === 1) return ownWS;
return null;
}
function ensureOwnSocket() {
ownWanted = true;
if (teedWS && teedWS.readyState <= 1) return; // SPA socket present or connecting — use it, don't race a 2nd
if (ownWS && (ownWS.readyState === 0 || ownWS.readyState === 1)) return;
try {
ownWS = new NativeWS('wss://' + location.host + '/api/ws');
ownWS.addEventListener('open', () => { ownBackoff = 1; emitFrame({ type: '_ws_open', own: true }, true); });
ownWS.addEventListener('message', (ev) => handleRaw(ev.data, true));
ownWS.addEventListener('close', () => {
ownWS = null;
if (ownWanted && !(teedWS && teedWS.readyState === 1)) {
setTimeout(ensureOwnSocket, ownBackoff * 1000 + Math.random() * 500);
ownBackoff = Math.min(ownBackoff * 2, 30);
}
});
} catch (e) {}
}
function sendFrame(obj) {
const s = liveSocket();
if (!s) { ensureOwnSocket(); toast('Connexion au chat en cours…'); return false; }
try { s.send(JSON.stringify(obj)); return true; } catch (e) { toast('Envoi impossible: ' + e.message); return false; }
}
// ==========================================================================
// CHAT DRAWER — global, every page (WebSocket + REST history/backfill)
// ==========================================================================
const CHANNELS_FALLBACK = [
{ id: 1, slug: 'general', name: 'Général' }, { id: 2, slug: 'aide', name: 'Aide' },
{ id: 23, slug: 'hors-sujet', name: 'Hors-Sujet' }, { id: 34, slug: 'wiki', name: 'Wiki' },
{ id: 44, slug: 'suggestions', name: 'Suggestions' }, { id: 129, slug: 'migrations', name: 'Migrations' },
{ id: 92, slug: 'petites-annonces', name: 'Petites Annonces', write_roles: ['moderator', 'admin'] },
];
const chat = {
open: false, channels: null, active: 1, myRole: 'user', myName: null,
seen: {}, replyTo: null, lastId: {}, typingTimer: null,
el: null, listEl: null, tabsEl: null, composerEl: null, inputEl: null, unread: {},
};
const roleColor = (r) => r === 'admin' ? 'var(--error,#ffb4ab)' : r === 'moderator' ? 'var(--ann-color,#ffd28a)'
: r === 'uploader' ? 'var(--t4x-seed,#4ade80)' : 'var(--primary,#93a5ff)';
function chatCSS() {
return `
.draw{ position:fixed; top:0; right:0; height:100vh; width:min(420px,94vw); display:flex; flex-direction:column;
background:var(--t4x-glass,rgba(15,17,23,.9)); border-left:1px solid var(--t4x-hair,rgba(147,165,255,.14));
box-shadow:-24px 0 64px rgba(0,0,0,.45); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2);
transform:translateX(100%); transition:transform .22s cubic-bezier(.3,0,0,1); z-index:2147483001; }
.draw.on{ transform:translateX(0); }
.draw .dh{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
.draw .dh b{ font-size:14px; flex:1; }
.draw .dh .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); }
.tabs{ display:flex; gap:4px; padding:8px 10px; overflow-x:auto; border-bottom:1px solid var(--outline-variant,#3a4056); }
.tab{ padding:6px 10px; border-radius:999px; font-size:12px; white-space:nowrap; cursor:pointer; color:var(--on-surface-variant,#b8bdd1);
background:var(--surface-container,#1a1e2b); position:relative; }
.tab.act{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
.tab .ub{ position:absolute; top:-3px; right:-3px; min-width:16px; height:16px; border-radius:999px; background:var(--primary,#93a5ff);
color:var(--on-primary,#101638); font:600 10px/16px 'JetBrains Mono',monospace; text-align:center; padding:0 3px; }
.msgs{ flex:1; overflow-y:auto; padding:10px 12px; display:flex; flex-direction:column; gap:2px; }
.m{ display:flex; gap:8px; padding:5px 6px; border-radius:10px; }
.m:hover{ background:var(--surface-container,#1a1e2b); }
.m .av{ width:28px; height:28px; border-radius:8px; flex:0 0 auto; background:var(--surface-container-high,#232838);
display:flex; align-items:center; justify-content:center; font:600 12px/1 'Geist Variable',sans-serif; color:var(--on-surface-variant,#b8bdd1); overflow:hidden; }
.m .av img{ width:100%; height:100%; object-fit:cover; }
.m .mc{ flex:1; min-width:0; }
.m .mh{ display:flex; align-items:baseline; gap:6px; }
.m .who{ font-size:13px; font-weight:600; }
.m .tm{ font:11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); }
.m .bd{ font-size:13px; line-height:1.4; word-wrap:break-word; overflow-wrap:anywhere; white-space:pre-wrap; }
.m .pq{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); border-left:2px solid var(--outline-variant,#3a4056);
padding-left:6px; margin:1px 0 2px; opacity:.85; max-height:2.4em; overflow:hidden; }
.m .acts{ opacity:0; display:flex; gap:6px; align-items:center; transition:opacity .12s; }
.m:hover .acts{ opacity:1; }
.m .acts span{ cursor:pointer; font-family:'Material Symbols Outlined'; font-size:16px; color:var(--on-surface-variant,#b8bdd1); }
.rx{ display:inline-flex; gap:3px; align-items:center; font-size:11px; padding:2px 6px; border-radius:999px;
background:var(--surface-container-high,#232838); margin-right:4px; }
.typing{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); padding:2px 12px; height:16px; }
.comp{ border-top:1px solid var(--outline-variant,#3a4056); padding:8px 10px; }
.comp .rc{ font-size:11px; color:var(--on-surface-variant,#b8bdd1); padding:2px 4px 6px; display:flex; gap:6px; align-items:center; }
.comp .rc .x{ cursor:pointer; }
.comp textarea{ width:100%; resize:none; background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2);
border:1px solid var(--outline-variant,#3a4056); border-radius:12px; padding:9px 12px; font:14px/1.4 'Geist Variable',sans-serif; outline:none; }
.comp textarea:focus{ border-color:var(--primary,#93a5ff); }
.comp .ro{ font-size:12px; color:var(--on-surface-variant,#b8bdd1); text-align:center; padding:8px; }
/* status/HUD pill */
.pill{ position:fixed; right:18px; bottom:80px; display:flex; align-items:center; gap:10px; z-index:2147483000;
background:var(--t4x-glass,rgba(15,17,23,.85)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14)); border-radius:999px;
padding:7px 10px 7px 14px; box-shadow:0 12px 32px rgba(0,0,0,.4); backdrop-filter:blur(18px) saturate(160%);
color:var(--on-surface,#e6e8f2); font-size:12px; user-select:none; }
.pill .stat{ display:flex; flex-direction:column; line-height:1.15; }
.pill .stat .k{ font-size:9px; letter-spacing:.06em; text-transform:uppercase; color:var(--on-surface-variant,#b8bdd1); }
.pill .stat .v{ font:600 13px/1 'JetBrains Mono',monospace; }
.pill .sep{ width:1px; height:22px; background:var(--outline-variant,#3a4056); }
.pill button{ all:unset; cursor:pointer; width:34px; height:34px; border-radius:50%; display:flex; align-items:center; justify-content:center;
background:var(--surface-container-high,#232838); color:var(--on-surface,#e6e8f2); position:relative; }
.pill button:hover{ background:var(--secondary-container,#2b3050); }
.pill button .ic{ font-family:'Material Symbols Outlined'; font-size:19px; }
.pill .badge{ position:absolute; top:-4px; right:-4px; min-width:16px; height:16px; border-radius:999px; background:var(--primary,#93a5ff);
color:var(--on-primary,#101638); font:600 10px/16px 'JetBrains Mono',monospace; text-align:center; padding:0 3px; }
.pill .fl{ color:#0f1117; background:var(--t4x-fl,#fbbf24); border-radius:999px; padding:2px 7px; font:600 10px/1.4 'JetBrains Mono',monospace; }
/* narrow viewports: drop the stat columns so the 5 action buttons never overflow the screen */
@media (max-width:560px){ .pill{ right:12px; bottom:16px; gap:6px; padding:6px 8px; max-width:calc(100vw - 24px); flex-wrap:wrap; justify-content:flex-end; }
.pill .stat, .pill .sep{ display:none; } .pill button{ width:38px; height:38px; }
/* keep the corner panels clear of the pill on narrow screens (inline styles → !important) */
.t4x-voicepanel{ bottom:80px !important; right:12px !important; }
.t4x-launcher{ bottom:80px !important; right:12px !important; } }
@media (prefers-reduced-motion: reduce){ .draw{ transition:none !important; } }
`;
}
let chatStyleAdded = false;
function ensureChatCSS() { if (chatStyleAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = chatCSS(); root.appendChild(s); chatStyleAdded = true; }
async function loadChannels() {
if (chat.channels) return chat.channels;
try { const j = await apiGet('/api/channels', { ttl: 3600000 }); chat.channels = pickArray(j, ['channels']); }
catch (e) { chat.channels = CHANNELS_FALLBACK; }
if (!chat.channels.length) chat.channels = CHANNELS_FALLBACK;
return chat.channels;
}
function buildDrawer() {
ensureChatCSS();
const el = document.createElement('div'); el.className = 'draw'; el.setAttribute('role', 'complementary'); el.setAttribute('aria-label', 'Chat TR4KER+');
const dh = document.createElement('div'); dh.className = 'dh';
const b = document.createElement('b'); b.textContent = 'Chat';
const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer (Échap)'; x.addEventListener('click', closeDrawer);
dh.append(b, x);
const tabs = document.createElement('div'); tabs.className = 'tabs';
const typing = document.createElement('div'); typing.className = 'typing';
const list = document.createElement('div'); list.className = 'msgs'; list.setAttribute('role', 'log'); list.setAttribute('aria-live', 'polite');
const comp = document.createElement('div'); comp.className = 'comp';
el.append(dh, tabs, typing, list, comp);
root.appendChild(el);
chat.el = el; chat.tabsEl = tabs; chat.listEl = list; chat.composerEl = comp; chat.typingEl = typing;
xtraStyle();
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeDrawer(); }, true);
}
function renderTabs() {
chat.tabsEl.replaceChildren();
for (const c of chat.channels) {
const t = document.createElement('div'); t.className = 'tab' + (c.id === chat.active ? ' act' : '');
t.textContent = c.name; t.addEventListener('click', () => selectChannel(c.id));
if (chat.unread[c.id]) { const ub = document.createElement('span'); ub.className = 'ub'; ub.textContent = chat.unread[c.id] > 99 ? '99+' : chat.unread[c.id]; t.appendChild(ub); }
chat.tabsEl.appendChild(t);
}
}
function renderComposer() {
const c = (chat.channels || []).find(x => x.id === chat.active) || {};
chat.composerEl.replaceChildren();
const canWrite = !(c.write_roles && c.write_roles.length && !c.write_roles.includes(chat.myRole));
if (!canWrite) { const ro = document.createElement('div'); ro.className = 'ro'; ro.textContent = 'Salon en lecture seule (réservé au staff).'; chat.composerEl.appendChild(ro); return; }
if (chat.replyTo) {
const rc = document.createElement('div'); rc.className = 'rc';
const label = document.createElement('span'); label.textContent = '↳ ' + (chat.replyTo.sender || '') + ' : ' + String(chat.replyTo.body || '').slice(0, 60);
const x = document.createElement('span'); x.className = 'x'; x.textContent = '✕'; x.title = 'Annuler'; x.addEventListener('click', () => { chat.replyTo = null; renderComposer(); });
label.style.flex = '1'; label.style.overflow = 'hidden'; label.style.whiteSpace = 'nowrap'; label.style.textOverflow = 'ellipsis';
rc.append(label, x); chat.composerEl.appendChild(rc);
}
const ta = document.createElement('textarea'); ta.rows = 1; ta.placeholder = 'Message dans #' + (c.slug || '') + '…'; ta.setAttribute('aria-label', 'Écrire un message');
ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px'; onTyping(); });
ta.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const v = ta.value.trim(); if (v) sendMessage(v); } // clears only on success (renderComposer)
});
// glass formatting toolbar — icon buttons; image = full-auto upload (pick / paste / drop)
emojiPop = null;
const bar = document.createElement('div'); bar.className = 't4x-cbar';
const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = 'image/*'; fileInput.style.display = 'none';
fileInput.addEventListener('change', () => { const f = fileInput.files && fileInput.files[0]; fileInput.value = ''; if (f) handleImageFile(f, ta); });
const iconBtn = (icon, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.title = title; b.className = 't4x-cbtn'; const s = document.createElement('span'); s.className = 'ic'; s.textContent = icon; b.appendChild(s); b.addEventListener('click', fn); bar.appendChild(b); return b; };
iconBtn('mood', 'Emoji', () => toggleEmoji(ta, bar));
chat._imgBtn = iconBtn('image', 'Envoyer une image (ou colle / glisse-dépose)', () => { if (!uploading) fileInput.click(); });
iconBtn('visibility_off', 'Spoiler', () => wrapSel(ta, '[spoiler]', '[/spoiler]'));
iconBtn('format_bold', 'Gras', () => wrapSel(ta, '**', '**'));
iconBtn('link', 'Lien', () => wrapSel(ta, 'https://', ''));
chat._upStatus = document.createElement('div'); chat._upStatus.className = 't4x-upstatus'; chat._upStatus.style.display = 'none'; bar.appendChild(chat._upStatus);
// paste an image straight into the composer → auto-upload
ta.addEventListener('paste', (e) => { const items = (e.clipboardData && e.clipboardData.items) || []; for (const it of items) { if (it.type && it.type.indexOf('image') === 0) { const f = it.getAsFile(); if (f) { e.preventDefault(); handleImageFile(f, ta); return; } } } });
chat.composerEl.append(bar, fileInput, ta); chat.inputEl = ta;
// drag-drop image (wired once on the persistent composer container)
if (!chat._dropWired) {
chat._dropWired = true;
const stop = (e) => { e.preventDefault(); e.stopPropagation(); };
['dragenter', 'dragover'].forEach((ev) => chat.composerEl.addEventListener(ev, (e) => { stop(e); chat.composerEl.classList.add('t4x-drop'); }));
['dragleave', 'drop'].forEach((ev) => chat.composerEl.addEventListener(ev, (e) => { stop(e); chat.composerEl.classList.remove('t4x-drop'); }));
chat.composerEl.addEventListener('drop', (e) => { const f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleImageFile(f, chat.inputEl); });
}
}
function fmtTime(ts) { try { const d = new Date(ts); return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0'); } catch (e) { return ''; } }
function initials(n) { return String(n || '?').trim().slice(0, 2).toUpperCase(); }
// ---- chat rich-body renderer (Omnichat-inspired: [spoiler], **bold**/[b], autolink, @mention) --------
// Strictly DOM-built — never assigns innerHTML with user content, so it can't inject markup.
const URL_RE = /\bhttps?:\/\/[^\s<>()]+/gi;
function escRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
function appendText(parent, s) { if (s) parent.appendChild(document.createTextNode(s)); }
function appendMention(parent, s, myName) {
if (!myName) { appendText(parent, s); return; }
const re = new RegExp('(^|[^\\w@])(@?' + escRe(myName) + ')(?![\\w])', 'gi');
let last = 0, m;
while ((m = re.exec(s))) { appendText(parent, s.slice(last, m.index) + m[1]); const sp = document.createElement('span'); sp.className = 't4x-ment'; sp.textContent = m[2]; parent.appendChild(sp); last = m.index + m[0].length; }
appendText(parent, s.slice(last));
}
function appendStyled(parent, s, myName) {
const BOLD = /\*\*([^*\n]+)\*\*|\[b\]([\s\S]*?)\[\/b\]/gi; let last = 0, m;
while ((m = BOLD.exec(s))) { appendMention(parent, s.slice(last, m.index), myName); const b = document.createElement('strong'); appendMention(b, m[1] || m[2] || '', myName); parent.appendChild(b); last = m.index + m[0].length; }
appendMention(parent, s.slice(last), myName);
}
function appendLinkified(parent, s, myName) {
let last = 0, m; URL_RE.lastIndex = 0;
while ((m = URL_RE.exec(s))) {
appendStyled(parent, s.slice(last, m.index), myName);
const a = document.createElement('a'); a.href = m[0]; a.textContent = m[0]; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.className = 't4x-lnk';
parent.appendChild(a); last = m.index + m[0].length;
}
appendStyled(parent, s.slice(last), myName);
}
function appendImage(parent, url) {
if (!/^https?:\/\//i.test(url)) { appendText(parent, '[img]' + url + '[/img]'); return; }
const im = document.createElement('img'); im.src = url; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer';
im.className = 't4x-cimg'; im.title = 'Ouvrir en grand';
im.addEventListener('click', () => window.open(url, '_blank', 'noreferrer'));
im.onerror = () => { const a = document.createElement('a'); a.href = url; a.textContent = url; a.target = '_blank'; a.rel = 'noreferrer noopener'; a.className = 't4x-lnk'; if (im.parentNode) im.replaceWith(a); };
parent.appendChild(im);
}
function renderBody(container, text, myName) {
container.replaceChildren();
text = String(text == null ? '' : text);
// block-level tags: [spoiler]…[/spoiler] and [img]url[/img]. Everything else → inline (bold/link/mention).
const BLOCK = /\[spoiler\]([\s\S]*?)\[\/spoiler\]|\[img\]\s*([^\[\]\s]+?)\s*\[\/img\]/gi;
let last = 0, m;
while ((m = BLOCK.exec(text))) {
appendLinkified(container, text.slice(last, m.index), myName);
if (m[1] !== undefined) {
const sp = document.createElement('span'); sp.className = 't4x-spoil'; sp.title = 'Cliquer pour révéler';
appendLinkified(sp, m[1], myName);
sp.addEventListener('click', () => sp.classList.add('on'));
container.appendChild(sp);
} else {
appendImage(container, m[2]);
}
last = m.index + m[0].length;
}
appendLinkified(container, text.slice(last), myName);
// bare image URLs (….png/.jpg/.gif/.webp) → render inline too, for messages without the [img] tag
// (handled by appendLinkified's autolink; we additionally upgrade image links below)
container.querySelectorAll && container.querySelectorAll('a.t4x-lnk').forEach((a) => {
if (/\.(png|jpe?g|gif|webp|avif)(\?|#|$)/i.test(a.href)) { const holder = document.createElement('span'); a.replaceWith(holder); appendImage(holder, a.href); }
});
}
// test-only hook: exposed solely when a harness sets window.__T4X_EXPOSE__ (never set in Tampermonkey).
try { if (window.__T4X_EXPOSE__) window.__t4xRB = renderBody; } catch (e) {}
// watchlist: does an incoming body mention me or a saved keyword?
function mentionHit(body) {
const b = String(body || '').toLowerCase();
if (chat.myName && b.includes('@' + chat.myName.toLowerCase())) return chat.myName;
const kw = String(pref0('chatKeywords', '') || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean);
for (const k of kw) if (k && b.includes(k)) return k;
return null;
}
function xtraStyle() {
if (chat._xtra || !root) return; chat._xtra = true;
const st = document.createElement('style');
st.textContent = '.t4x-spoil{background:var(--on-surface,#c8cede);color:transparent;border-radius:4px;padding:0 3px;cursor:pointer;filter:blur(.35em);transition:filter .12s,color .12s,background .12s}.t4x-spoil.on{filter:none;color:inherit;background:var(--surface-container-high,rgba(255,255,255,.06))}.t4x-ment{background:color-mix(in srgb,var(--primary,#93a5ff) 26%,transparent);color:var(--primary,#93a5ff);border-radius:4px;padding:0 3px;font-weight:600}.t4x-lnk{color:var(--primary,#93a5ff);text-decoration:underline}.m.ment-hit .mc{box-shadow:-3px 0 0 var(--primary,#93a5ff)}.t4x-cbar{display:flex;align-items:center;gap:3px;padding:5px 6px;margin-bottom:7px;border-radius:13px;background:var(--t4x-glass,rgba(15,17,23,.5));border:1px solid var(--t4x-hair,rgba(147,165,255,.12));backdrop-filter:blur(12px) saturate(150%);box-shadow:0 3px 14px rgba(0,0,0,.18)}.t4x-cbtn{all:unset;cursor:pointer;width:30px;height:30px;border-radius:9px;display:flex;align-items:center;justify-content:center;color:var(--on-surface-variant,#aab2c5);transition:background .13s,color .13s,transform .13s}.t4x-cbtn .ic{font-family:"Material Symbols Outlined";font-size:19px}.t4x-cbtn:hover{background:color-mix(in srgb,var(--primary,#93a5ff) 16%,transparent);color:var(--primary,#93a5ff)}.t4x-cbtn:active{transform:scale(.9)}.t4x-cbtn.busy{opacity:.45;cursor:default}.t4x-upstatus{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--on-surface-variant,#aab2c5);margin-left:auto;padding-right:6px}.t4x-drop{outline:2px dashed var(--primary,#93a5ff);outline-offset:3px;border-radius:12px}.t4x-cimg{max-width:100%;max-height:260px;border-radius:10px;display:block;margin:5px 0;cursor:zoom-in;border:1px solid var(--t4x-hair,rgba(147,165,255,.12))}';
root.appendChild(st);
}
function wrapSel(ta, open, close) {
const s = ta.selectionStart || 0, e = ta.selectionEnd || 0, v = ta.value;
ta.value = v.slice(0, s) + open + v.slice(s, e) + close + v.slice(e);
const pos = s + open.length + (e - s);
ta.focus(); ta.setSelectionRange(pos, pos);
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
const EMOJIS = '😀 😂 😅 😍 😎 🤨 🤔 😭 😡 😴 🥳 😏 🙃 👍 👎 🙏 👏 🔥 💯 🎉 ❤️ 💔 💀 👀 🤝 🙌 ✅ ❌ ⚡ ⭐ 🚀 🎬 📺 🎵 📚 🎮 💾 ⬇️ 🌱 🤖'.split(' ');
let emojiPop = null;
function toggleEmoji(ta, bar) {
if (emojiPop) { emojiPop.remove(); emojiPop = null; return; }
emojiPop = document.createElement('div');
emojiPop.style.cssText = 'display:grid;grid-template-columns:repeat(8,1fr);gap:2px;padding:8px;margin-bottom:6px;background:var(--surface-container-high,#1a1d26);border:1px solid var(--outline-variant,rgba(255,255,255,.15));border-radius:10px;max-height:150px;overflow-y:auto';
EMOJIS.forEach((em) => {
const b = document.createElement('button'); b.type = 'button'; b.textContent = em;
b.style.cssText = 'all:unset;cursor:pointer;font-size:18px;text-align:center;padding:4px;border-radius:6px';
b.addEventListener('mouseenter', () => b.style.background = 'var(--secondary-container,#2b3050)');
b.addEventListener('mouseleave', () => b.style.background = 'transparent');
b.addEventListener('click', () => { wrapSel(ta, em, ''); emojiPop.remove(); emojiPop = null; });
emojiPop.appendChild(b);
});
if (bar.parentNode) bar.parentNode.insertBefore(emojiPop, bar);
}
// ---- image auto-upload: pick / paste / drop → self-hosted store → insert [img]url[/img], zero friction ----
const UPLOAD_URL = 'https://t4x.ekaii.fr/upload';
let uploading = false;
function uploadImage(dataUrl) {
return new Promise((res, rej) => {
GM_xmlhttpRequest({
method: 'POST', url: UPLOAD_URL, headers: { 'Content-Type': 'application/json' },
data: JSON.stringify({ image: dataUrl }), timeout: 30000,
onload: (r) => { try { const j = JSON.parse(r.responseText); j.url ? res(j.url) : rej(new Error(j.error || 'échec')); } catch (e) { rej(new Error('réponse invalide')); } },
onerror: () => rej(new Error('serveur injoignable')), ontimeout: () => rej(new Error('délai dépassé')),
});
});
}
function readAsDataURL(file) { return new Promise((res, rej) => { const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => rej(new Error('lecture')); fr.readAsDataURL(file); }); }
function insertAtCursor(ta, text) {
if (!ta) return; const s = ta.selectionStart != null ? ta.selectionStart : ta.value.length, e = ta.selectionEnd != null ? ta.selectionEnd : ta.value.length, v = ta.value;
ta.value = v.slice(0, s) + text + v.slice(e); const pos = s + text.length; try { ta.setSelectionRange(pos, pos); } catch (_) {}
ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 120) + 'px';
}
function setComposerUploading(on) {
if (chat._imgBtn) { chat._imgBtn.disabled = on; chat._imgBtn.classList.toggle('busy', on); }
if (chat._upStatus) { chat._upStatus.style.display = on ? 'flex' : 'none'; chat._upStatus.replaceChildren(); if (on) { const sp = document.createElement('span'); sp.className = 'spin'; chat._upStatus.append(sp, document.createTextNode('Envoi de l’image…')); } }
}
async function handleImageFile(file, ta) {
if (!file || !/^image\//.test(file.type || '')) return;
if (file.size > 10 * 1024 * 1024) { toast('Image trop lourde (max 10 Mo).'); return; }
if (uploading) { toast('Un envoi est déjà en cours…'); return; }
uploading = true; setComposerUploading(true);
try {
const url = await uploadImage(await readAsDataURL(file));
insertAtCursor(ta || chat.inputEl, '[img]' + url + '[/img] ');
} catch (e) { toast('Envoi de l’image : ' + (e.message || 'échec')); }
uploading = false; setComposerUploading(false);
try { (ta || chat.inputEl) && (ta || chat.inputEl).focus(); } catch (_) {}
}
function mkMsg(m) {
const el = document.createElement('div'); el.className = 'm'; el.dataset.id = m.id;
const av = document.createElement('div'); av.className = 'av';
if (m.avatar_url && /^https?:\/\//i.test(m.avatar_url)) { const img = document.createElement('img'); img.loading = 'lazy'; img.referrerPolicy = 'no-referrer'; img.src = m.avatar_url; img.onerror = () => { av.textContent = initials(m.sender); img.remove(); }; av.appendChild(img); }
else av.textContent = initials(m.sender);
const mc = document.createElement('div'); mc.className = 'mc';
const mh = document.createElement('div'); mh.className = 'mh';
const who = document.createElement('span'); who.className = 'who'; who.textContent = m.sender || '?'; who.style.color = roleColor(m.sender_role);
const tm = document.createElement('span'); tm.className = 'tm'; tm.textContent = fmtTime(m.created_at || m.at);
mh.append(who, tm);
const acts = document.createElement('div'); acts.className = 'acts';
const rep = document.createElement('span'); rep.textContent = 'reply'; rep.title = 'Répondre'; rep.addEventListener('click', () => { chat.replyTo = { id: m.id, sender: m.sender, body: m.body }; renderComposer(); chat.inputEl && chat.inputEl.focus(); });
const lk = document.createElement('span'); lk.textContent = 'add_reaction'; lk.title = 'Réagir 👍'; lk.addEventListener('click', () => sendFrame({ type: 'reaction.add', message_id: m.id, emoji: '👍' }));
acts.append(rep, lk); mh.appendChild(acts);
mc.appendChild(mh);
if (m.parent && (m.parent.body || m.parent.sender)) { const pq = document.createElement('div'); pq.className = 'pq'; pq.textContent = '↳ ' + (m.parent.sender ? m.parent.sender + ' : ' : '') + String(m.parent.body || ''); mc.appendChild(pq); }
const bd = document.createElement('div'); bd.className = 'bd'; renderBody(bd, m.body, chat.myName); mc.appendChild(bd);
if (m.sender_id !== chat.myId && mentionHit(m.body)) el.classList.add('ment-hit');
if (Array.isArray(m.reactions) && m.reactions.length) {
const rr = document.createElement('div');
for (const rx of m.reactions) { const s = document.createElement('span'); s.className = 'rx'; s.textContent = (rx.emoji || '👍') + ' ' + (rx.count || rx.users && rx.users.length || 1); rr.appendChild(s); }
mc.appendChild(rr);
}
el.append(av, mc);
return el;
}
function atBottom() { const l = chat.listEl; return l && (l.scrollHeight - l.scrollTop - l.clientHeight < 80); }
function scrollBottom() { if (chat.listEl) chat.listEl.scrollTop = chat.listEl.scrollHeight; }
// look up a message row by id — CSS.escape so a crafted remote id can't break the selector
function msgEl(id) { if (!chat.listEl || id == null) return null; try { return chat.listEl.querySelector('.m[data-id="' + CSS.escape(String(id)) + '"]'); } catch (e) { return null; } }
async function selectChannel(id) {
chat.active = id; chat.replyTo = null; chat.unread[id] = 0;
renderTabs(); renderComposer(); refreshPillBadge(); // reflect the just-read channel in the pill badge immediately
chat.listEl.replaceChildren();
const load = document.createElement('div'); load.className = 'ro'; load.textContent = 'Chargement…'; chat.listEl.appendChild(load);
try {
const j = await apiGet('/api/conversations/' + id + '/messages?limit=50', { ttl: 8000 });
const msgs = pickArray(j, ['messages']).slice().reverse(); // API is newest-first → chrono
chat.listEl.replaceChildren();
for (const m of msgs) chat.listEl.appendChild(mkMsg(m));
if (msgs.length) chat.lastId[id] = msgs[msgs.length - 1].id;
scrollBottom();
} catch (e) { chat.listEl.replaceChildren(); const er = document.createElement('div'); er.className = 'ro'; er.textContent = 'Impossible de charger (' + e.message + ').'; chat.listEl.appendChild(er); }
}
function onChatFrame(f) {
if (f.type === 'msg.received') {
// watchlist alert (self-mention or saved keyword) — fires even for background channels
if (f.sender_id !== chat.myId && pref0('chatAlerts', true) && mentionHit(f.body)) {
const ch = (chat.channels || []).find(x => x.id === f.conv_id);
toast((f.sender || 'Quelqu’un') + ' t’a mentionné' + (ch ? ' dans #' + (ch.slug || ch.name) : ''), 6000);
}
if (f.conv_id === chat.active && chat.open) {
const stick = atBottom(); chat.listEl.appendChild(mkMsg(f)); chat.lastId[f.conv_id] = f.id; if (stick) scrollBottom();
} else if (f.sender_id !== chat.myId) {
chat.unread[f.conv_id] = (chat.unread[f.conv_id] || 0) + 1; if (chat.open) renderTabs(); refreshPillBadge();
}
} else if (f.type === 'msg.deleted') {
const el = msgEl(f.message_id); if (el) { el.querySelector('.bd').textContent = '(supprimé)'; el.style.opacity = '.5'; }
} else if (f.type === 'msg.edited') {
const el = msgEl(f.message_id); if (el) renderBody(el.querySelector('.bd'), f.body, chat.myName);
} else if (f.type === 'typing') {
if (f.conv_id === chat.active && f.user_id !== chat.myId && chat.typingEl) { chat.typingEl.textContent = (f.user || 'Quelqu’un') + ' écrit…'; clearTimeout(chat._typClr); chat._typClr = setTimeout(() => { chat.typingEl.textContent = ''; }, 3000); }
}
}
let typingSent = 0;
function onTyping() { const now = Date.now(); if (now - typingSent > 3500) { typingSent = now; sendFrame({ type: 'typing', conv_id: chat.active }); } }
function sendMessage(body) {
const frame = { type: 'msg.send', conv_id: chat.active, body };
if (chat.replyTo) frame.parent_id = chat.replyTo.id;
const ok = sendFrame(frame);
if (ok) { chat.replyTo = null; renderComposer(); } // success rebuilds an empty composer; on failure the text is kept
return ok;
}
async function openDrawer() {
if (!pref0('chat', true)) return;
ensureOwnSocket();
if (!chat.el) buildDrawer();
if (!chat.channels) { await loadChannels(); try { const m = await me(); chat.myRole = m.role || 'user'; chat.myId = m.id; chat.myName = m.username; } catch (e) {} }
chat.open = true; chat.el.classList.add('on'); markOpen(chat.el, true);
if (!chat._sub) chat._sub = onFrame((f) => onChatFrame(f));
renderTabs(); renderComposer();
await selectChannel(chat.active);
refreshPillBadge();
}
function closeDrawer() { chat.open = false; if (chat.el) { chat.el.classList.remove('on'); markOpen(chat.el, false); } }
function toggleDrawer() { chat.open ? closeDrawer() : openDrawer(); }
// ==========================================================================
// STATUS / HUD PILL
// ==========================================================================
let pillEl = null, pillUnreadEl = null, pillRatioEl = null, pillCreditEl = null, pillFlEl = null, pillVoiceBtn = null, pillAiBtn = null, pillUpBtn = null;
function buildPill() {
ensureChatCSS(); ensureHost();
if (pillEl) return;
const p = document.createElement('div'); p.className = 'pill';
const rs = document.createElement('div'); rs.className = 'stat'; rs.style.cursor = 'pointer'; rs.title = 'Évolution depuis ta dernière visite'; rs.addEventListener('click', showRatioDelta); const rk = document.createElement('span'); rk.className = 'k'; rk.textContent = 'Ratio'; pillRatioEl = document.createElement('span'); pillRatioEl.className = 'v'; pillRatioEl.textContent = '—'; rs.append(rk, pillRatioEl);
const cs = document.createElement('div'); cs.className = 'stat'; const ck = document.createElement('span'); ck.className = 'k'; ck.textContent = 'Crédits'; pillCreditEl = document.createElement('span'); pillCreditEl.className = 'v'; pillCreditEl.textContent = '—'; cs.append(ck, pillCreditEl);
pillFlEl = document.createElement('span'); pillFlEl.className = 'fl'; pillFlEl.textContent = 'FL'; pillFlEl.style.display = 'none';
const sep = document.createElement('div'); sep.className = 'sep';
const bk = document.createElement('button'); bk.title = 'Palette (Ctrl/⌘+K)'; bk.innerHTML = '<span class="ic">search</span>'; bk.addEventListener('click', openPalette);
const bc = document.createElement('button'); bc.title = 'Chat (Ctrl/⌘+J)'; bc.innerHTML = '<span class="ic">forum</span>'; bc.addEventListener('click', toggleDrawer);
pillUnreadEl = document.createElement('span'); pillUnreadEl.className = 'badge'; pillUnreadEl.style.display = 'none'; bc.appendChild(pillUnreadEl);
const ba = document.createElement('button'); ba.title = 'Assistant IA'; ba.innerHTML = '<span class="ic">auto_awesome</span>'; ba.addEventListener('click', toggleAi); pillAiBtn = ba;
ba.style.display = pref0('ai', false) ? '' : 'none'; // opt-in (v0.18)
const bv = document.createElement('button'); bv.title = 'Salon vocal'; bv.innerHTML = '<span class="ic">mic</span>'; bv.addEventListener('click', toggleVoice); pillVoiceBtn = bv;
const bu = document.createElement('button'); bu.title = 'Mise à jour disponible'; bu.innerHTML = '<span class="ic">system_update_alt</span>'; bu.addEventListener('click', openUpdate); pillUpBtn = bu;
bu.style.display = updateState.available ? '' : 'none';
bu.style.boxShadow = '0 0 0 2px var(--t4x-seed,#4ade80)';
const bm = document.createElement('button'); bm.title = 'Toutes les fonctions TR4KER+'; bm.innerHTML = '<span class="ic">apps</span>'; bm.addEventListener('click', (e) => { e.stopPropagation(); toggleLauncher(bm); });
p.append(rs, cs, pillFlEl, sep, bk, bc, ba, bu, bv, bm);
root.appendChild(p); pillEl = p;
refreshPill();
setInterval(refreshPill, 90000);
setInterval(refreshPillBadge, 60000);
}
// Feature launcher — a visible 1-click menu of everything TR4KER+ offers (discoverability).
let launcherEl = null;
function toggleLauncher() {
if (launcherEl) { launcherEl.remove(); launcherEl = null; return; }
const items = [
['search', 'Recherche universelle', 'Ctrl/⌘+K', openPalette],
['forum', 'Chat communautaire', 'Ctrl/⌘+J', openDrawer],
// the AI dock is opt-in (v0.18): only listed once the user has switched it on
...(pref0('ai', false) ? [['auto_awesome', 'Assistant IA', '', openAi]] : []),
...(updateState.available ? [['system_update_alt', 'Mettre à jour TR4KER+', 'v' + updateState.latest, openUpdate]] : []),
['mic', 'Salon vocal', 'bêta', openVoice],
['schedule', 'Demandes récentes', '', () => openRequests('recent')],
['person', 'Mes demandes', '', () => openRequests('mine')],
['notifications', 'Mots-clés chat à surveiller', '', () => { const cur = pref0('chatKeywords', ''); const v = prompt('Mots-clés séparés par des virgules :', cur); if (v !== null) { setPref('chatKeywords', v.trim()); toast('Surveillance mise à jour'); } }],
['contrast', 'Thème clair / sombre', '', toggleSiteTheme],
['palette', 'Changer d’accent', '', cycleAccent],
['tune', 'Réglages', '', openSettings],
['help', 'Aide & tutoriel', '', () => window.open('https://forgejo.ekaii.fr/tr4ker/tr4ker-plus', '_blank', 'noreferrer')],
];
const veil = document.createElement('div'); veil.style.cssText = 'position:fixed;inset:0;z-index:2147483001';
const m = document.createElement('div'); m.className = 't4x-launcher';
m.style.cssText = 'position:fixed;right:18px;bottom:124px;z-index:2147483002;width:272px;box-sizing:border-box;background:var(--t4x-glass,rgba(15,17,23,.92));backdrop-filter:blur(18px) saturate(160%);color:var(--on-surface,#e6e9f2);border:1px solid var(--t4x-hair,rgba(147,165,255,.14));border-radius:18px;box-shadow:0 20px 60px rgba(0,0,0,.5);overflow:hidden;padding:6px';
const title = document.createElement('div'); title.textContent = 'TR4KER+ — toutes les fonctions'; title.style.cssText = 'font-weight:700;font-size:11px;letter-spacing:.03em;opacity:.65;padding:8px 10px 6px'; m.appendChild(title);
items.forEach(([ic, label, hint, fn]) => {
const row = document.createElement('button'); row.type = 'button';
row.style.cssText = 'all:unset;display:flex;align-items:center;gap:11px;width:100%;box-sizing:border-box;padding:9px 10px;border-radius:9px;cursor:pointer;font-size:13px';
row.addEventListener('mouseenter', () => row.style.background = 'var(--secondary-container,#2b3050)');
row.addEventListener('mouseleave', () => row.style.background = 'transparent');
const i = document.createElement('span'); i.className = 'ic'; i.textContent = ic; i.style.cssText = 'font-family:"Material Symbols Outlined";font-size:19px;opacity:.85;flex:0 0 auto';
const l = document.createElement('span'); l.textContent = label; l.style.flex = '1';
row.append(i, l);
if (hint) { const h = document.createElement('span'); h.textContent = hint; h.style.cssText = 'font-size:10px;opacity:.5;flex:0 0 auto'; row.appendChild(h); }
row.addEventListener('click', () => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } try { fn(); } catch (e) { toast('Erreur : ' + e.message); } });
m.appendChild(row);
});
veil.addEventListener('click', () => { if (launcherEl) { launcherEl.remove(); launcherEl = null; } });
root.append(veil, m);
launcherEl = { remove() { m.remove(); veil.remove(); } };
}
async function refreshPill() {
try {
const m = await me();
if (m) {
const ratio = m.downloaded > 0 ? (m.uploaded / m.downloaded) : Infinity;
pillRatioEl.textContent = ratio === Infinity ? '∞' : ratio.toFixed(2);
pillRatioEl.style.color = ratio >= 1 ? 'var(--t4x-seed,#4ade80)' : ratio < 0.55 ? 'var(--error,#ffb4ab)' : 'var(--ann-color,#ffd28a)';
pillCreditEl.textContent = m.money != null ? Intl.NumberFormat('fr-FR', { notation: 'compact' }).format(m.money) : '—';
pillFlEl.style.display = m.freeleech_global ? '' : 'none';
chat.myId = m.id; chat.myRole = m.role || 'user';
// stats-since-last-visit: compute delta vs stored snapshot, refresh snapshot hourly
const snap = store.get('t4x.visit', null);
const now = { up: m.uploaded, dn: m.downloaded, money: m.money, ratio: (ratio === Infinity ? null : ratio), t: Date.now() };
window.__t4x_delta = (snap && typeof snap.up === 'number') ? { dUp: now.up - snap.up, dDn: now.dn - snap.dn, dMoney: (now.money || 0) - (snap.money || 0), dRatio: (now.ratio != null && snap.ratio != null) ? now.ratio - snap.ratio : null, ago: now.t - snap.t } : null;
if (!snap || now.t - snap.t > 3600000) store.set('t4x.visit', now);
}
} catch (e) {}
refreshPillBadge();
}
function showRatioDelta() {
const d = window.__t4x_delta;
if (!d) { toast('Référence enregistrée — reviens plus tard pour voir l’évolution de ton ratio.'); return; }
const hrs = Math.max(1, Math.round(d.ago / 3600000));
const rd = d.dRatio != null ? (' | ratio ' + (d.dRatio >= 0 ? '+' : '') + d.dRatio.toFixed(2)) : '';
toast('Depuis ~' + hrs + 'h : upload +' + fmtSize(Math.max(0, d.dUp)) + ' | download +' + fmtSize(Math.max(0, d.dDn)) + ' | crédits ' + (d.dMoney >= 0 ? '+' : '') + Math.round(d.dMoney) + rd, 7000);
}
let _lastUnread = 0;
function updateTabTitle(n) {
try { const cur = document.title.replace(/^\(\d+\+?\)\s+/, ''); document.title = n > 0 ? '(' + (n > 99 ? '99+' : n) + ') ' + cur : cur; } catch (e) {}
}
async function refreshPillBadge() {
let n = Object.values(chat.unread).reduce((a, b) => a + (b || 0), 0);
try { const u = await apiGet('/api/me/notifications/unread', { ttl: 45000 }); if (u && typeof u.chat === 'number' && !chat.open) n = Math.max(n, u.chat); } catch (e) {}
if (pillUnreadEl) { if (n > 0) { pillUnreadEl.textContent = n > 99 ? '99+' : n; pillUnreadEl.style.display = ''; } else pillUnreadEl.style.display = 'none'; }
_lastUnread = n; updateTabTitle(n); // unread count in the browser tab (community request)
}
// pref helper with default
function pref0(k, d) { const v = P[k]; return v === undefined ? d : v; }
// ==========================================================================
// AI ASSISTANT DOCK — streams from the self-hosted t4ai RAG service
// ==========================================================================
const T4AI_URL = 'https://t4ai.ekaii.fr';
function installId() {
let id = store.get('t4x.install', '');
if (!id) { id = 't4x_' + Math.random().toString(36).slice(2) + Date.now().toString(36); store.set('t4x.install', id); }
return id;
}
const ai = { el: null, listEl: null, inputEl: null, statEl: null, open: false, busy: false, cur: null };
function aiCSS() {
return `
.ai{ position:fixed; right:18px; bottom:74px; width:min(420px,94vw); height:min(560px,72vh); display:flex; flex-direction:column;
background:var(--t4x-glass,rgba(15,17,23,.92)); border:1px solid var(--t4x-hair,rgba(147,165,255,.14)); border-radius:18px;
box-shadow:0 24px 64px rgba(0,0,0,.5); backdrop-filter:blur(18px) saturate(160%); color:var(--on-surface,#e6e8f2);
transform:translateY(10px) scale(.98); opacity:0; transition:opacity .18s, transform .18s cubic-bezier(.3,0,0,1);
pointer-events:none; z-index:2147483002; overflow:hidden; }
.ai.on{ opacity:1; transform:none; pointer-events:auto; }
.ai .ah{ display:flex; align-items:center; gap:8px; padding:12px 14px; border-bottom:1px solid var(--outline-variant,#3a4056); }
.ai .ah .glyph{ width:20px;height:20px;border-radius:6px;background:var(--t4x-grad,linear-gradient(135deg,#93a5ff,#67e8f9)); }
.ai .ah b{ flex:1; font-size:14px; }
.ai .ah .astat{ font:11px/1 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); opacity:.7; }
/* per-answer timing: live counter while streaming, then first-word + total */
.ai .ameta{ font:11px/1.4 'JetBrains Mono',monospace; color:var(--on-surface-variant,#b8bdd1); opacity:.65; margin-top:5px; }
.ai .ah .x{ cursor:pointer; font-family:'Material Symbols Outlined'; color:var(--on-surface-variant,#b8bdd1); }
.ai .amsgs{ flex:1; overflow-y:auto; padding:12px 14px; display:flex; flex-direction:column; gap:12px; }
.ai .am{ font-size:13px; line-height:1.5; }
.ai .am.u{ align-self:flex-end; max-width:85%; background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff);
padding:8px 12px; border-radius:14px 14px 4px 14px; }
.ai .am.a{ align-self:flex-start; max-width:94%; }
.ai .am.a .body{ background:var(--surface-container,#1a1e2b); padding:10px 13px; border-radius:14px 14px 14px 4px; }
.ai .am.a .body p{ margin:0 0 8px; } .ai .am.a .body p:last-child{ margin:0; }
.ai .am.a .body strong{ color:var(--on-surface,#e6e8f2); } .ai .am.a .body code{ font-family:'JetBrains Mono',monospace; font-size:12px; background:var(--surface-container-high,#232838); padding:1px 5px; border-radius:5px; }
.ai .am.a .body a{ color:var(--primary,#93a5ff); }
.ai .am.a .cites{ margin-top:6px; display:flex; gap:6px; flex-wrap:wrap; }
.ai .am.a .cites a{ font:11px/1 'JetBrains Mono',monospace; color:var(--primary,#93a5ff); border:1px solid var(--outline-variant,#3a4056); border-radius:999px; padding:4px 8px; text-decoration:none; }
.ai .am.err{ align-self:flex-start; color:var(--error,#ffb4ab); font-size:12px; }
.ai .cursorblink::after{ content:'▍'; animation:t4blink 1s steps(2) infinite; color:var(--primary,#93a5ff); }
@keyframes t4blink{ 50%{ opacity:0; } }
.ai .foot{ border-top:1px solid var(--outline-variant,#3a4056); padding:8px 10px; }
.ai .foot .note{ font-size:10px; color:var(--on-surface-variant,#b8bdd1); text-align:center; padding-bottom:6px; }
.ai textarea{ width:100%; resize:none; background:var(--surface-container,#1a1e2b); color:var(--on-surface,#e6e8f2);
border:1px solid var(--outline-variant,#3a4056); border-radius:12px; padding:9px 12px; font:14px/1.4 'Geist Variable',sans-serif; outline:none; }
.ai textarea:focus{ border-color:var(--primary,#93a5ff); }
.ai .starter{ display:flex; flex-wrap:wrap; gap:6px; padding:0 0 8px; }
.ai .starter button{ all:unset; cursor:pointer; font-size:12px; padding:6px 10px; border-radius:999px; background:var(--surface-container,#1a1e2b);
border:1px solid var(--outline-variant,#3a4056); color:var(--on-surface-variant,#b8bdd1); }
.ai .starter button:hover{ background:var(--secondary-container,#2b3050); color:var(--on-secondary-container,#dfe3ff); }
`;
}
let aiCssAdded = false;
function ensureAiCSS() { if (aiCssAdded) return; ensureHost(); const s = document.createElement('style'); s.textContent = aiCSS(); root.appendChild(s); aiCssAdded = true; }
function routeInfo() {
const p = location.pathname;
const m = p.match(/^\/torrents?\/([^/?#]+)/);
return { route: p, slug: m ? m[1] : '' };
}
// safe markdown-lite → DOM (no innerHTML on model output)
function mdLite(text) {
const frag = document.createDocumentFragment();
for (const para of String(text).split(/\n\n+/)) {
const p = document.createElement('p');
_inline(para.replace(/\n/g, ' '), p);
frag.appendChild(p);
}
return frag;
}
function _inline(s, parent) {
// tokens: **bold**, `code`, [wiki:slug], bare https url
const re = /(\*\*[^*]+\*\*|`[^`]+`|\[wiki:[a-z0-9-]+\]|https?:\/\/[^\s)]+)/gi;
let last = 0, m;
while ((m = re.exec(s))) {
if (m.index > last) parent.appendChild(document.createTextNode(s.slice(last, m.index)));
const t = m[0];
if (t.startsWith('**')) { const b = document.createElement('strong'); b.textContent = t.slice(2, -2); parent.appendChild(b); }
else if (t.startsWith('`')) { const c = document.createElement('code'); c.textContent = t.slice(1, -1); parent.appendChild(c); }
else if (t.startsWith('[wiki:')) { const slug = t.slice(6, -1); const a = document.createElement('a'); a.textContent = slug; a.href = '/wiki/' + slug; a.addEventListener('click', (e) => { e.preventDefault(); closeAi(); navigate('/wiki/' + slug); }); parent.appendChild(a); }
else { const a = document.createElement('a'); a.textContent = t; a.href = t; a.target = '_blank'; a.rel = 'noreferrer'; parent.appendChild(a); }
last = re.lastIndex;
}
if (last < s.length) parent.appendChild(document.createTextNode(s.slice(last)));
}
function buildAiDock() {
ensureAiCSS();
const el = document.createElement('div'); el.className = 'ai'; el.setAttribute('role', 'dialog'); el.setAttribute('aria-label', 'Assistant TR4KER+');
const h = document.createElement('div'); h.className = 'ah';
const g = document.createElement('div'); g.className = 'glyph';
const b = document.createElement('b'); b.textContent = 'Assistant';
const stat = document.createElement('span'); stat.className = 'astat'; ai.statEl = stat;
const x = document.createElement('span'); x.className = 'x'; x.textContent = 'close'; x.title = 'Fermer'; x.addEventListener('click', closeAi);
h.append(g, b, stat, x);
const list = document.createElement('div'); list.className = 'amsgs'; list.setAttribute('role', 'log'); list.setAttribute('aria-live', 'polite');
const foot = document.createElement('div'); foot.className = 'foot';
const note = document.createElement('div'); note.className = 'note'; note.textContent = 'Outil communautaire non officiel — non affilié au staff.';
const ta = document.createElement('textarea'); ta.rows = 1; ta.placeholder = 'Pose ta question sur TR4KER…'; ta.setAttribute('aria-label', 'Question');
ta.addEventListener('input', () => { ta.style.height = 'auto'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px'; });
ta.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); const v = ta.value.trim(); if (v && !ai.busy) { ta.value = ''; ta.style.height = 'auto'; askAi(v); } } });
foot.append(note, ta);
el.append(h, list, foot);
root.appendChild(el);
ai.el = el; ai.listEl = list; ai.inputEl = ta;
el.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeAi(); }, true);
aiRefreshStat();
_aiWelcome();
}
function _aiWelcome() {
ai.listEl.replaceChildren();
const w = document.createElement('div'); w.className = 'am a';
const body = document.createElement('div'); body.className = 'body';
body.appendChild(mdLite("Salut. Je réponds sur le fonctionnement de TR4KER (ratio, crédits, upload, cross-seed, bugs connus…) à partir du wiki et de la communauté. Je ne suis pas le staff : pour un souci de compte, ouvre un ticket."));
w.appendChild(body);
const st = document.createElement('div'); st.className = 'starter';
const starters = [['Expliquer cette page', () => askAi('Explique-moi cette page et ce que je peux y faire.', true)],
['Améliorer mon ratio', () => askAi('Comment améliorer mon ratio ?')],
['Le freeleech ?', () => askAi("C'est quoi le freeleech et comment en profiter ?")]];
for (const [label, fn] of starters) { const bt = document.createElement('button'); bt.textContent = label; bt.addEventListener('click', () => { if (!ai.busy) fn(); }); st.appendChild(bt); }
ai.listEl.append(w, st);
}
function openAi() {
if (!pref0('ai', false)) { toast('Assistant IA désactivé — active-le dans les réglages TR4KER+.'); return; }
if (!ai.el) buildAiDock();
ai.open = true; ai.el.classList.add('on'); markOpen(ai.el, true);
setTimeout(() => ai.inputEl && ai.inputEl.focus(), 20);
}
function closeAi() { ai.open = false; if (ai.el) { ai.el.classList.remove('on'); markOpen(ai.el, false); } }
function toggleAi() { ai.open ? closeAi() : openAi(); }
function _aiAppendUser(q) { const m = document.createElement('div'); m.className = 'am u'; m.textContent = q; ai.listEl.appendChild(m); ai.listEl.scrollTop = ai.listEl.scrollHeight; }
function _aiNewAnswer() {
const m = document.createElement('div'); m.className = 'am a';
const body = document.createElement('div'); body.className = 'body cursorblink';
m.appendChild(body); ai.listEl.appendChild(m); ai.listEl.scrollTop = ai.listEl.scrollHeight;
return body;
}
// ---- answer timing -------------------------------------------------------
// The RAG service shells out to an LLM, so answers take seconds, not milliseconds. Show the wait
// as it happens (a frozen dock reads as broken) and the real cost afterwards: time to the first
// word and total time. Keep the last 20 totals so the header can advertise a realistic median.
const secs = (ms) => (ms / 1000).toFixed(1).replace('.', ',') + ' s';
function aiRecord(ms) {
const a = store.get('t4x.aiTimes', []); a.push(Math.round(ms));
store.set('t4x.aiTimes', a.slice(-20)); aiRefreshStat();
}
function aiMedian() {
const a = (store.get('t4x.aiTimes', []) || []).slice().sort((x, y) => x - y);
if (!a.length) return null;
return a.length % 2 ? a[(a.length - 1) / 2] : Math.round((a[a.length / 2 - 1] + a[a.length / 2]) / 2);
}
function aiRefreshStat() {
if (!ai.statEl) return;
const m = aiMedian();
ai.statEl.textContent = m ? '~' + secs(m) + ' / question' : '';
ai.statEl.title = m ? 'Temps de réponse médian sur tes ' + (store.get('t4x.aiTimes', []) || []).length + ' dernières questions' : '';
}
function askAi(q, explain) {
if (ai.busy) return;
if (!ai.el) buildAiDock();
if (!ai.open) openAi();
// clear the welcome/starters on first real turn
if (ai.listEl.querySelector('.starter')) ai.listEl.replaceChildren();
_aiAppendUser(q);
const body = _aiNewAnswer();
ai.busy = true;
const info = routeInfo();
let acc = '';
const t0 = performance.now();
let tFirst = null, failed = false;
const meta = document.createElement('div'); meta.className = 'ameta'; meta.textContent = '… 0,0 s';
body.parentNode.appendChild(meta);
const tick = setInterval(() => { meta.textContent = '… ' + secs(performance.now() - t0); }, 100);
const render = () => { body.replaceChildren(mdLite(acc)); ai.listEl.scrollTop = ai.listEl.scrollHeight; };
streamAi({ q, route: info.route, slug: info.slug, context: explain ? 'La personne demande une explication de la page actuelle.' : '' }, {
onText: (d) => { if (tFirst === null) tFirst = performance.now() - t0; acc += d; render(); },
onCites: (slugs) => {
if (!slugs || !slugs.length) return;
const c = document.createElement('div'); c.className = 'cites';
for (const s of slugs) { const a = document.createElement('a'); a.textContent = s; a.href = '/wiki/' + s; a.addEventListener('click', (e) => { e.preventDefault(); closeAi(); navigate('/wiki/' + s); }); c.appendChild(a); }
body.parentNode.appendChild(c);
},
onErr: (msg) => { failed = true; body.classList.remove('cursorblink'); const e = document.createElement('div'); e.className = 'am err'; e.textContent = '⚠ ' + (msg || 'erreur'); ai.listEl.appendChild(e); },
onDone: () => {
clearInterval(tick);
body.classList.remove('cursorblink'); ai.busy = false;
if (!acc.trim() && !body.textContent) body.textContent = '(pas de réponse)';
const total = performance.now() - t0;
meta.textContent = 'réponse en ' + secs(total) + (tFirst !== null ? ' · premier mot ' + secs(tFirst) : '');
body.parentNode.appendChild(meta); // keep the timing line below the citations
if (!failed && acc.trim()) aiRecord(total); // only real answers feed the median
},
});
}
function streamAi(payload, cb) {
let cursor = 0;
const feed = (buf) => {
let idx;
while ((idx = buf.indexOf('\n\n', cursor)) !== -1) {
const frame = buf.slice(cursor, idx); cursor = idx + 2;
for (const line of frame.split('\n')) {
if (!line.startsWith('data:')) continue;
let d; try { d = JSON.parse(line.slice(5).trim()); } catch (e) { continue; }
if (d.type === 'text') cb.onText(d.delta || (d.data || ''));
else if (d.type === 'citations') cb.onCites(d.slugs || []);
else if (d.type === 'refusal') cb.onErr(d.message || 'Je ne peux pas répondre à ça.');
else if (d.type === 'error') cb.onErr(d.message || (d.data || 'erreur'));
}
}
};
try {
GM_xmlhttpRequest({
method: 'POST', url: T4AI_URL + '/v1/chat',
headers: { 'Content-Type': 'application/json', 'X-Install-Id': installId(), 'Accept': 'text/event-stream' },
data: JSON.stringify(payload),
onprogress: (r) => { try { feed(r.responseText || ''); } catch (e) {} },
onload: (r) => { try { feed(r.responseText || ''); } catch (e) {} cb.onDone(); },
onerror: () => { cb.onErr('assistant injoignable'); cb.onDone(); },
ontimeout: () => { cb.onErr('délai dépassé'); cb.onDone(); },
timeout: 120000,
});
} catch (e) { cb.onErr('GM_xmlhttpRequest indisponible'); cb.onDone(); }
}
// ==========================================================================
// SETTINGS PANEL (minimal for v0.1)
// ==========================================================================
let settingsEl = null;
function openSettings() {
ensureHost();
if (settingsEl) { settingsEl(); return; } // settingsEl is the close() fn; calling it detaches + nulls
const veil = document.createElement('div'); veil.className = 'veil on';
const panel = document.createElement('div'); panel.className = 'pal on'; panel.style.maxWidth = '440px'; panel.style.top = '18vh';
panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'Réglages TR4KER+');
const h = document.createElement('header'); const g = document.createElement('div'); g.className = 'glyph';
const ti = document.createElement('div'); ti.style.cssText = 'flex:1;font-size:15px;font-weight:600'; ti.textContent = 'TR4KER+ ' + VERSION;
const x = document.createElement('span'); x.className = 'hintk'; x.textContent = 'esc'; x.style.cursor = 'pointer';
h.append(g, ti, x);
const body = document.createElement('div'); body.className = 'list'; body.style.padding = '10px 14px';
const toggles = [['theme', 'Thème « Encre & Signal »'], ['polish', 'Finitions (scrollbars, focus, chiffres)'], ['palette', 'Palette de commandes (Ctrl/⌘+K)'], ['chat', 'Chat sur toutes les pages (Ctrl/⌘+J)'], ['hud', 'HUD ratio / crédits (pastille)'], ['ai', 'Assistant IA (t4ai) — désactivé par défaut'], ['updates', 'Me prévenir des mises à jour de TR4KER+']];
for (const [k, label] of toggles) {
const rowd = document.createElement('label'); rowd.style.cssText = 'display:flex;align-items:center;gap:10px;padding:10px 4px;cursor:pointer;font-size:14px;color:var(--on-surface,#e6e8f2)';
const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = !!P[k];
cb.addEventListener('change', () => {
P = setPref(k, cb.checked); applyTheme();
// apply feature toggles live (no reload needed)
if (k === 'hud') { if (cb.checked) { try { buildPill(); } catch (e) {} } else if (pillEl) { pillEl.remove(); pillEl = null; pillAiBtn = null; pillUpBtn = null; pillVoiceBtn = null; } }
else if (k === 'chat' && !cb.checked) { closeDrawer(); }
else if (k === 'ai') { if (!cb.checked) closeAi(); if (pillAiBtn) pillAiBtn.style.display = cb.checked ? '' : 'none'; }
else if (k === 'updates' && cb.checked) { checkUpdate(true); }
});
const sp = document.createElement('span'); sp.textContent = label; sp.style.flex = '1';
rowd.append(cb, sp); body.appendChild(rowd);
}
const accRow = document.createElement('div'); accRow.style.cssText = 'display:flex;gap:8px;padding:12px 4px;align-items:center;flex-wrap:wrap';
const accLbl = document.createElement('span'); accLbl.textContent = 'Accent'; accLbl.style.cssText = 'font-size:14px;color:var(--on-surface,#e6e8f2);flex:1 0 100%';
accRow.appendChild(accLbl);
['indigo', 'cyan', 'amber', 'rose'].forEach(name => {
const b = document.createElement('button'); b.textContent = name;
b.style.cssText = 'padding:7px 12px;border-radius:999px;border:1px solid var(--outline-variant,#3a4056);cursor:pointer;font-size:12px;background:' + (P.accent === name ? 'var(--secondary-container,#2b3050)' : 'transparent') + ';color:var(--on-surface,#e6e8f2)';
b.addEventListener('click', () => { P = setPref('accent', name); applyTheme(); openSettings(); openSettings(); });
accRow.appendChild(b);
});
body.appendChild(accRow);
// version + on-demand update check (Tampermonkey's own polling is daily at best)
const verRow = document.createElement('div'); verRow.style.cssText = 'display:flex;align-items:center;gap:8px;padding:12px 4px 4px;font-size:13px;color:var(--on-surface,#e6e8f2);border-top:1px solid var(--outline-variant,rgba(255,255,255,.08));margin-top:6px;flex-wrap:wrap';
const verTxt = document.createElement('span'); verTxt.style.flex = '1 0 auto';
const paintVer = () => {
verTxt.textContent = updateState.available
? 'Version ' + VERSION + ' — v' + updateState.latest + ' disponible'
: 'Version ' + VERSION + (updateState.latest ? ' — à jour' : '');
verTxt.style.color = updateState.available ? 'var(--t4x-seed,#4ade80)' : '';
};
paintVer();
const btnCss = 'padding:7px 12px;border-radius:999px;border:1px solid var(--outline-variant,#3a4056);cursor:pointer;font-size:12px;background:transparent;color:var(--on-surface,#e6e8f2)';
const bChk = document.createElement('button'); bChk.textContent = 'Vérifier'; bChk.style.cssText = btnCss;
bChk.addEventListener('click', () => { bChk.textContent = '…'; checkUpdate(true); setTimeout(() => { bChk.textContent = 'Vérifier'; paintVer(); bUp.style.display = updateState.available ? '' : 'none'; }, 1600); });
const bUp = document.createElement('button'); bUp.textContent = 'Mettre à jour'; bUp.style.cssText = btnCss + ';border-color:var(--primary,#93a5ff)';
bUp.style.display = updateState.available ? '' : 'none';
bUp.addEventListener('click', openUpdate);
verRow.append(verTxt, bChk, bUp);
body.appendChild(verRow);
const note = document.createElement('div'); note.className = 'empty'; note.style.textAlign = 'left';
note.textContent = 'Outil communautaire non officiel — non affilié au staff TR4KER.';
body.appendChild(note);
panel.append(h, body);
root.append(veil, panel); // keep them IN the shadow root (previously moved into a detached div → invisible)
const onEsc = (e) => { if (e.key === 'Escape') close(); };
const close = () => { veil.remove(); panel.remove(); document.removeEventListener('keydown', onEsc, true); settingsEl = null; };
settingsEl = close;
veil.addEventListener('click', close); x.addEventListener('click', close);
document.addEventListener('keydown', onEsc, true);
}
// ==========================================================================
// HOTKEYS + MENU + BOOT
// ==========================================================================
function hotkeys(e) {
const mod = IS_MAC ? e.metaKey : e.ctrlKey;
if (mod && !e.shiftKey && !e.altKey && (e.key === 'k' || e.key === 'K')) {
e.preventDefault(); e.stopPropagation(); togglePalette();
} else if (mod && !e.shiftKey && !e.altKey && (e.key === 'j' || e.key === 'J')) {
e.preventDefault(); e.stopPropagation(); toggleDrawer();
} else if (mod && e.shiftKey && !e.altKey && (e.key === 'x' || e.key === 'X')) {
e.preventDefault(); e.stopPropagation(); closeAllOverlays(); // panic: unstick the UI
}
}
function menu() {
try {
GM_registerMenuCommand('Ouvrir la palette (Ctrl/⌘+K)', openPalette);
GM_registerMenuCommand('Débloquer l’interface — fermer tous les panneaux (Ctrl/⌘+Maj+X)', () => closeAllOverlays());
GM_registerMenuCommand('Vérifier les mises à jour de TR4KER+', () => checkUpdate(true));
GM_registerMenuCommand('Réglages TR4KER+', openSettings);
GM_registerMenuCommand('Chat : mots-clés à surveiller', () => {
const cur = pref0('chatKeywords', '');
const v = prompt('Mots-clés séparés par des virgules — une alerte s’affiche quand ils apparaissent dans le chat (ton pseudo est déjà surveillé) :', cur);
if (v !== null) { setPref('chatKeywords', v.trim()); toast('Surveillance du chat mise à jour'); }
});
GM_registerMenuCommand(P.enabled ? 'Désactiver TR4KER+' : 'Activer TR4KER+', () => {
P = setPref('enabled', !P.enabled); toast('TR4KER+ ' + (P.enabled ? 'activé' : 'désactivé') + ' — rechargez la page'); });
} catch (e) {}
}
function boot() {
if (SAFE_BOOT) { log('safe boot — features off'); whenHtml(menu); return; }
if (!P.enabled) { log('disabled'); whenHtml(menu); return; }
patchWS(); // must precede the SPA creating its socket (we run at document-start)
patchHistory(); // no DOM dependency
whenHtml(() => {
applyTheme();
applySiteTheme();
// the site sets data-theme during its React init (after us) → re-apply our saved choice a few times
[200, 700, 1600].forEach((d) => setTimeout(applySiteTheme, d));
ensureHost();
watchHost();
document.addEventListener('keydown', hotkeys, true);
// Safety net against invisible-but-clickable overlays (see OVERLAY INERTNESS). Cheap: a handful
// of shadow-root children, and each pass only writes when the state actually changed.
document.addEventListener('pointerdown', inertSweep, true);
setInterval(inertSweep, 1500);
window.addEventListener('t4x:locationchange', () => setTimeout(() => { updateTabTitle(_lastUnread); applySiteTheme(); }, 500)); // re-apply badge + theme after SPA sets title
window.addEventListener('t4x:locationchange', () => scheduleSweep());
// Subscribe to chat frames globally so @mention/keyword alerts + tab-title unread fire WITHOUT first opening
// the drawer. We rely on the SPA's teed socket (no eager 2nd connection); warm identity for mention matching.
if (pref0('chat', true) && !chat._sub) { chat._sub = onFrame((f) => onChatFrame(f)); ensureIdentity(); loadChannels(); }
if (pref0('hud', true)) { try { buildPill(); } catch (e) { log('pill err', e); } }
// update check: once shortly after boot (cached 6 h), then every 6 h on long-lived tabs
setTimeout(() => checkUpdate(false), 6000);
setInterval(() => checkUpdate(false), UPDATE_EVERY);
menu();
try { GM_addValueChangeListener('t4x.prefs', () => { P = prefs(); applyTheme(); }); } catch (e) {}
log('ready', VERSION);
});
}
boot();
})();