// Detail panel: General / Trackers / Peers / Content (files) / Pieces. import { api } from './api.js'; import * as f from './format.js'; const TABS = ['general', 'trackers', 'peers', 'content', 'pieces']; let activeTab = 'general'; let currentHash = null; let refreshTimer = null; let detailHeight = 300; // px, persisted across re-renders so resizing sticks export function renderDetailShell(hash, host) { currentHash = hash; // Drive the container's grid track (persists across row clicks / re-renders), // clamped so the panel always fits inside the available area. const view = host.closest('.torrents-view'); if (view) { detailHeight = clampDetailHeight(view, detailHeight); view.style.setProperty('--detail-h', `${detailHeight}px`); } host.innerHTML = `
${TABS.map((t) => ``).join('')}
`; host.querySelectorAll('[data-dtab]').forEach((b) => b.addEventListener('click', () => { activeTab = b.dataset.dtab; renderDetailShell(currentHash, host); })); setupResize(host); loadTab(); } function tabLabel(t) { return { general: 'General', trackers: 'Trackers', peers: 'Peers', content: 'Content', pieces: 'Pieces' }[t]; } export function closeDetail() { currentHash = null; if (refreshTimer) clearInterval(refreshTimer); refreshTimer = null; } async function loadTab() { const body = document.getElementById('detailBody'); if (!body || !currentHash) return; if (refreshTimer) clearInterval(refreshTimer); const render = async () => { if (!currentHash) return; try { if (activeTab === 'general') body.innerHTML = renderGeneral(await api.torrent(currentHash)); else if (activeTab === 'trackers') body.innerHTML = renderTrackers(await api.trackers(currentHash)); else if (activeTab === 'peers') body.innerHTML = renderPeers(await api.peers(currentHash)); else if (activeTab === 'content') { body.innerHTML = renderFiles(await api.files(currentHash)); bindFilePriorities(body); } else if (activeTab === 'pieces') body.innerHTML = renderPieces(await api.pieces(currentHash)); } catch { /* ignore transient */ } }; await render(); // live-refresh the dynamic tabs if (['peers', 'pieces', 'general'].includes(activeTab)) refreshTimer = setInterval(render, 1500); } /* ---------- General ---------- */ function renderGeneral(t) { const row = (k, v, mono) => `
${k}${v}
`; return `
Transfer
${row('Status', `${f.stateLabel(t.state)}`)} ${row('Progress', f.pct(t.progress))} ${row('Downloaded', f.bytes(t.downloaded))} ${row('Uploaded', f.bytes(t.uploaded))} ${row('Down speed', f.rate(t.dlspeed))} ${row('Up speed', f.rate(t.upspeed))} ${row('Share ratio', f.ratio(t.ratio))} ${row('ETA', f.eta(t.eta))} ${row('Availability', t.availability.toFixed(3))} ${row('Seeds', `${t.seeds} (${t.seedsTotal})`)} ${row('Peers', `${t.peers} (${t.peersTotal})`)} ${row('Down limit', t.downLimit ? f.rate(t.downLimit) : '∞')} ${row('Up limit', t.upLimit ? f.rate(t.upLimit) : '∞')} ${row('Ratio limit', t.ratioLimit > 0 ? t.ratioLimit.toFixed(2) : 'global')} ${row('Session DL / UL', `${f.bytes(t.downloadedSession)} / ${f.bytes(t.uploadedSession)}`)} ${row('Time active', f.duration(t.timeActive))}
Information
${row('Name', f.esc(t.name), true)} ${row('Total size', f.bytes(t.size))} ${row('Pieces', `${t.pieceCount} × ${f.bytes(t.pieceSize)}`)} ${row('Save path', f.esc(t.savePath), true)} ${row('Content path', f.esc(t.contentPath), true)} ${row('Category', t.category ? `${f.esc(t.category)}` : '—')} ${row('Tags', t.tags.length ? t.tags.map((x) => `${f.esc(x)}`).join(' ') : '—')} ${row('Added on', f.date(t.addedOn))} ${row('Completed on', f.date(t.completionOn))} ${row('Last activity', f.ago(t.lastActivity))} ${row('Hash (v1)', t.hash, true)} ${row('Privacy', t.private ? 'PRIVATE' : 'Public (DHT/PeX/LSD)')} ${row('Created by', f.esc(t.createdBy), true)} ${row('Creation date', f.date(t.creationDate))} ${row('Sequential', t.seqDl ? 'On' : 'Off')} ${row('Super seeding', t.superSeeding ? 'On' : 'Off')} ${row('Auto TMM', t.autoTMM ? 'On' : 'Off')} ${row('Force start', t.forceStart ? 'On' : 'Off')} ${t.comment ? row('Comment', f.esc(t.comment)) : ''}
`; } /* ---------- Trackers ---------- */ function renderTrackers(trackers) { return ` ${trackers.map((tr) => ``).join('')}
TierURLStatusSeedsPeers LeechesDownloadedMessage
${tr.tier < 0 ? '—' : tr.tier} ${f.esc(tr.url)} ${tr.status} ${tr.seeds < 0 ? '—' : tr.seeds} ${tr.peers < 0 ? '—' : tr.peers} ${tr.leeches < 0 ? '—' : tr.leeches} ${tr.downloaded < 0 ? '—' : tr.downloaded} ${f.esc(tr.message)}
`; } /* ---------- Peers ---------- */ function renderPeers(peers) { if (!peers.length) return '
No peers connected
'; const sorted = [...peers].sort((a, b) => (b.dlspeed + b.upspeed) - (a.dlspeed + a.upspeed)); return ` ${sorted.map((p) => ``).join('')}
CountryIP : PortClientConnFlags ProgressDownUp DownloadedUploadedRel.
${p.country} ${p.ip}:${p.port} ${f.esc(p.client)} ${p.connection} ${p.flags || '·'} ${f.pct(p.progress)} ${p.dlspeed ? f.rate(p.dlspeed) : '–'} ${p.upspeed ? f.rate(p.upspeed) : '–'} ${f.bytes(p.downloaded)} ${f.bytes(p.uploaded)} ${f.pct(p.relevance)}
`; } /* ---------- Content / files ---------- */ function renderFiles(files) { return ` ${files.map((file, i) => ``).join('')}
NameSizeProgress% PriorityAvailability
${f.esc(file.name)} ${f.bytes(file.size)} ${f.pct(file.progress)} ${file.availability.toFixed(2)}
`; } function bindFilePriorities(body) { const hash = currentHash; body.querySelectorAll('.prio-sel').forEach((sel) => sel.addEventListener('change', () => api.action('setFilePriority', [hash], { index: +sel.dataset.file, priority: +sel.value }))); } /* ---------- Pieces ---------- */ function renderPieces(data) { const { pieces, pieceSize, pieceCount } = data; let done = 0, dl = 0; for (const p of pieces) { if (p === 2) done++; else if (p === 1) dl++; } const cells = pieces.map((p) => ``).join(''); return `
Have (${done}) Downloading (${dl}) Missing (${pieceCount - done - dl}) ${pieceCount} pieces · ${f.bytes(pieceSize)} each
${cells}
`; } /* ---------- resize ---------- */ // Keep the panel between 120px and (container height − 80px) so the list above // always keeps a usable strip and the panel never overflows/clips. function clampDetailHeight(view, h) { const maxH = Math.max(120, view.clientHeight - 80); return Math.round(Math.max(120, Math.min(maxH, h))); } function setupResize(host) { const handle = host.querySelector('#detailResize'); const view = host.closest('.torrents-view'); if (!handle || !view) return; handle.addEventListener('pointerdown', (e) => { e.preventDefault(); handle.setPointerCapture(e.pointerId); const startY = e.clientY; const startH = detailHeight; document.body.style.userSelect = 'none'; document.body.style.cursor = 'ns-resize'; const move = (ev) => { detailHeight = clampDetailHeight(view, startH + (startY - ev.clientY)); view.style.setProperty('--detail-h', `${detailHeight}px`); }; const up = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); document.body.style.userSelect = ''; document.body.style.cursor = ''; }; handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up); }); }