// ── 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' ? `${item.preferred ? _mediaText('media.import.preferredActive', 'Preferred') : _mediaText('media.import.makePreferred', 'Make preferred')} ` : ''}
`).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 = `
`;
}
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 ? `${cachedAsset.pinned ? _mediaText('media.cache.unpin', 'Unpin') : _mediaText('media.cache.pin', 'Pin cache')} ` : ''}
${item.type === 'clip'
? `▶ Play `
: ''}
⇓ Download
🔗 Discord
`;
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);
}
});