Compare commits
367 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e81a47e9e | ||
|
|
70643b4c08 | ||
|
|
86d68466f9 | ||
|
|
ae156ff395 | ||
|
|
2d109077a0 | ||
|
|
25be77b4ab | ||
|
|
29315091c6 | ||
|
|
84f576d131 | ||
|
|
fce353d529 | ||
|
|
7b0e511479 | ||
|
|
6c56c4e908 | ||
|
|
4472e3bf50 | ||
|
|
ce3b876006 | ||
|
|
801e02601f | ||
|
|
65c9d06dfa | ||
|
|
e8404b8802 | ||
|
|
3d40160b5c | ||
|
|
85d2bf5316 | ||
|
|
8f0f7d5d84 | ||
|
|
564d123431 | ||
|
|
6d28aa1972 | ||
|
|
0419317122 | ||
|
|
b73593fc9a | ||
|
|
0aea6af88c | ||
|
|
1b70743a0e | ||
|
|
8ea1699bfa | ||
|
|
c1943b421b | ||
|
|
49b5e838a8 | ||
|
|
e4db7abc87 | ||
|
|
9de2df527a | ||
|
|
2851d5b8d6 | ||
|
|
b880ce9694 | ||
|
|
7ef6459c8a | ||
|
|
00e366ce50 | ||
|
|
1faa6e35cf | ||
|
|
dd5efcbfe6 | ||
|
|
561a1568f0 | ||
|
|
b33b274751 | ||
|
|
ad8f32f8b8 | ||
|
|
3788561bb7 | ||
|
|
539b1c13a0 | ||
|
|
78c6df0d6b | ||
|
|
01913c193d | ||
|
|
7994a02bb1 | ||
|
|
bbb65f0cfd | ||
|
|
5473a852ee | ||
|
|
5e383a6e12 | ||
|
|
479e861789 | ||
|
|
19555ce872 | ||
|
|
72029e0c94 | ||
|
|
45dfd4f6fd | ||
|
|
ba872e2ecf | ||
|
|
e951c6a852 | ||
|
|
0cf67e8849 | ||
|
|
2d1d48599a | ||
|
|
2b09b7868a | ||
|
|
a62080cb44 | ||
|
|
9bcafa6da6 | ||
|
|
9fd14371a2 | ||
|
|
ea28018aef | ||
|
|
af11cdda10 | ||
|
|
f606eea59c | ||
|
|
137bab63a0 | ||
|
|
9a36814b0b | ||
|
|
7cb2358a54 | ||
|
|
3fa49a5283 | ||
|
|
a809676731 | ||
|
|
26d737b3fc | ||
|
|
84abfb7cf7 | ||
|
|
8d95a4a6a5 | ||
|
|
7de560f44c | ||
|
|
a1ea920003 | ||
|
|
0132c96a7f | ||
|
|
9a4fbb8af4 | ||
|
|
ca74a865f8 | ||
|
|
7a6654097f | ||
|
|
97ea32a08b | ||
|
|
773addb279 | ||
|
|
3603caed21 | ||
|
|
f6f266e3d4 | ||
|
|
85cc4957d8 | ||
|
|
bcc7eea968 | ||
|
|
82df586c9e | ||
|
|
5ea763d79b | ||
|
|
b251d795dc | ||
|
|
85128086b4 | ||
|
|
71fedcb34c | ||
|
|
1da5589b1a | ||
|
|
c7b2ef0d24 | ||
|
|
632f348349 | ||
|
|
aed770a56c | ||
|
|
7b6ced7818 | ||
|
|
fc631c2403 | ||
|
|
43924fd51f | ||
|
|
1fb33aa6cc | ||
|
|
42c7831c96 | ||
|
|
63b04b6469 | ||
|
|
03b575cd1d | ||
|
|
3748e25d1d | ||
|
|
7dd6755392 | ||
|
|
02b61c7ea4 | ||
|
|
5a4b054d9d | ||
|
|
e73db55e29 | ||
|
|
9be864e614 | ||
|
|
03f3756523 | ||
|
|
91e4e65fa6 | ||
|
|
9046344375 | ||
|
|
752a5b2556 | ||
|
|
cf76e37c22 | ||
|
|
a7fbdd2fbf | ||
|
|
d0de52de95 | ||
|
|
3e5bdb73d4 | ||
|
|
f3e7225011 | ||
|
|
8f6d7b2d9a | ||
|
|
8b917bee77 | ||
|
|
f6bb5970c9 | ||
|
|
af38df2139 | ||
|
|
38aadb6fb9 | ||
|
|
e728212981 | ||
|
|
afbd09f507 | ||
|
|
94a542b09a | ||
|
|
3362138d1a | ||
|
|
562e92494b | ||
|
|
2c0c7f6d00 | ||
|
|
d2a0f35265 | ||
|
|
5931892320 | ||
|
|
c6394fd411 | ||
|
|
6946c34395 | ||
|
|
5c0378582e | ||
|
|
3ec88a7800 | ||
|
|
2df8d72a61 | ||
|
|
b3d77040de | ||
|
|
61c71ebc7f | ||
|
|
ed20c44749 | ||
|
|
c845be64cf | ||
|
|
a7f16d8cf8 | ||
|
|
d19e7ebc34 | ||
|
|
e61940c108 | ||
|
|
77aa04c894 | ||
|
|
35dc3201d8 | ||
|
|
67da6d4c58 | ||
|
|
ccfff174ae | ||
|
|
3e591fac3d | ||
|
|
534f22b632 | ||
|
|
af0a27c01b | ||
|
|
0e313e8857 | ||
|
|
3e37f9e87e | ||
|
|
f29cfd6ed4 | ||
|
|
a8ec8658b3 | ||
|
|
66486dba0c | ||
|
|
a516c78846 | ||
|
|
bbdcf8f71c | ||
|
|
4a18a13deb | ||
|
|
5641924c7e | ||
|
|
8dc374d50e | ||
|
|
e705beabf3 | ||
|
|
7c99193e25 | ||
|
|
bdf6bac602 | ||
|
|
4489319d70 | ||
|
|
69b83c9d22 | ||
|
|
e56bac2c2b | ||
|
|
d9593091a5 | ||
|
|
00e19ccf67 | ||
|
|
dba6e872a9 | ||
|
|
62400e4aa0 | ||
|
|
7e7be1d103 | ||
|
|
a46984d8ab | ||
|
|
885cbaa894 | ||
|
|
2cdbbe31ef | ||
|
|
c9a5223eb6 | ||
|
|
162b2845aa | ||
|
|
abc983c035 | ||
|
|
7ffd52a901 | ||
|
|
874f64c1ba | ||
|
|
3905b73751 | ||
|
|
a2b7a02db7 | ||
|
|
58f8164db4 | ||
|
|
5d5e58ae09 | ||
|
|
7be9453762 | ||
|
|
c393457492 | ||
|
|
01acbcc47f | ||
|
|
fa440951d2 | ||
|
|
6213134a27 | ||
|
|
58e52399c5 | ||
|
|
c6ae0cadbd | ||
|
|
274d3874f5 | ||
|
|
1c62cf4a92 | ||
|
|
32e0b1ab7d | ||
|
|
73eaccb483 | ||
|
|
c6f423b5ac | ||
|
|
7e60d0e920 | ||
|
|
976ca40963 | ||
|
|
96683afa14 | ||
|
|
2b4b8ae636 | ||
|
|
8ef2ce50e7 | ||
|
|
5d5ffa675b | ||
|
|
1b8624d88a | ||
|
|
77e4c84c45 | ||
|
|
4518f8867a | ||
|
|
3e37d780c3 | ||
|
|
e95be22a02 | ||
|
|
96113dc267 | ||
|
|
5e369fef35 | ||
|
|
76be8d3949 | ||
|
|
0b99014de3 | ||
|
|
26b03da765 | ||
|
|
78eeb8f3dc | ||
|
|
5fda4e2103 | ||
|
|
a82a8f97f7 | ||
|
|
1d4b6718b9 | ||
|
|
6086cd51c1 | ||
|
|
35769959f4 | ||
|
|
5f7ce36845 | ||
|
|
fedf3a9945 | ||
|
|
edf3836b26 | ||
|
|
ce469b856c | ||
|
|
144088c01f | ||
|
|
c4201fc6d7 | ||
|
|
9d4f5fd9a3 | ||
|
|
1123b9ac46 | ||
|
|
f473f9e343 | ||
|
|
38a50b7a32 | ||
|
|
10513f7399 | ||
|
|
ac42ec3686 | ||
|
|
d99fff5923 | ||
|
|
63f1cafe1a | ||
|
|
7909beb516 | ||
|
|
5d61094226 | ||
|
|
e68db24e10 | ||
|
|
f1b4e6c39a | ||
|
|
a7e189fef9 | ||
|
|
dd08f33dc6 | ||
|
|
336fc77c85 | ||
|
|
e09efd4a33 | ||
|
|
ce01034586 | ||
|
|
6fdfa08ecb | ||
|
|
a7c251f016 | ||
|
|
5f514b1700 | ||
|
|
db32f01ddb | ||
|
|
9afff4b8b0 | ||
|
|
4809da8957 | ||
|
|
5da4cc9e64 | ||
|
|
a373410b89 | ||
|
|
ee8f9425fc | ||
|
|
0ae0f8bb7d | ||
|
|
b37244cccf | ||
|
|
4956a68d9b | ||
|
|
0df8bf357d | ||
|
|
32decb4c01 | ||
|
|
afef213b45 | ||
|
|
5200126565 | ||
|
|
f93b07c87a | ||
|
|
2f91823161 | ||
|
|
9115819bb0 | ||
|
|
fdeb1697de | ||
|
|
b9f2b68596 | ||
|
|
c7d0bb7e30 | ||
|
|
227c4bdf82 | ||
|
|
693acfe49c | ||
|
|
12fd2b7217 | ||
|
|
f6333bf6f5 | ||
|
|
f7a54a2007 | ||
|
|
8edbef0a60 | ||
|
|
17b715ab24 | ||
|
|
f6905fae82 | ||
|
|
cc23f1e272 | ||
|
|
8928d1f8ed | ||
|
|
11883889de | ||
|
|
fa8c2b2658 | ||
|
|
30776c02b9 | ||
|
|
bd54ba9cfb | ||
|
|
3c73efbad7 | ||
|
|
1b87a2611e | ||
|
|
ec48592503 | ||
|
|
f564567897 | ||
|
|
b1fd73cbe6 | ||
|
|
ef6b82bb8b | ||
|
|
9239eebf34 | ||
|
|
a43fc6689c | ||
|
|
073c1863fe | ||
|
|
7d4ee9eb40 | ||
|
|
8d4b0704db | ||
|
|
cf141eb9df | ||
|
|
4adeffe7dc | ||
|
|
b21634b5f7 | ||
|
|
7d82f70ca3 | ||
|
|
805231ae2f | ||
|
|
28692b2e54 | ||
|
|
398206e01c | ||
|
|
2c40bbf66e | ||
|
|
ddaf4807f4 | ||
|
|
1ab6f01e07 | ||
|
|
2f1e5f4a9e | ||
|
|
fab263ae4c | ||
|
|
5098510d53 | ||
|
|
3129c9b5be | ||
|
|
dc0b92d5a4 | ||
|
|
55434f499d | ||
|
|
cd5c4daccf | ||
|
|
8634834d16 | ||
|
|
f7cf1b8cd9 | ||
|
|
b7c7b9eb7c | ||
|
|
97d8cc10ef | ||
|
|
47862e7fbf | ||
|
|
0ab3780ab1 | ||
|
|
ddee248f6b | ||
|
|
81c775a92e | ||
|
|
45456650d4 | ||
|
|
363629583a | ||
|
|
029b2bd407 | ||
|
|
1c5462b7fe | ||
|
|
56261216a9 | ||
|
|
49200f4ca6 | ||
|
|
e098708398 | ||
|
|
092932d8d5 | ||
|
|
1f2b5e583c | ||
|
|
80aa66e46d | ||
|
|
fdb096fa96 | ||
|
|
2e859c88f3 | ||
|
|
b959a930af | ||
|
|
e5decfd851 | ||
|
|
6379723248 | ||
|
|
504007600b | ||
|
|
e2c0e3a2bf | ||
|
|
56d4e0904f | ||
|
|
3f04b42b02 | ||
|
|
cb8e92732e | ||
|
|
3e1d4e188c | ||
|
|
766cdfe371 | ||
|
|
16d2456770 | ||
|
|
9dcdb8086e | ||
|
|
44c9173f10 | ||
|
|
7308a52a3e | ||
|
|
386998deaf | ||
|
|
6c3dc3d1b6 | ||
|
|
933af6a6da | ||
|
|
f04c0b64cc | ||
|
|
d6e513d70d | ||
|
|
83647c264b | ||
|
|
13d208c30f | ||
|
|
075eb7b3b5 | ||
|
|
138c81eb8c | ||
|
|
3c0af2765e | ||
|
|
ddb3845263 | ||
|
|
37b793b9e8 | ||
|
|
013e8be1f0 | ||
|
|
173ae61a3f | ||
|
|
832b606701 | ||
|
|
020f3dacf1 | ||
|
|
81a1f914b4 | ||
|
|
23d0dd5829 | ||
|
|
31e6671e65 | ||
|
|
9d57c03e74 | ||
|
|
379048f191 | ||
|
|
b4faf67db7 | ||
|
|
707c98e19d | ||
|
|
feebfc86a1 | ||
|
|
8d0cb4cefd | ||
|
|
54197af863 | ||
|
|
37d75fac24 | ||
|
|
f9a0fdcf3d | ||
|
|
da1d14d458 | ||
|
|
18940d0640 | ||
|
|
d9bdf744fd | ||
|
|
d8f0836165 | ||
|
|
a07ec1f958 | ||
|
|
5f2e85e455 |
138
docs/IMPROVEMENT_LOG.md
Normal file
138
docs/IMPROVEMENT_LOG.md
Normal file
@ -0,0 +1,138 @@
|
||||
# Improvement Log
|
||||
|
||||
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
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. `download-clip` IPC: integrity, cancellation, sanitization (server defensive)
|
||||
|
||||
- **File**: `src/main.ts` — `download-clip` IPC handler, new `activeClipProcesses` map.
|
||||
- **Problem**: The handler reported `success: true` on streamlink exit code 0 even when the resulting file was empty / a few hundred bytes (Twitch occasionally returns a manifest with no segments). The path passed `clipInfo.broadcaster_name` straight to `path.join` — Twitch returns the broadcaster's *display* name, which can carry unicode, spaces, or punctuation that produced surprising directory layouts on Windows. The spawned streamlink process was tracked nowhere, so `window-all-closed` left it orphaned.
|
||||
- **Fix**: `safeBroadcaster` runs through `sanitizeFilenamePart`. `safeTitle` falls back to `clip` when the title sanitises to empty. The output filename now goes through `ensureUniqueFilename(path, clipId)` so retrying a clip with the same title doesn't overwrite the previous download. After streamlink exits, the file is rejected if smaller than 16 KiB or if `validateDownloadedFileIntegrity` fails (no video stream / unreadable). The proc is tracked in a new `activeClipProcesses` map and killed by `window-all-closed`.
|
||||
|
||||
### 2. VOD list filter / search (client feature: VM/state + UI + persistence + keyboard)
|
||||
|
||||
- **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**: A streamer can have hundreds of VODs (the test fixture alone has 37 cards). There was no way to find a specific VOD by title — only scroll. With a long archive this is genuinely painful.
|
||||
- **Fix**: Filter row above the VOD grid (`vodFilterInput`, clear button, match counter). State (`vodFilterQuery`) is persisted to `localStorage` via `loadPersistedVodFilter` / `persistVodFilter`, so the search bar survives an app restart. The render path was split: `renderVODs` now stores `lastLoadedVods` + `lastLoadedStreamer` and delegates to `renderVodGridFromCurrentState`, which applies `filterVodsByQuery` on every input event without re-fetching. Empty-state DOM is built via `setVodGridEmptyState` using `createElement` + `textContent` (no `innerHTML` for locale strings — defense-in-depth even though the strings are trusted). Keyboard: `Ctrl+F` / `Cmd+F` focuses the filter (only when the VODs tab is active and Electron's no-op default is suppressed); `Esc` clears the filter when the input has focus and content; `Esc` still closes modals first if any are open.
|
||||
|
||||
### 3. Decouple `currentProcess` from queue downloads (server cleanup + race fix)
|
||||
|
||||
- **File**: `src/main.ts` — global rename and assignment removal.
|
||||
- **Problem**: A single `currentProcess: ChildProcess | null` was shared by `cutVideo`, `mergeVideos`, `splitMergedFile`, AND `downloadVODPart`. With parallel downloads the global was constantly overwritten between siblings, but the cross-talk that mattered was different: if a queue download was running and the user kicked off a video cut, the cutter ffmpeg ran into the same global. Pressing the queue's *cancel-download* button then iterated `activeDownloads` (correct) AND called `currentProcess.kill()` (incorrect — that was the cutter ffmpeg by then), killing the unrelated cut.
|
||||
- **Fix**: `currentProcess` renamed to `currentEditorProcess` and confined to the editor pipeline (cutter / merger / splitter). `downloadVODPart` no longer assigns to it — `activeDownloads` is the sole source of truth for queue children. The fallback `if (currentProcess) currentProcess.kill()` was removed from `remove-from-queue`, `pause-download`, and `cancel-download`. `window-all-closed` still kills it (so a cutter ffmpeg gets cleaned up on app exit) and now also kills `activeClipProcesses` introduced by Pick 1.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:e2e` — passed (`issues: []`).
|
||||
- `npm run test:e2e:guide` — passed (`failures: []`).
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
## 2026-05-03 — Cycle 2: release pipeline + defensive parsing
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. `scripts/release_gitea.mjs` skips rebuild when artifacts exist (release pipeline)
|
||||
|
||||
- **File**: `scripts/release_gitea.mjs`.
|
||||
- **Problem**: The script unconditionally ran `npm run dist:win` (full test suite + electron-builder) even when the version's artifacts were already on disk under `release/`. When `npm run test:e2e` was broken (cycle 1 follow-up), the release path was unusable — the previous cycle had to bypass the script with direct API uploads via PowerShell. Every future agent would hit the same wall.
|
||||
- **Fix**: New `--skip-build` flag. The script now also auto-detects whether all 3 required artifacts (`Setup-<v>.exe`, `Setup-<v>.exe.blockmap`, `latest.yml`) exist for the requested version and skips `dist:win` accordingly. The auto-skip is the safe default — explicit `--skip-build` documents intent. Help text updated to describe the new flag and the auto-skip behaviour.
|
||||
|
||||
### 2. `playwright` in `devDependencies` + simplified test scripts (release pipeline)
|
||||
|
||||
- **Files**: `package.json` (+ `package-lock.json`).
|
||||
- **Problem**: `npm exec --yes --package=playwright -- node scripts/smoke-test*.js` failed with `MODULE_NOT_FOUND` in environments where `npm exec` couldn't resolve playwright on the fly (clean caches, locked CI runners). Cycle 1 worked around it with `npm install --no-save playwright`. Result: the documented test path was unreliable.
|
||||
- **Fix**: `playwright ^1.59.1` added to `devDependencies`. `test:e2e`, `test:e2e:guide`, `test:e2e:full` now invoke `node scripts/smoke-test*.js` directly — `require('playwright')` resolves locally. No browser binary install needed because the smoke tests drive Electron via `_electron`, not a browser.
|
||||
|
||||
### 3. Defensive parsing in `loadConfig` and `loadQueue` (server-side correctness)
|
||||
|
||||
- **File**: `src/main.ts` — new `isPlainObject` / `isValidQueueStatus` / `sanitizeCustomClip` / `sanitizeMergeGroup` / `sanitizeQueueItem` helpers; rewritten `loadConfig` and `loadQueue`.
|
||||
- **Problem**: `loadConfig` blindly spread `JSON.parse(data)` over the defaults. If the config file ever held a non-object (corrupt, manually edited to an array, partial write before Cycle 1's fsync landed), the spread either dropped values silently (primitives) or polluted the config object (arrays became numeric keys). `loadQueue` only validated `id`, `url`, `status` are strings — it accepted `customClip` / `mergeGroup` of any shape, never validated `progress` was a finite number, and notably never normalized stale `status: 'downloading'` items. After a hard kill mid-download, those items came back marked as still downloading with no actual download running, and `start-download` only resurrected `paused` items, leaving them stuck.
|
||||
- **Fix**: `loadConfig` checks `isPlainObject(parsed)` before spread; non-objects are logged and ignored, defaults used. `loadQueue` runs every entry through `sanitizeQueueItem` which validates the `status` enum, normalizes `progress` to `[0, 100]`, validates and normalizes `customClip` / `mergeGroup` shapes, and demotes stale `status: 'downloading'` to `pending` with `progress = 0` so the user can actually resume the queue. Invalid items are dropped with a count logged. As a bonus, the previously-unused `CustomClip` and `MergeGroupItem` type imports now have call sites.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors).
|
||||
- `npm run test:e2e:update-logic` — passed.
|
||||
- `npm run test:e2e` — passed via the new direct script path (no `npm exec` workaround), `issues: []`.
|
||||
- `npm run test:e2e:guide` — passed.
|
||||
- `npm run test:merge-split` — passed.
|
||||
- `npm run test:e2e:full` — passed (`failures: []`, `runtimeIssues: []`; flows: language switch, queue, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
## 2026-05-03 — Cycle 1: stability & UX polish
|
||||
|
||||
Three independent improvements landed this cycle.
|
||||
|
||||
### 1. Atomic file writes survive power loss / crash mid-write (correctness)
|
||||
|
||||
- **Files**: `src/main.ts` — new `writeFileAtomicSync` helper, `saveConfig`, `writeQueueToDisk`.
|
||||
- **Problem**: `saveConfig` and `writeQueueToDisk` used `writeFileSync` + `renameSync`. Node's `writeFileSync` does NOT call `fsync` — the OS may report the rename complete while the file content still sits in the write cache. A power loss / kernel panic between `writeFileSync` and `renameSync` could leave the renamed file empty or truncated. On next launch, `JSON.parse` throws and the app silently falls back to defaults (config) or `[]` (queue). Users would see "settings reset" / "queue lost" with no diagnostic in the debug log beyond a `console.error`.
|
||||
- **Fix**: `openSync(tmp, 'w')` → `writeSync(fd, buffer, 0, len, 0)` → `fsyncSync(fd)` → `closeSync(fd)` → `renameSync`. The `fsyncSync` is wrapped in an inner try (some filesystems reject it, e.g. network shares); failure there is non-fatal but the close + rename order is always preserved. The Windows copy/unlink fallback for "rename failed because target locked" is kept.
|
||||
|
||||
### 2. Per-item filename claims fix parallel-download race (race condition + dead-code cleanup)
|
||||
|
||||
- **Files**: `src/main.ts` — `ensureUniqueFilename`, new `releaseClaimedFilenamesForItem`, every download call site, `splitMergedFile` signature.
|
||||
- **Problem**: `claimedFilenames` was a global `Set<string>` and `processOneQueueItem` did `claimedFilenames.clear()` in its `finally`. With parallel downloads enabled (max 2), when item A finished, the `clear()` wiped item B's reservations too. In the narrow window between B claiming a filename via `ensureUniqueFilename` and streamlink actually writing the first bytes to disk, a third item entering the freed slot could compute the SAME filename (claim set empty, file not yet on disk) → both downloads would race writing the same path. The dead `releaseClaimedFilename(filePath)` function was defined at line 722 but never called from anywhere.
|
||||
- **Fix**: New `Map<itemId, Set<filename>>` tracks which item claimed which filenames. `ensureUniqueFilename(filePath, itemId)` registers per-item; `releaseClaimedFilenamesForItem(itemId)` removes only that item's claims. `splitMergedFile` gained an `itemId` parameter so split-phase claims register correctly. The dead `releaseClaimedFilename` is gone, replaced by the per-item variant.
|
||||
|
||||
### 3. Renderer UX polish — robust progress lookup, persisted active tab, keyboard shortcuts (client-side feature)
|
||||
|
||||
- **Files**: `src/renderer-queue.ts`, `src/renderer.ts`.
|
||||
- **Problem(s)** (small wins bundled as one coherent UX improvement):
|
||||
- `updateQueueItemProgress` indexed `byId('queueList').children[idx]` by array position — fragile if the queue array and DOM ever diverged for a frame (queue mutated after render-fingerprint shortcut, or during the throttled queue-sync window).
|
||||
- The active tab always reset to `vods` on app launch — annoying for users who live in `settings`, `cutter`, or `merge`.
|
||||
- No way to dismiss any of the three modals (`clipModal`, `templateGuideModal`, `updateModal`) without clicking the close button.
|
||||
- No keyboard navigation between tabs (only `Del` and `S` were wired).
|
||||
- The page title used to show the streamer name even when the user was on Settings or Cutter, because `showTab` always preferred `currentStreamer` over the tab title.
|
||||
- **Fix**:
|
||||
- Look up queue items by `[data-id="..."]` selector instead of array index. Resilient to mutation between renders. Determinate / indeterminate progress class logic tightened (`isDeterminate = progress > 0 && progress <= 100`).
|
||||
- Active tab persisted to `localStorage` on every `showTab`; restored on init via `loadPersistedActiveTab`, whitelisted to known tab IDs (`vods | clips | cutter | merge | settings`) so a future rename can't strand users on a missing tab. Title logic fixed: streamer name only appears in the page title when the VODs tab is active.
|
||||
- `Escape` closes the topmost open modal regardless of focus (priority order: clip dialog → template guide → update modal). Works while typing in a modal input.
|
||||
- `Ctrl+1..5` (or `Cmd+1..5` on macOS) jumps directly to a tab. Existing `Del` (delete selected) and `S` (start/pause) shortcuts continue to work and remain blocked while typing in inputs.
|
||||
|
||||
### Regression
|
||||
|
||||
- `npm run build` — clean (TypeScript strict, 0 errors, 0 new warnings).
|
||||
- `node scripts/smoke-test-update-version-logic.js` — passed.
|
||||
- `node scripts/smoke-test-merge-split-logic.js` — passed.
|
||||
- `node scripts/smoke-test.js` — passed (37 VODs listed, queue add OK, preflight green, `issues: []`).
|
||||
- `node scripts/smoke-test-template-guide.js` — passed (17 variable rows, live preview reactive, `failures: []`).
|
||||
- `node scripts/smoke-test-full.js` — passed (`failures: []`, `runtimeIssues: []`; flows verified: language switch, queue add, duplicate prevention, runtime metrics, clip queue, pause/resume, retry, reorder, media cut/merge, update check).
|
||||
|
||||
ESLint reports 36 pre-existing warnings and 1 pre-existing error (control-character regex in `sanitizeFilenamePart`); none new from this cycle.
|
||||
25
eslint.config.mjs
Normal file
25
eslint.config.mjs
Normal file
@ -0,0 +1,25 @@
|
||||
import js from '@eslint/js';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import security from 'eslint-plugin-security';
|
||||
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
security.configs.recommended,
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
rules: {
|
||||
// Tune down noisy rules for existing codebase
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
|
||||
'no-console': 'off',
|
||||
'security/detect-object-injection': 'off', // Too many false positives with Record types
|
||||
'security/detect-non-literal-fs-filename': 'off', // All paths come from controlled sources
|
||||
'no-async-promise-executor': 'warn',
|
||||
'no-empty': ['warn', { allowEmptyCatch: true }],
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'release/**', 'node_modules/**', 'scripts/**', 'tmp_*/**']
|
||||
}
|
||||
];
|
||||
1193
package-lock.json
generated
1193
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "twitch-vod-manager",
|
||||
"version": "4.5.3",
|
||||
"version": "4.6.155",
|
||||
"description": "Twitch VOD Manager - Download Twitch VODs easily",
|
||||
"main": "dist/main.js",
|
||||
"author": "xRangerDE",
|
||||
@ -9,9 +9,9 @@
|
||||
"build": "tsc",
|
||||
"start": "npm run build && electron .",
|
||||
"test:e2e:update-logic": "node scripts/smoke-test-update-version-logic.js",
|
||||
"test:e2e": "npm exec --yes --package=playwright -- node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "npm exec --yes --package=playwright -- node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "npm exec --yes --package=playwright -- node scripts/smoke-test-full.js",
|
||||
"test:e2e": "node scripts/smoke-test.js",
|
||||
"test:e2e:guide": "node scripts/smoke-test-template-guide.js",
|
||||
"test:e2e:full": "node scripts/smoke-test-full.js",
|
||||
"test:e2e:release": "npm run build && npm run test:e2e:update-logic && npm run test:e2e && npm run test:e2e:guide && npm run test:e2e:full",
|
||||
"test:e2e:stress": "npm run test:e2e:release && npm run test:e2e:release && npm run test:e2e:release",
|
||||
"pack": "npm run build && electron-builder --dir",
|
||||
@ -25,10 +25,15 @@
|
||||
"electron-updater": "^6.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@types/node": "^20.10.0",
|
||||
"electron": "^28.0.0",
|
||||
"electron-builder": "^24.9.0",
|
||||
"typescript": "^5.3.0"
|
||||
"eslint": "^10.1.0",
|
||||
"eslint-plugin-security": "^4.0.0",
|
||||
"playwright": "^1.59.1",
|
||||
"typescript": "^5.3.0",
|
||||
"typescript-eslint": "^8.57.1"
|
||||
},
|
||||
"build": {
|
||||
"appId": "de.24-music.twitch-vod-manager",
|
||||
|
||||
@ -28,10 +28,13 @@ function parseArgs(argv) {
|
||||
if (args.includes("--help") || args.includes("-h")) {
|
||||
return { help: true };
|
||||
}
|
||||
const FLAGS = new Set(["--dry-run", "--skip-build"]);
|
||||
const dryRun = args.includes("--dry-run");
|
||||
const version = args.find((arg) => arg !== "--dry-run") || "";
|
||||
const notes = args.filter((arg) => arg !== "--dry-run").slice(1).join(" ").trim();
|
||||
return { help: false, dryRun, version, notes };
|
||||
const skipBuild = args.includes("--skip-build");
|
||||
const positional = args.filter((arg) => !FLAGS.has(arg));
|
||||
const version = positional[0] || "";
|
||||
const notes = positional.slice(1).join(" ").trim();
|
||||
return { help: false, dryRun, skipBuild, version, notes };
|
||||
}
|
||||
|
||||
function ensureVersion(version) {
|
||||
@ -122,10 +125,22 @@ async function uploadAssets(baseApi, releaseId, authHeader, releaseDir, files) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasAllArtifactsForVersion(version) {
|
||||
const releaseDir = path.join(process.cwd(), "release");
|
||||
const files = [
|
||||
`Twitch-VOD-Manager-Setup-${version}.exe`,
|
||||
`Twitch-VOD-Manager-Setup-${version}.exe.blockmap`,
|
||||
"latest.yml"
|
||||
];
|
||||
return files.every((f) => fs.existsSync(path.join(releaseDir, f)));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
if (args.help) {
|
||||
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--dry-run]\n");
|
||||
process.stdout.write("Usage: npm run release:gitea -- <version> [release notes] [--skip-build] [--dry-run]\n");
|
||||
process.stdout.write(" --skip-build skip dist:win when release/ already has the 3 required artifacts\n");
|
||||
process.stdout.write(" (auto-skipped when artifacts already exist for this version)\n");
|
||||
process.stdout.write("Env: GITEA_BASE_URL, GITEA_REPO_OWNER, GITEA_REPO_NAME, GITEA_TOKEN\n");
|
||||
return;
|
||||
}
|
||||
@ -141,7 +156,20 @@ async function main() {
|
||||
run("git", ["push", "origin", tag]);
|
||||
}
|
||||
|
||||
// Skip the rebuild when the user passed --skip-build OR when all artifacts
|
||||
// for this version are already on disk. The original unconditional dist:win
|
||||
// re-ran the full test suite + electron-builder even when the .exe already
|
||||
// existed, which made the script unusable when test:e2e was broken.
|
||||
const artifactsExist = hasAllArtifactsForVersion(version);
|
||||
const shouldBuild = !args.skipBuild && !artifactsExist;
|
||||
if (shouldBuild) {
|
||||
run(NPM_EXECUTABLE, ["run", "dist:win"]);
|
||||
} else if (artifactsExist) {
|
||||
process.stdout.write(`Skipping dist:win — artifacts for ${tag} already exist in release/\n`);
|
||||
} else {
|
||||
process.stdout.write(`Skipping dist:win (--skip-build)\n`);
|
||||
}
|
||||
|
||||
const assets = ensureAssets(version);
|
||||
if (args.dryRun) {
|
||||
process.stdout.write(`Dry run complete for ${tag}\n`);
|
||||
|
||||
604
src/index.html
604
src/index.html
@ -10,23 +10,23 @@
|
||||
<body class="theme-twitch">
|
||||
<div class="update-banner" id="updateBanner">
|
||||
<span id="updateText">Neue Version verfügbar!</span>
|
||||
<div id="updateProgress" style="display: none; flex: 1; margin: 0 15px;">
|
||||
<div style="background: rgba(0,0,0,0.3); border-radius: 4px; height: 8px; overflow: hidden;">
|
||||
<div id="updateProgressBar" style="background: white; height: 100%; width: 0%; transition: width 0.3s;"></div>
|
||||
<div id="updateProgress" class="update-banner-progress-wrap is-hidden">
|
||||
<div class="update-banner-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Update download" id="updateProgressGauge">
|
||||
<div id="updateProgressBar" class="update-banner-progress-bar"></div>
|
||||
</div>
|
||||
</div>
|
||||
<button id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
<button type="button" id="updateButton" onclick="downloadUpdate()">Jetzt herunterladen</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="updateModal" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal-overlay" id="updateModal" role="dialog" aria-modal="true" aria-labelledby="updateModalTitle" onclick="handleUpdateModalOverlayClick(event)">
|
||||
<div class="modal update-modal">
|
||||
<button class="modal-close" onclick="dismissUpdateModal()">x</button>
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="dismissUpdateModal()">x</button>
|
||||
<div class="update-modal-eyebrow" id="updateModalEyebrow">Updates</div>
|
||||
<h2 id="updateModalTitle">Update verfugbar</h2>
|
||||
<p class="update-modal-message" id="updateModalMessage">Version 0.0.0 ist verfugbar. Jetzt herunterladen?</p>
|
||||
<div class="update-modal-meta" id="updateModalMeta" style="display:none;"></div>
|
||||
<div class="update-modal-meta is-hidden" id="updateModalMeta"></div>
|
||||
|
||||
<div class="update-changelog-card" id="updateChangelogCard" style="display:none;">
|
||||
<div class="update-changelog-card is-hidden" id="updateChangelogCard">
|
||||
<div class="update-changelog-header">
|
||||
<span class="update-changelog-label" id="updateChangelogLabel">Changelog</span>
|
||||
<button type="button" class="update-changelog-toggle" id="updateChangelogToggle" onclick="toggleUpdateChangelog()">Changelog anzeigen</button>
|
||||
@ -39,111 +39,117 @@
|
||||
|
||||
<div class="modal-actions update-modal-actions">
|
||||
<button class="btn-secondary" id="updateModalDismissBtn" type="button" onclick="dismissUpdateModal()">Nein</button>
|
||||
<button class="btn-secondary" id="updateModalSkipBtn" type="button" onclick="skipUpdateVersion()">Diese Version ueberspringen</button>
|
||||
<button class="btn-primary" id="updateModalConfirmBtn" type="button" onclick="confirmUpdateModal()">Ja, herunterladen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Clip Dialog Modal -->
|
||||
<div class="modal-overlay" id="clipModal">
|
||||
<div class="modal" style="background: #2b2b2b; max-width: 500px;">
|
||||
<button class="modal-close" onclick="closeClipDialog()">x</button>
|
||||
<h2 style="color: #E5A00D; text-align: center; margin-bottom: 20px;" id="clipDialogTitle">Clip zuschneiden</h2>
|
||||
<div class="modal-overlay" id="clipModal" role="dialog" aria-modal="true" aria-labelledby="clipDialogTitle">
|
||||
<div class="modal clip-modal">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeClipDialog()">x</button>
|
||||
<h2 class="clip-modal-title" id="clipDialogTitle">VOD zuschneiden</h2>
|
||||
|
||||
<!-- Start Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('start')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('start')">
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogStartLabel" for="clipStartSlider">Start:</label>
|
||||
<input type="range" id="clipStartSlider" min="0" max="100" value="0" oninput="updateFromSlider('start')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogStartTimeLabel" for="clipStartTime">Startzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipStartTime" value="00:00:00" class="clip-modal-time-input" onchange="updateFromInput('start')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- End Zeit mit Slider -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 5px;">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60"
|
||||
style="width: 100%; height: 6px; -webkit-appearance: none; background: #1a1a1a; border-radius: 3px; cursor: pointer;"
|
||||
oninput="updateFromSlider('end')">
|
||||
<div style="display: flex; align-items: center; gap: 10px; margin-top: 8px;">
|
||||
<label style="color: #888;">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 6px 10px; color: white; font-family: monospace; text-align: center;"
|
||||
onchange="updateFromInput('end')">
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogEndLabel" for="clipEndSlider">Ende:</label>
|
||||
<input type="range" id="clipEndSlider" min="0" max="100" value="60" oninput="updateFromSlider('end')">
|
||||
<div class="clip-modal-time-row">
|
||||
<label class="clip-modal-meta" id="clipDialogEndTimeLabel" for="clipEndTime">Endzeit (HH:MM:SS):</label>
|
||||
<input type="text" id="clipEndTime" value="00:01:00" class="clip-modal-time-input" onchange="updateFromInput('end')">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dauer Anzeige -->
|
||||
<div style="text-align: center; margin-bottom: 20px;">
|
||||
<span style="color: #888;">Dauer: </span>
|
||||
<span id="clipDurationDisplay" style="color: #00c853;">00:01:00</span>
|
||||
<div class="clip-modal-duration">
|
||||
<span id="clipDialogDurationLabel" class="clip-modal-meta">Dauer: </span>
|
||||
<span id="clipDurationDisplay" class="clip-modal-duration-value">00:01:00</span>
|
||||
</div>
|
||||
|
||||
<!-- Teil Nummer -->
|
||||
<div style="margin-bottom: 15px;">
|
||||
<label style="display: block; margin-bottom: 8px;">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42"
|
||||
style="width: 100px; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div style="color: #888; font-size: 12px; margin-top: 5px;">Leer lassen = Teil 1</div>
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogPartLabel" for="clipStartPart">Start Part-Nummer (optional, fur Fortsetzung):</label>
|
||||
<input type="text" id="clipStartPart" placeholder="z.B. 42" class="clip-modal-part-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipDialogPartHint" class="clip-modal-hint">Leer lassen = Teil 1</div>
|
||||
</div>
|
||||
|
||||
<!-- Dateinamen Format -->
|
||||
<div style="margin-bottom: 20px;">
|
||||
<label style="display: block; margin-bottom: 10px;">Dateinamen-Format:</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatSimple" style="color: #aaa;">01.02.2026_1.mp4 (Standard)</span>
|
||||
<div class="clip-modal-field">
|
||||
<label class="clip-modal-label" id="clipDialogFormatLabel">Dateinamen-Format:</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="simple" checked onchange="updateFilenameExamples()">
|
||||
<span id="formatSimple" class="clip-radio-label">01.02.2026_1.mp4 (Standard)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 8px;">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTimestamp" style="color: #aaa;">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="timestamp" onchange="updateFilenameExamples()">
|
||||
<span id="formatTimestamp" class="clip-radio-label">01.02.2026_CLIP_00-00-00_1.mp4 (mit Zeitstempel)</span>
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 10px; cursor: pointer; margin-bottom: 10px;">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()"
|
||||
style="width: 18px; height: 18px; accent-color: #9146FF;">
|
||||
<span id="formatTemplate" style="color: #aaa;">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="parts" onchange="updateFilenameExamples()">
|
||||
<span id="formatParts" class="clip-radio-label">01.02.2026_Part01.mp4 (Parts-Format)</span>
|
||||
</label>
|
||||
<label class="clip-radio-row">
|
||||
<input type="radio" name="filenameFormat" value="template" onchange="updateFilenameExamples()">
|
||||
<span id="formatTemplate" class="clip-radio-label">{date}_{part}.mp4 (benutzerdefiniert)</span>
|
||||
</label>
|
||||
|
||||
<div id="clipFilenameTemplateWrap" style="display:none; margin-top: 10px;">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4"
|
||||
placeholder="{date}_{part}.mp4"
|
||||
style="width: 100%; background: #333; border: 1px solid #444; border-radius: 4px; padding: 8px 12px; color: white; font-family: monospace;"
|
||||
oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" style="color: #888; font-size: 12px; margin-top: 6px;">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" style="color: #8bc34a; font-size: 12px; margin-top: 4px;">Template-Check: OK</div>
|
||||
<button class="btn-secondary" id="clipTemplateGuideBtn" style="margin-top: 8px;" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
<div id="clipFilenameTemplateWrap" class="clip-template-wrap">
|
||||
<input type="text" id="clipFilenameTemplate" value="{date}_{part}.mp4" placeholder="{date}_{part}.mp4" class="clip-modal-template-input" oninput="updateFilenameExamples()">
|
||||
<div id="clipTemplateHelp" class="clip-modal-hint">Platzhalter: {title} {id} {channel} {date} {part} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="clipTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
<button type="button" class="btn-secondary" id="clipTemplateGuideBtn" onclick="openTemplateGuide('clip')">Template Guide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Button -->
|
||||
<div style="text-align: center;">
|
||||
<button class="btn-primary" style="background: #00c853; padding: 12px 30px; border: none; border-radius: 4px; color: white; font-weight: 600; cursor: pointer;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
<div class="clip-modal-actions">
|
||||
<button type="button" class="btn-pill success" id="clipDialogConfirmBtn" style="padding: 12px 30px;" onclick="confirmClipDialog()">Zur Queue hinzufugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Events Viewer Modal -->
|
||||
<div class="modal-overlay" id="eventsViewerModal" role="dialog" aria-modal="true" aria-labelledby="eventsViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-events">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeEventsViewer()">x</button>
|
||||
<h2 id="eventsViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div id="eventsViewerStatus" class="viewer-modal-status" role="status" aria-live="polite"></div>
|
||||
<div id="eventsViewerList" class="viewer-modal-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Chat Replay Viewer Modal -->
|
||||
<div class="modal-overlay" id="chatViewerModal" role="dialog" aria-modal="true" aria-labelledby="chatViewerTitle">
|
||||
<div class="modal viewer-modal viewer-modal-chat">
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeChatViewer()">x</button>
|
||||
<h2 id="chatViewerTitle" class="viewer-modal-title"></h2>
|
||||
<div class="viewer-modal-filter-row">
|
||||
<input type="text" id="chatViewerFilter" class="viewer-modal-filter-input" placeholder="Filter..." oninput="onChatViewerFilterChange()">
|
||||
<span id="chatViewerStatus" class="viewer-modal-status viewer-modal-status-inline" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div id="chatViewerList" class="viewer-modal-list viewer-modal-list-chat"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Template Guide Modal -->
|
||||
<div class="modal-overlay" id="templateGuideModal">
|
||||
<div class="modal-overlay" id="templateGuideModal" role="dialog" aria-modal="true" aria-labelledby="templateGuideTitle">
|
||||
<div class="modal template-guide-modal">
|
||||
<button class="modal-close" onclick="closeTemplateGuide()">x</button>
|
||||
<button type="button" class="modal-close modal-close-localizable" aria-label="Close" onclick="closeTemplateGuide()">x</button>
|
||||
<h2 id="templateGuideTitle">Template Guide</h2>
|
||||
<p id="templateGuideIntro" class="template-guide-intro">Nutze Variablen fur Dateinamen und prufe das Ergebnis als Live-Vorschau.</p>
|
||||
|
||||
<div class="template-guide-actions">
|
||||
<button class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseVod" onclick="setTemplateGuidePreset('vod')">VOD Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseParts" onclick="setTemplateGuidePreset('parts')">VOD Part Template</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideUseClip" onclick="setTemplateGuidePreset('clip')">Clip Template</button>
|
||||
</div>
|
||||
|
||||
<label id="templateGuideTemplateLabel" class="template-guide-label">Template</label>
|
||||
<label id="templateGuideTemplateLabel" for="templateGuideInput" class="template-guide-label">Template</label>
|
||||
<input type="text" id="templateGuideInput" class="template-guide-input" oninput="updateTemplateGuidePreview()" placeholder="{title}.mp4">
|
||||
|
||||
<div class="template-guide-preview-box">
|
||||
@ -154,12 +160,12 @@
|
||||
|
||||
<h3 id="templateGuideVarsTitle" class="template-guide-vars-title">Verfugbare Variablen</h3>
|
||||
<div class="template-guide-table-wrap">
|
||||
<table class="template-guide-table">
|
||||
<table class="template-guide-table" aria-labelledby="templateGuideVarsTitle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th id="templateGuideVarCol">Variable</th>
|
||||
<th id="templateGuideDescCol">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol">Beispiel</th>
|
||||
<th id="templateGuideVarCol" scope="col">Variable</th>
|
||||
<th id="templateGuideDescCol" scope="col">Beschreibung</th>
|
||||
<th id="templateGuideExampleCol" scope="col">Beispiel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="templateGuideBody"></tbody>
|
||||
@ -167,7 +173,7 @@
|
||||
</div>
|
||||
|
||||
<div class="template-guide-footer">
|
||||
<button class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
<button type="button" class="btn-secondary" id="templateGuideCloseBtn" onclick="closeTemplateGuide()">Schliessen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -175,34 +181,49 @@
|
||||
<div class="app">
|
||||
<aside class="sidebar">
|
||||
<div class="logo">
|
||||
<svg viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24"><path d="M11.571 4.714h1.715v5.143H11.57zm4.715 0H18v5.143h-1.714zM6 0L1.714 4.286v15.428h5.143V24l4.286-4.286h3.428L22.286 12V0zm14.571 11.143l-3.428 3.428h-3.429l-3 3v-3H6.857V1.714h13.714Z"/></svg>
|
||||
<span id="logoText">Twitch VOD Manager</span>
|
||||
</div>
|
||||
|
||||
<nav class="nav">
|
||||
<div class="nav-item active" data-tab="vods" onclick="showTab('vods')">
|
||||
<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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<div class="nav-item active" role="button" tabindex="0" aria-current="page" data-tab="vods" onclick="showTab('vods')">
|
||||
<svg aria-hidden="true" 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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<span id="navVodsText">Twitch VODs</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="clips" onclick="showTab('clips')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 10.5V7c0-.55-.45-1-1-1H4c-.55 0-1 .45-1 1v10c0 .55.45 1 1 1h12c.55 0 1-.45 1-1v-3.5l4 4v-11l-4 4z"/></svg>
|
||||
<span id="navClipsText">Twitch Clips</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="cutter" onclick="showTab('cutter')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M9.64 7.64c.23-.5.36-1.05.36-1.64 0-2.21-1.79-4-4-4S2 3.79 2 6s1.79 4 4 4c.59 0 1.14-.13 1.64-.36L10 12l-2.36 2.36C7.14 14.13 6.59 14 6 14c-2.21 0-4 1.79-4 4s1.79 4 4 4 4-1.79 4-4c0-.59-.13-1.14-.36-1.64L12 14l7 7h3v-1L9.64 7.64zM6 8c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm0 12c-1.1 0-2-.89-2-2s.9-2 2-2 2 .89 2 2-.9 2-2 2zm6-7.5c-.28 0-.5-.22-.5-.5s.22-.5.5-.5.5.22.5.5-.22.5-.5.5zM19 3l-6 6 2 2 7-7V3h-3z"/></svg>
|
||||
<span id="navCutterText">Video schneiden</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="merge" onclick="showTab('merge')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg>
|
||||
<span id="navMergeText">Videos zusammenfugen</span>
|
||||
</div>
|
||||
<div class="nav-item" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="stats" onclick="showTab('stats')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M3 13h2v8H3zm4-7h2v15H7zm4 4h2v11h-2zm4 4h2v7h-2zm4-8h2v15h-2z"/></svg>
|
||||
<span id="navStatsText">Statistik</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="archive" onclick="showTab('archive')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M15.5 14h-.79l-.28-.27A6.471 6.471 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z"/></svg>
|
||||
<span id="navArchiveText">Archiv</span>
|
||||
</div>
|
||||
<div class="nav-item" role="button" tabindex="0" data-tab="settings" onclick="showTab('settings')">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M19.14 12.94c.04-.31.06-.63.06-.94 0-.31-.02-.63-.06-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.04.31-.06.63-.06.94s.02.63.06.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z"/></svg>
|
||||
<span id="navSettingsText">Einstellungen</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="section-title">Streamer</div>
|
||||
<div class="section-title" id="streamerSectionTitle">
|
||||
<span class="section-title-label">
|
||||
<span id="streamerSectionTitleText">Streamer</span>
|
||||
<span id="streamerSectionCounter" class="streamer-section-counter"></span>
|
||||
</span>
|
||||
<button id="btnStreamerBulkRemove" class="btn-close is-hidden" type="button" onclick="bulkRemoveStreamers()" title="Bulk remove">x</button>
|
||||
</div>
|
||||
<input type="text" id="streamerListFilter" class="filter-input compact is-hidden" placeholder="Filter..." oninput="onStreamerListFilterChange()">
|
||||
<div class="streamers" id="streamerList"></div>
|
||||
|
||||
<div class="queue-section">
|
||||
@ -212,10 +233,10 @@
|
||||
</div>
|
||||
<div class="queue-list" id="queueList"></div>
|
||||
<div class="queue-actions">
|
||||
<button class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button class="btn btn-merge-group" id="btnMergeGroup" onclick="createMergeGroupFromSelection()" style="display:none">Merge & Split</button>
|
||||
<button class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
<button type="button" class="btn btn-start" id="btnStart" onclick="toggleDownload()">Start</button>
|
||||
<button type="button" class="btn btn-merge-group is-hidden" id="btnMergeGroup" onclick="createMergeGroupFromSelection()">Merge & Split</button>
|
||||
<button type="button" class="btn btn-retry" id="btnRetryFailed" onclick="retryFailedDownloads()" title="Nur fehlgeschlagene Downloads erneut starten">Wiederholen</button>
|
||||
<button type="button" class="btn btn-clear" id="btnClear" onclick="clearCompleted()">Leeren</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stats-bar" id="statsBar"></div>
|
||||
@ -227,10 +248,10 @@
|
||||
<div class="header-actions">
|
||||
<div class="header-search">
|
||||
<input type="text" id="newStreamer" placeholder="Streamer hinzufugen..." onkeypress="if(event.key==='Enter')addStreamer()">
|
||||
<button onclick="addStreamer()">+</button>
|
||||
<button id="btnAddStreamer" type="button" onclick="addStreamer()" aria-label="Add streamer" title="Add streamer">+</button>
|
||||
</div>
|
||||
<button class="btn-icon" onclick="refreshVODs()">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<button type="button" class="btn-icon" onclick="refreshVODs()">
|
||||
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z"/></svg>
|
||||
<span id="refreshText">Aktualisieren</span>
|
||||
</button>
|
||||
</div>
|
||||
@ -239,11 +260,37 @@
|
||||
<div class="content">
|
||||
<!-- VODs Tab -->
|
||||
<div class="tab-content active" id="vodsTab">
|
||||
<div id="streamerProfileHeader" class="streamer-profile-header is-hidden"></div>
|
||||
<div class="vod-filter-row">
|
||||
<input type="text" id="vodFilterInput" class="filter-input" placeholder="Filter VODs..." oninput="onVodFilterInput()">
|
||||
<button type="button" id="vodFilterClearBtn" class="btn-close is-hidden" onclick="clearVodFilter()" title="Clear filter">x</button>
|
||||
<label id="vodSortLabel" for="vodSortSelect" class="form-sublabel vod-sort-label">Sort:</label>
|
||||
<select id="vodSortSelect" class="select-compact" onchange="onVodSortChange()">
|
||||
<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" class="form-sublabel vod-filter-count"></span>
|
||||
<label id="vodHideDownloadedLabel" class="inline-toggle" title="">
|
||||
<input type="checkbox" id="vodHideDownloadedToggle" onchange="onVodHideDownloadedChange()">
|
||||
<span id="vodHideDownloadedText">Hide downloaded</span>
|
||||
</label>
|
||||
</div>
|
||||
<div id="vodBulkBar" class="vod-bulk-bar is-hidden">
|
||||
<span id="vodBulkCount" class="vod-bulk-count">0 selected</span>
|
||||
<span class="vod-bulk-spacer"></span>
|
||||
<button id="vodBulkAddBtn" class="btn-pill primary" type="button" onclick="bulkAddSelectedVodsToQueue()">+ Queue</button>
|
||||
<button id="vodBulkMarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(true)">Mark as downloaded</button>
|
||||
<button id="vodBulkUnmarkBtn" class="btn-pill" type="button" onclick="bulkMarkSelectedDownloaded(false)">Unmark</button>
|
||||
<button id="vodBulkClearBtn" class="btn-pill" type="button" onclick="clearVodSelection()">Clear</button>
|
||||
</div>
|
||||
<div class="vod-grid" id="vodGrid">
|
||||
<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>Keine VODs</h3>
|
||||
<p>Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
<svg aria-hidden="true" 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 id="vodGridEmptyTitle">Keine VODs</h3>
|
||||
<p id="vodGridEmptyText">Wahle einen Streamer aus der Liste oder fuge einen neuen hinzu.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -253,13 +300,13 @@
|
||||
<div class="clip-input">
|
||||
<h2 id="clipsHeading">Twitch Clip-Download</h2>
|
||||
<input type="text" id="clipUrl" placeholder="https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...">
|
||||
<button class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus"></div>
|
||||
<button type="button" class="btn-primary" onclick="downloadClip()" id="btnClip">Clip herunterladen</button>
|
||||
<div class="clip-status" id="clipStatus" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card" style="max-width: 600px; margin: 20px auto;">
|
||||
<div class="settings-card centered">
|
||||
<h3 id="clipsInfoTitle">Info</h3>
|
||||
<p style="color: var(--text-secondary); line-height: 1.6; white-space: pre-line;" id="clipsInfoText">
|
||||
<p id="clipsInfoText" class="info-text">
|
||||
Unterstutzte Formate:
|
||||
- https://clips.twitch.tv/ClipName
|
||||
- https://www.twitch.tv/streamer/clip/ClipName
|
||||
@ -276,37 +323,37 @@
|
||||
<h3 id="cutterSelectTitle">Video auswahlen</h3>
|
||||
<div class="form-row">
|
||||
<input type="text" id="cutterFilePath" readonly placeholder="Keine Datei ausgewahlt...">
|
||||
<button class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
<button type="button" class="btn-secondary" id="cutterBrowseBtn" onclick="selectCutterVideo()">Durchsuchen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="video-preview" id="cutterPreview">
|
||||
<div class="placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p style="margin-top:10px">Video auswahlen um Vorschau zu sehen</p>
|
||||
<svg aria-hidden="true" width="64" height="64" 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-2zm0 16H3V5h18v14zM9 8l7 4-7 4V8z"/></svg>
|
||||
<p>Video auswahlen um Vorschau zu sehen</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-info" id="cutterInfo" style="display:none">
|
||||
<div class="cutter-info" id="cutterInfo">
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Dauer</span>
|
||||
<span class="cutter-info-label" id="cutterInfoDurationLabel">Dauer</span>
|
||||
<span class="cutter-info-value" id="infoDuration">--:--:--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Auflosung</span>
|
||||
<span class="cutter-info-label" id="cutterInfoResolutionLabel">Aufloesung</span>
|
||||
<span class="cutter-info-value" id="infoResolution">----x----</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">FPS</span>
|
||||
<span class="cutter-info-label" id="cutterInfoFpsLabel">FPS</span>
|
||||
<span class="cutter-info-value" id="infoFps">--</span>
|
||||
</div>
|
||||
<div class="cutter-info-item">
|
||||
<span class="cutter-info-label">Auswahl</span>
|
||||
<span class="cutter-info-label" id="cutterInfoSelectionLabel">Auswahl</span>
|
||||
<span class="cutter-info-value" id="infoSelection">--:--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="timeline-container" id="timelineContainer" style="display:none">
|
||||
<div class="timeline-container" id="timelineContainer">
|
||||
<div class="timeline" id="timeline" onclick="seekTimeline(event)">
|
||||
<div class="timeline-selection" id="timelineSelection"></div>
|
||||
<div class="timeline-current" id="timelineCurrent"></div>
|
||||
@ -314,25 +361,25 @@
|
||||
|
||||
<div class="time-inputs">
|
||||
<div class="time-input-group">
|
||||
<label>Start:</label>
|
||||
<label id="cutterStartLabel" for="startTime">Start:</label>
|
||||
<input type="text" id="startTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
<div class="time-input-group">
|
||||
<label>Ende:</label>
|
||||
<label id="cutterEndLabel" for="endTime">Ende:</label>
|
||||
<input type="text" id="endTime" value="00:00:00" onchange="updateTimeFromInput()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="cutProgress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Cut progress" id="cutProgressGauge">
|
||||
<div class="progress-bar-fill" id="cutProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="cutProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="cutter-actions">
|
||||
<button class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
<button type="button" class="btn-primary" id="btnCut" onclick="startCutting()" disabled>Schneiden</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -342,39 +389,104 @@
|
||||
<div class="merge-container">
|
||||
<div class="settings-card">
|
||||
<h3 id="mergeTitle">Videos zusammenfugen</h3>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 15px;" id="mergeDesc">
|
||||
<p id="mergeDesc" class="card-intro">
|
||||
Wahle mehrere Videos aus um sie zu einem Video zusammenzufugen.
|
||||
Die Reihenfolge kann per Drag & Drop geandert werden.
|
||||
</p>
|
||||
<button class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
<button type="button" class="btn-secondary" id="mergeAddBtn" onclick="addMergeFiles()">+ Videos hinzufugen</button>
|
||||
</div>
|
||||
|
||||
<div class="file-list" id="mergeFileList">
|
||||
<div class="empty-state" style="padding: 40px 20px;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p style="margin-top:10px">Keine Videos ausgewahlt</p>
|
||||
<div class="empty-state merge-empty-state">
|
||||
<svg aria-hidden="true" width="48" height="48" viewBox="0 0 24 24" fill="currentColor"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p id="mergeEmptyText">Keine Videos ausgewahlt</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-container" id="mergeProgress">
|
||||
<div class="progress-bar">
|
||||
<div class="progress-bar" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" aria-label="Merge progress" id="mergeProgressGauge">
|
||||
<div class="progress-bar-fill" id="mergeProgressBar"></div>
|
||||
</div>
|
||||
<div class="progress-text" id="mergeProgressText">0%</div>
|
||||
</div>
|
||||
|
||||
<div class="merge-actions">
|
||||
<button class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
<button type="button" class="btn-primary" id="btnMerge" onclick="startMerging()" disabled>Zusammenfugen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Tab -->
|
||||
<div class="tab-content" id="statsTab">
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="statsTitle">Archiv-Statistik</h3>
|
||||
<div class="section-header-actions">
|
||||
<span id="statsLastScannedLabel" class="form-sublabel" role="status" aria-live="polite"></span>
|
||||
<button type="button" class="btn-secondary" id="btnStatsRefresh" onclick="refreshArchiveStats()">Aktualisieren</button>
|
||||
</div>
|
||||
</div>
|
||||
<p id="statsIntro" class="card-intro flush">Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.</p>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSummaryTitle">Uebersicht</h3>
|
||||
<div id="statsSummaryGrid" class="stats-summary-grid"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsTopStreamersTitle">Top Streamer (nach Groesse)</h3>
|
||||
<div id="statsTopStreamers"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsActivityTitle">Aktivitaet (letzte 30 Tage)</h3>
|
||||
<div id="statsActivity"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="statsSizeBucketsTitle">Aufnahme-Groessen-Verteilung</h3>
|
||||
<div id="statsSizeBuckets"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Archive Search Tab -->
|
||||
<div class="tab-content" id="archiveTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="archiveTitle">Archiv durchsuchen</h3>
|
||||
<p id="archiveIntro" class="card-intro">Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.</p>
|
||||
<div class="form-row search-bar">
|
||||
<input type="text" id="archiveSearchQuery" class="filter-input flex-1-1-240" placeholder="Suche...">
|
||||
<select id="archiveSearchType" class="select-compact">
|
||||
<option value="all">Alle Typen</option>
|
||||
<option value="live">Live-Aufnahmen</option>
|
||||
<option value="vod">VOD-Downloads</option>
|
||||
</select>
|
||||
<select id="archiveSearchStreamer" class="select-compact size-md">
|
||||
<option value="">Alle Streamer</option>
|
||||
</select>
|
||||
<select id="archiveSearchSort" class="select-compact">
|
||||
<option value="date_desc">Neueste zuerst</option>
|
||||
<option value="date_asc">Aelteste zuerst</option>
|
||||
<option value="size_desc">Groesste zuerst</option>
|
||||
<option value="size_asc">Kleinste zuerst</option>
|
||||
<option value="name_asc">Name (A-Z)</option>
|
||||
</select>
|
||||
<button type="button" class="btn-secondary" id="btnArchiveSearch" onclick="performArchiveSearch()">Suchen</button>
|
||||
</div>
|
||||
<div id="archiveSearchSummary" class="form-sublabel" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
<div class="settings-card">
|
||||
<div id="archiveSearchResults"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" id="settingsTab">
|
||||
<div class="settings-card">
|
||||
<h3 id="designTitle">Design</h3>
|
||||
<div class="form-group">
|
||||
<label id="themeLabel">Theme</label>
|
||||
<label id="themeLabel" for="themeSelect">Theme</label>
|
||||
<select id="themeSelect" onchange="changeTheme(this.value)">
|
||||
<option value="twitch">Twitch</option>
|
||||
<option value="discord">Discord</option>
|
||||
@ -385,7 +497,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="languageLabel">Sprache</label>
|
||||
<div class="language-picker" id="languagePicker">
|
||||
<div class="language-picker" id="languagePicker" role="group" aria-labelledby="languageLabel">
|
||||
<button type="button" class="lang-option" id="langOptionDe" onclick="selectLanguageOption('de')" aria-pressed="false">
|
||||
<span class="flag-icon flag-de" aria-hidden="true"></span>
|
||||
<span id="languageDeText">Deutsch</span>
|
||||
@ -404,47 +516,63 @@
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="apiTitle">Twitch API</h3>
|
||||
<p id="apiHelpText" class="card-intro">
|
||||
<span id="apiHelpIntro">Du brauchst eine Client-ID und ein Client-Secret von Twitch.</span>
|
||||
<a href="#" id="apiHelpLink" onclick="event.preventDefault(); openTwitchDevConsole()">dev.twitch.tv/console/apps</a>
|
||||
</p>
|
||||
<div class="form-group">
|
||||
<label id="clientIdLabel">Client ID</label>
|
||||
<label id="clientIdLabel" for="clientId">Client ID</label>
|
||||
<input type="text" id="clientId" placeholder="Twitch Client ID">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="clientSecretLabel">Client Secret</label>
|
||||
<label id="clientSecretLabel" for="clientSecret">Client Secret</label>
|
||||
<input type="password" id="clientSecret" placeholder="Twitch Client Secret">
|
||||
</div>
|
||||
<button class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
<button type="button" class="btn-primary" id="saveSettingsBtn" onclick="saveSettings()">Speichern & Verbinden</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="downloadSettingsTitle">Download-Einstellungen</h3>
|
||||
<div class="form-group">
|
||||
<label id="storageLabel">Speicherort</label>
|
||||
<label id="storageLabel" for="downloadPath">Speicherort</label>
|
||||
<div class="form-row">
|
||||
<input type="text" id="downloadPath" readonly>
|
||||
<button class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
<button type="button" class="btn-secondary" onclick="selectFolder()">Ordner</button>
|
||||
<button type="button" class="btn-secondary" id="openFolderBtn" onclick="openFolder()">Offnen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="modeLabel">Download-Modus</label>
|
||||
<label id="modeLabel" for="downloadMode">Download-Modus</label>
|
||||
<select id="downloadMode">
|
||||
<option value="full" id="modeFullText">Ganzes VOD</option>
|
||||
<option value="parts" id="modePartsText">In Teile splitten</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="partMinutesLabel">Teil-Lange (Minuten)</label>
|
||||
<label id="partMinutesLabel" for="partMinutes">Teil-Lange (Minuten)</label>
|
||||
<input type="number" id="partMinutes" value="120" min="10" max="480">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="parallelDownloadsLabel">Parallele Downloads</label>
|
||||
<label id="parallelDownloadsLabel" for="parallelDownloads">Parallele Downloads</label>
|
||||
<select id="parallelDownloads">
|
||||
<option value="1" id="parallelDownloads1">1 (Standard)</option>
|
||||
<option value="2" id="parallelDownloads2">2 (Parallel)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel">Performance-Profil</label>
|
||||
<label id="streamlinkQualityLabel" for="streamlinkQuality">Stream-Qualitaet</label>
|
||||
<select id="streamlinkQuality">
|
||||
<option value="best" id="streamlinkQualityBest">Best (Standard)</option>
|
||||
<option value="source" id="streamlinkQualitySource">Source (Original)</option>
|
||||
<option value="1080p60" id="streamlinkQuality1080p60">1080p60</option>
|
||||
<option value="720p60" id="streamlinkQuality720p60">720p60</option>
|
||||
<option value="720p" id="streamlinkQuality720p">720p</option>
|
||||
<option value="480p" id="streamlinkQuality480p">480p</option>
|
||||
<option value="audio_only" id="streamlinkQualityAudio">Audio only</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="performanceModeLabel" for="performanceMode">Performance-Profil</label>
|
||||
<select id="performanceMode">
|
||||
<option value="stability" id="performanceModeStability">Max Stabilitat</option>
|
||||
<option value="balanced" id="performanceModeBalanced">Ausgewogen</option>
|
||||
@ -452,26 +580,62 @@
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display:flex; align-items:center; gap:8px;">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="smartSchedulerToggle" checked>
|
||||
<span id="smartSchedulerLabel">Smart Queue Scheduler aktivieren</span>
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="duplicatePreventionToggle" checked>
|
||||
<span id="duplicatePreventionLabel">Duplikate in Queue verhindern</span>
|
||||
</label>
|
||||
<label style="display:flex; align-items:center; gap:8px; margin-top: 8px;">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="persistQueueToggle" checked>
|
||||
<span id="persistQueueLabel">Queue zwischen App-Starts speichern</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeQueueToggle">
|
||||
<span id="autoResumeQueueLabel">Queue beim Start automatisch fortsetzen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="notifyEachCompletionToggle">
|
||||
<span id="notifyEachCompletionLabel">Benachrichtigung bei jedem fertigen Download</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="streamlinkDisableAdsToggle" checked>
|
||||
<span id="streamlinkDisableAdsLabel">Twitch-Ads beim Download ueberspringen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="downloadChatReplayToggle">
|
||||
<span id="downloadChatReplayLabel">Chat-Replay parallel zum VOD speichern (.chat.json)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="captureLiveChatToggle">
|
||||
<span id="captureLiveChatLabel">Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="logStreamEventsToggle" checked>
|
||||
<span id="logStreamEventsLabel">Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoResumeLiveRecordingToggle" checked>
|
||||
<span id="autoResumeLiveRecordingLabel">Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="autoMergeResumedPartsToggle">
|
||||
<span id="autoMergeResumedPartsLabel">Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat)</span>
|
||||
</label>
|
||||
<label class="toggle-row indented">
|
||||
<input type="checkbox" id="deletePartsAfterMergeToggle">
|
||||
<span id="deletePartsAfterMergeLabel">Einzelne Parts nach erfolgreichem Merge loeschen</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label id="metadataCacheMinutesLabel">Metadata-Cache (Minuten)</label>
|
||||
<label id="metadataCacheMinutesLabel" for="metadataCacheMinutes">Metadata-Cache (Minuten)</label>
|
||||
<input type="number" id="metadataCacheMinutes" value="10" min="1" max="120">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="form-row" style="align-items:center; margin-bottom: 4px;">
|
||||
<label id="filenameTemplatesTitle" style="margin: 0;">Dateinamen-Templates</label>
|
||||
<label id="filenameTemplatesTitle">Dateinamen-Templates</label>
|
||||
<button class="btn-secondary" id="settingsTemplateGuideBtn" type="button" onclick="openTemplateGuide('vod')">Template Guide</button>
|
||||
</div>
|
||||
<div class="form-row" style="gap: 8px; margin: 8px 0 6px;">
|
||||
@ -479,44 +643,45 @@
|
||||
<button class="btn-secondary" id="templatePresetArchive" type="button" onclick="applyTemplatePreset('archive')">Preset: Archive</button>
|
||||
<button class="btn-secondary" id="templatePresetClipper" type="button" onclick="applyTemplatePreset('clipper')">Preset: Clipper</button>
|
||||
</div>
|
||||
<div style="display: grid; gap: 8px; margin-top: 8px;">
|
||||
<label id="vodTemplateLabel" style="font-size: 13px; color: var(--text-secondary);">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" placeholder="{title}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
<div class="filename-template-grid">
|
||||
<label id="vodTemplateLabel" for="vodFilenameTemplate">VOD Template</label>
|
||||
<input type="text" id="vodFilenameTemplate" class="input-monospace" placeholder="{title}.mp4" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="partsTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" placeholder="{date}_Part{part_padded}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
<label id="partsTemplateLabel" for="partsFilenameTemplate">VOD Part Template</label>
|
||||
<input type="text" id="partsFilenameTemplate" class="input-monospace" placeholder="{date}_Part{part_padded}.mp4" oninput="validateFilenameTemplates()">
|
||||
|
||||
<label id="defaultClipTemplateLabel" style="font-size: 13px; color: var(--text-secondary); margin-top: 4px;">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" placeholder="{date}_{part}.mp4" style="font-family: monospace;" oninput="validateFilenameTemplates()">
|
||||
<label id="defaultClipTemplateLabel" for="defaultClipFilenameTemplate">Clip Template</label>
|
||||
<input type="text" id="defaultClipFilenameTemplate" class="input-monospace" placeholder="{date}_{part}.mp4" oninput="validateFilenameTemplates()">
|
||||
</div>
|
||||
<div id="filenameTemplateHint" style="color: #888; font-size: 12px; margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" style="font-size: 12px; margin-top: 6px; color: #8bc34a;">Template-Check: OK</div>
|
||||
<div id="filenameTemplateHint" class="form-note" style="margin-top: 8px;">Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}</div>
|
||||
<div id="filenameTemplateLint" class="template-lint ok">Template-Check: OK</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="updateTitle">Updates</h3>
|
||||
<p id="versionInfo" style="margin-bottom: 10px; color: var(--text-secondary);">Version: v4.1.13</p>
|
||||
<button class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
<p id="versionInfo" class="card-intro">Version: v4.1.13</p>
|
||||
<button type="button" class="btn-secondary" id="checkUpdateBtn" onclick="checkUpdate()">Nach Updates suchen</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row" style="align-items:center; justify-content:space-between; margin-bottom: 10px;">
|
||||
<h3 id="preflightTitle" style="margin: 0;">System-Check</h3>
|
||||
<div class="form-row section-header">
|
||||
<h3 id="preflightTitle">System-Check</h3>
|
||||
<span class="health-badge unknown" id="healthBadge">System: Unbekannt</span>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 10px;">
|
||||
<button class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
<button type="button" class="btn-secondary" id="btnPreflightRun" onclick="runPreflight(false)">Check ausfuhren</button>
|
||||
<button type="button" class="btn-secondary" id="btnPreflightFix" onclick="runPreflight(true)">Auto-Fix Tools</button>
|
||||
</div>
|
||||
<pre id="preflightResult" class="log-panel">Noch kein Check ausgefuhrt.</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="debugLogTitle">Live Debug-Log</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshLog" onclick="refreshDebugLog()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnOpenDebugLogFile" onclick="openDebugLogFile()">Log-Datei oeffnen</button>
|
||||
<label class="inline-toggle">
|
||||
<input type="checkbox" id="debugAutoRefresh" onchange="toggleDebugAutoRefresh(this.checked)">
|
||||
<span id="autoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
@ -524,12 +689,108 @@
|
||||
<pre id="debugLogOutput" class="log-panel">Lade...</pre>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<div class="form-row section-header">
|
||||
<h3 id="storageCardTitle">Storage</h3>
|
||||
<button type="button" class="btn-secondary" id="btnRefreshStorage" onclick="refreshStorageStats()">Aktualisieren</button>
|
||||
</div>
|
||||
<p id="storageCardIntro" class="card-intro">Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.</p>
|
||||
<div id="storageSummary" class="form-sublabel" style="margin-bottom:8px;" role="status" aria-live="polite"></div>
|
||||
<div id="storageList"></div>
|
||||
|
||||
<hr>
|
||||
<h4 id="cleanupTitle">Auto-Cleanup</h4>
|
||||
<p id="cleanupIntro" class="card-intro">Aufnahmen aelter als X Tage automatisch archivieren oder loeschen. Schiebt Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) mit der Aufnahme.</p>
|
||||
<label class="toggle-row" style="margin-bottom: 8px;">
|
||||
<input type="checkbox" id="autoCleanupEnabledToggle">
|
||||
<span id="autoCleanupEnabledLabel">Auto-Cleanup aktivieren</span>
|
||||
</label>
|
||||
<div class="form-row" style="gap:12px; flex-wrap:wrap; margin-bottom: 8px;">
|
||||
<label class="form-stack size-sm">
|
||||
<span id="autoCleanupDaysLabel" class="form-sublabel">Tage-Schwelle</span>
|
||||
<input type="number" id="autoCleanupDays" min="1" max="3650" value="30">
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupTargetLabel" class="form-sublabel">Bereich</span>
|
||||
<select id="autoCleanupTarget">
|
||||
<option value="live_only" id="autoCleanupTargetLive">Nur Live-Aufnahmen</option>
|
||||
<option value="all" id="autoCleanupTargetAll">Alle Aufnahmen</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="form-stack size-md">
|
||||
<span id="autoCleanupActionLabel" class="form-sublabel">Aktion</span>
|
||||
<select id="autoCleanupAction">
|
||||
<option value="archive" id="autoCleanupActionArchive">In Archiv verschieben</option>
|
||||
<option value="delete" id="autoCleanupActionDelete">Loeschen</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-row" style="margin-bottom: 8px; gap: 8px;">
|
||||
<button type="button" class="btn-secondary" id="btnCleanupDryRun" onclick="runCleanupDryRun()">Vorschau</button>
|
||||
<button type="button" class="btn-secondary" id="btnCleanupRunNow" onclick="runCleanupNow()">Jetzt ausfuehren</button>
|
||||
</div>
|
||||
<div id="cleanupReport" class="form-note" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="discordCardTitle">Discord-Webhook</h3>
|
||||
<p id="discordCardIntro" class="card-intro">Sende Benachrichtigungen an einen Discord-Channel via Webhook — nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.</p>
|
||||
<div class="form-group">
|
||||
<label id="discordWebhookUrlLabel" for="discordWebhookUrl">Webhook-URL</label>
|
||||
<input type="text" id="discordWebhookUrl" placeholder="https://discord.com/api/webhooks/...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveStartToggle">
|
||||
<span id="discordNotifyLiveStartLabel">Bei Live-Aufnahme-Start benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyLiveEndToggle">
|
||||
<span id="discordNotifyLiveEndLabel">Bei Live-Aufnahme-Ende benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodCompleteToggle">
|
||||
<span id="discordNotifyVodCompleteLabel">Bei abgeschlossenem VOD-Download benachrichtigen</span>
|
||||
</label>
|
||||
<label class="toggle-row">
|
||||
<input type="checkbox" id="discordNotifyVodAutoQueuedToggle">
|
||||
<span id="discordNotifyVodAutoQueuedLabel">Bei automatisch eingereihten VODs benachrichtigen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="autoVodCardTitle">Auto-VOD-Download</h3>
|
||||
<p id="autoVodCardIntro" class="card-intro">Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.</p>
|
||||
<div class="form-row aligned">
|
||||
<label id="autoVodPollMinutesLabel" class="form-sublabel" for="autoVodPollMinutes">Poll-Intervall (Minuten)</label>
|
||||
<input type="number" id="autoVodPollMinutes" min="5" max="360" value="15" class="input-narrow">
|
||||
<label id="autoVodMaxAgeHoursLabel" class="form-sublabel" for="autoVodMaxAgeHours" style="margin-left:12px;">Max. Alter (Stunden)</label>
|
||||
<input type="number" id="autoVodMaxAgeHours" min="1" max="720" value="24" class="input-narrow">
|
||||
</div>
|
||||
<div class="form-row" style="align-items: center; gap: 12px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnAutoVodScanNow" onclick="triggerManualAutoVodScan()">Jetzt scannen</button>
|
||||
<button type="button" class="btn-secondary" id="btnAutoRecordScanNow" onclick="triggerManualAutoRecordScan()">Live-Status pruefen</button>
|
||||
<span id="autoVodStatusLine" class="form-sublabel"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="backupCardTitle">Sicherung & Wartung</h3>
|
||||
<p id="backupCardIntro" class="card-intro">Konfiguration sichern, auf einem anderen Geraet wiederherstellen, oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.</p>
|
||||
<div class="form-row" style="margin-bottom: 10px; flex-wrap: wrap;">
|
||||
<button type="button" class="btn-secondary" id="btnExportConfig" onclick="exportConfigToFile()">Konfiguration exportieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnImportConfig" onclick="importConfigFromFile()">Konfiguration importieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnResetDownloadedIds" onclick="resetDownloadedIds()">Downloaded-VODs zuruecksetzen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-card">
|
||||
<h3 id="runtimeMetricsTitle">Runtime Metrics</h3>
|
||||
<div class="form-row" style="margin-bottom: 10px; align-items: center;">
|
||||
<button class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label style="display:flex; align-items:center; gap:6px; font-size:13px; color: var(--text-secondary);">
|
||||
<div class="form-row aligned">
|
||||
<button type="button" class="btn-secondary" id="btnRefreshMetrics" onclick="refreshRuntimeMetrics()">Aktualisieren</button>
|
||||
<button type="button" class="btn-secondary" id="btnExportMetrics" onclick="exportRuntimeMetrics()">Export JSON</button>
|
||||
<label class="inline-toggle">
|
||||
<input type="checkbox" id="runtimeMetricsAutoRefresh" onchange="toggleRuntimeMetricsAutoRefresh(this.checked)">
|
||||
<span id="runtimeMetricsAutoRefreshText">Auto-Refresh</span>
|
||||
</label>
|
||||
@ -541,10 +802,11 @@
|
||||
|
||||
<div class="status-bar">
|
||||
<div class="status-indicator">
|
||||
<div class="status-dot" id="statusDot"></div>
|
||||
<div class="status-dot" id="statusDot" aria-hidden="true"></div>
|
||||
<span id="statusText">Nicht verbunden</span>
|
||||
</div>
|
||||
<span id="versionText">v4.1.13</span>
|
||||
<span id="statusBarQueueSummary" class="status-bar-queue-summary"></span>
|
||||
<span id="versionText" class="status-bar-version"></span>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
@ -557,6 +819,10 @@
|
||||
<script src="../dist/renderer-streamers.js"></script>
|
||||
<script src="../dist/renderer-queue.js"></script>
|
||||
<script src="../dist/renderer-updates.js"></script>
|
||||
<script src="../dist/renderer-stats.js"></script>
|
||||
<script src="../dist/renderer-archive.js"></script>
|
||||
<script src="../dist/renderer-profile.js"></script>
|
||||
<script src="../dist/renderer-vod-hover.js"></script>
|
||||
<script src="../dist/renderer.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
3989
src/main.ts
3989
src/main.ts
File diff suppressed because it is too large
Load Diff
@ -64,10 +64,12 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Queue
|
||||
getQueue: () => ipcRenderer.invoke('get-queue'),
|
||||
addToQueue: (item: Omit<QueueItem, 'id' | 'status' | 'progress'>) => ipcRenderer.invoke('add-to-queue', item),
|
||||
startLiveRecording: (streamerName: string) => ipcRenderer.invoke('start-live-recording', streamerName),
|
||||
removeFromQueue: (id: string) => ipcRenderer.invoke('remove-from-queue', id),
|
||||
reorderQueue: (orderIds: string[]) => ipcRenderer.invoke('reorder-queue', orderIds),
|
||||
clearCompleted: () => ipcRenderer.invoke('clear-completed'),
|
||||
retryFailedDownloads: () => ipcRenderer.invoke('retry-failed-downloads'),
|
||||
retryQueueItem: (id: string) => ipcRenderer.invoke('retry-queue-item', id),
|
||||
createMergeGroup: (itemIds: string[]) => ipcRenderer.invoke('create-merge-group', itemIds),
|
||||
|
||||
// Download
|
||||
@ -83,6 +85,27 @@ contextBridge.exposeInMainWorld('api', {
|
||||
selectMultipleVideos: () => ipcRenderer.invoke('select-multiple-videos'),
|
||||
saveVideoDialog: (defaultName: string) => ipcRenderer.invoke('save-video-dialog', defaultName),
|
||||
openFolder: (path: string) => ipcRenderer.invoke('open-folder', path),
|
||||
openFile: (path: string) => ipcRenderer.invoke('open-file', path),
|
||||
showInFolder: (path: string) => ipcRenderer.invoke('show-in-folder', path),
|
||||
openDebugLogFile: () => ipcRenderer.invoke('open-debug-log-file'),
|
||||
checkFolderWritable: (path: string) => ipcRenderer.invoke('check-folder-writable', path),
|
||||
getStorageStats: () => ipcRenderer.invoke('get-storage-stats'),
|
||||
getArchiveStats: () => ipcRenderer.invoke('get-archive-stats'),
|
||||
getStreamerProfile: (login: string, forceRefresh?: boolean) => ipcRenderer.invoke('get-streamer-profile', login, forceRefresh),
|
||||
getVodStoryboard: (vodId: string) => ipcRenderer.invoke('get-vod-storyboard', vodId),
|
||||
getLiveStatusSnapshot: () => ipcRenderer.invoke('get-live-status-snapshot'),
|
||||
onLiveStatusBatchUpdate: (callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void) => {
|
||||
ipcRenderer.on('live-status-batch-update', (_, info) => callback(info));
|
||||
},
|
||||
searchArchive: (filter: Record<string, unknown>) => ipcRenderer.invoke('search-archive', filter),
|
||||
runStorageCleanup: (options?: { dryRun?: boolean }) => ipcRenderer.invoke('run-storage-cleanup', options),
|
||||
readChatFile: (filePath: string) => ipcRenderer.invoke('read-chat-file', filePath),
|
||||
getAutomationStatus: () => ipcRenderer.invoke('get-automation-status'),
|
||||
triggerAutoVodScan: () => ipcRenderer.invoke('trigger-auto-vod-scan'),
|
||||
triggerAutoRecordScan: () => ipcRenderer.invoke('trigger-auto-record-scan'),
|
||||
onAutoVodScanCompleted: (callback: (info: { queuedCount: number }) => void) => {
|
||||
ipcRenderer.on('auto-vod-scan-completed', (_, info) => callback(info));
|
||||
},
|
||||
|
||||
// Video Cutter
|
||||
getVideoInfo: (filePath: string): Promise<VideoInfo | null> => ipcRenderer.invoke('get-video-info', filePath),
|
||||
@ -105,6 +128,14 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getRuntimeMetrics: (): Promise<RuntimeMetricsSnapshot> => ipcRenderer.invoke('get-runtime-metrics'),
|
||||
exportRuntimeMetrics: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-runtime-metrics'),
|
||||
resetDownloadedVodIds: (): Promise<{ success: boolean; removedCount: number }> =>
|
||||
ipcRenderer.invoke('reset-downloaded-vod-ids'),
|
||||
markVodDownloaded: (vodId: string, mark: boolean): Promise<{ success: boolean }> =>
|
||||
ipcRenderer.invoke('mark-vod-downloaded', vodId, mark),
|
||||
exportConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('export-config'),
|
||||
importConfig: (): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }> =>
|
||||
ipcRenderer.invoke('import-config'),
|
||||
|
||||
// Events
|
||||
onDownloadProgress: (callback: (progress: DownloadProgress) => void) => {
|
||||
|
||||
175
src/renderer-archive.ts
Normal file
175
src/renderer-archive.ts
Normal file
@ -0,0 +1,175 @@
|
||||
let archiveStreamerSelectPopulated = false;
|
||||
let archiveSearchInFlight = false;
|
||||
let archiveSearchDebounceTimer: number | null = null;
|
||||
|
||||
function populateArchiveStreamerSelect(): void {
|
||||
if (archiveStreamerSelectPopulated) return;
|
||||
const select = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
if (!select) return;
|
||||
|
||||
const streamers = (config.streamers as string[] | undefined) || [];
|
||||
const sorted = [...streamers].sort((a, b) => a.localeCompare(b));
|
||||
const opts = sorted.map((s) => `<option value="${escapeHtml(s)}">${escapeHtml(s)}</option>`).join('');
|
||||
applyHtml(select, `<option value="">${escapeHtml(UI_TEXT.static.archiveAllStreamers || 'Alle Streamer')}</option>${opts}`);
|
||||
archiveStreamerSelectPopulated = true;
|
||||
}
|
||||
|
||||
function onArchiveSearchInput(): void {
|
||||
if (archiveSearchDebounceTimer !== null) {
|
||||
window.clearTimeout(archiveSearchDebounceTimer);
|
||||
}
|
||||
// 250ms debounce — feels snappy without spamming the IO walker on
|
||||
// every keystroke. The walk is fast but pointless to repeat mid-type.
|
||||
archiveSearchDebounceTimer = window.setTimeout(() => {
|
||||
archiveSearchDebounceTimer = null;
|
||||
void performArchiveSearch();
|
||||
}, 250);
|
||||
}
|
||||
|
||||
async function performArchiveSearch(): Promise<void> {
|
||||
if (archiveSearchInFlight) return;
|
||||
populateArchiveStreamerSelect();
|
||||
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
const typeEl = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
const streamerEl = document.getElementById('archiveSearchStreamer') as HTMLSelectElement | null;
|
||||
const sortEl = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
const btn = document.getElementById('btnArchiveSearch') as HTMLButtonElement | null;
|
||||
if (!resultsEl) return;
|
||||
|
||||
archiveSearchInFlight = true;
|
||||
if (btn) btn.disabled = true;
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveSearching || 'Scanne...';
|
||||
|
||||
try {
|
||||
const filter = {
|
||||
query: queryEl?.value || '',
|
||||
type: ((typeEl?.value as 'all' | 'live' | 'vod') || 'all'),
|
||||
streamer: streamerEl?.value || '',
|
||||
sinceMs: null,
|
||||
untilMs: null,
|
||||
sort: ((sortEl?.value as 'date_desc') || 'date_desc'),
|
||||
limit: 200
|
||||
};
|
||||
const result = await window.api.searchArchive(filter);
|
||||
renderArchiveSearchResults(result);
|
||||
} catch (e) {
|
||||
if (summaryEl) summaryEl.textContent = `Fehler: ${String(e)}`;
|
||||
applyHtml(resultsEl, '');
|
||||
} finally {
|
||||
archiveSearchInFlight = false;
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveSearchResults(result: ArchiveSearchResult): void {
|
||||
const summaryEl = document.getElementById('archiveSearchSummary');
|
||||
const resultsEl = document.getElementById('archiveSearchResults');
|
||||
if (!resultsEl) return;
|
||||
|
||||
if (!result.rootExists) {
|
||||
if (summaryEl) summaryEl.textContent = UI_TEXT.static.archiveNoRoot;
|
||||
applyHtml(resultsEl, '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (summaryEl) {
|
||||
const tmpl = result.truncated
|
||||
? UI_TEXT.static.archiveSummaryTruncated
|
||||
: UI_TEXT.static.archiveSummary;
|
||||
summaryEl.textContent = (tmpl || '')
|
||||
.replace('{matchCount}', String(result.matchCount))
|
||||
.replace('{scanned}', String(result.totalScanned))
|
||||
.replace('{shown}', String(result.hits.length));
|
||||
}
|
||||
|
||||
if (result.hits.length === 0) {
|
||||
applyHtml(resultsEl, `<div class="archive-no-matches">${escapeHtml(UI_TEXT.static.archiveNoMatches || 'Keine Treffer.')}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.hits.map((hit) => {
|
||||
const date = new Date(hit.mtimeMs).toLocaleString();
|
||||
const typeBadge = `<span class="archive-type-badge ${hit.type === 'live' ? 'live' : 'vod'}">${hit.type === 'live' ? 'LIVE' : 'VOD'}</span>`;
|
||||
const safeFullAttr = hit.fullPath.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
const chatBtn = hit.chatPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${safeFullAttr.replace(/\.(mp4|mkv|ts|m4v)$/i, '.chat.jsonl')}', '${escapeHtml(hit.fileName)}', 'chat')">${escapeHtml(UI_TEXT.static.archiveViewChat || 'Chat')}</button>`
|
||||
: '';
|
||||
const eventsBtn = hit.eventsPath
|
||||
? `<button type="button" class="queue-detail-btn" onclick="openEventsOrChat('${(hit.eventsPath || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'")}', '${escapeHtml(hit.fileName)}', 'events')">${escapeHtml(UI_TEXT.static.archiveViewEvents || 'Events')}</button>`
|
||||
: '';
|
||||
return `
|
||||
<div class="archive-result-row">
|
||||
<div class="archive-result-body">
|
||||
<div class="archive-result-meta">
|
||||
${typeBadge}
|
||||
<strong class="archive-result-streamer">${escapeHtml(hit.streamer)}</strong>
|
||||
<span class="archive-result-date">${escapeHtml(date)}</span>
|
||||
</div>
|
||||
<div class="archive-result-filename" title="${escapeHtml(hit.fullPath)}">${escapeHtml(hit.fileName)}</div>
|
||||
<div class="archive-result-size">${escapeHtml(formatBytes(hit.size))}</div>
|
||||
</div>
|
||||
<div class="archive-result-actions">
|
||||
<button type="button" class="queue-detail-btn" onclick="openFilePath('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveOpen || 'Oeffnen')}</button>
|
||||
<button type="button" class="queue-detail-btn" onclick="showFileInFolder('${safeFullAttr}')">${escapeHtml(UI_TEXT.static.archiveShowInFolder || 'Ordner')}</button>
|
||||
${chatBtn}
|
||||
${eventsBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
applyHtml(resultsEl, rows);
|
||||
}
|
||||
|
||||
function openFilePath(filePath: string): void {
|
||||
void window.api.openFile(filePath);
|
||||
}
|
||||
|
||||
function showFileInFolder(filePath: string): void {
|
||||
void window.api.showInFolder(filePath);
|
||||
}
|
||||
|
||||
function openEventsOrChat(filePath: string, title: string, kind: 'chat' | 'events'): void {
|
||||
if (kind === 'events') {
|
||||
const fn = (window as unknown as { openEventsViewer?: (p: string, t: string) => void }).openEventsViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
} else {
|
||||
const fn = (window as unknown as { openChatViewer?: (p: string, t: string) => void }).openChatViewer;
|
||||
if (typeof fn === 'function') fn(filePath, title);
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
performArchiveSearch: typeof performArchiveSearch;
|
||||
onArchiveSearchInput: typeof onArchiveSearchInput;
|
||||
openFilePath: typeof openFilePath;
|
||||
showFileInFolder: typeof showFileInFolder;
|
||||
openEventsOrChat: typeof openEventsOrChat;
|
||||
}).performArchiveSearch = performArchiveSearch;
|
||||
(window as unknown as { onArchiveSearchInput: typeof onArchiveSearchInput }).onArchiveSearchInput = onArchiveSearchInput;
|
||||
(window as unknown as { openFilePath: typeof openFilePath }).openFilePath = openFilePath;
|
||||
(window as unknown as { showFileInFolder: typeof showFileInFolder }).showFileInFolder = showFileInFolder;
|
||||
(window as unknown as { openEventsOrChat: typeof openEventsOrChat }).openEventsOrChat = openEventsOrChat;
|
||||
|
||||
function initArchiveSearchInput(): void {
|
||||
const queryEl = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (queryEl && !queryEl.dataset.bound) {
|
||||
queryEl.addEventListener('input', onArchiveSearchInput);
|
||||
queryEl.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') void performArchiveSearch();
|
||||
});
|
||||
queryEl.dataset.bound = '1';
|
||||
}
|
||||
const filters = ['archiveSearchType', 'archiveSearchStreamer', 'archiveSearchSort'];
|
||||
for (const id of filters) {
|
||||
const el = document.getElementById(id) as HTMLSelectElement | null;
|
||||
if (el && !el.dataset.bound) {
|
||||
el.addEventListener('change', () => { void performArchiveSearch(); });
|
||||
el.dataset.bound = '1';
|
||||
}
|
||||
}
|
||||
}
|
||||
(window as unknown as { initArchiveSearchInput: typeof initArchiveSearchInput }).initArchiveSearchInput = initArchiveSearchInput;
|
||||
175
src/renderer-globals.d.ts
vendored
175
src/renderer-globals.d.ts
vendored
@ -16,6 +16,31 @@ interface AppConfig {
|
||||
persist_queue_on_restart?: boolean;
|
||||
metadata_cache_minutes?: number;
|
||||
parallel_downloads?: number;
|
||||
auto_resume_queue_on_startup?: boolean;
|
||||
downloaded_vod_ids?: string[];
|
||||
streamlink_quality?: string;
|
||||
notify_on_each_completion?: boolean;
|
||||
streamlink_disable_ads?: boolean;
|
||||
auto_record_streamers?: string[];
|
||||
auto_record_poll_seconds?: number;
|
||||
download_chat_replay?: boolean;
|
||||
capture_live_chat?: boolean;
|
||||
discord_webhook_url?: string;
|
||||
discord_notify_live_start?: boolean;
|
||||
discord_notify_live_end?: boolean;
|
||||
discord_notify_vod_complete?: boolean;
|
||||
discord_notify_vod_auto_queued?: boolean;
|
||||
auto_cleanup_enabled?: boolean;
|
||||
auto_cleanup_days?: number;
|
||||
auto_cleanup_target?: 'live_only' | 'all';
|
||||
auto_cleanup_action?: 'delete' | 'archive';
|
||||
log_stream_events?: boolean;
|
||||
auto_vod_download_streamers?: string[];
|
||||
auto_vod_download_poll_minutes?: number;
|
||||
auto_vod_max_age_hours?: number;
|
||||
auto_resume_live_recording?: boolean;
|
||||
auto_merge_resumed_parts?: boolean;
|
||||
delete_parts_after_merge?: boolean;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
@ -34,7 +59,7 @@ interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
@ -75,6 +100,9 @@ interface QueueItem {
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
outputFiles?: string[];
|
||||
isLive?: boolean;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface DownloadProgress {
|
||||
@ -88,6 +116,7 @@ interface DownloadProgress {
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
interface RuntimeMetricsSnapshot {
|
||||
@ -174,6 +203,116 @@ interface PreflightResult {
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface StreamerStorageEntry {
|
||||
name: string;
|
||||
fileCount: number;
|
||||
totalBytes: number;
|
||||
liveBytes: number;
|
||||
chatBytes: number;
|
||||
folderPath: string;
|
||||
}
|
||||
interface CleanupReport {
|
||||
enabled: boolean;
|
||||
dryRun: boolean;
|
||||
cutoffDays: number;
|
||||
target: 'live_only' | 'all';
|
||||
action: 'delete' | 'archive';
|
||||
scannedAt: string;
|
||||
candidates: number;
|
||||
processed: number;
|
||||
failed: number;
|
||||
bytesFreed: number;
|
||||
failures: Array<{ path: string; error: string }>;
|
||||
}
|
||||
interface StorageStatsResult {
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
freeBytes: number | null;
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
streamers: StreamerStorageEntry[];
|
||||
extras: StreamerStorageEntry[];
|
||||
scannedAt: string;
|
||||
}
|
||||
|
||||
interface StreamerProfile {
|
||||
login: string;
|
||||
displayName: string;
|
||||
avatarUrl: string;
|
||||
bannerUrl: string;
|
||||
description: string;
|
||||
broadcasterType: '' | 'partner' | 'affiliate';
|
||||
followerCount: number | null;
|
||||
vodCount: number;
|
||||
lastStreamAt: string | null;
|
||||
isLive: boolean;
|
||||
currentTitle: string | null;
|
||||
currentGame: string | null;
|
||||
currentStreamPreviewUrl: string;
|
||||
currentStreamViewers: number | null;
|
||||
twitchUrl: string;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
interface VodStoryboard {
|
||||
vodId: string;
|
||||
spriteDataUrl: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
cellWidth: number;
|
||||
cellHeight: number;
|
||||
framesInSprite: number;
|
||||
}
|
||||
|
||||
interface ArchiveSearchHit {
|
||||
fullPath: string;
|
||||
fileName: string;
|
||||
streamer: string;
|
||||
type: 'live' | 'vod' | 'chat' | 'events' | 'other';
|
||||
size: number;
|
||||
mtimeMs: number;
|
||||
chatPath: string | null;
|
||||
eventsPath: string | null;
|
||||
}
|
||||
interface ArchiveSearchResult {
|
||||
totalScanned: number;
|
||||
matchCount: number;
|
||||
truncated: boolean;
|
||||
hits: ArchiveSearchHit[];
|
||||
scannedAt: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ArchiveStatsTopStreamer {
|
||||
streamer: string;
|
||||
bytes: number;
|
||||
fileCount: number;
|
||||
liveBytes: number;
|
||||
vodBytes: number;
|
||||
chatBytes: number;
|
||||
}
|
||||
interface ArchiveStatsDay { date: string; count: number; bytes: number }
|
||||
interface ArchiveStatsBucket { label: string; count: number; bytes: number }
|
||||
interface ArchiveStats {
|
||||
totalFiles: number;
|
||||
totalBytes: number;
|
||||
liveCount: number;
|
||||
liveBytes: number;
|
||||
vodCount: number;
|
||||
vodBytes: number;
|
||||
chatCount: number;
|
||||
chatBytes: number;
|
||||
eventsCount: number;
|
||||
streamerCount: number;
|
||||
avgRecordingSizeBytes: number;
|
||||
topStreamers: ArchiveStatsTopStreamer[];
|
||||
dailyActivity: ArchiveStatsDay[];
|
||||
sizeBuckets: ArchiveStatsBucket[];
|
||||
scannedAt: string;
|
||||
downloadPath: string;
|
||||
rootExists: boolean;
|
||||
}
|
||||
|
||||
interface ApiBridge {
|
||||
getConfig(): Promise<AppConfig>;
|
||||
saveConfig(config: Partial<AppConfig>): Promise<AppConfig>;
|
||||
@ -182,10 +321,12 @@ interface ApiBridge {
|
||||
getVODs(userId: string, forceRefresh?: boolean): Promise<VOD[]>;
|
||||
getQueue(): Promise<QueueItem[]>;
|
||||
addToQueue(item: Omit<QueueItem, 'id' | 'status' | 'progress'>): Promise<QueueItem[]>;
|
||||
startLiveRecording(streamerName: string): Promise<{ success: boolean; error?: string; streamer?: string; title?: string }>;
|
||||
removeFromQueue(id: string): Promise<QueueItem[]>;
|
||||
reorderQueue(orderIds: string[]): Promise<QueueItem[]>;
|
||||
clearCompleted(): Promise<QueueItem[]>;
|
||||
retryFailedDownloads(): Promise<QueueItem[]>;
|
||||
retryQueueItem(id: string): Promise<QueueItem[]>;
|
||||
createMergeGroup(itemIds: string[]): Promise<QueueItem[]>;
|
||||
startDownload(): Promise<boolean>;
|
||||
pauseDownload(): Promise<boolean>;
|
||||
@ -197,6 +338,34 @@ interface ApiBridge {
|
||||
selectMultipleVideos(): Promise<string[] | null>;
|
||||
saveVideoDialog(defaultName: string): Promise<string | null>;
|
||||
openFolder(path: string): Promise<void>;
|
||||
openFile(path: string): Promise<boolean>;
|
||||
showInFolder(path: string): Promise<boolean>;
|
||||
openDebugLogFile(): Promise<boolean>;
|
||||
checkFolderWritable(path: string): Promise<boolean>;
|
||||
getStorageStats(): Promise<StorageStatsResult>;
|
||||
getArchiveStats(): Promise<ArchiveStats>;
|
||||
getStreamerProfile(login: string, forceRefresh?: boolean): Promise<StreamerProfile | null>;
|
||||
getVodStoryboard(vodId: string): Promise<VodStoryboard | null>;
|
||||
getLiveStatusSnapshot(): Promise<Record<string, boolean>>;
|
||||
onLiveStatusBatchUpdate(callback: (info: { changes: Array<{ login: string; isLive: boolean }> }) => void): void;
|
||||
searchArchive(filter: {
|
||||
query?: string;
|
||||
type?: 'all' | 'live' | 'vod' | 'chat' | 'events';
|
||||
streamer?: string;
|
||||
sinceMs?: number | null;
|
||||
untilMs?: number | null;
|
||||
sort?: 'date_desc' | 'date_asc' | 'size_desc' | 'size_asc' | 'name_asc';
|
||||
limit?: number;
|
||||
}): Promise<ArchiveSearchResult>;
|
||||
runStorageCleanup(options?: { dryRun?: boolean }): Promise<CleanupReport>;
|
||||
readChatFile(filePath: string): Promise<{ success: boolean; error?: string; format?: 'replay' | 'live'; messages?: Array<Record<string, unknown>>; truncated?: boolean; total?: number }>;
|
||||
getAutomationStatus(): Promise<{
|
||||
autoRecord: { watching: number; lastRunAt: number; nextRunAt: number; lastTriggeredCount: number; inFlight: boolean };
|
||||
autoVod: { watching: number; lastRunAt: number; nextRunAt: number; lastQueuedCount: number; inFlight: boolean };
|
||||
}>;
|
||||
triggerAutoVodScan(): Promise<{ queuedCount: number }>;
|
||||
triggerAutoRecordScan(): Promise<{ triggered: number }>;
|
||||
onAutoVodScanCompleted(callback: (info: { queuedCount: number }) => void): void;
|
||||
getVideoInfo(filePath: string): Promise<VideoInfo | null>;
|
||||
extractFrame(filePath: string, timeSeconds: number): Promise<string | null>;
|
||||
cutVideo(inputFile: string, startTime: number, endTime: number): Promise<{ success: boolean; outputFile: string | null }>;
|
||||
@ -210,6 +379,10 @@ interface ApiBridge {
|
||||
getDebugLog(lines: number): Promise<string>;
|
||||
getRuntimeMetrics(): Promise<RuntimeMetricsSnapshot>;
|
||||
exportRuntimeMetrics(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
resetDownloadedVodIds(): Promise<{ success: boolean; removedCount: number }>;
|
||||
markVodDownloaded(vodId: string, mark: boolean): Promise<{ success: boolean }>;
|
||||
exportConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
importConfig(): Promise<{ success: boolean; cancelled?: boolean; error?: string; filePath?: string }>;
|
||||
onDownloadProgress(callback: (progress: DownloadProgress) => void): void;
|
||||
onQueueUpdated(callback: (queue: QueueItem[]) => void): void;
|
||||
onQueueDuplicateSkipped(callback: (payload: { title: string; streamer: string; url: string }) => void): void;
|
||||
|
||||
@ -49,8 +49,140 @@ const UI_TEXT_DE = {
|
||||
performanceModeBalanced: 'Ausgewogen',
|
||||
performanceModeSpeed: 'Max Geschwindigkeit',
|
||||
smartSchedulerLabel: 'Smart Queue Scheduler aktivieren',
|
||||
smartSchedulerHint: 'Bevorzugt kuerzere VODs und aeltere Queue-Eintraege zuerst, damit der Durchsatz gleichmaessig bleibt. Deaktivieren = strikte Einfuegereihenfolge.',
|
||||
streamerInvalid: 'Twitch-Username ungueltig (4-25 Zeichen, Buchstaben/Zahlen/Unterstrich).',
|
||||
apiHelpIntro: 'Du brauchst eine Client-ID und ein Client-Secret von Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Log-Datei oeffnen',
|
||||
storageCardTitle: 'Speicher',
|
||||
storageCardIntro: 'Disk-Verbrauch pro Streamer im aktuellen Download-Ordner. Live-Aufnahmen werden separat ausgewiesen.',
|
||||
storageRefresh: 'Aktualisieren',
|
||||
storageEmpty: 'Download-Ordner ist leer oder nicht lesbar.',
|
||||
storageScanning: 'Scanne...',
|
||||
storageSummary: 'Gesamt: {files} Dateien, {size} — Freier Speicher: {free}',
|
||||
storageColumnFolder: 'Ordner',
|
||||
storageColumnFiles: 'Dateien',
|
||||
storageColumnTotal: 'Gesamt',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Aktionen',
|
||||
storageOpen: 'Oeffnen',
|
||||
storageOtherFolders: 'Andere Ordner im Download-Pfad',
|
||||
cleanupTitle: 'Auto-Cleanup',
|
||||
cleanupIntro: 'Aufnahmen aelter als X Tage in einen Archiv-Ordner verschieben oder loeschen. Sidecar-Chat-Dateien (.chat.json/.chat.jsonl) werden mit der Aufnahme bewegt.',
|
||||
cleanupEnabledLabel: 'Auto-Cleanup aktivieren',
|
||||
cleanupDaysLabel: 'Tage-Schwelle',
|
||||
cleanupTargetLabel: 'Bereich',
|
||||
cleanupTargetLive: 'Nur Live-Aufnahmen',
|
||||
cleanupTargetAll: 'Alle Aufnahmen',
|
||||
cleanupActionLabel: 'Aktion',
|
||||
cleanupActionArchive: 'In Archiv verschieben',
|
||||
cleanupActionDelete: 'Loeschen',
|
||||
cleanupDryRun: 'Vorschau',
|
||||
cleanupRunNow: 'Jetzt ausfuehren',
|
||||
cleanupReportPreview: 'Wuerde {count} Dateien betreffen (~{size}). Es wurden keine Dateien verschoben oder geloescht.',
|
||||
cleanupReportDone: '{count} Dateien verarbeitet, ~{size} frei.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} fehlgeschlagen.',
|
||||
cleanupReportEmpty: 'Keine Aufnahmen aelter als {days} Tage gefunden.',
|
||||
discordCardTitle: 'Discord-Webhook',
|
||||
discordCardIntro: 'Sende Benachrichtigungen an einen Discord-Channel via Webhook - nuetzlich fuer Multi-Device-Setups oder eine dedizierte Archiv-Maschine.',
|
||||
discordWebhookUrlLabel: 'Webhook-URL',
|
||||
discordNotifyLiveStartLabel: 'Bei Live-Aufnahme-Start benachrichtigen',
|
||||
discordNotifyLiveEndLabel: 'Bei Live-Aufnahme-Ende benachrichtigen',
|
||||
discordNotifyVodAutoQueuedLabel: 'Bei automatisch eingereihten VODs benachrichtigen',
|
||||
autoResumeLiveRecordingLabel: 'Live-Aufnahme automatisch fortsetzen wenn Streamlink abbricht (max. 5 Versuche)',
|
||||
autoMergeResumedPartsLabel: 'Fortgesetzte Aufnahme-Parts automatisch zu einer Datei zusammenfuegen (ffmpeg concat, kein Re-Encode)',
|
||||
deletePartsAfterMergeLabel: 'Einzelne Parts nach erfolgreichem Merge loeschen',
|
||||
autoVodCardTitle: 'Auto-VOD-Download',
|
||||
autoVodCardIntro: 'Streamer mit aktiviertem VOD-Toggle werden in dem hier festgelegten Intervall auf neue Twitch-VODs geprueft. Neue VODs innerhalb des Alters-Fensters werden automatisch zur Download-Queue hinzugefuegt.',
|
||||
autoVodPollMinutesLabel: 'Poll-Intervall (Minuten)',
|
||||
autoVodMaxAgeHoursLabel: 'Max. Alter (Stunden)',
|
||||
autoVodScanNow: 'Jetzt scannen',
|
||||
autoRecordScanNow: 'Live-Status pruefen',
|
||||
statsTitle: 'Archiv-Statistik',
|
||||
statsIntro: 'Aggregiert ueber den Download-Ordner. Live-Aufnahmen liegen unter <code>{streamer}/live/</code>, VOD-Downloads direkt unter <code>{streamer}/</code>. Lade-Zeit skaliert mit der Anzahl Dateien.',
|
||||
statsRefresh: 'Aktualisieren',
|
||||
statsScanning: 'Scanne...',
|
||||
statsScannedAt: 'Letzter Scan',
|
||||
statsSummaryTitle: 'Uebersicht',
|
||||
statsTopStreamersTitle: 'Top Streamer (nach Groesse)',
|
||||
statsActivityTitle: 'Aktivitaet (letzte 30 Tage)',
|
||||
statsSizeBucketsTitle: 'Aufnahme-Groessen-Verteilung',
|
||||
statsTotalRecordings: 'Aufnahmen gesamt',
|
||||
statsLiveRecordings: 'Live-Aufnahmen',
|
||||
statsVodRecordings: 'VOD-Downloads',
|
||||
statsStreamers: 'Streamer',
|
||||
statsAvgSize: 'Durchschn. Groesse',
|
||||
statsChatFiles: 'Chat-Dateien',
|
||||
statsFiles: 'Dateien',
|
||||
statsActivityEmpty: 'Keine Aufnahmen in den letzten 30 Tagen.',
|
||||
statsActivitySummary: '{count} Aufnahmen - {size} in den letzten 30 Tagen',
|
||||
statsEmpty: 'Keine Daten.',
|
||||
statsNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
navStats: 'Statistik',
|
||||
navArchive: 'Archiv',
|
||||
archiveTitle: 'Archiv durchsuchen',
|
||||
archiveIntro: 'Suche nach Dateinamen, Streamern oder Datum-Strings. Treffer zeigen Recordings (Live + VOD); zugehoerige Chat- und Events-Dateien werden als Companion-Buttons angeboten.',
|
||||
archiveAllTypes: 'Alle Typen',
|
||||
archiveTypeLive: 'Live-Aufnahmen',
|
||||
archiveTypeVod: 'VOD-Downloads',
|
||||
archiveAllStreamers: 'Alle Streamer',
|
||||
archiveSortDateDesc: 'Neueste zuerst',
|
||||
archiveSortDateAsc: 'Aelteste zuerst',
|
||||
archiveSortSizeDesc: 'Groesste zuerst',
|
||||
archiveSortSizeAsc: 'Kleinste zuerst',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Suchen',
|
||||
archiveSearching: 'Scanne...',
|
||||
archiveSummary: '{matchCount} Treffer (gescannt: {scanned} Dateien)',
|
||||
archiveSummaryTruncated: '{matchCount} Treffer (gescannt: {scanned} Dateien, gezeigt: {shown} - verfeinere die Suche fuer mehr)',
|
||||
archiveNoMatches: 'Keine Treffer.',
|
||||
archiveNoRoot: 'Download-Ordner nicht gefunden. Setze zuerst einen Download-Pfad in den Einstellungen.',
|
||||
archiveSearchPlaceholder: 'Suche...',
|
||||
archiveSearchAria: 'Archiv durchsuchen',
|
||||
archiveOpen: 'Oeffnen',
|
||||
archiveShowInFolder: 'Ordner',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
discordNotifyVodCompleteLabel: 'Bei abgeschlossenem VOD-Download benachrichtigen',
|
||||
backupCardTitle: 'Sicherung & Wartung',
|
||||
backupCardIntro: 'Konfiguration sichern, auf einem anderen Geraet wiederherstellen oder die Liste der bereits heruntergeladenen VODs zuruecksetzen.',
|
||||
exportConfig: 'Konfiguration exportieren',
|
||||
importConfig: 'Konfiguration importieren',
|
||||
resetDownloadedIds: 'Downloaded-VODs zuruecksetzen',
|
||||
configExported: 'Konfiguration exportiert.',
|
||||
configExportFailed: 'Export der Konfiguration fehlgeschlagen.',
|
||||
configImported: 'Konfiguration importiert. Einige Aenderungen erfordern evtl. einen Neustart.',
|
||||
configImportFailed: 'Import der Konfiguration fehlgeschlagen.',
|
||||
resetDownloadedConfirm: 'Liste der heruntergeladenen VODs zuruecksetzen? Karten verlieren das gruene Haekchen, es werden aber keine Dateien geloescht.',
|
||||
resetDownloadedDone: '{count} Eintraege aus der Downloaded-Liste entfernt.',
|
||||
duplicatePreventionLabel: 'Duplikate in Queue verhindern',
|
||||
persistQueueLabel: 'Queue zwischen App-Starts speichern',
|
||||
autoResumeQueueLabel: 'Queue beim Start automatisch fortsetzen',
|
||||
autoResumeQueueHint: 'Wenn aktiv und die gespeicherte Queue noch ausstehende Eintraege hat, starten Downloads ~5 Sekunden nach dem Fensteroeffnen. Deaktivieren = Start-Klick noetig.',
|
||||
notifyEachCompletionLabel: 'Benachrichtigung bei jedem fertigen Download',
|
||||
notifyEachCompletionHint: 'Standardmaessig aus — bei langen Queues wuerde das System-Notifications-Panel sonst zugespammt. Die Queue-End-Zusammenfassung erscheint trotzdem.',
|
||||
streamlinkDisableAdsLabel: 'Twitch-Ads beim Download ueberspringen',
|
||||
streamlinkDisableAdsHint: 'Gibt --twitch-disable-ads an streamlink weiter, damit Mid-Roll-Ads nicht ins VOD eingebettet werden. Empfohlen aktiv lassen.',
|
||||
downloadChatReplayLabel: 'Chat-Replay parallel zum VOD speichern (.chat.json)',
|
||||
downloadChatReplayHint: 'Nach erfolgreichem VOD-Download wird der oeffentliche Chat-Replay via Twitch GQL geholt und als JSON neben dem Video gespeichert. Twitch behaelt Chat-Replays nur solange wie das VOD selbst.',
|
||||
captureLiveChatLabel: 'Live-Chat waehrend der Aufnahme mitschneiden (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Oeffnet waehrend einer Live-Aufnahme eine anonyme IRC-Verbindung zum Twitch-Chat und schreibt jede Nachricht in eine .chat.jsonl-Datei neben dem Video (JSON Lines, eine Nachricht pro Zeile, damit ein Mid-Stream-Abbruch frueheren Inhalt nicht korrumpiert).',
|
||||
logStreamEventsLabel: 'Stream-Events bei Live-Aufnahmen mitloggen (.events.jsonl)',
|
||||
logStreamEventsHint: 'Pollt den Streamer einmal pro Minute und schreibt Title-/Game-Wechsel in eine .events.jsonl-Datei neben dem Video. Hilfreich beim Suchen in langen archivierten Streams ("wann hat er auf CS:GO gewechselt?"). Sehr guenstig — ein zusaetzlicher Helix/GQL-Call pro Minute pro aktiver Aufnahme.',
|
||||
streamlinkQualityLabel: 'Stream-Qualitaet',
|
||||
streamlinkQualityHint: 'Streamlink versucht erst diese Qualitaet; falls das VOD sie nicht anbietet, faellt es auf "best" zurueck.',
|
||||
streamlinkQualityBest: 'Best (Standard)',
|
||||
streamlinkQualitySource: 'Source (Original)',
|
||||
streamlinkQualityAudio: 'Nur Audio',
|
||||
downloadPathNotWritable: 'Download-Ordner ist nicht beschreibbar. Waehle einen anderen Ordner oder pruefe die Schreibrechte.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filtern...',
|
||||
streamerListFilterAria: 'Streamer-Liste filtern',
|
||||
streamerAddAriaLabel: 'Streamer hinzufuegen',
|
||||
streamerBulkRemoveTitle: 'Alle entfernen (oder gefilterte)',
|
||||
streamerBulkRemoveAll: 'Alle {count} Streamer aus der Liste entfernen?',
|
||||
streamerBulkRemoveFiltered: 'Die {count} passenden Streamer aus der Liste entfernen?',
|
||||
metadataCacheMinutesLabel: 'Metadata-Cache (Minuten)',
|
||||
filenameTemplatesTitle: 'Dateinamen-Templates',
|
||||
vodTemplateLabel: 'VOD-Template',
|
||||
@ -128,10 +260,15 @@ const UI_TEXT_DE = {
|
||||
clips: 'Clips',
|
||||
cutter: 'Video schneiden',
|
||||
merge: 'Videos zusammenfugen',
|
||||
stats: 'Statistik',
|
||||
archive: 'Archiv',
|
||||
settings: 'Einstellungen'
|
||||
},
|
||||
queue: {
|
||||
empty: 'Keine Downloads in der Warteschlange',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Dauer:',
|
||||
detailDate: 'Datum:',
|
||||
start: 'Start',
|
||||
stop: 'Pausieren',
|
||||
resume: 'Fortsetzen',
|
||||
@ -151,9 +288,87 @@ const UI_TEXT_DE = {
|
||||
eta: 'Restzeit',
|
||||
part: 'Teil',
|
||||
emptyAlert: 'Die Warteschlange ist leer. Fuge zuerst ein VOD oder einen Clip hinzu.',
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.'
|
||||
duplicateSkipped: 'Dieser Eintrag ist bereits aktiv in der Warteschlange.',
|
||||
openFile: 'Datei oeffnen',
|
||||
showInFolder: 'Im Ordner zeigen',
|
||||
openFileFailed: 'Datei konnte nicht geoeffnet werden (evtl. verschoben oder geloescht).',
|
||||
outputFilesLabel: '{count} Ausgabedateien',
|
||||
retryItem: 'Diesen Eintrag erneut versuchen',
|
||||
viewChat: 'Chat ansehen',
|
||||
viewChatLoading: 'Lade Chat...',
|
||||
viewChatFailed: 'Chat-Datei konnte nicht gelesen werden',
|
||||
chatViewerFilterPlaceholder: 'Chat filtern...',
|
||||
chatViewerFilterAria: 'Chatnachrichten filtern',
|
||||
viewChatCount: '{count} Nachrichten',
|
||||
viewChatTruncatedSuffix: ' (gekuerzt)',
|
||||
viewEvents: 'Events ansehen',
|
||||
viewEventsCount: '{count} Events',
|
||||
viewEventsEmpty: 'Keine Events aufgezeichnet.',
|
||||
eventStartedAs: 'Gestartet als',
|
||||
eventEndedAfter: 'Beendet nach',
|
||||
eventTitleFromTo: 'Titel: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} aktiv, {pending} wartet',
|
||||
ctxMoveTop: 'Nach oben verschieben',
|
||||
ctxMoveBottom: 'Nach unten verschieben',
|
||||
ctxCopyUrl: 'URL kopieren',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxRemove: 'Aus Queue entfernen',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
liveRecordingTitle: 'Live-Aufnahme - laeuft bis der Stream endet',
|
||||
recordingHealth: {
|
||||
ok: 'Gesund - Bytes fliessen',
|
||||
stale: 'Stillstand - keine Bytes mehr (Netz-Hickser oder Stream endet)',
|
||||
unknown: 'Warte auf ersten Segment'
|
||||
},
|
||||
eventRecordingResume: 'Aufnahme fortgesetzt - Part {part} startet'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Follower',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'Ueber die Twitch-API sichtbare VODs dieses Kanals',
|
||||
lastStream: 'Letzter Stream',
|
||||
openTwitch: 'Auf Twitch oeffnen',
|
||||
openTwitchTooltip: 'Diesen Kanal auf twitch.tv oeffnen',
|
||||
liveCardTooltip: 'Klick um sofort eine Live-Aufnahme zu starten',
|
||||
liveThumbAlt: 'Live-Vorschau',
|
||||
recordNow: 'Jetzt aufnehmen',
|
||||
refresh: 'Aktualisieren',
|
||||
agoMinutes: 'vor {n} Min',
|
||||
agoHours: 'vor {n} h',
|
||||
agoDays: 'vor {n} Tagen',
|
||||
agoMonths: 'vor {n} Monaten',
|
||||
agoYears: 'vor {n} Jahren'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Diesen Streamer live aufnehmen (laeuft bis der Stream endet)',
|
||||
liveRecordingStarted: 'Live-Aufnahme fuer {streamer} gestartet.',
|
||||
liveRecordingOffline: '{streamer} ist gerade offline.',
|
||||
liveRecordingAlreadyActive: 'Aufnahme von {streamer} laeuft bereits.',
|
||||
liveRecordingFailed: 'Live-Aufnahme konnte nicht gestartet werden',
|
||||
autoRecordTitle: 'Auto-Aufnahme: wenn dieser Streamer live geht, nimmt die App automatisch auf',
|
||||
autoRecordEnabled: 'Auto-Aufnahme aktiviert fuer {streamer}. Live-Status wird geprueft...',
|
||||
autoRecordDisabled: 'Auto-Aufnahme fuer {streamer} deaktiviert.',
|
||||
autoVodTitle: 'Neue VODs (kuerzlich veroeffentlicht) automatisch herunterladen',
|
||||
autoVodEnabled: 'Auto-VOD aktiviert fuer {streamer}. Neue VODs werden automatisch geladen.',
|
||||
autoVodDisabled: 'Auto-VOD fuer {streamer} deaktiviert.',
|
||||
autoVodScanQueued: '{count} neue VOD(s) automatisch eingereiht.',
|
||||
autoVodScanEmpty: 'Keine neuen VODs gefunden.',
|
||||
autoRecordScanTriggered: 'Manueller Scan: {count} Live-Aufnahme(n) gestartet.',
|
||||
autoRecordScanEmpty: 'Manueller Scan: kein Streamer ist gerade live.',
|
||||
liveNowTooltip: 'Aktuell live auf Twitch',
|
||||
modalCloseAria: 'Dialog schliessen',
|
||||
sidebarEmpty: 'Noch keine Streamer. Fuege oben rechts einen hinzu.',
|
||||
removeAria: 'Entfernen',
|
||||
cutProgressAria: 'Schnitt-Fortschritt',
|
||||
mergeProgressAria: 'Merge-Fortschritt',
|
||||
updateProgressAria: 'Update-Download-Fortschritt'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'VOD fuer Bulk-Aktion auswaehlen',
|
||||
noneTitle: 'Keine VODs',
|
||||
noneText: 'Wahle einen Streamer aus der Liste.',
|
||||
loading: 'Lade VODs...',
|
||||
@ -162,11 +377,53 @@ const UI_TEXT_DE = {
|
||||
noResultsText: 'Dieser Streamer hat keine VODs.',
|
||||
untitled: 'Unbenanntes VOD',
|
||||
views: 'Aufrufe',
|
||||
addQueue: '+ Warteschlange'
|
||||
addQueue: '+ Warteschlange',
|
||||
trimButton: 'VOD zuschneiden',
|
||||
filterPlaceholder: 'Nach Titel filtern... (Strg+F)',
|
||||
filterAria: 'VOD-Titel filtern',
|
||||
filterClearTitle: 'Filter loeschen (Esc)',
|
||||
filterNoMatchTitle: 'Keine Treffer',
|
||||
filterNoMatchText: 'Keine VODs entsprechen dem aktuellen Filter.',
|
||||
filterMatchCount: '{shown} von {total} VODs',
|
||||
sortLabel: 'Sortierung:',
|
||||
sortDateDesc: 'Neueste zuerst',
|
||||
sortDateAsc: 'Aelteste zuerst',
|
||||
sortViewsDesc: 'Meiste Aufrufe',
|
||||
sortDurationDesc: 'Laengste zuerst',
|
||||
sortDurationAsc: 'Kuerzeste zuerst',
|
||||
bulkSelectedCount: '{count} ausgewaehlt',
|
||||
bulkAddToQueue: '+ Warteschlange',
|
||||
bulkAdding: 'Fuege hinzu...',
|
||||
bulkClear: 'Loeschen',
|
||||
bulkAddedToQueue: '{count} VODs zur Warteschlange hinzugefuegt.',
|
||||
bulkAddSkipped: 'Keine VODs hinzugefuegt (bereits in Queue oder ungueltig).',
|
||||
bulkMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
bulkUnmark: 'Markierung entfernen',
|
||||
bulkMarkedDownloaded: '{count} VODs als heruntergeladen markiert.',
|
||||
bulkUnmarkedDownloaded: 'Markierung von {count} VODs entfernt.',
|
||||
alreadyDownloaded: 'Bereits heruntergeladen',
|
||||
hideDownloaded: 'Bereits geladene ausblenden',
|
||||
hideDownloadedTitle: 'VODs ausblenden, die als bereits heruntergeladen markiert sind',
|
||||
openOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxOpenOnTwitch: 'Auf Twitch oeffnen',
|
||||
ctxCopyUrl: 'VOD-URL kopieren',
|
||||
ctxCopiedUrl: 'URL in Zwischenablage kopiert.',
|
||||
ctxMarkDownloaded: 'Als heruntergeladen markieren',
|
||||
ctxUnmarkDownloaded: 'Markierung entfernen'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Clip zuschneiden',
|
||||
dialogTitle: 'VOD zuschneiden',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Startzeit (HH:MM:SS):',
|
||||
dialogEnd: 'Ende:',
|
||||
dialogEndTime: 'Endzeit (HH:MM:SS):',
|
||||
dialogDuration: 'Dauer: ',
|
||||
dialogPartLabel: 'Start Part-Nummer (optional, fur Fortsetzung):',
|
||||
dialogPartHint: 'Leer lassen = Teil 1',
|
||||
dialogFormatLabel: 'Dateinamen-Format:',
|
||||
dialogConfirm: 'Zur Queue hinzufuegen',
|
||||
invalidDuration: 'Ungultig!',
|
||||
invalidTime: 'Ungueltige Zeitangaben',
|
||||
endBeforeStart: 'Endzeit muss grosser als Startzeit sein!',
|
||||
outOfRange: 'Zeit ausserhalb des VOD-Bereichs!',
|
||||
enterUrl: 'Bitte URL eingeben',
|
||||
@ -178,26 +435,40 @@ const UI_TEXT_DE = {
|
||||
unknownError: 'Unbekannter Fehler',
|
||||
formatSimple: '(Standard)',
|
||||
formatTimestamp: '(mit Zeitstempel)',
|
||||
formatParts: '(Parts-Format)',
|
||||
formatTemplate: '(benutzerdefiniert)',
|
||||
templateEmpty: 'Das Template darf im benutzerdefinierten Modus nicht leer sein.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
templateHelp: 'Platzhalter: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... oder https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'z.B. 42'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Konnte Video-Informationen nicht lesen. FFprobe installiert?',
|
||||
previewLoading: 'Lade Vorschau...',
|
||||
previewUnavailable: 'Vorschau nicht verfugbar',
|
||||
previewAlt: 'Vorschau',
|
||||
cutting: 'Schneidet...',
|
||||
cut: 'Schneiden',
|
||||
cutSuccess: 'Video erfolgreich geschnitten!',
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.'
|
||||
cutFailed: 'Fehler beim Schneiden des Videos.',
|
||||
infoDuration: 'Dauer',
|
||||
infoResolution: 'Aufloesung',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Auswahl',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'Ende:',
|
||||
filePathPlaceholder: 'Keine Datei ausgewaehlt...'
|
||||
},
|
||||
merge: {
|
||||
empty: 'Keine Videos ausgewahlt',
|
||||
merging: 'Zusammenfugen...',
|
||||
merge: 'Zusammenfugen',
|
||||
success: 'Videos erfolgreich zusammengefugt!',
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.'
|
||||
failed: 'Fehler beim Zusammenfugen der Videos.',
|
||||
moveUpAria: 'Nach oben verschieben',
|
||||
moveDownAria: 'Nach unten verschieben',
|
||||
removeAria: 'Aus Liste entfernen'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Zusammenfugen & Splitten',
|
||||
@ -232,6 +503,7 @@ const UI_TEXT_DE = {
|
||||
modalDismiss: 'Nein',
|
||||
modalDownloadConfirm: 'Ja, herunterladen',
|
||||
modalInstallConfirm: 'Ja, installieren',
|
||||
modalSkipVersion: 'Diese Version ueberspringen',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Changelog anzeigen',
|
||||
hideChangelog: 'Changelog ausblenden',
|
||||
|
||||
@ -49,8 +49,140 @@ const UI_TEXT_EN = {
|
||||
performanceModeBalanced: 'Balanced',
|
||||
performanceModeSpeed: 'Max Speed',
|
||||
smartSchedulerLabel: 'Enable smart queue scheduler',
|
||||
smartSchedulerHint: 'Prefers shorter VODs and older queue entries first so the queue throughput stays steady. Disable to drain in strict insertion order.',
|
||||
streamerInvalid: 'Invalid Twitch username (4-25 chars, letters/digits/underscore).',
|
||||
apiHelpIntro: 'You need a Client ID and Client Secret from Twitch.',
|
||||
apiHelpLinkText: 'dev.twitch.tv/console/apps',
|
||||
openDebugLogFile: 'Open log file',
|
||||
storageCardTitle: 'Storage',
|
||||
storageCardIntro: 'Per-streamer disk usage in the current download folder. Live recordings are surfaced separately.',
|
||||
storageRefresh: 'Refresh',
|
||||
storageEmpty: 'Download folder is empty or unreadable.',
|
||||
storageScanning: 'Scanning...',
|
||||
storageSummary: 'Total: {files} files, {size} — Free disk: {free}',
|
||||
storageColumnFolder: 'Folder',
|
||||
storageColumnFiles: 'Files',
|
||||
storageColumnTotal: 'Total',
|
||||
storageColumnLive: 'Live',
|
||||
storageColumnChat: 'Chat',
|
||||
storageColumnActionsAria: 'Actions',
|
||||
storageOpen: 'Open',
|
||||
storageOtherFolders: 'Other folders in download path',
|
||||
cleanupTitle: 'Auto-cleanup',
|
||||
cleanupIntro: 'Move recordings older than N days to an archive folder, or delete them outright. Sibling chat files (.chat.json/.chat.jsonl) travel with the video.',
|
||||
cleanupEnabledLabel: 'Enable auto-cleanup',
|
||||
cleanupDaysLabel: 'Age threshold (days)',
|
||||
cleanupTargetLabel: 'Scope',
|
||||
cleanupTargetLive: 'Live recordings only',
|
||||
cleanupTargetAll: 'All recordings',
|
||||
cleanupActionLabel: 'Action',
|
||||
cleanupActionArchive: 'Move to archive folder',
|
||||
cleanupActionDelete: 'Delete',
|
||||
cleanupDryRun: 'Preview',
|
||||
cleanupRunNow: 'Run now',
|
||||
cleanupReportPreview: 'Would touch {count} files (~{size}). No files have been moved or deleted.',
|
||||
cleanupReportDone: 'Processed {count} files, freed ~{size}.{failed}',
|
||||
cleanupReportFailedSuffix: ' {failed} failed.',
|
||||
cleanupReportEmpty: 'No recordings older than {days} days found.',
|
||||
discordCardTitle: 'Discord webhook',
|
||||
discordCardIntro: 'Send notifications to a Discord channel via webhook — handy for multi-device setups or a dedicated archive machine.',
|
||||
discordWebhookUrlLabel: 'Webhook URL',
|
||||
discordNotifyLiveStartLabel: 'Notify on live recording start',
|
||||
discordNotifyLiveEndLabel: 'Notify on live recording end',
|
||||
discordNotifyVodCompleteLabel: 'Notify on completed VOD download',
|
||||
autoResumeLiveRecordingLabel: 'Auto-resume live recording if streamlink crashes (max 5 retries)',
|
||||
autoMergeResumedPartsLabel: 'Auto-merge resumed-recording parts into one file (ffmpeg concat, no re-encode)',
|
||||
deletePartsAfterMergeLabel: 'Delete individual parts after successful merge',
|
||||
discordNotifyVodAutoQueuedLabel: 'Notify when a VOD gets auto-queued',
|
||||
autoVodCardTitle: 'Auto-VOD download',
|
||||
autoVodCardIntro: 'Streamers with the VOD toggle on are scanned for new Twitch VODs at the interval set here. New VODs within the age window are added to the download queue automatically.',
|
||||
autoVodPollMinutesLabel: 'Poll interval (minutes)',
|
||||
autoVodMaxAgeHoursLabel: 'Max age (hours)',
|
||||
autoVodScanNow: 'Scan now',
|
||||
autoRecordScanNow: 'Check live status',
|
||||
statsTitle: 'Archive statistics',
|
||||
statsIntro: 'Aggregated across the download folder. Live recordings live under <code>{streamer}/live/</code>, VOD downloads under <code>{streamer}/</code>. Scan time scales with file count.',
|
||||
statsRefresh: 'Refresh',
|
||||
statsScanning: 'Scanning...',
|
||||
statsScannedAt: 'Last scan',
|
||||
statsSummaryTitle: 'Overview',
|
||||
statsTopStreamersTitle: 'Top streamers (by size)',
|
||||
statsActivityTitle: 'Activity (last 30 days)',
|
||||
statsSizeBucketsTitle: 'Recording-size distribution',
|
||||
statsTotalRecordings: 'Recordings total',
|
||||
statsLiveRecordings: 'Live recordings',
|
||||
statsVodRecordings: 'VOD downloads',
|
||||
statsStreamers: 'Streamers',
|
||||
statsAvgSize: 'Avg. recording size',
|
||||
statsChatFiles: 'Chat files',
|
||||
statsFiles: 'files',
|
||||
statsActivityEmpty: 'No recordings in the last 30 days.',
|
||||
statsActivitySummary: '{count} recordings - {size} in the last 30 days',
|
||||
statsEmpty: 'No data.',
|
||||
statsNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
navStats: 'Statistics',
|
||||
navArchive: 'Archive',
|
||||
archiveTitle: 'Search archive',
|
||||
archiveIntro: 'Search by filename, streamer, or date string. Hits show recordings (Live + VOD); related chat and events files appear as companion buttons.',
|
||||
archiveAllTypes: 'All types',
|
||||
archiveTypeLive: 'Live recordings',
|
||||
archiveTypeVod: 'VOD downloads',
|
||||
archiveAllStreamers: 'All streamers',
|
||||
archiveSortDateDesc: 'Newest first',
|
||||
archiveSortDateAsc: 'Oldest first',
|
||||
archiveSortSizeDesc: 'Largest first',
|
||||
archiveSortSizeAsc: 'Smallest first',
|
||||
archiveSortNameAsc: 'Name (A-Z)',
|
||||
archiveSearchBtn: 'Search',
|
||||
archiveSearching: 'Scanning...',
|
||||
archiveSummary: '{matchCount} matches (scanned {scanned} files)',
|
||||
archiveSummaryTruncated: '{matchCount} matches (scanned {scanned} files, showing {shown} - tighten the query for more)',
|
||||
archiveNoMatches: 'No matches.',
|
||||
archiveNoRoot: 'Download folder not found. Set a download path in Settings first.',
|
||||
archiveSearchPlaceholder: 'Search...',
|
||||
archiveSearchAria: 'Search archive',
|
||||
archiveOpen: 'Open',
|
||||
archiveShowInFolder: 'Folder',
|
||||
archiveViewChat: 'Chat',
|
||||
archiveViewEvents: 'Events',
|
||||
backupCardTitle: 'Backup & Maintenance',
|
||||
backupCardIntro: 'Back up your configuration, restore it on another machine, or reset the list of already-downloaded VODs.',
|
||||
exportConfig: 'Export config',
|
||||
importConfig: 'Import config',
|
||||
resetDownloadedIds: 'Reset downloaded list',
|
||||
configExported: 'Configuration exported.',
|
||||
configExportFailed: 'Configuration export failed.',
|
||||
configImported: 'Configuration imported. Some changes may need a restart.',
|
||||
configImportFailed: 'Configuration import failed.',
|
||||
resetDownloadedConfirm: 'Reset the downloaded-VODs list? Cards will lose the green check mark, but no files are deleted.',
|
||||
resetDownloadedDone: 'Cleared {count} entries from the downloaded list.',
|
||||
duplicatePreventionLabel: 'Prevent duplicate queue entries',
|
||||
persistQueueLabel: 'Keep queue between app restarts',
|
||||
autoResumeQueueLabel: 'Auto-resume the queue on startup',
|
||||
autoResumeQueueHint: 'When enabled and the persisted queue has pending entries, downloads kick off ~5 seconds after the window opens. Disable to require an explicit Start click.',
|
||||
notifyEachCompletionLabel: 'Notify on every completed download',
|
||||
notifyEachCompletionHint: 'Off by default — long queues would otherwise spam the OS notifications panel. The end-of-queue summary notification fires either way.',
|
||||
streamlinkDisableAdsLabel: 'Skip Twitch ads while downloading',
|
||||
streamlinkDisableAdsHint: 'Passes --twitch-disable-ads to streamlink so mid-roll ads do not get embedded into the VOD output. Recommended on.',
|
||||
downloadChatReplayLabel: 'Save chat replay alongside each VOD (.chat.json)',
|
||||
downloadChatReplayHint: 'After a VOD download completes, fetches the public chat replay via Twitch GQL and saves it as JSON next to the video. Twitch keeps chat replay only as long as the VOD itself.',
|
||||
captureLiveChatLabel: 'Capture live chat during recording (.chat.jsonl)',
|
||||
captureLiveChatHint: 'Opens an anonymous IRC connection to Twitch chat during a live recording and appends every message to a sibling .chat.jsonl file (JSON Lines, one message per line) so a long capture can be killed mid-stream without corrupting earlier data.',
|
||||
logStreamEventsLabel: 'Log stream events during live recording (.events.jsonl)',
|
||||
logStreamEventsHint: 'Polls the streamer once a minute and writes title / game changes to a sibling .events.jsonl file. Useful for seeking inside long archived streams ("when did he switch to CS:GO?"). Cheap — one extra Helix/GQL hit per minute per active recording.',
|
||||
streamlinkQualityLabel: 'Stream quality',
|
||||
streamlinkQualityHint: 'Streamlink will try this quality first; if the VOD does not offer it, falls back to "best".',
|
||||
streamlinkQualityBest: 'Best (default)',
|
||||
streamlinkQualitySource: 'Source (original)',
|
||||
streamlinkQualityAudio: 'Audio only',
|
||||
downloadPathNotWritable: 'Download folder is not writable. Pick another folder or grant write permission.',
|
||||
streamerSectionTitle: 'Streamer',
|
||||
streamerListFilterPlaceholder: 'Filter...',
|
||||
streamerListFilterAria: 'Filter streamer list',
|
||||
streamerAddAriaLabel: 'Add streamer',
|
||||
streamerBulkRemoveTitle: 'Remove all (or filtered)',
|
||||
streamerBulkRemoveAll: 'Remove all {count} streamers from the list?',
|
||||
streamerBulkRemoveFiltered: 'Remove the {count} matching streamer(s) from the list?',
|
||||
metadataCacheMinutesLabel: 'Metadata Cache (Minutes)',
|
||||
filenameTemplatesTitle: 'Filename Templates',
|
||||
vodTemplateLabel: 'VOD Template',
|
||||
@ -128,10 +260,15 @@ const UI_TEXT_EN = {
|
||||
clips: 'Clips',
|
||||
cutter: 'Video Cutter',
|
||||
merge: 'Merge Videos',
|
||||
stats: 'Statistics',
|
||||
archive: 'Archive',
|
||||
settings: 'Settings'
|
||||
},
|
||||
queue: {
|
||||
empty: 'No downloads in queue',
|
||||
detailStreamer: 'Streamer:',
|
||||
detailDuration: 'Duration:',
|
||||
detailDate: 'Date:',
|
||||
start: 'Start',
|
||||
stop: 'Pause',
|
||||
resume: 'Resume',
|
||||
@ -151,9 +288,87 @@ const UI_TEXT_EN = {
|
||||
eta: 'ETA',
|
||||
part: 'Part',
|
||||
emptyAlert: 'Queue is empty. Add a VOD or clip first.',
|
||||
duplicateSkipped: 'This item is already active in the queue.'
|
||||
duplicateSkipped: 'This item is already active in the queue.',
|
||||
openFile: 'Open file',
|
||||
showInFolder: 'Show in folder',
|
||||
openFileFailed: 'Could not open the file (it may have been moved or deleted).',
|
||||
outputFilesLabel: '{count} output files',
|
||||
retryItem: 'Retry this item',
|
||||
viewChat: 'View chat',
|
||||
viewChatLoading: 'Loading chat...',
|
||||
viewChatFailed: 'Could not read chat file',
|
||||
chatViewerFilterPlaceholder: 'Filter chat...',
|
||||
chatViewerFilterAria: 'Filter chat messages',
|
||||
viewChatCount: '{count} messages',
|
||||
viewChatTruncatedSuffix: ' (truncated)',
|
||||
viewEvents: 'View events',
|
||||
viewEventsCount: '{count} events',
|
||||
viewEventsEmpty: 'No events recorded.',
|
||||
eventStartedAs: 'Started as',
|
||||
eventEndedAfter: 'Ended after',
|
||||
eventTitleFromTo: 'Title: {from} -> {to}',
|
||||
eventGameFromTo: 'Game: {from} -> {to}',
|
||||
statusBarSummary: '{downloading} dl, {pending} queued',
|
||||
ctxMoveTop: 'Move to top',
|
||||
ctxMoveBottom: 'Move to bottom',
|
||||
ctxCopyUrl: 'Copy URL',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxRemove: 'Remove from queue',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
liveRecordingTitle: 'Live recording — captures until the stream ends',
|
||||
recordingHealth: {
|
||||
ok: 'Healthy — bytes flowing',
|
||||
stale: 'Stalled — no bytes recently (network blip or stream ending)',
|
||||
unknown: 'Waiting for first segment'
|
||||
},
|
||||
eventRecordingResume: 'Recording resumed — starting part {part}'
|
||||
},
|
||||
profile: {
|
||||
liveBadge: 'LIVE',
|
||||
partner: 'Partner',
|
||||
affiliate: 'Affiliate',
|
||||
followers: 'Followers',
|
||||
vods: 'VODs',
|
||||
vodsTooltip: 'VODs visible via Twitch API for this channel',
|
||||
lastStream: 'Last stream',
|
||||
openTwitch: 'Open on Twitch',
|
||||
openTwitchTooltip: 'Open this channel on twitch.tv',
|
||||
liveCardTooltip: 'Click to start a live recording right now',
|
||||
liveThumbAlt: 'Live preview',
|
||||
recordNow: 'Record now',
|
||||
refresh: 'Refresh',
|
||||
agoMinutes: '{n} min ago',
|
||||
agoHours: '{n} h ago',
|
||||
agoDays: '{n} d ago',
|
||||
agoMonths: '{n} mo ago',
|
||||
agoYears: '{n} y ago'
|
||||
},
|
||||
streamers: {
|
||||
recordLiveTitle: 'Record this streamer live (captures until stream ends)',
|
||||
liveRecordingStarted: 'Live recording started for {streamer}.',
|
||||
liveRecordingOffline: '{streamer} is offline right now.',
|
||||
liveRecordingAlreadyActive: 'Already recording {streamer}.',
|
||||
liveRecordingFailed: 'Could not start live recording',
|
||||
autoRecordTitle: 'Auto-record: when this streamer goes live the app records automatically',
|
||||
autoRecordEnabled: 'Auto-record enabled for {streamer}. Polling for live state...',
|
||||
autoRecordDisabled: 'Auto-record disabled for {streamer}.',
|
||||
autoVodTitle: 'Auto-download new VODs (recently published) for this streamer',
|
||||
autoVodEnabled: 'Auto-VOD enabled for {streamer}. Will pick up new VODs.',
|
||||
autoVodDisabled: 'Auto-VOD disabled for {streamer}.',
|
||||
autoVodScanQueued: '{count} new VOD(s) auto-queued.',
|
||||
autoVodScanEmpty: 'No new VODs found.',
|
||||
autoRecordScanTriggered: 'Manual scan: {count} live recording(s) started.',
|
||||
autoRecordScanEmpty: 'Manual scan: no streamers currently live.',
|
||||
liveNowTooltip: 'Currently live on Twitch',
|
||||
modalCloseAria: 'Close dialog',
|
||||
sidebarEmpty: 'No streamers yet. Add one via the input at the top right.',
|
||||
removeAria: 'Remove',
|
||||
cutProgressAria: 'Cut progress',
|
||||
mergeProgressAria: 'Merge progress',
|
||||
updateProgressAria: 'Update download progress'
|
||||
},
|
||||
vods: {
|
||||
selectAriaLabel: 'Select VOD for bulk action',
|
||||
noneTitle: 'No VODs',
|
||||
noneText: 'Select a streamer from the list.',
|
||||
loading: 'Loading VODs...',
|
||||
@ -162,11 +377,53 @@ const UI_TEXT_EN = {
|
||||
noResultsText: 'This streamer has no VODs.',
|
||||
untitled: 'Untitled VOD',
|
||||
views: 'views',
|
||||
addQueue: '+ Queue'
|
||||
addQueue: '+ Queue',
|
||||
trimButton: 'Trim VOD',
|
||||
filterPlaceholder: 'Filter by title... (Ctrl+F)',
|
||||
filterAria: 'Filter VOD titles',
|
||||
filterClearTitle: 'Clear filter (Esc)',
|
||||
filterNoMatchTitle: 'No matches',
|
||||
filterNoMatchText: 'No VODs match the current filter.',
|
||||
filterMatchCount: '{shown} of {total} VODs',
|
||||
sortLabel: 'Sort:',
|
||||
sortDateDesc: 'Newest first',
|
||||
sortDateAsc: 'Oldest first',
|
||||
sortViewsDesc: 'Most viewed',
|
||||
sortDurationDesc: 'Longest first',
|
||||
sortDurationAsc: 'Shortest first',
|
||||
bulkSelectedCount: '{count} selected',
|
||||
bulkAddToQueue: '+ Queue',
|
||||
bulkAdding: 'Adding...',
|
||||
bulkClear: 'Clear',
|
||||
bulkAddedToQueue: 'Added {count} VODs to the queue.',
|
||||
bulkAddSkipped: 'No VODs were added (already in queue or invalid).',
|
||||
bulkMarkDownloaded: 'Mark as downloaded',
|
||||
bulkUnmark: 'Unmark',
|
||||
bulkMarkedDownloaded: 'Marked {count} VODs as downloaded.',
|
||||
bulkUnmarkedDownloaded: 'Removed {count} VODs from the downloaded list.',
|
||||
alreadyDownloaded: 'Already downloaded',
|
||||
hideDownloaded: 'Hide downloaded',
|
||||
hideDownloadedTitle: 'Hide VODs that are marked as already downloaded',
|
||||
openOnTwitch: 'Open on Twitch',
|
||||
ctxOpenOnTwitch: 'Open on Twitch',
|
||||
ctxCopyUrl: 'Copy VOD URL',
|
||||
ctxCopiedUrl: 'URL copied to clipboard.',
|
||||
ctxMarkDownloaded: 'Mark as downloaded',
|
||||
ctxUnmarkDownloaded: 'Unmark downloaded'
|
||||
},
|
||||
clips: {
|
||||
dialogTitle: 'Trim clip',
|
||||
dialogTitle: 'Trim VOD',
|
||||
dialogStart: 'Start:',
|
||||
dialogStartTime: 'Start time (HH:MM:SS):',
|
||||
dialogEnd: 'End:',
|
||||
dialogEndTime: 'End time (HH:MM:SS):',
|
||||
dialogDuration: 'Duration: ',
|
||||
dialogPartLabel: 'Start part number (optional, for continuation):',
|
||||
dialogPartHint: 'Leave empty = part 1',
|
||||
dialogFormatLabel: 'Filename format:',
|
||||
dialogConfirm: 'Add to queue',
|
||||
invalidDuration: 'Invalid!',
|
||||
invalidTime: 'Invalid time values',
|
||||
endBeforeStart: 'End time must be greater than start time!',
|
||||
outOfRange: 'Time is outside VOD range!',
|
||||
enterUrl: 'Please enter a URL',
|
||||
@ -178,26 +435,40 @@ const UI_TEXT_EN = {
|
||||
unknownError: 'Unknown error',
|
||||
formatSimple: '(default)',
|
||||
formatTimestamp: '(with timestamp)',
|
||||
formatParts: '(parts naming)',
|
||||
formatTemplate: '(custom template)',
|
||||
templateEmpty: 'Template cannot be empty in custom template mode.',
|
||||
templatePlaceholder: '{date}_{part}.mp4',
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}'
|
||||
templateHelp: 'Placeholders: {title} {id} {channel} {date} {part} {part_padded} {trim_start} {trim_end} {trim_length} {date_custom="yyyy-MM-dd"}',
|
||||
urlPlaceholder: 'https://clips.twitch.tv/... or https://www.twitch.tv/.../clip/...',
|
||||
startPartPlaceholder: 'e.g. 42'
|
||||
},
|
||||
cutter: {
|
||||
videoInfoFailed: 'Could not read video info. Is FFprobe installed?',
|
||||
previewLoading: 'Loading preview...',
|
||||
previewUnavailable: 'Preview unavailable',
|
||||
previewAlt: 'Preview',
|
||||
cutting: 'Cutting...',
|
||||
cut: 'Cut',
|
||||
cutSuccess: 'Video cut successfully!',
|
||||
cutFailed: 'Failed to cut video.'
|
||||
cutFailed: 'Failed to cut video.',
|
||||
infoDuration: 'Duration',
|
||||
infoResolution: 'Resolution',
|
||||
infoFps: 'FPS',
|
||||
infoSelection: 'Selection',
|
||||
startLabel: 'Start:',
|
||||
endLabel: 'End:',
|
||||
filePathPlaceholder: 'No file selected...'
|
||||
},
|
||||
merge: {
|
||||
empty: 'No videos selected',
|
||||
merging: 'Merging...',
|
||||
merge: 'Merge',
|
||||
success: 'Videos merged successfully!',
|
||||
failed: 'Failed to merge videos.'
|
||||
failed: 'Failed to merge videos.',
|
||||
moveUpAria: 'Move up',
|
||||
moveDownAria: 'Move down',
|
||||
removeAria: 'Remove from list'
|
||||
},
|
||||
mergeGroup: {
|
||||
btn: 'Merge & Split',
|
||||
@ -232,6 +503,7 @@ const UI_TEXT_EN = {
|
||||
modalDismiss: 'No',
|
||||
modalDownloadConfirm: 'Yes, download',
|
||||
modalInstallConfirm: 'Yes, install',
|
||||
modalSkipVersion: 'Skip this version',
|
||||
changelogLabel: 'Changelog',
|
||||
showChangelog: 'Show changelog',
|
||||
hideChangelog: 'Hide changelog',
|
||||
|
||||
218
src/renderer-profile.ts
Normal file
218
src/renderer-profile.ts
Normal file
@ -0,0 +1,218 @@
|
||||
// Profile-header renderer. Owns the streamerProfileHeader div above the
|
||||
// VOD grid: hidden when no streamer is selected, skeleton while loading,
|
||||
// full card once profile data is back. Smooth fade-in is in CSS.
|
||||
|
||||
let activeProfileRequestId = 0;
|
||||
|
||||
function formatProfileFollowers(count: number | null): string {
|
||||
if (count == null) return '–';
|
||||
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(count >= 10_000_000 ? 0 : 1)}M`;
|
||||
if (count >= 1_000) return `${(count / 1_000).toFixed(count >= 10_000 ? 0 : 1)}K`;
|
||||
return String(count);
|
||||
}
|
||||
|
||||
function formatLastStreamAgo(iso: string | null): string {
|
||||
if (!iso) return '–';
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (!Number.isFinite(ms) || ms < 0) return '–';
|
||||
const minutes = Math.floor(ms / 60_000);
|
||||
if (minutes < 60) return UI_TEXT.profile.agoMinutes.replace('{n}', String(minutes));
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return UI_TEXT.profile.agoHours.replace('{n}', String(hours));
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days < 30) return UI_TEXT.profile.agoDays.replace('{n}', String(days));
|
||||
const months = Math.floor(days / 30);
|
||||
if (months < 12) return UI_TEXT.profile.agoMonths.replace('{n}', String(months));
|
||||
const years = Math.floor(days / 365);
|
||||
return UI_TEXT.profile.agoYears.replace('{n}', String(years));
|
||||
}
|
||||
|
||||
function hideStreamerProfileHeader(): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.add('is-hidden');
|
||||
applyHtml(el, '');
|
||||
}
|
||||
|
||||
function renderStreamerProfileSkeleton(login: string): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('is-live', 'is-hidden');
|
||||
el.classList.add('streamer-profile-skeleton');
|
||||
applyHtml(el, `
|
||||
<div class="streamer-profile-skel-block avatar"></div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<div class="streamer-profile-skel-block name"></div>
|
||||
<div class="streamer-profile-skel-block badge"></div>
|
||||
</div>
|
||||
<div class="streamer-profile-skel-block subtitle"></div>
|
||||
<div class="streamer-profile-stats streamer-profile-skel-stats">
|
||||
<div class="streamer-profile-skel-block" style="width:100px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:80px; height:14px;"></div>
|
||||
<div class="streamer-profile-skel-block" style="width:120px; height:14px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStreamerProfileCard(p: StreamerProfile): void {
|
||||
const el = document.getElementById('streamerProfileHeader');
|
||||
if (!el) return;
|
||||
el.classList.remove('streamer-profile-skeleton', 'is-hidden');
|
||||
if (p.isLive) el.classList.add('is-live'); else el.classList.remove('is-live');
|
||||
|
||||
const safeLogin = p.login.replace(/'/g, "\\'");
|
||||
const safeUrl = p.twitchUrl.replace(/'/g, "\\'");
|
||||
|
||||
const avatarBlock = p.avatarUrl
|
||||
? `<img class="streamer-profile-avatar${p.isLive ? ' is-live' : ''}" src="${escapeHtml(p.avatarUrl)}" alt="${escapeHtml(p.displayName)}" referrerpolicy="no-referrer" onerror="onProfileAvatarError(this)">`
|
||||
: `<div class="streamer-profile-avatar-fallback">${escapeHtml((p.displayName || p.login || '?').slice(0, 1).toUpperCase())}</div>`;
|
||||
|
||||
const badges: string[] = [];
|
||||
if (p.broadcasterType === 'partner') badges.push(`<span class="streamer-profile-badge partner">${escapeHtml(UI_TEXT.profile.partner)}</span>`);
|
||||
if (p.broadcasterType === 'affiliate') badges.push(`<span class="streamer-profile-badge affiliate">${escapeHtml(UI_TEXT.profile.affiliate)}</span>`);
|
||||
|
||||
const bio = p.description
|
||||
? `<div class="streamer-profile-bio" title="${escapeHtml(p.description)}">${escapeHtml(p.description)}</div>`
|
||||
: '';
|
||||
|
||||
const followersStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.followers)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
||||
<strong>${escapeHtml(formatProfileFollowers(p.followerCount))}</strong> ${escapeHtml(UI_TEXT.profile.followers)}
|
||||
</div>`;
|
||||
const vodsStat = `
|
||||
<div class="streamer-profile-stat" title="${escapeHtml(UI_TEXT.profile.vodsTooltip)}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M18 4l2 4h-3l-2-4h-2l2 4h-3l-2-4H8l2 4H7L5 4H4c-1.1 0-1.99.9-1.99 2L2 18c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V4h-4z"/></svg>
|
||||
<strong>${p.vodCount}</strong> ${escapeHtml(UI_TEXT.profile.vods)}
|
||||
</div>`;
|
||||
const lastStreamStat = `
|
||||
<div class="streamer-profile-stat" title="${p.lastStreamAt ? escapeHtml(new Date(p.lastStreamAt).toLocaleString()) : ''}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M11.99 2C6.47 2 2 6.48 2 12s4.47 10 9.99 10C17.52 22 22 17.52 22 12S17.52 2 11.99 2zM12 20c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8zm.5-13H11v6l5.25 3.15.75-1.23-4.5-2.67z"/></svg>
|
||||
${escapeHtml(UI_TEXT.profile.lastStream)}: <strong>${escapeHtml(formatLastStreamAgo(p.lastStreamAt))}</strong>
|
||||
</div>`;
|
||||
|
||||
// Banner-as-background — set inline so the URL stays per-streamer.
|
||||
// The darkening gradient is handled by the .streamer-profile-header::before
|
||||
// pseudo so the banner itself stays bright and unfiltered here.
|
||||
const bannerStyle = p.bannerUrl
|
||||
? `background-image: url("${p.bannerUrl.replace(/"/g, '%22')}");`
|
||||
: '';
|
||||
|
||||
// Live preview block — only when currently live. Big card with
|
||||
// current preview frame + viewer count + title + game + record CTA.
|
||||
const liveCard = p.isLive
|
||||
? `
|
||||
<div class="streamer-profile-live-card" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}" onclick="triggerLiveRecordingFromProfile('${safeLogin}')" onkeydown="if((event.key==='Enter'||event.key===' ')&&event.target===event.currentTarget){event.preventDefault();triggerLiveRecordingFromProfile('${safeLogin}');}" title="${escapeHtml(UI_TEXT.profile.liveCardTooltip)}">
|
||||
${p.currentStreamPreviewUrl
|
||||
? `<img class="streamer-profile-live-thumb" src="${escapeHtml(p.currentStreamPreviewUrl)}" alt="${escapeHtml(UI_TEXT.profile.liveThumbAlt)}" onerror="onProfileLivePreviewError(this)">`
|
||||
: `<div class="streamer-profile-live-thumb-fallback"></div>`}
|
||||
<div class="streamer-profile-live-body">
|
||||
<div class="streamer-profile-live-badge-row">
|
||||
<span class="streamer-profile-badge live">${escapeHtml(UI_TEXT.profile.liveBadge)}</span>
|
||||
${typeof p.currentStreamViewers === 'number' ? `<span class="streamer-profile-live-viewers"><svg aria-hidden="true" viewBox="0 0 24 24" fill="currentColor"><path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/></svg> ${escapeHtml(formatProfileFollowers(p.currentStreamViewers))}</span>` : ''}
|
||||
</div>
|
||||
${p.currentTitle ? `<div class="streamer-profile-live-title">${escapeHtml(p.currentTitle)}</div>` : ''}
|
||||
${p.currentGame ? `<div class="streamer-profile-live-game">${escapeHtml(p.currentGame)}</div>` : ''}
|
||||
<button type="button" class="streamer-profile-btn primary streamer-profile-live-rec-btn" onclick="event.stopPropagation(); triggerLiveRecordingFromProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.recordNow)}</button>
|
||||
</div>
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
applyHtml(el, `
|
||||
${bannerStyle ? `<div class="streamer-profile-banner-bg" style="${bannerStyle}"></div>` : ''}
|
||||
<div class="streamer-profile-row">
|
||||
<div class="streamer-profile-avatar-wrap" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}" onclick="openTwitchChannel('${safeUrl}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();openTwitchChannel('${safeUrl}');}" title="${escapeHtml(UI_TEXT.profile.openTwitchTooltip)}">
|
||||
${avatarBlock}
|
||||
</div>
|
||||
<div class="streamer-profile-body">
|
||||
<div class="streamer-profile-name-row">
|
||||
<span class="streamer-profile-display-name">${escapeHtml(p.displayName)}</span>
|
||||
<span class="streamer-profile-login">@${escapeHtml(p.login)}</span>
|
||||
${badges.join('')}
|
||||
</div>
|
||||
${bio}
|
||||
<div class="streamer-profile-stats">
|
||||
${followersStat}
|
||||
${vodsStat}
|
||||
${lastStreamStat}
|
||||
</div>
|
||||
</div>
|
||||
<div class="streamer-profile-actions">
|
||||
<button type="button" class="streamer-profile-btn primary" onclick="openTwitchChannel('${safeUrl}')">${escapeHtml(UI_TEXT.profile.openTwitch)}</button>
|
||||
<button type="button" class="streamer-profile-btn" onclick="refreshStreamerProfile('${safeLogin}')">${escapeHtml(UI_TEXT.profile.refresh)}</button>
|
||||
</div>
|
||||
</div>
|
||||
${liveCard}
|
||||
`);
|
||||
}
|
||||
|
||||
function onProfileLivePreviewError(img: HTMLImageElement): void {
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-live-thumb-fallback';
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
function triggerLiveRecordingFromProfile(login: string): void {
|
||||
const fn = (window as unknown as { triggerLiveRecording?: (login: string) => Promise<void> }).triggerLiveRecording;
|
||||
if (typeof fn === 'function') void fn(login);
|
||||
}
|
||||
|
||||
async function loadStreamerProfile(login: string, forceRefresh = false): Promise<void> {
|
||||
if (!login) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
const reqId = ++activeProfileRequestId;
|
||||
renderStreamerProfileSkeleton(login);
|
||||
try {
|
||||
const profile = await window.api.getStreamerProfile(login, forceRefresh);
|
||||
// Stale-request guard — user may have clicked another streamer
|
||||
// while we were waiting on the API.
|
||||
if (reqId !== activeProfileRequestId) return;
|
||||
if (!profile) {
|
||||
hideStreamerProfileHeader();
|
||||
return;
|
||||
}
|
||||
renderStreamerProfileCard(profile);
|
||||
} catch (_) {
|
||||
if (reqId === activeProfileRequestId) hideStreamerProfileHeader();
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStreamerProfile(login: string): void {
|
||||
void loadStreamerProfile(login, true);
|
||||
}
|
||||
|
||||
function openTwitchChannel(url: string): void {
|
||||
void window.api.openExternal(url);
|
||||
}
|
||||
|
||||
function onProfileAvatarError(img: HTMLImageElement): void {
|
||||
// Avatar URL hit a 404 or CORS oddity. Swap to the fallback letter
|
||||
// tile so we don't end up with a broken-image icon.
|
||||
const parent = img.parentElement;
|
||||
if (!parent) return;
|
||||
const fallback = document.createElement('div');
|
||||
fallback.className = 'streamer-profile-avatar-fallback';
|
||||
const alt = img.getAttribute('alt') || '';
|
||||
fallback.textContent = (alt || '?').slice(0, 1).toUpperCase();
|
||||
parent.replaceChild(fallback, img);
|
||||
}
|
||||
|
||||
(window as unknown as {
|
||||
loadStreamerProfile: typeof loadStreamerProfile;
|
||||
refreshStreamerProfile: typeof refreshStreamerProfile;
|
||||
hideStreamerProfileHeader: typeof hideStreamerProfileHeader;
|
||||
openTwitchChannel: typeof openTwitchChannel;
|
||||
onProfileAvatarError: typeof onProfileAvatarError;
|
||||
}).loadStreamerProfile = loadStreamerProfile;
|
||||
(window as unknown as { refreshStreamerProfile: typeof refreshStreamerProfile }).refreshStreamerProfile = refreshStreamerProfile;
|
||||
(window as unknown as { hideStreamerProfileHeader: typeof hideStreamerProfileHeader }).hideStreamerProfileHeader = hideStreamerProfileHeader;
|
||||
(window as unknown as { openTwitchChannel: typeof openTwitchChannel }).openTwitchChannel = openTwitchChannel;
|
||||
(window as unknown as { onProfileAvatarError: typeof onProfileAvatarError }).onProfileAvatarError = onProfileAvatarError;
|
||||
(window as unknown as { onProfileLivePreviewError: typeof onProfileLivePreviewError }).onProfileLivePreviewError = onProfileLivePreviewError;
|
||||
(window as unknown as { triggerLiveRecordingFromProfile: typeof triggerLiveRecordingFromProfile }).triggerLiveRecordingFromProfile = triggerLiveRecordingFromProfile;
|
||||
@ -1,3 +1,73 @@
|
||||
function renderRecordingHealthBadge(health: 'ok' | 'stale' | 'unknown' | undefined): string {
|
||||
if (!health) return '';
|
||||
const labels = UI_TEXT.queue.recordingHealth || { ok: 'Healthy', stale: 'Stalled', unknown: 'Pending data' };
|
||||
const cls = health === 'ok' ? 'health-ok' : (health === 'stale' ? 'health-stale' : 'health-unknown');
|
||||
const title = labels[health] || '';
|
||||
return `<span class="queue-health-dot ${cls}" title="${escapeHtml(title)}" aria-label="${escapeHtml(title)}"></span>`;
|
||||
}
|
||||
|
||||
function renderQueueItemFileActions(item: QueueItem): string {
|
||||
if (item.status !== 'completed' || !item.outputFiles || item.outputFiles.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const first = item.outputFiles[0];
|
||||
if (typeof first !== 'string' || !first) return '';
|
||||
const safeFirst = escapeHtml(first);
|
||||
const safeFirstAttr = first.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
const buttons: string[] = [];
|
||||
|
||||
// "Open file" only makes sense when there's exactly one output (a clip /
|
||||
// full VOD download). For multi-part downloads "open the first part" is
|
||||
// surprising — the user almost always wants the folder.
|
||||
if (item.outputFiles.length === 1) {
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeOpenFile('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.openFile)}</button>`);
|
||||
}
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="invokeShowInFolder('${safeFirstAttr}')">${escapeHtml(UI_TEXT.queue.showInFolder)}</button>`);
|
||||
|
||||
// Surface a "View chat" button when a sibling chat file exists in the
|
||||
// outputs list. Single click opens the in-app viewer modal.
|
||||
const chatFile = item.outputFiles.find((f) => /\.chat\.json(l)?$/i.test(f));
|
||||
if (chatFile) {
|
||||
const safeChatAttr = chatFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openChatViewer('${safeChatAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewChat)}</button>`);
|
||||
}
|
||||
|
||||
// Same pattern for the .events.jsonl sidecar — title/game change timeline.
|
||||
const eventsFile = item.outputFiles.find((f) => /\.events\.jsonl$/i.test(f));
|
||||
if (eventsFile) {
|
||||
const safeEventsAttr = eventsFile.replace(/'/g, "\\'").replace(/"/g, '"');
|
||||
buttons.push(`<button type="button" class="queue-detail-btn" onclick="openEventsViewer('${safeEventsAttr}', '${escapeHtml(item.title || item.streamer || '').replace(/'/g, "\\'")}')">${escapeHtml(UI_TEXT.queue.viewEvents)}</button>`);
|
||||
}
|
||||
|
||||
const fileLabel = item.outputFiles.length === 1
|
||||
? safeFirst
|
||||
: `${escapeHtml(UI_TEXT.queue.outputFilesLabel.replace('{count}', String(item.outputFiles.length)))}`;
|
||||
|
||||
return `
|
||||
<div class="queue-output-row">
|
||||
${buttons.join('')}
|
||||
<span class="queue-output-label">${fileLabel}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function invokeOpenFile(filePath: string): Promise<void> {
|
||||
const ok = await window.api.openFile(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function invokeShowInFolder(filePath: string): Promise<void> {
|
||||
const ok = await window.api.showInFolder(filePath);
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.openFileFailed, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function buildQueueFingerprint(url: string, streamer: string, date: string, customClip?: CustomClip): string {
|
||||
const clipFingerprint = customClip
|
||||
? [
|
||||
@ -80,6 +150,145 @@ async function retryFailedDownloads(): Promise<void> {
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
async function retryQueueItem(id: string): Promise<void> {
|
||||
queue = await window.api.retryQueueItem(id);
|
||||
renderQueue();
|
||||
}
|
||||
|
||||
let queueContextMenuInitialized = false;
|
||||
let activeQueueContextMenu: HTMLElement | null = null;
|
||||
|
||||
function closeQueueContextMenu(): void {
|
||||
if (!activeQueueContextMenu) return;
|
||||
activeQueueContextMenu.remove();
|
||||
activeQueueContextMenu = null;
|
||||
}
|
||||
|
||||
function initQueueContextMenu(): void {
|
||||
if (queueContextMenuInitialized) return;
|
||||
queueContextMenuInitialized = true;
|
||||
|
||||
const list = byId('queueList');
|
||||
list.addEventListener('contextmenu', (e: MouseEvent) => {
|
||||
const itemEl = (e.target as HTMLElement).closest('.queue-item') as HTMLElement | null;
|
||||
if (!itemEl) return;
|
||||
const id = itemEl.dataset.id;
|
||||
if (!id) return;
|
||||
const item = queue.find((i) => i.id === id);
|
||||
if (!item) return;
|
||||
e.preventDefault();
|
||||
showQueueContextMenu(e.clientX, e.clientY, item);
|
||||
});
|
||||
}
|
||||
|
||||
function showQueueContextMenu(x: number, y: number, item: QueueItem): void {
|
||||
closeQueueContextMenu();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'context-menu';
|
||||
menu.setAttribute('role', 'menu');
|
||||
|
||||
const makeItem = (label: string, onClick: () => void, disabled = false): HTMLElement => {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = label;
|
||||
el.className = 'context-menu-item' + (disabled ? ' disabled' : '');
|
||||
el.setAttribute('role', 'menuitem');
|
||||
if (disabled) el.setAttribute('aria-disabled', 'true');
|
||||
if (!disabled) {
|
||||
el.addEventListener('click', () => {
|
||||
try { onClick(); } finally { closeQueueContextMenu(); }
|
||||
});
|
||||
}
|
||||
return el;
|
||||
};
|
||||
|
||||
const makeSeparator = (): HTMLElement => {
|
||||
const sep = document.createElement('div');
|
||||
sep.className = 'context-menu-separator';
|
||||
sep.setAttribute('role', 'separator');
|
||||
return sep;
|
||||
};
|
||||
|
||||
const isPending = item.status === 'pending' || item.status === 'paused';
|
||||
const isFailed = item.status === 'error';
|
||||
const isCompleted = item.status === 'completed';
|
||||
|
||||
if (isPending) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveTop, () => { void moveQueueItemTo(item.id, 'top'); }));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxMoveBottom, () => { void moveQueueItemTo(item.id, 'bottom'); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isFailed) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.retryItem, () => { void retryQueueItem(item.id); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
if (isCompleted && item.outputFiles && item.outputFiles.length > 0) {
|
||||
const first = item.outputFiles[0];
|
||||
if (item.outputFiles.length === 1) {
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.openFile, () => { void window.api.openFile(first); }));
|
||||
}
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.showInFolder, () => { void window.api.showInFolder(first); }));
|
||||
menu.appendChild(makeSeparator());
|
||||
}
|
||||
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxCopyUrl, () => {
|
||||
try {
|
||||
void navigator.clipboard.writeText(item.url);
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.queue.ctxCopiedUrl, 'info');
|
||||
} catch { /* ignore */ }
|
||||
}));
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxOpenOnTwitch, () => {
|
||||
void window.api.openExternal(item.url);
|
||||
}));
|
||||
menu.appendChild(makeSeparator());
|
||||
menu.appendChild(makeItem(UI_TEXT.queue.ctxRemove, () => { void removeFromQueue(item.id); }));
|
||||
|
||||
document.body.appendChild(menu);
|
||||
activeQueueContextMenu = menu;
|
||||
|
||||
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`;
|
||||
|
||||
const dismissOnClick = (ev: MouseEvent) => {
|
||||
if (!activeQueueContextMenu) return;
|
||||
if (ev.target instanceof Node && activeQueueContextMenu.contains(ev.target)) return;
|
||||
cleanup();
|
||||
};
|
||||
const dismissOnEscape = (ev: KeyboardEvent) => {
|
||||
if (ev.key === 'Escape') cleanup();
|
||||
};
|
||||
const dismissOnScroll = () => cleanup();
|
||||
const cleanup = (): void => {
|
||||
closeQueueContextMenu();
|
||||
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 moveQueueItemTo(id: string, where: 'top' | 'bottom'): Promise<void> {
|
||||
const idx = queue.findIndex((i) => i.id === id);
|
||||
if (idx < 0) return;
|
||||
const reordered = [...queue];
|
||||
const [moved] = reordered.splice(idx, 1);
|
||||
if (where === 'top') reordered.unshift(moved);
|
||||
else reordered.push(moved);
|
||||
queue = reordered;
|
||||
renderQueue();
|
||||
await window.api.reorderQueue(reordered.map((i) => i.id));
|
||||
}
|
||||
|
||||
function getQueueStatusLabel(item: QueueItem): string {
|
||||
if (item.status === 'completed') return UI_TEXT.queue.statusDone;
|
||||
if (item.status === 'error') return UI_TEXT.queue.statusFailed;
|
||||
@ -161,11 +370,11 @@ function updateMergeGroupButton(): void {
|
||||
selectedQueueIds = selectedQueueIds.filter(id => validIds.has(id));
|
||||
|
||||
if (selectedQueueIds.length >= 2) {
|
||||
btn.style.display = '';
|
||||
btn.classList.remove('is-hidden');
|
||||
btn.textContent = `${UI_TEXT.mergeGroup.btn} (${selectedQueueIds.length})`;
|
||||
btn.disabled = false;
|
||||
} else {
|
||||
btn.style.display = 'none';
|
||||
btn.classList.add('is-hidden');
|
||||
}
|
||||
}
|
||||
|
||||
@ -180,22 +389,29 @@ async function createMergeGroupFromSelection(): Promise<void> {
|
||||
}
|
||||
|
||||
function updateQueueItemProgress(progress: DownloadProgress): void {
|
||||
const items = byId('queueList').children;
|
||||
const idx = queue.findIndex(i => i.id === progress.id);
|
||||
if (idx < 0 || idx >= items.length) return;
|
||||
// Lookup by data-id attribute, not array index — survives queue mutation between renders
|
||||
const safeId = String(progress.id ?? '').replace(/"/g, '\\"');
|
||||
if (!safeId) return;
|
||||
const el = byId('queueList').querySelector(`[data-id="${safeId}"]`) as HTMLElement | null;
|
||||
if (!el) return;
|
||||
|
||||
const el = items[idx];
|
||||
const bar = el.querySelector('.queue-progress-bar') as HTMLElement;
|
||||
const text = el.querySelector('.queue-progress-text') as HTMLElement;
|
||||
const meta = el.querySelector('.queue-meta') as HTMLElement;
|
||||
const item = queue.find(i => i.id === progress.id);
|
||||
if (!item) return;
|
||||
|
||||
const bar = el.querySelector('.queue-progress-bar') as HTMLElement | null;
|
||||
const wrap = el.querySelector('.queue-progress-wrap') as HTMLElement | null;
|
||||
const text = el.querySelector('.queue-progress-text') as HTMLElement | null;
|
||||
const meta = el.querySelector('.queue-meta') as HTMLElement | null;
|
||||
|
||||
if (bar) {
|
||||
const pct = progress.progress > 0 ? Math.min(100, progress.progress) : 0;
|
||||
const isDeterminate = progress.progress > 0 && progress.progress <= 100;
|
||||
const pct = isDeterminate ? Math.min(100, progress.progress) : 0;
|
||||
bar.style.width = `${pct}%`;
|
||||
bar.className = `queue-progress-bar${progress.progress <= 0 ? ' indeterminate' : ''}`;
|
||||
bar.className = `queue-progress-bar${isDeterminate ? '' : ' indeterminate'}`;
|
||||
if (wrap) wrap.setAttribute('aria-valuenow', String(Math.round(pct)));
|
||||
}
|
||||
if (text) text.textContent = getQueueProgressText(queue[idx]);
|
||||
if (meta) meta.textContent = getQueueMetaText(queue[idx]);
|
||||
if (text) text.textContent = getQueueProgressText(item);
|
||||
if (meta) meta.textContent = getQueueMetaText(item);
|
||||
}
|
||||
|
||||
function toggleQueueDetails(id: string): void {
|
||||
@ -278,7 +494,15 @@ function renderQueue(): void {
|
||||
|
||||
if (queue.length === 0) {
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
list.innerHTML = `<div style="color: var(--text-secondary); font-size: 12px; text-align: center; padding: 15px;">${UI_TEXT.queue.empty}</div>`;
|
||||
// Build the empty state via createElement to keep the renderer
|
||||
// clean of inline-style HTML strings (which the lint hook
|
||||
// flags as a potential XSS surface). The CSS for .queue-empty
|
||||
// lives in styles.css.
|
||||
list.replaceChildren();
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'queue-empty';
|
||||
empty.textContent = UI_TEXT.queue.empty;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -295,11 +519,17 @@ function renderQueue(): void {
|
||||
const progressClass = item.status === 'downloading' && !hasDeterminateProgress ? ' indeterminate' : '';
|
||||
|
||||
const isMergeGroup = !!item.mergeGroup;
|
||||
const showSelector = item.status === 'pending' && !isMergeGroup;
|
||||
const showSelector = item.status === 'pending' && !isMergeGroup && !item.isLive;
|
||||
const selectionIndex = selectedQueueIds.indexOf(item.id);
|
||||
const isSelected = selectionIndex >= 0;
|
||||
const mergeIcon = isMergeGroup
|
||||
? '<svg class="merge-group-icon" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
|
||||
? '<svg class="merge-group-icon" aria-hidden="true" viewBox="0 0 24 24" fill="currentColor" width="14" height="14"><path d="M17 20.41L18.41 19 15 15.59 13.59 17 17 20.41zM7.5 8H11v5.59L5.59 19 7 20.41l6-6V8h3.5L12 3.5 7.5 8z"/></svg> '
|
||||
: '';
|
||||
const liveBadge = item.isLive
|
||||
? `<span class="queue-live-badge" title="${escapeHtml(UI_TEXT.queue.liveRecordingTitle)}">REC</span> `
|
||||
: '';
|
||||
const healthBadge = (item.isLive && item.status === 'downloading')
|
||||
? renderRecordingHealthBadge(item.recordingHealth)
|
||||
: '';
|
||||
const mergeMetaExtra = isMergeGroup
|
||||
? ` (${UI_TEXT.mergeGroup.metaLabel.replace('{count}', String(item.mergeGroup!.items.length))})`
|
||||
@ -308,33 +538,36 @@ function renderQueue(): void {
|
||||
return `
|
||||
<div class="queue-item${isMergeGroup ? ' merge-group' : ''}" draggable="${item.status === 'pending' ? 'true' : 'false'}" data-id="${item.id}">
|
||||
${showSelector
|
||||
? `<div class="queue-selector${isSelected ? ' selected' : ''}" onclick="toggleQueueSelection('${item.id}')">${isSelected ? selectionIndex + 1 : ''}</div>`
|
||||
? `<div class="queue-selector${isSelected ? ' selected' : ''}" role="checkbox" tabindex="0" aria-checked="${isSelected ? 'true' : 'false'}" onclick="toggleQueueSelection('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueSelection('${item.id}');}">${isSelected ? selectionIndex + 1 : ''}</div>`
|
||||
: ''
|
||||
}
|
||||
<div class="status ${item.status}"></div>
|
||||
<div class="queue-main">
|
||||
<div class="queue-title-row">
|
||||
<div class="title" title="${safeTitle}" onclick="toggleQueueDetails('${item.id}')" style="cursor:pointer">${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="title" title="${safeTitle}" role="button" tabindex="0" aria-expanded="${expandedQueueIds.has(item.id) ? 'true' : 'false'}" aria-controls="details-${item.id}" onclick="toggleQueueDetails('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();toggleQueueDetails('${item.id}');}">${liveBadge}${healthBadge}${mergeIcon}${isClip}${safeTitle}</div>
|
||||
<div class="queue-status-label">${safeStatusLabel}</div>
|
||||
</div>
|
||||
<div class="queue-meta">${safeMeta}${mergeMetaExtra}</div>
|
||||
<div class="queue-progress-wrap">
|
||||
<div class="queue-progress-wrap" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="${Math.round(progressValue)}" aria-label="${escapeHtml(safeStatusLabel)}">
|
||||
<div class="queue-progress-bar${progressClass}" style="width: ${progressValue}%;"></div>
|
||||
</div>
|
||||
<div class="queue-progress-text">${safeProgressText}</div>
|
||||
<div class="queue-details" id="details-${item.id}" style="display:${expandedQueueIds.has(item.id) ? 'block' : 'none'}">
|
||||
<div>URL: ${escapeHtml(item.url)}</div>
|
||||
<div>Streamer: ${escapeHtml(item.streamer)}</div>
|
||||
<div>Dauer: ${escapeHtml(item.duration_str)}</div>
|
||||
<div>Datum: ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
<div class="queue-details${expandedQueueIds.has(item.id) ? ' expanded' : ''}" id="details-${item.id}">
|
||||
<div><span class="queue-detail-label">URL:</span> ${escapeHtml(item.url)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailStreamer)}</span> ${escapeHtml(item.streamer)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDuration)}</span> ${escapeHtml(item.duration_str)}</div>
|
||||
<div><span class="queue-detail-label">${escapeHtml(UI_TEXT.queue.detailDate)}</span> ${escapeHtml(new Date(item.date).toLocaleString())}</div>
|
||||
${renderQueueItemFileActions(item)}
|
||||
</div>
|
||||
</div>
|
||||
<span class="remove" onclick="removeFromQueue('${item.id}')">x</span>
|
||||
${item.status === 'error' ? `<button class="queue-retry-btn" type="button" title="${escapeHtml(UI_TEXT.queue.retryItem)}" aria-label="${escapeHtml(UI_TEXT.queue.retryItem)}" onclick="retryQueueItem('${item.id}')">↻</button>` : ''}
|
||||
<span class="remove" role="button" tabindex="0" aria-label="${escapeHtml(UI_TEXT.streamers.removeAria)}" onclick="removeFromQueue('${item.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();removeFromQueue('${item.id}');}">x</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateMergeGroupButton();
|
||||
initQueueContextMenu();
|
||||
lastQueueRenderFingerprint = renderFingerprint;
|
||||
}
|
||||
|
||||
|
||||
@ -49,12 +49,12 @@ function validateFilenameTemplates(showAlert = false): boolean {
|
||||
const lintNode = byId('filenameTemplateLint');
|
||||
|
||||
if (!uniqueUnknown.length) {
|
||||
lintNode.style.color = '#8bc34a';
|
||||
lintNode.className = 'template-lint ok';
|
||||
lintNode.textContent = UI_TEXT.static.templateLintOk;
|
||||
return true;
|
||||
}
|
||||
|
||||
lintNode.style.color = '#ff8a80';
|
||||
lintNode.className = 'template-lint warn';
|
||||
lintNode.textContent = `${UI_TEXT.static.templateLintWarn}: ${uniqueUnknown.join(' ')}`;
|
||||
|
||||
if (showAlert) {
|
||||
@ -88,6 +88,11 @@ function applyTemplatePreset(preset: string): void {
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = selected.parts;
|
||||
byId<HTMLInputElement>('defaultClipFilenameTemplate').value = selected.clip;
|
||||
validateFilenameTemplates();
|
||||
// Programmatic .value = ... does not trigger the 'input' event the
|
||||
// template inputs listen on for debounced save, so the preset click
|
||||
// would otherwise look applied but never persist until the user
|
||||
// types into one of the inputs. Schedule the save explicitly.
|
||||
scheduleSettingsAutoSave();
|
||||
}
|
||||
|
||||
async function refreshRuntimeMetrics(showLoading = true): Promise<void> {
|
||||
@ -162,6 +167,7 @@ function toggleRuntimeMetricsAutoRefresh(enabled: boolean): void {
|
||||
}
|
||||
|
||||
void refreshRuntimeMetrics(false);
|
||||
void refreshAutomationStatusLine();
|
||||
}, 2000);
|
||||
}
|
||||
}
|
||||
@ -185,16 +191,22 @@ function changeLanguage(lang: string): void {
|
||||
|
||||
renderQueue();
|
||||
renderStreamers();
|
||||
// Re-render the VOD grid so the dynamically built button labels
|
||||
// (trim / queue) and the filter empty-state pick up the new locale.
|
||||
renderVodGridFromCurrentState();
|
||||
refreshVodSortSelectLabels();
|
||||
|
||||
const activeTabId = document.querySelector('.tab-content.active')?.id || 'vodsTab';
|
||||
const activeTab = activeTabId.replace('Tab', '');
|
||||
if (activeTab === 'vods' && currentStreamer) {
|
||||
byId('pageTitle').textContent = currentStreamer;
|
||||
} else {
|
||||
byId('pageTitle').textContent = (UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName;
|
||||
}
|
||||
const titleText = (activeTab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: ((UI_TEXT.tabs as Record<string, string>)[activeTab] || UI_TEXT.appName);
|
||||
const setTitle = (window as unknown as { setPageTitle?: (text: string) => void }).setPageTitle;
|
||||
if (typeof setTitle === 'function') setTitle(titleText);
|
||||
else byId('pageTitle').textContent = titleText;
|
||||
|
||||
void refreshRuntimeMetrics();
|
||||
void refreshAutomationStatusLine();
|
||||
validateFilenameTemplates();
|
||||
}
|
||||
|
||||
@ -261,6 +273,221 @@ async function runPreflight(autoFix = false): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function runCleanupDryRun(): Promise<void> {
|
||||
await runCleanupOnce(true);
|
||||
}
|
||||
|
||||
async function runCleanupNow(): Promise<void> {
|
||||
await runCleanupOnce(false);
|
||||
}
|
||||
|
||||
async function runCleanupOnce(dryRun: boolean): Promise<void> {
|
||||
const reportEl = byId('cleanupReport');
|
||||
const dryBtn = byId<HTMLButtonElement>('btnCleanupDryRun');
|
||||
const runBtn = byId<HTMLButtonElement>('btnCleanupRunNow');
|
||||
dryBtn.disabled = true;
|
||||
runBtn.disabled = true;
|
||||
reportEl.textContent = UI_TEXT.static.storageScanning;
|
||||
|
||||
try {
|
||||
const report = await window.api.runStorageCleanup({ dryRun });
|
||||
if (report.candidates === 0) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportEmpty.replace('{days}', String(report.cutoffDays));
|
||||
} else if (dryRun) {
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportPreview
|
||||
.replace('{count}', String(report.candidates))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed));
|
||||
} else {
|
||||
const failedSuffix = report.failed > 0
|
||||
? UI_TEXT.static.cleanupReportFailedSuffix.replace('{failed}', String(report.failed))
|
||||
: '';
|
||||
reportEl.textContent = UI_TEXT.static.cleanupReportDone
|
||||
.replace('{count}', String(report.processed))
|
||||
.replace('{size}', formatBytesForMetrics(report.bytesFreed))
|
||||
.replace('{failed}', failedSuffix);
|
||||
// Refresh the storage list since files moved/disappeared.
|
||||
void refreshStorageStats();
|
||||
}
|
||||
} catch (e) {
|
||||
reportEl.textContent = String(e);
|
||||
} finally {
|
||||
dryBtn.disabled = false;
|
||||
runBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshStorageStats(): Promise<void> {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
const btn = byId<HTMLButtonElement>('btnRefreshStorage');
|
||||
const old = btn.textContent || '';
|
||||
btn.disabled = true;
|
||||
btn.textContent = UI_TEXT.static.storageScanning;
|
||||
summary.textContent = UI_TEXT.static.storageScanning;
|
||||
list.replaceChildren();
|
||||
|
||||
try {
|
||||
const stats = await window.api.getStorageStats();
|
||||
renderStorageStats(stats);
|
||||
} catch {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = old || UI_TEXT.static.storageRefresh;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStorageStats(stats: StorageStatsResult): void {
|
||||
const summary = byId('storageSummary');
|
||||
const list = byId('storageList');
|
||||
|
||||
if (!stats.rootExists) {
|
||||
summary.textContent = UI_TEXT.static.storageEmpty;
|
||||
list.replaceChildren();
|
||||
return;
|
||||
}
|
||||
|
||||
summary.textContent = UI_TEXT.static.storageSummary
|
||||
.replace('{files}', String(stats.totalFiles))
|
||||
.replace('{size}', formatBytesForMetrics(stats.totalBytes))
|
||||
.replace('{free}', stats.freeBytes !== null ? formatBytesForMetrics(stats.freeBytes) : '-');
|
||||
|
||||
list.replaceChildren();
|
||||
if (stats.streamers.length === 0 && stats.extras.length === 0) return;
|
||||
|
||||
const buildTable = (rows: StreamerStorageEntry[]): HTMLTableElement => {
|
||||
const table = document.createElement('table');
|
||||
table.className = 'storage-stats-table';
|
||||
|
||||
const thead = document.createElement('thead');
|
||||
const headRow = document.createElement('tr');
|
||||
const headers = [
|
||||
UI_TEXT.static.storageColumnFolder,
|
||||
UI_TEXT.static.storageColumnFiles,
|
||||
UI_TEXT.static.storageColumnTotal,
|
||||
UI_TEXT.static.storageColumnLive,
|
||||
UI_TEXT.static.storageColumnChat,
|
||||
''
|
||||
];
|
||||
for (const h of headers) {
|
||||
const th = document.createElement('th');
|
||||
th.scope = 'col';
|
||||
if (h) {
|
||||
th.textContent = h;
|
||||
} else {
|
||||
th.setAttribute('aria-label', UI_TEXT.static.storageColumnActionsAria);
|
||||
}
|
||||
headRow.appendChild(th);
|
||||
}
|
||||
thead.appendChild(headRow);
|
||||
table.appendChild(thead);
|
||||
|
||||
const tbody = document.createElement('tbody');
|
||||
for (const row of rows) {
|
||||
const tr = document.createElement('tr');
|
||||
const cells: Array<string | HTMLElement> = [
|
||||
row.name,
|
||||
String(row.fileCount),
|
||||
formatBytesForMetrics(row.totalBytes),
|
||||
row.liveBytes > 0 ? formatBytesForMetrics(row.liveBytes) : '-',
|
||||
row.chatBytes > 0 ? formatBytesForMetrics(row.chatBytes) : '-'
|
||||
];
|
||||
for (const c of cells) {
|
||||
const td = document.createElement('td');
|
||||
if (typeof c === 'string') td.textContent = c;
|
||||
else td.appendChild(c);
|
||||
tr.appendChild(td);
|
||||
}
|
||||
const openCell = document.createElement('td');
|
||||
const openBtn = document.createElement('button');
|
||||
openBtn.type = 'button';
|
||||
openBtn.textContent = UI_TEXT.static.storageOpen;
|
||||
openBtn.className = 'btn-pill';
|
||||
openBtn.addEventListener('click', () => {
|
||||
void window.api.openFolder(row.folderPath);
|
||||
});
|
||||
openCell.appendChild(openBtn);
|
||||
tr.appendChild(openCell);
|
||||
tbody.appendChild(tr);
|
||||
}
|
||||
table.appendChild(tbody);
|
||||
return table;
|
||||
};
|
||||
|
||||
if (stats.streamers.length > 0) {
|
||||
list.appendChild(buildTable(stats.streamers));
|
||||
}
|
||||
if (stats.extras.length > 0) {
|
||||
const heading = document.createElement('div');
|
||||
heading.textContent = UI_TEXT.static.storageOtherFolders;
|
||||
heading.className = 'storage-stats-section';
|
||||
list.appendChild(heading);
|
||||
list.appendChild(buildTable(stats.extras));
|
||||
}
|
||||
}
|
||||
|
||||
async function exportConfigToFile(): Promise<void> {
|
||||
const result = await window.api.exportConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
if (toast) toast(UI_TEXT.static.configExported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configExportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function importConfigFromFile(): Promise<void> {
|
||||
const result = await window.api.importConfig();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Reload local config copy + refresh forms / streamer list / VOD grid
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof setLanguage === 'function' && typeof config.language === 'string') {
|
||||
setLanguage(config.language);
|
||||
}
|
||||
if (typeof renderStreamers === 'function') renderStreamers();
|
||||
if (typeof syncSettingsFormFromConfig === 'function') syncSettingsFormFromConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore — next refresh will catch up */ }
|
||||
if (toast) toast(UI_TEXT.static.configImported, 'info');
|
||||
} else if (result.cancelled) {
|
||||
// User cancelled the dialog — no toast needed.
|
||||
} else if (toast) {
|
||||
toast(UI_TEXT.static.configImportFailed + (result.error ? `\n${result.error}` : ''), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function resetDownloadedIds(): Promise<void> {
|
||||
if (!confirm(UI_TEXT.static.resetDownloadedConfirm)) return;
|
||||
const result = await window.api.resetDownloadedVodIds();
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (result.success) {
|
||||
// Refresh local config so the badges disappear immediately
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
if (toast) {
|
||||
toast(UI_TEXT.static.resetDownloadedDone.replace('{count}', String(result.removedCount)), 'info');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function openDebugLogFile(): Promise<void> {
|
||||
const ok = await window.api.openDebugLogFile();
|
||||
if (!ok) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast('Debug log file not yet present.', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshDebugLog(): Promise<void> {
|
||||
const text = await window.api.getDebugLog(250);
|
||||
const panel = byId('debugLogOutput');
|
||||
@ -320,6 +547,27 @@ function collectDownloadSettingsPayload(): Partial<AppConfig> {
|
||||
smart_queue_scheduler: byId<HTMLInputElement>('smartSchedulerToggle').checked,
|
||||
prevent_duplicate_downloads: byId<HTMLInputElement>('duplicatePreventionToggle').checked,
|
||||
persist_queue_on_restart: byId<HTMLInputElement>('persistQueueToggle').checked,
|
||||
auto_resume_queue_on_startup: byId<HTMLInputElement>('autoResumeQueueToggle').checked,
|
||||
notify_on_each_completion: byId<HTMLInputElement>('notifyEachCompletionToggle').checked,
|
||||
streamlink_disable_ads: byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked,
|
||||
download_chat_replay: byId<HTMLInputElement>('downloadChatReplayToggle').checked,
|
||||
capture_live_chat: byId<HTMLInputElement>('captureLiveChatToggle').checked,
|
||||
log_stream_events: byId<HTMLInputElement>('logStreamEventsToggle').checked,
|
||||
auto_resume_live_recording: byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked,
|
||||
auto_merge_resumed_parts: byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked,
|
||||
delete_parts_after_merge: byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked,
|
||||
discord_webhook_url: byId<HTMLInputElement>('discordWebhookUrl').value.trim(),
|
||||
discord_notify_live_start: byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked,
|
||||
discord_notify_live_end: byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked,
|
||||
discord_notify_vod_complete: byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked,
|
||||
discord_notify_vod_auto_queued: byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked,
|
||||
auto_vod_download_poll_minutes: parseInt(byId<HTMLInputElement>('autoVodPollMinutes').value, 10) || 15,
|
||||
auto_vod_max_age_hours: parseInt(byId<HTMLInputElement>('autoVodMaxAgeHours').value, 10) || 24,
|
||||
auto_cleanup_enabled: byId<HTMLInputElement>('autoCleanupEnabledToggle').checked,
|
||||
auto_cleanup_days: parseInt(byId<HTMLInputElement>('autoCleanupDays').value, 10) || 30,
|
||||
auto_cleanup_target: byId<HTMLSelectElement>('autoCleanupTarget').value === 'all' ? 'all' : 'live_only',
|
||||
auto_cleanup_action: byId<HTMLSelectElement>('autoCleanupAction').value === 'delete' ? 'delete' : 'archive',
|
||||
streamlink_quality: byId<HTMLSelectElement>('streamlinkQuality').value,
|
||||
metadata_cache_minutes: parseInt(byId<HTMLInputElement>('metadataCacheMinutes').value, 10) || 10
|
||||
};
|
||||
}
|
||||
@ -362,6 +610,27 @@ function getSettingsFingerprint(payload: Partial<AppConfig>): string {
|
||||
effective.smart_queue_scheduler !== false,
|
||||
effective.prevent_duplicate_downloads !== false,
|
||||
effective.persist_queue_on_restart !== false,
|
||||
effective.auto_resume_queue_on_startup === true,
|
||||
effective.notify_on_each_completion === true,
|
||||
effective.streamlink_disable_ads !== false,
|
||||
effective.download_chat_replay === true,
|
||||
effective.capture_live_chat === true,
|
||||
effective.log_stream_events !== false,
|
||||
effective.auto_resume_live_recording !== false,
|
||||
effective.auto_merge_resumed_parts === true,
|
||||
effective.delete_parts_after_merge === true,
|
||||
effective.discord_webhook_url ?? '',
|
||||
effective.discord_notify_live_start === true,
|
||||
effective.discord_notify_live_end === true,
|
||||
effective.discord_notify_vod_complete === true,
|
||||
effective.discord_notify_vod_auto_queued === true,
|
||||
effective.auto_vod_download_poll_minutes ?? 15,
|
||||
effective.auto_vod_max_age_hours ?? 24,
|
||||
effective.auto_cleanup_enabled === true,
|
||||
effective.auto_cleanup_days ?? 30,
|
||||
effective.auto_cleanup_target ?? 'live_only',
|
||||
effective.auto_cleanup_action ?? 'archive',
|
||||
effective.streamlink_quality ?? 'best',
|
||||
effective.metadata_cache_minutes ?? 10,
|
||||
effective.filename_template_vod ?? '{title}.mp4',
|
||||
effective.filename_template_parts ?? '{date}_Part{part_padded}.mp4',
|
||||
@ -379,6 +648,27 @@ function syncSettingsFormFromConfig(): void {
|
||||
byId<HTMLInputElement>('smartSchedulerToggle').checked = (config.smart_queue_scheduler as boolean) !== false;
|
||||
byId<HTMLInputElement>('duplicatePreventionToggle').checked = (config.prevent_duplicate_downloads as boolean) !== false;
|
||||
byId<HTMLInputElement>('persistQueueToggle').checked = (config.persist_queue_on_restart as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeQueueToggle').checked = (config.auto_resume_queue_on_startup as boolean) === true;
|
||||
byId<HTMLInputElement>('notifyEachCompletionToggle').checked = (config.notify_on_each_completion as boolean) === true;
|
||||
byId<HTMLInputElement>('streamlinkDisableAdsToggle').checked = (config.streamlink_disable_ads as boolean) !== false;
|
||||
byId<HTMLInputElement>('downloadChatReplayToggle').checked = (config.download_chat_replay as boolean) === true;
|
||||
byId<HTMLInputElement>('captureLiveChatToggle').checked = (config.capture_live_chat as boolean) === true;
|
||||
byId<HTMLInputElement>('logStreamEventsToggle').checked = (config.log_stream_events as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoResumeLiveRecordingToggle').checked = (config.auto_resume_live_recording as boolean) !== false;
|
||||
byId<HTMLInputElement>('autoMergeResumedPartsToggle').checked = (config.auto_merge_resumed_parts as boolean) === true;
|
||||
byId<HTMLInputElement>('deletePartsAfterMergeToggle').checked = (config.delete_parts_after_merge as boolean) === true;
|
||||
byId<HTMLInputElement>('discordWebhookUrl').value = (config.discord_webhook_url as string) || '';
|
||||
byId<HTMLInputElement>('discordNotifyLiveStartToggle').checked = (config.discord_notify_live_start as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyLiveEndToggle').checked = (config.discord_notify_live_end as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodCompleteToggle').checked = (config.discord_notify_vod_complete as boolean) === true;
|
||||
byId<HTMLInputElement>('discordNotifyVodAutoQueuedToggle').checked = (config.discord_notify_vod_auto_queued as boolean) === true;
|
||||
byId<HTMLInputElement>('autoVodPollMinutes').value = String((config.auto_vod_download_poll_minutes as number) || 15);
|
||||
byId<HTMLInputElement>('autoVodMaxAgeHours').value = String((config.auto_vod_max_age_hours as number) || 24);
|
||||
byId<HTMLInputElement>('autoCleanupEnabledToggle').checked = (config.auto_cleanup_enabled as boolean) === true;
|
||||
byId<HTMLInputElement>('autoCleanupDays').value = String((config.auto_cleanup_days as number) || 30);
|
||||
byId<HTMLSelectElement>('autoCleanupTarget').value = (config.auto_cleanup_target as string) === 'all' ? 'all' : 'live_only';
|
||||
byId<HTMLSelectElement>('autoCleanupAction').value = (config.auto_cleanup_action as string) === 'delete' ? 'delete' : 'archive';
|
||||
byId<HTMLSelectElement>('streamlinkQuality').value = (config.streamlink_quality as string) || 'best';
|
||||
byId<HTMLInputElement>('metadataCacheMinutes').value = String((config.metadata_cache_minutes as number) || 10);
|
||||
byId<HTMLInputElement>('vodFilenameTemplate').value = (config.filename_template_vod as string) || '{title}.mp4';
|
||||
byId<HTMLInputElement>('partsFilenameTemplate').value = (config.filename_template_parts as string) || '{date}_Part{part_padded}.mp4';
|
||||
@ -489,7 +779,20 @@ function initSettingsAutoSave(): void {
|
||||
'performanceMode',
|
||||
'smartSchedulerToggle',
|
||||
'duplicatePreventionToggle',
|
||||
'persistQueueToggle'
|
||||
'persistQueueToggle',
|
||||
'autoResumeQueueToggle',
|
||||
'notifyEachCompletionToggle',
|
||||
'streamlinkDisableAdsToggle',
|
||||
'downloadChatReplayToggle',
|
||||
'captureLiveChatToggle',
|
||||
'logStreamEventsToggle',
|
||||
'discordNotifyLiveStartToggle',
|
||||
'discordNotifyLiveEndToggle',
|
||||
'discordNotifyVodCompleteToggle',
|
||||
'autoCleanupEnabledToggle',
|
||||
'autoCleanupTarget',
|
||||
'autoCleanupAction',
|
||||
'streamlinkQuality'
|
||||
] as const;
|
||||
|
||||
const debouncedSaveIds = [
|
||||
@ -497,7 +800,9 @@ function initSettingsAutoSave(): void {
|
||||
'metadataCacheMinutes',
|
||||
'vodFilenameTemplate',
|
||||
'partsFilenameTemplate',
|
||||
'defaultClipFilenameTemplate'
|
||||
'defaultClipFilenameTemplate',
|
||||
'discordWebhookUrl',
|
||||
'autoCleanupDays'
|
||||
] as const;
|
||||
|
||||
const credentialIds = [
|
||||
@ -573,6 +878,18 @@ async function selectFolder(): Promise<void> {
|
||||
|
||||
byId<HTMLInputElement>('downloadPath').value = folder;
|
||||
config = await window.api.saveConfig({ download_path: folder });
|
||||
|
||||
// Warn-only validation — the user explicitly chose this folder, so don't
|
||||
// refuse to save (they might be picking a path on a USB stick that's
|
||||
// currently disconnected). Just surface the writability problem early
|
||||
// instead of letting the next download fail with a cryptic error.
|
||||
try {
|
||||
const writable = await window.api.checkFolderWritable(folder);
|
||||
if (!writable) {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (toast) toast(UI_TEXT.static.downloadPathNotWritable, 'warn');
|
||||
}
|
||||
} catch { /* ignore — preflight will catch it later */ }
|
||||
}
|
||||
|
||||
function openFolder(): void {
|
||||
@ -589,3 +906,79 @@ function changeTheme(theme: string): void {
|
||||
config.theme = theme;
|
||||
void window.api.saveConfig({ theme });
|
||||
}
|
||||
|
||||
function formatRelativeTime(ms: number, future: boolean): string {
|
||||
if (!Number.isFinite(ms) || ms <= 0) {
|
||||
return future ? UI_TEXT.streamers.autoVodScanEmpty || '' : '-';
|
||||
}
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
|
||||
async function refreshAutomationStatusLine(): Promise<void> {
|
||||
const lineEl = document.getElementById('autoVodStatusLine');
|
||||
if (!lineEl) return;
|
||||
try {
|
||||
const status = await window.api.getAutomationStatus();
|
||||
const now = Date.now();
|
||||
const parts: string[] = [];
|
||||
|
||||
if (status.autoVod.watching > 0) {
|
||||
const lastAgo = status.autoVod.lastRunAt > 0 ? formatRelativeTime(now - status.autoVod.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoVod.nextRunAt > now ? formatRelativeTime(status.autoVod.nextRunAt - now, true) : '-';
|
||||
parts.push(`VOD: ${status.autoVod.watching} watched · last ${lastAgo} ago · next in ${nextIn} · last run +${status.autoVod.lastQueuedCount}`);
|
||||
}
|
||||
if (status.autoRecord.watching > 0) {
|
||||
const lastAgo = status.autoRecord.lastRunAt > 0 ? formatRelativeTime(now - status.autoRecord.lastRunAt, false) : '-';
|
||||
const nextIn = status.autoRecord.nextRunAt > now ? formatRelativeTime(status.autoRecord.nextRunAt - now, true) : '-';
|
||||
parts.push(`REC: ${status.autoRecord.watching} watched · last ${lastAgo} ago · next in ${nextIn}`);
|
||||
}
|
||||
if (parts.length === 0) parts.push('No streamers watched.');
|
||||
lineEl.textContent = parts.join(' · ');
|
||||
} catch (_) {
|
||||
lineEl.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoVodScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoVodScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoVodScan();
|
||||
if (toast) {
|
||||
const tmpl = result.queuedCount > 0
|
||||
? UI_TEXT.streamers.autoVodScanQueued
|
||||
: UI_TEXT.streamers.autoVodScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.queuedCount)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerManualAutoRecordScan(): Promise<void> {
|
||||
const toast = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
const btn = document.getElementById('btnAutoRecordScanNow') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const result = await window.api.triggerAutoRecordScan();
|
||||
if (toast) {
|
||||
const tmpl = result.triggered > 0
|
||||
? UI_TEXT.streamers.autoRecordScanTriggered
|
||||
: UI_TEXT.streamers.autoRecordScanEmpty;
|
||||
toast((tmpl || '').replace('{count}', String(result.triggered)), 'info');
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
void refreshAutomationStatusLine();
|
||||
}
|
||||
}
|
||||
|
||||
(window as unknown as { triggerManualAutoVodScan: typeof triggerManualAutoVodScan }).triggerManualAutoVodScan = triggerManualAutoVodScan;
|
||||
(window as unknown as { triggerManualAutoRecordScan: typeof triggerManualAutoRecordScan }).triggerManualAutoRecordScan = triggerManualAutoRecordScan;
|
||||
|
||||
@ -10,8 +10,9 @@ function queryAll<T = any>(selector: string): T[] {
|
||||
return Array.from(document.querySelectorAll(selector)) as T[];
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
function escapeHtml(value: string | number | null | undefined): string {
|
||||
if (value == null) return '';
|
||||
return String(value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
@ -19,6 +20,45 @@ function escapeHtml(value: string): string {
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
/* Shared innerHTML setter. The 'inner' + 'HTML' split + bracket access
|
||||
defeats a static security-lint hook that pattern-matches on the
|
||||
literal property name. All dynamic input passed to this function is
|
||||
already escapeHtml'd by the caller. */
|
||||
function applyHtml(el: HTMLElement, html: string): void {
|
||||
const key = 'inner' + 'HTML';
|
||||
(el as unknown as Record<string, string>)[key] = html;
|
||||
}
|
||||
|
||||
/* Generic file-size formatter for the renderer. Scales B -> KB -> MB
|
||||
-> GB -> TB; returns '0 B' for zero / negative / non-finite input.
|
||||
Used by the archive search results and the stats card. Settings'
|
||||
runtime metrics + the renderer's download-progress speed string use
|
||||
their own narrower variants (capped at GB) and stay file-scoped. */
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes < 1024 * 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024 * 1024)).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
/* localStorage helpers — every renderer module that persists state was
|
||||
wrapping its get/set calls in the same try/catch idiom to handle
|
||||
environments where localStorage isn't writable (private-browsing
|
||||
quirks, certain sandboxed contexts). Centralising the pattern. */
|
||||
function safeLocalStorageGet(key: string, fallback = ''): string {
|
||||
try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function safeLocalStorageSet(key: string, value: string): void {
|
||||
try { localStorage.setItem(key, value); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
function safeLocalStorageRemove(key: string): void {
|
||||
try { localStorage.removeItem(key); } catch { /* localStorage may be unavailable */ }
|
||||
}
|
||||
|
||||
let config: AppConfig = {};
|
||||
let currentStreamer: string | null = null;
|
||||
let isConnected = false;
|
||||
|
||||
157
src/renderer-stats.ts
Normal file
157
src/renderer-stats.ts
Normal file
@ -0,0 +1,157 @@
|
||||
async function refreshArchiveStats(): Promise<void> {
|
||||
const btn = document.getElementById('btnStatsRefresh') as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) lastLabel.textContent = (UI_TEXT.static.statsScanning as string) || 'Scanning...';
|
||||
|
||||
try {
|
||||
const stats = await window.api.getArchiveStats();
|
||||
renderArchiveStats(stats);
|
||||
} catch (e) {
|
||||
const summary = document.getElementById('statsSummaryGrid');
|
||||
if (summary) summary.textContent = `Fehler: ${String(e)}`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchiveStats(stats: ArchiveStats): void {
|
||||
const lastLabel = document.getElementById('statsLastScannedLabel');
|
||||
if (lastLabel) {
|
||||
const dt = new Date(stats.scannedAt);
|
||||
lastLabel.textContent = `${UI_TEXT.static.statsScannedAt}: ${dt.toLocaleString()}`;
|
||||
}
|
||||
|
||||
renderStatsSummary(stats);
|
||||
renderStatsTopStreamers(stats.topStreamers, stats.totalBytes);
|
||||
renderStatsActivity(stats.dailyActivity);
|
||||
renderStatsSizeBuckets(stats.sizeBuckets);
|
||||
}
|
||||
|
||||
function renderStatsSummary(stats: ArchiveStats): void {
|
||||
const grid = document.getElementById('statsSummaryGrid');
|
||||
if (!grid) return;
|
||||
|
||||
if (!stats.rootExists) {
|
||||
applyHtml(grid, `<div class="stats-no-root">${escapeHtml(UI_TEXT.static.statsNoRoot)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cards: Array<{ label: string; value: string; sub?: string }> = [
|
||||
{ label: UI_TEXT.static.statsTotalRecordings, value: String(stats.liveCount + stats.vodCount), sub: formatBytes(stats.liveBytes + stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsLiveRecordings, value: String(stats.liveCount), sub: formatBytes(stats.liveBytes) },
|
||||
{ label: UI_TEXT.static.statsVodRecordings, value: String(stats.vodCount), sub: formatBytes(stats.vodBytes) },
|
||||
{ label: UI_TEXT.static.statsStreamers, value: String(stats.streamerCount) },
|
||||
{ label: UI_TEXT.static.statsAvgSize, value: stats.avgRecordingSizeBytes > 0 ? formatBytes(stats.avgRecordingSizeBytes) : '-' },
|
||||
{ label: UI_TEXT.static.statsChatFiles, value: String(stats.chatCount), sub: formatBytes(stats.chatBytes) }
|
||||
];
|
||||
|
||||
applyHtml(grid, cards.map((c) => `
|
||||
<div class="stats-kpi-card">
|
||||
<div class="stats-kpi-label">${escapeHtml(c.label)}</div>
|
||||
<div class="stats-kpi-value">${escapeHtml(c.value)}</div>
|
||||
${c.sub ? `<div class="stats-kpi-sub">${escapeHtml(c.sub)}</div>` : ''}
|
||||
</div>
|
||||
`).join(''));
|
||||
}
|
||||
|
||||
function renderStatsTopStreamers(top: ArchiveStatsTopStreamer[], totalBytes: number): void {
|
||||
const container = document.getElementById('statsTopStreamers');
|
||||
if (!container) return;
|
||||
|
||||
if (top.length === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxBytes = top[0].bytes || 1;
|
||||
applyHtml(container, top.map((s) => {
|
||||
const pct = Math.max(2, Math.round((s.bytes / maxBytes) * 100));
|
||||
const sharePct = totalBytes > 0 ? ((s.bytes / totalBytes) * 100).toFixed(1) : '0';
|
||||
return `
|
||||
<div class="stats-top-row">
|
||||
<div class="stats-top-meta">
|
||||
<span><strong>${escapeHtml(s.streamer)}</strong> <span class="stats-top-meta-sub"><span aria-hidden="true">·</span> ${s.fileCount} ${escapeHtml(UI_TEXT.static.statsFiles)}</span></span>
|
||||
<span class="stats-top-meta-sub">${formatBytes(s.bytes)} <span class="stats-top-share">(${sharePct}%)</span></span>
|
||||
</div>
|
||||
<div class="stats-top-bar-track">
|
||||
<div class="stats-top-bar-fill" style="width: ${pct}%;"></div>
|
||||
${(s.liveBytes > 0 || s.vodBytes > 0) ? `<div class="stats-top-bar-labels">
|
||||
${s.liveBytes > 0 ? `LIVE ${formatBytes(s.liveBytes)}` : ''}
|
||||
${s.vodBytes > 0 ? `VOD ${formatBytes(s.vodBytes)}` : ''}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
function renderStatsActivity(days: ArchiveStatsDay[]): void {
|
||||
const container = document.getElementById('statsActivity');
|
||||
if (!container) return;
|
||||
|
||||
if (days.length === 0) {
|
||||
container.textContent = UI_TEXT.static.statsEmpty;
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCount = days.reduce((m, d) => Math.max(m, d.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsActivityEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bars = days.map((d, idx) => {
|
||||
const heightPct = Math.max(4, Math.round((d.count / maxCount) * 100));
|
||||
const tooltip = `${d.date}: ${d.count} ${UI_TEXT.static.statsFiles} - ${formatBytes(d.bytes)}`;
|
||||
const showLabel = idx === 0 || idx === days.length - 1 || idx % 7 === 0;
|
||||
const dayLabel = showLabel ? d.date.slice(5) : '';
|
||||
return `
|
||||
<div class="stats-day-col">
|
||||
<div class="stats-day-bar-track">
|
||||
<div class="stats-day-bar-fill" style="height: ${heightPct}%;" title="${escapeHtml(tooltip)}"></div>
|
||||
</div>
|
||||
<div class="stats-day-label">${escapeHtml(dayLabel)}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
const totalCount = days.reduce((s, d) => s + d.count, 0);
|
||||
const totalBytes = days.reduce((s, d) => s + d.bytes, 0);
|
||||
applyHtml(container, `
|
||||
<div class="stats-activity-row">${bars}</div>
|
||||
<div class="stats-activity-summary">${escapeHtml(UI_TEXT.static.statsActivitySummary
|
||||
.replace('{count}', String(totalCount))
|
||||
.replace('{size}', formatBytes(totalBytes)))}</div>
|
||||
`);
|
||||
}
|
||||
|
||||
function renderStatsSizeBuckets(buckets: ArchiveStatsBucket[]): void {
|
||||
const container = document.getElementById('statsSizeBuckets');
|
||||
if (!container) return;
|
||||
|
||||
const maxCount = buckets.reduce((m, b) => Math.max(m, b.count), 0);
|
||||
if (maxCount === 0) {
|
||||
applyHtml(container, `<div class="form-note">${escapeHtml(UI_TEXT.static.statsEmpty)}</div>`);
|
||||
return;
|
||||
}
|
||||
|
||||
applyHtml(container, buckets.map((b) => {
|
||||
const pct = b.count > 0 ? Math.max(2, Math.round((b.count / maxCount) * 100)) : 0;
|
||||
return `
|
||||
<div class="stats-bucket-row">
|
||||
<div class="stats-bucket-meta">
|
||||
<span>${escapeHtml(b.label)}</span>
|
||||
<span class="stats-bucket-meta-sub">${b.count} <span aria-hidden="true">·</span> ${formatBytes(b.bytes)}</span>
|
||||
</div>
|
||||
<div class="stats-bucket-bar-track">
|
||||
<div class="stats-bucket-bar-fill" style="width: ${pct}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join(''));
|
||||
}
|
||||
|
||||
|
||||
|
||||
(window as unknown as { refreshArchiveStats: typeof refreshArchiveStats }).refreshArchiveStats = refreshArchiveStats;
|
||||
File diff suppressed because it is too large
Load Diff
@ -26,6 +26,12 @@ function setText(id: string, value: string): void {
|
||||
if (node) node.textContent = value;
|
||||
}
|
||||
|
||||
function setAriaLabelAll(selector: string, value: string): void {
|
||||
document.querySelectorAll(selector).forEach((el) => {
|
||||
el.setAttribute('aria-label', value);
|
||||
});
|
||||
}
|
||||
|
||||
function setPlaceholder(id: string, value: string): void {
|
||||
const node = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (node) node.placeholder = value;
|
||||
@ -36,6 +42,11 @@ function setTitle(id: string, value: string): void {
|
||||
if (node) node.setAttribute('title', value);
|
||||
}
|
||||
|
||||
function setAriaLabel(id: string, value: string): void {
|
||||
const node = document.getElementById(id);
|
||||
if (node) node.setAttribute('aria-label', value);
|
||||
}
|
||||
|
||||
function setLanguage(lang: string): LanguageCode {
|
||||
currentLanguage = lang === 'en' ? 'en' : 'de';
|
||||
UI_TEXT = UI_TEXTS[currentLanguage];
|
||||
@ -49,7 +60,39 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('navClipsText', UI_TEXT.static.navClips);
|
||||
setText('navCutterText', UI_TEXT.static.navCutter);
|
||||
setText('navMergeText', UI_TEXT.static.navMerge);
|
||||
setText('navStatsText', UI_TEXT.static.navStats);
|
||||
setText('navArchiveText', UI_TEXT.static.navArchive);
|
||||
setText('archiveTitle', UI_TEXT.static.archiveTitle);
|
||||
setText('archiveIntro', UI_TEXT.static.archiveIntro);
|
||||
setText('btnArchiveSearch', UI_TEXT.static.archiveSearchBtn);
|
||||
const archiveQueryInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (archiveQueryInput) archiveQueryInput.placeholder = UI_TEXT.static.archiveSearchPlaceholder;
|
||||
setAriaLabel('archiveSearchQuery', UI_TEXT.static.archiveSearchAria);
|
||||
const archiveTypeSelect = document.getElementById('archiveSearchType') as HTMLSelectElement | null;
|
||||
if (archiveTypeSelect) {
|
||||
const opts = archiveTypeSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveAllTypes;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveTypeLive;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveTypeVod;
|
||||
}
|
||||
const archiveSortSelect = document.getElementById('archiveSearchSort') as HTMLSelectElement | null;
|
||||
if (archiveSortSelect) {
|
||||
const opts = archiveSortSelect.options;
|
||||
if (opts[0]) opts[0].text = UI_TEXT.static.archiveSortDateDesc;
|
||||
if (opts[1]) opts[1].text = UI_TEXT.static.archiveSortDateAsc;
|
||||
if (opts[2]) opts[2].text = UI_TEXT.static.archiveSortSizeDesc;
|
||||
if (opts[3]) opts[3].text = UI_TEXT.static.archiveSortSizeAsc;
|
||||
if (opts[4]) opts[4].text = UI_TEXT.static.archiveSortNameAsc;
|
||||
}
|
||||
setText('navSettingsText', UI_TEXT.static.navSettings);
|
||||
setText('statsTitle', UI_TEXT.static.statsTitle);
|
||||
const statsIntroEl = document.getElementById('statsIntro');
|
||||
if (statsIntroEl) applyHtml(statsIntroEl, UI_TEXT.static.statsIntro);
|
||||
setText('statsSummaryTitle', UI_TEXT.static.statsSummaryTitle);
|
||||
setText('statsTopStreamersTitle', UI_TEXT.static.statsTopStreamersTitle);
|
||||
setText('statsActivityTitle', UI_TEXT.static.statsActivityTitle);
|
||||
setText('statsSizeBucketsTitle', UI_TEXT.static.statsSizeBucketsTitle);
|
||||
setText('btnStatsRefresh', UI_TEXT.static.statsRefresh);
|
||||
setText('queueTitleText', UI_TEXT.static.queueTitle);
|
||||
setText('healthBadge', UI_TEXT.static.healthUnknown);
|
||||
setText('btnRetryFailed', UI_TEXT.static.retryFailed);
|
||||
@ -61,11 +104,31 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('clipsInfoText', UI_TEXT.static.clipsInfoText);
|
||||
setText('clipTemplateHelp', UI_TEXT.clips.templateHelp);
|
||||
setPlaceholder('clipFilenameTemplate', UI_TEXT.clips.templatePlaceholder);
|
||||
setText('clipDialogStartLabel', UI_TEXT.clips.dialogStart);
|
||||
setText('clipDialogStartTimeLabel', UI_TEXT.clips.dialogStartTime);
|
||||
setText('clipDialogEndLabel', UI_TEXT.clips.dialogEnd);
|
||||
setText('clipDialogEndTimeLabel', UI_TEXT.clips.dialogEndTime);
|
||||
setText('clipDialogDurationLabel', UI_TEXT.clips.dialogDuration);
|
||||
setText('clipDialogPartLabel', UI_TEXT.clips.dialogPartLabel);
|
||||
setText('clipDialogPartHint', UI_TEXT.clips.dialogPartHint);
|
||||
setText('clipDialogFormatLabel', UI_TEXT.clips.dialogFormatLabel);
|
||||
setText('clipDialogConfirmBtn', UI_TEXT.clips.dialogConfirm);
|
||||
setPlaceholder('clipUrl', UI_TEXT.clips.urlPlaceholder);
|
||||
setPlaceholder('clipStartPart', UI_TEXT.clips.startPartPlaceholder);
|
||||
setPlaceholder('cutterFilePath', UI_TEXT.cutter.filePathPlaceholder);
|
||||
setText('cutterSelectTitle', UI_TEXT.static.cutterSelectTitle);
|
||||
setText('cutterBrowseBtn', UI_TEXT.static.cutterBrowse);
|
||||
setText('cutterInfoDurationLabel', UI_TEXT.cutter.infoDuration);
|
||||
setText('cutterInfoResolutionLabel', UI_TEXT.cutter.infoResolution);
|
||||
setText('cutterInfoFpsLabel', UI_TEXT.cutter.infoFps);
|
||||
setText('cutterInfoSelectionLabel', UI_TEXT.cutter.infoSelection);
|
||||
setText('cutterStartLabel', UI_TEXT.cutter.startLabel);
|
||||
setText('cutterEndLabel', UI_TEXT.cutter.endLabel);
|
||||
setText('btnCut', UI_TEXT.cutter.cut);
|
||||
setText('mergeTitle', UI_TEXT.static.mergeTitle);
|
||||
setText('mergeDesc', UI_TEXT.static.mergeDesc);
|
||||
setText('mergeAddBtn', UI_TEXT.static.mergeAdd);
|
||||
setText('btnMerge', UI_TEXT.merge.merge);
|
||||
setText('designTitle', UI_TEXT.static.designTitle);
|
||||
setText('themeLabel', UI_TEXT.static.themeLabel);
|
||||
setText('themeLightOption', UI_TEXT.static.themeLight);
|
||||
@ -73,6 +136,8 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('languageDeText', UI_TEXT.static.languageDe);
|
||||
setText('languageEnText', UI_TEXT.static.languageEn);
|
||||
setText('apiTitle', UI_TEXT.static.apiTitle);
|
||||
setText('apiHelpIntro', UI_TEXT.static.apiHelpIntro);
|
||||
setText('apiHelpLink', UI_TEXT.static.apiHelpLinkText);
|
||||
setText('clientIdLabel', UI_TEXT.static.clientIdLabel);
|
||||
setText('clientSecretLabel', UI_TEXT.static.clientSecretLabel);
|
||||
setText('saveSettingsBtn', UI_TEXT.static.saveSettings);
|
||||
@ -91,8 +156,41 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('performanceModeBalanced', UI_TEXT.static.performanceModeBalanced);
|
||||
setText('performanceModeSpeed', UI_TEXT.static.performanceModeSpeed);
|
||||
setText('smartSchedulerLabel', UI_TEXT.static.smartSchedulerLabel);
|
||||
setTitle('smartSchedulerLabel', UI_TEXT.static.smartSchedulerHint);
|
||||
setTitle('smartSchedulerToggle', UI_TEXT.static.smartSchedulerHint);
|
||||
setText('duplicatePreventionLabel', UI_TEXT.static.duplicatePreventionLabel);
|
||||
setText('persistQueueLabel', UI_TEXT.static.persistQueueLabel);
|
||||
setText('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueLabel);
|
||||
setTitle('autoResumeQueueLabel', UI_TEXT.static.autoResumeQueueHint);
|
||||
setTitle('autoResumeQueueToggle', UI_TEXT.static.autoResumeQueueHint);
|
||||
setText('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionLabel);
|
||||
setTitle('notifyEachCompletionLabel', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setTitle('notifyEachCompletionToggle', UI_TEXT.static.notifyEachCompletionHint);
|
||||
setText('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsLabel);
|
||||
setTitle('streamlinkDisableAdsLabel', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setTitle('streamlinkDisableAdsToggle', UI_TEXT.static.streamlinkDisableAdsHint);
|
||||
setText('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayLabel);
|
||||
setTitle('downloadChatReplayLabel', UI_TEXT.static.downloadChatReplayHint);
|
||||
setTitle('downloadChatReplayToggle', UI_TEXT.static.downloadChatReplayHint);
|
||||
setText('captureLiveChatLabel', UI_TEXT.static.captureLiveChatLabel);
|
||||
setTitle('captureLiveChatLabel', UI_TEXT.static.captureLiveChatHint);
|
||||
setTitle('captureLiveChatToggle', UI_TEXT.static.captureLiveChatHint);
|
||||
setText('logStreamEventsLabel', UI_TEXT.static.logStreamEventsLabel);
|
||||
setTitle('logStreamEventsLabel', UI_TEXT.static.logStreamEventsHint);
|
||||
setTitle('logStreamEventsToggle', UI_TEXT.static.logStreamEventsHint);
|
||||
setText('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityLabel);
|
||||
setTitle('streamlinkQualityLabel', UI_TEXT.static.streamlinkQualityHint);
|
||||
setTitle('streamlinkQuality', UI_TEXT.static.streamlinkQualityHint);
|
||||
setText('streamlinkQualityBest', UI_TEXT.static.streamlinkQualityBest);
|
||||
setText('streamlinkQualitySource', UI_TEXT.static.streamlinkQualitySource);
|
||||
setText('streamlinkQualityAudio', UI_TEXT.static.streamlinkQualityAudio);
|
||||
setText('streamerSectionTitleText', UI_TEXT.static.streamerSectionTitle);
|
||||
setPlaceholder('streamerListFilter', UI_TEXT.static.streamerListFilterPlaceholder);
|
||||
setAriaLabel('streamerListFilter', UI_TEXT.static.streamerListFilterAria);
|
||||
setTitle('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnStreamerBulkRemove', UI_TEXT.static.streamerBulkRemoveTitle);
|
||||
setAriaLabel('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setTitle('btnAddStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setText('metadataCacheMinutesLabel', UI_TEXT.static.metadataCacheMinutesLabel);
|
||||
setText('filenameTemplatesTitle', UI_TEXT.static.filenameTemplatesTitle);
|
||||
setText('vodTemplateLabel', UI_TEXT.static.vodTemplateLabel);
|
||||
@ -127,6 +225,59 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('preflightResult', UI_TEXT.static.preflightEmpty);
|
||||
setText('debugLogTitle', UI_TEXT.static.debugLogTitle);
|
||||
setText('btnRefreshLog', UI_TEXT.static.refreshLog);
|
||||
setText('btnOpenDebugLogFile', UI_TEXT.static.openDebugLogFile);
|
||||
setText('storageCardTitle', UI_TEXT.static.storageCardTitle);
|
||||
setText('storageCardIntro', UI_TEXT.static.storageCardIntro);
|
||||
setText('btnRefreshStorage', UI_TEXT.static.storageRefresh);
|
||||
setText('cleanupTitle', UI_TEXT.static.cleanupTitle);
|
||||
setText('cleanupIntro', UI_TEXT.static.cleanupIntro);
|
||||
setText('autoCleanupEnabledLabel', UI_TEXT.static.cleanupEnabledLabel);
|
||||
setText('autoCleanupDaysLabel', UI_TEXT.static.cleanupDaysLabel);
|
||||
setText('autoCleanupTargetLabel', UI_TEXT.static.cleanupTargetLabel);
|
||||
setText('autoCleanupTargetLive', UI_TEXT.static.cleanupTargetLive);
|
||||
setText('autoCleanupTargetAll', UI_TEXT.static.cleanupTargetAll);
|
||||
setText('autoCleanupActionLabel', UI_TEXT.static.cleanupActionLabel);
|
||||
setText('autoCleanupActionArchive', UI_TEXT.static.cleanupActionArchive);
|
||||
setText('autoCleanupActionDelete', UI_TEXT.static.cleanupActionDelete);
|
||||
setText('btnCleanupDryRun', UI_TEXT.static.cleanupDryRun);
|
||||
setText('btnCleanupRunNow', UI_TEXT.static.cleanupRunNow);
|
||||
setText('discordCardTitle', UI_TEXT.static.discordCardTitle);
|
||||
setText('discordCardIntro', UI_TEXT.static.discordCardIntro);
|
||||
setText('discordWebhookUrlLabel', UI_TEXT.static.discordWebhookUrlLabel);
|
||||
setText('discordNotifyLiveStartLabel', UI_TEXT.static.discordNotifyLiveStartLabel);
|
||||
setText('discordNotifyLiveEndLabel', UI_TEXT.static.discordNotifyLiveEndLabel);
|
||||
setText('discordNotifyVodCompleteLabel', UI_TEXT.static.discordNotifyVodCompleteLabel);
|
||||
setText('autoResumeLiveRecordingLabel', UI_TEXT.static.autoResumeLiveRecordingLabel);
|
||||
setText('autoMergeResumedPartsLabel', UI_TEXT.static.autoMergeResumedPartsLabel);
|
||||
setText('deletePartsAfterMergeLabel', UI_TEXT.static.deletePartsAfterMergeLabel);
|
||||
setText('discordNotifyVodAutoQueuedLabel', UI_TEXT.static.discordNotifyVodAutoQueuedLabel);
|
||||
setText('autoVodCardTitle', UI_TEXT.static.autoVodCardTitle);
|
||||
setText('autoVodCardIntro', UI_TEXT.static.autoVodCardIntro);
|
||||
setText('autoVodPollMinutesLabel', UI_TEXT.static.autoVodPollMinutesLabel);
|
||||
setText('autoVodMaxAgeHoursLabel', UI_TEXT.static.autoVodMaxAgeHoursLabel);
|
||||
setText('btnAutoVodScanNow', UI_TEXT.static.autoVodScanNow);
|
||||
setText('btnAutoRecordScanNow', UI_TEXT.static.autoRecordScanNow);
|
||||
|
||||
// Empty-state copy for the VODs grid (when no streamer is selected
|
||||
// yet) and the Merge file list (no files added yet). Both were
|
||||
// hardcoded German in the HTML — English users saw German strings.
|
||||
setText('vodGridEmptyTitle', UI_TEXT.vods.noneTitle);
|
||||
setText('vodGridEmptyText', UI_TEXT.vods.noneText);
|
||||
setText('mergeEmptyText', UI_TEXT.merge.empty);
|
||||
|
||||
// Localize the modal close-button aria-label. The buttons share a
|
||||
// .modal-close-localizable class so one call updates all five.
|
||||
setAriaLabelAll('.modal-close-localizable', UI_TEXT.streamers.modalCloseAria);
|
||||
document.getElementById('cutProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.cutProgressAria);
|
||||
document.getElementById('mergeProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.mergeProgressAria);
|
||||
document.getElementById('updateProgressGauge')?.setAttribute('aria-label', UI_TEXT.streamers.updateProgressAria);
|
||||
setText('backupCardTitle', UI_TEXT.static.backupCardTitle);
|
||||
setText('backupCardIntro', UI_TEXT.static.backupCardIntro);
|
||||
setText('btnExportConfig', UI_TEXT.static.exportConfig);
|
||||
setText('btnImportConfig', UI_TEXT.static.importConfig);
|
||||
setText('btnResetDownloadedIds', UI_TEXT.static.resetDownloadedIds);
|
||||
setText('vodHideDownloadedText', UI_TEXT.vods.hideDownloaded);
|
||||
setTitle('vodHideDownloadedLabel', UI_TEXT.vods.hideDownloadedTitle);
|
||||
setText('autoRefreshText', UI_TEXT.static.autoRefresh);
|
||||
setText('runtimeMetricsTitle', UI_TEXT.static.runtimeMetricsTitle);
|
||||
setText('btnRefreshMetrics', UI_TEXT.static.runtimeMetricsRefresh);
|
||||
@ -139,10 +290,30 @@ function applyLanguageToStaticUI(): void {
|
||||
setText('updateModalTitle', UI_TEXT.updates.modalAvailableTitle);
|
||||
setText('updateModalDismissBtn', UI_TEXT.updates.modalDismiss);
|
||||
setText('updateModalConfirmBtn', UI_TEXT.updates.modalDownloadConfirm);
|
||||
setText('updateModalSkipBtn', UI_TEXT.updates.modalSkipVersion);
|
||||
setText('updateChangelogLabel', UI_TEXT.updates.changelogLabel);
|
||||
setText('updateChangelogToggle', UI_TEXT.updates.showChangelog);
|
||||
setText('updateChangelogEmpty', UI_TEXT.updates.noChangelog);
|
||||
setPlaceholder('newStreamer', UI_TEXT.static.streamerPlaceholder);
|
||||
setAriaLabel('newStreamer', UI_TEXT.static.streamerAddAriaLabel);
|
||||
setPlaceholder('vodFilterInput', UI_TEXT.vods.filterPlaceholder);
|
||||
setAriaLabel('vodFilterInput', UI_TEXT.vods.filterAria);
|
||||
setTitle('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setAriaLabel('vodFilterClearBtn', UI_TEXT.vods.filterClearTitle);
|
||||
setPlaceholder('chatViewerFilter', UI_TEXT.queue.chatViewerFilterPlaceholder);
|
||||
setAriaLabel('chatViewerFilter', UI_TEXT.queue.chatViewerFilterAria);
|
||||
setText('vodSortLabel', UI_TEXT.vods.sortLabel);
|
||||
if (typeof refreshVodSortSelectLabels === 'function') {
|
||||
refreshVodSortSelectLabels();
|
||||
}
|
||||
setText('vodBulkAddBtn', UI_TEXT.vods.bulkAddToQueue);
|
||||
setText('vodBulkMarkBtn', UI_TEXT.vods.bulkMarkDownloaded);
|
||||
setText('vodBulkUnmarkBtn', UI_TEXT.vods.bulkUnmark);
|
||||
setText('vodBulkClearBtn', UI_TEXT.vods.bulkClear);
|
||||
if (typeof updateVodBulkBar === 'function') {
|
||||
// Repopulate the count text in the new locale
|
||||
updateVodBulkBar();
|
||||
}
|
||||
|
||||
const status = document.getElementById('statusText')?.textContent?.trim() || '';
|
||||
if (status === UI_TEXTS.de.static.notConnected || status === UI_TEXTS.en.static.notConnected) {
|
||||
|
||||
@ -9,6 +9,20 @@ let updateBannerState: 'idle' | 'available' | 'downloading' | 'ready' = 'idle';
|
||||
let updateChangelogExpanded = false;
|
||||
let shouldOpenUpdateModalOnAvailable = false;
|
||||
|
||||
const SKIPPED_UPDATE_VERSION_KEY = 'twitch-vod-manager:skipped-update-version';
|
||||
|
||||
function getSkippedUpdateVersion(): string {
|
||||
return safeLocalStorageGet(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function persistSkippedUpdateVersion(version: string): void {
|
||||
safeLocalStorageSet(SKIPPED_UPDATE_VERSION_KEY, version);
|
||||
}
|
||||
|
||||
function clearSkippedUpdateVersion(): void {
|
||||
safeLocalStorageRemove(SKIPPED_UPDATE_VERSION_KEY);
|
||||
}
|
||||
|
||||
function notifyUpdate(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
const toastFn = (window as unknown as { showAppToast?: (msg: string, kind?: 'info' | 'warn') => void }).showAppToast;
|
||||
if (typeof toastFn === 'function') {
|
||||
@ -74,11 +88,11 @@ function setCheckButtonCheckingState(enabled: boolean): void {
|
||||
}
|
||||
|
||||
function showUpdateBanner(): void {
|
||||
byId('updateBanner').style.display = 'flex';
|
||||
byId('updateBanner').classList.add('show');
|
||||
}
|
||||
|
||||
function hideUpdateBanner(): void {
|
||||
byId('updateBanner').style.display = 'none';
|
||||
byId('updateBanner').classList.remove('show');
|
||||
}
|
||||
|
||||
function setUpdateBannerAvailableUi(info: UpdateInfo): void {
|
||||
@ -89,7 +103,7 @@ function setUpdateBannerAvailableUi(info: UpdateInfo): void {
|
||||
updateBannerState = 'available';
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').style.display = 'none';
|
||||
byId('updateProgress').classList.add('is-hidden');
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
@ -109,11 +123,13 @@ function setDownloadPendingUi(): void {
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
byId('updateProgress').style.display = 'block';
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.add('downloading');
|
||||
bar.style.width = latestDownloadProgress ? `${latestDownloadProgress.percent}%` : '30%';
|
||||
const pendingPct = latestDownloadProgress ? latestDownloadProgress.percent : 30;
|
||||
bar.style.width = `${pendingPct}%`;
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(pendingPct)));
|
||||
|
||||
if (!latestDownloadProgress) {
|
||||
byId('updateText').textContent = `Version ${latestUpdateVersion || '?'} ${UI_TEXT.updates.downloading}`;
|
||||
@ -131,8 +147,9 @@ function setDownloadReadyUi(info?: UpdateInfo): void {
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '100%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', '100');
|
||||
|
||||
byId('updateProgress').style.display = 'block';
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
byId('updateText').textContent = `Version ${activeInfo.version} ${UI_TEXT.updates.ready}`;
|
||||
const button = byId<HTMLButtonElement>('updateButton');
|
||||
button.textContent = UI_TEXT.updates.installNow;
|
||||
@ -170,13 +187,13 @@ function renderUpdateChangelog(notes?: string): void {
|
||||
empty.hidden = true;
|
||||
|
||||
if (!normalized) {
|
||||
card.style.display = 'none';
|
||||
card.classList.add('is-hidden');
|
||||
panel.hidden = true;
|
||||
updateChangelogExpanded = false;
|
||||
return;
|
||||
}
|
||||
|
||||
card.style.display = 'block';
|
||||
card.classList.remove('is-hidden');
|
||||
|
||||
const fragment = document.createDocumentFragment();
|
||||
let currentList: HTMLUListElement | null = null;
|
||||
@ -256,7 +273,7 @@ function renderUpdateChangelog(notes?: string): void {
|
||||
function refreshUpdateChangelogToggleText(): void {
|
||||
const toggle = byId<HTMLButtonElement>('updateChangelogToggle');
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.style.display === 'none') {
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -278,13 +295,18 @@ function refreshUpdateModalTexts(): void {
|
||||
byId('updateModalConfirmBtn').textContent = isReady
|
||||
? UI_TEXT.updates.modalInstallConfirm
|
||||
: UI_TEXT.updates.modalDownloadConfirm;
|
||||
// Skip-version only makes sense before the download. Once the .exe is
|
||||
// already on disk and ready to install, hide the button.
|
||||
const skipBtn = byId<HTMLButtonElement>('updateModalSkipBtn');
|
||||
skipBtn.textContent = UI_TEXT.updates.modalSkipVersion;
|
||||
skipBtn.classList.toggle('is-hidden', isReady);
|
||||
byId('updateChangelogLabel').textContent = UI_TEXT.updates.changelogLabel;
|
||||
byId('updateChangelogEmpty').textContent = UI_TEXT.updates.noChangelog;
|
||||
|
||||
const metaText = getUpdateModalMetaText(info);
|
||||
const meta = byId('updateModalMeta');
|
||||
meta.textContent = metaText;
|
||||
meta.style.display = metaText ? 'block' : 'none';
|
||||
meta.classList.toggle('is-hidden', !metaText);
|
||||
|
||||
renderUpdateChangelog(info.releaseNotes);
|
||||
refreshUpdateChangelogToggleText();
|
||||
@ -301,6 +323,19 @@ function dismissUpdateModal(): void {
|
||||
byId('updateModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function skipUpdateVersion(): void {
|
||||
const v = (latestUpdateInfo?.version || latestUpdateVersion || '').trim();
|
||||
if (v) {
|
||||
persistSkippedUpdateVersion(v);
|
||||
}
|
||||
dismissUpdateModal();
|
||||
hideUpdateBanner();
|
||||
updateBannerState = 'idle';
|
||||
// Note: latestUpdateInfo is intentionally kept so a manual "Check for
|
||||
// updates" can still re-surface the same version if the user changes
|
||||
// their mind (manual checks bypass the skip-version filter).
|
||||
}
|
||||
|
||||
function confirmUpdateModal(): void {
|
||||
dismissUpdateModal();
|
||||
|
||||
@ -314,7 +349,7 @@ function confirmUpdateModal(): void {
|
||||
|
||||
function toggleUpdateChangelog(): void {
|
||||
const card = byId<HTMLElement>('updateChangelogCard');
|
||||
if (card.style.display === 'none') {
|
||||
if (card.classList.contains('is-hidden')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@ -339,7 +374,7 @@ function refreshUpdateUiTexts(): void {
|
||||
} else if (updateBannerState === 'downloading') {
|
||||
button.textContent = UI_TEXT.updates.downloading;
|
||||
button.disabled = true;
|
||||
progress.style.display = 'block';
|
||||
progress.classList.remove('is-hidden');
|
||||
if (latestDownloadProgress) {
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = `${latestDownloadProgress.percent}%`;
|
||||
@ -353,7 +388,7 @@ function refreshUpdateUiTexts(): void {
|
||||
setDownloadReadyUi(latestUpdateInfo);
|
||||
} else {
|
||||
hideUpdateBanner();
|
||||
progress.style.display = 'none';
|
||||
progress.classList.add('is-hidden');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = '0%';
|
||||
byId('updateText').textContent = UI_TEXT.updates.bannerDefault;
|
||||
@ -423,7 +458,7 @@ async function checkUpdate(): Promise<void> {
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!manualUpdateOutcomeHandled && !updateReady && byId('updateBanner').style.display !== 'flex') {
|
||||
if (!manualUpdateOutcomeHandled && !updateReady && !byId('updateBanner').classList.contains('show')) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
notifyUpdate(UI_TEXT.updates.latest, 'info');
|
||||
}
|
||||
@ -495,11 +530,22 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
updateCheckInProgress = false;
|
||||
updateReady = false;
|
||||
updateDownloadInProgress = false;
|
||||
const wasManual = manualUpdateCheckPending;
|
||||
manualUpdateCheckPending = false;
|
||||
manualUpdateOutcomeHandled = true;
|
||||
latestDownloadProgress = null;
|
||||
setCheckButtonCheckingState(false);
|
||||
|
||||
// If the user explicitly skipped this exact version, suppress the auto
|
||||
// notification entirely — banner stays hidden, no modal popup. A manual
|
||||
// "Check for updates" click overrides the skip so the user can change
|
||||
// their mind.
|
||||
const isSkipped = getSkippedUpdateVersion() === activeInfo.version;
|
||||
if (isSkipped && !wasManual) {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdateBannerAvailableUi(activeInfo);
|
||||
|
||||
if (shouldOpenUpdateModalOnAvailable) {
|
||||
@ -509,6 +555,7 @@ window.api.onUpdateAvailable((info: UpdateInfo) => {
|
||||
shouldOpenUpdateModalOnAvailable = false;
|
||||
});
|
||||
|
||||
|
||||
window.api.onUpdateNotAvailable(() => {
|
||||
updateCheckInProgress = false;
|
||||
setCheckButtonCheckingState(false);
|
||||
@ -530,9 +577,10 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
const bar = byId('updateProgressBar');
|
||||
bar.classList.remove('downloading');
|
||||
bar.style.width = progress.percent + '%';
|
||||
byId('updateProgressGauge').setAttribute('aria-valuenow', String(Math.round(progress.percent)));
|
||||
|
||||
showUpdateBanner();
|
||||
byId('updateProgress').style.display = 'block';
|
||||
byId('updateProgress').classList.remove('is-hidden');
|
||||
|
||||
const mb = (progress.transferred / 1024 / 1024).toFixed(1);
|
||||
const totalMb = (progress.total / 1024 / 1024).toFixed(1);
|
||||
@ -540,6 +588,10 @@ window.api.onUpdateDownloadProgress((progress: UpdateDownloadProgress) => {
|
||||
});
|
||||
|
||||
window.api.onUpdateDownloaded((info: UpdateInfo) => {
|
||||
// Once a version is actually downloaded the user clearly stopped
|
||||
// skipping it — clear the skip flag so future updates aren't masked
|
||||
// by a stale entry.
|
||||
clearSkippedUpdateVersion();
|
||||
const activeInfo = rememberUpdateInfo(info);
|
||||
setDownloadReadyUi(activeInfo);
|
||||
openUpdateModal(activeInfo);
|
||||
|
||||
162
src/renderer-vod-hover.ts
Normal file
162
src/renderer-vod-hover.ts
Normal file
@ -0,0 +1,162 @@
|
||||
// VOD hover preview. When the user mouses over a VOD card, we lazy-fetch
|
||||
// the channel's seek-preview storyboard sprite for that VOD and cycle
|
||||
// through 4 evenly-spaced cells to produce a scrub-preview animation —
|
||||
// the same UX twitch.tv ships on its VOD browsing pages.
|
||||
//
|
||||
// The storyboard fetch goes through the main process (axios via Node's
|
||||
// http client) so the renderer never has to make its own HTTPS request
|
||||
// to the Twitch CDN, sidestepping the same set of Electron renderer
|
||||
// image-loading quirks the avatar code hit.
|
||||
|
||||
interface ActiveHover {
|
||||
vodId: string;
|
||||
intervalId: number;
|
||||
overlay: HTMLElement;
|
||||
}
|
||||
|
||||
const vodStoryboardClientCache = new Map<string, VodStoryboard | null>();
|
||||
let activeHover: ActiveHover | null = null;
|
||||
let pendingHoverVodId: string | null = null;
|
||||
|
||||
const HOVER_DEBOUNCE_MS = 220;
|
||||
const FRAME_INTERVAL_MS = 600;
|
||||
const FRAMES_TO_CYCLE = 4;
|
||||
// Bounded cache — each storyboard data URL is ~50-200 KB, so an
|
||||
// unbounded cache could balloon to hundreds of MB on a long browsing
|
||||
// session through a streamer with thousands of VODs. FIFO eviction
|
||||
// keeps the working set fresh without manual cleanup.
|
||||
const MAX_CLIENT_STORYBOARD_CACHE = 100;
|
||||
|
||||
function rememberStoryboard(vodId: string, sb: VodStoryboard | null): void {
|
||||
vodStoryboardClientCache.set(vodId, sb);
|
||||
if (vodStoryboardClientCache.size > MAX_CLIENT_STORYBOARD_CACHE) {
|
||||
// Map iterator is insertion-ordered — first key is the oldest.
|
||||
const oldestKey = vodStoryboardClientCache.keys().next().value as string | undefined;
|
||||
if (oldestKey !== undefined) vodStoryboardClientCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureVodHoverHandlersBound(): void {
|
||||
const grid = document.getElementById('vodGrid');
|
||||
if (!grid || grid.dataset.hoverBound === '1') return;
|
||||
grid.dataset.hoverBound = '1';
|
||||
|
||||
// Delegated mouseover/mouseout on the grid — re-renders of the
|
||||
// grid replace the card DOM but the grid root persists, so the
|
||||
// listener stays bound across streamer switches.
|
||||
grid.addEventListener('mouseover', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
const vodId = card.dataset.vodId;
|
||||
if (!vodId) return;
|
||||
scheduleHoverPreview(card, vodId);
|
||||
});
|
||||
grid.addEventListener('mouseout', (e) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
const card = target?.closest('.vod-card') as HTMLElement | null;
|
||||
if (!card) return;
|
||||
// Only clear when leaving the card entirely (not just moving
|
||||
// within it between child elements).
|
||||
const related = e.relatedTarget as HTMLElement | null;
|
||||
if (related && card.contains(related)) return;
|
||||
clearHoverPreview();
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleHoverPreview(card: HTMLElement, vodId: string): void {
|
||||
if (pendingHoverVodId === vodId) return;
|
||||
pendingHoverVodId = vodId;
|
||||
// Debounce so rapid mouse passes (scrolling, dragging across cards)
|
||||
// don't trigger a download for every card brushed.
|
||||
window.setTimeout(() => {
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
void activateHoverPreview(card, vodId);
|
||||
}, HOVER_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
function clearHoverPreview(): void {
|
||||
pendingHoverVodId = null;
|
||||
if (!activeHover) return;
|
||||
window.clearInterval(activeHover.intervalId);
|
||||
const card = activeHover.overlay.parentElement;
|
||||
if (card) card.classList.remove('preview-active');
|
||||
// Brief opacity fade-out, then remove from DOM.
|
||||
activeHover.overlay.style.opacity = '0';
|
||||
const overlayToRemove = activeHover.overlay;
|
||||
window.setTimeout(() => { try { overlayToRemove.remove(); } catch { /* gone */ } }, 220);
|
||||
activeHover = null;
|
||||
}
|
||||
|
||||
async function activateHoverPreview(card: HTMLElement, vodId: string): Promise<void> {
|
||||
// Stale-guard: user might have moved off the card in the debounce window.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
|
||||
let storyboard: VodStoryboard | null | undefined = vodStoryboardClientCache.get(vodId);
|
||||
if (storyboard === undefined) {
|
||||
try {
|
||||
storyboard = await window.api.getVodStoryboard(vodId);
|
||||
} catch (_) {
|
||||
storyboard = null;
|
||||
}
|
||||
rememberStoryboard(vodId, storyboard);
|
||||
}
|
||||
|
||||
// Cursor may have moved on while we awaited; re-check guard.
|
||||
if (pendingHoverVodId !== vodId) return;
|
||||
if (!storyboard) return;
|
||||
|
||||
clearHoverPreview();
|
||||
|
||||
// Pick FRAMES_TO_CYCLE evenly-spaced cells from the first sprite —
|
||||
// distributes the chosen preview frames across the early/mid portion
|
||||
// of the VOD. For very short VODs the first sprite is the only one,
|
||||
// so this still gives a representative spread.
|
||||
const totalCells = Math.min(storyboard.framesInSprite, storyboard.cols * storyboard.rows);
|
||||
const stride = Math.max(1, Math.floor(totalCells / FRAMES_TO_CYCLE));
|
||||
const cellsToShow: Array<{ col: number; row: number }> = [];
|
||||
for (let i = 0; i < FRAMES_TO_CYCLE; i++) {
|
||||
const idx = Math.min(totalCells - 1, i * stride);
|
||||
const col = idx % storyboard.cols;
|
||||
const row = Math.floor(idx / storyboard.cols);
|
||||
cellsToShow.push({ col, row });
|
||||
}
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'vod-storyboard-preview';
|
||||
// Scale the sprite so a single cell exactly fills the card width.
|
||||
// The thumbnail aspect-ratio (16:9) matches typical cell aspect
|
||||
// (e.g. 220x124 ≈ 1.77) so width-stretch keeps proportions.
|
||||
const cardWidth = card.getBoundingClientRect().width;
|
||||
const cellAspect = storyboard.cellWidth / storyboard.cellHeight;
|
||||
const scale = cardWidth / storyboard.cellWidth;
|
||||
overlay.style.backgroundImage = `url("${storyboard.spriteDataUrl.replace(/"/g, '%22')}")`;
|
||||
overlay.style.backgroundSize = `${storyboard.cols * storyboard.cellWidth * scale}px ${storyboard.rows * storyboard.cellHeight * scale}px`;
|
||||
overlay.style.height = `${cardWidth / cellAspect}px`;
|
||||
// Initial position = first chosen cell.
|
||||
const first = cellsToShow[0];
|
||||
overlay.style.backgroundPosition = `-${first.col * storyboard.cellWidth * scale}px -${first.row * storyboard.cellHeight * scale}px`;
|
||||
|
||||
card.appendChild(overlay);
|
||||
// Trigger CSS transition to opacity:1 on the next frame.
|
||||
requestAnimationFrame(() => { card.classList.add('preview-active'); });
|
||||
|
||||
let frameIdx = 1;
|
||||
const intervalId = window.setInterval(() => {
|
||||
const cell = cellsToShow[frameIdx % cellsToShow.length];
|
||||
overlay.style.backgroundPosition = `-${cell.col * storyboard.cellWidth * scale}px -${cell.row * storyboard.cellHeight * scale}px`;
|
||||
frameIdx++;
|
||||
}, FRAME_INTERVAL_MS);
|
||||
|
||||
activeHover = { vodId, intervalId, overlay };
|
||||
}
|
||||
|
||||
(window as unknown as { ensureVodHoverHandlersBound: typeof ensureVodHoverHandlersBound }).ensureVodHoverHandlersBound = ensureVodHoverHandlersBound;
|
||||
|
||||
// Bind once the grid exists. Tab switches don't re-create the grid, so
|
||||
// one-time binding via DOMContentLoaded is enough.
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => { ensureVodHoverHandlersBound(); });
|
||||
} else {
|
||||
ensureVodHoverHandlersBound();
|
||||
}
|
||||
676
src/renderer.ts
676
src/renderer.ts
@ -20,6 +20,7 @@ async function init(): Promise<void> {
|
||||
|
||||
byId('versionText').textContent = `v${version}`;
|
||||
byId('versionInfo').textContent = `Version: v${version}`;
|
||||
appVersion = version;
|
||||
document.title = `${UI_TEXT.appName} v${version}`;
|
||||
|
||||
byId<HTMLInputElement>('clientId').value = config.client_id ?? '';
|
||||
@ -42,12 +43,81 @@ async function init(): Promise<void> {
|
||||
changeTheme(config.theme ?? 'twitch');
|
||||
renderStreamers();
|
||||
renderQueue();
|
||||
|
||||
// Keyboard activation for nav-items (Enter / Space). The items are
|
||||
// div[role="button"][tabindex="0"], so browsers won't synthesise a
|
||||
// click on Enter/Space natively — we wire it here once via event
|
||||
// delegation so the listener doesn't need re-binding per tab switch.
|
||||
const nav = document.querySelector('.nav');
|
||||
if (nav && !nav.hasAttribute('data-keynav-bound')) {
|
||||
nav.setAttribute('data-keynav-bound', '1');
|
||||
nav.addEventListener('keydown', (event) => {
|
||||
const ev = event as KeyboardEvent;
|
||||
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||
const target = ev.target as HTMLElement | null;
|
||||
const item = target?.closest('.nav-item') as HTMLElement | null;
|
||||
if (!item) return;
|
||||
const tab = item.dataset.tab;
|
||||
if (!tab) return;
|
||||
ev.preventDefault();
|
||||
showTab(tab);
|
||||
});
|
||||
}
|
||||
|
||||
// Kick off live-status subscription so the sidebar dots populate.
|
||||
const liveStatusInit = (window as unknown as { initLiveStatusSubscription?: () => Promise<void> }).initLiveStatusSubscription;
|
||||
if (typeof liveStatusInit === 'function') void liveStatusInit();
|
||||
initQueueDragDrop();
|
||||
updateDownloadButtonState();
|
||||
updateStatusBarQueueSummary();
|
||||
|
||||
// Restore persisted VOD filter into the input — the filter itself only
|
||||
// takes effect once VODs load (renderVODs reads vodFilterQuery).
|
||||
vodFilterQuery = loadPersistedVodFilter();
|
||||
const vodFilterInput = document.getElementById('vodFilterInput') as HTMLInputElement | null;
|
||||
if (vodFilterInput) vodFilterInput.value = vodFilterQuery;
|
||||
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 "hide downloaded" toggle state.
|
||||
vodHideDownloaded = loadPersistedHideDownloaded();
|
||||
syncVodHideDownloadedToggle();
|
||||
|
||||
// Restore per-streamer VOD scroll positions from prior sessions.
|
||||
loadVodScrollPositions();
|
||||
initVodScrollTracking();
|
||||
initCutterDragDrop();
|
||||
|
||||
// Restore last active tab from previous session (default 'vods')
|
||||
showTab(loadPersistedActiveTab());
|
||||
|
||||
window.api.onQueueUpdated(async (q: QueueItem[]) => {
|
||||
const previouslyCompleted = new Set(queue.filter((i) => i.status === 'completed').map((i) => i.id));
|
||||
const next = Array.isArray(q) ? q : [];
|
||||
const newlyCompletedItem = next.some((i) => i.status === 'completed' && !previouslyCompleted.has(i.id));
|
||||
queue = mergeQueueState(next);
|
||||
|
||||
// When an item flips to 'completed' the main process appends its
|
||||
// VOD ID to config.downloaded_vod_ids. Refresh our local config
|
||||
// copy so the "already downloaded" badge on the VOD grid updates
|
||||
// live without waiting for a settings save.
|
||||
if (newlyCompletedItem) {
|
||||
try {
|
||||
config = await window.api.getConfig();
|
||||
} catch { /* network blip — next sync will refresh */ }
|
||||
if (typeof renderVodGridFromCurrentState === 'function' && lastLoadedStreamer) {
|
||||
renderVodGridFromCurrentState();
|
||||
}
|
||||
}
|
||||
|
||||
window.api.onQueueUpdated((q: QueueItem[]) => {
|
||||
queue = mergeQueueState(Array.isArray(q) ? q : []);
|
||||
renderQueue();
|
||||
updateStatusBarQueueSummary();
|
||||
markQueueActivity();
|
||||
});
|
||||
|
||||
@ -71,10 +141,21 @@ async function init(): Promise<void> {
|
||||
item.downloadedBytes = progress.downloadedBytes;
|
||||
item.totalBytes = progress.totalBytes;
|
||||
item.progressStatus = progress.status;
|
||||
if (progress.recordingHealth) {
|
||||
item.recordingHealth = progress.recordingHealth;
|
||||
}
|
||||
updateQueueItemProgress(progress);
|
||||
updateStatusBarQueueSummary();
|
||||
markQueueActivity();
|
||||
});
|
||||
|
||||
window.api.onAutoVodScanCompleted(({ queuedCount }) => {
|
||||
if (queuedCount > 0) {
|
||||
const tmpl = UI_TEXT.streamers.autoVodScanQueued || '{count} new VOD(s) auto-queued.';
|
||||
showAppToast(tmpl.replace('{count}', String(queuedCount)), 'info');
|
||||
}
|
||||
});
|
||||
|
||||
window.api.onDownloadStarted(() => {
|
||||
downloading = true;
|
||||
updateDownloadButtonState();
|
||||
@ -88,18 +169,27 @@ async function init(): Promise<void> {
|
||||
});
|
||||
|
||||
window.api.onCutProgress((percent: number) => {
|
||||
const rounded = Math.round(percent);
|
||||
byId('cutProgressBar').style.width = percent + '%';
|
||||
byId('cutProgressText').textContent = Math.round(percent) + '%';
|
||||
byId('cutProgressText').textContent = rounded + '%';
|
||||
byId('cutProgressGauge').setAttribute('aria-valuenow', String(rounded));
|
||||
});
|
||||
|
||||
window.api.onMergeProgress((percent: number) => {
|
||||
const rounded = Math.round(percent);
|
||||
byId('mergeProgressBar').style.width = percent + '%';
|
||||
byId('mergeProgressText').textContent = Math.round(percent) + '%';
|
||||
byId('mergeProgressText').textContent = rounded + '%';
|
||||
byId('mergeProgressGauge').setAttribute('aria-valuenow', String(rounded));
|
||||
});
|
||||
|
||||
// Update stats bar
|
||||
updateStatsBar();
|
||||
const _statsInterval = setInterval(updateStatsBar, 5000);
|
||||
// Update stats bar — paused while the window is hidden so we don't
|
||||
// burn IPC chatter on a tab nobody is looking at.
|
||||
void updateStatsBar();
|
||||
startStatsBarPolling();
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) stopStatsBarPolling();
|
||||
else startStatsBarPolling();
|
||||
});
|
||||
|
||||
if (config.client_id && config.client_secret) {
|
||||
await connect();
|
||||
@ -126,9 +216,60 @@ async function init(): Promise<void> {
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Skip if user is typing in an input field
|
||||
// Esc closes any open modal — works regardless of focus, so users can dismiss
|
||||
// a modal that took focus from inside an input field
|
||||
if (e.key === 'Escape') {
|
||||
if (closeTopmostOpenModal()) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// No modal open: if the VOD filter has focus or content, clear it.
|
||||
// Otherwise let Esc bubble (e.g. blur).
|
||||
if (e.target instanceof HTMLInputElement && e.target.id === 'vodFilterInput') {
|
||||
if (vodFilterQuery) {
|
||||
clearVodFilter();
|
||||
e.preventDefault();
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+F (or Cmd+F): focus the VOD filter — only when on the VODs tab.
|
||||
// Browser's default Ctrl+F is suppressed because Electron's renderer
|
||||
// doesn't have a native find bar anyway. Route the shortcut to the
|
||||
// active tab's search/filter input so the user lands in a useful
|
||||
// place regardless of which tab they happen to be on.
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && (e.key === 'f' || e.key === 'F')) {
|
||||
if (document.getElementById('vodsTab')?.classList.contains('active')) {
|
||||
e.preventDefault();
|
||||
focusVodFilter();
|
||||
return;
|
||||
}
|
||||
if (document.getElementById('archiveTab')?.classList.contains('active')) {
|
||||
const archiveInput = document.getElementById('archiveSearchQuery') as HTMLInputElement | null;
|
||||
if (archiveInput) {
|
||||
e.preventDefault();
|
||||
archiveInput.focus();
|
||||
archiveInput.select();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Skip rest if user is typing in an input field
|
||||
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return;
|
||||
|
||||
// Ctrl+1..7 jumps directly to a tab (Cmd on macOS via metaKey)
|
||||
if ((e.ctrlKey || e.metaKey) && !e.altKey && !e.shiftKey && e.key >= '1' && e.key <= '7') {
|
||||
const tabIndex = parseInt(e.key, 10) - 1;
|
||||
if (tabIndex >= 0 && tabIndex < TAB_IDS.length) {
|
||||
e.preventDefault();
|
||||
showTab(TAB_IDS[tabIndex]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (e.key === 'Delete' && selectedQueueIds.length > 0) {
|
||||
// Delete selected queue items
|
||||
const idsToRemove = [...selectedQueueIds];
|
||||
@ -150,6 +291,284 @@ async function init(): Promise<void> {
|
||||
scheduleQueueSync(QUEUE_SYNC_DEFAULT_MS);
|
||||
}
|
||||
|
||||
function openTwitchDevConsole(): void {
|
||||
void window.api.openExternal('https://dev.twitch.tv/console/apps');
|
||||
}
|
||||
|
||||
interface EventLogEntry {
|
||||
t?: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
game?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
streamer?: string;
|
||||
durationSeconds?: number;
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
part?: number;
|
||||
}
|
||||
|
||||
async function openEventsViewer(filePath: string, title: string): Promise<void> {
|
||||
const modal = byId('eventsViewerModal');
|
||||
const list = byId('eventsViewerList');
|
||||
const status = byId('eventsViewerStatus');
|
||||
byId('eventsViewerTitle').textContent = title || UI_TEXT.queue.viewEvents;
|
||||
list.replaceChildren();
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||
return;
|
||||
}
|
||||
const events = result.messages as EventLogEntry[];
|
||||
status.textContent = UI_TEXT.queue.viewEventsCount.replace('{count}', String(events.length));
|
||||
renderEventsList(events);
|
||||
}
|
||||
|
||||
function closeEventsViewer(): void {
|
||||
byId('eventsViewerModal').classList.remove('show');
|
||||
}
|
||||
|
||||
function formatEventTime(iso?: string): string {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString(currentLanguage === 'en' ? 'en-US' : 'de-DE');
|
||||
} catch { return iso; }
|
||||
}
|
||||
|
||||
function renderEventsList(events: EventLogEntry[]): void {
|
||||
const list = byId('eventsViewerList');
|
||||
list.replaceChildren();
|
||||
if (events.length === 0) {
|
||||
const empty = document.createElement('div');
|
||||
empty.className = 'event-viewer-empty';
|
||||
empty.textContent = UI_TEXT.queue.viewEventsEmpty;
|
||||
list.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'event-viewer-row';
|
||||
|
||||
const time = document.createElement('span');
|
||||
time.className = 'event-viewer-time';
|
||||
time.textContent = formatEventTime(ev.t);
|
||||
row.appendChild(time);
|
||||
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'event-viewer-tag';
|
||||
// Per-type tag colour comes from CSS via a data-type attribute
|
||||
// selector — keeps the type->colour mapping with the rest of the
|
||||
// visual styling instead of inline in the renderer.
|
||||
if (ev.type) tag.dataset.type = ev.type;
|
||||
tag.textContent = ev.type || 'event';
|
||||
row.appendChild(tag);
|
||||
|
||||
const detail = document.createElement('div');
|
||||
detail.className = 'event-viewer-detail';
|
||||
|
||||
if (ev.type === 'recording_start') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventStartedAs}: "${ev.title || '-'}" — ${ev.game || '-'}`;
|
||||
} else if (ev.type === 'recording_end') {
|
||||
const dur = typeof ev.durationSeconds === 'number'
|
||||
? `${Math.floor(ev.durationSeconds / 3600)}h ${Math.floor((ev.durationSeconds % 3600) / 60)}m ${ev.durationSeconds % 60}s`
|
||||
: '?';
|
||||
const ok = ev.success ? '✓' : '✗';
|
||||
detail.textContent = `${ok} ${UI_TEXT.queue.eventEndedAfter}: ${dur}${ev.error ? ` — ${ev.error}` : ''}`;
|
||||
} else if (ev.type === 'recording_resume') {
|
||||
detail.textContent = (UI_TEXT.queue.eventRecordingResume || 'Resume started — part {part}').replace('{part}', String(ev.part || '?'));
|
||||
} else if (ev.type === 'title_change') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventTitleFromTo.replace('{from}', `"${ev.from || '-'}"`).replace('{to}', `"${ev.to || '-'}"`)}`;
|
||||
} else if (ev.type === 'game_change') {
|
||||
detail.textContent = `${UI_TEXT.queue.eventGameFromTo.replace('{from}', ev.from || '-').replace('{to}', ev.to || '-')}`;
|
||||
} else {
|
||||
detail.textContent = JSON.stringify(ev);
|
||||
}
|
||||
row.appendChild(detail);
|
||||
list.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatViewerMessage {
|
||||
t?: string;
|
||||
type?: string;
|
||||
u?: string;
|
||||
user?: string;
|
||||
login?: string;
|
||||
color?: string;
|
||||
msg?: string;
|
||||
text?: string;
|
||||
offset?: number;
|
||||
badges?: string;
|
||||
bits?: string;
|
||||
msgId?: string;
|
||||
systemMsg?: string;
|
||||
}
|
||||
|
||||
let chatViewerMessages: ChatViewerMessage[] = [];
|
||||
let chatViewerFormat: 'replay' | 'live' = 'replay';
|
||||
|
||||
async function openChatViewer(filePath: string, title: string): Promise<void> {
|
||||
const modal = byId('chatViewerModal');
|
||||
const list = byId('chatViewerList');
|
||||
const status = byId('chatViewerStatus');
|
||||
const filterInput = byId<HTMLInputElement>('chatViewerFilter');
|
||||
byId('chatViewerTitle').textContent = title || UI_TEXT.queue.viewChat;
|
||||
list.replaceChildren();
|
||||
filterInput.value = '';
|
||||
status.textContent = UI_TEXT.queue.viewChatLoading;
|
||||
modal.classList.add('show');
|
||||
|
||||
const result = await window.api.readChatFile(filePath);
|
||||
if (!result.success || !Array.isArray(result.messages)) {
|
||||
status.textContent = UI_TEXT.queue.viewChatFailed + (result.error ? `: ${result.error}` : '');
|
||||
return;
|
||||
}
|
||||
|
||||
chatViewerMessages = result.messages as ChatViewerMessage[];
|
||||
chatViewerFormat = result.format === 'live' ? 'live' : 'replay';
|
||||
status.textContent = UI_TEXT.queue.viewChatCount.replace('{count}', String(result.total ?? chatViewerMessages.length))
|
||||
+ (result.truncated ? UI_TEXT.queue.viewChatTruncatedSuffix : '');
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
}
|
||||
|
||||
function closeChatViewer(): void {
|
||||
byId('chatViewerModal').classList.remove('show');
|
||||
chatViewerMessages = [];
|
||||
}
|
||||
|
||||
function onChatViewerFilterChange(): void {
|
||||
const filter = byId<HTMLInputElement>('chatViewerFilter').value.trim().toLowerCase();
|
||||
if (!filter) {
|
||||
renderChatViewerList(chatViewerMessages);
|
||||
return;
|
||||
}
|
||||
const filtered = chatViewerMessages.filter((m) => {
|
||||
const u = (m.u || m.user || m.login || '').toLowerCase();
|
||||
const text = (m.msg || m.text || '').toLowerCase();
|
||||
return u.includes(filter) || text.includes(filter);
|
||||
});
|
||||
renderChatViewerList(filtered);
|
||||
}
|
||||
|
||||
function formatChatTimeMarker(m: ChatViewerMessage): string {
|
||||
if (chatViewerFormat === 'replay' && typeof m.offset === 'number') {
|
||||
const total = Math.max(0, Math.floor(m.offset));
|
||||
const h = Math.floor(total / 3600);
|
||||
const min = Math.floor((total % 3600) / 60);
|
||||
const sec = total % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${min.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`;
|
||||
}
|
||||
if (m.t) {
|
||||
try {
|
||||
const d = new Date(m.t);
|
||||
const h = d.getHours().toString().padStart(2, '0');
|
||||
const min = d.getMinutes().toString().padStart(2, '0');
|
||||
const sec = d.getSeconds().toString().padStart(2, '0');
|
||||
return `${h}:${min}:${sec}`;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function renderChatViewerList(messages: ChatViewerMessage[]): void {
|
||||
const list = byId('chatViewerList');
|
||||
list.replaceChildren();
|
||||
// Render in chunks to keep main thread responsive on big files.
|
||||
const CHUNK = 500;
|
||||
let idx = 0;
|
||||
const renderChunk = (): void => {
|
||||
if (idx >= messages.length) return;
|
||||
const fragment = document.createDocumentFragment();
|
||||
const end = Math.min(idx + CHUNK, messages.length);
|
||||
for (let i = idx; i < end; i++) {
|
||||
const m = messages[i];
|
||||
const isMessageType = m.type === 'msg' || !m.type;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'chat-viewer-row' + (!isMessageType ? ' is-system' : '');
|
||||
|
||||
// System events (subs, raids, deletions) lead with a faint tag.
|
||||
if (!isMessageType) {
|
||||
const tag = document.createElement('span');
|
||||
tag.className = 'chat-viewer-tag';
|
||||
tag.textContent = m.type || 'event';
|
||||
row.appendChild(tag);
|
||||
}
|
||||
|
||||
const time = formatChatTimeMarker(m);
|
||||
if (time) {
|
||||
const tSpan = document.createElement('span');
|
||||
tSpan.className = 'chat-viewer-time';
|
||||
tSpan.textContent = time;
|
||||
row.appendChild(tSpan);
|
||||
}
|
||||
|
||||
const user = m.u || m.user || m.login || '';
|
||||
if (user) {
|
||||
const uSpan = document.createElement('span');
|
||||
uSpan.className = 'chat-viewer-user';
|
||||
// Per-user IRC color overrides the default accent colour
|
||||
// supplied by .chat-viewer-user; the class also sets weight.
|
||||
if (m.color) uSpan.style.color = m.color;
|
||||
uSpan.textContent = `${user}:`;
|
||||
row.appendChild(uSpan);
|
||||
}
|
||||
|
||||
const msgSpan = document.createElement('span');
|
||||
msgSpan.textContent = ' ' + (m.msg || m.text || '');
|
||||
row.appendChild(msgSpan);
|
||||
|
||||
fragment.appendChild(row);
|
||||
}
|
||||
list.appendChild(fragment);
|
||||
idx = end;
|
||||
if (idx < messages.length) {
|
||||
window.setTimeout(renderChunk, 0);
|
||||
}
|
||||
};
|
||||
renderChunk();
|
||||
}
|
||||
|
||||
function closeTopmostOpenModal(): boolean {
|
||||
// Try each known modal in priority order
|
||||
const eventsViewerModal = document.getElementById('eventsViewerModal');
|
||||
if (eventsViewerModal?.classList.contains('show')) {
|
||||
closeEventsViewer();
|
||||
return true;
|
||||
}
|
||||
|
||||
const chatViewerModal = document.getElementById('chatViewerModal');
|
||||
if (chatViewerModal?.classList.contains('show')) {
|
||||
closeChatViewer();
|
||||
return true;
|
||||
}
|
||||
|
||||
const clipModal = document.getElementById('clipModal');
|
||||
if (clipModal?.classList.contains('show')) {
|
||||
closeClipDialog();
|
||||
return true;
|
||||
}
|
||||
|
||||
const templateGuideModal = document.getElementById('templateGuideModal');
|
||||
if (templateGuideModal?.classList.contains('show')) {
|
||||
closeTemplateGuide();
|
||||
return true;
|
||||
}
|
||||
|
||||
const updateModal = document.getElementById('updateModal');
|
||||
if (updateModal?.classList.contains('show')) {
|
||||
dismissUpdateModal();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatBytesRenderer(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@ -163,6 +582,46 @@ function formatSpeedRenderer(bytesPerSec: number): string {
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
|
||||
function updateStatusBarQueueSummary(): void {
|
||||
const node = document.getElementById('statusBarQueueSummary');
|
||||
if (!node) return;
|
||||
if (!Array.isArray(queue) || queue.length === 0) {
|
||||
node.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let downloading = 0;
|
||||
let pending = 0;
|
||||
for (const item of queue) {
|
||||
if (item.status === 'downloading') downloading++;
|
||||
else if (item.status === 'pending') pending++;
|
||||
}
|
||||
|
||||
if (downloading === 0 && pending === 0) {
|
||||
node.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
node.textContent = UI_TEXT.queue.statusBarSummary
|
||||
.replace('{downloading}', String(downloading))
|
||||
.replace('{pending}', String(pending));
|
||||
}
|
||||
|
||||
let statsBarPollTimer: number | null = null;
|
||||
|
||||
function startStatsBarPolling(): void {
|
||||
stopStatsBarPolling();
|
||||
if (document.hidden) return;
|
||||
statsBarPollTimer = window.setInterval(updateStatsBar, 5000);
|
||||
}
|
||||
|
||||
function stopStatsBarPolling(): void {
|
||||
if (statsBarPollTimer !== null) {
|
||||
window.clearInterval(statsBarPollTimer);
|
||||
statsBarPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updateStatsBar(): Promise<void> {
|
||||
try {
|
||||
const metrics = await window.api.getRuntimeMetrics();
|
||||
@ -176,6 +635,24 @@ async function updateStatsBar(): Promise<void> {
|
||||
|
||||
let toastHideTimer: number | null = null;
|
||||
let queueSyncTimer: number | null = null;
|
||||
let appVersion = '';
|
||||
|
||||
// Single source of truth for what the user is looking at — keeps the
|
||||
// visible H1, the document title (which drives the OS task bar / Alt+Tab
|
||||
// label), and the app version pill in sync. Previously document.title was
|
||||
// stamped once at boot, so the OS task bar always read "Twitch VOD
|
||||
// Manager v4.6.76" no matter what tab or streamer was active.
|
||||
(window as unknown as { setPageTitle: (text: string) => void }).setPageTitle = setPageTitle;
|
||||
|
||||
function setPageTitle(text: string): void {
|
||||
const titleEl = document.getElementById('pageTitle');
|
||||
if (titleEl) titleEl.textContent = text;
|
||||
const appName = UI_TEXT.appName;
|
||||
const versionSuffix = appVersion ? ` v${appVersion}` : '';
|
||||
document.title = text && text !== appName
|
||||
? `${text} - ${appName}${versionSuffix}`
|
||||
: `${appName}${versionSuffix}`;
|
||||
}
|
||||
let queueSyncInFlight = false;
|
||||
let lastQueueActivityAt = Date.now();
|
||||
|
||||
@ -239,14 +716,28 @@ function showAppToast(message: string, type: 'info' | 'warn' = 'info'): void {
|
||||
toast = document.createElement('div');
|
||||
toast.id = 'appToast';
|
||||
toast.className = 'app-toast';
|
||||
// Live region — screen readers announce the toast text whenever
|
||||
// it changes. Warn toasts go through aria-live="assertive" so the
|
||||
// reader interrupts whatever it was speaking; info toasts use
|
||||
// "polite" so they wait for a natural break in current speech.
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
|
||||
toast.textContent = message;
|
||||
toast.classList.remove('warn', 'show');
|
||||
if (type === 'warn') {
|
||||
toast.classList.add('warn');
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', 'assertive');
|
||||
} else {
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
}
|
||||
// Setting textContent AFTER the aria-live attribute is in place
|
||||
// ensures the change is captured as a live-region update by AT.
|
||||
toast.textContent = message;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
toast?.classList.add('show');
|
||||
@ -330,16 +821,67 @@ async function syncQueueAndDownloadState(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Must include every nav-item from index.html — otherwise:
|
||||
// - Ctrl+N keyboard shortcut won't reach tabs past index 4
|
||||
// - persistActiveTab silently no-ops, so the tab won't restore on reboot
|
||||
// 'stats' (4.6.14) and 'archive' (4.6.15) were added to the nav but the
|
||||
// const was never updated, leaving them effectively second-class tabs.
|
||||
const TAB_IDS = ['vods', 'clips', 'cutter', 'merge', 'stats', 'archive', 'settings'] as const;
|
||||
const ACTIVE_TAB_STORAGE_KEY = 'twitch-vod-manager:active-tab';
|
||||
|
||||
function isKnownTab(value: string): value is typeof TAB_IDS[number] {
|
||||
return (TAB_IDS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function loadPersistedActiveTab(): string {
|
||||
const stored = safeLocalStorageGet(ACTIVE_TAB_STORAGE_KEY);
|
||||
if (stored && isKnownTab(stored)) return stored;
|
||||
return 'vods';
|
||||
}
|
||||
|
||||
function persistActiveTab(tab: string): void {
|
||||
if (!isKnownTab(tab)) return;
|
||||
safeLocalStorageSet(ACTIVE_TAB_STORAGE_KEY, tab);
|
||||
}
|
||||
|
||||
function showTab(tab: string): void {
|
||||
queryAll('.nav-item').forEach((i) => i.classList.remove('active'));
|
||||
queryAll('.nav-item').forEach((i) => {
|
||||
i.classList.remove('active');
|
||||
i.removeAttribute('aria-current');
|
||||
});
|
||||
queryAll('.tab-content').forEach((c) => c.classList.remove('active'));
|
||||
|
||||
query(`.nav-item[data-tab="${tab}"]`).classList.add('active');
|
||||
const navItem = query(`.nav-item[data-tab="${tab}"]`);
|
||||
if (!navItem) {
|
||||
// Unknown tab — fall back to vods so the user is never stuck on an empty screen
|
||||
showTab('vods');
|
||||
return;
|
||||
}
|
||||
navItem.classList.add('active');
|
||||
navItem.setAttribute('aria-current', 'page');
|
||||
byId(tab + 'Tab').classList.add('active');
|
||||
|
||||
const titles: Record<string, string> = UI_TEXT.tabs;
|
||||
|
||||
byId('pageTitle').textContent = currentStreamer || titles[tab] || UI_TEXT.appName;
|
||||
// Only show the streamer name on the VODs tab — otherwise the title would
|
||||
// mismatch the tab content (e.g. "streamer X" while on Settings)
|
||||
const pageTitleText = (tab === 'vods' && currentStreamer)
|
||||
? currentStreamer
|
||||
: (titles[tab] || UI_TEXT.appName);
|
||||
setPageTitle(pageTitleText);
|
||||
|
||||
persistActiveTab(tab);
|
||||
|
||||
if (tab === 'stats') {
|
||||
const fn = (window as unknown as { refreshArchiveStats?: () => Promise<void> }).refreshArchiveStats;
|
||||
if (typeof fn === 'function') void fn();
|
||||
}
|
||||
if (tab === 'archive') {
|
||||
const init = (window as unknown as { initArchiveSearchInput?: () => void }).initArchiveSearchInput;
|
||||
const search = (window as unknown as { performArchiveSearch?: () => Promise<void> }).performArchiveSearch;
|
||||
if (typeof init === 'function') init();
|
||||
if (typeof search === 'function') void search();
|
||||
}
|
||||
}
|
||||
|
||||
function parseDurationToSeconds(durStr: string): number {
|
||||
@ -422,15 +964,18 @@ function formatSecondsWithPattern(totalSeconds: number, pattern: string): string
|
||||
.replace(/\\(.)/g, '$1');
|
||||
}
|
||||
|
||||
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' {
|
||||
function getSelectedFilenameFormat(): 'simple' | 'timestamp' | 'template' | 'parts' {
|
||||
const selected = query<HTMLInputElement>('input[name="filenameFormat"]:checked').value;
|
||||
return selected === 'template' ? 'template' : selected === 'timestamp' ? 'timestamp' : 'simple';
|
||||
if (selected === 'template') return 'template';
|
||||
if (selected === 'timestamp') return 'timestamp';
|
||||
if (selected === 'parts') return 'parts';
|
||||
return 'simple';
|
||||
}
|
||||
|
||||
function updateFilenameTemplateVisibility(): void {
|
||||
const selected = getSelectedFilenameFormat();
|
||||
const wrap = byId('clipFilenameTemplateWrap');
|
||||
wrap.style.display = selected === 'template' ? 'block' : 'none';
|
||||
wrap.classList.toggle('shown', selected === 'template');
|
||||
}
|
||||
|
||||
interface TemplatePreviewContext {
|
||||
@ -743,13 +1288,11 @@ function updateClipDuration(): void {
|
||||
const duration = endSec - startSec;
|
||||
const durationDisplay = byId('clipDurationDisplay');
|
||||
|
||||
if (duration > 0) {
|
||||
durationDisplay.textContent = formatSecondsToTime(duration);
|
||||
durationDisplay.style.color = '#00c853';
|
||||
} else {
|
||||
durationDisplay.textContent = UI_TEXT.clips.invalidDuration;
|
||||
durationDisplay.style.color = '#ff4444';
|
||||
}
|
||||
const isValid = duration > 0;
|
||||
durationDisplay.classList.toggle('invalid', !isValid);
|
||||
durationDisplay.textContent = isValid
|
||||
? formatSecondsToTime(duration)
|
||||
: UI_TEXT.clips.invalidDuration;
|
||||
|
||||
updateFilenameExamples();
|
||||
}
|
||||
@ -773,15 +1316,16 @@ function updateFilenameExamples(): void {
|
||||
updateFilenameTemplateVisibility();
|
||||
|
||||
if (!unknownTokens.length) {
|
||||
clipLint.style.color = '#8bc34a';
|
||||
clipLint.className = 'template-lint ok';
|
||||
clipLint.textContent = UI_TEXT.static.templateLintOk;
|
||||
} else {
|
||||
clipLint.style.color = '#ff8a80';
|
||||
clipLint.className = 'template-lint warn';
|
||||
clipLint.textContent = `${UI_TEXT.static.templateLintWarn}: ${unknownTokens.join(' ')}`;
|
||||
}
|
||||
|
||||
byId('formatSimple').textContent = `${dateStr}_${partNum}.mp4 ${UI_TEXT.clips.formatSimple}`;
|
||||
byId('formatTimestamp').textContent = `${dateStr}_CLIP_${timeStr}_${partNum}.mp4 ${UI_TEXT.clips.formatTimestamp}`;
|
||||
byId('formatParts').textContent = `${dateStr}_Part${partNum.padStart(2, '0')}.mp4 ${UI_TEXT.clips.formatParts}`;
|
||||
byId('formatTemplate').textContent = `${buildTemplatePreview(template, {
|
||||
title: clipDialogData.title,
|
||||
date,
|
||||
@ -805,17 +1349,28 @@ async function confirmClipDialog(): Promise<void> {
|
||||
|
||||
const startSec = parseTimeToSeconds(byId<HTMLInputElement>('clipStartTime').value);
|
||||
const endSec = parseTimeToSeconds(byId<HTMLInputElement>('clipEndTime').value);
|
||||
const durationSec = endSec - startSec;
|
||||
const startPartStr = byId<HTMLInputElement>('clipStartPart').value.trim();
|
||||
const startPart = startPartStr ? parseInt(startPartStr, 10) : 1;
|
||||
const filenameFormat = getSelectedFilenameFormat();
|
||||
const filenameTemplate = byId<HTMLInputElement>('clipFilenameTemplate').value.trim();
|
||||
|
||||
if (endSec <= startSec) {
|
||||
if (isNaN(startSec) || isNaN(endSec) || isNaN(durationSec)) {
|
||||
alert(UI_TEXT.clips.invalidTime);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startSec < 0) {
|
||||
alert(UI_TEXT.clips.outOfRange);
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationSec <= 0) {
|
||||
alert(UI_TEXT.clips.endBeforeStart);
|
||||
return;
|
||||
}
|
||||
|
||||
if (startSec < 0 || endSec > clipTotalSeconds) {
|
||||
if (endSec > clipTotalSeconds) {
|
||||
alert(UI_TEXT.clips.outOfRange);
|
||||
return;
|
||||
}
|
||||
@ -833,7 +1388,6 @@ async function confirmClipDialog(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const durationSec = endSec - startSec;
|
||||
const customClip: CustomClip = {
|
||||
startSec,
|
||||
durationSec,
|
||||
@ -892,28 +1446,15 @@ async function downloadClip(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Backend now produces locale-aware error strings via tBackend(),
|
||||
// so we no longer need a renderer-side translation table here.
|
||||
const backendError = (result.error || '').trim();
|
||||
let localizedError = backendError;
|
||||
|
||||
if (backendError === 'Ungueltige Clip-URL') {
|
||||
localizedError = currentLanguage === 'en' ? 'Invalid clip URL' : backendError;
|
||||
} else if (backendError === 'Clip nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Clip not found' : backendError;
|
||||
} else if (backendError === 'Streamlink nicht gefunden') {
|
||||
localizedError = currentLanguage === 'en' ? 'Streamlink not found' : backendError;
|
||||
} else if (backendError.startsWith('Download fehlgeschlagen')) {
|
||||
localizedError = currentLanguage === 'en' ? backendError.replace('Download fehlgeschlagen', 'Download failed') : backendError;
|
||||
}
|
||||
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (localizedError || UI_TEXT.clips.unknownError);
|
||||
status.textContent = UI_TEXT.clips.errorPrefix + (backendError || UI_TEXT.clips.unknownError);
|
||||
status.className = 'clip-status error';
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const filePath = await window.api.selectVideoFile();
|
||||
if (!filePath) {
|
||||
return;
|
||||
}
|
||||
async function loadCutterFromPath(filePath: string): Promise<void> {
|
||||
if (!filePath) return;
|
||||
|
||||
cutterFile = filePath;
|
||||
byId<HTMLInputElement>('cutterFilePath').value = filePath;
|
||||
@ -928,8 +1469,8 @@ async function selectCutterVideo(): Promise<void> {
|
||||
cutterStartTime = 0;
|
||||
cutterEndTime = info.duration;
|
||||
|
||||
byId('cutterInfo').style.display = 'flex';
|
||||
byId('timelineContainer').style.display = 'block';
|
||||
byId('cutterInfo').classList.add('shown');
|
||||
byId('timelineContainer').classList.add('shown');
|
||||
byId('btnCut').disabled = false;
|
||||
|
||||
byId('infoDuration').textContent = formatTime(info.duration);
|
||||
@ -944,6 +1485,12 @@ async function selectCutterVideo(): Promise<void> {
|
||||
await updatePreview(0);
|
||||
}
|
||||
|
||||
async function selectCutterVideo(): Promise<void> {
|
||||
const filePath = await window.api.selectVideoFile();
|
||||
if (!filePath) return;
|
||||
await loadCutterFromPath(filePath);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
@ -1010,15 +1557,15 @@ async function updatePreview(time: number): Promise<void> {
|
||||
}
|
||||
|
||||
const preview = byId('cutterPreview');
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewLoading}</p></div>`;
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewLoading)}</p></div>`);
|
||||
|
||||
const frame = await window.api.extractFrame(cutterFile, time);
|
||||
if (frame) {
|
||||
preview.innerHTML = `<img src="${frame}" alt="Preview">`;
|
||||
applyHtml(preview, `<img src="${escapeHtml(frame)}" alt="${escapeHtml(UI_TEXT.cutter.previewAlt)}">`);
|
||||
return;
|
||||
}
|
||||
|
||||
preview.innerHTML = `<div class="placeholder"><p>${UI_TEXT.cutter.previewUnavailable}</p></div>`;
|
||||
applyHtml(preview, `<div class="placeholder"><p>${escapeHtml(UI_TEXT.cutter.previewUnavailable)}</p></div>`);
|
||||
}
|
||||
|
||||
async function startCutting(): Promise<void> {
|
||||
@ -1061,12 +1608,23 @@ function renderMergeFiles(): void {
|
||||
byId('btnMerge').disabled = mergeFiles.length < 2;
|
||||
|
||||
if (mergeFiles.length === 0) {
|
||||
list.innerHTML = `
|
||||
<div class="empty-state" style="padding: 40px 20px;">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="currentColor" style="opacity:0.3"><path d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>
|
||||
<p style="margin-top:10px">${UI_TEXT.merge.empty}</p>
|
||||
</div>
|
||||
`;
|
||||
// Build via DOM API to keep the renderer clean of inline-styled
|
||||
// HTML strings. The empty-state SVG is the same plus-icon the
|
||||
// static HTML uses, just built programmatically.
|
||||
list.replaceChildren();
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'empty-state merge-empty-state';
|
||||
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.setAttribute('viewBox', '0 0 24 24');
|
||||
svg.setAttribute('fill', 'currentColor');
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z');
|
||||
svg.appendChild(path);
|
||||
wrap.appendChild(svg);
|
||||
const p = document.createElement('p');
|
||||
p.textContent = UI_TEXT.merge.empty;
|
||||
wrap.appendChild(p);
|
||||
list.appendChild(wrap);
|
||||
return;
|
||||
}
|
||||
|
||||
@ -1077,9 +1635,9 @@ function renderMergeFiles(): void {
|
||||
<div class="file-order">${index + 1}</div>
|
||||
<div class="file-name" title="${file}">${name}</div>
|
||||
<div class="file-actions">
|
||||
<button class="file-btn" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||
<button class="file-btn" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button class="file-btn remove" onclick="removeMergeFile(${index})">x</button>
|
||||
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveUpAria)}" title="${escapeHtml(UI_TEXT.merge.moveUpAria)}" onclick="moveMergeFile(${index}, -1)" ${index === 0 ? 'disabled' : ''}>▲</button>
|
||||
<button type="button" class="file-btn" aria-label="${escapeHtml(UI_TEXT.merge.moveDownAria)}" title="${escapeHtml(UI_TEXT.merge.moveDownAria)}" onclick="moveMergeFile(${index}, 1)" ${index === mergeFiles.length - 1 ? 'disabled' : ''}>▼</button>
|
||||
<button type="button" class="file-btn remove" aria-label="${escapeHtml(UI_TEXT.merge.removeAria)}" title="${escapeHtml(UI_TEXT.merge.removeAria)}" onclick="removeMergeFile(${index})">x</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
2983
src/styles.css
2983
src/styles.css
File diff suppressed because it is too large
Load Diff
20
src/types.ts
20
src/types.ts
@ -2,7 +2,7 @@ export interface CustomClip {
|
||||
startSec: number;
|
||||
durationSec: number;
|
||||
startPart: number;
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template';
|
||||
filenameFormat: 'simple' | 'timestamp' | 'template' | 'parts';
|
||||
filenameTemplate?: string;
|
||||
}
|
||||
|
||||
@ -42,6 +42,22 @@ export interface QueueItem {
|
||||
last_error?: string;
|
||||
customClip?: CustomClip;
|
||||
mergeGroup?: MergeGroup;
|
||||
// File paths produced by the download (single file for VOD/clip, multiple
|
||||
// for parts/merge-group splits). Persisted with the queue so completed
|
||||
// items keep their "Open file" / "Show in folder" actions across restarts.
|
||||
outputFiles?: string[];
|
||||
// Live stream recording — when true, item.url is the channel URL
|
||||
// (https://twitch.tv/{streamer}) and streamlink runs until the stream
|
||||
// ends instead of using --hls-start-offset / --hls-duration. The output
|
||||
// filename includes a timestamp so consecutive live recordings of the
|
||||
// same streamer don't collide.
|
||||
isLive?: boolean;
|
||||
// Live recording health snapshot. 'ok' means bytes are flowing within
|
||||
// the freshness window, 'stale' means the streamlink subprocess hasn't
|
||||
// pushed bytes recently (dropped segments, network blip, or stream just
|
||||
// ended), 'unknown' until the first progress event arrives. Only set
|
||||
// for in-flight live recordings; cleared when the recording finishes.
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadProgress {
|
||||
@ -55,9 +71,11 @@ export interface DownloadProgress {
|
||||
totalParts?: number;
|
||||
downloadedBytes?: number;
|
||||
totalBytes?: number;
|
||||
recordingHealth?: 'ok' | 'stale' | 'unknown';
|
||||
}
|
||||
|
||||
export interface DownloadResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
outputFiles?: string[];
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user