Adds a sort selector next to the existing filter input. Five modes: newest first (default), oldest first, most viewed, longest first, shortest first. Concrete user pain — long archives previously had no way to find the longest stream, the most-watched, or to scroll back to the start chronologically. - vodSortKey state persisted to localStorage as twitch-vod-manager:vod-sort and validated against an enum on load, so an unknown stored value falls back to date_desc - renderVodGridFromCurrentState now applies sortVods before filterVodsByQuery so the filter sees the sort and the match counter is consistent - sortVods uses created_at timestamps for date sorts, view_count for views, and a tiny vodDurationToSeconds parser (XhYmZs) for duration - DE + EN labels for both the "Sort:" prefix and the five option texts; refreshVodSortSelectLabels re-runs on language switch - syncVodSortSelect on init preselects the persisted value before any VOD load so the dropdown reflects state immediately Browser-default keyboard nav (arrows, type-ahead) covers keyboard access for the select. docs/IMPROVEMENT_LOG.md: Cycle 4 dated section. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
360 lines
12 KiB
TypeScript
360 lines
12 KiB
TypeScript
let selectStreamerRequestId = 0;
|
|
let vodRenderTaskId = 0;
|
|
const VOD_RENDER_CHUNK_SIZE = 64;
|
|
|
|
// VOD filter state — persists across renderer reloads via localStorage so the
|
|
// user's search query survives an app restart. Cleared explicitly via Esc /
|
|
// the clear button. Shared across streamers (acts like a search bar).
|
|
let lastLoadedVods: VOD[] = [];
|
|
let lastLoadedStreamer: string | null = null;
|
|
let vodFilterQuery = '';
|
|
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
|
|
|
type VodSortKey = 'date_desc' | 'date_asc' | 'views_desc' | 'duration_desc' | 'duration_asc';
|
|
const VALID_VOD_SORTS: ReadonlyArray<VodSortKey> = ['date_desc', 'date_asc', 'views_desc', 'duration_desc', 'duration_asc'];
|
|
const VOD_SORT_STORAGE_KEY = 'twitch-vod-manager:vod-sort';
|
|
let vodSortKey: VodSortKey = 'date_desc';
|
|
|
|
function loadPersistedVodSort(): VodSortKey {
|
|
try {
|
|
const stored = localStorage.getItem(VOD_SORT_STORAGE_KEY);
|
|
if (stored && (VALID_VOD_SORTS as readonly string[]).includes(stored)) {
|
|
return stored as VodSortKey;
|
|
}
|
|
} catch { /* localStorage may be unavailable */ }
|
|
return 'date_desc';
|
|
}
|
|
|
|
function persistVodSort(key: VodSortKey): void {
|
|
try { localStorage.setItem(VOD_SORT_STORAGE_KEY, key); } catch { /* localStorage may be unavailable */ }
|
|
}
|
|
|
|
function vodDurationToSeconds(durationStr: string): number {
|
|
let total = 0;
|
|
const h = durationStr.match(/(\d+)h/);
|
|
const m = durationStr.match(/(\d+)m/);
|
|
const s = durationStr.match(/(\d+)s/);
|
|
if (h) total += parseInt(h[1], 10) * 3600;
|
|
if (m) total += parseInt(m[1], 10) * 60;
|
|
if (s) total += parseInt(s[1], 10);
|
|
return total;
|
|
}
|
|
|
|
function sortVods(vods: VOD[], key: VodSortKey): VOD[] {
|
|
const sorted = [...vods];
|
|
const ts = (s: string): number => {
|
|
const n = new Date(s).getTime();
|
|
return Number.isFinite(n) ? n : 0;
|
|
};
|
|
switch (key) {
|
|
case 'date_desc':
|
|
sorted.sort((a, b) => ts(b.created_at) - ts(a.created_at));
|
|
break;
|
|
case 'date_asc':
|
|
sorted.sort((a, b) => ts(a.created_at) - ts(b.created_at));
|
|
break;
|
|
case 'views_desc':
|
|
sorted.sort((a, b) => (b.view_count || 0) - (a.view_count || 0));
|
|
break;
|
|
case 'duration_desc':
|
|
sorted.sort((a, b) => vodDurationToSeconds(b.duration) - vodDurationToSeconds(a.duration));
|
|
break;
|
|
case 'duration_asc':
|
|
sorted.sort((a, b) => vodDurationToSeconds(a.duration) - vodDurationToSeconds(b.duration));
|
|
break;
|
|
}
|
|
return sorted;
|
|
}
|
|
|
|
function onVodSortChange(): void {
|
|
const select = byId<HTMLSelectElement>('vodSortSelect');
|
|
const value = select.value;
|
|
if ((VALID_VOD_SORTS as readonly string[]).includes(value)) {
|
|
vodSortKey = value as VodSortKey;
|
|
persistVodSort(vodSortKey);
|
|
if (lastLoadedStreamer) {
|
|
renderVodGridFromCurrentState();
|
|
}
|
|
}
|
|
}
|
|
|
|
function syncVodSortSelect(): void {
|
|
const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null;
|
|
if (select) select.value = vodSortKey;
|
|
}
|
|
|
|
function refreshVodSortSelectLabels(): void {
|
|
const select = document.getElementById('vodSortSelect') as HTMLSelectElement | null;
|
|
if (!select) return;
|
|
const labels: Record<VodSortKey, string> = {
|
|
date_desc: UI_TEXT.vods.sortDateDesc,
|
|
date_asc: UI_TEXT.vods.sortDateAsc,
|
|
views_desc: UI_TEXT.vods.sortViewsDesc,
|
|
duration_desc: UI_TEXT.vods.sortDurationDesc,
|
|
duration_asc: UI_TEXT.vods.sortDurationAsc
|
|
};
|
|
for (const opt of Array.from(select.options)) {
|
|
const k = opt.value as VodSortKey;
|
|
if (labels[k]) opt.textContent = labels[k];
|
|
}
|
|
}
|
|
|
|
function loadPersistedVodFilter(): string {
|
|
try {
|
|
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
|
|
} catch {
|
|
return '';
|
|
}
|
|
}
|
|
|
|
function persistVodFilter(query: string): void {
|
|
try { localStorage.setItem(VOD_FILTER_STORAGE_KEY, query); } catch { /* localStorage may be unavailable */ }
|
|
}
|
|
|
|
function filterVodsByQuery(vods: VOD[], query: string): VOD[] {
|
|
const q = query.trim().toLowerCase();
|
|
if (!q) return vods;
|
|
return vods.filter((vod) => (vod.title || '').toLowerCase().includes(q));
|
|
}
|
|
|
|
function updateVodFilterCount(filteredCount: number, totalCount: number): void {
|
|
const node = document.getElementById('vodFilterCount');
|
|
if (!node) return;
|
|
if (!totalCount || !vodFilterQuery.trim()) {
|
|
node.textContent = '';
|
|
return;
|
|
}
|
|
node.textContent = UI_TEXT.vods.filterMatchCount
|
|
.replace('{shown}', String(filteredCount))
|
|
.replace('{total}', String(totalCount));
|
|
}
|
|
|
|
function syncVodFilterClearButton(): void {
|
|
const btn = document.getElementById('vodFilterClearBtn') as HTMLButtonElement | null;
|
|
if (!btn) return;
|
|
btn.style.display = vodFilterQuery.trim() ? '' : 'none';
|
|
}
|
|
|
|
function onVodFilterInput(): void {
|
|
const input = byId<HTMLInputElement>('vodFilterInput');
|
|
vodFilterQuery = input.value;
|
|
persistVodFilter(vodFilterQuery);
|
|
syncVodFilterClearButton();
|
|
if (lastLoadedStreamer) {
|
|
renderVodGridFromCurrentState();
|
|
}
|
|
}
|
|
|
|
function clearVodFilter(): void {
|
|
vodFilterQuery = '';
|
|
const input = byId<HTMLInputElement>('vodFilterInput');
|
|
if (input) input.value = '';
|
|
persistVodFilter('');
|
|
syncVodFilterClearButton();
|
|
if (lastLoadedStreamer) {
|
|
renderVodGridFromCurrentState();
|
|
}
|
|
}
|
|
|
|
function focusVodFilter(): void {
|
|
const input = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
|
if (input) {
|
|
input.focus();
|
|
input.select();
|
|
}
|
|
}
|
|
|
|
function buildVodCardHtml(vod: VOD, streamer: 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);
|
|
|
|
return `
|
|
<div class="vod-card">
|
|
<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>'">
|
|
<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>
|
|
</div>
|
|
</div>
|
|
<div class="vod-actions">
|
|
<button class="vod-btn secondary" onclick="openClipDialog('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">Clip</button>
|
|
<button class="vod-btn primary" onclick="addToQueue('${vod.url}', '${escapedTitle}', '${vod.created_at}', '${streamer}', '${vod.duration}')">${UI_TEXT.vods.addQueue}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function renderStreamers(): void {
|
|
const list = byId('streamerList');
|
|
list.innerHTML = '';
|
|
|
|
(config.streamers ?? []).forEach((streamer: string) => {
|
|
const item = document.createElement('div');
|
|
item.className = 'streamer-item' + (currentStreamer === streamer ? ' active' : '');
|
|
item.innerHTML = `
|
|
<span>${streamer}</span>
|
|
<span class="remove" onclick="event.stopPropagation(); removeStreamer('${streamer}')">x</span>
|
|
`;
|
|
item.onclick = () => {
|
|
void selectStreamer(streamer);
|
|
};
|
|
list.appendChild(item);
|
|
});
|
|
}
|
|
|
|
async function addStreamer(): Promise<void> {
|
|
const input = byId<HTMLInputElement>('newStreamer');
|
|
const name = input.value.trim().toLowerCase();
|
|
if (!name || (config.streamers ?? []).includes(name)) {
|
|
return;
|
|
}
|
|
|
|
config.streamers = [...(config.streamers ?? []), name];
|
|
config = await window.api.saveConfig({ streamers: config.streamers });
|
|
input.value = '';
|
|
renderStreamers();
|
|
await selectStreamer(name);
|
|
}
|
|
|
|
async function removeStreamer(name: string): Promise<void> {
|
|
config.streamers = (config.streamers ?? []).filter((s: string) => s !== name);
|
|
config = await window.api.saveConfig({ streamers: config.streamers });
|
|
renderStreamers();
|
|
|
|
if (currentStreamer !== name) {
|
|
return;
|
|
}
|
|
|
|
currentStreamer = null;
|
|
byId('vodGrid').innerHTML = `
|
|
<div class="empty-state">
|
|
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M21 3H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-9 14l-5-4 5-4v8zm2-8l5 4-5 4V9z"/></svg>
|
|
<h3>${UI_TEXT.vods.noneTitle}</h3>
|
|
<p>${UI_TEXT.vods.noneText}</p>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function selectStreamer(name: string, forceRefresh = false): Promise<void> {
|
|
const requestId = ++selectStreamerRequestId;
|
|
const isStaleRequest = () => requestId !== selectStreamerRequestId || currentStreamer !== name;
|
|
|
|
currentStreamer = name;
|
|
renderStreamers();
|
|
byId('pageTitle').textContent = name;
|
|
|
|
if (!isConnected) {
|
|
await connect();
|
|
if (isStaleRequest()) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (!isConnected) {
|
|
updateStatus(UI_TEXT.status.noLogin, false);
|
|
}
|
|
|
|
byId('vodGrid').innerHTML = `<div class="empty-state"><p>${UI_TEXT.vods.loading}</p></div>`;
|
|
|
|
const userId = await window.api.getUserId(name);
|
|
if (isStaleRequest()) {
|
|
return;
|
|
}
|
|
|
|
if (!userId) {
|
|
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);
|
|
}
|
|
|
|
function setVodGridEmptyState(grid: HTMLElement, title: string, text: string): void {
|
|
// Build via DOM API so the (locale-only) strings can never escape into HTML.
|
|
const wrap = document.createElement('div');
|
|
wrap.className = 'empty-state';
|
|
const h3 = document.createElement('h3');
|
|
h3.textContent = title;
|
|
const p = document.createElement('p');
|
|
p.textContent = text;
|
|
wrap.appendChild(h3);
|
|
wrap.appendChild(p);
|
|
grid.replaceChildren(wrap);
|
|
}
|
|
|
|
function renderVODs(vods: VOD[] | null | undefined, streamer: string): void {
|
|
lastLoadedVods = Array.isArray(vods) ? vods : [];
|
|
lastLoadedStreamer = streamer;
|
|
renderVodGridFromCurrentState();
|
|
}
|
|
|
|
function renderVodGridFromCurrentState(): void {
|
|
if (!lastLoadedStreamer) return;
|
|
|
|
const grid = byId('vodGrid');
|
|
const renderTaskId = ++vodRenderTaskId;
|
|
const total = lastLoadedVods.length;
|
|
|
|
if (total === 0) {
|
|
setVodGridEmptyState(grid, UI_TEXT.vods.noResultsTitle, UI_TEXT.vods.noResultsText);
|
|
updateVodFilterCount(0, 0);
|
|
return;
|
|
}
|
|
|
|
const sorted = sortVods(lastLoadedVods, vodSortKey);
|
|
const filtered = filterVodsByQuery(sorted, vodFilterQuery);
|
|
|
|
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
|
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
|
updateVodFilterCount(0, total);
|
|
return;
|
|
}
|
|
|
|
grid.replaceChildren();
|
|
updateVodFilterCount(filtered.length, total);
|
|
|
|
const scheduleNextChunk = (nextStartIndex: number): void => {
|
|
const delayMs = document.hidden ? 16 : 0;
|
|
window.setTimeout(() => {
|
|
renderChunk(nextStartIndex);
|
|
}, delayMs);
|
|
};
|
|
|
|
const renderChunk = (startIndex: number): void => {
|
|
if (renderTaskId !== vodRenderTaskId) {
|
|
return;
|
|
}
|
|
|
|
const chunk = filtered.slice(startIndex, startIndex + VOD_RENDER_CHUNK_SIZE);
|
|
if (!chunk.length) {
|
|
return;
|
|
}
|
|
|
|
grid.insertAdjacentHTML('beforeend', chunk.map((vod) => buildVodCardHtml(vod, lastLoadedStreamer || '')).join(''));
|
|
|
|
if (startIndex + chunk.length < filtered.length) {
|
|
scheduleNextChunk(startIndex + chunk.length);
|
|
}
|
|
};
|
|
|
|
renderChunk(0);
|
|
}
|
|
|
|
async function refreshVODs(): Promise<void> {
|
|
if (!currentStreamer) {
|
|
return;
|
|
}
|
|
|
|
await selectStreamer(currentStreamer, true);
|
|
}
|