release: publish Twitch VOD Manager 1.0.3

Polish navigation, settings, streamer and queue workflows; add safe pause and partial-file lifecycle handling; apply the product identity across Windows surfaces; and replace the public README with a complete English product guide and isolated screenshot.
This commit is contained in:
Sucukdeluxe
2026-08-10 19:25:08 +02:00
parent f9e415da88
commit b3c2a8b9fd
48 changed files with 4035 additions and 826 deletions
+352 -131
View File
@@ -1,12 +1,90 @@
let selectStreamerRequestId = 0;
let vodRenderTaskId = 0;
let streamerIndicatorFrame: number | null = null;
const VOD_RENDER_CHUNK_SIZE = 64;
function scheduleStreamerActiveIndicatorSync(): void {
if (streamerIndicatorFrame !== null) return;
streamerIndicatorFrame = requestAnimationFrame(() => {
streamerIndicatorFrame = null;
const list = document.getElementById('streamerList');
const activeItem = list?.querySelector<HTMLElement>('.streamer-item.active');
if (!list || !activeItem) {
list?.classList.remove('streamer-indicator-visible');
return;
}
list.style.setProperty('--streamer-active-x', `${activeItem.offsetLeft}px`);
list.style.setProperty('--streamer-active-y', `${activeItem.offsetTop}px`);
list.style.setProperty('--streamer-active-width', `${activeItem.offsetWidth}px`);
list.style.setProperty('--streamer-active-height', `${activeItem.offsetHeight}px`);
list.classList.add('streamer-indicator-visible');
requestAnimationFrame(() => list.classList.add('streamer-indicator-ready'));
});
}
// Live status snapshot — updated by the main process via the
// 'live-status-batch-update' IPC event. Keys are lowercase logins so
// the lookup is case-insensitive regardless of how the streamer's
// name was added (display-cased vs login-cased).
const liveStatusByLogin = new Map<string, boolean>();
const streamerDisplayNames = new Map<string, string>();
function rememberStreamerDisplayName(login: string, displayName: string): void {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (!normalizedLogin || !normalizedDisplayName) return;
const changed = streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName;
if (changed) streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
if (currentStreamer?.toLowerCase() === normalizedLogin) {
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(normalizedDisplayName);
}
if (changed) renderStreamers();
}
(window as unknown as { rememberStreamerDisplayName: typeof rememberStreamerDisplayName }).rememberStreamerDisplayName = rememberStreamerDisplayName;
async function hydrateStreamerDisplayNames(): Promise<void> {
const configuredNames = config.streamer_display_names || {};
let changed = false;
for (const [login, displayName] of Object.entries(configuredNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
const streamers = (config.streamers ?? []) as string[];
if (streamers.length === 0) {
if (changed) renderStreamers();
return;
}
try {
const resolvedNames = await window.api.getStreamerDisplayNames(streamers);
for (const [login, displayName] of Object.entries(resolvedNames)) {
const normalizedLogin = login.trim().toLowerCase();
const normalizedDisplayName = displayName.trim();
if (normalizedLogin && normalizedDisplayName && streamerDisplayNames.get(normalizedLogin) !== normalizedDisplayName) {
streamerDisplayNames.set(normalizedLogin, normalizedDisplayName);
changed = true;
}
}
} catch { }
if (changed) {
if (currentStreamer) {
const displayName = streamerDisplayNames.get(currentStreamer.toLowerCase());
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (displayName && typeof setTitle === 'function') setTitle(displayName);
}
renderStreamers();
}
}
(window as unknown as { hydrateStreamerDisplayNames: typeof hydrateStreamerDisplayNames }).hydrateStreamerDisplayNames = hydrateStreamerDisplayNames;
async function initLiveStatusSubscription(): Promise<void> {
try {
@@ -37,6 +115,15 @@ async function initLiveStatusSubscription(): Promise<void> {
// the clear button. Shared across streamers (acts like a search bar).
let lastLoadedVods: VOD[] = [];
let lastLoadedStreamer: string | null = null;
interface CachedStreamerVods {
userId: string;
vods: VOD[];
updatedAt: number;
}
const streamerVodCache = new Map<string, CachedStreamerVods>();
const streamerVodLoads = new Map<string, Promise<CachedStreamerVods | null>>();
let streamerBackgroundRefreshTimer: number | null = null;
const STREAMER_BACKGROUND_REFRESH_MS = 5 * 60 * 1000;
let vodFilterQuery = '';
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
@@ -222,7 +309,7 @@ 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 date = formatVodCardDate(vod.created_at);
const safeDisplayTitle = escapeHtml(vod.title || UI_TEXT.vods.untitled);
const safeUrlAttr = escapeHtml(vod.url);
const safeTitleAttr = escapeHtml(vod.title || '');
@@ -254,15 +341,14 @@ function buildVodCardHtml(vod: VOD, streamer: string, downloadedIds?: Set<string
<input type="checkbox" class="vod-select-checkbox" data-vod-url="${safeUrlAttr}" ${isChecked ? 'checked' : ''} aria-label="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}">
${downloadedBadge}
<div class="vod-thumb-wrap">
<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>'">
<img class="vod-thumbnail" loading="lazy" decoding="async" src="${thumb}" alt="" title="${escapeHtml(UI_TEXT.vods.selectAriaLabel)}" 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-duration-badge">${escapeHtml(vod.duration)}</div>
</div>
<div class="vod-info">
<div class="vod-title" title="${escapeHtml(vod.title || '')}">${safeDisplayTitle}</div>
<div class="vod-meta">
<span>${date}</span>
<span>${escapeHtml(vod.duration)}</span>
<span>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
<span class="vod-date"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M7 3v3M17 3v3M4 9h16M5 5h14a1 1 0 0 1 1 1v14H4V6a1 1 0 0 1 1-1z"></path></svg>${date}</span>
<span class="vod-views"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6S2 12 2 12z"></path><circle cx="12" cy="12" r="2.5"></circle></svg>${formatUiNumber(vod.view_count)} ${escapeHtml(UI_TEXT.vods.views)}</span>
</div>
</div>
<div class="vod-actions">
@@ -412,6 +498,76 @@ function initCutterDragDrop(): void {
});
}
let streamerContextMenu: HTMLDivElement | null = null;
let streamerContextMenuCleanup: (() => void) | null = null;
function dismissStreamerContextMenu(): void {
streamerContextMenuCleanup?.();
streamerContextMenuCleanup = null;
streamerContextMenu = null;
}
function showStreamerContextMenu(event: MouseEvent, streamer: string): void {
event.preventDefault();
event.stopPropagation();
dismissStreamerContextMenu();
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const menu = document.createElement('div');
menu.className = 'streamer-context-menu';
menu.setAttribute('role', 'menu');
menu.setAttribute('aria-label', streamerDisplayNames.get(streamer.toLowerCase()) || streamer);
const appendAction = (action: 'auto' | 'vod' | 'record', label: string, active: boolean, handler: () => void): void => {
const button = document.createElement('button');
button.type = 'button';
button.dataset.streamerAction = action;
button.className = `streamer-context-action streamer-context-${action}${active ? ' is-active' : ''}`;
button.textContent = label;
button.setAttribute('role', 'menuitem');
button.setAttribute('aria-label', action === 'auto'
? UI_TEXT.streamers?.autoRecordTitle || 'Auto-record'
: action === 'vod'
? UI_TEXT.streamers?.autoVodTitle || 'Auto-download VODs'
: UI_TEXT.streamers?.recordLiveTitle || 'Record live now');
button.addEventListener('click', () => {
dismissStreamerContextMenu();
handler();
});
menu.appendChild(button);
};
appendAction('auto', 'AUTO', autoList.includes(streamer), () => { void toggleAutoRecord(streamer); });
appendAction('vod', 'VOD', vodList.includes(streamer), () => { void toggleAutoVodDownload(streamer); });
appendAction('record', 'REC', false, () => { void triggerLiveRecording(streamer); });
document.body.appendChild(menu);
const bounds = menu.getBoundingClientRect();
const margin = 8;
menu.style.left = `${Math.max(margin, Math.min(event.clientX, window.innerWidth - bounds.width - margin))}px`;
menu.style.top = `${Math.max(margin, Math.min(event.clientY, window.innerHeight - bounds.height - margin))}px`;
const onPointerDown = (pointerEvent: PointerEvent): void => {
if (!menu.contains(pointerEvent.target as Node)) dismissStreamerContextMenu();
};
const onKeyDown = (keyEvent: KeyboardEvent): void => {
if (keyEvent.key === 'Escape') dismissStreamerContextMenu();
};
const onResize = (): void => dismissStreamerContextMenu();
streamerContextMenu = menu;
streamerContextMenuCleanup = () => {
document.removeEventListener('pointerdown', onPointerDown, true);
document.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('resize', onResize);
menu.remove();
};
document.addEventListener('pointerdown', onPointerDown, true);
document.addEventListener('keydown', onKeyDown, true);
window.addEventListener('resize', onResize);
menu.querySelector<HTMLButtonElement>('button')?.focus();
}
function renderStreamers(): void {
const list = byId('streamerList');
list.replaceChildren();
@@ -436,6 +592,7 @@ function renderStreamers(): void {
if (counter) counter.textContent = '';
const bulkBtn = document.getElementById('btnStreamerBulkRemove') as HTMLButtonElement | null;
if (bulkBtn) bulkBtn.classList.add('is-hidden');
scheduleStreamerActiveIndicatorSync();
return;
}
@@ -443,14 +600,7 @@ function renderStreamers(): void {
// stays accurate after add/remove/live-status changes.
const counter = document.getElementById('streamerSectionCounter');
if (counter) {
const liveCount = all.reduce((n, s) => n + (liveStatusByLogin.get(s.toLowerCase()) === true ? 1 : 0), 0);
if (all.length === 0) {
counter.textContent = '';
} else if (liveCount > 0) {
counter.innerHTML = `${all.length} <span class="streamer-section-counter-divider" aria-hidden="true">·</span> <span class="streamer-section-counter-live">${liveCount} live</span>`;
} else {
counter.textContent = String(all.length);
}
counter.textContent = all.length === 0 ? '' : String(all.length);
}
const q = (streamerListFilterQuery || '').trim().toLowerCase();
@@ -487,75 +637,7 @@ function renderStreamers(): void {
const nameSpan = document.createElement('span');
nameSpan.className = 'streamer-name' + (isLive ? ' is-live' : '');
nameSpan.textContent = streamer;
// Three streamer-row action chips (AUTO toggle / VOD toggle / REC
// one-shot). All share the same accessibility wiring:
// role="button", tabindex="0", aria-pressed for the toggles +
// aria-label for screen readers, plus Enter/Space keydown
// activation. wireChipButton centralises that so each chip only
// declares its own visual class + label + handler.
const wireChipButton = (el: HTMLElement, opts: {
handler: () => void;
ariaLabel: string;
pressed?: boolean;
}): void => {
el.setAttribute('role', 'button');
el.setAttribute('tabindex', '0');
el.setAttribute('aria-label', opts.ariaLabel);
if (opts.pressed !== undefined) el.setAttribute('aria-pressed', String(opts.pressed));
el.addEventListener('click', (e) => {
e.stopPropagation();
opts.handler();
});
el.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
opts.handler();
}
});
};
// AUTO toggle — when enabled, the main-process auto-record poller
// watches this channel for offline->live transitions and queues a
// live recording automatically.
const autoList = (config.auto_record_streamers as string[] | undefined) || [];
const isAutoOn = autoList.includes(streamer);
const autoBtn = document.createElement('span');
autoBtn.className = 'streamer-auto' + (isAutoOn ? ' active' : '');
autoBtn.textContent = 'AUTO';
autoBtn.title = UI_TEXT.streamers?.autoRecordTitle || 'Auto-record when this streamer goes live';
wireChipButton(autoBtn, {
handler: () => { void toggleAutoRecord(streamer); },
ariaLabel: UI_TEXT.streamers?.autoRecordTitle || 'Auto-record',
pressed: isAutoOn
});
// VOD-auto-download toggle — periodic scan of this streamer's
// VOD list, auto-queues anything new within the age window.
const vodList = (config.auto_vod_download_streamers as string[] | undefined) || [];
const isVodOn = vodList.includes(streamer);
const vodBtn = document.createElement('span');
vodBtn.className = 'streamer-vod' + (isVodOn ? ' active' : '');
vodBtn.textContent = 'VOD';
vodBtn.title = UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs';
wireChipButton(vodBtn, {
handler: () => { void toggleAutoVodDownload(streamer); },
ariaLabel: UI_TEXT.streamers?.autoVodTitle || 'Auto-download new VODs',
pressed: isVodOn
});
// Live-record one-shot — triggers a recording immediately (server
// verifies the streamer is online before honoring the request).
const recBtn = document.createElement('span');
recBtn.className = 'streamer-rec';
recBtn.textContent = 'REC';
recBtn.title = UI_TEXT.streamers?.recordLiveTitle || 'Record live now';
wireChipButton(recBtn, {
handler: () => { void triggerLiveRecording(streamer); },
ariaLabel: UI_TEXT.streamers?.recordLiveTitle || 'Record live now'
});
nameSpan.textContent = streamerDisplayNames.get(streamer.toLowerCase()) || streamer;
const removeSpan = document.createElement('span');
removeSpan.className = 'remove';
removeSpan.textContent = 'x';
@@ -573,7 +655,22 @@ function renderStreamers(): void {
void removeStreamer(streamer);
}
});
item.append(nameSpan, autoBtn, vodBtn, recBtn, removeSpan);
item.append(nameSpan, removeSpan);
item.addEventListener('contextmenu', (contextEvent) => {
showStreamerContextMenu(contextEvent, streamer);
});
item.addEventListener('keydown', (keyEvent) => {
if (keyEvent.key !== 'ContextMenu' && !(keyEvent.shiftKey && keyEvent.key === 'F10')) return;
keyEvent.preventDefault();
const rect = item.getBoundingClientRect();
showStreamerContextMenu(new MouseEvent('contextmenu', {
bubbles: true,
cancelable: true,
clientX: rect.left + 12,
clientY: rect.top + 12
}), streamer);
});
item.addEventListener('click', () => {
// Skip click if drag was just released — drop fires after dragend
@@ -598,6 +695,7 @@ function renderStreamers(): void {
if (bulkBtn) bulkBtn.classList.toggle('is-hidden', all.length < STREAMER_FILTER_THRESHOLD);
initStreamerDragDrop();
scheduleStreamerActiveIndicatorSync();
}
function onStreamerListFilterChange(): void {
@@ -710,6 +808,7 @@ async function addStreamer(): Promise<void> {
config = await window.api.saveConfig({ streamers: config.streamers });
input.value = '';
renderStreamers();
void hydrateStreamerDisplayNames();
await selectStreamer(name);
}
@@ -728,6 +827,77 @@ async function removeStreamer(name: string): Promise<void> {
setVodGridEmptyState(byId('vodGrid'), UI_TEXT.vods.noneTitle, UI_TEXT.vods.noneText);
}
function normalizeStreamerCacheKey(name: string): string {
return name.trim().toLowerCase();
}
function vodCollectionsMatch(left: VOD[], right: VOD[]): boolean {
if (left.length !== right.length) return false;
return left.every((vod, index) => {
const other = right[index];
return Boolean(other)
&& vod.id === other.id
&& vod.title === other.title
&& vod.created_at === other.created_at
&& vod.duration === other.duration
&& vod.thumbnail_url === other.thumbnail_url
&& vod.url === other.url
&& vod.view_count === other.view_count;
});
}
async function loadStreamerVods(name: string, forceRefresh = false): Promise<CachedStreamerVods | null> {
const key = normalizeStreamerCacheKey(name);
const cached = streamerVodCache.get(key);
if (!forceRefresh && cached) return cached;
const pending = streamerVodLoads.get(key);
if (pending) return pending;
const task = (async () => {
const userId = await window.api.getUserId(name);
if (!userId) return null;
const vods = await window.api.getVODs(userId, forceRefresh);
const entry = { userId, vods: Array.isArray(vods) ? vods : [], updatedAt: Date.now() };
streamerVodCache.set(key, entry);
return entry;
})();
streamerVodLoads.set(key, task);
try {
return await task;
} finally {
if (streamerVodLoads.get(key) === task) streamerVodLoads.delete(key);
}
}
async function preloadConfiguredStreamerData(streamers: string[] = (config.streamers ?? []) as string[]): Promise<void> {
const profilePreloader = (window as unknown as { preloadStreamerProfiles?: (logins: string[]) => Promise<void> }).preloadStreamerProfiles;
const jobs: Promise<unknown>[] = streamers.map((name) => loadStreamerVods(name));
if (typeof profilePreloader === 'function') jobs.push(profilePreloader(streamers));
await Promise.allSettled(jobs);
}
async function refreshConfiguredStreamersInBackground(): Promise<void> {
const streamers = [...((config.streamers ?? []) as string[])];
const profileRefresher = (window as unknown as { refreshStreamerProfilesInBackground?: (logins: string[]) => Promise<void> }).refreshStreamerProfilesInBackground;
const activeKey = currentStreamer ? normalizeStreamerCacheKey(currentStreamer) : '';
const previousActiveVods = activeKey ? streamerVodCache.get(activeKey)?.vods ?? [] : [];
const jobs = streamers.map((name) => loadStreamerVods(name, true));
await Promise.allSettled([
...jobs,
...(typeof profileRefresher === 'function' ? [profileRefresher(streamers)] : [])
]);
if (!currentStreamer || normalizeStreamerCacheKey(currentStreamer) !== activeKey) return;
const updated = streamerVodCache.get(activeKey)?.vods;
if (updated && !vodCollectionsMatch(previousActiveVods, updated)) renderVODs(updated, currentStreamer, true);
}
function startStreamerBackgroundRefresh(): void {
if (streamerBackgroundRefreshTimer !== null) window.clearInterval(streamerBackgroundRefreshTimer);
streamerBackgroundRefreshTimer = window.setInterval(() => {
void refreshConfiguredStreamersInBackground();
}, STREAMER_BACKGROUND_REFRESH_MS);
}
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
// Save where we were on the OLD streamer before navigating away.
rememberCurrentVodScroll();
@@ -742,15 +912,16 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
pendingScrollRestore = (typeof savedY === 'number' && savedY > 0) ? { streamer: name, y: savedY } : null;
renderStreamers();
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
if (typeof setTitle === 'function') setTitle(name);
else byId('pageTitle').textContent = name;
const displayName = streamerDisplayNames.get(name.toLowerCase()) || name;
if (typeof setTitle === 'function') setTitle(displayName);
else byId('pageTitle').textContent = displayName;
// Kick off the profile header load in parallel with VOD fetching.
// It's a separate request stream and not strictly needed for the VOD
// grid, so we don't await it here — the skeleton appears immediately.
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string) => Promise<void> }).loadStreamerProfile;
const profileLoader = (window as unknown as { loadStreamerProfile?: (login: string, forceRefresh?: boolean) => Promise<void> }).loadStreamerProfile;
if (typeof profileLoader === 'function') {
void profileLoader(name);
void profileLoader(name, forceRefresh);
}
if (!isConnected) {
@@ -764,36 +935,32 @@ async function selectStreamer(name: string, forceRefresh = false): Promise<void>
updateStatus(UI_TEXT.status.noLogin, false);
}
// Skeleton loader — six placeholder cards while VODs come in. Much
// less jarring than a "Loading..." text block in an otherwise blank
// grid. Shimmer animation is in CSS.
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
const key = normalizeStreamerCacheKey(name);
const cached = streamerVodCache.get(key);
if (cached) {
renderVODs(cached.vods, name);
} else {
byId('vodGrid').innerHTML = Array.from({ length: 6 }, () => `
<div class="vod-card vod-card-skeleton">
<div class="vod-skel-thumb"></div>
<div class="vod-info">
<div class="vod-skel-line title"></div>
<div class="vod-skel-line meta-1"></div>
<div class="vod-skel-line meta-2"></div>
</div>
</div>
</div>
`).join('');
const userId = await window.api.getUserId(name);
if (isStaleRequest()) {
return;
`).join('');
}
if (!userId) {
const loaded = await loadStreamerVods(name, forceRefresh);
if (isStaleRequest()) return;
if (!loaded) {
byId('vodGrid').innerHTML = `<div class="empty-state"><h3>${UI_TEXT.vods.notFound}</h3></div>`;
return;
}
const vods = await window.api.getVODs(userId, forceRefresh);
if (isStaleRequest()) {
return;
}
renderVODs(vods, name);
if (!cached || !vodCollectionsMatch(cached.vods, loaded.vods)) renderVODs(loaded.vods, name, Boolean(cached));
}
function createVodEmptyStateIcon(): SVGSVGElement {
@@ -826,16 +993,17 @@ function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): v
grid.replaceChildren(wrap);
}
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
function renderVODs(vods: VOD[] | null | undefined, streamer: string, animateChanges = false): void {
// Clear bulk-selection on streamer switch — selection is per-streamer
if (lastLoadedStreamer && lastLoadedStreamer !== streamer && selectedVodUrls.size > 0) {
selectedVodUrls.clear();
updateVodBulkBar();
}
const motion = animateChanges ? captureVodGridMotion() : undefined;
lastLoadedVods = Array.isArray(vods) ? vods : [];
lastLoadedStreamer = streamer;
initVodGridSelectionDelegation();
renderVodGridFromCurrentState();
renderVodGridFromCurrentState(motion);
// After the first chunk lands the grid has size, so scroll-restore can
// succeed. Use a small delay to let chunked rendering paint.
@@ -862,13 +1030,8 @@ function initVodGridSelectionDelegation(): void {
const target = e.target as HTMLElement;
// 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();
if (card) setVodCardSelection(card, target.checked);
return;
}
@@ -886,14 +1049,10 @@ function initVodGridSelectionDelegation(): void {
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) 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);
toggleVodCardSelection(card);
});
grid.addEventListener('contextmenu', (e) => {
@@ -905,23 +1064,34 @@ function initVodGridSelectionDelegation(): void {
showVodContextMenu(e.clientX, e.clientY, ctx);
});
// Enter / Space on a focused VOD card opens the VOD on Twitch — same
// outcome as a mouse click on the thumbnail. Skip when focus is on a
// child (action button, checkbox) because those have their own
// keyboard handlers (native button + checkbox semantics).
grid.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
const target = e.target as HTMLElement | null;
if (!target) return;
const card = target.closest('.vod-card') as HTMLElement | null;
if (!card || card !== target) return;
const ctx = readVodCardContext(card);
if (!ctx) return;
e.preventDefault();
void window.api.openExternal(ctx.url);
toggleVodCardSelection(card);
});
}
function setVodCardSelection(card: HTMLElement, selected: boolean): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
const url = checkbox?.dataset.vodUrl || '';
if (!checkbox || !url) return;
checkbox.checked = selected;
card.classList.toggle('selected', selected);
if (selected) selectedVodUrls.add(url);
else selectedVodUrls.delete(url);
updateVodBulkBar();
}
function toggleVodCardSelection(card: HTMLElement): void {
const checkbox = card.querySelector<HTMLInputElement>('.vod-select-checkbox');
if (!checkbox) return;
setVodCardSelection(card, !checkbox.checked);
}
let activeVodContextMenu: HTMLElement | null = null;
function closeVodContextMenu(): void {
@@ -1175,7 +1345,48 @@ async function bulkAddSelectedVodsToQueue(): Promise<void> {
}
}
function renderVodGridFromCurrentState(): void {
interface VodGridMotion {
rects: Map<string, { left: number; top: number }>;
ids: Set<string>;
}
function captureVodGridMotion(): VodGridMotion {
const rects = new Map<string, { left: number; top: number }>();
const ids = new Set<string>();
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card) => {
const id = card.dataset.vodId;
if (!id) return;
const rect = card.getBoundingClientRect();
rects.set(id, { left: rect.left, top: rect.top });
ids.add(id);
});
return { rects, ids };
}
function animateVodGridMotion(motion: VodGridMotion): void {
document.querySelectorAll<HTMLElement>('#vodGrid .vod-card[data-vod-id]').forEach((card, index) => {
const id = card.dataset.vodId || '';
const previous = motion.rects.get(id);
if (previous) {
const rect = card.getBoundingClientRect();
const x = previous.left - rect.left;
const y = previous.top - rect.top;
if (Math.abs(x) > 0.5 || Math.abs(y) > 0.5) {
card.animate([
{ transform: `translate(${x}px, ${y}px)` },
{ transform: 'translate(0, 0)' }
], { duration: 420, easing: 'cubic-bezier(.22, 1, .36, 1)' });
}
return;
}
card.animate([
{ opacity: 0, transform: 'translateY(16px) scale(.98)' },
{ opacity: 1, transform: 'translateY(0) scale(1)' }
], { duration: 360, delay: Math.min(index * 24, 144), easing: 'cubic-bezier(.22, 1, .36, 1)', fill: 'backwards' });
});
}
function renderVodGridFromCurrentState(motion?: VodGridMotion): void {
if (!lastLoadedStreamer) return;
const grid = byId('vodGrid');
@@ -1237,6 +1448,8 @@ function renderVodGridFromCurrentState(): void {
if (startIndex + chunk.length < filtered.length) {
scheduleNextChunk(startIndex + chunk.length);
} else if (motion) {
requestAnimationFrame(() => animateVodGridMotion(motion));
}
};
@@ -1250,3 +1463,11 @@ async function refreshVODs(): Promise<void> {
await selectStreamer(currentStreamer, true);
}
(window as unknown as {
preloadConfiguredStreamerData: typeof preloadConfiguredStreamerData;
refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground;
startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh;
}).preloadConfiguredStreamerData = preloadConfiguredStreamerData;
(window as unknown as { refreshConfiguredStreamersInBackground: typeof refreshConfiguredStreamersInBackground }).refreshConfiguredStreamersInBackground = refreshConfiguredStreamersInBackground;
(window as unknown as { startStreamerBackgroundRefresh: typeof startStreamerBackgroundRefresh }).startStreamerBackgroundRefresh = startStreamerBackgroundRefresh;