const state = { localDir: '', remoteUrl: '', localEntries: [], remoteEntries: [], hasWget: false, remoteTree: { rootUrl: '', currentUrl: '', nodes: new Map(), }, transfer: null, }; const el = { remoteInput: document.querySelector('#remote-url'), remoteSubmit: document.querySelector('#remote-submit'), remoteList: document.querySelector('#remote-list'), remoteListBody: document.querySelector('#remote-list-body'), remoteTree: document.querySelector('#remote-tree'), localPath: document.querySelector('#local-path'), localBrowse: document.querySelector('#local-browse'), localList: document.querySelector('#local-list'), localListBody: document.querySelector('#local-list-body'), downloadBtn: document.querySelector('#download-btn'), useWget: document.querySelector('#use-wget'), themeToggle: document.querySelector('#theme-toggle'), progress: document.querySelector('#transfer-progress'), progressBar: document.querySelector('#transfer-bar'), progressMeta: document.querySelector('#transfer-meta'), progressMetaSecondary: document.querySelector('#transfer-meta-secondary'), logList: document.querySelector('#log-list'), }; function formatBytes(size) { if (size === 0) return '0 B'; if (!size) return '-'; const units = ['B', 'KB', 'MB', 'GB', 'TB']; let value = size; let unitIndex = 0; while (value >= 1024 && unitIndex < units.length - 1) { value /= 1024; unitIndex += 1; } return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`; } function formatDuration(seconds) { if (!seconds || !Number.isFinite(seconds)) return ''; const rounded = Math.max(0, Math.round(seconds)); const mins = Math.floor(rounded / 60); const secs = rounded % 60; if (mins > 0) return `${mins}m ${secs}s`; return `${secs}s`; } function showProgress({ percent, label, indeterminate }) { el.progress.classList.add('active'); el.progress.classList.toggle('indeterminate', Boolean(indeterminate)); el.progressBar.style.width = indeterminate ? '40%' : `${percent}%`; el.progressMeta.textContent = label || ''; } function setSecondaryProgress(text) { el.progressMetaSecondary.textContent = text || ''; } function hideProgress() { el.progress.classList.remove('active', 'indeterminate'); el.progressBar.style.width = '0%'; el.progressMeta.textContent = ''; el.progressMetaSecondary.textContent = ''; } function parseSizeToBytes(token) { if (!token || token === '-') return null; const match = token.trim().match(/^([\d.]+)\s*([KMGTP]?)(?:B)?$/i); if (!match) return null; const value = Number(match[1]); if (Number.isNaN(value)) return null; const unit = match[2] ? match[2].toUpperCase() : ''; const multipliers = { '': 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4, P: 1024 ** 5, }; const multiplier = multipliers[unit]; if (!multiplier) return null; return Math.round(value * multiplier); } function normalizeDirUrl(url) { try { const urlObj = new URL(url); if (!urlObj.pathname.endsWith('/')) { urlObj.pathname += '/'; } return urlObj.toString(); } catch (err) { return url; } } function log(message) { const line = document.createElement('div'); line.textContent = message; el.logList.prepend(line); } function renderLocal() { el.localListBody.innerHTML = ''; state.localEntries.forEach((entry) => { const row = document.createElement('div'); row.className = 'row list-row'; const name = document.createElement('div'); name.className = 'name'; name.textContent = entry.isDir ? `${entry.name}/` : entry.name; const size = document.createElement('div'); size.className = 'size'; size.textContent = entry.isDir ? '-' : formatBytes(entry.size); row.appendChild(name); row.appendChild(size); row.addEventListener('dblclick', async () => { if (!entry.isDir) return; state.localDir = entry.path; await refreshLocal(); }); el.localListBody.appendChild(row); }); } function renderRemote() { el.remoteListBody.innerHTML = ''; state.remoteEntries.forEach((entry) => { const row = document.createElement('div'); row.className = 'row list-row'; const name = document.createElement('div'); name.className = 'name'; name.textContent = entry.name; const size = document.createElement('div'); size.className = 'size'; const sizeBytes = entry.sizeBytes !== undefined && entry.sizeBytes !== null ? entry.sizeBytes : parseSizeToBytes(entry.size); size.textContent = entry.isDir ? '-' : formatBytes(sizeBytes); row.appendChild(name); row.appendChild(size); row.addEventListener('dblclick', async () => { if (!entry.isDir) return; state.remoteUrl = entry.href; el.remoteInput.value = state.remoteUrl; await refreshRemote(); }); row.addEventListener('click', () => { el.remoteList.querySelectorAll('.selected').forEach((n) => n.classList.remove('selected')); row.classList.add('selected'); row.dataset.href = entry.href; row.dataset.isDir = entry.isDir ? '1' : '0'; }); el.remoteListBody.appendChild(row); }); } function ensureRemoteNode(url, name) { const normalized = normalizeDirUrl(url); let node = state.remoteTree.nodes.get(normalized); if (!node) { node = { url: normalized, name: name || normalized, parentUrl: null, children: [], expanded: false, loaded: false, loading: false, }; state.remoteTree.nodes.set(normalized, node); } if (name) { node.name = name; } return node; } function linkChild(parent, child) { if (!parent.children.includes(child.url)) { parent.children.push(child.url); } child.parentUrl = parent.url; } function syncTreeWithCurrent(url, entries) { if (!url) return; let urlObj; try { urlObj = new URL(url); } catch (err) { return; } const rootUrl = `${urlObj.origin}/`; state.remoteTree.rootUrl = rootUrl; const root = ensureRemoteNode(rootUrl, `${urlObj.origin}/`); const segments = urlObj.pathname.split('/').filter(Boolean); let parent = root; let currentPath = '/'; root.expanded = true; segments.forEach((seg) => { currentPath += `${seg}/`; const nodeUrl = `${urlObj.origin}${currentPath}`; const node = ensureRemoteNode(nodeUrl, `${seg}/`); linkChild(parent, node); parent.expanded = true; parent = node; }); parent.expanded = true; state.remoteTree.currentUrl = parent.url; const childDirs = entries.filter((entry) => entry.isDir); parent.children = []; childDirs.forEach((entry) => { const child = ensureRemoteNode(entry.href, entry.name); linkChild(parent, child); }); parent.loaded = true; parent.loading = false; } async function loadNodeChildren(node) { if (node.loading) return; node.loading = true; renderRemoteTree(); let entries = []; try { entries = await window.oddl.listRemoteDir(node.url); } catch (err) { log(err.message); node.loading = false; return; } node.children = []; entries.filter((entry) => entry.isDir).forEach((entry) => { const child = ensureRemoteNode(entry.href, entry.name); linkChild(node, child); }); node.loaded = true; node.loading = false; } function renderTreeNode(node, depth, container) { const row = document.createElement('div'); const current = node.url === state.remoteTree.currentUrl; row.className = `tree-node${current ? ' current' : ''}`; row.style.paddingLeft = `${depth * 16}px`; const toggle = document.createElement('span'); toggle.className = 'tree-toggle'; const hasChildren = node.children.length > 0 || !node.loaded; toggle.textContent = hasChildren ? (node.expanded ? 'v' : '>') : ''; toggle.addEventListener('click', async (event) => { event.stopPropagation(); if (!hasChildren && node.loaded) return; node.expanded = !node.expanded; if (node.expanded && !node.loaded) { await loadNodeChildren(node); } renderRemoteTree(); }); const label = document.createElement('span'); label.className = 'tree-label'; label.textContent = node.name; label.addEventListener('click', async () => { state.remoteUrl = node.url; el.remoteInput.value = state.remoteUrl; await refreshRemote(); }); row.appendChild(toggle); row.appendChild(label); container.appendChild(row); if (node.loading) { const loadingRow = document.createElement('div'); loadingRow.className = 'tree-loading'; loadingRow.textContent = 'loading...'; loadingRow.style.paddingLeft = `${(depth + 1) * 16}px`; container.appendChild(loadingRow); } if (node.expanded) { const children = node.children .map((url) => state.remoteTree.nodes.get(url)) .filter(Boolean) .sort((a, b) => a.name.localeCompare(b.name)); children.forEach((child) => renderTreeNode(child, depth + 1, container)); } } function renderRemoteTree() { el.remoteTree.innerHTML = ''; const root = state.remoteTree.nodes.get(state.remoteTree.rootUrl); if (!root) return; renderTreeNode(root, 0, el.remoteTree); } async function refreshLocal() { if (!state.localDir) return; const entries = await window.oddl.listLocalDir(state.localDir); state.localEntries = entries; el.localPath.value = state.localDir; renderLocal(); } async function refreshRemote() { if (!state.remoteUrl) return; state.remoteUrl = normalizeDirUrl(state.remoteUrl); const entries = await window.oddl.listRemoteDir(state.remoteUrl); state.remoteEntries = entries; renderRemote(); syncTreeWithCurrent(state.remoteUrl, entries); renderRemoteTree(); } function setupResizer(listEl, resizerEl) { let startX = 0; let startWidth = 0; function onMove(event) { const delta = event.clientX - startX; const newWidth = Math.max(160, startWidth + delta); listEl.style.setProperty('--col-name', `${newWidth}px`); } function onUp() { window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); } resizerEl.addEventListener('mousedown', (event) => { event.preventDefault(); startX = event.clientX; const nameCol = listEl.querySelector('.list-header .name'); startWidth = nameCol ? nameCol.getBoundingClientRect().width : 240; window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onUp); }); } async function init() { const caps = await window.oddl.getCapabilities(); state.hasWget = caps.hasWget; el.useWget.disabled = !state.hasWget; if (!state.hasWget) { el.useWget.checked = false; } const savedTheme = localStorage.getItem('oddl.theme'); if (savedTheme === 'dark' || savedTheme === 'light') { document.documentElement.setAttribute('data-theme', savedTheme); el.themeToggle.checked = savedTheme === 'dark'; } const savedDir = localStorage.getItem('oddl.localDir'); if (savedDir) { state.localDir = savedDir; try { await refreshLocal(); } catch (err) { log('Saved local folder missing, please select a new folder.'); state.localDir = ''; localStorage.removeItem('oddl.localDir'); } } if (!state.localDir) { const home = await window.oddl.selectLocalDir(); if (home) { state.localDir = home; localStorage.setItem('oddl.localDir', home); await refreshLocal(); } } document.querySelectorAll('.col-resizer').forEach((resizer) => { const targetId = resizer.dataset.target; const listEl = document.getElementById(targetId); if (listEl) { setupResizer(listEl, resizer); } }); } el.localBrowse.addEventListener('click', async () => { const dir = await window.oddl.selectLocalDir(); if (!dir) return; state.localDir = dir; localStorage.setItem('oddl.localDir', dir); await refreshLocal(); }); el.themeToggle.addEventListener('change', () => { const theme = el.themeToggle.checked ? 'dark' : 'light'; document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('oddl.theme', theme); }); el.remoteSubmit.addEventListener('click', async () => { const url = el.remoteInput.value.trim(); if (!url) return; state.remoteUrl = url; await refreshRemote(); }); el.downloadBtn.addEventListener('click', async () => { const selected = el.remoteList.querySelector('.selected'); if (!selected) { log('Select a remote file or folder first.'); return; } if (!state.localDir) { log('Choose a local destination first.'); return; } const href = selected.dataset.href; const isDir = selected.dataset.isDir === '1'; log(`Queueing ${href}`); await window.oddl.downloadItem({ url: href, destDir: state.localDir, isDir, useWget: el.useWget.checked, }); log('Done.'); await refreshLocal(); }); window.oddl.onLog((message) => log(message)); window.oddl.onProgress((payload) => { if (payload.phase === 'scan-start') { state.progressMode = 'aggregate'; showProgress({ percent: 100, label: 'Scanning remote folders...', indeterminate: true }); setSecondaryProgress(''); return; } if (payload.phase === 'scan-done') { const count = payload.fileCount || 0; showProgress({ percent: 0, label: `Found ${count} files`, indeterminate: false }); setSecondaryProgress(''); return; } if (payload.phase === 'aggregate-start') { state.progressMode = 'aggregate'; if (payload.totalBytes) { showProgress({ percent: 0, label: `0 / ${formatBytes(payload.totalBytes)}`, }); } else { showProgress({ percent: 100, label: 'Downloading...', indeterminate: true }); } setSecondaryProgress(''); return; } if (payload.phase === 'aggregate-progress') { const downloaded = payload.downloadedBytes || 0; const total = payload.totalBytes || 0; if (total > 0) { const percent = Math.min(100, Math.round((downloaded / total) * 100)); const label = `${formatBytes(downloaded)} / ${formatBytes(total)}`; showProgress({ percent, label }); } else { showProgress({ percent: 100, label: `${formatBytes(downloaded)} downloaded`, indeterminate: true }); } return; } if (payload.phase === 'start') { state.progressMode = 'single'; if (payload.mode === 'wget') { showProgress({ percent: 100, label: `wget running ${payload.fileName ? `(${payload.fileName})` : ''}`, indeterminate: true, }); setSecondaryProgress(''); return; } state.transfer = { fileName: payload.fileName || '', startTime: Date.now(), lastBytes: 0, lastTime: Date.now(), }; showProgress({ percent: 0, label: `Starting ${payload.fileName || 'download'}` }); setSecondaryProgress(''); return; } if (payload.phase === 'progress') { const downloaded = payload.downloadedBytes || 0; const total = payload.totalBytes || 0; const now = Date.now(); const transfer = state.transfer || { lastBytes: 0, lastTime: now }; const deltaBytes = Math.max(0, downloaded - transfer.lastBytes); const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000); const speed = deltaBytes / deltaTime; transfer.lastBytes = downloaded; transfer.lastTime = now; state.transfer = transfer; if (total > 0) { const percent = Math.min(100, Math.round((downloaded / total) * 100)); const remaining = Math.max(0, total - downloaded); const eta = speed > 0 ? remaining / speed : null; const label = `${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`; showProgress({ percent, label }); } else { showProgress({ percent: 100, label: `${formatBytes(downloaded)} downloaded`, indeterminate: true, }); } return; } if (payload.phase === 'file-start') { state.transfer = { fileName: payload.fileName || '', startTime: Date.now(), lastBytes: 0, lastTime: Date.now(), }; if (state.progressMode === 'aggregate') { setSecondaryProgress(`Downloading ${payload.fileName || 'file'}`); } else { showProgress({ percent: 0, label: `Downloading ${payload.fileName || 'file'}` }); setSecondaryProgress(''); } return; } if (payload.phase === 'file-progress') { const downloaded = payload.downloadedBytes || 0; const total = payload.totalBytes || 0; const now = Date.now(); const transfer = state.transfer || { lastBytes: 0, lastTime: now }; const deltaBytes = Math.max(0, downloaded - transfer.lastBytes); const deltaTime = Math.max(0.5, (now - transfer.lastTime) / 1000); const speed = deltaBytes / deltaTime; transfer.lastBytes = downloaded; transfer.lastTime = now; state.transfer = transfer; if (total > 0) { const percent = Math.min(100, Math.round((downloaded / total) * 100)); const remaining = Math.max(0, total - downloaded); const eta = speed > 0 ? remaining / speed : null; const label = `${payload.fileName || 'file'} • ${formatBytes(downloaded)} / ${formatBytes(total)}${eta ? ` • ETA ${formatDuration(eta)}` : ''}`; if (state.progressMode === 'aggregate') { setSecondaryProgress(label); } else { showProgress({ percent, label }); } } else { const label = `${payload.fileName || 'file'} • ${formatBytes(downloaded)} downloaded`; if (state.progressMode === 'aggregate') { setSecondaryProgress(label); } else { showProgress({ percent: 100, label, indeterminate: true, }); } } return; } if (payload.phase === 'file-done') { if (state.progressMode === 'aggregate') { setSecondaryProgress(`Finished ${payload.fileName || 'file'}`); } else { showProgress({ percent: 100, label: `Finished ${payload.fileName || 'file'}` }); } return; } if (payload.phase === 'done') { state.transfer = null; state.progressMode = null; hideProgress(); } }); init().catch((err) => log(err.message));