feat: taskbar progress + VOD card delegation + context menu + LRU bound
Four wins from a deep-audit pass.
1. Windows taskbar progress bar. While downloads run, mainWindow.
setProgressBar(0..1) shows aggregate progress on the taskbar icon
(visible while minimised). New activeDownloadProgress map tracks
per-item fractions because main's downloadQueue.progress field
is not updated mid-download (only renderer streams progress).
Cleared via clearDownloadProgress in processOneQueueItem.finally
so the bar resets when the queue idles.
2. VOD card data-* refactor. The previous inline-onclick template
strings did escapedTitle = title.replace(/'/, "\\'").replace(/"/,
""") and then interpolated that into onclick="addToQueue('...')".
Edge cases (titles with backslash, ', etc.) could break the
JS parser. All identity now lives on data-vod-id / -url / -title /
-date / -streamer / -duration on .vod-card. A delegated click
listener on #vodGrid reads the dataset at click time and
dispatches to openClipDialog / addToQueue / openExternal. Plus:
clicking the thumbnail / title / meta now opens the VOD on Twitch
in the OS default browser.
3. Right-click context menu on VOD cards. Items: "Open on Twitch",
"Copy VOD URL" (uses navigator.clipboard, toast confirmation),
"Trim VOD", "+ Queue", and toggle "Mark as downloaded" /
"Unmark downloaded". The mark toggle hits a new
ipcMain.handle("mark-vod-downloaded", id, mark) so a user can
add or remove entries in config.downloaded_vod_ids manually
without re-downloading. Menu auto-closes on outside-click /
Escape / scroll. Repositioned to stay inside the viewport.
4. userIdLoginCache now bounded (insertion-order eviction at 4096).
Was Map<string, string> with no cap; setUserIdLogin helper
centralises insertion + eviction. Long-running sessions with
thousands of unique streamer lookups no longer accumulate the
reverse-lookup table forever.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
504007600b
commit
6379723248
77
src/main.ts
77
src/main.ts
@ -675,7 +675,23 @@ let downloadedBytes = 0;
|
||||
// Per-item tracking for parallel downloads
|
||||
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
|
||||
const cancelledItemIds = new Set<string>();
|
||||
// userId -> login reverse map. Bounded via Map insertion-order eviction so
|
||||
// a long-running session doesn't grow it unbounded across thousands of
|
||||
// streamer lookups. Values are short (~20 char each) but accumulate.
|
||||
const USER_ID_LOGIN_CACHE_MAX = 4096;
|
||||
const userIdLoginCache = new Map<string, string>();
|
||||
function setUserIdLogin(userId: string, login: string): void {
|
||||
if (!userId || !login) return;
|
||||
if (userIdLoginCache.has(userId)) {
|
||||
userIdLoginCache.delete(userId);
|
||||
}
|
||||
userIdLoginCache.set(userId, login);
|
||||
while (userIdLoginCache.size > USER_ID_LOGIN_CACHE_MAX) {
|
||||
const oldest = userIdLoginCache.keys().next().value as string | undefined;
|
||||
if (!oldest) break;
|
||||
userIdLoginCache.delete(oldest);
|
||||
}
|
||||
}
|
||||
const loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
||||
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
||||
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
||||
@ -1484,6 +1500,40 @@ function emitQueueUpdated(force = false): void {
|
||||
|
||||
lastQueueBroadcastFingerprint = nextFingerprint;
|
||||
mainWindow?.webContents.send('queue-updated', downloadQueue);
|
||||
updateTaskbarProgress();
|
||||
}
|
||||
|
||||
// Per-item taskbar progress is tracked here because main's downloadQueue
|
||||
// items don't update their .progress field mid-download (only the renderer
|
||||
// gets a stream of progress events). Map is cleared in processOneQueueItem.finally.
|
||||
const activeDownloadProgress = new Map<string, number>();
|
||||
|
||||
function recordDownloadProgress(progress: DownloadProgress): void {
|
||||
const p = Number(progress.progress);
|
||||
const fraction = Number.isFinite(p) && p > 0 && p <= 100 ? p / 100 : 0.3;
|
||||
activeDownloadProgress.set(progress.id, fraction);
|
||||
updateTaskbarProgress();
|
||||
}
|
||||
|
||||
function clearDownloadProgress(itemId: string): void {
|
||||
activeDownloadProgress.delete(itemId);
|
||||
updateTaskbarProgress();
|
||||
}
|
||||
|
||||
// Aggregate progress across all currently-downloading items, mapped to the
|
||||
// Windows taskbar progress indicator (-1 = no progress, 0..1 = fraction).
|
||||
// Visible whenever the user has minimised / collapsed the window. Indeterminate
|
||||
// downloads (no percentage yet) report a 30% bar so the taskbar still shows
|
||||
// activity instead of going cold.
|
||||
function updateTaskbarProgress(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
const entries = Array.from(activeDownloadProgress.values());
|
||||
if (entries.length === 0) {
|
||||
try { mainWindow.setProgressBar(-1); } catch { /* unsupported on some platforms */ }
|
||||
return;
|
||||
}
|
||||
const avg = entries.reduce((s, v) => s + v, 0) / entries.length;
|
||||
try { mainWindow.setProgressBar(Math.max(0, Math.min(1, avg))); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
function hasQueueItemId(id: string): boolean {
|
||||
@ -1826,7 +1876,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
|
||||
if (!user?.id) return null;
|
||||
|
||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
||||
userIdLoginCache.set(user.id, user.login || login);
|
||||
setUserIdLogin(user.id, user.login || login);
|
||||
return user.id;
|
||||
}
|
||||
|
||||
@ -1919,7 +1969,7 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
if (!user?.id) return await getUserViaPublicApi();
|
||||
|
||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
||||
userIdLoginCache.set(user.id, user.login || login);
|
||||
setUserIdLogin(user.id, user.login || login);
|
||||
return user.id;
|
||||
} catch (e) {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
||||
@ -1929,7 +1979,7 @@ async function getUserId(username: string): Promise<string | null> {
|
||||
if (!user?.id) return await getUserViaPublicApi();
|
||||
|
||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
||||
userIdLoginCache.set(user.id, user.login || login);
|
||||
setUserIdLogin(user.id, user.login || login);
|
||||
return user.id;
|
||||
} catch (retryError) {
|
||||
console.error('Error getting user after relogin:', retryError);
|
||||
@ -2009,7 +2059,7 @@ async function getVODs(userId: string, forceRefresh = false): Promise<VOD[]> {
|
||||
if (pageCount === 0) {
|
||||
const login = pageVods[0]?.user_login;
|
||||
if (login) {
|
||||
userIdLoginCache.set(userId, normalizeLogin(login));
|
||||
setUserIdLogin(userId, normalizeLogin(login));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3190,9 +3240,11 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
const result = item.mergeGroup
|
||||
? await processDownloadMergeGroup(item, (progress) => {
|
||||
mainWindow?.webContents.send('download-progress', progress);
|
||||
recordDownloadProgress(progress);
|
||||
})
|
||||
: await downloadVOD(item, (progress) => {
|
||||
mainWindow?.webContents.send('download-progress', progress);
|
||||
recordDownloadProgress(progress);
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
@ -3294,6 +3346,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
||||
cancelledItemIds.delete(item.id);
|
||||
// Release only THIS item's claimed filenames (other parallel downloads keep their claims)
|
||||
releaseClaimedFilenamesForItem(item.id);
|
||||
clearDownloadProgress(item.id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -4276,6 +4329,22 @@ ipcMain.handle('export-runtime-metrics', async () => {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('mark-vod-downloaded', (_, vodId: string, mark: boolean): { success: boolean } => {
|
||||
if (typeof vodId !== 'string' || !vodId) return { success: false };
|
||||
if (!Array.isArray(config.downloaded_vod_ids)) config.downloaded_vod_ids = [];
|
||||
const has = config.downloaded_vod_ids.includes(vodId);
|
||||
if (mark && !has) {
|
||||
config.downloaded_vod_ids.push(vodId);
|
||||
} else if (!mark && has) {
|
||||
config.downloaded_vod_ids = config.downloaded_vod_ids.filter((id) => id !== vodId);
|
||||
} else {
|
||||
return { success: true };
|
||||
}
|
||||
saveConfig(config);
|
||||
appendDebugLog('mark-vod-downloaded', { vodId, mark });
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle('reset-downloaded-vod-ids', () => {
|
||||
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||
config.downloaded_vod_ids = [];
|
||||
|
||||
@ -111,6 +111,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
||||
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
|
||||
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-config'),
|
||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
|
||||
1
src/renderer-globals.d.ts
vendored
1
src/renderer-globals.d.ts
vendored
@ -218,6 +218,7 @@ interface ApiBridge {
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
|
||||
@ -207,7 +207,13 @@ const UI_TEXT_DE = {
|
||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||
hideDownloaded: 'Bereits geladene ausblenden',
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind'
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
|
||||
openOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxCopyUrl: 'VOD-URL kopieren',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
ctxMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
ctxUnmarkDownloaded: 'Markierung entfernen'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
|
||||
@ -207,7 +207,13 @@ const UI_TEXT_EN = {
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
alreadyDownloaded: 'Already downloaded',
|
||||
hideDownloaded: 'Hide downloaded',
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded'
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
|
||||
openOnTwitch: 'Open on Twitch',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxCopyUrl: 'Copy VOD URL',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
ctxMarkDownloaded: 'Mark as downloaded',
|
||||
ctxUnmarkDownloaded: 'Unmark downloaded'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim VOD',
|
||||
|
||||
@ -199,36 +199,73 @@ function focusVodFilter(): void {
|
||||
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
|
||||
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
|
||||
const date = formatUiDate(vod.created_at);
|
||||
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
||||
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
||||
const safeUrlAttr = escapeHtml(vod.url);
|
||||
const safeTitleAttr = escapeHtml(vod.title || '');
|
||||
const safeStreamerAttr = escapeHtml(streamer);
|
||||
const safeDateAttr = escapeHtml(vod.created_at);
|
||||
const safeDurationAttr = escapeHtml(vod.duration);
|
||||
const safeIdAttr = escapeHtml(vod.id);
|
||||
const isChecked = selectedVodUrls.has(vod.url);
|
||||
const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false;
|
||||
const downloadedBadge = isAlreadyDownloaded
|
||||
? `<div class="vod-downloaded-badge" title="${escapeHtml(UI_TEXT.vods.alreadyDownloaded)}">✓</div>`
|
||||
: '';
|
||||
|
||||
// All identity attributes go on data-* — a delegated listener on #vodGrid
|
||||
// reads them at click time. This removes the previous inline-onclick
|
||||
// template-injection pattern (escapedTitle dance) which was fragile for
|
||||
// titles containing backslashes / HTML entities like '.
|
||||
return `
|
||||
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}">
|
||||
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="Select for bulk action" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
|
||||
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}"
|
||||
data-vod-id="${safeIdAttr}"
|
||||
data-vod-url="${safeUrlAttr}"
|
||||
data-vod-title="${safeTitleAttr}"
|
||||
data-vod-date="${safeDateAttr}"
|
||||
data-vod-streamer="${safeStreamerAttr}"
|
||||
data-vod-duration="${safeDurationAttr}">
|
||||
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} title="${escapeHtml(UI_TEXT.vods.bulkSelectedCount.replace('{count}', '0').replace(/[0-9]/g, '').trim() || 'Select')}" style="position:absolute; top:8px; left:8px; width:18px; height:18px; accent-color:#9146FF; cursor:pointer; z-index:2;">
|
||||
${downloadedBadge}
|
||||
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
|
||||
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.openOnTwitch)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 180%22><rect fill=%22%23333%22 width=%22320%22 height=%22180%22/></svg>'">
|
||||
<div class="vod-info">
|
||||
<div class="vod-title">${safeDisplayTitle}</div>
|
||||
<div class="vod-meta">
|
||||
<span>${date}</span>
|
||||
<span>${vod.duration}</span>
|
||||
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span>
|
||||
<span>${escapeHtml(vod.duration)}</span>
|
||||
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="vod-actions">
|
||||
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.trimButton}</button>
|
||||
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
|
||||
<button class="vod-btn secondary" data-vod-action="trim">${escapeHtml(UI_TEXT.vods.trimButton)}</button>
|
||||
<button class="vod-btn primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
interface VodCardContext {
|
||||
id: string;
|
||||
url: string;
|
||||
title: string;
|
||||
date: string;
|
||||
streamer: string;
|
||||
duration: string;
|
||||
}
|
||||
|
||||
function readVodCardContext(card: HTMLElement | null): VodCardContext | null {
|
||||
if (!card) return null;
|
||||
const url = card.dataset.vodUrl;
|
||||
if (!url) return null;
|
||||
return {
|
||||
id: card.dataset.vodId || '',
|
||||
url,
|
||||
title: card.dataset.vodTitle || '',
|
||||
date: card.dataset.vodDate || '',
|
||||
streamer: card.dataset.vodStreamer || '',
|
||||
duration: card.dataset.vodDuration || ''
|
||||
};
|
||||
}
|
||||
|
||||
let streamerDragInitialized = false;
|
||||
let draggedStreamerName: string | null = null;
|
||||
|
||||
@ -434,19 +471,167 @@ function initVodGridSelectionDelegation(): void {
|
||||
|
||||
const grid = document.getElementById('vodGrid');
|
||||
if (!grid) return;
|
||||
|
||||
grid.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!(target instanceof HTMLInputElement)) return;
|
||||
if (!target.classList.contains('vod-select-checkbox')) return;
|
||||
const url = target.dataset.vodUrl || '';
|
||||
if (!url) return;
|
||||
if (target.checked) selectedVodUrls.add(url);
|
||||
else selectedVodUrls.delete(url);
|
||||
// Keep card visual + bar in sync without a full re-render of all cards
|
||||
// 1) Checkbox toggles (bulk-select)
|
||||
if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) {
|
||||
const url = target.dataset.vodUrl || '';
|
||||
if (!url) return;
|
||||
if (target.checked) selectedVodUrls.add(url);
|
||||
else selectedVodUrls.delete(url);
|
||||
const card = target.closest('.vod-card') as HTMLElement | null;
|
||||
if (card) card.classList.toggle('selected', target.checked);
|
||||
updateVodBulkBar();
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Action buttons (trim / queue) — replaces the previous inline
|
||||
// onclick template that mangled titles with special characters
|
||||
const btn = target.closest('button[data-vod-action]') as HTMLButtonElement | null;
|
||||
if (btn) {
|
||||
const ctx = readVodCardContext(btn.closest('.vod-card') as HTMLElement | null);
|
||||
if (!ctx) return;
|
||||
if (btn.dataset.vodAction === 'trim') {
|
||||
openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
|
||||
} else if (btn.dataset.vodAction === 'queue') {
|
||||
void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3) Click on thumbnail / title / meta -> open VOD on Twitch in the
|
||||
// OS default browser. Convenient + non-destructive.
|
||||
const card = target.closest('.vod-card') as HTMLElement | null;
|
||||
if (card) card.classList.toggle('selected', target.checked);
|
||||
updateVodBulkBar();
|
||||
if (!card) return;
|
||||
if (target.closest('.vod-actions') || target.classList.contains('vod-select-checkbox')) return;
|
||||
const ctx = readVodCardContext(card);
|
||||
if (!ctx) return;
|
||||
void window.api.openExternal(ctx.url);
|
||||
});
|
||||
|
||||
grid.addEventListener('contextmenu', (e) => {
|
||||
const card = (e.target as HTMLElement).closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const ctx = readVodCardContext(card);
|
||||
if (!ctx) return;
|
||||
e.preventDefault();
|
||||
showVodContextMenu(e.clientX, e.clientY, ctx);
|
||||
});
|
||||
}
|
||||
|
||||
let activeVodContextMenu: HTMLElement | null = null;
|
||||
|
||||
function closeVodContextMenu(): void {
|
||||
if (!activeVodContextMenu) return;
|
||||
activeVodContextMenu.remove();
|
||||
activeVodContextMenu = null;
|
||||
}
|
||||
|
||||
function showVodContextMenu(x: number, y: number, ctx: VodCardContext): void {
|
||||
closeVodContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'vod-context-menu';
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = '9999';
|
||||
menu.style.background = 'var(--bg-card)';
|
||||
menu.style.border = '1px solid var(--border-soft)';
|
||||
menu.style.borderRadius = '6px';
|
||||
menu.style.boxShadow = '0 4px 12px rgba(0,0,0,0.4)';
|
||||
menu.style.padding = '4px';
|
||||
menu.style.minWidth = '200px';
|
||||
|
||||
const downloadedIds = new Set(
|
||||
Array.isArray(config.downloaded_vod_ids)
|
||||
? (config.downloaded_vod_ids as string[]).filter((id) => typeof id === 'string')
|
||||
: []
|
||||
);
|
||||
const isMarkedDownloaded = downloadedIds.has(ctx.id);
|
||||
|
||||
const makeItem = (label: string, onClick: () => void): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = label;
|
||||
el.style.padding = '8px 12px';
|
||||
el.style.cursor = 'pointer';
|
||||
el.style.fontSize = '13px';
|
||||
el.style.color = 'var(--text)';
|
||||
el.style.borderRadius = '4px';
|
||||
el.addEventListener('mouseenter', () => { el.style.background = 'rgba(145,70,255,0.15)'; });
|
||||
el.addEventListener('mouseleave', () => { el.style.background = 'transparent'; });
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeVodContextMenu(); }
|
||||
});
|
||||
return el;
|
||||
};
|
||||
|
||||
menu.appendChild(makeItem(UI_TEXT.vods.ctxOpenOnTwitch, () => {
|
||||
void window.api.openExternal(ctx.url);
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.vods.ctxCopyUrl, () => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(ctx.url);
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.vods.ctxCopiedUrl, 'info');
|
||||
} catch { /* ignore */ }
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.vods.trimButton, () => {
|
||||
openClipDialog(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.vods.addQueue, () => {
|
||||
void addToQueue(ctx.url, ctx.title, ctx.date, ctx.streamer, ctx.duration);
|
||||
}));
|
||||
menu.appendChild(makeItem(
|
||||
isMarkedDownloaded ? UI_TEXT.vods.ctxUnmarkDownloaded : UI_TEXT.vods.ctxMarkDownloaded,
|
||||
() => { void toggleVodDownloadedMark(ctx.id, !isMarkedDownloaded); }
|
||||
));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeVodContextMenu = menu;
|
||||
|
||||
// Reposition if it would clip off the viewport
|
||||
const rect = menu.getBoundingClientRect();
|
||||
let left = x;
|
||||
let top = y;
|
||||
if (left + rect.width > window.innerWidth - 4) left = Math.max(4, window.innerWidth - rect.width - 4);
|
||||
if (top + rect.height > window.innerHeight - 4) top = Math.max(4, window.innerHeight - rect.height - 4);
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
|
||||
// Close on click anywhere else / Escape / scroll
|
||||
const dismissOnClick = (ev: MouseEvent) => {
|
||||
if (!activeVodContextMenu) return;
|
||||
if (ev.target instanceof Node && activeVodContextMenu.contains(ev.target)) return;
|
||||
closeVodContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key !== 'Escape') return;
|
||||
closeVodContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
const dismissOnScroll = () => {
|
||||
closeVodContextMenu();
|
||||
document.removeEventListener('mousedown', dismissOnClick, true);
|
||||
document.removeEventListener('keydown', dismissOnEscape, true);
|
||||
document.removeEventListener('scroll', dismissOnScroll, true);
|
||||
};
|
||||
document.addEventListener('mousedown', dismissOnClick, true);
|
||||
document.addEventListener('keydown', dismissOnEscape, true);
|
||||
document.addEventListener('scroll', dismissOnScroll, true);
|
||||
}
|
||||
|
||||
async function toggleVodDownloadedMark(vodId: string, mark: boolean): Promise<void> {
|
||||
const result = await window.api.markVodDownloaded(vodId, mark);
|
||||
if (!result?.success) return;
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
} catch { /* ignore */ }
|
||||
if (lastLoadedStreamer) renderVodGridFromCurrentState();
|
||||
}
|
||||
|
||||
function updateVodBulkBar(): void {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user