Compare commits
2 Commits
504007600b
...
e5decfd851
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5decfd851 | ||
|
|
6379723248 |
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.23",
|
"version": "4.5.24",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.23",
|
"version": "4.5.24",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.23",
|
"version": "4.5.24",
|
||||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||||
"main": "dist/main.js",
|
"main": "dist/main.js",
|
||||||
"author": "xRangerDE",
|
"author": "xRangerDE",
|
||||||
|
|||||||
77
src/main.ts
77
src/main.ts
@ -675,7 +675,23 @@ let downloadedBytes = 0;
|
|||||||
// Per-item tracking for parallel downloads
|
// Per-item tracking for parallel downloads
|
||||||
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
|
const activeDownloads = new Map<string, { process: ChildProcess | null; cancelled: boolean; startTime: number; bytes: number }>();
|
||||||
const cancelledItemIds = new Set<string>();
|
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>();
|
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 loginToUserIdCache = new Map<string, CacheEntry<string>>();
|
||||||
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
const vodListCache = new Map<string, CacheEntry<VOD[]>>();
|
||||||
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
const clipInfoCache = new Map<string, CacheEntry<any>>();
|
||||||
@ -1484,6 +1500,40 @@ function emitQueueUpdated(force = false): void {
|
|||||||
|
|
||||||
lastQueueBroadcastFingerprint = nextFingerprint;
|
lastQueueBroadcastFingerprint = nextFingerprint;
|
||||||
mainWindow?.webContents.send('queue-updated', downloadQueue);
|
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 {
|
function hasQueueItemId(id: string): boolean {
|
||||||
@ -1826,7 +1876,7 @@ async function getPublicUserId(username: string): Promise<string | null> {
|
|||||||
if (!user?.id) return null;
|
if (!user?.id) return null;
|
||||||
|
|
||||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
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;
|
return user.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1919,7 +1969,7 @@ async function getUserId(username: string): Promise<string | null> {
|
|||||||
if (!user?.id) return await getUserViaPublicApi();
|
if (!user?.id) return await getUserViaPublicApi();
|
||||||
|
|
||||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
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;
|
return user.id;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 401 && (await ensureTwitchAuth(true))) {
|
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();
|
if (!user?.id) return await getUserViaPublicApi();
|
||||||
|
|
||||||
setCachedValue(loginToUserIdCache, login, user.id, MAX_LOGIN_TO_USER_ID_CACHE_ENTRIES);
|
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;
|
return user.id;
|
||||||
} catch (retryError) {
|
} catch (retryError) {
|
||||||
console.error('Error getting user after relogin:', 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) {
|
if (pageCount === 0) {
|
||||||
const login = pageVods[0]?.user_login;
|
const login = pageVods[0]?.user_login;
|
||||||
if (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
|
const result = item.mergeGroup
|
||||||
? await processDownloadMergeGroup(item, (progress) => {
|
? await processDownloadMergeGroup(item, (progress) => {
|
||||||
mainWindow?.webContents.send('download-progress', progress);
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
recordDownloadProgress(progress);
|
||||||
})
|
})
|
||||||
: await downloadVOD(item, (progress) => {
|
: await downloadVOD(item, (progress) => {
|
||||||
mainWindow?.webContents.send('download-progress', progress);
|
mainWindow?.webContents.send('download-progress', progress);
|
||||||
|
recordDownloadProgress(progress);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@ -3294,6 +3346,7 @@ async function processOneQueueItem(item: QueueItem): Promise<void> {
|
|||||||
cancelledItemIds.delete(item.id);
|
cancelledItemIds.delete(item.id);
|
||||||
// Release only THIS item's claimed filenames (other parallel downloads keep their claims)
|
// Release only THIS item's claimed filenames (other parallel downloads keep their claims)
|
||||||
releaseClaimedFilenamesForItem(item.id);
|
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', () => {
|
ipcMain.handle('reset-downloaded-vod-ids', () => {
|
||||||
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
const count = Array.isArray(config.downloaded_vod_ids) ? config.downloaded_vod_ids.length : 0;
|
||||||
config.downloaded_vod_ids = [];
|
config.downloaded_vod_ids = [];
|
||||||
|
|||||||
@ -111,6 +111,8 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
ipcRenderer.invoke('export-runtime-metrics'),
|
ipcRenderer.invoke('export-runtime-metrics'),
|
||||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
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 }> =>
|
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||||
ipcRenderer.invoke('export-config'),
|
ipcRenderer.invoke('export-config'),
|
||||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
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>;
|
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||||
|
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
||||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||||
importConfig(): 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;
|
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||||
|
|||||||
@ -207,7 +207,13 @@ const UI_TEXT_DE = {
|
|||||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||||
hideDownloaded: 'Bereits geladene ausblenden',
|
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: {
|
clips: {
|
||||||
dialogTitle: 'VOD zuschneiden',
|
dialogTitle: 'VOD zuschneiden',
|
||||||
|
|||||||
@ -207,7 +207,13 @@ const UI_TEXT_EN = {
|
|||||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||||
alreadyDownloaded: 'Already downloaded',
|
alreadyDownloaded: 'Already downloaded',
|
||||||
hideDownloaded: 'Hide 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: {
|
clips: {
|
||||||
dialogTitle: 'Trim VOD',
|
dialogTitle: 'Trim VOD',
|
||||||
|
|||||||
@ -199,36 +199,73 @@ function focusVodFilter(): void {
|
|||||||
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
|
function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string>): string {
|
||||||
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
|
const thumb = vod.thumbnail_url.replace('%{width}', '320').replace('%{height}', '180');
|
||||||
const date = formatUiDate(vod.created_at);
|
const date = formatUiDate(vod.created_at);
|
||||||
const escapedTitle = vod.title.replace(/'/g, "\\'").replace(/\"/g, '"');
|
|
||||||
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
|
||||||
const safeUrlAttr = escapeHtml(vod.url);
|
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 isChecked = selectedVodUrls.has(vod.url);
|
||||||
const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false;
|
const isAlreadyDownloaded = downloadedIds ? downloadedIds.has(vod.id) : false;
|
||||||
const downloadedBadge = isAlreadyDownloaded
|
const downloadedBadge = isAlreadyDownloaded
|
||||||
? `<div class="vod-downloaded-badge" title="${escapeHtml(UI_TEXT.vods.alreadyDownloaded)}">✓</div>`
|
? `<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 `
|
return `
|
||||||
<div class="vod-card${isChecked ? ' selected' : ''}${isAlreadyDownloaded ? ' already-downloaded' : ''}">
|
<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;">
|
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}
|
${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-info">
|
||||||
<div class="vod-title">${safeDisplayTitle}</div>
|
<div class="vod-title">${safeDisplayTitle}</div>
|
||||||
<div class="vod-meta">
|
<div class="vod-meta">
|
||||||
<span>${date}</span>
|
<span>${date}</span>
|
||||||
<span>${vod.duration}</span>
|
<span>${escapeHtml(vod.duration)}</span>
|
||||||
<span>${formatUiNumber(vod.view_count)} ${UI_TEXT.vods.views}</span>
|
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="vod-actions">
|
<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 secondary" data-vod-action="trim">${escapeHtml(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 primary" data-vod-action="queue">${escapeHtml(UI_TEXT.vods.addQueue)}</button>
|
||||||
</div>
|
</div>
|
||||||
</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 streamerDragInitialized = false;
|
||||||
let draggedStreamerName: string | null = null;
|
let draggedStreamerName: string | null = null;
|
||||||
|
|
||||||
@ -434,19 +471,167 @@ function initVodGridSelectionDelegation(): void {
|
|||||||
|
|
||||||
const grid = document.getElementById('vodGrid');
|
const grid = document.getElementById('vodGrid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
grid.addEventListener('click', (e) => {
|
grid.addEventListener('click', (e) => {
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (!(target instanceof HTMLInputElement)) return;
|
// 1) Checkbox toggles (bulk-select)
|
||||||
if (!target.classList.contains('vod-select-checkbox')) return;
|
if (target instanceof HTMLInputElement && target.classList.contains('vod-select-checkbox')) {
|
||||||
const url = target.dataset.vodUrl || '';
|
const url = target.dataset.vodUrl || '';
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (target.checked) selectedVodUrls.add(url);
|
if (target.checked) selectedVodUrls.add(url);
|
||||||
else selectedVodUrls.delete(url);
|
else selectedVodUrls.delete(url);
|
||||||
// Keep card visual + bar in sync without a full re-render of all cards
|
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;
|
const card = target.closest('.vod-card') as HTMLElement | null;
|
||||||
if (card) card.classList.toggle('selected', target.checked);
|
if (!card) return;
|
||||||
updateVodBulkBar();
|
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 {
|
function updateVodBulkBar(): void {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user