// ── Transfer Manager controls ───────────────────────────────────────────────── // ── Media page ──────────────────────────────────────────────────────────────── // Format a Unix ms timestamp into a short human-readable string. // Respects _timeFormat ('12h' | '24h'). let _timeFormat = '12h'; // default; overwritten by loadSettings function _fmtDate(ms) { if (!ms) return ''; const d = new Date(ms); const now = new Date(); let time; if (_timeFormat === '24h') { const pad = n => String(n).padStart(2, '0'); time = `${pad(d.getHours())}:${pad(d.getMinutes())}`; } else { const h = d.getHours(); const m = d.getMinutes(); const ampm = h >= 12 ? 'PM' : 'AM'; const h12 = h % 12 || 12; time = `${h12}:${String(m).padStart(2, '0')} ${ampm}`; } const isSameDay = d.toDateString() === now.toDateString(); const yesterday = new Date(now); yesterday.setDate(now.getDate() - 1); const isYesterday = d.toDateString() === yesterday.toDateString(); if (isSameDay) return `Today ${time}`; if (isYesterday) return `Yesterday ${time}`; const mo = d.toLocaleString('default', { month: 'short' }); return `${mo} ${d.getDate()} ${time}`; } let _mediaItems = []; // all items returned by last scan let _mediaFilter = 'all'; let _mediaSort = 'date-desc'; let _mediaSearch = ''; let _mediaSearchTimer = null; const _thumbCache = new Map(); // remotePath → dataUrl (in-memory, cleared on new scan) const _thumbFetching = new Set(); // remotePaths currently being fetched let _thumbQueued = 0; // total thumb loads enqueued in current render cycle let _thumbDone = 0; // completed (success + failure) let _mediaCacheStatus = { entries: [], metrics: {} }; function _mediaText(key, fallback) { return window.t ? window.t(key, fallback) : fallback; } function _renderMediaCacheStatus() { const metrics = _mediaCacheStatus.metrics || {}; const target = $('media-cache-metrics'); if (!target) return; target.textContent = metrics.managedCount ? `${metrics.managedCount} ${_mediaText('media.cache.assets', 'assets')} · ${fmt(metrics.managedBytes || 0)} · ${metrics.pinnedCount || 0} ${_mediaText('media.cache.pinned', 'pinned')}` : _mediaText('media.cache.empty', 'No managed cache assets.'); } async function _refreshMediaCacheStatus({ rerender = false } = {}) { if (!window.pork.mediaCacheStatus) return; _mediaCacheStatus = await window.pork.mediaCacheStatus(); _renderMediaCacheStatus(); if (rerender) renderMediaGrid(); } async function _mediaSha256(blob) { const digest = await crypto.subtle.digest('SHA-256', await blob.arrayBuffer()); return Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, '0')).join(''); } function _renderImportedMedia(packet) { const target = $('media-imported-list'); if (!target) return; const items = packet?.items || []; target.innerHTML = items.map(item => `
${item.mime.startsWith('image/') ? `` : ``}
${escHtml(item.titleId)} · ${escHtml(item.kind)}${escHtml(item.originalName)} · ${fmt(item.bytes)} · ${escHtml(item.provenance.provider)}${item.kind === 'cover' ? `` : ''}
`).join(''); } async function _loadImportedMedia() { if (!window.pork.mediaImportsList) return; _renderImportedMedia(await window.pork.mediaImportsList()); } function _updateThumbStatus() { const el = $('media-thumb-status'); if (!el) return; const pending = _thumbQueued - _thumbDone; if (pending <= 0 || _thumbQueued === 0) { el.hidden = true; return; } el.hidden = false; el.textContent = `Loading thumbnails: ${_thumbDone} / ${_thumbQueued}`; } /** Show or update the thumb-status bar with an arbitrary message (for clip-batch). */ function _setThumbStatusMsg(msg) { const el = $('media-thumb-status'); if (!el) return; if (msg == null) { el.hidden = true; return; } el.textContent = msg; el.hidden = false; } function _setCardLabel(remotePath, text) { const lbl = document.querySelector(`.thumb-label[data-remote="${CSS.escape(remotePath)}"]`); if (lbl) lbl.textContent = text; } // ── Video frame extractor removed ─────────────────────────────────────────── // Webm thumbnails are not generated — the card shows a WEBM type badge instead. // generateVideoThumb intentionally omitted. // ── IntersectionObserver for lazy thumbnail loading ─────────────────────────── const _thumbObserver = new IntersectionObserver(entries => { for (const entry of entries) { if (!entry.isIntersecting) continue; const imgEl = entry.target; const remote = imgEl.dataset.remote; if (!remote) continue; _thumbObserver.unobserve(imgEl); if (_thumbCache.has(remote)) { _applyThumb(imgEl, _thumbCache.get(remote)); continue; } if (_thumbFetching.has(remote)) continue; _thumbFetching.add(remote); _thumbQueued++; _updateThumbStatus(); // Videos: screenshots only now (clips use static badge) const thumbPromise = window.pork.mediaFetchThumb(remote); thumbPromise .then(thumbUrl => { _thumbCache.set(remote, thumbUrl); document.querySelectorAll(`.media-thumb-img[data-remote="${CSS.escape(remote)}"]`).forEach(el => { _applyThumb(el, thumbUrl); }); }) .catch(err => { const card = imgEl.closest('.media-card'); const wrap = imgEl.closest('.media-card-thumb'); wrap?.classList.add('thumb-error'); // Show a retry button so the user isn't left with a silent `!` if (card && !card.querySelector('.btn-thumb-retry')) { const retryBtn = document.createElement('button'); retryBtn.className = 'btn btn-sm btn-thumb-retry'; retryBtn.title = err?.message || 'Thumbnail failed'; retryBtn.textContent = '\u21BB Retry'; retryBtn.addEventListener('click', () => { _thumbFetching.delete(remote); _thumbCache.delete(remote); _thumbQueued = Math.max(0, _thumbQueued - 1); _thumbDone = Math.max(0, _thumbDone - 1); retryBtn.remove(); wrap?.classList.remove('thumb-error'); // Re-add the spinner and img, then re-observe const newSpin = document.createElement('div'); newSpin.className = 'thumb-spin'; const newLbl = document.createElement('div'); newLbl.className = 'thumb-label'; newLbl.dataset.remote = remote; newLbl.textContent = 'Waiting…'; wrap?.prepend(newLbl); wrap?.prepend(newSpin); imgEl.src = ''; imgEl.classList.remove('thumb-loaded'); imgEl.classList.add('thumb-loading'); _thumbObserver.observe(imgEl); }); card.querySelector('.media-card-body')?.prepend(retryBtn); } }) .finally(() => { _thumbFetching.delete(remote); _thumbDone++; _updateThumbStatus(); }); } }, { rootMargin: '200px', threshold: 0 }); function _applyThumb(imgEl, dataUrl) { imgEl.src = dataUrl; imgEl.classList.remove('thumb-loading'); imgEl.classList.add('thumb-loaded'); const spin = imgEl.parentElement?.querySelector('.thumb-spin'); if (spin) spin.remove(); } async function loadMedia() { const gameContext = consumeGameWorkspaceContext('media'); if (gameContext) { _mediaSearch = gameContext; if ($('media-search')) $('media-search').value = gameContext; } try { const s = await window.pork.getSettings(); if (s.mediaLocalPath) $('media-save-path').value = s.mediaLocalPath; } catch (_) {} if (window.pork.mediaCatalog) { try { const catalog = await window.pork.mediaCatalog(); if (Array.isArray(catalog?.items)) { _mediaItems = catalog.items; const status = $('media-scan-status'); if (catalog.scannedAt) { status.textContent = `Catalogus: ${catalog.stats?.total || catalog.items.length} items · ${_fmtDate(catalog.scannedAt)}${catalog.stale ? ' · nieuwe scan aanbevolen' : ''}`; status.hidden = false; } } } catch (_) {} } try { await _refreshMediaCacheStatus(); } catch (_) {} try { await _loadImportedMedia(); } catch (_) {} renderMediaGrid(); } function getFilteredMedia() { const filtered = _mediaItems.filter(item => { if (_mediaFilter !== 'all' && item.type !== _mediaFilter) return false; if (_mediaSearch) { const q = _mediaSearch.toLowerCase(); return item.game_id.toLowerCase().includes(q) || item.filename.toLowerCase().includes(q); } return true; }); // Sort filtered.sort((a, b) => { switch (_mediaSort) { case 'date-asc': return (a.capturedAt || 0) - (b.capturedAt || 0); case 'size-desc': return b.size - a.size; case 'name-asc': return a.filename.localeCompare(b.filename); default: return (b.capturedAt || 0) - (a.capturedAt || 0); // date-desc } }); return filtered; } function renderMediaGrid() { const grid = $('media-grid'); const empty = $('media-empty'); const all = _mediaItems; const shots = all.filter(i => i.type === 'screenshot'); const clips = all.filter(i => i.type === 'clip'); $('media-count-all').textContent = all.length || ''; $('media-count-screenshots').textContent = shots.length || ''; $('media-count-clips').textContent = clips.length || ''; // Show the "Fetch Clip Thumbnails" button whenever there are clips loaded const clipThumbBtn = $('btn-clip-thumbs'); if (clipThumbBtn) { const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length; clipThumbBtn.style.display = clips.length ? '' : 'none'; clipThumbBtn.title = missing ? `Generate thumbnails for ${missing} clip${missing !== 1 ? 's' : ''} (${clips.length - missing} already cached)` : 'All clip thumbnails are already cached'; } const badge = $('nav-media-badge'); // The Chimera v2 navigation replaces Porkfolio's legacy media badge. The // background warmer may render Media after that replacement, so the badge is // deliberately optional. if (badge) { if (all.length) { badge.textContent = all.length; badge.hidden = false; } else badge.hidden = true; } const visible = getFilteredMedia(); grid.innerHTML = ''; if (!visible.length) { empty.hidden = false; return; } empty.hidden = true; const iconMap = new Map((state.games || []).map(g => [g.game_id, g.prospero_icon_url || ''])); for (const item of visible) { const card = document.createElement('div'); card.className = 'media-card'; card.dataset.remote = item.remotePath; card.dataset.type = item.type; const icon = iconMap.get(item.game_id) || ''; const iconHtml = icon ? `` : `
`; // Thumbnail area — real for screenshots (lazy-loaded), styled placeholder for clips let thumbHtml; if (item.type === 'screenshot') { const cached = _thumbCache.get(item.remotePath); if (cached) { thumbHtml = `
`; } else { thumbHtml = `
`; } } else { // Video clip — static WEBM badge + play overlay; no thumbnail download. const cachedClip = _thumbCache.get(item.remotePath); const clipImg = cachedClip ? `` : `
WEBM
`; thumbHtml = `
${clipImg}
`; } const cachedAsset = item.cache || (_mediaCacheStatus.entries || []).find(entry => entry.origin?.locator === item.remotePath && entry.kind === 'cached'); const provenance = item.provenance || { type: 'remote', provider: 'ps5-ftp' }; const originLabel = provenance.type === 'remote' ? _mediaText('media.cache.remote', 'Remote PS5') : provenance.type === 'imported' ? _mediaText('media.cache.imported', 'Imported original') : _mediaText('media.cache.generated', 'Generated'); card.innerHTML = ` ${thumbHtml}
${iconHtml} ${escHtml(item.game_id)}
${escHtml(item.filename)}
${fmt(item.size)} ${item.capturedAt ? `${_fmtDate(item.capturedAt)}` : ''}
${escHtml(originLabel)}${escHtml(provenance.provider || '')}${cachedAsset ? `` : ''}
${item.type === 'clip' ? `` : ''}
`; grid.appendChild(card); // Register lazy loading — screenshots only; clips show a static WEBM badge. if (item.type === 'screenshot' && !_thumbCache.has(item.remotePath)) { const imgEl = card.querySelector('.media-thumb-img'); if (imgEl) _thumbObserver.observe(imgEl); } } } $('media-grid').addEventListener('click', async event => { const button = event.target.closest('[data-cache-pin]'); if (!button || !window.pork.mediaCachePin) return; button.disabled = true; try { await window.pork.mediaCachePin(button.dataset.cachePin, button.dataset.pinned !== 'true'); await _refreshMediaCacheStatus({ rerender: true }); } catch (error) { setStatus(error.message, 'error'); button.disabled = false; } }); async function _clearMediaCache(options) { if (!window.pork.mediaCacheClear) return; const result = await window.pork.mediaCacheClear(options); _thumbCache.clear(); await _refreshMediaCacheStatus({ rerender: true }); setStatus(`${result.removed || 0} ${_mediaText('media.cache.cleared', 'cache assets cleared')}.`, 'ok'); } $('btn-media-cache-expired').addEventListener('click', () => _clearMediaCache({ expiredOnly: true }).catch(error => setStatus(error.message, 'error'))); $('btn-media-cache-unpinned').addEventListener('click', async () => { const ok = await showConfirm(_mediaText('media.cache.clearConfirm', 'Clear all unpinned generated and cached media? Imported originals are never deleted.')); if (ok) await _clearMediaCache({}); }); $('btn-media-import').addEventListener('click', async () => { const button = $('btn-media-import'); const status = $('media-import-status'); const file = $('media-import-file').files?.[0]; const titleId = $('media-import-title-id').value.trim().toUpperCase(); const kind = $('media-import-kind').value; if (!file || !/^(CUSA|PPSA)\d{5}$/.test(titleId)) return setStatus(_mediaText('media.import.invalid', 'Select a file and valid title ID.'), 'error'); button.disabled = true; try { const session = await window.pork.importUploadCreate([{ relativePath: file.name, size: file.size }]); const staged = session.files[0]; let offset = 0; while (offset < file.size) { const chunk = file.slice(offset, Math.min(file.size, offset + 4 * 1024 * 1024)); const progress = await window.pork.importUploadChunk(session.id, staged.id, offset, chunk, await _mediaSha256(chunk)); offset = Number(progress.files[0].offset); status.textContent = `${Math.round(offset / file.size * 100)}%`; } const crop = $('media-import-crop').value === 'center' ? { x: 0.125, y: 0, width: 0.75, height: 1 } : { x: 0, y: 0, width: 1, height: 1 }; await window.pork.mediaImportFinalize({ sessionId: session.id, fileId: staged.id, titleId, kind, crop: kind === 'cover' ? crop : null, preferred: kind === 'cover' && $('media-import-preferred').checked }); status.textContent = _mediaText('media.import.done', 'Import complete.'); $('media-import-file').value = ''; await _loadImportedMedia(); } catch (error) { status.textContent = error.message; setStatus(error.message, 'error'); } finally { button.disabled = false; } }); $('media-imported-list').addEventListener('click', async event => { const button = event.target.closest('[data-preferred-cover]'); if (!button || !window.pork.mediaImportPreferred) return; await window.pork.mediaImportPreferred(button.dataset.titleId, button.dataset.preferredCover); await _loadImportedMedia(); }); // Clip thumbnail batch progress — update the status bar with live count window.pork.on('media:clip-thumb:progress', ({ done, total, remote, state }) => { const label = remote ? remote.split('/').pop() : ''; if (state === 'downloading') { _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — downloading ${label}…`); } else if (state === 'extracting') { _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total} — extracting frame…`); } else { // 'ok' or 'failed' if (done >= total) { const clips = _mediaItems.filter(i => i.type === 'clip'); const missing = clips.filter(c => !_thumbCache.has(c.remotePath)).length; _setThumbStatusMsg(`Done — fetched ${done} clip thumbnail${done !== 1 ? 's' : ''}${missing ? `, ${missing} still missing (partial download may not have contained a keyframe)` : ''}`); setTimeout(() => _setThumbStatusMsg(null), 8000); // Refresh button tooltip const clipThumbBtn = $('btn-clip-thumbs'); if (clipThumbBtn) { clipThumbBtn.disabled = false; clipThumbBtn.textContent = '\u{1F3AC} Fetch Clip Thumbnails'; } renderMediaGrid(); } else { _setThumbStatusMsg(`Fetching clip thumbnails: ${done} / ${total}${state === 'failed' ? ` (last failed)` : ''}`); } } }); // ── Fetch Clip Thumbnails button ────────────────────────────────────────────── $('btn-clip-thumbs').addEventListener('click', async () => { const clips = _mediaItems.filter(i => i.type === 'clip'); const missing = clips.filter(c => !_thumbCache.has(c.remotePath)); if (missing.length === 0) { setStatus('All clip thumbnails are already cached.', 'ok'); return; } // Warn if it will take a while — each clip needs a partial FTP download + ffmpeg const warnThreshold = 5; if (missing.length > warnThreshold) { const ok = await showConfirm( `Fetching thumbnails for ${missing.length} video clip${missing.length !== 1 ? 's' : ''}.\n\n` + `This will download the first ~10 MB of each clip from your PS5 and extract a frame.\n` + `With many files this can take several minutes.\n\nContinue?` ); if (!ok) return; } const btn = $('btn-clip-thumbs'); btn.disabled = true; btn.textContent = 'Fetching…'; _setThumbStatusMsg(`Starting thumbnail fetch for ${missing.length} clip${missing.length !== 1 ? 's' : ''}…`); try { await window.pork.mediaFetchClipThumbs(missing.map(c => c.remotePath)); } catch (err) { setStatus(`Clip thumbnail fetch failed: ${err.message}`, 'error'); _setThumbStatusMsg(null); btn.disabled = false; btn.textContent = '\u{1F3AC} Fetch Clip Thumbnails'; } }); // ── Scan buttons ────────────────────────────────────────────────────────────── async function runMediaScan(force = false) { const btn = $('btn-media-scan'); const forceBtn = $('btn-media-force-scan'); const cancelBtn = $('btn-media-cancel-scan'); const status = $('media-scan-status'); btn.disabled = true; forceBtn.disabled = true; cancelBtn.hidden = !window.pork.mediaScanCancel; btn.textContent = force ? 'Volledige scan…' : 'Scanning…'; status.textContent = 'Connecting to PS5…'; status.hidden = false; // Clear old data + caches _mediaItems = []; _thumbCache.clear(); _thumbFetching.clear(); _thumbQueued = 0; _thumbDone = 0; const tsEl = $('media-thumb-status'); if (tsEl) tsEl.hidden = true; renderMediaGrid(); try { const result = await window.pork.mediaScan({ force }); const packet = Array.isArray(result) ? { items: result, stats: { total: result.length }, scan: { mode: 'full' }, delta: {} } : result; _mediaItems = Array.isArray(packet?.items) ? packet.items : []; const mode = packet.cached ? 'cache' : packet.scan?.mode === 'incremental' ? 'incrementeel' : 'volledig'; const delta = packet.delta || {}; status.textContent = `${_mediaItems.length} items · ${mode} · +${delta.added || 0} / -${delta.removed || 0} / changed ${delta.changed || 0}${packet.scan?.reusedDirectories ? ` · ${packet.scan.reusedDirectories} folders reused` : ''}${packet.scan?.errors?.length ? ` · ${packet.scan.errors.length} FTP error(s), previous contents preserved` : ''}`; status.style.color = ''; // Restore persisted clip thumbnails so they show immediately after scan try { const diskCache = await window.pork.mediaLoadClipThumbs(); for (const [rp, du] of Object.entries(diskCache || {})) _thumbCache.set(rp, du); } catch (_) {} renderMediaGrid(); } catch (e) { status.textContent = `Scan failed: ${e.message}`; status.style.cssText += ';color:var(--red)'; status.style.animationName = 'none'; } finally { btn.disabled = false; forceBtn.disabled = false; cancelBtn.hidden = true; btn.textContent = '↺ Scan via FTP'; } } $('btn-media-scan').addEventListener('click', () => runMediaScan(false)); $('btn-media-force-scan').addEventListener('click', () => runMediaScan(true)); $('btn-media-cancel-scan').addEventListener('click', async event => { event.currentTarget.disabled = true; try { await window.pork.mediaScanCancel(); $('media-scan-status').textContent = 'Scan annuleren…'; } catch (error) { setStatus(error.message, 'error'); } finally { event.currentTarget.disabled = false; } }); // Per-card thumb state updates from main process window.pork.on('media:thumb:progress', ({ remotePath, state }) => { const labels = { downloading: 'Downloading…', decoding: 'Processing…' }; if (labels[state]) _setCardLabel(remotePath, labels[state]); }); // Live scan progress window.pork.on('media:scan:progress', ({ game_id, type, found, scannedDirectories, reusedDirectories }) => { const status = $('media-scan-status'); if (!status.hidden) { status.textContent = `Scanning ${type === 'screenshot' ? 'screenshots' : 'video clips'}… ${found} found${game_id ? ` (${game_id})` : ''}${scannedDirectories ? ` · ${scannedDirectories} folders read` : ''}${reusedDirectories ? ` · ${reusedDirectories} reused` : ''}`; } }); // Discord share progress — update the share button live for every pipeline stage // Track remote paths whose discord share has fully resolved so late progress // events (which can race against the IPC reply) don't override the final state. const _discordCompleted = new Set(); window.pork.on('media:discord:progress', ({ remotePath, state, pct, attempt, max }) => { if (_discordCompleted.has(remotePath)) return; const btn = document.querySelector(`.btn-media-share[data-remote="${CSS.escape(remotePath)}"]`); if (!btn) return; if (state === 'downloading') { btn.classList.remove('uploading'); btn.textContent = pct != null ? `Downloading ${pct}%` : 'Downloading…'; } else if (state === 'converting') { btn.classList.remove('uploading'); btn.textContent = pct != null ? `Converting ${pct}%` : 'Converting…'; } else if (state === 'uploading-video') { btn.classList.add('uploading'); btn.style.setProperty('--upload-pct', `${pct ?? 0}%`); btn.textContent = pct != null ? `Uploading ${pct}%` : 'Uploading…'; } else if (state === 'retrying') { btn.classList.remove('uploading'); btn.style.removeProperty('--upload-pct'); btn.textContent = `Retrying… (${attempt}/${max})`; } else { btn.classList.remove('uploading'); btn.style.removeProperty('--upload-pct'); const labels = { sending: 'Sending…', uploading: 'Uploading…' }; if (labels[state]) btn.textContent = labels[state]; } }); // Discord share thumbnail — ffmpeg extracted a frame from the local mp4; cache it // and patch the clip card immediately so the user sees the thumbnail right away. window.pork.on('media:discord:thumb', ({ remotePath, dataUrl }) => { if (!remotePath || !dataUrl) return; _thumbCache.set(remotePath, dataUrl); // Find every clip card for this path and swap the background → actual thumb document.querySelectorAll(`.media-card[data-remote="${CSS.escape(remotePath)}"]`).forEach(card => { const wrap = card.querySelector('.media-card-thumb'); if (!wrap) return; // Replace the WEBM badge placeholder with a real thumbnail image const badge = wrap.querySelector('.clip-type-badge'); if (badge) badge.remove(); let img = wrap.querySelector('.media-thumb-img'); if (!img) { img = document.createElement('img'); img.className = 'media-thumb-img'; img.alt = ''; img.dataset.remote = remotePath; wrap.insertBefore(img, wrap.firstChild); } img.src = dataUrl; img.classList.add('thumb-loaded'); img.classList.remove('thumb-loading'); }); }); // Save path browse $('btn-media-save-browse').addEventListener('click', async () => { const folder = await window.pork.selectFolder(); if (!folder) return; $('media-save-path').value = folder; await window.pork.setSettings({ mediaLocalPath: folder }); }); // Open media folder in Explorer $('btn-media-open-folder').addEventListener('click', () => { const folder = $('media-save-path').value; if (folder) window.pork.openShell(folder); else setStatus('No media folder configured — select one first.', 'error'); }); // Live update when main process falls back to a default path (e.g. configured drive missing) window.pork.on('settings:updated', (patch) => { if (patch?.mediaLocalPath) { const el = $('media-save-path'); if (el) el.value = patch.mediaLocalPath; setStatus(`Media save folder updated to "${patch.mediaLocalPath}" (previous path was inaccessible).`, 'ok'); } }); // Sort dropdown $('media-sort').addEventListener('change', e => { _mediaSort = e.target.value; renderMediaGrid(); }); // Filter buttons document.querySelectorAll('[data-mfilter]').forEach(btn => btn.addEventListener('click', () => { document.querySelectorAll('[data-mfilter]').forEach(b => b.classList.remove('active')); btn.classList.add('active'); _mediaFilter = btn.dataset.mfilter; renderMediaGrid(); }) ); // Search $('media-search').addEventListener('input', () => { clearTimeout(_mediaSearchTimer); _mediaSearchTimer = setTimeout(() => { _mediaSearch = $('media-search').value.trim(); renderMediaGrid(); }, 250); }); // ── Lightbox ────────────────────────────────────────────────────────────────── let _lightboxItem = null; // current { remotePath, game_id, filename } function openLightbox(item) { _lightboxItem = item; const box = $('media-lightbox'); const img = $('lightbox-img'); const spin = $('lightbox-spinner'); const meta = $('lightbox-meta'); img.src = ''; img.hidden = true; spin.hidden = false; meta.textContent = `${item.game_id} — ${item.filename}`; box.hidden = false; document.body.classList.add('lightbox-open'); const cached = _thumbCache.get(item.remotePath); if (cached) { img.src = cached; img.hidden = false; spin.hidden = true; } else { window.pork.mediaFetchThumb(item.remotePath) .then(dataUrl => { _thumbCache.set(item.remotePath, dataUrl); if (_lightboxItem?.remotePath === item.remotePath) { img.src = dataUrl; img.hidden = false; spin.hidden = true; } }) .catch(err => { spin.hidden = true; meta.textContent += ` — Load failed: ${err.message}`; }); } } function closeLightbox() { $('media-lightbox').hidden = true; document.body.classList.remove('lightbox-open'); $('lightbox-img').src = ''; _lightboxItem = null; } $('btn-lightbox-close').addEventListener('click', closeLightbox); $('media-lightbox').addEventListener('click', e => { if (e.target === $('media-lightbox') || e.target.classList.contains('media-lightbox-backdrop')) closeLightbox(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && !$('media-lightbox').hidden) closeLightbox(); }); $('btn-lightbox-dl').addEventListener('click', async () => { if (!_lightboxItem) return; const btn = $('btn-lightbox-dl'); btn.disabled = true; btn.textContent = 'Queued'; try { await window.pork.mediaDownload({ remotePath: _lightboxItem.remotePath, game_id: _lightboxItem.game_id, filename: _lightboxItem.filename, }); setStatus(`Queued: ${_lightboxItem.filename} — see Transfers`, 'ok'); } catch (err) { setStatus(err.message, 'error'); } finally { setTimeout(() => { btn.disabled = false; btn.textContent = '↓ Save to PC'; }, 2000); } }); // ── Grid click delegation ───────────────────────────────────────────────────── $('media-grid').addEventListener('click', async e => { // Share to Discord button const shareBtn = e.target.closest('.btn-media-share'); if (shareBtn) { shareBtn.disabled = true; shareBtn.textContent = 'Preparing…'; try { const res = await window.pork.mediaDiscordSend({ remotePath: shareBtn.dataset.remote, game_id: shareBtn.dataset.game, filename: shareBtn.dataset.file, type: shareBtn.dataset.type, }); _discordCompleted.add(shareBtn.dataset.remote); shareBtn.classList.remove('uploading'); shareBtn.style.removeProperty('--upload-pct'); shareBtn.textContent = '\u2713 Sent!'; const detail = res?.url ? ` • ${res.url}` : ''; setStatus(`Shared to Discord: ${shareBtn.dataset.file}${detail}`, 'ok'); setTimeout(() => { shareBtn.disabled = false; shareBtn.textContent = '\uD83D\uDD17 Discord'; _discordCompleted.delete(shareBtn.dataset.remote); }, 3000); } catch (err) { _discordCompleted.add(shareBtn.dataset.remote); shareBtn.classList.remove('uploading'); shareBtn.style.removeProperty('--upload-pct'); shareBtn.disabled = false; shareBtn.textContent = '\uD83D\uDD17 Discord'; _discordCompleted.delete(shareBtn.dataset.remote); setStatus(`Discord share failed: ${err.message}`, 'error'); } return; } // Download button const dlBtn = e.target.closest('.btn-media-dl'); if (dlBtn) { dlBtn.disabled = true; dlBtn.textContent = 'Queued'; try { await window.pork.mediaDownload({ remotePath: dlBtn.dataset.remote, game_id: dlBtn.dataset.game, filename: dlBtn.dataset.file, }); setStatus(`Queued: ${dlBtn.dataset.file} — see Transfers`, 'ok'); } catch (err) { setStatus(err.message, 'error'); } dlBtn.disabled = false; dlBtn.textContent = '\u21D3 Download'; return; } // Play button (video clips) const playBtn = e.target.closest('.btn-media-play'); if (playBtn) { playBtn.disabled = true; playBtn.textContent = 'Opening\u2026'; try { await window.pork.mediaOpen(playBtn.dataset.remote); } catch (err) { setStatus(err.message, 'error'); } finally { setTimeout(() => { playBtn.disabled = false; playBtn.textContent = '\u25B6 Play'; }, 1500); } return; } // Click on a screenshot thumbnail → open lightbox const card = e.target.closest('.media-card[data-type="screenshot"]'); if (card && !e.target.closest('button')) { const remote = card.dataset.remote; const mediaItem = _mediaItems.find(i => i.remotePath === remote); if (mediaItem) openLightbox(mediaItem); } });