Compare commits
3 Commits
81a1f914b4
...
173ae61a3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
173ae61a3f | ||
|
|
832b606701 | ||
|
|
020f3dacf1 |
@ -2,6 +2,37 @@
|
|||||||
|
|
||||||
Dated entries from improvement cycles. Newest at top.
|
Dated entries from improvement cycles. Newest at top.
|
||||||
|
|
||||||
|
## 2026-05-03 — Cycle 4: GQL retry + VOD sort + shutdown consolidation
|
||||||
|
|
||||||
|
Three independent improvements landed this cycle.
|
||||||
|
|
||||||
|
### 1. Public Twitch GQL fallback retries on transient failures (defensive error handling)
|
||||||
|
|
||||||
|
- **File**: `src/main.ts` — new `isTransientAxiosError` + retry loop in `fetchPublicTwitchGql`.
|
||||||
|
- **Problem**: `fetchPublicTwitchGql` swallowed every network error with `catch (e) { console.error(...); return null; }`. The public-API fallback path is what users without a Twitch client_id/secret hit on every VOD list load — a single TCP RST or a transient `503` from `gql.twitch.tv` produced an empty list and the user had to click refresh.
|
||||||
|
- **Fix**: Up to 3 attempts with exponential backoff (`400ms × 2^(attempt-1)` + jitter, capped by attempt count). Retries cover transient HTTP (`408`, `429`, `5xx`) and pure network failures (no response). GraphQL errors in `errors[]` are still returned without retry — those are application-level rejections of the query itself. Recovery is logged via `appendDebugLog('public-gql-recovered', ...)` so we can later see in logs whether the retries actually pay off.
|
||||||
|
|
||||||
|
### 2. VOD list sort dropdown with persistence (client feature: VM/state + UI + persistence)
|
||||||
|
|
||||||
|
- **Files**: `src/renderer-streamers.ts`, `src/renderer.ts`, `src/renderer-texts.ts`, `src/index.html`, `src/renderer-locale-de.ts`, `src/renderer-locale-en.ts`.
|
||||||
|
- **Problem**: VODs always rendered in the order Twitch returned them (`sort:TIME` desc). With long archives users had no way to find the longest stream, the most-watched, or the oldest.
|
||||||
|
- **Fix**: `vodSortSelect` dropdown next to the filter input. Five sort modes: newest first, oldest first, most viewed, longest first, shortest first. State (`vodSortKey`) persisted to `localStorage` under `twitch-vod-manager:vod-sort` and validated against an enum on load — an unknown stored value falls back to `date_desc` so a future rename can't strand the user. `renderVodGridFromCurrentState` now applies `sortVods` before `filterVodsByQuery` so the filter sees the sort order and the match-counter is consistent. Sort labels and the "Sort:" prefix label are localized (DE + EN), and `refreshVodSortSelectLabels` re-runs on language switch so the option labels stay in the active language. Browser-default keyboard nav on the select (arrow keys, type-ahead) covers keyboard access.
|
||||||
|
|
||||||
|
### 3. `shutdownCleanup()` consolidates `window-all-closed` + `before-quit` (cleanup of meaningful size)
|
||||||
|
|
||||||
|
- **File**: `src/main.ts`.
|
||||||
|
- **Problem**: Both lifecycle handlers ran nearly identical cleanup blocks but had drifted: `window-all-closed` killed children and was platform-aware (`app.quit()` on non-darwin), `before-quit` only stopped timers and saved state. There was no single place to add a new "must run on exit" step — every future addition had to be pasted into both handlers and inevitably one would diverge.
|
||||||
|
- **Fix**: Single `shutdownCleanup(reason)` helper, gated by an idempotent `shutdownCleanupDone` flag so a `before-quit` immediately following a `window-all-closed` is a no-op. The helper kills `activeDownloads`, `activeClipProcesses`, and `currentEditorProcess` (with try/catch so an already-exited proc doesn't throw), persists config + queue, then stops timers. Debug-log flush is reordered to run AFTER `saveConfig` / `flushQueueSave` so any error in those persistence calls actually reaches the log file before the flush timer is gone. Both `app.on(...)` handlers shrank to one line each.
|
||||||
|
|
||||||
|
### Regression
|
||||||
|
|
||||||
|
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||||
|
- `npm run test:e2e:update-logic` — passed.
|
||||||
|
- `npm run test:merge-split` — passed.
|
||||||
|
- `npm run test:e2e` — passed (`issues: []`).
|
||||||
|
- `npm run test:e2e:guide` — passed (`failures: []`).
|
||||||
|
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`).
|
||||||
|
|
||||||
## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix
|
## 2026-05-03 — Cycle 3: clip hardening + VOD filter + cancel-cross-talk fix
|
||||||
|
|
||||||
Three independent improvements landed this cycle.
|
Three independent improvements landed this cycle.
|
||||||
|
|||||||
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.10",
|
"version": "4.5.11",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "twitch-vod-manager",
|
"name": "twitch-vod-manager",
|
||||||
"version": "4.5.10",
|
"version": "4.5.11",
|
||||||
"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.10",
|
"version": "4.5.11",
|
||||||
"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",
|
||||||
|
|||||||
@ -239,9 +239,17 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- VODs Tab -->
|
<!-- VODs Tab -->
|
||||||
<div class="tab-content active" id="vodsTab">
|
<div class="tab-content active" id="vodsTab">
|
||||||
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px;">
|
<div class="vod-filter-row" style="display:flex; align-items:center; gap:8px; margin-bottom:12px; flex-wrap:wrap;">
|
||||||
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
|
<input type="text" id="vodFilterInput" placeholder="Filter VODs..." oninput="onVodFilterInput()" style="flex:1; min-width:180px; background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-primary,#fff); font-size:13px;">
|
||||||
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
|
<button id="vodFilterClearBtn" onclick="clearVodFilter()" title="Clear filter" style="display:none; background:transparent; border:1px solid var(--border-color,#444); border-radius:6px; padding:8px 12px; color: var(--text-secondary,#888); cursor:pointer;">x</button>
|
||||||
|
<label id="vodSortLabel" for="vodSortSelect" style="color: var(--text-secondary,#888); font-size:12px; margin-left:8px;">Sort:</label>
|
||||||
|
<select id="vodSortSelect" onchange="onVodSortChange()" style="background: var(--bg-secondary,#222); border:1px solid var(--border-color,#444); border-radius:6px; padding:7px 10px; color: var(--text-primary,#fff); font-size:13px;">
|
||||||
|
<option value="date_desc">Newest first</option>
|
||||||
|
<option value="date_asc">Oldest first</option>
|
||||||
|
<option value="views_desc">Most viewed</option>
|
||||||
|
<option value="duration_desc">Longest first</option>
|
||||||
|
<option value="duration_asc">Shortest first</option>
|
||||||
|
</select>
|
||||||
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
<span id="vodFilterCount" style="color: var(--text-secondary,#888); font-size:12px; min-width:80px;"></span>
|
||||||
</div>
|
</div>
|
||||||
<div class="vod-grid" id="vodGrid">
|
<div class="vod-grid" id="vodGrid">
|
||||||
|
|||||||
139
src/main.ts
139
src/main.ts
@ -1598,30 +1598,78 @@ function formatTwitchDurationFromSeconds(totalSeconds: number): string {
|
|||||||
return `${s}s`;
|
return `${s}s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
|
// Transient HTTP errors that warrant a retry (5xx, 408 timeout, 429 rate limit).
|
||||||
try {
|
// 4xx (other than 408/429) are application errors and not retried.
|
||||||
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
|
function isTransientAxiosError(err: unknown): boolean {
|
||||||
'https://gql.twitch.tv/gql',
|
if (!axios.isAxiosError(err)) {
|
||||||
{ query, variables },
|
// Non-axios errors thrown from axios.post are typically network-layer
|
||||||
{
|
// failures (DNS, ECONNRESET, socket hangup) — retry those too.
|
||||||
headers: {
|
return true;
|
||||||
'Client-ID': TWITCH_WEB_CLIENT_ID,
|
|
||||||
'Content-Type': 'application/json'
|
|
||||||
},
|
|
||||||
timeout: API_TIMEOUT
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
if (response.data.errors?.length) {
|
|
||||||
console.error('Public Twitch GQL errors:', response.data.errors.map((err) => err.message).join('; '));
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.data.data || null;
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Public Twitch GQL request failed:', e);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === undefined) {
|
||||||
|
// No response means the request never reached / never returned —
|
||||||
|
// treat as transient (network blip, timeout).
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return status === 408 || status === 429 || (status >= 500 && status < 600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const TWITCH_GQL_RETRY_ATTEMPTS = 3;
|
||||||
|
const TWITCH_GQL_RETRY_BASE_DELAY_MS = 400;
|
||||||
|
|
||||||
|
async function fetchPublicTwitchGql<T>(query: string, variables: Record<string, unknown>): Promise<T | null> {
|
||||||
|
let lastError: unknown = null;
|
||||||
|
|
||||||
|
for (let attempt = 1; attempt <= TWITCH_GQL_RETRY_ATTEMPTS; attempt++) {
|
||||||
|
try {
|
||||||
|
const response = await axios.post<{ data?: T; errors?: Array<{ message: string }> }>(
|
||||||
|
'https://gql.twitch.tv/gql',
|
||||||
|
{ query, variables },
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
'Client-ID': TWITCH_WEB_CLIENT_ID,
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
timeout: API_TIMEOUT
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// GraphQL errors (in `errors[]`) are application-level and not
|
||||||
|
// retried — the query itself is rejected.
|
||||||
|
if (response.data.errors?.length) {
|
||||||
|
const messages = response.data.errors.map((err) => err.message).join('; ');
|
||||||
|
appendDebugLog('public-gql-errors', { messages, attempt });
|
||||||
|
console.error('Public Twitch GQL errors:', messages);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt > 1) {
|
||||||
|
appendDebugLog('public-gql-recovered', { attempt });
|
||||||
|
}
|
||||||
|
return response.data.data || null;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
const transient = isTransientAxiosError(e);
|
||||||
|
const willRetry = transient && attempt < TWITCH_GQL_RETRY_ATTEMPTS;
|
||||||
|
appendDebugLog('public-gql-failed', {
|
||||||
|
attempt,
|
||||||
|
maxAttempts: TWITCH_GQL_RETRY_ATTEMPTS,
|
||||||
|
transient,
|
||||||
|
willRetry,
|
||||||
|
error: String(e)
|
||||||
|
});
|
||||||
|
if (!willRetry) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Exponential backoff with jitter
|
||||||
|
const delay = TWITCH_GQL_RETRY_BASE_DELAY_MS * Math.pow(2, attempt - 1) + Math.floor(Math.random() * 250);
|
||||||
|
await sleep(delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error('Public Twitch GQL request failed:', lastError);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPublicUserId(username: string): Promise<string | null> {
|
async function getPublicUserId(username: string): Promise<string | null> {
|
||||||
@ -4050,38 +4098,59 @@ app.whenReady().then(() => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('window-all-closed', () => {
|
// Both window-all-closed and before-quit ran nearly identical cleanup blocks
|
||||||
|
// before, with slight drift (only window-all-closed killed children, only
|
||||||
|
// window-all-closed did anything platform-specific). Consolidating them into
|
||||||
|
// a single idempotent helper means any future tweak (e.g. flushing a new
|
||||||
|
// debug stream) lands once and applies on every quit path.
|
||||||
|
let shutdownCleanupDone = false;
|
||||||
|
|
||||||
|
function shutdownCleanup(reason: 'window-all-closed' | 'before-quit'): void {
|
||||||
|
if (shutdownCleanupDone) return;
|
||||||
|
shutdownCleanupDone = true;
|
||||||
|
|
||||||
|
appendDebugLog('shutdown-cleanup', { reason });
|
||||||
|
|
||||||
stopMetadataCacheCleanup();
|
stopMetadataCacheCleanup();
|
||||||
cleanupMetadataCaches('shutdown');
|
cleanupMetadataCaches('shutdown');
|
||||||
stopDebugLogFlushTimer(true);
|
|
||||||
stopAutoUpdatePolling();
|
stopAutoUpdatePolling();
|
||||||
|
|
||||||
// Kill all active children: queue downloads, standalone clip downloads,
|
// Kill all active children: queue downloads, standalone clip downloads,
|
||||||
// and any in-flight cutter/merger/splitter ffmpeg.
|
// and any in-flight cutter/merger/splitter ffmpeg. before-quit used to
|
||||||
|
// skip this entirely; window-all-closed did it but only via direct
|
||||||
|
// kill() (no try/catch around the queue process kill).
|
||||||
for (const [, tracking] of activeDownloads) {
|
for (const [, tracking] of activeDownloads) {
|
||||||
if (tracking.process) {
|
if (tracking.process) {
|
||||||
tracking.process.kill();
|
try { tracking.process.kill(); } catch { /* already exited */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
activeDownloads.clear();
|
||||||
|
|
||||||
for (const [, proc] of activeClipProcesses) {
|
for (const [, proc] of activeClipProcesses) {
|
||||||
try { proc.kill(); } catch { }
|
try { proc.kill(); } catch { /* already exited */ }
|
||||||
}
|
}
|
||||||
|
activeClipProcesses.clear();
|
||||||
|
|
||||||
if (currentEditorProcess) {
|
if (currentEditorProcess) {
|
||||||
currentEditorProcess.kill();
|
try { currentEditorProcess.kill(); } catch { /* already exited */ }
|
||||||
|
currentEditorProcess = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveConfig(config);
|
saveConfig(config);
|
||||||
flushQueueSave();
|
flushQueueSave();
|
||||||
|
|
||||||
|
// Flush debug log AFTER persisting state so any errors saving config /
|
||||||
|
// queue land in the log before the timer is gone.
|
||||||
|
stopDebugLogFlushTimer(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
shutdownCleanup('window-all-closed');
|
||||||
if (process.platform !== 'darwin') {
|
if (process.platform !== 'darwin') {
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on('before-quit', () => {
|
||||||
stopMetadataCacheCleanup();
|
shutdownCleanup('before-quit');
|
||||||
cleanupMetadataCaches('shutdown');
|
|
||||||
stopDebugLogFlushTimer(true);
|
|
||||||
stopAutoUpdatePolling();
|
|
||||||
saveConfig(config);
|
|
||||||
flushQueueSave();
|
|
||||||
});
|
});
|
||||||
|
|||||||
@ -167,7 +167,13 @@ const UI_TEXT_DE = {
|
|||||||
filterClearTitle: 'Filter loeschen (Esc)',
|
filterClearTitle: 'Filter loeschen (Esc)',
|
||||||
filterNoMatchTitle: 'Keine Treffer',
|
filterNoMatchTitle: 'Keine Treffer',
|
||||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||||
filterMatchCount: '{shown} von {total} VODs'
|
filterMatchCount: '{shown} von {total} VODs',
|
||||||
|
sortLabel: 'Sortierung:',
|
||||||
|
sortDateDesc: 'Neueste zuerst',
|
||||||
|
sortDateAsc: 'Aelteste zuerst',
|
||||||
|
sortViewsDesc: 'Meiste Aufrufe',
|
||||||
|
sortDurationDesc: 'Laengste zuerst',
|
||||||
|
sortDurationAsc: 'Kuerzeste zuerst'
|
||||||
},
|
},
|
||||||
clips: {
|
clips: {
|
||||||
dialogTitle: 'Clip zuschneiden',
|
dialogTitle: 'Clip zuschneiden',
|
||||||
|
|||||||
@ -167,7 +167,13 @@ const UI_TEXT_EN = {
|
|||||||
filterClearTitle: 'Clear filter (Esc)',
|
filterClearTitle: 'Clear filter (Esc)',
|
||||||
filterNoMatchTitle: 'No matches',
|
filterNoMatchTitle: 'No matches',
|
||||||
filterNoMatchText: 'No VODs match the current filter.',
|
filterNoMatchText: 'No VODs match the current filter.',
|
||||||
filterMatchCount: '{shown} of {total} VODs'
|
filterMatchCount: '{shown} of {total} VODs',
|
||||||
|
sortLabel: 'Sort:',
|
||||||
|
sortDateDesc: 'Newest first',
|
||||||
|
sortDateAsc: 'Oldest first',
|
||||||
|
sortViewsDesc: 'Most viewed',
|
||||||
|
sortDurationDesc: 'Longest first',
|
||||||
|
sortDurationAsc: 'Shortest first'
|
||||||
},
|
},
|
||||||
clips: {
|
clips: {
|
||||||
dialogTitle: 'Trim clip',
|
dialogTitle: 'Trim clip',
|
||||||
|
|||||||
@ -10,6 +10,95 @@ let lastLoadedStreamer: string | null = null;
|
|||||||
let vodFilterQuery = '';
|
let vodFilterQuery = '';
|
||||||
const VOD_FILTER_STORAGE_KEY = 'twitch-vod-manager:vod-filter';
|
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 {
|
function loadPersistedVodFilter(): string {
|
||||||
try {
|
try {
|
||||||
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
|
return localStorage.getItem(VOD_FILTER_STORAGE_KEY) ?? '';
|
||||||
@ -222,7 +311,8 @@ function renderVodGridFromCurrentState(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filtered = filterVodsByQuery(lastLoadedVods, vodFilterQuery);
|
const sorted = sortVods(lastLoadedVods, vodSortKey);
|
||||||
|
const filtered = filterVodsByQuery(sorted, vodFilterQuery);
|
||||||
|
|
||||||
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
if (filtered.length === 0 && vodFilterQuery.trim()) {
|
||||||
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
setVodGridEmptyState(grid, UI_TEXT.vods.filterNoMatchTitle, UI_TEXT.vods.filterNoMatchText);
|
||||||
|
|||||||
@ -145,6 +145,10 @@ function applyLanguageToStaticUI(): void {
|
|||||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||||
|
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
|
||||||
|
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||||
|
refreshVodSortSelectLabels();
|
||||||
|
}
|
||||||
|
|
||||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||||
|
|||||||
@ -52,6 +52,13 @@ async function init(): Promise<void> {
|
|||||||
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
||||||
syncVodFilterClearButton();
|
syncVodFilterClearButton();
|
||||||
|
|
||||||
|
// Restore persisted VOD sort key. Apply localized labels to <option>s
|
||||||
|
// before syncing the select value so the right option is preselected
|
||||||
|
// even on first load before any language change fires.
|
||||||
|
vodSortKey = loadPersistedVodSort();
|
||||||
|
refreshVodSortSelectLabels();
|
||||||
|
syncVodSortSelect();
|
||||||
|
|
||||||
// Restore last active tab from previous session (default 'vods')
|
// Restore last active tab from previous session (default 'vods')
|
||||||
showTab(loadPersistedActiveTab());
|
showTab(loadPersistedActiveTab());
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user